Compare commits

..

2 Commits

Author SHA1 Message Date
nicolas.dorier
b1b3b5a5b2
bump clightning 2022-12-14 10:57:19 +09:00
nicolas.dorier
35aeda799c
Introduce CreateInvoiceParams.DescriptionHashOnly 2022-12-07 18:40:45 +09:00
113 changed files with 3196 additions and 3364 deletions

View File

@ -3,14 +3,14 @@ jobs:
build:
machine:
enabled: true
image: default
image: ubuntu-2004:202201-02
steps:
- checkout
test:
machine:
enabled: true
image: default
image: ubuntu-2004:202201-02
steps:
- checkout
- run:

View File

@ -14,21 +14,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.CLig
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.Tests", "tests\BTCPayServer.Lightning.Tests.csproj", "{957F3D96-7982-4D27-84B9-97F75CA44B1D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.Charge", "src\BTCPayServer.Lightning.Charge\BTCPayServer.Lightning.Charge.csproj", "{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.Common", "src\BTCPayServer.Lightning.Common\BTCPayServer.Lightning.Common.csproj", "{CA4021BC-41F4-44C6-B249-F2DC05429E44}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.All", "src\BTCPayServer.Lightning.All\BTCPayServer.Lightning.All.csproj", "{691B1F98-4CC3-47FF-B3F3-B97FC0AB4C94}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.Eclair", "src\BTCPayServer.Lightning.Eclair\BTCPayServer.Lightning.Eclair.csproj", "{542D3F73-7067-4873-89EF-FA0345E32C04}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.Phoenixd", "src\BTCPayServer.Lightning.Phoenixd\BTCPayServer.Lightning.Phoenixd.csproj", "{477D7912-04E7-473F-A0D5-8CE415082927}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.LNbank", "src\BTCPayServer.Lightning.LNbank\BTCPayServer.Lightning.LNbank.csproj", "{4057015B-9D8A-411A-B7C2-3342D9F53BD0}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.LNDhub", "src\BTCPayServer.Lightning.LNDhub\BTCPayServer.Lightning.LNDhub.csproj", "{B024DBD2-FCF4-4C48-9EBE-09AA7AAF36FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "misc", "misc", "{216059DB-7E3A-4CAF-A273-AB43BAAFDB28}"
ProjectSection(SolutionItems) = preProject
src\Build\Common.csproj = src\Build\Common.csproj
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -75,6 +72,18 @@ Global
{957F3D96-7982-4D27-84B9-97F75CA44B1D}.Release|x64.Build.0 = Release|Any CPU
{957F3D96-7982-4D27-84B9-97F75CA44B1D}.Release|x86.ActiveCfg = Release|Any CPU
{957F3D96-7982-4D27-84B9-97F75CA44B1D}.Release|x86.Build.0 = Release|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Debug|x64.ActiveCfg = Debug|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Debug|x64.Build.0 = Debug|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Debug|x86.ActiveCfg = Debug|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Debug|x86.Build.0 = Debug|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Release|Any CPU.Build.0 = Release|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Release|x64.ActiveCfg = Release|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Release|x64.Build.0 = Release|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Release|x86.ActiveCfg = Release|Any CPU
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F}.Release|x86.Build.0 = Release|Any CPU
{CA4021BC-41F4-44C6-B249-F2DC05429E44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA4021BC-41F4-44C6-B249-F2DC05429E44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA4021BC-41F4-44C6-B249-F2DC05429E44}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -111,20 +120,18 @@ Global
{542D3F73-7067-4873-89EF-FA0345E32C04}.Release|x64.Build.0 = Release|Any CPU
{542D3F73-7067-4873-89EF-FA0345E32C04}.Release|x86.ActiveCfg = Release|Any CPU
{542D3F73-7067-4873-89EF-FA0345E32C04}.Release|x86.Build.0 = Release|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Debug|Any CPU.Build.0 = Debug|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Debug|x64.ActiveCfg = Debug|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Debug|x64.Build.0 = Debug|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Debug|x86.ActiveCfg = Debug|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Debug|x86.Build.0 = Debug|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Release|Any CPU.ActiveCfg = Release|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Release|Any CPU.Build.0 = Release|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Release|x64.ActiveCfg = Release|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Release|x64.Build.0 = Release|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Release|x86.ActiveCfg = Release|Any CPU
{477D7912-04E7-473F-A0D5-8CE415082927}.Release|x86.Build.0 = Release|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Debug|x64.ActiveCfg = Debug|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Debug|x64.Build.0 = Debug|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Debug|x86.ActiveCfg = Debug|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Debug|x86.Build.0 = Debug|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Release|Any CPU.Build.0 = Release|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Release|x64.ActiveCfg = Release|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Release|x64.Build.0 = Release|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Release|x86.ActiveCfg = Release|Any CPU
{4057015B-9D8A-411A-B7C2-3342D9F53BD0}.Release|x86.Build.0 = Release|Any CPU
{B024DBD2-FCF4-4C48-9EBE-09AA7AAF36FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B024DBD2-FCF4-4C48-9EBE-09AA7AAF36FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B024DBD2-FCF4-4C48-9EBE-09AA7AAF36FB}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -144,10 +151,11 @@ Global
GlobalSection(NestedProjects) = preSolution
{B6390570-4997-477E-8E53-92D514ED816E} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{BB6EF7D6-3631-4760-9690-B87280E16FE1} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{1DC00571-AD1C-4B28-A918-ED34DDCAFC4F} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{CA4021BC-41F4-44C6-B249-F2DC05429E44} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{691B1F98-4CC3-47FF-B3F3-B97FC0AB4C94} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{542D3F73-7067-4873-89EF-FA0345E32C04} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{477D7912-04E7-473F-A0D5-8CE415082927} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{4057015B-9D8A-411A-B7C2-3342D9F53BD0} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
{B024DBD2-FCF4-4C48-9EBE-09AA7AAF36FB} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution

View File

@ -20,7 +20,9 @@ Here is a description of all packages:
* `BTCPayServer.Lightning.Common` exposes common classes and `ILightningClient` [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.Common.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.Common)
* `BTCPayServer.Lightning.LND` exposes easy to use LND clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.LND.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.LND)
* `BTCPayServer.Lightning.CLightning` exposes easy to use clightning clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.CLightning.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.CLightning)
* `BTCPayServer.Lightning.Charge` exposes easy to use Charge clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.Charge.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.Charge)
* `BTCPayServer.Lightning.Eclair` exposes easy to use Eclair clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.Eclair.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.Eclair)
* `BTCPayServer.Lightning.LNbank` exposes easy to use LNbank clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.LNbank.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.LNbank)
* `BTCPayServer.Lightning.LNDhub` exposes easy to use LNDhub clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.LNDhub.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.LNDhub)
If you develop an app, we advise you to reference `BTCPayServer.Lightning.All` [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.All.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.All).
@ -40,7 +42,7 @@ dotnet add package BTCPayServer.Lightning.All
You have two ways to use this library:
* Either you want your code to works with all lightning implementation (right now LND, CLightning, Eclair, LNDHub, Phoenixd)
* Either you want your code to works with all lightning implementation (right now LND, Charge, CLightning)
* Or you want your code to work on a particular lightning implementation
### Using the generic interface
@ -59,20 +61,26 @@ LightningInvoice invoice = await client.CreateInvoice(10000, "CanCreateInvoice",
The `connectionString` encapsulates the necessary information BTCPay needs to connect to your Lightning node, we currently support:
* `clightning` via TCP or unix domain socket connection
* `lightning charge` via HTTPS
* `LND` via the REST proxy
* `Eclair` via their new REST API
* `LNbank` via REST API
* `LNDhub` via their REST API
#### Examples
* `type=clightning;server=unix://root/.lightning/lightning-rpc`
* `type=clightning;server=tcp://1.1.1.1:27743/`
* `type=lnd-rest;server=http://mylnd:8080/;macaroonfilepath=/root/.lnd/invoice.macaroon;allowinsecure=true`
* `type=lnd-rest;server=http://mylnd:8080/;macaroonfilepath=/root/.lnd/admin.macaroon;allowinsecure=true`
* `type=lnd-rest;server=https://mylnd:8080/;macaroon=abef263adfe...`
* `type=lnd-rest;server=https://mylnd:8080/;macaroon=abef263adfe...;certthumbprint=abef263adfe...`
* `type=lnd-rest;server=https://mylnd:8080/;macaroonfilepath=/root/.lnd/invoice.macaroon;certfilepath=/var/lib/lnd/tls.cert`
* `type=lnd-rest;server=https://mylnd:8080/;macaroonfilepath=/root/.lnd/admin.macaroon;certfilepath=/var/lib/lnd/tls.cert`
* `type=charge;server=https://charge:8080/;api-token=myapitoken...`
* `type=charge;server=https://charge:8080/;cookiefilepath=/path/to/cookie...`
* `type=eclair;server=http://127.0.0.1:4570;password=eclairpass`
* `type=eclair;server=http://127.0.0.1:4570;password=eclairpass;bitcoin-host=bitcoin.host;bitcoin-auth=btcpass`
* `type=lnbank;server=http://lnbank:5000;api-token=myapitoken;allowinsecure=true`
* `type=lnbank;server=https://mybtcpay.com/lnbank;api-token=myapitoken`
* `type=lndhub;server=https://login:password@lndhub.io`
##### Eclair notes
@ -105,7 +113,7 @@ The library turns it into the expected `type=lndhub` connection string format.
### Using implementation specific class
If you want to leverage specific lightning network implementation, either instanciate directly `LndClient`, `CLightningClient`, or `EclairLightningClient`, or cast the `ILightningClient` object returned by `LightningClientFactory`.
If you want to leverage specific lightning network implementation, either instanciate directly `ChargeClient`, `LndClient` or `CLightningClient`, or cast the `ILightningClient` object returned by `LightningClientFactory`.
## How to test

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<RootNamespace>BTCPayServer.Lightning</RootNamespace>
<Version>1.7.3</Version>
<Version>1.4.12</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.All</PackageId>
<Description>Client library for lightning network implementations to build Lightning Network Apps in C#.</Description>
@ -14,10 +14,14 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Lightning.Charge\BTCPayServer.Lightning.Charge.csproj" />
<ProjectReference Include="..\BTCPayServer.Lightning.CLightning\BTCPayServer.Lightning.CLightning.csproj" />
<ProjectReference Include="..\BTCPayServer.Lightning.Eclair\BTCPayServer.Lightning.Eclair.csproj" />
<ProjectReference Include="..\BTCPayServer.Lightning.Phoenixd\BTCPayServer.Lightning.Phoenixd.csproj" />
<ProjectReference Include="..\BTCPayServer.Lightning.LNbank\BTCPayServer.Lightning.LNbank.csproj" />
<ProjectReference Include="..\BTCPayServer.Lightning.LNDhub\BTCPayServer.Lightning.LNDhub.csproj" />
<ProjectReference Include="..\BTCPayServer.Lightning.LND\BTCPayServer.Lightning.LND.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.10"></PackageReference>
</ItemGroup>
</Project>

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -9,7 +8,6 @@ using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Logging;
using NBitcoin.RPC;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.Tests
{
@ -52,16 +50,9 @@ namespace BTCPayServer.Lightning.Tests
private static async Task CreateChannel(RPCClient cashCow, ILightningClient sender, ILightningClient dest)
{
// Use arbitrary amount to check if channel exists and also push some funds over to the other side
var channelCapacity = Money.Satoshis(16777215);
var channelFunding = LightMoney.FromUnit(channelCapacity.ToDecimal(MoneyUnit.Satoshi) * 0.1m, LightMoneyUnit.Satoshi);
await WaitLNSynched(cashCow, sender);
await WaitLNSynched(cashCow, dest);
// use arbitrary amount to check if channel exists and also push some funds over to the other side
var amount = new LightMoney(123456789);
var destInfo = await dest.GetInfo();
var amount = LightMoney.FromUnit(10m, LightMoneyUnit.Satoshi);
var destInvoice = await dest.CreateInvoice(amount, "EnsureConnectedToDestination", TimeSpan.FromSeconds(5000));
var payErrors = 0;
@ -73,42 +64,27 @@ namespace BTCPayServer.Lightning.Tests
{
break;
}
if (result.Result == PayResult.CouldNotFindRoute || result.Result == PayResult.Error || result.Result == PayResult.Unknown && result.ErrorDetail?.StartsWith("not enough balance") is true)
if (result.Result == PayResult.CouldNotFindRoute || result.Result == PayResult.Error && result.ErrorDetail.StartsWith("not enough balance"))
{
// check channels that are in process of opening, to prevent double channel open
await Task.Delay(100);
var pendingChannels = await sender.ListChannels();
var channel = pendingChannels.FirstOrDefault(a => a.RemoteNode == destInfo.NodeInfoList[0].NodeId);
var channelDropped = false;
if (channel != null)
if (pendingChannels.Any(a => a.RemoteNode == destInfo.NodeInfoList[0].NodeId))
{
if (channel.IsActive)
{
Logs.LogInformation($"Channel to {destInfo.NodeInfoList[0]} is already open(ing)");
Logs.LogInformation($"Attempting to reconnect Result: {await sender.ConnectTo(destInfo.NodeInfoList.First())}");
await cashCow.GenerateAsync(1);
await WaitLNSynched(cashCow, sender);
await WaitLNSynched(cashCow, dest);
continue;
}
else
{
channelDropped = true;
Logs.LogInformation($"Channel dropped");
await cashCow.GenerateAsync(1);
}
Logs.LogInformation($"Channel to {destInfo.NodeInfoList[0]} is already open(ing)");
Logs.LogInformation($"Attempting to reconnect Result: {await sender.ConnectTo(destInfo.NodeInfoList.First())}");
await cashCow.GenerateAsync(1);
await WaitLNSynched(cashCow, sender);
await WaitLNSynched(cashCow, dest);
continue;
}
if (!channelDropped)
{
var connectedResult = await sender.ConnectTo(destInfo.NodeInfoList.First());
Logs.LogInformation($"Connection result: " + connectedResult);
Logs.LogInformation($"Opening channel to {destInfo.NodeInfoList[0]}");
}
Logs.LogInformation($"Opening channel to {destInfo.NodeInfoList[0]}");
var openChannel = await sender.OpenChannel(new OpenChannelRequest()
{
NodeInfo = destInfo.NodeInfoList[0],
ChannelAmount = channelCapacity,
ChannelAmount = Money.Satoshis(16777215),
FeeRate = new FeeRate(1UL, 1)
});
Logs.LogInformation($"Channel opening result: {openChannel.Result}");
@ -150,26 +126,6 @@ namespace BTCPayServer.Lightning.Tests
await WaitLNSynched(cashCow, dest);
await Task.Delay(500);
}
if (openChannel.Result is OpenChannelResult.Ok or OpenChannelResult.NeedMoreConf)
{
// Push 10% of the channel funding to the other side
var fundInvoice = await dest.CreateInvoice(channelFunding, "Funding", TimeSpan.FromSeconds(5000));
int retry = 0;
retry:
var r = await Pay(sender, fundInvoice.BOLT11);
if (r.Result == PayResult.CouldNotFindRoute && retry < 10)
{
retry++;
await Task.Delay(100 * retry);
goto retry;
}
if (r.Result != PayResult.Ok)
{
var str = $"Failed to push funds to the other side: {r.Result} {r.ErrorDetail}";
Logs.LogInformation(str);
throw new Exception(str);
}
}
}
else
{
@ -188,7 +144,7 @@ retry:
retry:
try
{
return await sender.Pay(payreq, new PayInvoiceParams() { SendTimeout = TimeSpan.FromSeconds(10.0) }, cts.Token);
return await sender.Pay(payreq, cts.Token);
}
catch (CLightning.LightningRPCException ex) when (ex.Message.Contains("WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS") &&
!cts.IsCancellationRequested)

View File

@ -1,86 +1,91 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayServer.Lightning.Charge;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Lightning.Eclair;
using BTCPayServer.Lightning.Phoenixd;
using BTCPayServer.Lightning.LNbank;
using BTCPayServer.Lightning.LND;
using BTCPayServer.Lightning.LNDhub;
using BTCPayServer.Lightning.LndHub;
using NBitcoin;
using NBitcoin.RPC;
namespace BTCPayServer.Lightning;
public class LightningClientFactory : ILightningClientFactory
namespace BTCPayServer.Lightning
{
public static readonly IReadOnlyList<ILightningConnectionStringHandler> DefaultHandlers =
new ILightningConnectionStringHandler[]
public class LightningClientFactory : ILightningClientFactory
{
public static ILightningClient CreateClient(LightningConnectionString connectionString, Network network)
{
new CLightningConnectionStringHandler(),
new EclairConnectionStringHandler(), new PhoenixdConnectionStringHandler(),
new LndConnectionStringHandler(),
new LndHubConnectionStringHandler()
};
private readonly Network _network;
private readonly ILightningConnectionStringHandler[] _connectionStringHandlers;
public LightningClientFactory(
Network network) : this(DefaultHandlers, network)
{
}
public LightningClientFactory(IEnumerable<ILightningConnectionStringHandler> connectionStringHandlers,
Network network)
{
_network = network;
_connectionStringHandlers = connectionStringHandlers.ToArray();
}
public ILightningClient Create(string connectionString)
{
if (connectionString == null)
throw new ArgumentNullException(nameof(connectionString));
FormatException lastError = null;
foreach (var handler in _connectionStringHandlers)
{
try
{
var client = handler.Create(connectionString, _network, out var error);
if (client != null)
{
return client;
}
if (error is not null)
{
throw new FormatException(error);
}
}
catch (FormatException e)
{
lastError = e;
}
return new LightningClientFactory(network).Create(connectionString);
}
if(lastError is not null)
throw lastError;
throw new NotSupportedException(
$"Unsupported connection string");
}
public bool TryCreate(string connectionString, out ILightningClient client, out string error)
{
try
public static ILightningClient CreateClient(string connectionString, Network network)
{
client= Create(connectionString);
error = null;
return true;
if (!LightningConnectionString.TryParse(connectionString, false, out var conn, out string error))
throw new FormatException($"Invalid format ({error})");
return CreateClient(conn, network);
}
catch (Exception e)
public LightningClientFactory(Network network)
{
client = null;
error = e.Message;
return false;
Network = network ?? throw new ArgumentNullException(nameof(network));
}
public Network Network { get; }
public HttpClient HttpClient { get; set; }
public ILightningClient Create(string connectionString) => CreateClient(connectionString, Network);
public ILightningClient Create(LightningConnectionString connectionString)
{
if (connectionString == null)
throw new ArgumentNullException(nameof(connectionString));
if (connectionString.ConnectionType == LightningConnectionType.Charge)
{
if (connectionString.CookieFilePath != null)
{
return new ChargeClient(connectionString.BaseUri, connectionString.CookieFilePath, Network,
HttpClient, connectionString.AllowInsecure);
}
return new ChargeClient(connectionString.ToUri(true), Network, HttpClient, connectionString.AllowInsecure);
}
if (connectionString.ConnectionType == LightningConnectionType.CLightning)
{
return new CLightningClient(connectionString.ToUri(false), Network);
}
if (connectionString.ConnectionType == LightningConnectionType.LndREST)
{
return new LndClient(new LndSwaggerClient(new LndRestSettings(connectionString.BaseUri)
{
Macaroon = connectionString.Macaroon,
MacaroonFilePath = connectionString.MacaroonFilePath,
CertificateThumbprint = connectionString.CertificateThumbprint,
CertificateFilePath = connectionString.CertificateFilePath,
AllowInsecure = connectionString.AllowInsecure,
}, HttpClient), Network);
}
if (connectionString.ConnectionType == LightningConnectionType.Eclair)
{
return new EclairLightningClient(connectionString.BaseUri, connectionString.Username, connectionString.Password, Network, HttpClient);
}
if (connectionString.ConnectionType == LightningConnectionType.LNbank)
{
return new LNbankLightningClient(connectionString.BaseUri, connectionString.ApiToken, Network, HttpClient);
}
if (connectionString.ConnectionType == LightningConnectionType.LNDhub)
{
return new LndHubLightningClient(connectionString.BaseUri, connectionString.Username, connectionString.Password, Network, HttpClient);
}
throw new NotSupportedException(
$"Unsupported connection string for lightning server ({connectionString.ConnectionType})");
}
}
}

View File

@ -0,0 +1,700 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Lightning
{
public enum LightningConnectionType
{
Charge,
[Display(Name = "c-lightning")]
CLightning,
[Display(Name = "LND (REST)")]
LndREST,
[Display(Name = "LND (gRPC)")]
LndGRPC,
Eclair,
LNbank,
LNDhub
}
public class LightningConnectionString
{
static Dictionary<string, LightningConnectionType> typeMapping;
static Dictionary<LightningConnectionType, string> typeMappingReverse;
static LightningConnectionString()
{
typeMapping = new Dictionary<string, LightningConnectionType>();
typeMapping.Add("clightning", LightningConnectionType.CLightning);
typeMapping.Add("charge", LightningConnectionType.Charge);
typeMapping.Add("lnd-rest", LightningConnectionType.LndREST);
typeMapping.Add("lnd-grpc", LightningConnectionType.LndGRPC);
typeMapping.Add("eclair", LightningConnectionType.Eclair);
typeMapping.Add("lnbank", LightningConnectionType.LNbank);
typeMapping.Add("lndhub", LightningConnectionType.LNDhub);
typeMappingReverse = new Dictionary<LightningConnectionType, string>();
foreach (var kv in typeMapping)
{
typeMappingReverse.Add(kv.Value, kv.Key);
}
}
public static bool TryParse(string str, out LightningConnectionString connectionString)
{
return TryParse(str, false, out connectionString);
}
public static bool TryParse(string str, bool supportLegacy, out LightningConnectionString connectionString)
{
return TryParse(str, supportLegacy, out connectionString, out _);
}
public static bool TryParse(string str, bool supportLegacy, out LightningConnectionString connectionString, out string error)
{
if (str == null)
throw new ArgumentNullException(nameof(str));
if (supportLegacy)
{
var parsed = TryParseLegacy(str, out connectionString, out error);
if (!parsed)
{
parsed = TryParseNewFormat(str, out connectionString, out error);
}
return parsed;
}
if (str.StartsWith("lndhub://"))
{
return TryParseLNDhub(str, out connectionString, out error);
}
return TryParseNewFormat(str, out connectionString, out error);
}
private static bool TryParseNewFormat(string str, out LightningConnectionString connectionString, out string error)
{
connectionString = null;
error = null;
var parts = str.Split(new [] { ';' }, StringSplitOptions.RemoveEmptyEntries);
Dictionary<string, string> keyValues = new Dictionary<string, string>();
foreach (var part in parts.Select(p => p.Trim()))
{
var idx = part.IndexOf('=');
if (idx == -1)
{
error = "The format of the connectionString should a list of key=value delimited by semicolon";
return false;
}
var key = part.Substring(0, idx).Trim().ToLowerInvariant();
var value = part.Substring(idx + 1).Trim();
if (keyValues.ContainsKey(key))
{
error = $"Duplicate key {key}";
return false;
}
keyValues.Add(key, value);
}
var possibleTypes = String.Join(", ", typeMapping.Select(k => k.Key).ToArray());
LightningConnectionString result = new LightningConnectionString();
var type = Take(keyValues, "type");
if (type == null)
{
error = $"The key 'type' is mandatory, possible values are {possibleTypes}";
return false;
}
if (!typeMapping.TryGetValue(type.ToLowerInvariant(), out var connectionType))
{
error = $"The key 'type' is invalid, possible values are {possibleTypes}";
return false;
}
result.ConnectionType = connectionType;
switch (connectionType)
{
case LightningConnectionType.Charge:
{
var server = Take(keyValues, "server");
if (server == null)
{
error = $"The key 'server' is mandatory for charge connection strings";
return false;
}
var allowinsecureStr = Take(keyValues, "allowinsecure");
if (allowinsecureStr != null)
{
var allowedValues = new[] { "true", "false" };
if (!allowedValues.Any(v => v.Equals(allowinsecureStr, StringComparison.OrdinalIgnoreCase)))
{
error = $"The key 'allowinsecure' should be true or false";
return false;
}
bool allowInsecure = allowinsecureStr.Equals("true", StringComparison.OrdinalIgnoreCase);
result.AllowInsecure = allowInsecure;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri) || (uri.Scheme != "http" && uri.Scheme != "https"))
{
error = $"The key 'server' should be an URI starting by http:// or https://";
return false;
}
if (!result.AllowInsecure && uri.Scheme == "http")
{
error = $"The key 'allowinsecure' is false, but server's Uri is not using https";
return false;
}
parts = uri.UserInfo.Split(':');
if (!string.IsNullOrEmpty(uri.UserInfo) && parts.Length == 2)
{
result.Username = parts[0];
result.Password = parts[1];
var cookieFilePath = Take(keyValues, "cookiefilepath");
if (cookieFilePath != null)
{
error = "The key 'cookiefilepath' should not be used if you are passing credentials inside the url";
return false;
}
}
else
{
var apiToken = Take(keyValues, "api-token");
var cookieFilePath = Take(keyValues, "cookiefilepath");
if (apiToken != null && cookieFilePath != null)
{
error = "Keys 'api-token' and 'cookiefilepath' are mutually exclusive";
return false;
}
if (apiToken != null)
{
result.Username = "api-token";
result.Password = apiToken;
}
else if (cookieFilePath != null)
{
result.Username = "api-token";
result.CookieFilePath = cookieFilePath;
}
else
{
error = "The key 'api-token' or 'cookiefilepath' is not found";
return false;
}
}
result.BaseUri = new UriBuilder(uri) { UserName = "", Password = "" }.Uri;
}
break;
case LightningConnectionType.CLightning:
{
var server = Take(keyValues, "server");
if (server == null)
{
error = $"The key 'server' is mandatory for charge connection strings";
return false;
}
if (server.StartsWith("//", StringComparison.OrdinalIgnoreCase))
server = "unix:" + str;
else if (server.StartsWith("/", StringComparison.OrdinalIgnoreCase))
server = "unix:/" + str;
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri)
|| (uri.Scheme != "tcp" && uri.Scheme != "unix"))
{
error = $"The key 'server' should be an URI starting by tcp:// or unix:// or a path to the 'lightning-rpc' unix socket";
return false;
}
result.BaseUri = uri;
}
break;
case LightningConnectionType.LndREST:
case LightningConnectionType.LndGRPC:
{
var server = Take(keyValues, "server");
if (server == null)
{
error = $"The key 'server' is mandatory for lnd connection strings";
return false;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https"))
{
error = $"The key 'server' should be an URI starting by http:// or https://";
return false;
}
parts = uri.UserInfo.Split(':');
if (!string.IsNullOrEmpty(uri.UserInfo) && parts.Length == 2)
{
result.Username = parts[0];
result.Password = parts[1];
}
result.BaseUri = new UriBuilder(uri) { UserName = "", Password = "" }.Uri;
var macaroon = Take(keyValues, "macaroon");
if (macaroon != null)
{
try
{
result.Macaroon = Encoder.DecodeData(macaroon);
}
catch
{
error = $"The key 'macaroon' format should be in hex";
return false;
}
}
var macaroonFilePath = Take(keyValues, "macaroonfilepath");
if (macaroonFilePath != null)
{
if (macaroon != null)
{
error = $"The key 'macaroon' is already specified";
return false;
}
if (!macaroonFilePath.EndsWith(".macaroon", StringComparison.OrdinalIgnoreCase))
{
error = $"The key 'macaroonfilepath' should point to a .macaroon file";
return false;
}
result.MacaroonFilePath = macaroonFilePath;
}
// Those two are deprecated fields, but we don't want to break users
Take(keyValues, "restrictedmacaroon");
Take(keyValues, "restrictedmacaroonfilepath");
result.MacaroonDirectoryPath = Take(keyValues, "macaroondirectorypath");
string securitySet = null;
var certthumbprint = Take(keyValues, "certthumbprint");
if (certthumbprint != null)
{
try
{
var bytes = Encoders.Hex.DecodeData(certthumbprint.Replace(":", string.Empty));
if (bytes.Length != 32)
{
error = $"The key 'certthumbprint' has invalid length: it should be the SHA256 of the PEM format of the certificate (32 bytes)";
return false;
}
result.CertificateThumbprint = bytes;
}
catch
{
error = $"The key 'certthumbprint' has invalid format: it should be the SHA256 of the PEM format of the certificate";
return false;
}
securitySet = "certthumbprint";
}
var certificateFilePath = Take(keyValues, "certfilepath");
if (certificateFilePath != null)
{
if (securitySet != null) {
error = $"The key 'certfilepath' conflict with '{securitySet}'";
return false;
}
result.CertificateFilePath = certificateFilePath;
securitySet = "certfilepath";
}
var allowinsecureStr = Take(keyValues, "allowinsecure");
if (allowinsecureStr != null)
{
var allowedValues = new[] { "true", "false" };
if (!allowedValues.Any(v => v.Equals(allowinsecureStr, StringComparison.OrdinalIgnoreCase)))
{
error = $"The key 'allowinsecure' should be true or false";
return false;
}
bool allowInsecure = allowinsecureStr.Equals("true", StringComparison.OrdinalIgnoreCase);
if (securitySet != null && allowInsecure)
{
error = $"The key 'allowinsecure' conflict with '{securitySet}'";
return false;
}
result.AllowInsecure = allowInsecure;
}
if (!result.AllowInsecure && result.BaseUri.Scheme == "http")
{
error = $"The key 'allowinsecure' is false, but server's Uri is not using https";
return false;
}
}
break;
case LightningConnectionType.Eclair:
var eclairserver = Take(keyValues, "server");
if (eclairserver == null)
{
error = $"The key 'server' is mandatory for lnd connection strings";
return false;
}
if (!Uri.TryCreate(eclairserver, UriKind.Absolute, out var eclairuri)
|| (eclairuri.Scheme != "http" && eclairuri.Scheme != "https"))
{
error = $"The key 'server' should be an URI starting by http:// or https://";
return false;
}
result.BaseUri = eclairuri;
result.Password = Take(keyValues, "password");
result.Username = Take(keyValues, "username");
result.BitcoinHost = Take(keyValues, "bitcoin-host");
if (result.BitcoinHost != null)
{
result.BitcoinAuth = Take(keyValues, "bitcoin-auth");
if (result.BitcoinAuth == null)
{
error =
$"The key 'bitcoin-auth' is mandatory for eclair connection strings when bitcoin-host is specified";
return false;
}
}
break;
case LightningConnectionType.LNbank:
{
var server = Take(keyValues, "server");
if (server == null)
{
error = "The key 'server' is mandatory for LNbank connection strings";
return false;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri)
|| uri.Scheme != "http" && uri.Scheme != "https")
{
error = "The key 'server' should be an URI starting by http:// or https://";
return false;
}
var allowinsecureStr = Take(keyValues, "allowinsecure");
if (allowinsecureStr != null)
{
var allowedValues = new[] { "true", "false" };
if (!allowedValues.Any(v => v.Equals(allowinsecureStr, StringComparison.OrdinalIgnoreCase)))
{
error = "The key 'allowinsecure' should be true or false";
return false;
}
result.AllowInsecure = allowinsecureStr.Equals("true", StringComparison.OrdinalIgnoreCase);
}
if (!result.AllowInsecure && uri.Scheme == "http")
{
error = "The key 'allowinsecure' is false, but server's Uri is not using https";
return false;
}
var apiToken = Take(keyValues, "api-token");
if (apiToken == null)
{
error = "The key 'api-token' is not found";
return false;
}
result.BaseUri = uri;
result.ApiToken = apiToken;
}
break;
case LightningConnectionType.LNDhub:
{
var server = Take(keyValues, "server");
if (server == null)
{
error = "The key 'server' is mandatory for LNDhub connection strings";
return false;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri)
|| uri.Scheme != "http" && uri.Scheme != "https")
{
error = "The key 'server' should be an URI starting by http:// or https://";
return false;
}
parts = uri.UserInfo.Split(':');
if (!string.IsNullOrEmpty(uri.UserInfo) && parts.Length == 2)
{
result.Username = parts[0];
result.Password = parts[1];
}
var allowinsecureStr = Take(keyValues, "allowinsecure");
if (allowinsecureStr != null)
{
var allowedValues = new[] { "true", "false" };
if (!allowedValues.Any(v => v.Equals(allowinsecureStr, StringComparison.OrdinalIgnoreCase)))
{
error = "The key 'allowinsecure' should be true or false";
return false;
}
result.AllowInsecure = allowinsecureStr.Equals("true", StringComparison.OrdinalIgnoreCase);
}
if (!result.AllowInsecure && uri.Scheme == "http" && !uri.Host.EndsWith(".onion"))
{
error = "The key 'allowinsecure' is false, but server's Uri is not using https";
return false;
}
result.BaseUri = uri;
}
break;
default:
throw new NotSupportedException(connectionType.ToString());
}
if (keyValues.Count != 0)
{
error = $"Unknown keys ({String.Join(", ", keyValues.Select(k => k.Key).ToArray())})";
return false;
}
connectionString = result;
return true;
}
public LightningConnectionString Clone()
{
LightningConnectionString.TryParse(this.ToString(), false, out var result);
return result;
}
private static string Take(Dictionary<string, string> keyValues, string key)
{
if (keyValues.TryGetValue(key, out var v))
keyValues.Remove(key);
return v;
}
private static bool TryParseLegacy(string str, out LightningConnectionString connectionString, out string error)
{
if (str.StartsWith("/"))
str = "unix:" + str;
var result = new LightningConnectionString();
connectionString = null;
error = null;
Uri uri;
if (!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);
result.ConnectionType = LightningConnectionType.CLightning;
}
if (uri.Scheme == "tcp")
result.ConnectionType = LightningConnectionType.CLightning;
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];
result.ConnectionType = LightningConnectionType.Charge;
if (uri.Scheme == "http")
result.AllowInsecure = true;
}
else if (!string.IsNullOrEmpty(uri.UserInfo))
{
error = "The url should not have user information";
return false;
}
result.BaseUri = new UriBuilder(uri) { UserName = "", Password = "" }.Uri;
result.IsLegacy = true;
connectionString = result;
return true;
}
private static bool TryParseLNDhub(string str, out LightningConnectionString connectionString, out string error)
{
var parts = str.Replace("lndhub://", "").Split('@');
if (parts.Length != 2 || !Uri.TryCreate(parts[1].Replace("://", $"://{parts[0]}@"), UriKind.Absolute, out var uri))
{
connectionString = null;
error = "Invalid LNDhub URI";
return false;
}
// transform into connection string format
return TryParseNewFormat($"type=lndhub;server={uri.AbsoluteUri}", out connectionString, out error);
}
public LightningConnectionString()
{
}
public string Username { get; set; }
public string Password { get; set; }
public Uri BaseUri { get; set; }
public bool IsLegacy { get; private set; }
public LightningConnectionType ConnectionType
{
get;
set;
}
public byte[] Macaroon { get; set; }
public string MacaroonFilePath { get; set; }
public string CertificateFilePath { get; set; }
public byte[] CertificateThumbprint { get; set; }
public bool AllowInsecure { get; set; }
public string CookieFilePath { get; set; }
public string MacaroonDirectoryPath { get; set; }
public string BitcoinHost { get; set; }
public string BitcoinAuth { get; set; }
public string ApiToken { get; set; }
public Uri ToUri(bool withCredentials)
{
if (withCredentials)
{
return new UriBuilder(BaseUri) { UserName = Username ?? "", Password = Password ?? "" }.Uri;
}
else
{
return BaseUri;
}
}
static NBitcoin.DataEncoders.DataEncoder Encoder = NBitcoin.DataEncoders.Encoders.Hex;
public override string ToString()
{
var type = typeMappingReverse[ConnectionType];
StringBuilder builder = new StringBuilder();
builder.Append($"type={type}");
switch (ConnectionType)
{
case LightningConnectionType.Charge:
if (Username == null || Username == "api-token")
{
builder.Append($";server={BaseUri}");
if (string.IsNullOrEmpty(Password))
{
builder.Append($";cookiefilepath={CookieFilePath}");
}
else
{
builder.Append($";api-token={Password}");
}
}
else
{
builder.Append($";server={ToUri(true)}");
}
if (AllowInsecure)
{
builder.Append($";allowinsecure=true");
}
break;
case LightningConnectionType.CLightning:
builder.Append($";server={BaseUri}");
break;
case LightningConnectionType.LndREST:
case LightningConnectionType.LndGRPC:
if (Username == null)
{
builder.Append($";server={BaseUri}");
}
else
{
builder.Append($";server={ToUri(true)}");
}
if (Macaroon != null)
{
builder.Append($";macaroon={Encoder.EncodeData(Macaroon)}");
}
if (MacaroonFilePath != null)
{
builder.Append($";macaroonfilepath={MacaroonFilePath}");
}
if (MacaroonDirectoryPath != null)
{
builder.Append($";macaroondirectorypath={MacaroonDirectoryPath}");
}
if (CertificateThumbprint != null)
{
builder.Append($";certthumbprint={Encoders.Hex.EncodeData(CertificateThumbprint)}");
}
if (AllowInsecure)
{
builder.Append($";allowinsecure=true");
}
break;
case LightningConnectionType.Eclair:
builder.Append($";server={BaseUri}");
if (Password != null)
{
builder.Append($";password={Password}");
}
if (BitcoinHost != null)
{
builder.Append($";bitcoin-host={BitcoinHost}");
}
if (BitcoinAuth != null)
{
builder.Append($";bitcoin-auth={BitcoinAuth}");
}
break;
case LightningConnectionType.LNbank:
builder.Append($";server={BaseUri};api-token={ApiToken}");
if (AllowInsecure)
{
builder.Append(";allowinsecure=true");
}
break;
case LightningConnectionType.LNDhub:
builder.Append($";server={BaseUri}");
if (AllowInsecure)
{
builder.Append(";allowinsecure=true");
}
break;
default:
throw new NotSupportedException(type);
}
return builder.ToString();
}
}
}

View File

@ -1,13 +0,0 @@
using System;
namespace BTCPayServer.Lightning;
[Obsolete]
public static class LightningConnectionType
{
public const string CLightning= "clightning";
public const string LndREST= "lnd-rest";
public const string LndGRPC = "lnd-grpc";
public const string Eclair = "eclair";
public const string LNDhub = "lndhub";
}

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "All/v$ver" -m "All/$ver"
git push --tags

View File

@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<Version>1.7.2</Version>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<Version>1.3.19</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.CLightning</PackageId>
<Description>Client library for c-lightning to build Lightning Network Apps in C#.</Description>

View File

@ -107,6 +107,16 @@ namespace BTCPayServer.Lightning.CLightning
return SendCommandAsync<ListFundsResponse>("listfunds", cancellation: cancellation);
}
public async Task<PeerInfo[]> ListPeersAsync(CancellationToken cancellation = default)
{
var peers = await SendCommandAsync<PeerInfo[]>("listpeers", isArray: true, cancellation: cancellation);
foreach (var peer in peers)
{
peer.Channels = peer.Channels ?? Array.Empty<ChannelInfo>();
}
return peers;
}
public Task FundChannelAsync(OpenChannelRequest openChannelRequest, CancellationToken cancellation)
{
OpenChannelRequest.AssertIsSane(openChannelRequest);
@ -164,12 +174,7 @@ namespace BTCPayServer.Lightning.CLightning
var error = result.Property("error");
if (error != null)
{
var errorCode = error.Value["code"].Value<int>();
var message = error.Value["message"].Value<string>();
// For some reason, they decided that they should stop sending and error code...
if (errorCode == 0 && message.EndsWith("is not reachable directly and all routehints were unusable.", StringComparison.OrdinalIgnoreCase))
errorCode = (int)CLightningErrorCode.ROUTE_NOT_FOUND;
throw new LightningRPCException(message, errorCode);
throw new LightningRPCException(error.Value["message"].Value<string>(), error.Value["code"].Value<int>());
}
if (noReturn)
return default;
@ -231,8 +236,8 @@ namespace BTCPayServer.Lightning.CLightning
public async Task<BitcoinAddress> NewAddressAsync(CancellationToken cancellation = default)
{
var obj = await SendCommandAsync<JObject>("newaddr", cancellation: cancellation);
var addr = obj.Properties().First().Value.Value<string>();
return BitcoinAddress.Create(addr, Network);
var addr = obj.ContainsKey("address") ? "address" : "bech32";
return BitcoinAddress.Create(obj.Property(addr).Value.Value<string>(), Network);
}
public async Task<CLightningChannel[]> ListChannelsAsync(ShortChannelId ShortChannelId = null, CancellationToken cancellation = default)
@ -244,10 +249,6 @@ namespace BTCPayServer.Lightning.CLightning
return resp;
}
public async Task<PeerChannel[]> ListPeerChannelsAsync(CancellationToken cancellation = default)
{
return await SendCommandAsync<PeerChannel[]>("listpeerchannels", null, false, true, cancellation);
}
async Task<LightningPayment> ILightningClient.GetPayment(string paymentHash, CancellationToken cancellation)
{
@ -257,23 +258,12 @@ namespace BTCPayServer.Lightning.CLightning
async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation)
{
var payments = await SendCommandAsync<CLightningPayment[]>("listpays", new[] { null, paymentHash }, false, true, cancellation);
return payments.Length == 0 ? null : ToLightningPayment(payments.Last());
return payments.Length == 0 ? null : ToLightningPayment(payments[0]);
}
async Task<LightningInvoice> ILightningClient.GetInvoice(string invoiceId, CancellationToken cancellation)
{
var invoices = await SendCommandAsync<CLightningInvoice[]>("listinvoices", new[] { invoiceId }, false, true, cancellation);
if (invoices.Length == 0 && invoiceId.Length == 64)
{
var paymentHash = new uint256(invoiceId);
return await GetInvoice(paymentHash, cancellation);
}
return invoices.Length == 0 ? null : ToLightningInvoice(invoices[0]);
}
public async Task<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation)
{
var invoices = await SendCommandAsync<CLightningInvoice[]>("listinvoices", new[] { null, null, paymentHash.ToString() }, false, true, cancellation);
return invoices.Length == 0 ? null : ToLightningInvoice(invoices[0]);
}
@ -299,7 +289,7 @@ namespace BTCPayServer.Lightning.CLightning
async Task<LightningPayment[]> ILightningClient.ListPayments(CancellationToken cancellation)
{
return await ListPayments(null, cancellation);
}
}
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation)
{
@ -317,14 +307,9 @@ namespace BTCPayServer.Lightning.CLightning
private async Task<PayResponse> PayAsync(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = default)
{
var isKeysend = bolt11 == null;
if (isKeysend)
{
if (payParams?.Destination is null)
throw new ArgumentNullException(nameof(payParams.Destination));
if (payParams?.Amount is null)
throw new ArgumentNullException(nameof(payParams.Amount));
}
if (bolt11 == null && payParams.Destination is null)
throw new ArgumentNullException(nameof(bolt11));
bolt11 = bolt11?.Replace("lightning:", "").Replace("LIGHTNING:", "");
// Pay the invoice - cancel after timeout, potentially caused by hold invoices
@ -334,36 +319,24 @@ namespace BTCPayServer.Lightning.CLightning
try
{
var pr = bolt11 is null ? null : BOLT11PaymentRequest.Parse(bolt11, Network);
// Normally, it should be possible to pay above the minimum amount, but CLN doesn't support it, unless the bolt amount is 0.
var explicitAmount = pr?.MinimumAmount is null || pr?.MinimumAmount == LightMoney.Zero ? payParams?.Amount : null;
long? maxFeeFlat = payParams?.MaxFeeFlat is null ? null : new LightMoney(payParams?.MaxFeeFlat).MilliSatoshi;
if (maxFeeFlat is null)
var explicitAmount = payParams?.Amount;
var feePercent = payParams?.MaxFeePercent;
if (feePercent is null && payParams?.MaxFeeFlat is Money m)
{
if (payParams?.MaxFeePercent is { } feePercent && explicitAmount is not null)
{
maxFeeFlat = (long)(explicitAmount.ToDecimal(LightMoneyUnit.Satoshi) * (decimal)feePercent / 100m);
}
var pr = BOLT11PaymentRequest.Parse(bolt11, Network);
var amountSat = (explicitAmount ?? pr.MinimumAmount).ToUnit(LightMoneyUnit.Satoshi);
feePercent = (double)(m.Satoshi / amountSat) * 100;
}
var command = isKeysend ? "xkeysend" : "xpay";
var opts = isKeysend
// xkeysend: destination amount_msat [label] [maxfee] [layers] [retry_for] [maxdelay] [extratlvs]
? new object[] { payParams.Destination.ToHex(), explicitAmount!.MilliSatoshi, null, maxFeeFlat }
// xpay: invstring [amount_msat] [maxfee] [layers] [retry_for] [retry_for] [partial_msat] [maxdelay] [payer_note] [label] [localinvreqid]
: new object[] { bolt11, explicitAmount?.MilliSatoshi, maxFeeFlat };
var response = await SendCommandAsync<CLightningPayResponse>(command, opts, false, cancellation: cts.Token);
var command = bolt11 == null ? "keysend" : "pay";
var destination = bolt11 ?? payParams.Destination.ToHex();
var response = await SendCommandAsync<CLightningPayResponse>(command,
new object[] { destination, explicitAmount?.MilliSatoshi, null, null, feePercent }, false, cancellation: cts.Token);
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = response.AmountSent,
FeeAmount = response.AmountSent - response.Amount,
PaymentHash = response.GetPaymentHash(),
Preimage = response.PaymentPreImage,
Status = LightningPaymentStatus.Complete
FeeAmount = response.AmountSent - response.Amount
});
}
catch (LightningRPCException ex) when (
@ -373,7 +346,7 @@ namespace BTCPayServer.Lightning.CLightning
ex.Code == CLightningErrorCode.WRONG_PARAMETERS || ex.Code == CLightningErrorCode.GENERAL_ERROR)
{
var routingError = ex.Code == CLightningErrorCode.ROUTE_NOT_FOUND ||
(ex.Code == CLightningErrorCode.STOPPED_RETRYING && !ex.Message.Contains("invalid payload")) ||
ex.Code == CLightningErrorCode.STOPPED_RETRYING ||
(ex.Code == CLightningErrorCode.WRONG_PARAMETERS && ex.Message.Contains("Self-payment"));
var result =
routingError
@ -402,10 +375,7 @@ namespace BTCPayServer.Lightning.CLightning
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = response.AmountSent,
FeeAmount = response.Fee,
PaymentHash = new uint256(response.PaymentHash),
Preimage = new uint256(response.Preimage),
Status = response.Status
FeeAmount = response.Fee
});
default:
throw new ArgumentOutOfRangeException();
@ -454,16 +424,48 @@ namespace BTCPayServer.Lightning.CLightning
args.Add(null); // [preimage]
args.Add(req.PrivateRouteHints);
bool usePlugin;
if (req.DescriptionHashOnly)
{
args.Add(null); // [cltv]
args.Add(true);
usePlugin = false;
}
else
{
usePlugin = req.DescriptionHash is not null;
}
CLightningInvoice invoice = await SendCommandAsync<CLightningInvoice>(
"invoice",
args.ToArray(),
cancellation: cancellation);
// Pre 22.11, we needed to use a plugin to support bolt11 with description hash.
// This is not the case anymore, but we may fallback to using the plugin for old nodes.
CLightningInvoice invoice = null;
if (!usePlugin)
{
try
{
invoice = await SendCommandAsync<CLightningInvoice>(
"invoice",
args.ToArray(),
cancellation: cancellation);
}
// Old nodes doesn't support descriptionHashOnly
catch (LightningRPCException ex) when (req.DescriptionHashOnly && ex.Code == CLightningErrorCode.WRONG_PARAMETERS)
{
// Remove two last parameters
args.RemoveAt(args.Count - 1);
args.RemoveAt(args.Count - 1);
usePlugin = true;
}
}
if (usePlugin)
{
args[2] = req.DescriptionHash.ToString();
invoice = await SendCommandAsync<CLightningInvoice>(
"invoicewithdescriptionhash",
args.ToArray(),
cancellation: cancellation);
}
if (invoice is null)
throw new InvalidOperationException("Bug in BTCPayServer.Lightning library, contact developers, code 52917");
@ -495,19 +497,22 @@ namespace BTCPayServer.Lightning.CLightning
async Task<LightningChannel[]> ILightningClient.ListChannels(CancellationToken cancellation)
{
var listChannels = await this.ListPeerChannelsAsync();
var listPeersAsync = this.ListPeersAsync(cancellation);
List<LightningChannel> channels = new List<LightningChannel>();
foreach (var channel in listChannels)
foreach (var peer in await listPeersAsync)
{
channels.Add(new LightningChannel
foreach (var channel in peer.Channels)
{
RemoteNode = new PubKey(channel.PeerId),
IsPublic = !channel.Private,
LocalBalance = channel.ToUs,
ChannelPoint = new OutPoint(channel.FundingTxId, channel.ShortChannelId.TxOutIndex),
Capacity = channel.Total,
IsActive = channel.State == "CHANNELD_NORMAL"
});
channels.Add(new LightningChannel()
{
RemoteNode = new PubKey(peer.Id),
IsPublic = !channel.Private,
LocalBalance = channel.ToUs,
ChannelPoint = new OutPoint(channel.FundingTxId, channel.ShortChannelId.TxOutIndex),
Capacity = channel.Total,
IsActive = channel.State == "CHANNELD_NORMAL"
});
}
}
return channels.ToArray();
}
@ -516,8 +521,6 @@ namespace BTCPayServer.Lightning.CLightning
new LightningInvoice
{
Id = invoice.Label,
PaymentHash = invoice.PaymentHash.ToString(),
Preimage = invoice.PaymentPreimage?.ToString(),
Amount = invoice.MilliSatoshi,
AmountReceived = invoice.MilliSatoshiReceived,
BOLT11 = invoice.BOLT11,
@ -712,11 +715,6 @@ retry:
return new LightningNodeBalance(onchain, offchain);
}
public override string ToString()
{
return $"type=clightning;server={Address}";
}
}
class CLightningInvoiceListener : ILightningInvoiceListener

View File

@ -1,44 +0,0 @@
using System;
using NBitcoin;
namespace BTCPayServer.Lightning.CLightning;
public class CLightningConnectionStringHandler : ILightningConnectionStringHandler
{
public ILightningClient Create(string connectionString, Network network, out string error)
{
var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type);
if (type != "clightning")
{
error = null;
return null;
}
if (!kv.TryGetValue("server", out var server))
{
error = $"The key 'server' is mandatory for clightning connection strings";
return null;
}
if (server.StartsWith("//", StringComparison.OrdinalIgnoreCase))
server = "unix:" + server;
else if (server.StartsWith("/", StringComparison.OrdinalIgnoreCase))
server = "unix:/" + server;
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri)
|| (uri.Scheme != "tcp" && uri.Scheme != "unix"))
{
error = $"The key 'server' should be an URI starting by tcp:// or unix:// or a path to the 'lightning-rpc' unix socket";
return null;
}
error = null;
return new CLightningClient(uri, network);
}
}

View File

@ -11,16 +11,6 @@ namespace BTCPayServer.Lightning.CLightning
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("payment_hash")]
public uint256 PaymentHash { get; set; }
// this is used by the invoice endpoint
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("payment_secret")]
public uint256 PaymentSecret { get; set; }
// this is used by the waitanyinvoice and listinvoices endpoints
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("payment_preimage")]
public uint256 PaymentPreimage { get; set; }
[JsonProperty("amount_msat")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
@ -46,6 +36,7 @@ namespace BTCPayServer.Lightning.CLightning
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? PaidAt { get; set; }
#pragma warning disable IDE0051
// Legacy stuff
[JsonProperty("msatoshi")]

View File

@ -1,6 +1,5 @@
using System.Collections.Generic;
using NBitcoin;
using NBitcoin.Crypto;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -13,6 +12,9 @@ namespace BTCPayServer.Lightning.CLightning
public string Status { get; set; }
public int Parts { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("payment_hash")]
public uint256 PaymentHash { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("payment_preimage")]
public uint256 PaymentPreImage { get; set; }
@ -38,7 +40,5 @@ namespace BTCPayServer.Lightning.CLightning
[Newtonsoft.Json.JsonExtensionData]
public IDictionary<string, JToken> AdditionalProperties { get; set; }
public uint256 GetPaymentHash() => new uint256(Hashes.SHA256(PaymentPreImage.ToBytes(false)), false);
}
}

View File

@ -1,42 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.CLightning
{
public class PeerChannel
{
[JsonProperty("peer_id")]
public string PeerId { get; set; }
public bool Private { get; set; }
[JsonProperty("to_us_msat")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney ToUs
{
get;
set;
}
[JsonProperty("funding_txid")]
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 FundingTxId { get; set; }
[JsonProperty("short_channel_id")]
[JsonConverter(typeof(JsonConverters.ShortChannelIdJsonConverter))]
public ShortChannelId ShortChannelId { get; set; }
[JsonProperty("total_msat")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney Total
{
get;
set;
}
public string State { get; set; }
}
}

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.CLightning
{
public class ChannelInfo
{
public string State { get; set; }
public string Owner { get; set; }
[JsonProperty("funding_txid")]
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 FundingTxId { get; set; }
[JsonProperty("short_channel_id")]
[JsonConverter(typeof(JsonConverters.ShortChannelIdJsonConverter))]
public ShortChannelId ShortChannelId { get; set; }
[JsonProperty("msatoshi_to_us")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney ToUs { get; set; }
[JsonProperty("msatoshi_total")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney Total { get; set; }
[JsonProperty("dust_limit_satoshis")]
[JsonConverter(typeof(NBitcoin.JsonConverters.MoneyJsonConverter))]
public Money DustLimit { get; set; }
[JsonProperty("max_htlc_value_in_flight_msat")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney MaxHTLCValueInFlight { get; set; }
[JsonProperty("channel_reserve_satoshis")]
[JsonConverter(typeof(NBitcoin.JsonConverters.MoneyJsonConverter))]
public Money ChannelReserve { get; set; }
[JsonProperty("htlc_minimum_msat")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney HTLCMinimum { get; set; }
[JsonProperty("to_self_delay")]
public int ToSelfDelay { get; set; }
[JsonProperty("max_accepted_htlcs")]
public int MaxAcceptedHTLCS { get; set; }
public bool Private { get; set; }
public string[] Status { get; set; }
}
public class PeerInfo
{
public string State { get; set; }
public string Id { get; set; }
[JsonProperty("netaddr")]
public string[] NetworkAddresses { get; set; }
public bool Connected { get; set; }
public string Owner { get; set; }
public ChannelInfo[] Channels { get; set; }
}
}

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "CLightning/v$ver" -m "CLightning/$ver"
git push --tags

View File

@ -29,8 +29,8 @@ namespace BTCPayServer.Lightning.CLightning
return false;
if (!int.TryParse(datas[0], out var blockHeight) ||
!int.TryParse(datas[1], out var blockIndex) ||
!int.TryParse(datas[2], out var txOutIndex))
!int.TryParse(datas[0], out var blockIndex) ||
!int.TryParse(datas[0], out var txOutIndex))
return false;
if (blockHeight < 0 || blockIndex < 0 || txOutIndex < 0)
return false;

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<Version>1.3.16</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.Charge</PackageId>
<Description>Client library for lightning charge to build Lightning Network Apps in C#.</Description>
<PackageProjectUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</PackageProjectUrl>
<RepositoryUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>lightning;bitcoin;clightning;charge;lapps</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Net.WebSockets.Client" Version="4.3.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Lightning.CLightning\BTCPayServer.Lightning.CLightning.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
namespace BTCPayServer.Lightning.Charge
{
public abstract class ChargeAuthentication
{
public class UserPasswordAuthentication : ChargeAuthentication
{
public UserPasswordAuthentication(NetworkCredential networkCredential)
{
if (networkCredential == null)
throw new ArgumentNullException(nameof(networkCredential));
NetworkCredential = networkCredential;
}
public NetworkCredential NetworkCredential { get; }
public override string GetBase64Creds()
{
return Convert.ToBase64String(Encoding.ASCII.GetBytes($"{NetworkCredential.UserName}:{NetworkCredential.Password}"));
}
}
public class CookieFileAuthentication : ChargeAuthentication
{
public CookieFileAuthentication(string filePath)
{
if (filePath == null)
throw new ArgumentNullException(nameof(filePath));
FilePath = filePath;
}
public string FilePath { get; set; }
public override string GetBase64Creds()
{
try
{
var password = File.ReadAllText(FilePath);
return Convert.ToBase64String(Encoding.ASCII.GetBytes($"api-token:{password}"));
}
catch
{
return Convert.ToBase64String(Encoding.ASCII.GetBytes(""));
}
}
}
public abstract string GetBase64Creds();
}
}

View File

@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.CLightning;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Charge
{
public class ChargeClient : ILightningClient
{
private Uri _Uri;
public Uri Uri => _Uri;
private Network _Network;
private HttpClient _Client;
private static readonly HttpClient SharedClient = new HttpClient();
public ChargeClient(Uri uri, Network network, HttpClient httpClient = null, bool allowInsecure = false)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (network == null)
throw new ArgumentNullException(nameof(network));
httpClient = CreateHttpClient(uri, allowInsecure, httpClient ?? SharedClient);
_Client = httpClient;
this._Uri = uri;
this._Network = network;
if (uri.UserInfo == null)
throw new ArgumentException(paramName: nameof(uri), message: "User information not present in uri");
var userInfo = uri.UserInfo.Split(':');
if (userInfo.Length != 2)
throw new ArgumentException(paramName: nameof(uri), message: "User information not present in uri");
ChargeAuthentication = new ChargeAuthentication.UserPasswordAuthentication(new NetworkCredential(userInfo[0], userInfo[1]));
}
public ChargeClient(Uri uri, string cookieFilePath, Network network, HttpClient httpClient = null, bool allowInsecure = false)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (network == null)
throw new ArgumentNullException(nameof(network));
if (cookieFilePath == null)
throw new ArgumentNullException(nameof(cookieFilePath));
httpClient = CreateHttpClient(uri, allowInsecure, httpClient ?? SharedClient);
_Client = httpClient;
this._Uri = uri;
this._Network = network;
ChargeAuthentication = new ChargeAuthentication.CookieFileAuthentication(cookieFilePath);
}
internal static HttpClient CreateHttpClient(Uri uri, bool allowInsecure, HttpClient defaultHttpClient)
{
// If certificate pinning or https disabled, we need to create a special HttpClientHandler
// But if that's not the case, we can just use the default httpclient
if (defaultHttpClient != null)
{
// If we allow insecure and want http, we don't need specific http handlers
if (allowInsecure)
{
if (uri.Scheme == "http")
return defaultHttpClient;
}
// If we do not allow insecure and want https and do not pin certificates, we don't need specific http handlers
else if (uri.Scheme == "https")
{
return defaultHttpClient;
}
}
var handler = new HttpClientHandler();
if (allowInsecure)
{
handler.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => true;
}
else
{
if (uri.Scheme == "http")
throw new InvalidOperationException("AllowInsecure is set to false, but the URI is not using https");
}
return new HttpClient(handler);
}
public async Task<CreateInvoiceResponse> CreateInvoiceAsync(CreateInvoiceRequest request, CancellationToken cancellation = default)
{
var message = CreateMessage(HttpMethod.Post, "invoice");
Dictionary<string, string> parameters = new Dictionary<string, string>();
if (request.Amount != null && request.Amount != LightMoney.Zero)
{
parameters.Add("msatoshi", request.Amount.MilliSatoshi.ToString(CultureInfo.InvariantCulture));
}
parameters.Add("expiry", ((int)request.Expiry.TotalSeconds).ToString(CultureInfo.InvariantCulture));
if (request.Description != null)
parameters.Add("description", request.Description);
message.Content = new FormUrlEncodedContent(parameters);
var result = await _Client.SendAsync(message, cancellation);
result.EnsureSuccessStatusCode();
var content = await result.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<CreateInvoiceResponse>(content);
}
public async Task<ChargeSession> Listen(CancellationToken cancellation = default)
{
return new ChargeSession(
await WebsocketHelper.CreateClientWebSocket(Uri.ToString(),
$"Basic {ChargeAuthentication.GetBase64Creds()}", cancellation));
}
public ChargeAuthentication ChargeAuthentication { get; set; }
public GetInfoResponse GetInfo()
{
return GetInfoAsync().GetAwaiter().GetResult();
}
private async Task<ChargeInvoice> GetInvoice(string invoiceId, CancellationToken cancellation = default)
{
var request = CreateMessage(HttpMethod.Get, $"invoice/{invoiceId}");
var message = await _Client.SendAsync(request, cancellation);
if (message.StatusCode == HttpStatusCode.NotFound)
return null;
message.EnsureSuccessStatusCode();
var content = await message.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<ChargeInvoice>(content);
}
private async Task<GetInfoResponse> GetInfoAsync(CancellationToken cancellation = default)
{
var request = CreateMessage(HttpMethod.Get, "info");
var message = await _Client.SendAsync(request, cancellation);
message.EnsureSuccessStatusCode();
var content = await message.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<GetInfoResponse>(content);
}
private HttpRequestMessage CreateMessage(HttpMethod method, string path)
{
var uri = GetFullUri(path);
var request = new HttpRequestMessage(method, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", ChargeAuthentication.GetBase64Creds());
return request;
}
private Uri GetFullUri(string partialUrl)
{
var uri = _Uri.AbsoluteUri;
if (!uri.EndsWith("/", StringComparison.InvariantCultureIgnoreCase))
uri += "/";
return new Uri(uri + partialUrl);
}
async Task<LightningInvoice> ILightningClient.GetInvoice(string invoiceId, CancellationToken cancellation)
{
var invoice = await GetInvoice(invoiceId, cancellation);
if (invoice == null)
return null;
return ToLightningInvoice(invoice);
}
async Task<LightningInvoice[]> ILightningClient.ListInvoices(CancellationToken cancellation)
{
var invoices = await ListInvoices(null, cancellation);
return invoices.Select(ToLightningInvoice).ToArray();
}
async Task<LightningInvoice[]> ILightningClient.ListInvoices(ListInvoicesParams param, CancellationToken cancellation)
{
var invoices = await ListInvoices(param, cancellation);
return invoices.Select(ToLightningInvoice).ToArray();
}
private async Task<ChargeInvoice[]> ListInvoices(ListInvoicesParams param, CancellationToken cancellation)
{
var request = CreateMessage(HttpMethod.Get, "invoices");
var message = await _Client.SendAsync(request, cancellation);
if (message.StatusCode == HttpStatusCode.NotFound)
return null;
message.EnsureSuccessStatusCode();
var content = await message.Content.ReadAsStringAsync();
var invoices = JsonConvert.DeserializeObject<ChargeInvoice[]>(content);
if (param != null)
{
// we need to filter client-side, because the listinvoices command does not support these filters
invoices = invoices.Where(invoice =>
(!param.PendingOnly.HasValue || param.PendingOnly.Value is false || ToInvoiceStatus(invoice.Status) == LightningInvoiceStatus.Unpaid) &&
(!param.OffsetIndex.HasValue || invoice.PayIndex >= param.OffsetIndex.Value)).ToArray();
}
return invoices;
}
private static LightningInvoiceStatus ToInvoiceStatus(string s) => CLightningClient.ToInvoiceStatus(s);
async Task<ILightningInvoiceListener> ILightningClient.Listen(CancellationToken cancellation)
{
return await Listen(cancellation);
}
internal static LightningInvoice ToLightningInvoice(ChargeInvoice invoice) => new()
{
Id = invoice.Id ?? invoice.Label,
Amount = invoice.MilliSatoshi,
AmountReceived = invoice.MilliSatoshiReceived,
BOLT11 = invoice.PaymentRequest,
PaidAt = invoice.PaidAt,
ExpiresAt = invoice.ExpiresAt,
Status = ToInvoiceStatus(invoice.Status)
};
async Task<LightningInvoice> ILightningClient.CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation)
{
var invoice = await CreateInvoiceAsync(new CreateInvoiceRequest() { Amount = amount, Expiry = expiry, Description = description ?? "" }, cancellation);
return new LightningInvoice() { Id = invoice.Id, Amount = amount, BOLT11 = invoice.PayReq, Status = LightningInvoiceStatus.Unpaid, ExpiresAt = DateTimeOffset.UtcNow + expiry };
}
Task<LightningInvoice> ILightningClient.CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation)
{
if (req.DescriptionHash is not null)
{
throw new NotSupportedException("Lightning Charge does not support creating an invoice with description_hash");
}
return (this as ILightningClient).CreateInvoice(req.Amount, req.Description, req.Expiry, cancellation);
}
async Task<LightningNodeInformation> ILightningClient.GetInfo(CancellationToken cancellation)
{
var info = await GetInfoAsync(cancellation);
return CLightningClient.ToLightningNodeInformation(info);
}
Task<LightningNodeBalance> ILightningClient.GetBalance(CancellationToken cancellation)
{
throw new NotSupportedException();
}
Task<PayResponse> ILightningClient.Pay(string bolt11, CancellationToken cancellation)
{
throw new NotSupportedException();
}
Task<PayResponse> ILightningClient.Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation)
{
throw new NotSupportedException();
}
Task<PayResponse> ILightningClient.Pay(PayInvoiceParams payParams, CancellationToken cancellation)
{
throw new NotSupportedException();
}
Task<OpenChannelResponse> ILightningClient.OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation)
{
throw new NotSupportedException();
}
Task<BitcoinAddress> ILightningClient.GetDepositAddress(CancellationToken cancellation)
{
throw new NotSupportedException();
}
Task<ConnectionResult> ILightningClient.ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation)
{
throw new NotSupportedException();
}
public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = default)
{
var message = CreateMessage(HttpMethod.Delete, $"invoice/{invoiceId}");
Dictionary<string, string> parameters = new Dictionary<string, string>();
parameters.Add("status", "unpaid");
message.Content = new FormUrlEncodedContent(parameters);
var result = await _Client.SendAsync(message, cancellation);
result.EnsureSuccessStatusCode();
}
Task<LightningChannel[]> ILightningClient.ListChannels(CancellationToken cancellation)
{
throw new NotSupportedException();
}
public Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
}
}

View File

@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Charge
{
public class ChargeInvoice
{
public string Id { get; set; }
[JsonProperty("msatoshi")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney MilliSatoshi { get; set; }
[JsonProperty("msatoshi_received")]
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney MilliSatoshiReceived { get; set; }
[JsonProperty("paid_at")]
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? PaidAt { get; set; }
[JsonProperty("expires_at")]
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset ExpiresAt { get; set; }
public string Status { get; set; }
[JsonProperty("payreq")]
public string PaymentRequest { get; set; }
public string Label { get; set; }
[JsonProperty("pay_index")]
public int? PayIndex { get; set; }
}
public class ChargeSession : WebsocketListener, ILightningInvoiceListener
{
public ChargeSession(ClientWebSocket socket) : base(socket)
{
}
public async Task<ChargeInvoice> WaitInvoice(CancellationToken cancellation = default)
{
var message = await WaitMessage(cancellation);
return JsonConvert.DeserializeObject<ChargeInvoice>(message, new JsonSerializerSettings());
}
async Task<LightningInvoice> ILightningInvoiceListener.WaitInvoice(CancellationToken token)
{
return ChargeClient.ToLightningInvoice(await WaitInvoice(token));
}
}
}

View File

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Lightning.Charge
{
public class CreateInvoiceRequest
{
public LightMoney Amount { get; set; }
public TimeSpan Expiry { get; set; }
public string Description { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Lightning.Charge
{
public class CreateInvoiceResponse
{
public string PayReq { get; set; }
public string Id { get; set; }
}
}

View File

@ -3,5 +3,5 @@ dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snu
$package=(ls .\bin\Release\*.nupkg).FullName
dotnet nuget push $package --source "https://api.nuget.org/v3/index.json"
$ver = ((Get-ChildItem .\bin\release\*.nupkg)[0].Name -replace '[^\d]*\.(\d+(\.\d+){1,4}).*', '$1')
git tag -a "Phoenixd/v$ver" -m "Phoenixd/$ver"
git tag -a "Charge/v$ver" -m "Charge/$ver"
git push --tags

View File

@ -1,21 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<RootNamespace>BTCPayServer.Lightning</RootNamespace>
<Version>1.7.1</Version>
<Version>1.3.17</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.Common</PackageId>
<Description>Client library for lightning network implementations to build Lightning Network Apps in C#.</Description>
<PackageProjectUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</PackageProjectUrl>
<RepositoryUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>lightning;bitcoin;clightning;lnd;lapps</PackageTags>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<PackageTags>lightning;bitcoin;clightning;lnd;charge;lapps</PackageTags>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
</PropertyGroup>
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj" />
<ItemGroup>
<PackageReference Include="NBitcoin" Version="10.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NBitcoin" Version="7.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
</Project>

View File

@ -1,28 +0,0 @@
#if NETSTANDARD
using NBitcoin.DataEncoders;
#else
using System;
#endif
namespace BTCPayServer.Lightning;
public static class ConvertHelper
{
public static byte[] FromHexString(string hex)
{
#if NETSTANDARD
return Encoders.Hex.DecodeData(hex);
#else
return Convert.FromHexString(hex);
#endif
}
public static string ToHexString(byte[] data)
{
#if NETSTANDARD
return Encoders.Hex.EncodeData(data).ToLowerInvariant();;
#else
return Convert.ToHexString(data).ToLowerInvariant();
#endif
}
}

View File

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NBitcoin;
using NBitcoin.Crypto;
@ -18,7 +21,6 @@ namespace BTCPayServer.Lightning
Description = description;
Expiry = expiry;
}
[Obsolete("Set the Description and turn DescriptionHashOnly to true instead")]
public CreateInvoiceParams(LightMoney amount, uint256 descriptionHash, TimeSpan expiry)
{

View File

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NBitcoin;
@ -10,7 +8,6 @@ namespace BTCPayServer.Lightning
public interface ILightningClient
{
Task<LightningInvoice> GetInvoice(string invoiceId, CancellationToken cancellation = default);
Task<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation = default);
Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default);
Task<LightningInvoice[]> ListInvoices(ListInvoicesParams request, CancellationToken cancellation = default);
Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation = default);
@ -29,96 +26,10 @@ namespace BTCPayServer.Lightning
Task<ConnectionResult> ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = default);
Task CancelInvoice(string invoiceId, CancellationToken cancellation = default);
Task<LightningChannel[]> ListChannels(CancellationToken cancellation = default);
}
public interface ILightningInvoiceListener : IDisposable
{
Task<LightningInvoice> WaitInvoice(CancellationToken cancellation);
}
public interface ILightningConnectionStringHandler
{
ILightningClient Create(string connectionString, Network network, out string error);
}
public static class LightningConnectionStringHelper
{
public static Dictionary<string, string> ExtractValues(string connectionString, out string type)
{
if (!TryParseLegacy(connectionString, out var keyValues))
{
var parts = connectionString.Split(new [] { ';' }, StringSplitOptions.RemoveEmptyEntries);
keyValues = new Dictionary<string, string>();
foreach (var part in parts.Select(p => p.Trim()))
{
var idx = part.IndexOf('=');
if (idx == -1)
{
throw new FormatException("The format of the connectionString should a list of key=value delimited by semicolon");
}
var key = part.Substring(0, idx).Trim().ToLowerInvariant();
var value = part.Substring(idx + 1).Trim();
if (keyValues.ContainsKey(key))
{
throw new FormatException($"Duplicate key {key}");
}
keyValues.Add(key, value);
}
}
if (!keyValues.TryGetValue("type", out type))
{
throw new FormatException("The key 'type' is mandatory");
}
return keyValues;
}
public static bool VerifySecureEndpoint(Uri uri, bool allowInsecure)
{
return uri.Scheme== "https" || allowInsecure || uri.Host.EndsWith("onion");
}
private static bool TryParseLegacy(string str, out Dictionary<string, string> connectionString)
{
if (str.StartsWith("/"))
str = "unix:" + str;
var result = new Dictionary<string, string>();
connectionString = null;
Uri uri;
if (!Uri.TryCreate(str, UriKind.Absolute, out uri))
{
return false;
}
var supportedDomains = new string[] { "unix", "tcp" };
if (!supportedDomains.Contains(uri.Scheme))
{
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);
result.Add("type", "clightning");
}
if (uri.Scheme == "tcp")
result.Add("type", "clightning");
if (!string.IsNullOrEmpty(uri.UserInfo))
{
return false;
}
result.Add("server",new UriBuilder(uri) { UserName = "", Password = "" }.Uri.ToString());
connectionString = result;
return true;
}
}
}

View File

@ -7,7 +7,5 @@ namespace BTCPayServer.Lightning
public interface ILightningClientFactory
{
ILightningClient Create(string connectionString);
bool TryCreate(string connectionString, out ILightningClient client, out string error);
}
}

View File

@ -25,15 +25,13 @@ namespace BTCPayServer.Lightning.JsonConverters
JsonToken.Integer => _longType.IsAssignableFrom(reader.ValueType)
? new LightMoney((long)reader.Value)
: new LightMoney(long.MaxValue),
JsonToken.Float => new LightMoney(Convert.ToInt64(reader.Value)),
JsonToken.String =>
// some of the c-lightning values have a trailing "msat" that we need to remove before parsing
// some of the charge values have a trailing ".0" that we need to remove before parsing
new LightMoney(long.Parse(((string)reader.Value)
.Replace("msat", "")
.Replace(".0", ""), CultureInfo.InvariantCulture)),
new LightMoney(long.Parse(((string)reader.Value).Replace("msat", ""), CultureInfo.InvariantCulture)),
// Fix for Eclair having empty objects for zero amount cases, see https://acinq.github.io/eclair/#globalbalance
JsonToken.StartObject => JObject.Load(reader) != null ? LightMoney.Zero : null,
// Eclair denominates global balance amounts in BTC, see https://acinq.github.io/eclair/#globalbalance
JsonToken.Float => new LightMoney(Convert.ToDecimal(reader.Value), LightMoneyUnit.BTC),
_ => null
};
}

View File

@ -1,78 +0,0 @@
using System;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.JsonConverters;
public abstract class TimeSpanJsonConverter : JsonConverter
{
public class Seconds : TimeSpanJsonConverter
{
protected override long ToLong(TimeSpan value)
{
return (long)value.TotalSeconds;
}
protected override TimeSpan ToTimespan(long value)
{
return TimeSpan.FromSeconds(value);
}
}
public class Minutes : TimeSpanJsonConverter
{
protected override long ToLong(TimeSpan value)
{
return (long)value.TotalMinutes;
}
protected override TimeSpan ToTimespan(long value)
{
return TimeSpan.FromMinutes(value);
}
}
public class Days : TimeSpanJsonConverter
{
protected override long ToLong(TimeSpan value)
{
return (long)value.TotalDays;
}
protected override TimeSpan ToTimespan(long value)
{
return TimeSpan.FromDays(value);
}
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(TimeSpan) || objectType == typeof(TimeSpan?);
}
protected abstract TimeSpan ToTimespan(long value);
protected abstract long ToLong(TimeSpan value);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
try
{
var nullable = objectType == typeof(TimeSpan?);
if (reader.TokenType == JsonToken.Null)
{
if (nullable)
return null;
return TimeSpan.Zero;
}
if (reader.TokenType != JsonToken.Integer)
throw new JsonObjectException("Invalid timespan, expected integer", reader);
return ToTimespan((long)reader.Value);
}
catch
{
throw new JsonObjectException("Invalid timespan", reader);
}
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value is TimeSpan s)
{
writer.WriteValue(ToLong(s));
}
}
}

View File

@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning;
public class LightningInvoice
{
public string Id { get; set; }
public string PaymentHash { get; set; }
public string Preimage { get; set; }
public LightningInvoiceStatus Status { get; set; }
public string BOLT11 { get; set; }
public DateTimeOffset? PaidAt { get; set; }

View File

@ -1,7 +1,4 @@
using System;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Lightning
{
@ -47,14 +44,5 @@ namespace BTCPayServer.Lightning
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
public LightMoney FeeAmount { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public LightningPaymentStatus Status { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 Preimage { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 PaymentHash { get; set; }
}
}

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack BTCPayServer.Lightning.Common.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "Common/v$ver" -m "Common/$ver"
git push --tags

View File

@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<Version>1.7.1</Version>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<Version>1.3.16</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.Eclair</PackageId>
<Description>Client library for Eclair to build Lightning Network Apps in C#.</Description>

View File

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
@ -9,8 +8,10 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.Eclair.Models;
using NBitcoin;
using NBitcoin.Protocol;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace BTCPayServer.Lightning.Eclair
{
@ -20,11 +21,11 @@ namespace BTCPayServer.Lightning.Eclair
private readonly string _username;
private readonly string _password;
private readonly HttpClient _httpClient;
private static readonly HttpClient SharedClient = new();
private static readonly HttpClient SharedClient = new HttpClient();
public Network Network { get; }
public EclairClient(Uri address, string password, Network network, HttpClient httpClient = null) : this(address, null, password, network, httpClient) { }
public EclairClient(Uri address, string password, Network network, HttpClient httpClient = null):this(address,null, password,network, httpClient){}
public EclairClient(Uri address, string username, string password, Network network, HttpClient httpClient = null)
{
if (address == null)
@ -214,7 +215,7 @@ namespace BTCPayServer.Lightning.Eclair
CancellationToken cts = default)
{
return await SendCommandAsync<GetReceivedInfoRequest, GetReceivedInfoResponse>("getreceivedinfo",
new GetReceivedInfoRequest
new GetReceivedInfoRequest()
{
PaymentHash = paymentHash,
Invoice = invoice
@ -341,8 +342,6 @@ namespace BTCPayServer.Lightning.Eclair
content = new FormUrlEncodedContent(x.Select(pair => pair));
}
int retry = 0;
retry:
var httpRequest = new HttpRequestMessage
{
Method = HttpMethod.Post,
@ -351,26 +350,18 @@ retry:
};
httpRequest.Headers.Accept.Clear();
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.Default.GetBytes($"{_username ?? string.Empty}:{_password}")));
try
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.Default.GetBytes($"{_username??string.Empty}:{_password}")));
var rawResult = await _httpClient.SendAsync(httpRequest, cts);
var rawJson = await rawResult.Content.ReadAsStringAsync();
if (!rawResult.IsSuccessStatusCode)
{
using var rawResult = await _httpClient.SendAsync(httpRequest, cts);
var rawJson = await rawResult.Content.ReadAsStringAsync();
if (!rawResult.IsSuccessStatusCode)
throw new EclairApiException
{
throw new EclairApiException
{
Error = JsonConvert.DeserializeObject<EclairApiError>(rawJson, SerializerSettings)
};
}
return JsonConvert.DeserializeObject<TResponse>(rawJson, SerializerSettings);
}
catch (HttpRequestException e) when (e.InnerException is IOException && retry < 10)
{
retry++;
await Task.Delay(100 * retry, cts);
goto retry;
Error = JsonConvert.DeserializeObject<EclairApiError>(rawJson, SerializerSettings)
};
}
return JsonConvert.DeserializeObject<TResponse>(rawJson, SerializerSettings);
}

View File

@ -1,53 +0,0 @@
using System;
using System.Net.Http;
using NBitcoin;
namespace BTCPayServer.Lightning.Eclair;
public class EclairConnectionStringHandler : ILightningConnectionStringHandler
{
private readonly HttpClient _httpClient;
public EclairConnectionStringHandler(HttpClient httpClient = null)
{
_httpClient = httpClient;
}
public ILightningClient Create(string connectionString, Network network, out string error)
{
var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type);
if (type != "eclair")
{
error = null;
return null;
}
if (!kv.TryGetValue("server", out var server))
{
error = $"The key 'server' is mandatory for eclair connection strings";
return null;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var eclairuri)
|| (eclairuri.Scheme != "http" && eclairuri.Scheme != "https"))
{
error = $"The key 'server' should be an URI starting by http:// or https://";
return null;
}
kv.TryGetValue("username", out var username);
kv.TryGetValue("password", out var password);
if (kv.TryGetValue("bitcoin-host", out var bitcoinHost))
{
if (!kv.TryGetValue("bitcoin-auth", out var bitcoinAuth))
{
error =
$"The key 'bitcoin-auth' is mandatory for eclair connection strings when bitcoin-host is specified";
return null;
}
}
error = null;
return new EclairLightningClient(eclairuri, username, password, network, _httpClient);
}
}

View File

@ -1,12 +1,16 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.ExceptionServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.Eclair.Models;
using NBitcoin;
using NBitcoin.RPC;
namespace BTCPayServer.Lightning.Eclair
{
@ -48,9 +52,6 @@ namespace BTCPayServer.Lightning.Eclair
return null;
}
}
public async Task<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation = default) =>
await GetInvoice(paymentHash.ToString(), cancellation);
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default) =>
await ListInvoices(null, cancellation);
@ -72,7 +73,6 @@ namespace BTCPayServer.Lightning.Eclair
var lnInvoice = new LightningInvoice
{
Id = invoiceId,
PaymentHash = invoice.PaymentHash,
Amount = parsed.MinimumAmount,
ExpiresAt = parsed.ExpiryDate,
BOLT11 = invoice.Serialized
@ -96,7 +96,6 @@ namespace BTCPayServer.Lightning.Eclair
lnInvoice.AmountReceived = info.Status.Amount;
lnInvoice.Status = info.Status.Amount >= parsed.MinimumAmount ? LightningInvoiceStatus.Paid : LightningInvoiceStatus.Unpaid;
lnInvoice.PaidAt = info.Status.ReceivedAt;
lnInvoice.Preimage = info.PaymentPreimage;
}
return lnInvoice;
@ -105,22 +104,20 @@ namespace BTCPayServer.Lightning.Eclair
public async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation = default)
{
var result = await _eclairClient.GetSentInfo(paymentHash, null, cancellation);
if (result.Count == 0)
return null;
var sentInfo = result.First();
var fees = sentInfo.Status.FeesPaid;
var payment = new LightningPayment
{
Id = sentInfo.Id.ToString(),
Preimage = sentInfo.Status.PaymentPreimage,
Preimage = sentInfo.Preimage,
PaymentHash = sentInfo.PaymentHash,
CreatedAt = sentInfo.CreatedAt,
Amount = sentInfo.Amount,
AmountSent = sentInfo.Amount + fees,
Fee = fees
AmountSent = sentInfo.Amount + sentInfo.FeesPaid,
Fee = sentInfo.FeesPaid
};
switch (sentInfo.Status.Type)
switch (sentInfo.Status.type)
{
case "pending":
payment.Status = LightningPaymentStatus.Pending;
@ -158,14 +155,13 @@ namespace BTCPayServer.Lightning.Eclair
Convert.ToInt32(expiry.TotalSeconds), null, cancellation);
var parsed = BOLT11PaymentRequest.Parse(result.Serialized, _network);
var invoice = new LightningInvoice
var invoice = new LightningInvoice()
{
BOLT11 = result.Serialized,
Amount = amount,
Id = result.PaymentHash,
Status = LightningInvoiceStatus.Unpaid,
ExpiresAt = parsed.ExpiryDate,
PaymentHash = result.PaymentHash
ExpiresAt = parsed.ExpiryDate
};
return invoice;
}
@ -226,7 +222,7 @@ namespace BTCPayServer.Lightning.Eclair
{
Opening =
global.Offchain.WaitForFundingConfirmed +
global.Offchain.WaitForChannelReady +
global.Offchain.WaitForFundingLocked +
global.Offchain.WaitForPublishFutureCommitment,
Local = global.Offchain.Normal.ToLocal,
Remote = usable.Sum(channel => channel.CanReceive),
@ -247,117 +243,37 @@ namespace BTCPayServer.Lightning.Eclair
public async Task<PayResponse> Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = default)
{
// Pay the invoice - cancel after timeout, potentially caused by hold invoices
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var timeout = payParams?.SendTimeout ?? PayInvoiceParams.DefaultSendTimeout;
cts.CancelAfter(timeout);
try
{
var req = new PayInvoiceRequest
{
Invoice = bolt11,
AmountMsat = payParams?.Amount?.MilliSatoshi,
MaxFeePct = payParams?.MaxFeePercent != null
? (int)Math.Round(payParams.MaxFeePercent.Value)
: null,
MaxFeePct = payParams?.MaxFeePercent != null ? (int)Math.Round(payParams.MaxFeePercent.Value) : null,
MaxFeeFlatSat = payParams?.MaxFeeFlat?.Satoshi
};
var uuid = await _eclairClient.PayInvoice(req, cts.Token);
while (!cts.Token.IsCancellationRequested)
var uuid = await _eclairClient.PayInvoice(req, cancellation);
while (!cancellation.IsCancellationRequested)
{
var status = await _eclairClient.GetSentInfo(null, uuid, cts.Token);
var status = await _eclairClient.GetSentInfo(null, uuid, cancellation);
if (!status.Any())
{
continue;
}
var sentInfo = status.First();
switch (sentInfo.Status.Type)
{
case "sent":
return new PayResponse(PayResult.Ok,
new PayDetails
{
TotalAmount = sentInfo.Amount,
FeeAmount = sentInfo.Status.FeesPaid,
PaymentHash = new uint256(sentInfo.PaymentHash),
Preimage = new uint256(sentInfo.Status.PaymentPreimage),
Status = LightningPaymentStatus.Complete
});
case "failed":
var failure = sentInfo.Status.Failures.First();
var result =
failure.FailureMessage.Contains("route") ||
failure.FailureMessage.StartsWith("in-flight htlcs hold too much value", StringComparison.OrdinalIgnoreCase)
? PayResult.CouldNotFindRoute
: PayResult.Error;
return new PayResponse(result, failure.FailureMessage);
case "pending":
await Task.Delay(200, cts.Token);
break;
}
}
}
catch (EclairClient.EclairApiException exception)
{
return new PayResponse(PayResult.Error, exception.Message);
}
catch (Exception exception)
{
return cts.Token.IsCancellationRequested
? new PayResponse(PayResult.Unknown)
: new PayResponse(PayResult.Error, exception.Message);
}
return new PayResponse(PayResult.Unknown);
}
public async Task<PayResponse> Pay(PayInvoiceParams payParams, CancellationToken cancellation = default)
{
// Pay the invoice - cancel after timeout, potentially caused by hold invoices
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var timeout = payParams?.SendTimeout ?? PayInvoiceParams.DefaultSendTimeout;
cts.CancelAfter(timeout);
try
{
var req = new SendToNodeRequest
{
NodeId = payParams.Destination?.ToString(),
AmountMsat = payParams.Amount?.MilliSatoshi,
MaxFeePct = payParams.MaxFeePercent != null ? (int)Math.Round(payParams.MaxFeePercent.Value) : null,
MaxFeeFlatSat = payParams.MaxFeeFlat?.Satoshi,
};
var uuid = await _eclairClient.SendToNode(req, cts.Token);
while (!cts.Token.IsCancellationRequested)
{
var status = await _eclairClient.GetSentInfo(null, uuid, cts.Token);
if (!status.Any())
{
continue;
}
var sentInfo = status.First();
switch (sentInfo.Status.Type)
switch (sentInfo.Status.type)
{
case "sent":
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = sentInfo.Amount,
FeeAmount = sentInfo.Status.FeesPaid,
PaymentHash = new uint256(sentInfo.PaymentHash),
Preimage = new uint256(sentInfo.Status.PaymentPreimage),
Status = LightningPaymentStatus.Complete
FeeAmount = sentInfo.FeesPaid
});
case "failed":
var failure = sentInfo.Status.Failures.First();
var result = failure.FailureMessage.Contains("route")
? PayResult.CouldNotFindRoute
: PayResult.Error;
return new PayResponse(result, failure.FailureMessage);
return new PayResponse(PayResult.CouldNotFindRoute);
case "pending":
await Task.Delay(200, cts.Token);
await Task.Delay(200, cancellation);
break;
}
}
@ -366,13 +282,56 @@ namespace BTCPayServer.Lightning.Eclair
{
return new PayResponse(PayResult.Error, exception.Message);
}
catch (Exception exception)
return new PayResponse(PayResult.CouldNotFindRoute);
}
public async Task<PayResponse> Pay(PayInvoiceParams payParams, CancellationToken cancellation = default)
{
try
{
return cts.Token.IsCancellationRequested
? new PayResponse(PayResult.Unknown)
: new PayResponse(PayResult.Error, exception.Message);
var paymentHash = payParams.PaymentHash.ToString();
var req = new SendToNodeRequest
{
NodeId = payParams.Destination.ToString(),
AmountMsat = payParams.Amount?.MilliSatoshi,
PaymentHash = paymentHash,
MaxFeePct = payParams.MaxFeePercent != null ? (int)Math.Round(payParams.MaxFeePercent.Value) : null,
MaxFeeFlatSat = payParams.MaxFeeFlat?.Satoshi,
};
var uuid = await _eclairClient.SendToNode(req, cancellation);
while (!cancellation.IsCancellationRequested)
{
var status = await _eclairClient.GetSentInfo(paymentHash, uuid, cancellation);
if (!status.Any())
{
continue;
}
var sentInfo = status.First();
switch (sentInfo.Status.type)
{
case "sent":
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = sentInfo.Amount,
FeeAmount = sentInfo.FeesPaid
});
case "failed":
return new PayResponse(PayResult.CouldNotFindRoute);
case "pending":
await Task.Delay(200, cancellation);
break;
}
}
}
return new PayResponse(PayResult.Unknown);
catch (EclairClient.EclairApiException exception)
{
return new PayResponse(PayResult.Error, exception.Message);
}
return new PayResponse(PayResult.CouldNotFindRoute);
}
public async Task<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest,
@ -433,10 +392,6 @@ namespace BTCPayServer.Lightning.Eclair
return ConnectionResult.Ok;
return ConnectionResult.CouldNotConnect;
}
catch (System.TimeoutException)
{
return ConnectionResult.CouldNotConnect;
}
catch (EclairClient.EclairApiException)
{
return ConnectionResult.CouldNotConnect;
@ -453,10 +408,8 @@ namespace BTCPayServer.Lightning.Eclair
var channels = await _eclairClient.Channels(null, cancellation);
return channels.Select(response =>
{
var outpointStr = response.Data?.Commitments?.CommitInput?.OutPoint?.Replace(":", "-");
OutPoint outPoint = null;
if (outpointStr != null)
OutPoint.TryParse(outpointStr, out outPoint);
OutPoint.TryParse(response.Data.Commitments.CommitInput.OutPoint.Replace(":", "-"),
out var outPoint);
return new LightningChannel
{
@ -469,16 +422,5 @@ namespace BTCPayServer.Lightning.Eclair
};
}).ToArray();
}
public override string ToString()
{
var result= $"type=eclair;server={_address}";
if (_username is { })
result += $";username={_username}";
if (_password is { })
result += $";password={_password}";
return result;
}
}
}

View File

@ -1,53 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using System.Text;
using NBitcoin;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.Eclair.JsonConverters
{
public class EclairBtcJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return typeof(LightMoney).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
}
readonly Type _longType = typeof(long).GetTypeInfo();
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
try
{
return reader.TokenType switch
{
JsonToken.Null => null,
JsonToken.Integer => _longType.IsAssignableFrom(reader.ValueType)
? new LightMoney((long)reader.Value, LightMoneyUnit.BTC)
: new LightMoney(long.MaxValue, LightMoneyUnit.BTC),
// Eclair denominates global balance amounts in BTC, see https://acinq.github.io/eclair/#globalbalance
JsonToken.Float => new LightMoney(Convert.ToDecimal(reader.Value), LightMoneyUnit.BTC),
JsonToken.String =>
// some of the c-lightning values have a trailing "msat" that we need to remove before parsing
new LightMoney(long.Parse(((string)reader.Value).Replace("msat", ""), CultureInfo.InvariantCulture), LightMoneyUnit.BTC),
// Fix for Eclair having empty objects for zero amount cases, see https://acinq.github.io/eclair/#globalbalance
JsonToken.StartObject => JObject.Load(reader) != null ? LightMoney.Zero : null,
_ => null
};
}
catch (InvalidCastException)
{
throw new JsonObjectException("Money amount should be in BTC", reader);
}
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
writer.WriteValue(((LightMoney)value).ToUnit(LightMoneyUnit.Bit));
}
}
}

View File

@ -13,9 +13,12 @@ namespace BTCPayServer.Lightning.Eclair.Models
public string PaymentHash { get; set; }
public string PaymentType { get; set; }
public string RecipientNodeId { get; set; }
public string Preimage { get; set; }
public long AmountMsat { get; set; }
[JsonConverter(typeof(EclairDateTimeJsonConverter))]
public DateTimeOffset CreatedAt { get; set; }
[JsonConverter(typeof(EclairDateTimeJsonConverter))]
public DateTimeOffset CompletedAt { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney RecipientAmount { get; set; }
@ -23,6 +26,9 @@ namespace BTCPayServer.Lightning.Eclair.Models
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Amount { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public long FeesPaid { get; set; }
public PaymentStatus Status { get; set; }
}
}

View File

@ -1,4 +1,3 @@
using BTCPayServer.Lightning.Eclair.JsonConverters;
using BTCPayServer.Lightning.JsonConverters;
using NBitcoin;
using Newtonsoft.Json;
@ -29,19 +28,19 @@ namespace BTCPayServer.Lightning.Eclair.Models
public class GlobalOffchainBalance
{
[JsonProperty("waitForFundingConfirmed")]
[JsonConverter(typeof(EclairBtcJsonConverter))]
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney WaitForFundingConfirmed { get; set; }
[JsonProperty("waitForChannelReady")]
[JsonConverter(typeof(EclairBtcJsonConverter))]
public LightMoney WaitForChannelReady { get; set; }
[JsonProperty("waitForFundingLocked")]
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney WaitForFundingLocked { get; set; }
[JsonProperty("waitForPublishFutureCommitment")]
[JsonConverter(typeof(EclairBtcJsonConverter))]
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney WaitForPublishFutureCommitment { get; set; }
[JsonProperty("negotiating")]
[JsonConverter(typeof(EclairBtcJsonConverter))]
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Negotiating { get; set; }
[JsonProperty("normal")]
@ -73,7 +72,7 @@ namespace BTCPayServer.Lightning.Eclair.Models
public class EclairChannelBalance
{
[JsonProperty("toLocal")]
[JsonConverter(typeof(EclairBtcJsonConverter))]
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney ToLocal { get; set; }
}
}

View File

@ -1,38 +1,7 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Lightning.Eclair.JsonConverters;
using BTCPayServer.Lightning.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Eclair.Models
{
public class PaymentStatus
{
public string Type { get; set; }
public string PaymentPreimage { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney FeesPaid { get; set; }
[JsonConverter(typeof(EclairDateTimeJsonConverter))]
public DateTimeOffset CompletedAt { get; set; }
public List<PaymentRoutes> Route { get; set; }
public List<PaymentFailures> Failures { get; set; }
}
public class PaymentRoutes
{
public string NodeId { get; set; }
public string NextNodeId { get; set; }
public string ShortChannelId { get; set; }
}
public class PaymentFailures
{
public string FailureType { get; set; }
public string FailureMessage { get; set; }
public string FailedNode { get; set; }
public List<PaymentRoutes> FailedRoute { get; set; }
public string type { get; set; }
}
}

View File

@ -4,6 +4,7 @@ namespace BTCPayServer.Lightning.Eclair.Models
{
public string NodeId { get; set; }
public long? AmountMsat { get; set; }
public string PaymentHash { get; set; }
public int? MaxAttempts { get; set; }
public int? MaxFeePct { get; set; }
public long? MaxFeeFlatSat { get; set; }

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "Eclair/v$ver" -m "Eclair/$ver"
git push --tags

View File

@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<Version>1.7.1</Version>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<Version>1.4.9</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.LND</PackageId>
<Description>Client library for LND to build Lightning Network Apps in C#.</Description>
@ -13,15 +13,11 @@
</PropertyGroup>
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj" />
<ItemGroup>
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Threading.Channels" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Lightning.Common\BTCPayServer.Lightning.Common.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>BTCPayServer.Lightning.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@ -1,16 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.ExceptionServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using NBitcoin;
using NBitcoin.Crypto;
using NBitcoin.DataEncoders;
using Newtonsoft.Json.Linq;
@ -28,13 +25,10 @@ namespace BTCPayServer.Lightning.LND
Stream _Body;
StreamReader _Reader;
Task _ListenLoop;
private readonly Action<string> _log;
private const int MaxConsecutiveNullReads = 5;
public LndInvoiceClientSession(LndSwaggerClient parent, Action<string> log)
public LndInvoiceClientSession(LndSwaggerClient parent)
{
_Parent = parent;
_log = log ?? ((_) => { });
}
public Task StartListening()
@ -68,13 +62,11 @@ namespace BTCPayServer.Lightning.LND
_Response = await _Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _Cts.Token);
_Body = await _Response.Content.ReadAsStreamAsync();
_Reader = new StreamReader(_Body);
var consecutiveNullReads = 0;
while (!_Cts.IsCancellationRequested)
{
string line = await WithCancellation(_Reader.ReadLineAsync(), _Cts.Token);
if (line != null)
{
consecutiveNullReads = 0;
if (line.StartsWith("{\"result\":", StringComparison.OrdinalIgnoreCase))
{
var invoiceString = JObject.Parse(line)["result"].ToString();
@ -84,7 +76,7 @@ namespace BTCPayServer.Lightning.LND
else if (line.StartsWith("{\"error\":", StringComparison.OrdinalIgnoreCase))
{
var errorString = JObject.Parse(line)["error"].ToString();
var error = _Parent.Deserialize<LNDError>(errorString);
var error = _Parent.Deserialize<LndError>(errorString);
throw new LndException(error);
}
else
@ -92,13 +84,6 @@ namespace BTCPayServer.Lightning.LND
throw new LndException("Unknown result from LND: " + line);
}
}
else
{
consecutiveNullReads++;
_log($"LND invoice stream returned null (read #{consecutiveNullReads} of {MaxConsecutiveNullReads})");
if (consecutiveNullReads >= MaxConsecutiveNullReads)
break;
}
}
}
catch when (_Cts.IsCancellationRequested)
@ -177,44 +162,12 @@ namespace BTCPayServer.Lightning.LND
Stream _Body;
StreamReader _Reader;
Task _ListenLoop;
private readonly Func<HttpRequestMessage> _requestBuilder;
private readonly Action<string> _log;
private const int MaxConsecutiveNullReads = 5;
private readonly string _PaymentHash;
// Set from the latest streamed payment result (routerrpc failure_reason enum name),
// used by the sender to distinguish a missing route from other failures.
public string LastFailureReason { get; private set; }
// Tracks an existing payment: GET /v2/router/track/{payment_hash} (TrackPaymentV2).
public LndPaymentClientSession(LndSwaggerClient parent, string paymentHash, Action<string> log)
public LndPaymentClientSession(LndSwaggerClient parent, string paymentHash)
{
_Parent = parent;
_log = log ?? ((_) => { });
_requestBuilder = () =>
{
var hash = paymentHash.HexStringToBase64UrlString();
var request = new HttpRequestMessage(HttpMethod.Get, WithTrailingSlash(_Parent.BaseUrl) + $"v2/router/track/{hash}");
_Parent._Authentication.AddAuthentication(request);
return request;
};
}
// Sends a payment: POST /v2/router/send (SendPaymentV2). This replaces the
// lnrpc.SendPaymentSync (POST /v1/channels/transactions) endpoint that was
// removed in LND 0.21.0.
public LndPaymentClientSession(LndSwaggerClient parent, JObject sendRequest, Action<string> log)
{
_Parent = parent;
_log = log ?? ((_) => { });
_requestBuilder = () =>
{
var request = new HttpRequestMessage(HttpMethod.Post, WithTrailingSlash(_Parent.BaseUrl) + "v2/router/send")
{
Content = new StringContent(sendRequest.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json")
};
_Parent._Authentication.AddAuthentication(request);
return request;
};
_PaymentHash = paymentHash;
}
public Task StartListening()
@ -222,7 +175,9 @@ namespace BTCPayServer.Lightning.LND
try
{
_Client = _Parent.CreateHttpClient();
var request = _requestBuilder();
var paymentHash = _PaymentHash.HexStringToBase64UrlString();
var request = new HttpRequestMessage(HttpMethod.Get, WithTrailingSlash(_Parent.BaseUrl) + $"v2/router/track/{paymentHash}");
_Parent._Authentication.AddAuthentication(request);
_ListenLoop = ListenLoop(request);
}
catch
@ -246,24 +201,21 @@ namespace BTCPayServer.Lightning.LND
_Response = await _Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _Cts.Token);
_Body = await _Response.Content.ReadAsStreamAsync();
_Reader = new StreamReader(_Body);
var consecutiveNullReads = 0;
while (!_Cts.IsCancellationRequested)
{
var line = await WithCancellation(_Reader.ReadLineAsync(), _Cts.Token);
if (line != null)
{
consecutiveNullReads = 0;
if (line.StartsWith("{\"result\":", StringComparison.OrdinalIgnoreCase))
{
var resultToken = JObject.Parse(line)["result"];
LastFailureReason = resultToken["failure_reason"]?.ToString();
LnrpcPayment parsed = _Parent.Deserialize<LnrpcPayment>(resultToken.ToString());
var paymentString = JObject.Parse(line)["result"].ToString();
LnrpcPayment parsed = _Parent.Deserialize<LnrpcPayment>(paymentString);
await _Payments.Writer.WriteAsync(ConvertLndPayment(parsed), _Cts.Token);
}
else if (line.StartsWith("{\"error\":", StringComparison.OrdinalIgnoreCase))
{
var errorString = JObject.Parse(line)["error"].ToString();
var error = _Parent.Deserialize<LNDError>(errorString);
var error = _Parent.Deserialize<LndError>(errorString);
throw new LndException(error);
}
else
@ -271,13 +223,6 @@ namespace BTCPayServer.Lightning.LND
throw new LndException("Unknown result from LND: " + line);
}
}
else
{
consecutiveNullReads++;
_log($"LND payment stream returned null (read #{consecutiveNullReads} of {MaxConsecutiveNullReads})");
if (consecutiveNullReads >= MaxConsecutiveNullReads)
break;
}
}
}
catch when (_Cts.IsCancellationRequested)
@ -360,8 +305,6 @@ namespace BTCPayServer.Lightning.LND
}
public Action<string> Log { get; set; }
public Network Network
{
get;
@ -378,14 +321,12 @@ namespace BTCPayServer.Lightning.LND
}
public async Task<LightningInvoice> CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation = default)
{
var strAmount = ConvertInv.ToString(req.Amount.ToUnit(LightMoneyUnit.MilliSatoshi));
var strExpiry = ConvertInv.ToString(Math.Round(req.Expiry.TotalSeconds, 0));
var lndRequest = new LnrpcInvoice
{
// null → field omitted from JSON (NullValueHandling.Ignore) → LND produces amountless bolt11
ValueMSat = req.Amount == LightMoney.Zero
? null
: ConvertInv.ToString(req.Amount.ToUnit(LightMoneyUnit.MilliSatoshi)),
ValueMSat = strAmount,
Memo = req.Description,
Description_hash = req.DescriptionHash?.ToBytes(false),
Expiry = strExpiry,
@ -395,24 +336,21 @@ namespace BTCPayServer.Lightning.LND
var invoice = new LightningInvoice
{
Id = Encoders.Hex.EncodeData(resp.R_hash),
Id = BitString(resp.R_hash),
Amount = req.Amount,
BOLT11 = resp.Payment_request,
Status = LightningInvoiceStatus.Unpaid,
ExpiresAt = DateTimeOffset.UtcNow + req.Expiry,
PaymentHash = new uint256(resp.R_hash, false).ToString()
ExpiresAt = DateTimeOffset.UtcNow + req.Expiry
};
return invoice;
}
public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = default)
{
var h = InvoiceIdToRHash(invoiceId);
if (h is null)
return;
var resp = await SwaggerClient.LookupInvoiceAsync(invoiceId, null, cancellation);
await SwaggerClient.CancelInvoiceAsync(new InvoicesrpcCancelInvoiceMsg
{
Payment_hash = h
Payment_hash = resp.R_hash
}, cancellation);
}
@ -438,21 +376,22 @@ namespace BTCPayServer.Lightning.LND
async Task<LightningNodeInformation> ILightningClient.GetInfo(CancellationToken cancellation)
{
var resp = await SwaggerClient.GetInfoAsync(cancellation);
var nodeInfo = new LightningNodeInformation
{
BlockHeight = (int?)resp.Block_height ?? 0,
Alias = resp.Alias,
Color = resp.Color,
Version = resp.Version,
PeersCount = resp.Num_peers,
ActiveChannelsCount = resp.Num_active_channels,
InactiveChannelsCount = resp.Num_inactive_channels,
PendingChannelsCount = resp.Num_pending_channels
};
try
{
var resp = await SwaggerClient.GetInfoAsync(cancellation);
var nodeInfo = new LightningNodeInformation
{
BlockHeight = (int?)resp.Block_height ?? 0,
Alias = resp.Alias,
Color = resp.Color,
Version = resp.Version,
PeersCount = resp.Num_peers,
ActiveChannelsCount = resp.Num_active_channels,
InactiveChannelsCount = resp.Num_inactive_channels,
PendingChannelsCount = resp.Num_pending_channels
};
if (resp.Uris != null)
{
foreach (var uri in resp.Uris)
@ -463,13 +402,9 @@ namespace BTCPayServer.Lightning.LND
}
return nodeInfo;
}
catch (SwaggerException ex) when (ex.AsLNDError() is {} lndError)
catch (SwaggerException ex) when (!string.IsNullOrEmpty(ex.Response))
{
if (lndError.Code == 2 || lndError.Error.StartsWith("permission denied"))
{
throw new UnauthorizedAccessException(lndError.Error);
}
throw new LndException(lndError.Error);
throw new Exception("LND threw an error: " + ex.Response);
}
}
@ -485,8 +420,9 @@ namespace BTCPayServer.Lightning.LND
var pendingResponse = pendingChannels.Result;
var closing = new LightMoney(0);
closing += pendingResponse.Pending_force_closing_channels.Sum(c => LightMoney.Satoshis(c.Limbo_balance));
closing += pendingResponse.Waiting_close_channels.Sum(c => LightMoney.Satoshis(c.Limbo_balance));
closing += pendingResponse.Pending_closing_channels.Sum(c => c.Channel.Local_balance);
closing += pendingResponse.Pending_force_closing_channels.Sum(c => c.Channel.Local_balance);
closing += pendingResponse.Waiting_close_channels.Sum(c => c.Channel.Local_balance);
var onchain = new OnchainBalance
{
@ -505,11 +441,11 @@ namespace BTCPayServer.Lightning.LND
return new LightningNodeBalance(onchain, offchain);
}
async Task<LightningInvoice> GetInvoice(byte[] invoiceId, CancellationToken cancellation)
async Task<LightningInvoice> ILightningClient.GetInvoice(string invoiceId, CancellationToken cancellation)
{
try
{
var resp = await SwaggerClient.LookupInvoiceAsync(invoiceId, cancellation);
var resp = await SwaggerClient.LookupInvoiceAsync(invoiceId, null, cancellation);
return resp.State?.Equals("CANCELED", StringComparison.InvariantCultureIgnoreCase) is true ? null : ConvertLndInvoice(resp);
}
catch (SwaggerException ex) when
@ -524,31 +460,6 @@ namespace BTCPayServer.Lightning.LND
}
}
async Task<LightningInvoice> ILightningClient.GetInvoice(string invoiceId, CancellationToken cancellation)
{
var h = InvoiceIdToRHash(invoiceId);
if (h is null)
return null;
return await GetInvoice(h, cancellation);
}
byte[] InvoiceIdToRHash(string invoiceId)
{
try
{
var hash = Encoders.Hex.DecodeData(invoiceId);
if (hash.Length != 32)
return null;
return hash;
}
catch { return null; }
}
async Task<LightningInvoice> ILightningClient.GetInvoice(uint256 paymentHash, CancellationToken cancellation)
{
var invoiceId = paymentHash.ToBytes(false);
return await GetInvoice(invoiceId, cancellation);
}
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default)
{
return await ListInvoices(null, cancellation);
@ -569,19 +480,16 @@ namespace BTCPayServer.Lightning.LND
{
try
{
using var session = new LndPaymentClientSession(SwaggerClient, paymentHash, Log);
using var session = new LndPaymentClientSession(SwaggerClient, paymentHash);
await session.StartListening();
var payment = await session.WaitPayment(cancellation);
return payment;
}
catch (LndException ex) when (ex.Error is { Code: 5 } lndError)
catch (SwaggerException ex)
{
return null;
}
catch (LndException ex) when (ex.Error is { Message: "payment isn't initiated" } lndError)
{
return null;
var errorString = JObject.Parse(ex.Response)["error"]["message"].ToString();
throw new LndException(errorString);
}
}
@ -606,7 +514,7 @@ namespace BTCPayServer.Lightning.LND
async Task<ILightningInvoiceListener> ILightningClient.Listen(CancellationToken cancellation)
{
var session = new LndInvoiceClientSession(SwaggerClient, Log);
var session = new LndInvoiceClientSession(SwaggerClient);
await session.StartListening();
return session;
}
@ -615,9 +523,8 @@ namespace BTCPayServer.Lightning.LND
{
var invoice = new LightningInvoice
{
Id = Encoders.Hex.EncodeData(resp.R_hash),
PaymentHash = new uint256(resp.R_hash, false).ToString(),
Preimage = resp.R_preimage != null && resp.R_preimage.Length == 32 ? new uint256(resp.R_preimage, false).ToString() : null,
// TODO: Verify id corresponds to R_hash
Id = BitString(resp.R_hash),
Amount = new LightMoney(ConvertInv.ToInt64(resp.ValueMSat), LightMoneyUnit.MilliSatoshi),
AmountReceived = string.IsNullOrWhiteSpace(resp.AmountPaid) ? null : new LightMoney(ConvertInv.ToInt64(resp.AmountPaid), LightMoneyUnit.MilliSatoshi),
BOLT11 = resp.Payment_request,
@ -628,10 +535,7 @@ namespace BTCPayServer.Lightning.LND
if (resp.Htlcs != null && resp.Htlcs.Any())
{
invoice.CustomRecords = resp.Htlcs
.Where(htlc => htlc.State.ToUpperInvariant() == "SETTLED")
.SelectMany(htlc => htlc.CustomRecords)
.GroupBy(htlc => htlc.Key)
.Select(x => x.First())
.ToDictionary(x => x.Key, y => y.Value);
}
@ -666,7 +570,6 @@ namespace BTCPayServer.Lightning.LND
payment.Status = resp.Status switch
{
"INITIATED" => LightningPaymentStatus.Pending,
"IN_FLIGHT" => LightningPaymentStatus.Pending,
"SUCCEEDED" => LightningPaymentStatus.Complete,
"FAILED" => LightningPaymentStatus.Failed,
@ -677,164 +580,144 @@ namespace BTCPayServer.Lightning.LND
return payment;
}
// utility static methods... maybe move to separate class
private static string BitString(byte[] bytes)
{
return BitConverter.ToString(bytes)
.Replace("-", "")
.ToLower(CultureInfo.InvariantCulture);
}
private async Task<PayResponse> PayAsync(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation)
{
// Pay the invoice - cancel after timeout, potentially caused by hold invoices
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var timeout = payParams?.SendTimeout ?? PayInvoiceParams.DefaultSendTimeout;
cts.CancelAfter(timeout);
var retryCount = 0;
retry:
var retryCount = 0;
try
{
var sendRequest = BuildRouterSendRequest(bolt11, payParams, timeout);
using var session = new LndPaymentClientSession(SwaggerClient, sendRequest, Log);
await session.StartListening();
var payment = await session.WaitPayment(cts.Token);
switch (payment?.Status)
var req = !string.IsNullOrEmpty(bolt11)
// regular payment request
? new LnrpcSendRequest
{
Payment_request = bolt11
}
// keysend payment
: new LnrpcSendRequest
{
Dest = Encoders.Base64.EncodeData(payParams.Destination.ToBytes()),
Payment_hash = Encoders.Base64.EncodeData(payParams.PaymentHash.ToBytes()),
Dest_custom_records = payParams.CustomRecords
};
if (payParams?.MaxFeePercent > 0)
{
case LightningPaymentStatus.Complete:
req.Fee_limit ??= new LnrpcFeeLimit();
if (payParams.MaxFeePercent.Value < 1.0) // doesn't support sub 1% fee, so we calculate ourself
{
var satValue = BOLT11PaymentRequest.Parse(bolt11, Network).MinimumAmount.ToDecimal(LightMoneyUnit.Satoshi);
req.Fee_limit.Fixed = (long)((satValue * (decimal)payParams.MaxFeePercent.Value) / 100m);
}
else
req.Fee_limit.Percent = ((int)Math.Round(payParams.MaxFeePercent.Value));
}
if (payParams?.MaxFeeFlat?.Satoshi > 0)
{
req.Fee_limit ??= new LnrpcFeeLimit();
req.Fee_limit.Fixed = payParams.MaxFeeFlat.Satoshi;
}
if (payParams?.Amount?.MilliSatoshi > 0)
{
req.AmtMsat = payParams.Amount.MilliSatoshi.ToString();
}
var response = await SwaggerClient.SendPaymentSyncAsync(req, cts.Token);
if (string.IsNullOrEmpty(response.Payment_error) && response.Payment_preimage != null)
{
if (response.Payment_route != null)
{
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = payment.AmountSent,
FeeAmount = payment.Fee,
PaymentHash = string.IsNullOrEmpty(payment.PaymentHash) ? null : new uint256(payment.PaymentHash),
Preimage = string.IsNullOrEmpty(payment.Preimage) ? null : new uint256(payment.Preimage),
Status = LightningPaymentStatus.Complete
TotalAmount = new LightMoney(response.Payment_route.Total_amt_msat),
FeeAmount = new LightMoney(response.Payment_route.Total_fees_msat)
});
case LightningPaymentStatus.Failed:
return session.LastFailureReason switch
{
"FAILURE_REASON_NO_ROUTE" => new PayResponse(PayResult.CouldNotFindRoute, session.LastFailureReason),
"FAILURE_REASON_INSUFFICIENT_BALANCE" => new PayResponse(PayResult.CouldNotFindRoute, session.LastFailureReason),
null or "" or "FAILURE_REASON_NONE" => new PayResponse(PayResult.Error, "The payment failed"),
_ => new PayResponse(PayResult.Error, session.LastFailureReason)
};
}
return new PayResponse(PayResult.Ok);
}
switch (response.Payment_error)
{
case "invoice is already paid":
return new PayResponse(PayResult.Ok);
case "insufficient local balance":
case "unable to find a path to destination":
// code in 0.10.0+
case "insufficient_balance":
case "no_route":
return new PayResponse(PayResult.CouldNotFindRoute, response.Payment_error);
case "payment is in transition":
return new PayResponse(PayResult.Unknown, response.Payment_error);
default:
return new PayResponse(PayResult.Unknown);
return new PayResponse(PayResult.Error, response.Payment_error);
}
}
catch (LndException ex)
catch (SwaggerException ex) when (ex.AsLNDError() is {} lndError)
{
var message = ex.Message ?? string.Empty;
if (message.IndexOf("already paid", StringComparison.OrdinalIgnoreCase) >= 0)
return new PayResponse(PayResult.Ok);
if (message.IndexOf("still syncing", StringComparison.OrdinalIgnoreCase) >= 0)
if (lndError.Error.StartsWith("chain backend is still syncing"))
{
if (retryCount++ > 3)
return new PayResponse(PayResult.Error, message);
return new PayResponse(PayResult.Error, lndError.Error);
await Task.Delay(1000, cancellation);
goto retry;
}
if (message.IndexOf("self-payment", StringComparison.OrdinalIgnoreCase) >= 0)
return new PayResponse(PayResult.CouldNotFindRoute, message);
if (message.IndexOf("in transition", StringComparison.OrdinalIgnoreCase) >= 0 ||
message.IndexOf("in flight", StringComparison.OrdinalIgnoreCase) >= 0)
return new PayResponse(PayResult.Unknown, message);
if (lndError.Error.StartsWith("self-payments not allowed"))
{
return new PayResponse(PayResult.CouldNotFindRoute, lndError.Error);
}
if (lndError.Error.StartsWith("payment is in transition"))
{
return new PayResponse(PayResult.Error, lndError.Error);
}
throw;
throw new LndException(lndError.Error);
}
catch (Exception ex) when (cts.Token.IsCancellationRequested)
{
// The send stream was cancelled (our send timeout, e.g. a hold invoice that
// never settles). The payment may still be in-flight, so resolve its real state.
if (bolt11 != null)
{
var pr = BOLT11PaymentRequest.Parse(bolt11, Network);
var paymentHash = pr.PaymentHash?.ToString();
var response = await GetPayment(paymentHash, cancellation);
switch (response?.Status)
switch (response.Status)
{
case null:
case LightningPaymentStatus.Unknown:
case LightningPaymentStatus.Pending:
return new PayResponse(PayResult.Unknown, ex.Message);
case LightningPaymentStatus.Failed:
return new PayResponse(PayResult.Error, ex.Message);
case LightningPaymentStatus.Complete:
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = response.AmountSent,
FeeAmount = response.Fee,
PaymentHash = new uint256(response.PaymentHash),
Preimage = new uint256(response.Preimage),
Status = LightningPaymentStatus.Complete
FeeAmount = response.Fee
});
default:
throw new ArgumentOutOfRangeException();
}
}
}
return new PayResponse(PayResult.Unknown);
}
// Builds the routerrpc.SendPaymentRequest body (JSON) for POST /v2/router/send.
private JObject BuildRouterSendRequest(string bolt11, PayInvoiceParams payParams, TimeSpan timeout)
{
var req = new JObject();
// routerrpc.SendPaymentV2 rejects amt/amt_msat when the invoice already encodes a
// non-zero amount, so we may only set amt_msat for amountless invoices (or keysend).
var amountAlreadyOnInvoice = false;
LightMoney invoiceAmount = null;
if (!string.IsNullOrEmpty(bolt11))
{
req["payment_request"] = bolt11;
invoiceAmount = BOLT11PaymentRequest.Parse(bolt11, Network).MinimumAmount;
amountAlreadyOnInvoice = invoiceAmount > LightMoney.Zero;
}
else
{
// keysend payment
req["dest"] = Encoders.Base64.EncodeData(payParams.Destination.ToBytes());
req["payment_hash"] = Encoders.Base64.EncodeData(payParams.PaymentHash.ToBytes());
if (payParams.CustomRecords is { Count: > 0 })
{
var records = new JObject();
foreach (var rec in payParams.CustomRecords)
records[rec.Key.ToString(CultureInfo.InvariantCulture)] = rec.Value;
req["dest_custom_records"] = records;
}
}
// routerrpc.SendPaymentV2 requires a payment attempt timeout; align it with the
// client side send timeout so lnd and BTCPay give up at roughly the same time.
req["timeout_seconds"] = Math.Max(1, (int)Math.Round(timeout.TotalSeconds));
// routerrpc only supports an absolute fee limit (no percentage), so convert.
long? feeLimitSat = null;
if (payParams?.MaxFeePercent > 0)
{
var amount = payParams.Amount ?? invoiceAmount;
feeLimitSat = (long)(amount.ToDecimal(LightMoneyUnit.Satoshi) * (decimal)payParams.MaxFeePercent.Value / 100m);
}
if (payParams?.MaxFeeFlat?.Satoshi > 0)
feeLimitSat = payParams.MaxFeeFlat.Satoshi;
if (feeLimitSat is null)
{
// Preserve SendPaymentSync's default fee policy: 100% for payments up to
// 1,000 sats, 5% for larger payments.
var amount = payParams?.Amount ?? invoiceAmount;
if (amount is not null)
{
var sats = amount.ToDecimal(LightMoneyUnit.Satoshi);
feeLimitSat = (long)(sats <= 1000m ? sats : sats * 0.05m);
}
}
if (feeLimitSat is not null)
req["fee_limit_sat"] = feeLimitSat.Value.ToString(CultureInfo.InvariantCulture);
if (payParams?.Amount?.MilliSatoshi > 0 && !amountAlreadyOnInvoice)
req["amt_msat"] = payParams.Amount.MilliSatoshi.ToString(CultureInfo.InvariantCulture);
// We only need the terminal result, so suppress intermediate in-flight updates.
req["no_inflight_updates"] = true;
return req;
}
async Task<PayResponse> ILightningClient.Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation)
{
return await PayAsync(bolt11, payParams, cancellation);
@ -963,37 +846,5 @@ retry:
return d.ToString(CultureInfo.InvariantCulture);
}
}
public override string ToString()
{
var builder = new StringBuilder();
builder.Append($"type=lnd-rest;server={SwaggerClient._LndSettings.Uri}");
if (SwaggerClient._LndSettings.Macaroon != null)
{
builder.Append($";macaroon={ConvertHelper.ToHexString(SwaggerClient._LndSettings.Macaroon)}");
}
if (SwaggerClient._LndSettings.MacaroonFilePath != null)
{
builder.Append($";macaroonfilepath={SwaggerClient._LndSettings.MacaroonFilePath}");
}
if (SwaggerClient._LndSettings.MacaroonDirectoryPath != null)
{
builder.Append($";macaroondirectorypath={SwaggerClient._LndSettings.MacaroonDirectoryPath}");
}
if (SwaggerClient._LndSettings.CertificateThumbprint != null)
{
builder.Append($";certthumbprint={ConvertHelper.ToHexString(SwaggerClient._LndSettings.CertificateThumbprint)}");
}
if (SwaggerClient._LndSettings.CertificateFilePath != null)
{
builder.Append($";certfilepath={SwaggerClient._LndSettings.CertificateFilePath}");
}
if (SwaggerClient._LndSettings.AllowInsecure)
{
builder.Append($";allowinsecure=true");
}
return builder.ToString();
}
}
}

View File

@ -1,151 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using NBitcoin;
namespace BTCPayServer.Lightning.LND;
public class LndConnectionStringHandler : ILightningConnectionStringHandler
{
private readonly HttpClient _httpClient;
public LndConnectionStringHandler(HttpClient httpClient = null)
{
_httpClient = httpClient;
}
public ILightningClient Create(string connectionString, Network network, out string error)
{
var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type);
if (type != "lnd-rest" && type != "lnd-grpc")
{
error = null;
return null;
}
if (!kv.TryGetValue("server", out var server))
{
error = $"The key 'server' is mandatory for lnd connection strings";
return null;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri)
|| uri.Scheme != "http" && uri.Scheme != "https")
{
error = "The key 'server' should be an URI starting by http:// or https://";
return null;
}
byte[] macaroonData = null;
string username = null;
string password = null;
byte[] certificateThumbprint = null;
var parts = uri.UserInfo.Split(':');
if (!string.IsNullOrEmpty(uri.UserInfo) && parts.Length == 2)
{
username = parts[0];
password = parts[1];
}
// uri = new UriBuilder(uri) {UserName = "", Password = ""}.Uri;
if (kv.TryGetValue("macaroon", out var macaroon))
{
try
{
macaroonData = ConvertHelper.FromHexString(macaroon);
}
catch
{
error = $"The key 'macaroon' format should be in hex";
return null;
}
}
kv.TryGetValue("macaroondirectorypath", out var macaroonDirectoryPath);
if (kv.TryGetValue("macaroonfilepath", out var macaroonFilePath))
{
if (macaroon != null)
{
error = $"The key 'macaroon' is already specified";
return null;
}
if (!macaroonFilePath.EndsWith(".macaroon", StringComparison.OrdinalIgnoreCase))
{
error = $"The key 'macaroonfilepath' should point to a .macaroon file";
return null;
}
}
string securitySet = null;
if (kv.TryGetValue("certthumbprint", out var certthumbprint))
{
try
{
var bytes = ConvertHelper.FromHexString(certthumbprint.Replace(":", string.Empty));
if (bytes.Length != 32)
{
error =
$"The key 'certthumbprint' has invalid length: it should be the SHA256 of the PEM format of the certificate (32 bytes)";
return null;
}
certificateThumbprint = bytes;
}
catch
{
error =
$"The key 'certthumbprint' has invalid format: it should be the SHA256 of the PEM format of the certificate";
return null;
}
securitySet = "certthumbprint";
}
if (kv.TryGetValue("certfilepath", out var certificateFilePath))
{
if (securitySet != null)
{
error = $"The key 'certfilepath' conflict with '{securitySet}'";
return null;
}
securitySet = "certfilepath";
}
bool allowInsecure = false;
if (kv.TryGetValue("allowinsecure", out var allowinsecureStr))
{
var allowedValues = new[] {"true", "false"};
if (!allowedValues.Any(v => v.Equals(allowinsecureStr, StringComparison.OrdinalIgnoreCase)))
{
error = "The key 'allowinsecure' should be true or false";
return null;
}
allowInsecure = allowinsecureStr.Equals("true", StringComparison.OrdinalIgnoreCase);
}
if (!LightningConnectionStringHelper.VerifySecureEndpoint(uri, allowInsecure))
{
error = "The key 'allowinsecure' is false, but server's Uri is not using https";
return null;
}
error = null;
return new LndClient(new LndSwaggerClient(new LndRestSettings(uri)
{
Macaroon = macaroonData,
MacaroonFilePath = macaroonFilePath,
MacaroonDirectoryPath = macaroonDirectoryPath,
CertificateThumbprint = certificateThumbprint,
CertificateFilePath = certificateFilePath,
AllowInsecure = allowInsecure,
}, _httpClient), network);
}
}

View File

@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BTCPayServer.Lightning.LND
{
public class LNDError
class LNDError
{
public string Error { get; set; }
public string Message { get; set; }

View File

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@ -25,19 +24,13 @@ namespace BTCPayServer.Lightning.LND
public byte[] Macaroon { get; set; }
public bool AllowInsecure { get; set; }
public string MacaroonFilePath { get; set; }
public string MacaroonDirectoryPath { get; set; }
public LndAuthentication CreateLndAuthentication()
{
if (Macaroon != null)
return new LndAuthentication.FixedMacaroonAuthentication(Macaroon);
if (!string.IsNullOrEmpty(MacaroonFilePath))
{
return !string.IsNullOrEmpty(MacaroonDirectoryPath)
? new LndAuthentication.MacaroonFileAuthentication(Path.Combine(MacaroonDirectoryPath, MacaroonFilePath))
: new LndAuthentication.MacaroonFileAuthentication(MacaroonFilePath);
}
return new LndAuthentication.MacaroonFileAuthentication(MacaroonFilePath);
return LndAuthentication.NullAuthentication.Instance;
}
}

View File

@ -10,7 +10,6 @@ using Newtonsoft.Json.Linq;
using System.Diagnostics;
using NBitcoin;
using NBitcoin.DataEncoders;
using System.Text;
namespace BTCPayServer.Lightning.LND
{
@ -1566,9 +1565,9 @@ namespace BTCPayServer.Lightning.LND
/// returned.</summary>
/// <param name="r_hash">/ The payment hash of the invoice to be looked up.</param>
/// <exception cref="SwaggerException">A server side error occurred.</exception>
public System.Threading.Tasks.Task<LnrpcInvoice> LookupInvoiceAsync(byte[] r_hash)
public System.Threading.Tasks.Task<LnrpcInvoice> LookupInvoiceAsync(string r_hash_str, byte[] r_hash)
{
return LookupInvoiceAsync(r_hash, System.Threading.CancellationToken.None);
return LookupInvoiceAsync(r_hash_str, r_hash, System.Threading.CancellationToken.None);
}
/// <summary>* lncli: `lookupinvoice`
@ -1578,14 +1577,15 @@ namespace BTCPayServer.Lightning.LND
/// <param name="r_hash">/ The payment hash of the invoice to be looked up.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <exception cref="SwaggerException">A server side error occurred.</exception>
public async System.Threading.Tasks.Task<LnrpcInvoice> LookupInvoiceAsync(byte[] r_hash, System.Threading.CancellationToken cancellationToken)
public async System.Threading.Tasks.Task<LnrpcInvoice> LookupInvoiceAsync(string r_hash_str, byte[] r_hash, System.Threading.CancellationToken cancellationToken)
{
if (r_hash is null)
throw new ArgumentNullException(nameof(r_hash));
if (r_hash_str == null)
throw new System.ArgumentNullException("r_hash_str");
var urlBuilder_ = new System.Text.StringBuilder();
urlBuilder_.Append(BaseUrl).Append($"/v1/invoice/?");
var b64 = Convert.ToBase64String(r_hash);
urlBuilder_.Append("r_hash=").Append(b64.Replace("+", "-").Replace("/", "_")).Append("&");
urlBuilder_.Append(BaseUrl).Append("/v1/invoice/{r_hash_str}?");
urlBuilder_.Replace("{r_hash_str}", System.Uri.EscapeDataString(System.Convert.ToString(r_hash_str, System.Globalization.CultureInfo.InvariantCulture)));
if (r_hash != null) urlBuilder_.Append("r_hash=").Append(System.Uri.EscapeDataString(System.Convert.ToString(r_hash, System.Globalization.CultureInfo.InvariantCulture))).Append("&");
urlBuilder_.Length--;
var client_ = _httpClient;
@ -2849,7 +2849,7 @@ namespace BTCPayServer.Lightning.LND
{
private PendingChannelsResponsePendingChannel _channel;
private string _closing_txid;
private long _limbo_balance;
private string _limbo_balance;
private long? _maturity_height;
private int? _blocks_til_maturity;
private string _recovered_balance;
@ -2884,7 +2884,7 @@ namespace BTCPayServer.Lightning.LND
}
[Newtonsoft.Json.JsonProperty("limbo_balance", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public long Limbo_balance
public string Limbo_balance
{
get { return _limbo_balance; }
set
@ -3188,7 +3188,7 @@ namespace BTCPayServer.Lightning.LND
public partial class PendingChannelsResponseWaitingCloseChannel : System.ComponentModel.INotifyPropertyChanged
{
private PendingChannelsResponsePendingChannel _channel;
private long _limbo_balance;
private string _limbo_balance;
[Newtonsoft.Json.JsonProperty("channel", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public PendingChannelsResponsePendingChannel Channel
@ -3205,7 +3205,7 @@ namespace BTCPayServer.Lightning.LND
}
[Newtonsoft.Json.JsonProperty("limbo_balance", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public long Limbo_balance
public string Limbo_balance
{
get { return _limbo_balance; }
set
@ -8906,7 +8906,6 @@ namespace BTCPayServer.Lightning.LND
public partial class LnrpcSendResponse : System.ComponentModel.INotifyPropertyChanged
{
private string _payment_error;
private byte[] _payment_hash;
private byte[] _payment_preimage;
private LnrpcRoute _payment_route;
@ -8938,20 +8937,6 @@ namespace BTCPayServer.Lightning.LND
}
}
[Newtonsoft.Json.JsonProperty("payment_hash", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public byte[] Payment_hash
{
get { return _payment_hash; }
set
{
if (_payment_hash != value)
{
_payment_hash = value;
RaisePropertyChanged();
}
}
}
[Newtonsoft.Json.JsonProperty("payment_route", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public LnrpcRoute Payment_route
{

View File

@ -23,7 +23,7 @@ namespace BTCPayServer.Lightning.LND
{
}
public LndException(LNDError error) : base(error.Message)
public LndException(LndError error) : base(error.Message)
{
if (error == null)
throw new ArgumentNullException(nameof(error));
@ -31,8 +31,8 @@ namespace BTCPayServer.Lightning.LND
}
private readonly LNDError _Error;
public LNDError Error
private readonly LndError _Error;
public LndError Error
{
get
{
@ -40,6 +40,18 @@ namespace BTCPayServer.Lightning.LND
}
}
}
// {"grpc_code":2,"http_code":500,"message":"rpc error: code = Unknown desc = expected 1 macaroon, got 0","http_status":"Internal Server Error"}
public class LndError
{
[JsonProperty("grpc_code")]
public int GRPCCode { get; set; }
[JsonProperty("http_code")]
public int HttpCode { get; set; }
[JsonProperty("message")]
public string Message { get; set; }
[JsonProperty("http_status")]
public string HttpStatus { get; set; }
}
public partial class LndSwaggerClient
{
internal HttpClient _DefaultHttpClient;
@ -63,7 +75,7 @@ namespace BTCPayServer.Lightning.LND
return json;
});
}
internal LndRestSettings _LndSettings;
LndRestSettings _LndSettings;
internal LndAuthentication _Authentication;
partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url)

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "LND/v$ver" -m "LND/$ver"
git push --tags

View File

@ -1,88 +0,0 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace BTCPayServer.Lightning.LndHub;
///from https://stackoverflow.com/a/31194647/275504
public sealed class AsyncDuplicateLock
{
private sealed class RefCounted<T>
{
public RefCounted(T value)
{
RefCount = 1;
Value = value;
}
public int RefCount { get; set; }
public T Value { get; private set; }
}
private readonly ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> _semaphoreSlims = new();
private SemaphoreSlim GetOrCreate(object key)
{
RefCounted<SemaphoreSlim>? item;
lock (_semaphoreSlims)
{
if (_semaphoreSlims.TryGetValue(key, out item) && item is { })
{
++item.RefCount;
}
else
{
item = new RefCounted<SemaphoreSlim>(new SemaphoreSlim(1, 1));
_semaphoreSlims[key] = item;
}
}
return item.Value;
}
// get a lock for a specific key, and wait until it is available
public async Task<IDisposable> LockAsync(object key, CancellationToken cancellationToken = default)
{
await GetOrCreate(key).WaitAsync(cancellationToken).ConfigureAwait(false);
return new Releaser(_semaphoreSlims, key);
}
// get a lock for a specific key if it is available, or return null if it is currently locked
public async Task<IDisposable?> LockOrBustAsync(object key, CancellationToken cancellationToken = default)
{
var semaphore = GetOrCreate(key);
if (semaphore.CurrentCount == 0)
return null;
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new Releaser(_semaphoreSlims, key);
}
private sealed class Releaser : IDisposable
{
private readonly ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> _semaphoreSlims;
public Releaser(ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> semaphoreSlims, object key)
{
_semaphoreSlims = semaphoreSlims;
Key = key;
}
private object Key { get; set; }
public void Dispose()
{
RefCounted<SemaphoreSlim>? item;
lock (_semaphoreSlims)
{
if (_semaphoreSlims.TryGetValue(Key, out item) && item is { })
{
--item.RefCount;
if (item.RefCount == 0)
_semaphoreSlims.TryRemove(Key, out _);
}
}
item?.Value.Release();
}
}
}

View File

@ -1,8 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<Version>1.7.1</Version>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<Version>1.0.12</Version>
<PackageId>BTCPayServer.Lightning.LNDhub</PackageId>
<Description>Client library for BlueWallet LNDhub to build Lightning Network Apps in C#.</Description>
<PackageProjectUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</PackageProjectUrl>
@ -12,17 +11,13 @@
<LangVersion>10</LangVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj" />
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj"/>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Lightning.Common\BTCPayServer.Lightning.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Condition="'$(TargetFramework)' == 'netstandard2.0'" Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Threading.Channels" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>BTCPayServer.Lightning.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@ -14,18 +14,12 @@ namespace BTCPayServer.Lightning.LNDhub.JsonConverters
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
switch (reader.TokenType)
{
case JsonToken.String:
return uint256.Parse((string)reader.Value);
case JsonToken.StartObject:
var obj = JObject.Load(reader);
return obj["type"]?.Value<string>() == "Buffer" && obj["data"] != null
? new uint256(BitString(obj["data"].ToObject<byte[]>()))
: null;
default:
return null;
};
if (reader.TokenType != JsonToken.StartObject) return null;
var obj = JObject.Load(reader);
return obj["type"]?.Value<string>() == "Buffer" && obj["data"] != null
? new uint256(BitString(obj["data"].ToObject<byte[]>()))
: null;
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)

View File

@ -37,12 +37,7 @@ namespace BTCPayServer.Lightning.LNDhub.JsonConverters
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value != null)
{
// LNDhub: "All amounts are satoshis (int)"
// https://github.com/BlueWallet/LndHub/blob/master/doc/Send-requirements.md
var sats = ((LightMoney)value).ToUnit(LightMoneyUnit.Satoshi);
writer.WriteValue((int)Math.Round(sats));
}
writer.WriteValue(((LightMoney)value).ToUnit(LightMoneyUnit.Satoshi));
else
writer.WriteNull();
}

View File

@ -1,6 +1,7 @@
using System;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.LNDhub.JsonConverters
{

View File

@ -1,17 +1,13 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.LNDhub.JsonConverters;
using BTCPayServer.Lightning.LNDhub.Models;
using NBitcoin;
using NBitcoin.Crypto;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -28,11 +24,12 @@ namespace BTCPayServer.Lightning.LndHub
private readonly HttpClient _httpClient;
private readonly string _login;
private readonly string _password;
private readonly JsonSerializer _serializer;
private readonly Network _network;
private static readonly HttpClient _sharedClient = new ();
private static readonly ConcurrentDictionary<string, AuthResponse> _cache = new();
public readonly string CacheKey;
private static readonly AsyncDuplicateLock _locker = new();
private static readonly HttpClient _sharedClient = new HttpClient();
private string AccessToken { get; set; }
private string RefreshToken { get; set; }
public LndHubClient(Uri baseUri, string login, string password, Network network, HttpClient httpClient)
{
@ -42,7 +39,10 @@ namespace BTCPayServer.Lightning.LndHub
_baseUri = baseUri;
_httpClient = httpClient ?? _sharedClient;
CacheKey = ConvertHelper.ToHexString(Hashes.SHA256(Encoding.UTF8.GetBytes(_baseUri+ _login + _password)));
// JSON
var serializerSettings = new JsonSerializerSettings();
Serializer.RegisterFrontConverters(serializerSettings, network);
_serializer = JsonSerializer.Create(serializerSettings);
}
public async Task<CreateAccountResponse> CreateAccount(CancellationToken cancellation)
@ -100,7 +100,7 @@ namespace BTCPayServer.Lightning.LndHub
};
if (payParams?.Amount != null)
payload.Amount = payParams.Amount;
payload.Amount = (long)payParams.Amount.ToUnit(LightMoneyUnit.Satoshi);
return await Post<PayInvoiceRequest, PaymentResponse>("payinvoice", payload, cancellation);
}
@ -131,7 +131,7 @@ namespace BTCPayServer.Lightning.LndHub
var req = new HttpRequestMessage
{
RequestUri = new Uri($"{WithTrailingSlash(_baseUri.ToString())}{path}"),
RequestUri = new Uri($"{_baseUri}{path}"),
Method = method,
Content = content
};
@ -139,9 +139,13 @@ namespace BTCPayServer.Lightning.LndHub
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.Add("User-Agent", "BTCPayServer.Lightning.LndHubClient");
if (!path.StartsWith("auth") && path != "create")
if (path != "auth" && path != "create")
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessToken(cancellation));
if (string.IsNullOrEmpty(AccessToken))
{
await Authorize(cancellation);
}
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
}
var res = await _httpClient.SendAsync(req, cancellation);
@ -151,8 +155,9 @@ namespace BTCPayServer.Lightning.LndHub
{
var exception = new LndHubApiException(str);
if (!exception.AuthenticationFailed || isAuthRetry) throw exception;
await ClearAccessToken();
// unset auth tokens and retry
AccessToken = RefreshToken = null;
return await Send<TRequest, TResponse>(method, path, payload, true, cancellation);
}
@ -170,24 +175,6 @@ namespace BTCPayServer.Lightning.LndHub
PaymentError = "",
Decoded = JsonConvert.DeserializeObject<PaymentData>(str)
};
if (resp.PaymentRoute?.Fee is null && resp.AdditionalProperties?.TryGetValue("fee", out var weirdlyPlaceFeeProp) is true)
{
var fee = weirdlyPlaceFeeProp.Type switch
{
JTokenType.Integer => new LightMoney(weirdlyPlaceFeeProp.Value<long>(),
LightMoneyUnit.Satoshi),
JTokenType.Float => new LightMoney((long)weirdlyPlaceFeeProp.Value<double>(),
LightMoneyUnit.Satoshi),
JTokenType.String => LightMoney.Satoshis(long.Parse(weirdlyPlaceFeeProp.Value<string>())),
_ => null
};
if (fee != null)
{
resp.PaymentRoute ??= new PaymentRoute();
resp.PaymentRoute.Fee = fee;
}
}
return (TResponse)Convert.ChangeType(resp, typeof(TResponse));
}
@ -196,76 +183,30 @@ namespace BTCPayServer.Lightning.LndHub
public async Task<ILightningInvoiceListener> CreateInvoiceSession(CancellationToken cancellation = default)
{
var at = await GetAccessToken(cancellation);
return at is null ? null : new LndHubInvoiceListener(this, cancellation);
}
private async Task ClearAccessToken()
{
using var release = await _locker.LockAsync(CacheKey);
_cache.TryRemove(CacheKey, out _);
}
private async Task<string> GetAccessToken(CancellationToken cancellation = default)
{
using var release = await _locker.LockAsync(CacheKey, cancellation);
AuthResponse response;
if (_cache.TryGetValue(CacheKey, out var cached))
if (await Authorize(cancellation))
{
if (cached.Expiry <= DateTimeOffset.UtcNow)
{
_cache.TryRemove(CacheKey, out _);
}
else if (cached.Expiry - DateTimeOffset.UtcNow > TimeSpan.FromMinutes(5))
{
return cached.AccessToken;
}
response = await Post<AuthRequest, AuthResponse>("auth?type=refresh_token",
new AuthRequest {RefreshToken = cached.RefreshToken}, cancellation);
}
else
{
response = await Post<AuthRequest, AuthResponse>("auth?type=auth",
new AuthRequest {Login = _login, Password = _password}, cancellation);
var streamUrl = WithTrailingSlash(_baseUri.ToString()) + "invoices/stream";
var session = new LndHubInvoiceListener(this);
await session.StartListening(streamUrl, AccessToken, cancellation);
return session;
}
if (response.Expiry is null)
{
try
{
response.Expiry = DateTimeOffset.FromUnixTimeSeconds(
long.Parse(ParseClaimsFromJwt(response.AccessToken).First(claim => claim.Type == "exp").Value));
}
catch (Exception)
{
//it's ok if we dont parse it, once auth fails we try again
}
}
_cache.AddOrReplace(CacheKey, response);
return response.AccessToken;
}
private static IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JObject.Parse(Encoding.UTF8.GetString(jsonBytes)).ToObject<Dictionary<string, object>>();
return keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()));
return null;
}
private static byte[] ParseBase64WithoutPadding(string base64)
private async Task<bool> Authorize(CancellationToken cancellation = default)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
var payload = new AuthRequest { Login = _login, Password = _password };
var response = await Post<AuthRequest, AuthResponse>("auth", payload, cancellation);
AccessToken = response.AccessToken;
RefreshToken = response.RefreshToken;
return !string.IsNullOrEmpty(AccessToken);
}
private static string WithTrailingSlash(string str) =>
str.EndsWith("/", StringComparison.InvariantCulture) ? str : str + "/";
str.EndsWith("/", StringComparison.InvariantCulture) ? str :str + "/";
private class EmptyRequestModel
{

View File

@ -1,95 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using BTCPayServer.Lightning.LndHub;
using NBitcoin;
namespace BTCPayServer.Lightning.LNDhub;
public class LndHubConnectionStringHandler : ILightningConnectionStringHandler
{
private readonly HttpClient _httpClient;
public LndHubConnectionStringHandler(HttpClient httpClient = null)
{
_httpClient = httpClient;
}
private static bool TryParseLNDhub(string str, out string transformedConnectionString, out string error)
{
var parts = str.Replace("lndhub://", "").Split('@');
if (parts.Length != 2 || !Uri.TryCreate(parts[1].Replace("://", $"://{parts[0]}@"), UriKind.Absolute, out var uri))
{
transformedConnectionString = null;
error = "Invalid LNDhub URI";
return false;
}
// transform into connection string format
transformedConnectionString = $"type=lndhub;server={uri.AbsoluteUri}" + (uri.Scheme == "http" ? ";allowinsecure=true" : "");
error = null;
return true;
}
public ILightningClient Create(string connectionString, Network network, out string error)
{
if(connectionString.StartsWith("lndhub://", StringComparison.OrdinalIgnoreCase))
{
return !TryParseLNDhub(connectionString, out connectionString, out error) ? null : Create(connectionString, network, out error);
}
var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type);
if (type != "lndhub")
{
error = null;
return null;
}
if (!kv.TryGetValue("server", out var server))
{
error = $"The key 'server' is mandatory for lndhub connection strings";
return null;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var uri) || uri.Scheme != "http" && uri.Scheme != "https")
{
error = "The key 'server' should be an URI starting by http:// or https://";
return null;
}
bool allowInsecure = false;
if (kv.TryGetValue("allowinsecure", out var allowinsecureStr))
{
var allowedValues = new[] {"true", "false"};
if (!allowedValues.Any(v => v.Equals(allowinsecureStr, StringComparison.OrdinalIgnoreCase)))
{
error = "The key 'allowinsecure' should be true or false";
return null;
}
allowInsecure = allowinsecureStr.Equals("true", StringComparison.OrdinalIgnoreCase);
}
if (!LightningConnectionStringHelper.VerifySecureEndpoint(uri, allowInsecure))
{
error = "The key 'allowinsecure' is false, but server's Uri is not using https";
return null;
}
var parts = uri.UserInfo.Split(':');
string username = null;
string password = null;
if (!string.IsNullOrEmpty(uri.UserInfo) && parts.Length == 2)
{
username = parts[0];
password = parts[1];
}
else
{
kv.TryGetValue("username", out username);
kv.TryGetValue("password", out password);
}
error = null;
return new LndHubLightningClient(uri, username, password, network, _httpClient);
}
}

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
@ -8,15 +7,16 @@ using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using BTCPayServer.Lightning.LNDhub.Models;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.LndHub
{
public class LndHubInvoiceListener : ILightningInvoiceListener
{
private readonly LndHubClient _client;
private readonly Channel<LightningInvoice> _invoices = Channel.CreateUnbounded<LightningInvoice>();
private readonly CancellationTokenSource _cts;
private readonly Channel<LightningInvoice> _invoices = Channel.CreateBounded<LightningInvoice>(50);
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private HttpClient _httpClient;
private HttpResponseMessage _response;
private Stream _body;
@ -24,12 +24,37 @@ namespace BTCPayServer.Lightning.LndHub
private Task _listenLoop;
private readonly List<string> _paidInvoiceIds;
public LndHubInvoiceListener(LndHubClient lndHubClient, CancellationToken cancellation)
public LndHubInvoiceListener(LndHubClient lndHubClient)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
_client = lndHubClient;
_paidInvoiceIds = new List<string>();
_listenLoop = ListenLoop();
}
public Task StartListening(string streamUrl, string accessToken, CancellationToken cancellation = default)
{
try
{
_listenLoop = ListenLoop();
// FIXME: This websocket based version would work with LNDhub.go, see:
// https://ln.getalby.com/swagger/index.html#/Invoice/get_invoices_stream
/*
_httpClient = new HttpClient();
_httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite);
var req = new HttpRequestMessage(HttpMethod.Get, streamUrl);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
req.Headers.Add("User-Agent", "BTCPayServer.Lightning.LndHubClient");
_listenLoop = ListenLoop(req, cancellation);
*/
}
catch
{
Dispose();
}
return Task.CompletedTask;
}
public async Task<LightningInvoice> WaitInvoice(CancellationToken cancellation)
@ -54,39 +79,26 @@ namespace BTCPayServer.Lightning.LndHub
Dispose(true);
}
static readonly AsyncDuplicateLock _locker = new();
static readonly ConcurrentDictionary<string, InvoiceData[]> _activeListeners = new();
private async Task ListenLoop()
{
try
{
var releaser = await _locker.LockOrBustAsync(_client.CacheKey, _cts.Token);
if (releaser is null)
retry:
while (!_cts.IsCancellationRequested)
{
while (!_cts.IsCancellationRequested &&releaser is null)
var invoicesData = await _client.GetInvoices(_cts.Token);
foreach (var data in invoicesData)
{
if (_activeListeners.TryGetValue(_client.CacheKey, out var invoicesData))
var invoice = LndHubUtil.ToLightningInvoice(data);
if (invoice.PaidAt != null && !_paidInvoiceIds.Contains(invoice.Id))
{
await HandleInvoicesData(invoicesData);
await _invoices.Writer.WriteAsync(invoice, _cts.Token);
_paidInvoiceIds.Add(invoice.Id);
}
releaser = await _locker.LockOrBustAsync(_client.CacheKey, _cts.Token);
if(releaser is null)
await Task.Delay(2500, _cts.Token);
}
}
using (releaser)
{
while (!_cts.IsCancellationRequested)
{
var invoicesData = await _client.GetInvoices(_cts.Token);
_activeListeners.AddOrReplace(_client.CacheKey, invoicesData);
await HandleInvoicesData(invoicesData);
await Task.Delay(2500, _cts.Token);
}
await Task.Delay(2500, _cts.Token);
goto retry;
}
}
catch when (_cts.IsCancellationRequested)
@ -98,27 +110,69 @@ namespace BTCPayServer.Lightning.LndHub
}
finally
{
_activeListeners.TryRemove(_client.CacheKey, out _);
Dispose(false);
}
}
private async Task HandleInvoicesData(IEnumerable<InvoiceData> invoicesData)
// FIXME: This websocket based version would work with LNDhub.go, see:
// https://ln.getalby.com/swagger/index.html#/Invoice/get_invoices_stream
/*
private async Task ListenLoop(HttpRequestMessage request, CancellationToken cancellation = default)
{
foreach (var data in invoicesData)
try
{
var invoice = LndHubUtil.ToLightningInvoice(data);
if (invoice.PaidAt != null && !_paidInvoiceIds.Contains(invoice.Id))
_response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation);
_body = await _response.Content.ReadAsStreamAsync();
_reader = new StreamReader(_body);
while(!cancellation.IsCancellationRequested)
{
await _invoices.Writer.WriteAsync(invoice, _cts.Token);
_paidInvoiceIds.Add(invoice.Id);
var line = await WithCancellation(_reader.ReadLineAsync(), cancellation);
if (line == null) continue;
if (line.StartsWith("{\"result\":", StringComparison.OrdinalIgnoreCase))
{
var invoiceString = JObject.Parse(line)["invoice"].ToString();
var data = JsonConvert.DeserializeObject<InvoiceData>(invoiceString);
var invoice = LndHubUtil.ToLightningInvoice(data);
await _invoices.Writer.WriteAsync(invoice, cancellation);
}
else if (line.StartsWith("{\"error\":", StringComparison.OrdinalIgnoreCase))
{
throw new LndHubClient.LndHubApiException(line);
}
else
{
throw new LndHubClient.LndHubApiException("Unknown result from LNDHub: " + line);
}
}
}
catch when(cancellation.IsCancellationRequested)
{
}
catch(Exception ex)
{
_invoices.Writer.TryComplete(ex);
}
finally
{
Dispose(false);
}
}
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken cancellationToken)
{
using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var waiting = Task.Delay(-1, delayCts.Token);
await Task.WhenAny(waiting, task);
delayCts.Cancel();
cancellationToken.ThrowIfCancellationRequested();
return await task;
}
*/
private void Dispose(bool waitLoop)
{
if (_cts.IsCancellationRequested)
if(_cts.IsCancellationRequested)
return;
_cts.Cancel();
_reader?.Dispose();

View File

@ -10,31 +10,23 @@ namespace BTCPayServer.Lightning.LndHub
{
public class LndHubLightningClient : ILightningClient
{
public readonly LndHubClient Client;
internal readonly Uri _baseUri;
internal readonly string _login;
internal readonly string _password;
private readonly LndHubClient _client;
private readonly Network _network;
public LndHubLightningClient(Uri baseUri, string login, string password, Network network, HttpClient httpClient = null )
public LndHubLightningClient(Uri baseUri, string login, string password, Network network, HttpClient httpClient = null)
{
_baseUri = baseUri;
_login = login;
_password = password;
_network = network;
Client = new LndHubClient(baseUri, login, password, network, httpClient);
_client = new LndHubClient(baseUri, login, password, network, httpClient);
}
public async Task<CreateAccountResponse> CreateAccount(CancellationToken cancellation = default)
{
return await Client.CreateAccount(cancellation);
return await _client.CreateAccount(cancellation);
}
public async Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = default)
{
var data = await Client.GetInfo(cancellation);
if (data == null)
throw new NotSupportedException("The LNDHub instance does not support GetInfo");
var data = await _client.GetInfo(cancellation);
var nodeInfo = new LightningNodeInformation
{
@ -47,13 +39,10 @@ namespace BTCPayServer.Lightning.LndHub
InactiveChannelsCount = data.InactiveChannelsCount,
PendingChannelsCount = data.PendingChannelsCount
};
if (data.Uris != null)
foreach (var nodeUri in data.Uris)
{
foreach (var nodeUri in data.Uris)
{
if (NodeInfo.TryParse(nodeUri, out var info))
nodeInfo.NodeInfoList.Add(info);
}
if (NodeInfo.TryParse(nodeUri, out var info))
nodeInfo.NodeInfoList.Add(info);
}
return nodeInfo;
@ -61,7 +50,7 @@ namespace BTCPayServer.Lightning.LndHub
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation = default)
{
var balance = await Client.GetBalance(cancellation);
var balance = await _client.GetBalance(cancellation);
var offchain = new OffchainBalance
{
Local = balance.BTC.AvailableBalance
@ -71,29 +60,24 @@ namespace BTCPayServer.Lightning.LndHub
public async Task<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = default)
{
return await Client.GetDepositAddress(cancellation);
return await _client.GetDepositAddress(cancellation);
}
public async Task<LightningInvoice> GetInvoice(string invoiceId, CancellationToken cancellation = default)
{
var invoices = await Client.GetInvoices(cancellation);
var invoices = await _client.GetInvoices(cancellation);
var data = invoices.FirstOrDefault(i => i.Id.ToString() == invoiceId);
return data == null ? null : LndHubUtil.ToLightningInvoice(data);
}
public async Task<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation = default)
public Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default)
{
return await GetInvoice(paymentHash.ToString(), cancellation);
}
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default)
{
return await ListInvoices(null, cancellation);
return ListInvoices(null, cancellation);
}
public async Task<LightningInvoice[]> ListInvoices(ListInvoicesParams request, CancellationToken cancellation = default)
{
var invoices = await Client.GetInvoices(cancellation);
var invoices = await _client.GetInvoices(cancellation);
if (request != null)
{
// we need to filter client-side, because LNDhub does not support these filters
@ -106,8 +90,8 @@ namespace BTCPayServer.Lightning.LndHub
public async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation = default)
{
var payments = await Client.GetTransactions(cancellation);
var data = payments.FirstOrDefault(i => i.PaymentHash?.ToString() == paymentHash);
var payments = await _client.GetTransactions(cancellation);
var data = payments.FirstOrDefault(i => i.PaymentHash.ToString() == paymentHash);
return data == null ? null : LndHubUtil.ToLightningPayment(data);
}
@ -118,7 +102,7 @@ namespace BTCPayServer.Lightning.LndHub
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
{
var payments = await Client.GetTransactions(cancellation);
var payments = await _client.GetTransactions(cancellation);
if (request != null)
{
// we need to filter client-side, because LNDhub does not support these filters
@ -136,10 +120,10 @@ namespace BTCPayServer.Lightning.LndHub
public async Task<LightningInvoice> CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation = default)
{
var invoice = await Client.CreateInvoice(req, cancellation);
var invoice = await _client.CreateInvoice(req, cancellation);
// the response to addinvoice is incomplete, fetch the invoice to return that data
return await GetInvoice(invoice.Id, cancellation);
return await GetInvoice(invoice.Id.ToString(), cancellation);
}
public async Task<PayResponse> Pay(string bolt11, CancellationToken cancellation = default)
@ -153,18 +137,14 @@ namespace BTCPayServer.Lightning.LndHub
{
var pr = BOLT11PaymentRequest.Parse(bolt11, _network);
var payAmount = payParams?.Amount ?? pr.MinimumAmount;
var response = await Client.Pay(bolt11, payParams, cancellation);
var response = await _client.Pay(bolt11, payParams, cancellation);
var totalAmount = response.Decoded?.Amount;
var feeAmount = response.PaymentRoute?.FeeMsat ??
(totalAmount is null ? null : totalAmount - payAmount);
var feeAmount = response.PaymentRoute?.FeeMsat ?? totalAmount - payAmount;
return new PayResponse(PayResult.Ok, new PayDetails
{
TotalAmount = totalAmount,
FeeAmount = feeAmount,
Preimage = response.PaymentPreimage,
PaymentHash = response.PaymentHash ?? pr.PaymentHash,
Status = LightningPaymentStatus.Complete
FeeAmount = feeAmount
});
}
catch (LndHubClient.LndHubApiException exception)
@ -182,7 +162,7 @@ namespace BTCPayServer.Lightning.LndHub
async Task<ILightningInvoiceListener> ILightningClient.Listen(CancellationToken cancellation)
{
return await Client.CreateInvoiceSession(cancellation);
return await _client.CreateInvoiceSession(cancellation);
}
public Task<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = default)
@ -204,17 +184,5 @@ namespace BTCPayServer.Lightning.LndHub
{
throw new NotSupportedException();
}
public override string ToString()
{
var builder = new UriBuilder(_baseUri)
{
UserName = "",
Password = ""
};
builder.UserName = _login;
builder.Password = _password;
return $"type=lndhub;server={builder.Uri.AbsoluteUri}{(builder.Scheme != "https" ? ";allowinsecure=true" : "")}";
}
}
}

View File

@ -1,5 +1,6 @@
using System;
using BTCPayServer.Lightning.LNDhub.Models;
using NBitcoin;
namespace BTCPayServer.Lightning.LndHub
{
@ -18,8 +19,7 @@ namespace BTCPayServer.Lightning.LndHub
Status = status,
ExpiresAt = expiresAt.GetValueOrDefault(),
Amount = data.Amount,
AmountReceived = data.IsPaid ? data.Amount : null,
PaymentHash = data.PaymentHash
AmountReceived = data.IsPaid ? data.Amount : null
};
if (data.IsPaid)

View File

@ -1,4 +1,3 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNDhub.Models
@ -10,8 +9,5 @@ namespace BTCPayServer.Lightning.LNDhub.Models
[JsonProperty("refresh_token")]
public string RefreshToken { get; set; }
[JsonProperty("expiry")]
public DateTimeOffset? Expiry { get; set; }
}
}

View File

@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Lightning.JsonConverters;
using BTCPayServer.Lightning.LNDhub.JsonConverters;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.LNDhub.Models
{
@ -28,11 +28,6 @@ namespace BTCPayServer.Lightning.LNDhub.Models
[JsonProperty("decoded")]
public PaymentData Decoded { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalProperties { get; set; }
}
public class PaymentRoute

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "LNDhub/v$ver" -m "LNDhub/$ver"
git push --tags

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
<Version>1.3.18</Version>
<PackageId>BTCPayServer.Lightning.LNBank</PackageId>
<Description>Client library for LNBank to build Lightning Network Apps in C#.</Description>
<PackageProjectUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</PackageProjectUrl>
<RepositoryUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>lightning;bitcoin;lnbank;lapps</PackageTags>
<LangVersion>10</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Lightning.Common\BTCPayServer.Lightning.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="3.1.10" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.LNbank.Models;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank
{
public class LNbankClient
{
private readonly string _apiToken;
private readonly Uri _baseUri;
private readonly HttpClient _httpClient;
private readonly Network _network;
private static readonly HttpClient _sharedClient = new HttpClient();
public LNbankClient(Uri baseUri, string apiToken, Network network, HttpClient httpClient)
{
_baseUri = baseUri;
_apiToken = apiToken;
_network = network;
_httpClient = httpClient ?? _sharedClient;
}
public async Task<NodeInfoData> GetInfo(CancellationToken cancellation)
{
return await Get<NodeInfoData>("info", cancellation);
}
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation)
{
return await Get<LightningNodeBalance>("balance", cancellation);
}
public async Task<InvoiceData> GetInvoice(string invoiceId, CancellationToken cancellation)
{
return await Get<InvoiceData>($"invoice/{invoiceId}", cancellation);
}
public async Task<InvoiceData[]> ListInvoices(ListInvoicesParams param, CancellationToken cancellation)
{
var path = new StringBuilder("invoices");
if (param != null)
{
var query = new List<string>();
if (param is { PendingOnly: true }) query.Add("pending_only=true");
if (param.OffsetIndex.HasValue) query.Add($"offset_index={param.OffsetIndex.Value}");
path.Append($"?{string.Join("&", query)}");
}
return await Get<InvoiceData[]>(path.ToString(), cancellation);
}
public async Task<PaymentData> GetPayment(string paymentHash, CancellationToken cancellation)
{
return await Get<PaymentData>($"payment/{paymentHash}", cancellation);
}
public async Task<PaymentData[]> ListPayments(ListPaymentsParams param, CancellationToken cancellation)
{
var path = new StringBuilder("payments");
if (param != null)
{
var query = new List<string>();
if (param is { IncludePending: true }) query.Add("include_pending=true");
if (param.OffsetIndex.HasValue) query.Add($"offset_index={param.OffsetIndex.Value}");
path.Append($"?{string.Join("&", query)}");
}
return await Get<PaymentData[]>(path.ToString(), cancellation);
}
public async Task CancelInvoice(string invoiceId, CancellationToken cancellation)
{
await Send<EmptyRequestModel, EmptyRequestModel>(HttpMethod.Delete, $"invoice/{invoiceId}", new EmptyRequestModel(), cancellation);
}
public async Task<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = default)
{
var address = await Post<EmptyRequestModel, string>("deposit-address", null, cancellation);
return BitcoinAddress.Create(address, _network);
}
public async Task<ChannelData[]> ListChannels(CancellationToken cancellation)
{
return await Get<ChannelData[]>("channels", cancellation);
}
public async Task<InvoiceData> CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation)
{
var payload = new CreateInvoiceRequest
{
Amount = req.Amount,
Description = req.Description,
DescriptionHash = req.DescriptionHash,
Expiry = req.Expiry,
PrivateRouteHints = req.PrivateRouteHints
};
return await Post<CreateInvoiceRequest, InvoiceData>("invoice", payload, cancellation);
}
public async Task<PayResponse> Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation)
{
var payload = new PayInvoiceRequest
{
PaymentRequest = bolt11,
MaxFeePercent = payParams?.MaxFeePercent,
MaxFeeFlat = payParams?.MaxFeeFlat?.Satoshi
};
return await Post<PayInvoiceRequest, PayResponse>("pay", payload, cancellation);
}
public async Task<OpenChannelResponse> OpenChannel(NodeInfo nodeUri, Money amount, FeeRate feeRate, CancellationToken cancellation)
{
var payload = new CreateChannelRequest
{
NodeURI = nodeUri.ToString(),
ChannelAmount = amount,
FeeRate = feeRate
};
return await Post<CreateChannelRequest, OpenChannelResponse>("channels", payload, cancellation);
}
public async Task ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = default)
{
var payload = new ConnectNodeRequest
{
NodeURI = nodeInfo.ToString()
};
await Post<ConnectNodeRequest, string>("connect", payload, cancellation);
}
private async Task<TResponse> Get<TResponse>(string path, CancellationToken cancellation)
{
return await Send<EmptyRequestModel, TResponse>(HttpMethod.Get, path, null, cancellation);
}
private async Task<TResponse> Post<TRequest, TResponse>(string path, TRequest payload, CancellationToken cancellation)
{
return await Send<TRequest, TResponse>(HttpMethod.Post, path, payload, cancellation);
}
private async Task<TResponse> Send<TRequest, TResponse>(HttpMethod method, string path, TRequest payload, CancellationToken cancellation)
{
HttpContent content = null;
if (payload != null)
{
var payloadJson = JsonConvert.SerializeObject(payload);
content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
}
var req = new HttpRequestMessage
{
RequestUri = new Uri($"{_baseUri}plugins/lnbank/api/lightning/{path}"),
Method = method,
Content = content
};
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiToken);
req.Headers.Add("User-Agent", "BTCPayServer.Lightning.LNbankClient");
var res = await _httpClient.SendAsync(req, cancellation);
var str = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
{
if (res.StatusCode.Equals(422))
{
var validationErrors = JsonConvert.DeserializeObject<GreenfieldValidationErrorData[]>(str);
var message = string.Join(", ", validationErrors.Select(ve => $"{ve.Path}: {ve.Message}"));
var err = new GreenfieldApiErrorData("validation-failed", message);
throw new LNbankApiException(err);
} else {
var err = JsonConvert.DeserializeObject<GreenfieldApiErrorData>(str);
throw new LNbankApiException(err);
}
}
if (typeof(TResponse) == typeof(EmptyRequestModel))
{
return (TResponse)(object)new EmptyRequestModel();
}
var data = JsonConvert.DeserializeObject<TResponse>(str);
return data;
}
private class EmptyRequestModel
{
}
internal class LNbankApiException : Exception
{
private readonly GreenfieldApiErrorData _error;
public override string Message => _error?.Message;
public string ErrorCode => _error?.Code;
public LNbankApiException(GreenfieldApiErrorData error)
{
_error = error;
}
}
}
}

View File

@ -0,0 +1,70 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.LNbank.Models;
using Microsoft.AspNetCore.SignalR.Client;
namespace BTCPayServer.Lightning.LNbank
{
public class LNbankHubClient : ILightningInvoiceListener
{
private readonly LNbankLightningClient _lightningClient;
private readonly HubConnection _connection;
private readonly CancellationToken _cancellationToken;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
public LNbankHubClient(Uri baseUri, string apiToken, LNbankLightningClient lightningClient, CancellationToken cancellation)
{
_lightningClient = lightningClient;
_cancellationToken = cancellation;
_connection = new HubConnectionBuilder()
.WithUrl($"{baseUri.AbsoluteUri}plugins/lnbank/hubs/transaction", options =>
{
options.AccessTokenProvider = () => Task.FromResult(apiToken);
})
.WithAutomaticReconnect()
.Build();
}
public async Task Start(CancellationToken cancellation)
{
await _connection.StartAsync(cancellation);
}
public async Task<LightningInvoice> WaitInvoice(CancellationToken cancellation)
{
try
{
LightningInvoice invoice;
var tcs = new TaskCompletionSource<LightningInvoice>(cancellation);
_connection.On<TransactionUpdateEvent>("transaction-update", async data =>
{
invoice = await _lightningClient.GetInvoice(data.InvoiceId, cancellation);
if (invoice != null)
tcs.SetResult(invoice);
});
return await tcs.Task;
}
catch (Exception) when (_cts.IsCancellationRequested)
{
throw new OperationCanceledException(_cts.Token);
}
}
public async void Dispose()
{
await DisposeAsync();
}
private async Task DisposeAsync()
{
await _connection.StopAsync(_cancellationToken);
await _connection.DisposeAsync();
_cts.Cancel();
}
}
}

View File

@ -0,0 +1,264 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.LNbank.Models;
using NBitcoin;
namespace BTCPayServer.Lightning.LNbank
{
public class LNbankLightningClient : ILightningClient
{
private readonly LNbankClient _client;
private readonly Uri _baseUri;
private readonly string _apiToken;
public LNbankLightningClient(Uri baseUri, string apiToken, Network network, HttpClient httpClient = null)
{
_baseUri = baseUri;
_apiToken = apiToken;
_client = new LNbankClient(baseUri, apiToken, network, httpClient);
}
public async Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = default)
{
var data = await _client.GetInfo(cancellation);
var nodeInfo = new LightningNodeInformation
{
BlockHeight = data.BlockHeight,
Alias = data.Alias,
Color = data.Color,
Version = data.Version,
PeersCount = data.PeersCount,
ActiveChannelsCount = data.ActiveChannelsCount,
InactiveChannelsCount = data.InactiveChannelsCount,
PendingChannelsCount = data.PendingChannelsCount
};
foreach (var nodeUri in data.NodeURIs)
{
if (NodeInfo.TryParse(nodeUri, out var info))
nodeInfo.NodeInfoList.Add(info);
}
return nodeInfo;
}
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation = default)
{
try
{
return await _client.GetBalance(cancellation);
}
catch (LNbankClient.LNbankApiException)
{
return null;
}
}
public async Task<LightningInvoice> GetInvoice(string invoiceId, CancellationToken cancellation = default)
{
try
{
var invoice = await _client.GetInvoice(invoiceId, cancellation);
return ToLightningInvoice(invoice);
}
catch (LNbankClient.LNbankApiException)
{
return null;
}
}
public Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default)
{
return ListInvoices(null, cancellation);
}
public async Task<LightningInvoice[]> ListInvoices(ListInvoicesParams request, CancellationToken cancellation = default)
{
var invoices = await _client.ListInvoices(request, cancellation);
return invoices.Select(ToLightningInvoice).ToArray();
}
public async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation = default)
{
try
{
var payment = await _client.GetPayment(paymentHash, cancellation);
return ToLightningPayment(payment);
}
catch (LNbankClient.LNbankApiException)
{
return null;
}
}
public Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default)
{
return ListPayments(null, cancellation);
}
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
{
var payments = await _client.ListPayments(request, cancellation);
return payments.Select(ToLightningPayment).ToArray();
}
public async Task<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = default)
{
return await _client.GetDepositAddress(cancellation);
}
public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = default)
{
await _client.CancelInvoice(invoiceId, cancellation);
}
public async Task<LightningChannel[]> ListChannels(CancellationToken cancellation = default)
{
var channels = await _client.ListChannels(cancellation);
return channels.Select(channel => new LightningChannel
{
IsPublic = channel.IsPublic,
IsActive = channel.IsActive,
RemoteNode = new PubKey(channel.RemoteNode),
LocalBalance = channel.LocalBalance,
Capacity = channel.Capacity,
ChannelPoint = OutPoint.Parse(channel.ChannelPoint),
}).ToArray();
}
public async Task<LightningInvoice> CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation = default)
{
return await (this as ILightningClient).CreateInvoice(new CreateInvoiceParams(amount, description, expiry), cancellation);
}
public async Task<LightningInvoice> CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation = default)
{
var invoice = await _client.CreateInvoice(req, cancellation);
return new LightningInvoice
{
Id = invoice.Id,
Amount = invoice.Amount,
PaidAt = invoice.PaidAt,
ExpiresAt = invoice.ExpiresAt,
BOLT11 = invoice.BOLT11,
Status = invoice.Status,
AmountReceived = invoice.AmountReceived
};
}
public async Task<PayResponse> Pay(string bolt11, CancellationToken cancellation = default)
{
return await Pay(bolt11, null, cancellation);
}
public async Task<PayResponse> Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = default)
{
try
{
return await _client.Pay(bolt11, payParams, cancellation);
}
catch (LNbankClient.LNbankApiException exception)
{
switch (exception.ErrorCode)
{
case "could-not-find-route":
return new PayResponse(PayResult.CouldNotFindRoute, exception.Message);
default:
return new PayResponse(PayResult.Error, exception.Message);
}
}
}
public Task<PayResponse> Pay(PayInvoiceParams payParams, CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public async Task<OpenChannelResponse> OpenChannel(OpenChannelRequest req, CancellationToken cancellation = default)
{
OpenChannelResult result;
try
{
await _client.OpenChannel(req.NodeInfo, req.ChannelAmount, req.FeeRate, cancellation);
result = OpenChannelResult.Ok;
}
catch (LNbankClient.LNbankApiException ex)
{
switch (ex.ErrorCode)
{
case "channel-already-exists":
result = OpenChannelResult.AlreadyExists;
break;
case "cannot-afford-funding":
result = OpenChannelResult.CannotAffordFunding;
break;
case "need-more-confirmations":
result = OpenChannelResult.NeedMoreConf;
break;
case "peer-not-connected":
result = OpenChannelResult.PeerNotConnected;
break;
default:
throw new NotSupportedException("Unknown OpenChannelResult");
}
}
return new OpenChannelResponse(result);
}
public async Task<ConnectionResult> ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = default)
{
try
{
await _client.ConnectTo(nodeInfo, cancellation);
return ConnectionResult.Ok;
}
catch (LNbankClient.LNbankApiException ex)
{
switch (ex.ErrorCode)
{
case "could-not-connect":
return ConnectionResult.CouldNotConnect;
default:
throw new NotSupportedException("Unknown ConnectionResult");
}
}
}
public async Task<ILightningInvoiceListener> Listen(CancellationToken cancellation = default)
{
var listener = new LNbankHubClient(_baseUri, _apiToken, this, cancellation);
await listener.Start(cancellation);
return listener;
}
private static LightningInvoice ToLightningInvoice(InvoiceData invoice) => new()
{
Id = invoice.Id,
Amount = invoice.Amount,
PaidAt = invoice.PaidAt,
ExpiresAt = invoice.ExpiresAt,
BOLT11 = invoice.BOLT11,
Status = invoice.Status,
AmountReceived = invoice.AmountReceived
};
private static LightningPayment ToLightningPayment(PaymentData payment) => new()
{
Id = payment.Id,
Amount = payment.TotalAmount != null && payment.FeeAmount != null ? payment.TotalAmount - payment.FeeAmount : null,
AmountSent = payment.TotalAmount,
CreatedAt = payment.CreatedAt,
BOLT11 = payment.BOLT11,
Preimage = payment.Preimage,
PaymentHash = payment.PaymentHash,
Status = payment.Status
};
}
}

View File

@ -0,0 +1,22 @@
using BTCPayServer.Lightning.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class ChannelData
{
public string RemoteNode { get; set; }
public bool IsPublic { get; set; }
public bool IsActive { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Capacity { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney LocalBalance { get; set; }
public string ChannelPoint { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class ConnectNodeRequest
{
[JsonProperty("nodeURI")]
public string NodeURI { get; set; }
}
}

View File

@ -0,0 +1,18 @@
using NBitcoin;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class CreateChannelRequest
{
[JsonProperty("nodeURI")]
public string NodeURI { get; set; }
[JsonConverter(typeof(MoneyJsonConverter))]
public Money ChannelAmount { get; set; }
[JsonConverter(typeof(FeeRateJsonConverter))]
public FeeRate FeeRate { get; set; }
}
}

View File

@ -0,0 +1,22 @@
using System;
using BTCPayServer.Lightning.JsonConverters;
using NBitcoin;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class CreateInvoiceRequest
{
public string Description { get; set; }
[JsonConverter(typeof(UInt256JsonConverter))]
public uint256 DescriptionHash { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Amount { get; set; }
public TimeSpan Expiry { get; set; }
public bool PrivateRouteHints { get; set; }
}
}

View File

@ -0,0 +1,24 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class GreenfieldApiErrorData
{
public GreenfieldApiErrorData()
{
}
public GreenfieldApiErrorData(string code, string message)
{
Code = code ?? throw new ArgumentNullException(nameof(code));
Message = message ?? throw new ArgumentNullException(nameof(message));
}
[JsonProperty("message")]
public string Message { get; set; }
[JsonProperty("code")]
public string Code { get; set; }
}
}

View File

@ -0,0 +1,24 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class GreenfieldValidationErrorData
{
public GreenfieldValidationErrorData()
{
}
public GreenfieldValidationErrorData(string path, string message)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Message = message ?? throw new ArgumentNullException(nameof(message));
}
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("message")]
public string Message { get; set; }
}
}

View File

@ -0,0 +1,30 @@
using System;
using BTCPayServer.Lightning.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class InvoiceData
{
public string Id { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public LightningInvoiceStatus Status { get; set; }
[JsonProperty("BOLT11")]
public string BOLT11 { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? PaidAt { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset ExpiresAt { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Amount { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney AmountReceived { get; set; }
}
}

View File

@ -0,0 +1,33 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.LNbank.Models;
public class NodeInfoData
{
public int BlockHeight { get; set; }
[JsonProperty("nodeURIs")]
public List<string> NodeURIs { get; set; }
[JsonProperty(PropertyName = "alias", NullValueHandling = NullValueHandling.Ignore)]
public string Alias { get; set; }
[JsonProperty(PropertyName = "color", NullValueHandling = NullValueHandling.Ignore)]
public string Color { get; set; }
[JsonProperty(PropertyName = "version", NullValueHandling = NullValueHandling.Ignore)]
public string Version { get; set; }
[JsonProperty(PropertyName = "peersCount", NullValueHandling = NullValueHandling.Ignore)]
public int PeersCount { get; set; }
[JsonProperty(PropertyName = "inactiveChannelsCount", NullValueHandling = NullValueHandling.Ignore)]
public int InactiveChannelsCount { get; set; }
[JsonProperty(PropertyName = "pendingChannelsCount", NullValueHandling = NullValueHandling.Ignore)]
public int PendingChannelsCount { get; set; }
[JsonProperty(PropertyName = "activeChannelsCount", NullValueHandling = NullValueHandling.Ignore)]
public int ActiveChannelsCount { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace BTCPayServer.Lightning.LNbank.Models
{
public class PayInvoiceRequest
{
public string PaymentRequest { get; set; }
public double? MaxFeePercent { get; set; }
public long? MaxFeeFlat { get; set; }
}
}

View File

@ -0,0 +1,29 @@
using System;
using BTCPayServer.Lightning.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Lightning.LNbank.Models
{
public class PaymentData
{
public string Id { get; set; }
public string Preimage { get; set; }
public string PaymentHash { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public LightningPaymentStatus Status { get; set; }
[JsonProperty("BOLT11")]
public string BOLT11 { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? CreatedAt { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney FeeAmount { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney TotalAmount { get; set; }
}
}

View File

@ -0,0 +1,12 @@
namespace BTCPayServer.Lightning.LNbank.Models
{
public class TransactionUpdateEvent
{
public string TransactionId { get; set; }
public string InvoiceId { get; set; }
public string Status { get; set; }
public string Event { get; set; }
public bool IsPaid { get; set; }
public bool IsExpired { get; set; }
}
}

View File

@ -0,0 +1,7 @@
Remove-Item "bin\release\" -Recurse -Force
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
$package=(ls .\bin\Release\*.nupkg).FullName
dotnet nuget push $package --source "https://api.nuget.org/v3/index.json"
$ver = ((Get-ChildItem .\bin\release\*.nupkg)[0].Name -replace '[^\d]*\.(\d+(\.\d+){1,4}).*', '$1')
git tag -a "LNBank/v$ver" -m "LNBank/$ver"
git push --tags

View File

@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Common.csproj"></Import>
<PropertyGroup>
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
<Version>1.7.1</Version>
<LangVersion>10</LangVersion>
<PackageId>BTCPayServer.Lightning.Phoenixd</PackageId>
<Description>Client library for Phoenixd to build Lightning Network Apps in C#.</Description>
<PackageProjectUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</PackageProjectUrl>
<RepositoryUrl>https://github.com/btcpayserver/BTCPayServer.Lightning</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>lightning;bitcoin;eclair;lapps</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Lightning.Common\BTCPayServer.Lightning.Common.csproj" />
</ItemGroup>
</Project>

View File

@ -1,2 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/CSharpLanguageProject/LanguageLevel/@EntryValue">Experimental</s:String></wpf:ResourceDictionary>

View File

@ -1,49 +0,0 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.Phoenixd.JsonConverters
{
public class PhoenixdDateTimeJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return typeof(DateTime).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()) ||
typeof(DateTimeOffset).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()) ||
typeof(DateTimeOffset?).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
DateTimeOffset result;
if (reader.TokenType == JsonToken.StartObject)
{
result = Utils.UnixTimeToDateTime(JObject.Load(reader)["unix"].Value<long>());
}
else
result = Utils.UnixTimeToDateTime((ulong)(long)reader.Value / 1000UL);
if (objectType == typeof(DateTime))
return result.UtcDateTime;
return result;
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
DateTime time;
if (value is DateTime)
time = (DateTime)value;
else
time = ((DateTimeOffset)value).UtcDateTime;
if (time < Utils.UnixTimeToDateTime(0))
time = Utils.UnixTimeToDateTime(0).UtcDateTime;
writer.WriteValue(Utils.DateTimeToUnixTime(time) * 1000UL);
}
}
}

View File

@ -1,19 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class CreateInvoiceRequest
{
[JsonProperty("description")]
public string Description { get; set; }
[JsonProperty("descriptionHash")]
public string DescriptionHash { get; set; }
[JsonProperty("amountSat")]
public long? AmountSat { get; set; }
[JsonProperty("expirySeconds")]
public int? ExpirySeconds { get; set; }
}
}

View File

@ -1,16 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class CreateInvoiceResponse
{
[JsonProperty("amountSat")]
public long AmountSat { get; set; }
[JsonProperty("paymentHash")]
public string PaymentHash { get; set; }
[JsonProperty("serialized")]
public string Serialized { get; set; }
}
}

View File

@ -1,13 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class GetBalanceResponse
{
[JsonProperty("balanceSat")]
public long balanceSat { get; set; }
[JsonProperty("feeCreditSat")]
public long feeCreditSat { get; set; }
}
}

View File

@ -1,53 +0,0 @@
using System;
using BTCPayServer.Lightning.Phoenixd.JsonConverters;
using BTCPayServer.Lightning.JsonConverters;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class GetIncomingPaymentResponse
{
[JsonProperty("paymentHash")]
public string PaymentHash { get; set; }
[JsonProperty("preimage")]
public string PreImage { get; set; }
[JsonProperty("externalId")]
public string ExternalId { get; set; }
[JsonProperty("description")]
public string Description { get; set; }
[JsonProperty("invoice")]
public string Invoice { get; set; }
[JsonProperty("isPaid")]
public bool IsPaid { get; set; }
[JsonProperty("isExpired")]
public bool IsExpired { get; set; }
[JsonProperty("requestedSat")]
public long? RequestedSat { get; set; }
[JsonProperty("receivedSat")]
public long ReceivedSat { get; set; }
[JsonProperty("fees")]
public long Fees { get; set; }
[JsonProperty("completedAt")]
[JsonConverter(typeof(PhoenixdDateTimeJsonConverter))]
public DateTimeOffset? CompletedAt { get; set; }
[JsonProperty("expiresAt")]
[JsonConverter(typeof(PhoenixdDateTimeJsonConverter))]
public DateTimeOffset? ExpiresAt { get; set; }
[JsonProperty("createdAt")]
[JsonConverter(typeof(PhoenixdDateTimeJsonConverter))]
public DateTimeOffset? CreatedAt { get; set; }
}
}

View File

@ -1,27 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models;
public class GetInfoResponse
{
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("channels")]
public GetInfoChannel[] Channels { get; set; }
[JsonProperty("chain")]
public string Chain { get; set; }
[JsonProperty("blockHeight")]
public int BlockHeight { get; set; }
[JsonProperty("version")]
public string Version { get; set; }
}
public class GetInfoChannel
{
[JsonProperty("state")]
public string State { get; set; }
}

View File

@ -1,40 +0,0 @@
using System;
using BTCPayServer.Lightning.Phoenixd.JsonConverters;
using BTCPayServer.Lightning.JsonConverters;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class GetOutgoingPaymentResponse
{
[JsonProperty("paymentId")]
public string paymentId { get; set; }
[JsonProperty("paymentHash")]
public string paymentHash { get; set; }
[JsonProperty("preimage")]
public string preImage { get; set; }
[JsonProperty("isPaid")]
public bool isPaid { get; set; }
[JsonProperty("sent")]
public long sent { get; set; }
[JsonProperty("fees")]
public long fees { get; set; }
[JsonProperty("invoice")]
public string invoice { get; set; }
[JsonProperty("completedAt")]
[JsonConverter(typeof(PhoenixdDateTimeJsonConverter))]
public DateTimeOffset? completedAt { get; set; }
[JsonProperty("createdAt")]
[JsonConverter(typeof(PhoenixdDateTimeJsonConverter))]
public DateTimeOffset? createdAt { get; set; }
}
}

View File

@ -1,13 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class PayInvoiceRequest
{
[JsonProperty("amountSat")]
public long? AmountSat { get; set; }
[JsonProperty("invoice")]
public string Invoice { get; set; }
}
}

View File

@ -1,25 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class PayInvoiceResponse
{
[JsonProperty("recipientAmountSat")]
public long RecipientAmountSat { get; set; }
[JsonProperty("routingFeeSat")]
public long RoutingFeeSat { get; set; }
[JsonProperty("paymentId")]
public string PaymentId { get; set; }
[JsonProperty("paymentHash")]
public string PaymentHash { get; set; }
[JsonProperty("paymentPreimage")]
public string PaymentPreimage { get; set; }
[JsonProperty("reason")]
public string Reason { get; set; }
}
}

View File

@ -1,20 +0,0 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public partial class PaymentReceivedEvent
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("amountSat")]
public long Amount { get; set; }
[JsonProperty("paymentHash")]
public string PaymentHash { get; set; }
[JsonProperty("timestamp")]
public long Timestamp { get; set; }
}
}

View File

@ -1,16 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Lightning.Phoenixd.Models
{
public class SendPaymentRequest
{
[JsonProperty("amountSat")]
public long? AmountSat { get; set; }
[JsonProperty("address")]
public string Address { get; set; }
[JsonProperty("feerateSatByte")]
public long? FeerateSatByte { get; set; }
}
}

View File

@ -1,245 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.Phoenixd.Models;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.Phoenixd
{
public class PhoenixdClient
{
private readonly Uri _address;
private readonly string _username;
private readonly string _password;
private readonly HttpClient _httpClient;
private static readonly HttpClient SharedClient = new();
public Network Network { get; }
public PhoenixdClient(Uri address, string password, Network network, HttpClient httpClient = null) : this(address, null, password, network, httpClient) { }
public PhoenixdClient(Uri address, string username, string password, Network network, HttpClient httpClient = null)
{
if (address == null)
throw new ArgumentNullException(nameof(address));
if (network == null)
throw new ArgumentNullException(nameof(network));
_address = address;
_username = username;
_password = password;
Network = network;
_httpClient = httpClient ?? SharedClient;
}
public async Task<GetInfoResponse> GetInfo(CancellationToken cts = default)
{
return await SendCommandAsync<NoRequestModel, GetInfoResponse>("getinfo", NoRequestModel.Instance, cts, true);
}
public async Task<GetBalanceResponse> GetBalance(CancellationToken cts = default)
{
return await SendCommandAsync<NoRequestModel, GetBalanceResponse>("getbalance", NoRequestModel.Instance, cts, true);
}
public async Task<CreateInvoiceResponse> CreateInvoice(string description, long? amountSat = null,
int? expirySeconds = null, BitcoinAddress fallbackAddress = null,
string descriptionHash = null, CancellationToken cts = default)
{
return await SendCommandAsync<CreateInvoiceRequest, CreateInvoiceResponse>("createinvoice",
new CreateInvoiceRequest
{
Description = description,
DescriptionHash = descriptionHash,
ExpirySeconds = expirySeconds,
AmountSat = amountSat == 0 ? null : amountSat
}, cts);
}
public async Task<PayInvoiceResponse> PayInvoice(string bolt11, long? amountSat = null, CancellationToken cts = default)
{
return await SendCommandAsync<PayInvoiceRequest, PayInvoiceResponse>("payinvoice",
new PayInvoiceRequest
{
Invoice = bolt11,
AmountSat = amountSat
}, cts);
}
public async Task<GetIncomingPaymentResponse> GetIncomingPayment(string paymentHash, string invoice = null,
CancellationToken cts = default)
{
return await SendCommandAsync<NoRequestModel, GetIncomingPaymentResponse>($"payments/incoming/{paymentHash}", NoRequestModel.Instance, cts, true);
}
public async Task<GetIncomingPaymentResponse[]> ListIncomingPayments(bool all = false, long? offset = null,
int? limit = null, CancellationToken cts = default)
{
var query = new Dictionary<string, string>();
if (all)
query.Add("all", "true");
if (offset is not null)
query.Add("offset", offset.Value.ToString());
if (limit is not null)
query.Add("limit", limit.Value.ToString());
return await SendCommandAsync<NoRequestModel, GetIncomingPaymentResponse[]>("payments/incoming" + BuildQuery(query), NoRequestModel.Instance, cts, true);
}
public async Task<GetOutgoingPaymentResponse> GetOutgoingPayment(string paymentHash, string invoice = null,
CancellationToken cts = default)
{
return await SendCommandAsync<NoRequestModel, GetOutgoingPaymentResponse>($"payments/outgoingbyhash/{paymentHash}", NoRequestModel.Instance, cts, true);
}
public async Task<GetOutgoingPaymentResponse[]> ListOutgoingPayments(bool all = false, long? offset = null,
int? limit = null, CancellationToken cts = default)
{
var query = new Dictionary<string, string>();
if (all)
query.Add("all", "true");
if (offset is not null)
query.Add("offset", offset.Value.ToString());
if (limit is not null)
query.Add("limit", limit.Value.ToString());
return await SendCommandAsync<NoRequestModel, GetOutgoingPaymentResponse[]>("payments/outgoing" + BuildQuery(query), NoRequestModel.Instance, cts, true);
}
private static string BuildQuery(Dictionary<string, string> query)
{
if (query.Count == 0)
return string.Empty;
return "?" + string.Join("&", query.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
}
public async Task<string> SendPayment(string address, long amountSat, long feerateSat,
CancellationToken cts = default)
{
return await SendCommandAsync<SendPaymentRequest, string>($"sendtoaddress",
new SendPaymentRequest
{
AmountSat = amountSat,
Address = address,
FeerateSatByte = feerateSat
}, cts);
}
JsonSerializer _Serializer;
JsonSerializerSettings _SerializerSettings;
JsonSerializerSettings SerializerSettings
{
get
{
if (_SerializerSettings == null)
{
var jsonSerializer = new JsonSerializerSettings();
NBitcoin.JsonConverters.Serializer.RegisterFrontConverters(jsonSerializer, Network);
_SerializerSettings = jsonSerializer;
}
return _SerializerSettings;
}
}
JsonSerializer Serializer
{
get
{
if (_Serializer == null)
{
_Serializer = JsonSerializer.Create(SerializerSettings);
}
return _Serializer;
}
}
private async Task<TResponse> SendCommandAsync<TRequest, TResponse>(string method, TRequest data, CancellationToken cts, bool httpGet = false)
{
HttpContent content = null;
if (data != null && !(data is NoRequestModel))
{
var jobj = JObject.FromObject(data, Serializer);
Dictionary<string, string> x = new Dictionary<string, string>();
foreach (var item in jobj)
{
if (item.Value == null || (item.Value.Type == JTokenType.Null))
{
continue;
}
x.Add(item.Key, item.Value.ToString());
}
content = new FormUrlEncodedContent(x.Select(pair => pair));
}
int retry = 0;
retry:
var httpRequest = new HttpRequestMessage
{
Method = httpGet ? HttpMethod.Get : HttpMethod.Post,
RequestUri = new Uri(_address, method),
Content = content
};
httpRequest.Headers.Accept.Clear();
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.Default.GetBytes($"{_username ?? string.Empty}:{_password}")));
try
{
using var rawResult = await _httpClient.SendAsync(httpRequest, cts);
var rawJson = await rawResult.Content.ReadAsStringAsync();
if (!rawResult.IsSuccessStatusCode)
{
PhoenixdApiError apiError = null;
try
{
apiError = JsonConvert.DeserializeObject<PhoenixdApiError>(rawJson, SerializerSettings);
}
catch
{
apiError = new PhoenixdApiError { Error = rawJson };
}
throw new PhoenixdApiException { Error = apiError };
}
try
{
return JsonConvert.DeserializeObject<TResponse>(rawJson, SerializerSettings);
}
catch
{
if (typeof(TResponse) == typeof(string))
return (TResponse)(object)rawJson;
throw new PhoenixdApiException { Error = new PhoenixdApiError { Error = rawJson } };
}
}
catch (HttpRequestException e) when (e.InnerException is IOException && retry < 10)
{
retry++;
await Task.Delay(100 * retry, cts);
goto retry;
}
}
internal class NoRequestModel
{
public static NoRequestModel Instance = new NoRequestModel();
}
internal class PhoenixdApiException : Exception
{
public PhoenixdApiError Error { get; set; }
public override string Message => Error?.Error;
}
internal class PhoenixdApiError
{
public string Error { get; set; }
}
}
}

View File

@ -1,43 +0,0 @@
using System;
using System.Net.Http;
using NBitcoin;
namespace BTCPayServer.Lightning.Phoenixd;
public class PhoenixdConnectionStringHandler : ILightningConnectionStringHandler
{
private readonly HttpClient _httpClient;
public PhoenixdConnectionStringHandler(HttpClient httpClient = null)
{
_httpClient = httpClient;
}
public ILightningClient Create(string connectionString, Network network, out string error)
{
var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type);
if (type != "phoenixd")
{
error = null;
return null;
}
if (!kv.TryGetValue("server", out var server))
{
error = $"The key 'server' is mandatory for Phoenixd connection strings";
return null;
}
if (!Uri.TryCreate(server, UriKind.Absolute, out var Phoenixduri)
|| (Phoenixduri.Scheme != "http" && Phoenixduri.Scheme != "https"))
{
error = $"The key 'server' should be an URI starting by http:// or https://";
return null;
}
kv.TryGetValue("username", out var username);
kv.TryGetValue("password", out var password);
error = null;
return new PhoenixdLightningClient(Phoenixduri, username, password, network, _httpClient);
}
}

View File

@ -1,342 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Net.WebSockets;
using BTCPayServer.Lightning.Phoenixd.Models;
using NBitcoin;
namespace BTCPayServer.Lightning.Phoenixd
{
public class PhoenixdLightningClient : ILightningClient
{
private readonly Uri _address;
private readonly string _username;
private readonly string _password;
private readonly Network _network;
private readonly PhoenixdClient _PhoenixdClient;
public static PhoenixdClient PhoenixdClientInstance { get; private set; }
private string NormalizeChain(string input)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
// Converts chain name to a single letter to distinguish main chains (for example Main/mainnet from testnet, but not testnet3 from testnet4)
return input.Substring(0, 1).ToLowerInvariant();
}
private async Task<LightningInvoice> ToLightningInvoice(string PaymentHash, CancellationToken cancellation)
{
GetIncomingPaymentResponse info = null;
try
{
info = await _PhoenixdClient.GetIncomingPayment(PaymentHash, null, cancellation);
}
catch (PhoenixdClient.PhoenixdApiException)
{
}
return ToLightningInvoice(info);
}
private LightningInvoice ToLightningInvoice(GetIncomingPaymentResponse info)
{
if (info == null || string.IsNullOrEmpty(info.PaymentHash) || string.IsNullOrEmpty(info.Invoice))
return null;
var parsed = BOLT11PaymentRequest.Parse(info.Invoice, _network);
return new LightningInvoice
{
Id = info.PaymentHash,
PaymentHash = info.PaymentHash,
Amount = parsed.MinimumAmount,
ExpiresAt = info.ExpiresAt ?? parsed.ExpiryDate,
BOLT11 = info.Invoice,
// Phoenixd may charge recipient liquidity fees; include them so the invoice is not considered underpaid.
AmountReceived = LightMoney.Satoshis(info.ReceivedSat) + new LightMoney(info.Fees, LightMoneyUnit.MilliSatoshi),
Status = info.IsPaid ? LightningInvoiceStatus.Paid :
(info.IsExpired || DateTimeOffset.UtcNow >= parsed.ExpiryDate ? LightningInvoiceStatus.Expired : LightningInvoiceStatus.Unpaid),
PaidAt = info.CompletedAt,
Preimage = info.PreImage
};
}
private LightningPayment ToLightningPayment(GetOutgoingPaymentResponse info)
{
if (info == null || string.IsNullOrEmpty(info.paymentHash))
return null;
var fee = new LightMoney(info.fees, LightMoneyUnit.MilliSatoshi);
var sent = LightMoney.Satoshis(info.sent);
return new LightningPayment
{
Id = info.paymentId,
Preimage = info.preImage,
PaymentHash = info.paymentHash,
CreatedAt = info.createdAt,
Amount = sent,
AmountSent = sent + fee,
Fee = fee,
BOLT11 = info.invoice,
Status = info.isPaid ? LightningPaymentStatus.Complete : LightningPaymentStatus.Unknown
};
}
public PhoenixdLightningClient(Uri address, string password, Network network, HttpClient httpClient = null) :
this(address, null, password, network, httpClient)
{
}
public PhoenixdLightningClient(Uri address, string username, string password, Network network, HttpClient httpClient = null)
{
if (address == null)
throw new ArgumentNullException(nameof(address));
if (network == null)
throw new ArgumentNullException(nameof(network));
_address = address;
_username = username;
_password = password;
_network = network;
_PhoenixdClient = new PhoenixdClient(address, username, password, network, httpClient);
PhoenixdClientInstance = _PhoenixdClient;
}
public async Task<LightningInvoice> GetInvoice(string invoiceId, CancellationToken cancellation = default)
{
try
{
return await ToLightningInvoice(invoiceId, cancellation);
}
catch (PhoenixdClient.PhoenixdApiException)
{
return null;
}
}
public async Task<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation = default) =>
await GetInvoice(paymentHash.ToString(), cancellation);
public async Task<LightningInvoice[]> ListInvoices(ListInvoicesParams request, CancellationToken cancellation = default)
{
request ??= new ListInvoicesParams();
var incoming = await _PhoenixdClient.ListIncomingPayments(true, request.OffsetIndex, 100, cancellation);
var invoices = incoming.Select(ToLightningInvoice).Where(i => i != null);
if (request.PendingOnly is true)
invoices = invoices.Where(i => i.Status == LightningInvoiceStatus.Unpaid);
return invoices.ToArray();
}
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default) =>
await ListInvoices(null, cancellation);
public async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation = default)
{
try
{
return ToLightningPayment(await _PhoenixdClient.GetOutgoingPayment(paymentHash, null, cancellation));
}
catch (PhoenixdClient.PhoenixdApiException)
{
return null;
}
}
public async Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default) =>
await ListPayments(null, cancellation);
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
{
request ??= new ListPaymentsParams();
var outgoing = await _PhoenixdClient.ListOutgoingPayments(request.IncludePending is true, request.OffsetIndex, 100, cancellation);
return outgoing.Select(ToLightningPayment).Where(p => p != null).ToArray();
}
Task<LightningInvoice> ILightningClient.CreateInvoice(LightMoney amount, string description, TimeSpan expiry,
CancellationToken cancellation)
=> (this as ILightningClient).CreateInvoice(new CreateInvoiceParams(amount, description, expiry),
cancellation);
async Task<LightningInvoice> ILightningClient.CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation)
{
var result = await _PhoenixdClient.CreateInvoice(req.DescriptionHash is null ? req.Description : null,
req.Amount.MilliSatoshi / 1000, Convert.ToInt32(req.Expiry.TotalSeconds), null,
req.DescriptionHash?.ToString(), cancellation);
var parsed = BOLT11PaymentRequest.Parse(result.Serialized, _network);
return new LightningInvoice
{
BOLT11 = result.Serialized,
Amount = LightMoney.Satoshis(result.AmountSat),
Id = result.PaymentHash,
Status = LightningInvoiceStatus.Unpaid,
ExpiresAt = parsed.ExpiryDate,
PaymentHash = result.PaymentHash
};
}
public static async Task<ClientWebSocket> ClientWebSocket(string url, string authorizationValue, CancellationToken cancellation = default)
{
var socket = new ClientWebSocket();
socket.Options.SetRequestHeader("Authorization", authorizationValue);
var uri = new UriBuilder(url) { UserName = null, Password = null }.Uri.AbsoluteUri;
if (!uri.EndsWith("/"))
uri += "/";
uri += "websocket";
uri = WebsocketHelper.ToWebsocketUri(uri);
await socket.ConnectAsync(new Uri(uri), cancellation);
return socket;
}
public async Task<ILightningInvoiceListener> Listen(CancellationToken cancellation = default)
{
return new PhoenixdSession(
await ClientWebSocket(_address.AbsoluteUri,
new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.Default.GetBytes($"{_username??string.Empty}:{_password}"))).ToString(), cancellation), this);
}
public async Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = default)
{
var network = _network.ToString();
var info = await _PhoenixdClient.GetInfo(cancellation);
if (!string.IsNullOrEmpty(info.Chain) && NormalizeChain(network) != NormalizeChain(info.Chain))
throw new PhoenixdApiException { Error = new PhoenixdApiError { Error = $"Chain mismatch: BTCPay Server is using \"{network}\" while Phoenixd is configured for \"{info.Chain}\""} };
var nodeInfo = new LightningNodeInformation
{
BlockHeight = info.BlockHeight,
Version = info.Version,
Alias = "Phoenixd",
Color = "3399ff",
ActiveChannelsCount = info.Channels?.Count(c => c.State == "Normal"),
InactiveChannelsCount = info.Channels?.Count(c => c.State != "Normal"),
PendingChannelsCount = info.Channels?.Count(c => c.State != "Normal")
};
return nodeInfo;
}
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation = default)
{
var balance = await _PhoenixdClient.GetBalance(cancellation);
var onchain = new OnchainBalance
{
// Not supported by Phoenixd
Confirmed = null,
Unconfirmed = null,
Reserved = null
};
var offchain = new OffchainBalance
{
Opening = 0,
Local = new LightMoney(balance.balanceSat + balance.feeCreditSat, LightMoneyUnit.Satoshi),
Remote = 0,
Closing = 0
};
return new LightningNodeBalance(onchain, offchain);
}
public async Task<PayResponse> Pay(string bolt11, CancellationToken cancellation = default)
{
return await Pay(bolt11, null, cancellation);
}
public async Task<PayResponse> Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = default)
{
// Cancel after timeout
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var timeout = payParams?.SendTimeout ?? PayInvoiceParams.DefaultSendTimeout;
cts.CancelAfter(timeout);
var info = await _PhoenixdClient.PayInvoice(bolt11, payParams?.Amount?.MilliSatoshi / 1000, cts.Token);
if (info.PaymentPreimage is not null)
{
return new PayResponse(PayResult.Ok,
new PayDetails
{
TotalAmount = new LightMoney(info.RecipientAmountSat, LightMoneyUnit.Satoshi),
FeeAmount = new LightMoney(info.RoutingFeeSat, LightMoneyUnit.Satoshi),
PaymentHash = new uint256(info.PaymentHash),
Preimage = new uint256(info.PaymentPreimage),
Status = LightningPaymentStatus.Complete
});
}
else if (info.Reason is not null)
{
switch (info.Reason)
{
case "this invoice has already been paid":
return new PayResponse(PayResult.Ok);
case "channel is not connected yet, please retry when connected":
case "channel creation is in progress, please retry when ready":
case "channel closing is in progress, please retry when a new channel has been created":
case "payment could not be sent through existing channels, check individual failures for more details":
case "not enough funds in wallet to afford payment":
case "the recipient was offline or did not have enough liquidity to receive the payment":
return new PayResponse(PayResult.CouldNotFindRoute, info.Reason);
default:
return new PayResponse(PayResult.Unknown, info.Reason);
}
}
return new PayResponse(PayResult.Unknown);
}
public Task<PayResponse> Pay(PayInvoiceParams payParams, CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest,
CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task<ConnectionResult> ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task CancelInvoice(string invoiceId, CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public Task<LightningChannel[]> ListChannels(CancellationToken cancellation = default)
{
throw new NotSupportedException();
}
public override string ToString()
{
var result= $"type=phoenixd;server={_address}";
if (_username is { })
result += $";username={_username}";
if (_password is { })
result += $";password={_password}";
return result;
}
internal class PhoenixdApiException : Exception
{
public PhoenixdApiError Error { get; set; }
public override string Message => Error?.Error;
}
internal class PhoenixdApiError
{
public string Error { get; set; }
}
}
}

View File

@ -1,41 +0,0 @@
using System;
using System.Net.Http.Headers;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Lightning.Phoenixd.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.Phoenixd
{
public class PhoenixdSession : WebsocketListener, ILightningInvoiceListener
{
private readonly PhoenixdLightningClient lightningClient;
public PhoenixdSession(ClientWebSocket clientWebSocket, PhoenixdLightningClient lightningClient) : base(clientWebSocket)
{
this.lightningClient = lightningClient;
}
public async Task<LightningInvoice> WaitInvoice(CancellationToken cancellation)
{
retry:
var message = await this.WaitMessage(cancellation);
var obj = JObject.Parse(message);
object typedObj = null;
switch (obj.GetValue("type").ToString())
{
case "payment_received":
typedObj = obj.ToObject<PaymentReceivedEvent>();
break;
}
if (typedObj is PaymentReceivedEvent r)
{
return await lightningClient.GetInvoice(r.PaymentHash);
}
goto retry;
}
}
}

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -euo pipefail
rm -rf "bin/Release/"
dotnet pack --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg
package=$(find ./bin/Release -name "*.nupkg" -type f | head -n 1)
dotnet nuget push "$package" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
ver=$(basename "$package" | sed -E 's/[^0-9]*\.([0-9]+(\.[0-9]+){1,4}).*/\1/')
git tag -a "Phoenixd/v$ver" -m "Phoenixd/$ver"
git push --tags

Some files were not shown because too many files have changed in this diff Show More