Compare commits
193 Commits
better-cha
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7e70c1ca | ||
|
|
da5a0c9011 | ||
|
|
40e4baf6af | ||
|
|
42e52c877c | ||
|
|
175bdf4d90 | ||
|
|
d51e7701f8 | ||
|
|
d3071ad752 | ||
|
|
eb6487a920 | ||
|
|
22559ea196 | ||
|
|
86ce7c702d | ||
|
|
a819e8eed5 | ||
|
|
190c7be392 | ||
|
|
3455330dcb | ||
|
|
6ee05f0431 | ||
|
|
422181eccd | ||
|
|
7ffe1d1645 | ||
|
|
0055736b6d | ||
|
|
9f55ced739 | ||
|
|
952e54ba73 | ||
|
|
c3f6c0607c | ||
|
|
8983ab70b8 | ||
|
|
0c0ec5eba5 | ||
|
|
0b3b120070 | ||
|
|
5f3226908c | ||
|
|
1078e1be8d | ||
|
|
77b139e826 | ||
|
|
af165f4cfa | ||
|
|
04db053d16 | ||
|
|
79b652b8d6 | ||
|
|
9248d3ea89 | ||
|
|
b90ee8676e | ||
|
|
2d2a42962f | ||
|
|
d361f633c8 | ||
|
|
b674167416 | ||
|
|
1aeb2ab3fd | ||
|
|
8e1b399680 | ||
|
|
8c71e11ba6 | ||
|
|
88ec6c9aac | ||
|
|
b8019e5e80 | ||
|
|
385e7f3e5d | ||
|
|
e876a00f7d | ||
|
|
91ab59551c | ||
|
|
c8d1260cc3 | ||
|
|
c972863c77 | ||
|
|
c269b49567 | ||
|
|
9886308144 | ||
|
|
281bb28a54 | ||
|
|
c631f2bb0e | ||
|
|
3fde7ec628 | ||
|
|
870daa2720 | ||
|
|
eac093e03f | ||
|
|
fc53710f56 | ||
|
|
562105da1e | ||
|
|
a6bad1e901 | ||
|
|
2b176dc361 | ||
|
|
c9430fd934 | ||
|
|
cbc87a8e47 | ||
|
|
80fc6d54a7 | ||
|
|
171d0cdaef | ||
|
|
4cac82ee70 | ||
|
|
d2c6854e4c | ||
|
|
349ad4974e | ||
|
|
3499786509 | ||
|
|
74925edeea | ||
|
|
38fc1e68a7 | ||
|
|
840d0a3a7f | ||
|
|
c04829b654 | ||
|
|
077a4afc93 | ||
|
|
4e79f720e0 | ||
|
|
16882b4759 | ||
|
|
1e7a3bd013 | ||
|
|
0627bec180 | ||
|
|
322e0979ab | ||
|
|
3e58958732 | ||
|
|
58b70042f2 | ||
|
|
c447bb8c0a | ||
|
|
82e8cce170 | ||
|
|
d01eda5fe3 | ||
|
|
5702bc24b5 | ||
|
|
b927fdb504 | ||
|
|
bf6916fe78 | ||
|
|
eb3bd0b824 | ||
|
|
bfecd34ede | ||
|
|
3b6dfd6e8a | ||
|
|
647d5f35b7 | ||
|
|
12a8df5f27 | ||
|
|
4bc69ba447 | ||
|
|
898af47a9f | ||
|
|
4ce106dcee | ||
|
|
f41b2bac43 | ||
|
|
c14d655cfb | ||
|
|
a4369cd648 | ||
|
|
b2d45ac061 | ||
|
|
412330db13 | ||
|
|
838bda2eb1 | ||
|
|
8f242c3efe | ||
|
|
94d869d2fb | ||
|
|
e0484122f9 | ||
|
|
5e4edf1c77 | ||
|
|
0f066243da | ||
|
|
fc8b0dc1d9 | ||
|
|
f21f8534bf | ||
|
|
9a22bb6536 | ||
|
|
3a10b19f25 | ||
|
|
d56c58cd26 | ||
|
|
c7a6ca373f | ||
|
|
087be52f5b | ||
|
|
dc2ae91d68 | ||
|
|
0fced9d0bc | ||
|
|
9a2f19d094 | ||
|
|
97e626a17c | ||
|
|
b34c1b222d | ||
|
|
7a8f27ae33 | ||
|
|
a5a65cef9c | ||
|
|
40638e1434 | ||
|
|
d6e4e50762 | ||
|
|
10f05eca94 | ||
|
|
82aa080bac | ||
|
|
65998123e2 | ||
|
|
227a022522 | ||
|
|
3e16c38d9d | ||
|
|
14667a7789 | ||
|
|
0cae645e82 | ||
|
|
fb2d1ea966 | ||
|
|
148b9b2810 | ||
|
|
e70757fcc5 | ||
|
|
c5b5d4b4cb | ||
|
|
bc9013d2ef | ||
|
|
6c27c486d1 | ||
|
|
94dd05d5f0 | ||
|
|
78f4da934a | ||
|
|
caee0440ce | ||
|
|
e31f6f76f9 | ||
|
|
5bda969251 | ||
|
|
13f8eaccc3 | ||
|
|
ee7e28d1b1 | ||
|
|
93401484a9 | ||
|
|
a33c5d4178 | ||
|
|
a730daa82f | ||
|
|
3556108584 | ||
|
|
e9aa10a607 | ||
|
|
93596ed195 | ||
|
|
0fc87e9ab4 | ||
|
|
67db217cd0 | ||
|
|
76db2bd4ab | ||
|
|
772331cdd4 | ||
|
|
0a289a22df | ||
|
|
da62575f2e | ||
|
|
fd565efc12 | ||
|
|
c5c129242b | ||
|
|
12a8aa12f5 | ||
|
|
c4b1339490 | ||
|
|
334656e197 | ||
|
|
3c6e4a8313 | ||
|
|
c909a3821a | ||
|
|
a9e7d3e363 | ||
|
|
bfdb8e08e1 | ||
|
|
af8c680838 | ||
|
|
d4804baa4e | ||
|
|
5ea90f4204 | ||
|
|
d87ab0b585 | ||
|
|
00b2d374e6 | ||
|
|
38cc376c44 | ||
|
|
99f80ca987 | ||
|
|
947e95e17d | ||
|
|
b7c8f88c26 | ||
|
|
1d9a32c266 | ||
|
|
edb9ce3368 | ||
|
|
f556fafd46 | ||
|
|
8cb4a656d5 | ||
|
|
0450a9207b | ||
|
|
52601db6be | ||
|
|
51ef487d8d | ||
|
|
7c2be5bf15 | ||
|
|
0dd59d0aa3 | ||
|
|
5ae595eb2c | ||
|
|
b5eeca42b3 | ||
|
|
d6274b83b2 | ||
|
|
d7f957b3ab | ||
|
|
250b76f97a | ||
|
|
21fc136dc7 | ||
|
|
028364c9ba | ||
|
|
f3917fe3f8 | ||
|
|
c5e0e6b637 | ||
|
|
ea00e7dded | ||
|
|
87d74b1ab1 | ||
|
|
8a874b314f | ||
|
|
6c2bfc787d | ||
|
|
ca85529b34 | ||
|
|
9a8b609438 | ||
|
|
3945c3937f | ||
|
|
92a3c7bf8d | ||
|
|
9682222208 |
@ -3,14 +3,14 @@ jobs:
|
||||
build:
|
||||
machine:
|
||||
enabled: true
|
||||
image: ubuntu-2004:202201-02
|
||||
image: default
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
test:
|
||||
machine:
|
||||
enabled: true
|
||||
image: ubuntu-2004:202201-02
|
||||
image: default
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
|
||||
@ -14,18 +14,21 @@ 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.LNbank", "src\BTCPayServer.Lightning.LNbank\BTCPayServer.Lightning.LNbank.csproj", "{4057015B-9D8A-411A-B7C2-3342D9F53BD0}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Lightning.Phoenixd", "src\BTCPayServer.Lightning.Phoenixd\BTCPayServer.Lightning.Phoenixd.csproj", "{477D7912-04E7-473F-A0D5-8CE415082927}"
|
||||
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
|
||||
@ -72,18 +75,6 @@ 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
|
||||
@ -120,18 +111,20 @@ 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
|
||||
{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
|
||||
|
||||
{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
|
||||
|
||||
{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
|
||||
@ -151,11 +144,10 @@ 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}
|
||||
{4057015B-9D8A-411A-B7C2-3342D9F53BD0} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
|
||||
{477D7912-04E7-473F-A0D5-8CE415082927} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
|
||||
{B024DBD2-FCF4-4C48-9EBE-09AA7AAF36FB} = {5BA1A1B2-2713-4CF4-9B63-087531598797}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
|
||||
16
README.md
16
README.md
@ -20,9 +20,7 @@ Here is a description of all packages:
|
||||
* `BTCPayServer.Lightning.Common` exposes common classes and `ILightningClient` [](https://www.nuget.org/packages/BTCPayServer.Lightning.Common)
|
||||
* `BTCPayServer.Lightning.LND` exposes easy to use LND clients [](https://www.nuget.org/packages/BTCPayServer.Lightning.LND)
|
||||
* `BTCPayServer.Lightning.CLightning` exposes easy to use clightning clients [](https://www.nuget.org/packages/BTCPayServer.Lightning.CLightning)
|
||||
* `BTCPayServer.Lightning.Charge` exposes easy to use Charge clients [](https://www.nuget.org/packages/BTCPayServer.Lightning.Charge)
|
||||
* `BTCPayServer.Lightning.Eclair` exposes easy to use Eclair clients [](https://www.nuget.org/packages/BTCPayServer.Lightning.Eclair)
|
||||
* `BTCPayServer.Lightning.LNbank` exposes easy to use LNbank clients [](https://www.nuget.org/packages/BTCPayServer.Lightning.LNbank)
|
||||
* `BTCPayServer.Lightning.LNDhub` exposes easy to use LNDhub clients [](https://www.nuget.org/packages/BTCPayServer.Lightning.LNDhub)
|
||||
|
||||
If you develop an app, we advise you to reference `BTCPayServer.Lightning.All` [](https://www.nuget.org/packages/BTCPayServer.Lightning.All).
|
||||
@ -42,7 +40,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, Charge, CLightning)
|
||||
* Either you want your code to works with all lightning implementation (right now LND, CLightning, Eclair, LNDHub, Phoenixd)
|
||||
* Or you want your code to work on a particular lightning implementation
|
||||
|
||||
### Using the generic interface
|
||||
@ -61,26 +59,20 @@ 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/admin.macaroon;allowinsecure=true`
|
||||
* `type=lnd-rest;server=http://mylnd:8080/;macaroonfilepath=/root/.lnd/invoice.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/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=lnd-rest;server=https://mylnd:8080/;macaroonfilepath=/root/.lnd/invoice.macaroon;certfilepath=/var/lib/lnd/tls.cert`
|
||||
* `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
|
||||
@ -113,7 +105,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 `ChargeClient`, `LndClient` or `CLightningClient`, or cast the `ILightningClient` object returned by `LightningClientFactory`.
|
||||
If you want to leverage specific lightning network implementation, either instanciate directly `LndClient`, `CLightningClient`, or `EclairLightningClient`, or cast the `ILightningClient` object returned by `LightningClientFactory`.
|
||||
|
||||
## How to test
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../Build/Common.csproj"></Import>
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
|
||||
<RootNamespace>BTCPayServer.Lightning</RootNamespace>
|
||||
<Version>1.4.6</Version>
|
||||
<Version>1.7.3</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,14 +14,10 @@
|
||||
</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.LNbank\BTCPayServer.Lightning.LNbank.csproj" />
|
||||
<ProjectReference Include="..\BTCPayServer.Lightning.Phoenixd\BTCPayServer.Lightning.Phoenixd.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>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Logging;
|
||||
using NBitcoin.RPC;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Lightning.Tests
|
||||
{
|
||||
@ -50,9 +52,16 @@ 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 amount = new LightMoney(123456789);
|
||||
// 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);
|
||||
|
||||
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;
|
||||
|
||||
@ -64,27 +73,42 @@ namespace BTCPayServer.Lightning.Tests
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (result.Result == PayResult.CouldNotFindRoute || result.Result == PayResult.Error && result.ErrorDetail.StartsWith("not enough balance"))
|
||||
if (result.Result == PayResult.CouldNotFindRoute || result.Result == PayResult.Error || result.Result == PayResult.Unknown && result.ErrorDetail?.StartsWith("not enough balance") is true)
|
||||
{
|
||||
// check channels that are in process of opening, to prevent double channel open
|
||||
await Task.Delay(100);
|
||||
var pendingChannels = await sender.ListChannels();
|
||||
if (pendingChannels.Any(a => a.RemoteNode == destInfo.NodeInfoList[0].NodeId))
|
||||
var channel = pendingChannels.FirstOrDefault(a => a.RemoteNode == destInfo.NodeInfoList[0].NodeId);
|
||||
var channelDropped = false;
|
||||
if (channel != null)
|
||||
{
|
||||
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 (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($"Opening channel to {destInfo.NodeInfoList[0]}");
|
||||
if (!channelDropped)
|
||||
{
|
||||
var connectedResult = await sender.ConnectTo(destInfo.NodeInfoList.First());
|
||||
Logs.LogInformation($"Connection result: " + connectedResult);
|
||||
Logs.LogInformation($"Opening channel to {destInfo.NodeInfoList[0]}");
|
||||
}
|
||||
var openChannel = await sender.OpenChannel(new OpenChannelRequest()
|
||||
{
|
||||
NodeInfo = destInfo.NodeInfoList[0],
|
||||
ChannelAmount = Money.Satoshis(16777215),
|
||||
ChannelAmount = channelCapacity,
|
||||
FeeRate = new FeeRate(1UL, 1)
|
||||
});
|
||||
Logs.LogInformation($"Channel opening result: {openChannel.Result}");
|
||||
@ -126,6 +150,26 @@ 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
|
||||
{
|
||||
@ -139,12 +183,12 @@ namespace BTCPayServer.Lightning.Tests
|
||||
|
||||
private static async Task<PayResponse> Pay(ILightningClient sender, string payreq)
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(5000))
|
||||
using (var cts = new CancellationTokenSource(30_000))
|
||||
{
|
||||
retry:
|
||||
try
|
||||
{
|
||||
return await sender.Pay(payreq, cts.Token);
|
||||
return await sender.Pay(payreq, new PayInvoiceParams() { SendTimeout = TimeSpan.FromSeconds(10.0) }, cts.Token);
|
||||
}
|
||||
catch (CLightning.LightningRPCException ex) when (ex.Message.Contains("WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS") &&
|
||||
!cts.IsCancellationRequested)
|
||||
|
||||
@ -1,91 +1,86 @@
|
||||
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.LNbank;
|
||||
using BTCPayServer.Lightning.Phoenixd;
|
||||
using BTCPayServer.Lightning.LND;
|
||||
using BTCPayServer.Lightning.LndHub;
|
||||
using BTCPayServer.Lightning.LNDhub;
|
||||
using NBitcoin;
|
||||
using NBitcoin.RPC;
|
||||
|
||||
namespace BTCPayServer.Lightning
|
||||
namespace BTCPayServer.Lightning;
|
||||
|
||||
public class LightningClientFactory : ILightningClientFactory
|
||||
{
|
||||
public class LightningClientFactory : ILightningClientFactory
|
||||
public static readonly IReadOnlyList<ILightningConnectionStringHandler> DefaultHandlers =
|
||||
new ILightningConnectionStringHandler[]
|
||||
{
|
||||
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 static ILightningClient CreateClient(LightningConnectionString connectionString, Network 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)
|
||||
{
|
||||
return new LightningClientFactory(network).Create(connectionString);
|
||||
}
|
||||
|
||||
public static ILightningClient CreateClient(string connectionString, Network network)
|
||||
{
|
||||
if (!LightningConnectionString.TryParse(connectionString, false, out var conn, out string error))
|
||||
throw new FormatException($"Invalid format ({error})");
|
||||
return CreateClient(conn, network);
|
||||
}
|
||||
|
||||
public LightningClientFactory(Network network)
|
||||
{
|
||||
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)
|
||||
try
|
||||
{
|
||||
if (connectionString.CookieFilePath != null)
|
||||
var client = handler.Create(connectionString, _network, out var error);
|
||||
if (client != null)
|
||||
{
|
||||
return new ChargeClient(connectionString.BaseUri, connectionString.CookieFilePath, Network,
|
||||
HttpClient, connectionString.AllowInsecure);
|
||||
return client;
|
||||
}
|
||||
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)
|
||||
if (error is not null)
|
||||
{
|
||||
Macaroon = connectionString.Macaroon,
|
||||
MacaroonFilePath = connectionString.MacaroonFilePath,
|
||||
CertificateThumbprint = connectionString.CertificateThumbprint,
|
||||
CertificateFilePath = connectionString.CertificateFilePath,
|
||||
AllowInsecure = connectionString.AllowInsecure,
|
||||
}, HttpClient), Network);
|
||||
throw new FormatException(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionString.ConnectionType == LightningConnectionType.Eclair)
|
||||
catch (FormatException e)
|
||||
{
|
||||
return new EclairLightningClient(connectionString.BaseUri, connectionString.Username, connectionString.Password, Network, HttpClient);
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
if(lastError is not null)
|
||||
throw lastError;
|
||||
|
||||
if (connectionString.ConnectionType == LightningConnectionType.LNbank)
|
||||
{
|
||||
return new LNbankLightningClient(connectionString.BaseUri, connectionString.ApiToken, Network, HttpClient);
|
||||
}
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported connection string");
|
||||
}
|
||||
|
||||
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})");
|
||||
public bool TryCreate(string connectionString, out ILightningClient client, out string error)
|
||||
{
|
||||
try
|
||||
{
|
||||
client= Create(connectionString);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
client = null;
|
||||
error = e.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,700 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/BTCPayServer.Lightning.All/LightningConnectionType.cs
Normal file
13
src/BTCPayServer.Lightning.All/LightningConnectionType.cs
Normal file
@ -0,0 +1,13 @@
|
||||
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";
|
||||
}
|
||||
9
src/BTCPayServer.Lightning.All/PushNuget.sh
Executable file
9
src/BTCPayServer.Lightning.All/PushNuget.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../Build/Common.csproj"></Import>
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.3.14</Version>
|
||||
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.7.2</Version>
|
||||
<LangVersion>10</LangVersion>
|
||||
<PackageId>BTCPayServer.Lightning.CLightning</PackageId>
|
||||
<Description>Client library for c-lightning to build Lightning Network Apps in C#.</Description>
|
||||
|
||||
@ -3,9 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -109,17 +107,7 @@ 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<FundChannelResponse> FundChannelAsync(OpenChannelRequest openChannelRequest, CancellationToken cancellation)
|
||||
public Task FundChannelAsync(OpenChannelRequest openChannelRequest, CancellationToken cancellation)
|
||||
{
|
||||
OpenChannelRequest.AssertIsSane(openChannelRequest);
|
||||
List<object> parameters = new List<object>();
|
||||
@ -127,31 +115,7 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
parameters.Add(openChannelRequest.ChannelAmount.Satoshi);
|
||||
if (openChannelRequest.FeeRate != null)
|
||||
parameters.Add($"{openChannelRequest.FeeRate.FeePerK.Satoshi * 4}perkw");
|
||||
else
|
||||
{
|
||||
parameters.Add("normal");
|
||||
}
|
||||
|
||||
if (openChannelRequest.Private != null)
|
||||
{
|
||||
parameters.Add(openChannelRequest.Private.ToString().ToLowerInvariant());
|
||||
|
||||
}
|
||||
return SendCommandAsync<FundChannelResponse>("fundchannel", parameters.ToArray(), true, cancellation: cancellation);
|
||||
}
|
||||
|
||||
public class FundChannelResponse
|
||||
{
|
||||
[JsonProperty("tx")]
|
||||
public string Transaction { get; set; }
|
||||
[JsonProperty("txid")]
|
||||
public string TransactionId { get; set; }
|
||||
[JsonProperty("outnum")]
|
||||
public string FundingOutputIndex { get; set; }
|
||||
[JsonProperty("channel_id")]
|
||||
public string ChannelId { get; set; }
|
||||
[JsonProperty("close_to")]
|
||||
public string CloseToScriptPubKey { get; set; }
|
||||
return SendCommandAsync<object>("fundchannel", parameters.ToArray(), true, cancellation: cancellation);
|
||||
}
|
||||
|
||||
public Task ConnectAsync(NodeInfo nodeInfo, CancellationToken cancellation = default)
|
||||
@ -173,6 +137,7 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
{
|
||||
var req = new JObject();
|
||||
req.Add("id", 0);
|
||||
req.Add("jsonrpc", "2.0");
|
||||
req.Add("method", command);
|
||||
req.Add("params", new JArray(parameters));
|
||||
await req.WriteToAsync(jsonWriter, cancellation);
|
||||
@ -199,7 +164,12 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
var error = result.Property("error");
|
||||
if (error != null)
|
||||
{
|
||||
throw new LightningRPCException(error.Value["message"].Value<string>(), error.Value["code"].Value<int>());
|
||||
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);
|
||||
}
|
||||
if (noReturn)
|
||||
return default;
|
||||
@ -261,8 +231,8 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
public async Task<BitcoinAddress> NewAddressAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
var obj = await SendCommandAsync<JObject>("newaddr", cancellation: cancellation);
|
||||
var addr = obj.ContainsKey("address") ? "address" : "bech32";
|
||||
return BitcoinAddress.Create(obj.Property(addr).Value.Value<string>(), Network);
|
||||
var addr = obj.Properties().First().Value.Value<string>();
|
||||
return BitcoinAddress.Create(addr, Network);
|
||||
}
|
||||
|
||||
public async Task<CLightningChannel[]> ListChannelsAsync(ShortChannelId ShortChannelId = null, CancellationToken cancellation = default)
|
||||
@ -274,21 +244,37 @@ 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)
|
||||
{
|
||||
return await GetPayment(paymentHash, cancellation);
|
||||
}
|
||||
|
||||
async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation)
|
||||
{
|
||||
var payments = await SendCommandAsync<CLightningPayment[]>("listpays", new[] { null, paymentHash }, false, true, cancellation);
|
||||
if (payments.Length == 0)
|
||||
return null;
|
||||
return ToLightningPayment(payments[0]);
|
||||
return payments.Length == 0 ? null : ToLightningPayment(payments.Last());
|
||||
}
|
||||
|
||||
async Task<LightningInvoice> ILightningClient.GetInvoice(string invoiceId, CancellationToken cancellation)
|
||||
{
|
||||
var invoices = await SendCommandAsync<CLightningInvoice[]>("listinvoices", new[] { invoiceId }, false, true, cancellation);
|
||||
if (invoices.Length == 0)
|
||||
return null;
|
||||
return ToLightningInvoice(invoices[0]);
|
||||
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]);
|
||||
}
|
||||
|
||||
async Task<LightningInvoice[]> ILightningClient.ListInvoices(CancellationToken cancellation)
|
||||
@ -310,29 +296,74 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
return invoices.Select(ToLightningInvoice).ToArray();
|
||||
}
|
||||
|
||||
async Task<LightningPayment[]> ILightningClient.ListPayments(CancellationToken cancellation)
|
||||
{
|
||||
return await ListPayments(null, cancellation);
|
||||
}
|
||||
|
||||
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation)
|
||||
{
|
||||
var payments = await SendCommandAsync<CLightningPayment[]>("listpays", null, false, true, cancellation);
|
||||
if (request != null)
|
||||
{
|
||||
// we need to filter client-side, because the listpays command does not support these filters
|
||||
payments = payments.Where(payment =>
|
||||
((request.IncludePending.HasValue && request.IncludePending.Value) || ToPaymentStatus(payment.Status) != LightningPaymentStatus.Pending) &&
|
||||
(!request.OffsetIndex.HasValue || !payment.CreatedAt.HasValue || payment.CreatedAt.Value.ToUnixTimeMilliseconds() >= request.OffsetIndex.Value)).ToArray();
|
||||
}
|
||||
|
||||
return payments.Select(ToLightningPayment).ToArray();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
bolt11 = bolt11?.Replace("lightning:", "").Replace("LIGHTNING:", "");
|
||||
|
||||
// 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
|
||||
{
|
||||
if (bolt11 == null && payParams.Destination is null)
|
||||
throw new ArgumentNullException(nameof(bolt11));
|
||||
var pr = bolt11 is null ? null : BOLT11PaymentRequest.Parse(bolt11, Network);
|
||||
|
||||
bolt11 = bolt11?.Replace("lightning:", "").Replace("LIGHTNING:", "");
|
||||
var explicitAmount = payParams?.Amount;
|
||||
var feePercent = payParams?.MaxFeePercent;
|
||||
if (feePercent is null && payParams?.MaxFeeFlat is Money m)
|
||||
// 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 pr = BOLT11PaymentRequest.Parse(bolt11, Network);
|
||||
var amountSat = (explicitAmount ?? pr.MinimumAmount).ToUnit(LightMoneyUnit.Satoshi);
|
||||
feePercent = (double)(m.Satoshi / amountSat) * 100;
|
||||
if (payParams?.MaxFeePercent is { } feePercent && explicitAmount is not null)
|
||||
{
|
||||
maxFeeFlat = (long)(explicitAmount.ToDecimal(LightMoneyUnit.Satoshi) * (decimal)feePercent / 100m);
|
||||
}
|
||||
}
|
||||
|
||||
var response = await SendCommandAsync<CLightningPayResponse>(bolt11 == null?"keysend":"pay", new object[] { bolt11 is null?payParams.Destination.ToHex(): bolt11, explicitAmount?.MilliSatoshi, null, null, feePercent }, false, cancellation: cancellation);
|
||||
|
||||
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);
|
||||
|
||||
return new PayResponse(PayResult.Ok, new PayDetails
|
||||
{
|
||||
TotalAmount = response.AmountSent,
|
||||
FeeAmount = response.AmountSent - response.Amount
|
||||
FeeAmount = response.AmountSent - response.Amount,
|
||||
PaymentHash = response.GetPaymentHash(),
|
||||
Preimage = response.PaymentPreImage,
|
||||
Status = LightningPaymentStatus.Complete
|
||||
});
|
||||
}
|
||||
catch (LightningRPCException ex) when (
|
||||
@ -342,7 +373,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.Code == CLightningErrorCode.STOPPED_RETRYING && !ex.Message.Contains("invalid payload")) ||
|
||||
(ex.Code == CLightningErrorCode.WRONG_PARAMETERS && ex.Message.Contains("Self-payment"));
|
||||
var result =
|
||||
routingError
|
||||
@ -350,6 +381,38 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
: PayResult.Error;
|
||||
return new PayResponse(result, ex.Message);
|
||||
}
|
||||
catch (Exception ex) when (cts.Token.IsCancellationRequested && !cancellation.IsCancellationRequested)
|
||||
{
|
||||
if (bolt11 != null)
|
||||
{
|
||||
var pr = BOLT11PaymentRequest.Parse(bolt11, Network);
|
||||
var paymentHash = pr.PaymentHash?.ToString();
|
||||
var response = await GetPayment(paymentHash, cancellation);
|
||||
|
||||
switch (response.Status)
|
||||
{
|
||||
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 = response.Status
|
||||
});
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new PayResponse(PayResult.Unknown);
|
||||
}
|
||||
|
||||
async Task<PayResponse> ILightningClient.Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation)
|
||||
@ -381,12 +444,30 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
var msat = amount == LightMoney.Zero ? "any" : amount.MilliSatoshi.ToString();
|
||||
var expiry = Math.Max(0, (int)req.Expiry.TotalSeconds);
|
||||
var id = InvoiceIdEncoder.EncodeData(RandomUtils.GetBytes(20));
|
||||
var cmd = req.DescriptionHash == null ? "invoice" : "invoicewithdescriptionhash";
|
||||
var opts = req.DescriptionHash == null
|
||||
? new object[] { msat, id, req.Description ?? "", expiry, null, null, req.PrivateRouteHints }
|
||||
: new object[] { msat, id, req.DescriptionHash.ToString(), expiry, null, null, req.PrivateRouteHints };
|
||||
|
||||
var invoice = await SendCommandAsync<CLightningInvoice>(cmd, opts, cancellation: cancellation);
|
||||
List<object> args = new List<object>();
|
||||
args.Add(msat);
|
||||
args.Add(id);
|
||||
args.Add(req.Description ?? "");
|
||||
args.Add(expiry);
|
||||
args.Add(null); // [fallbacks]
|
||||
args.Add(null); // [preimage]
|
||||
args.Add(req.PrivateRouteHints);
|
||||
|
||||
if (req.DescriptionHashOnly)
|
||||
{
|
||||
args.Add(null); // [cltv]
|
||||
args.Add(true);
|
||||
}
|
||||
|
||||
CLightningInvoice invoice = await SendCommandAsync<CLightningInvoice>(
|
||||
"invoice",
|
||||
args.ToArray(),
|
||||
cancellation: cancellation);
|
||||
|
||||
if (invoice is null)
|
||||
throw new InvalidOperationException("Bug in BTCPayServer.Lightning library, contact developers, code 52917");
|
||||
|
||||
invoice.Label = id;
|
||||
invoice.MilliSatoshi = amount;
|
||||
invoice.Status = "unpaid";
|
||||
@ -414,23 +495,19 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
|
||||
async Task<LightningChannel[]> ILightningClient.ListChannels(CancellationToken cancellation)
|
||||
{
|
||||
var listPeersAsync = this.ListPeersAsync(cancellation);
|
||||
var listChannels = await this.ListPeerChannelsAsync();
|
||||
List<LightningChannel> channels = new List<LightningChannel>();
|
||||
foreach (var peer in await listPeersAsync)
|
||||
foreach (var channel in listChannels)
|
||||
{
|
||||
foreach (var channel in peer.Channels)
|
||||
channels.Add(new LightningChannel
|
||||
{
|
||||
channels.Add(new LightningChannel()
|
||||
{
|
||||
Id = channel.ShortChannelId.ToString(),
|
||||
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"
|
||||
});
|
||||
}
|
||||
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"
|
||||
});
|
||||
}
|
||||
return channels.ToArray();
|
||||
}
|
||||
@ -439,6 +516,8 @@ 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,
|
||||
@ -511,11 +590,10 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
async Task<OpenChannelResponse> ILightningClient.OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation)
|
||||
{
|
||||
retry:
|
||||
FundChannelResponse response;
|
||||
try
|
||||
{
|
||||
response = await FundChannelAsync(openChannelRequest, cancellation);
|
||||
}
|
||||
try
|
||||
{
|
||||
await FundChannelAsync(openChannelRequest, cancellation);
|
||||
}
|
||||
catch (LightningRPCException ex) when (ex.Code == CLightningErrorCode.STILL_SYNCING_BITCOIN)
|
||||
{
|
||||
await Task.Delay(1000, cancellation);
|
||||
@ -531,7 +609,7 @@ try
|
||||
ex.Message == "Unknown peer" ||
|
||||
ex.Message == "Unable to connect, no address known for peer")
|
||||
{
|
||||
return new OpenChannelResponse(OpenChannelResult.PeerNotConnected);
|
||||
return new OpenChannelResponse(OpenChannelResult.PeerNotConnected);
|
||||
}
|
||||
catch (LightningRPCException ex) when (ex.Message.Contains("CHANNELD_AWAITING_LOCKIN"))
|
||||
{
|
||||
@ -548,10 +626,7 @@ try
|
||||
{
|
||||
return new OpenChannelResponse(OpenChannelResult.AlreadyExists);
|
||||
}
|
||||
return new OpenChannelResponse(OpenChannelResult.Ok)
|
||||
{
|
||||
ChannelId = response.ChannelId
|
||||
};
|
||||
return new OpenChannelResponse(OpenChannelResult.Ok);
|
||||
}
|
||||
|
||||
async Task<BitcoinAddress> ILightningClient.GetDepositAddress(CancellationToken cancellation)
|
||||
@ -637,6 +712,11 @@ try
|
||||
|
||||
return new LightningNodeBalance(onchain, offchain);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"type=clightning;server={Address}";
|
||||
}
|
||||
}
|
||||
|
||||
class CLightningInvoiceListener : ILightningInvoiceListener
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Lightning.CLightning
|
||||
{
|
||||
@ -9,12 +11,22 @@ 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("msatoshi")]
|
||||
[JsonProperty("amount_msat")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
public LightMoney MilliSatoshi { get; set; }
|
||||
|
||||
[JsonProperty("msatoshi_received")]
|
||||
[JsonProperty("amount_received_msat")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
public LightMoney MilliSatoshiReceived { get; set; }
|
||||
|
||||
@ -33,5 +45,19 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
[JsonProperty("paid_at")]
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
|
||||
#pragma warning disable IDE0051
|
||||
// Legacy stuff
|
||||
[JsonProperty("msatoshi")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
LightMoney msatoshi { set { MilliSatoshi = value; } }
|
||||
|
||||
[JsonProperty("msatoshi_received")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
LightMoney msatoshi_received { set { MilliSatoshiReceived = value; } }
|
||||
#pragma warning restore IDE0051
|
||||
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalProperties { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Crypto;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Lightning.CLightning
|
||||
{
|
||||
@ -10,19 +13,32 @@ 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; }
|
||||
|
||||
[JsonProperty("msatoshi")]
|
||||
[JsonProperty("amount_msat")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
public LightMoney Amount { get; set; }
|
||||
|
||||
[JsonProperty("msatoshi_sent")]
|
||||
[JsonProperty("amount_sent_msat")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
public LightMoney AmountSent { get; set; }
|
||||
|
||||
#pragma warning disable IDE0051
|
||||
// Legacy stuff
|
||||
[JsonProperty("msatoshi_sent")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
LightMoney msatoshi_sent { set { AmountSent = value; } }
|
||||
|
||||
[JsonProperty("msatoshi")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
LightMoney msatoshi { set { Amount = value; } }
|
||||
#pragma warning restore IDE0051
|
||||
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalProperties { get; set; }
|
||||
|
||||
public uint256 GetPaymentHash() => new uint256(Hashes.SHA256(PaymentPreImage.ToBytes(false)), false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Lightning.CLightning
|
||||
{
|
||||
@ -22,6 +24,16 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.MoneyJsonConverter))]
|
||||
public Money Value { get; set; }
|
||||
|
||||
#pragma warning disable IDE0051
|
||||
// For some reason clightning decided the value of a UTXO should be in millisat... when it is impossible
|
||||
[JsonProperty("amount_msat")]
|
||||
[JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))]
|
||||
LightMoney amount_msat { set { Value = Money.Coins(value.ToDecimal(LightMoneyUnit.BTC)); } }
|
||||
#pragma warning restore IDE0051
|
||||
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalProperties { get; set; }
|
||||
}
|
||||
|
||||
public class FundsChannel
|
||||
@ -49,5 +61,8 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
[JsonProperty("short_channel_id")]
|
||||
[JsonConverter(typeof(JsonConverters.ShortChannelIdJsonConverter))]
|
||||
public ShortChannelId ShortChannelId { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalProperties { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
42
src/BTCPayServer.Lightning.CLightning/PeerChannel.cs
Normal file
42
src/BTCPayServer.Lightning.CLightning/PeerChannel.cs
Normal file
@ -0,0 +1,42 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
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; }
|
||||
|
||||
}
|
||||
}
|
||||
9
src/BTCPayServer.Lightning.CLightning/PushNuget.sh
Executable file
9
src/BTCPayServer.Lightning.CLightning/PushNuget.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@ -29,8 +29,8 @@ namespace BTCPayServer.Lightning.CLightning
|
||||
return false;
|
||||
|
||||
if (!int.TryParse(datas[0], out var blockHeight) ||
|
||||
!int.TryParse(datas[0], out var blockIndex) ||
|
||||
!int.TryParse(datas[0], out var txOutIndex))
|
||||
!int.TryParse(datas[1], out var blockIndex) ||
|
||||
!int.TryParse(datas[2], out var txOutIndex))
|
||||
return false;
|
||||
if (blockHeight < 0 || blockIndex < 0 || txOutIndex < 0)
|
||||
return false;
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.3.12</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>
|
||||
@ -1,53 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,290 +0,0 @@
|
||||
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 != 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../Build/Common.csproj"></Import>
|
||||
<PropertyGroup>
|
||||
<RootNamespace>BTCPayServer.Lightning</RootNamespace>
|
||||
<Version>1.3.13</Version>
|
||||
<Version>1.7.1</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;charge;lapps</PackageTags>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<PackageTags>lightning;bitcoin;clightning;lnd;lapps</PackageTags>
|
||||
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NBitcoin" Version="7.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||
<PackageReference Include="NBitcoin" Version="10.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
28
src/BTCPayServer.Lightning.Common/ConvertHelper.cs
Normal file
28
src/BTCPayServer.Lightning.Common/ConvertHelper.cs
Normal file
@ -0,0 +1,28 @@
|
||||
#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
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Crypto;
|
||||
|
||||
namespace BTCPayServer.Lightning
|
||||
{
|
||||
@ -20,6 +18,8 @@ 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)
|
||||
{
|
||||
if (amount == null)
|
||||
@ -34,7 +34,22 @@ namespace BTCPayServer.Lightning
|
||||
|
||||
public LightMoney Amount { get; set; }
|
||||
public string Description { get; set; }
|
||||
public uint256 DescriptionHash { get; set; }
|
||||
uint256 _DescriptionHash;
|
||||
public uint256 DescriptionHash
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_DescriptionHash is null && (Description is null || !DescriptionHashOnly))
|
||||
return null;
|
||||
return _DescriptionHash ?? new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(Description)), false);
|
||||
}
|
||||
[Obsolete("Set the Description and turn DescriptionHashOnly to true instead")]
|
||||
set
|
||||
{
|
||||
_DescriptionHash = value;
|
||||
}
|
||||
}
|
||||
public bool DescriptionHashOnly { get; set; }
|
||||
public TimeSpan Expiry { get; set; }
|
||||
public bool PrivateRouteHints { get; set; }
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NBitcoin;
|
||||
@ -8,9 +10,12 @@ 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);
|
||||
Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default);
|
||||
Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default);
|
||||
Task<LightningInvoice> CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation = default);
|
||||
Task<LightningInvoice> CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = default);
|
||||
Task<ILightningInvoiceListener> Listen(CancellationToken cancellation = default);
|
||||
@ -24,10 +29,96 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,5 +7,7 @@ namespace BTCPayServer.Lightning
|
||||
public interface ILightningClientFactory
|
||||
{
|
||||
ILightningClient Create(string connectionString);
|
||||
|
||||
bool TryCreate(string connectionString, out ILightningClient client, out string error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,13 +25,15 @@ 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
|
||||
new LightMoney(long.Parse(((string)reader.Value).Replace("msat", ""), CultureInfo.InvariantCulture)),
|
||||
// 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)),
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ namespace BTCPayServer.Lightning
|
||||
{
|
||||
public class LightningChannel
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public PubKey RemoteNode { get; set; }
|
||||
public bool IsPublic { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
@ -6,6 +6,8 @@ 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; }
|
||||
|
||||
7
src/BTCPayServer.Lightning.Common/ListPaymentsParams.cs
Normal file
7
src/BTCPayServer.Lightning.Common/ListPaymentsParams.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace BTCPayServer.Lightning;
|
||||
|
||||
public class ListPaymentsParams
|
||||
{
|
||||
public bool? IncludePending { get; set; }
|
||||
public long? OffsetIndex { get; set; }
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using NBitcoin;
|
||||
|
||||
@ -13,8 +14,18 @@ namespace BTCPayServer.Lightning
|
||||
throw new ArgumentNullException(nameof(host));
|
||||
if (nodeId == null)
|
||||
throw new ArgumentNullException(nameof(nodeId));
|
||||
|
||||
Port = port;
|
||||
Host = host;
|
||||
if (IPAddress.TryParse(host, out var addr))
|
||||
{
|
||||
Host = addr.ToString();
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
Host = $"[{Host}]";
|
||||
}
|
||||
else
|
||||
{
|
||||
Host = host;
|
||||
}
|
||||
NodeId = nodeId;
|
||||
}
|
||||
|
||||
@ -44,18 +55,25 @@ namespace BTCPayServer.Lightning
|
||||
return false;
|
||||
}
|
||||
|
||||
var portIndex = str.IndexOf(':');
|
||||
var portIndex = str.LastIndexOf(':');
|
||||
// An ipv6 can contains two ::
|
||||
if (portIndex >= 1 && str[portIndex - 1] == ':')
|
||||
portIndex = -1;
|
||||
int port = 9735;
|
||||
string host;
|
||||
if (portIndex != -1)
|
||||
{
|
||||
if (portIndex <= atIndex)
|
||||
return false;
|
||||
if (!int.TryParse(str.Substring(portIndex + 1), out port))
|
||||
return false;
|
||||
host = str.Substring(atIndex + 1, portIndex - atIndex - 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
host = str.Substring(atIndex + 1);
|
||||
}
|
||||
|
||||
|
||||
string host = str.Substring(atIndex + 1, portIndex - atIndex - 1);
|
||||
if (host.Length == 0)
|
||||
return false;
|
||||
nodeInfo = new NodeInfo(nodeId, host, port);
|
||||
|
||||
@ -19,8 +19,6 @@ namespace BTCPayServer.Lightning
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public bool? Private { get; set; }
|
||||
public static void AssertIsSane(OpenChannelRequest openChannelRequest)
|
||||
{
|
||||
if (openChannelRequest == null)
|
||||
|
||||
@ -19,7 +19,5 @@ namespace BTCPayServer.Lightning
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public string ChannelId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NBitcoin;
|
||||
|
||||
@ -15,4 +16,7 @@ public class PayInvoiceParams
|
||||
public uint256? PaymentHash { get; set; }
|
||||
|
||||
public Dictionary<ulong, string>? CustomRecords { get; set; }
|
||||
|
||||
public TimeSpan? SendTimeout { get; set; }
|
||||
public static TimeSpan DefaultSendTimeout = TimeSpan.FromSeconds(30.0);
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace BTCPayServer.Lightning
|
||||
{
|
||||
public enum PayResult
|
||||
{
|
||||
Ok,
|
||||
Unknown,
|
||||
CouldNotFindRoute,
|
||||
Error
|
||||
}
|
||||
@ -43,5 +47,14 @@ 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; }
|
||||
}
|
||||
}
|
||||
|
||||
9
src/BTCPayServer.Lightning.Common/PushNuget.sh
Executable file
9
src/BTCPayServer.Lightning.Common/PushNuget.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../Build/Common.csproj"></Import>
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.3.12</Version>
|
||||
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.7.1</Version>
|
||||
<LangVersion>10</LangVersion>
|
||||
<PackageId>BTCPayServer.Lightning.Eclair</PackageId>
|
||||
<Description>Client library for Eclair to build Lightning Network Apps in C#.</Description>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@ -8,10 +9,8 @@ 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
|
||||
{
|
||||
@ -21,11 +20,11 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
private readonly string _username;
|
||||
private readonly string _password;
|
||||
private readonly HttpClient _httpClient;
|
||||
private static readonly HttpClient SharedClient = new HttpClient();
|
||||
private static readonly HttpClient SharedClient = new();
|
||||
|
||||
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)
|
||||
@ -73,19 +72,17 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
}, cts);
|
||||
}
|
||||
|
||||
public async Task<string> Open(PubKey nodeId, long fundingSatoshis, string channelType = null,int? pushMsat = null,
|
||||
long? fundingFeerateSatByte = null, bool? announceChannel = null, int? openTimeoutSeconds = null,
|
||||
public async Task<string> Open(PubKey nodeId, long fundingSatoshis, int? pushMsat = null,
|
||||
long? fundingFeerateSatByte = null, ChannelFlags? channelFlags = null,
|
||||
CancellationToken cts = default)
|
||||
{
|
||||
return await SendCommandAsync<OpenRequest, string>("open", new OpenRequest()
|
||||
{
|
||||
NodeId = nodeId.ToString(),
|
||||
FundingSatoshis = fundingSatoshis,
|
||||
AnnounceChannel = announceChannel,
|
||||
ChannelType = channelType,
|
||||
ChannelFlags = channelFlags,
|
||||
PushMsat = pushMsat,
|
||||
FundingFeerateSatByte = fundingFeerateSatByte,
|
||||
OpenTimeoutSeconds = openTimeoutSeconds
|
||||
FundingFeerateSatByte = fundingFeerateSatByte
|
||||
}, cts);
|
||||
|
||||
}
|
||||
@ -217,7 +214,7 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
CancellationToken cts = default)
|
||||
{
|
||||
return await SendCommandAsync<GetReceivedInfoRequest, GetReceivedInfoResponse>("getreceivedinfo",
|
||||
new GetReceivedInfoRequest()
|
||||
new GetReceivedInfoRequest
|
||||
{
|
||||
PaymentHash = paymentHash,
|
||||
Invoice = invoice
|
||||
@ -344,6 +341,8 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
content = new FormUrlEncodedContent(x.Select(pair => pair));
|
||||
}
|
||||
|
||||
int retry = 0;
|
||||
retry:
|
||||
var httpRequest = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Post,
|
||||
@ -352,18 +351,26 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
};
|
||||
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}")));
|
||||
|
||||
var rawResult = await _httpClient.SendAsync(httpRequest, cts);
|
||||
var rawJson = await rawResult.Content.ReadAsStringAsync();
|
||||
if (!rawResult.IsSuccessStatusCode)
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.Default.GetBytes($"{_username ?? string.Empty}:{_password}")));
|
||||
try
|
||||
{
|
||||
throw new EclairApiException
|
||||
using var rawResult = await _httpClient.SendAsync(httpRequest, cts);
|
||||
var rawJson = await rawResult.Content.ReadAsStringAsync();
|
||||
if (!rawResult.IsSuccessStatusCode)
|
||||
{
|
||||
Error = JsonConvert.DeserializeObject<EclairApiError>(rawJson, SerializerSettings)
|
||||
};
|
||||
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;
|
||||
}
|
||||
return JsonConvert.DeserializeObject<TResponse>(rawJson, SerializerSettings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,12 @@
|
||||
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
|
||||
{
|
||||
@ -52,6 +48,9 @@ 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);
|
||||
@ -73,6 +72,7 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
var lnInvoice = new LightningInvoice
|
||||
{
|
||||
Id = invoiceId,
|
||||
PaymentHash = invoice.PaymentHash,
|
||||
Amount = parsed.MinimumAmount,
|
||||
ExpiresAt = parsed.ExpiryDate,
|
||||
BOLT11 = invoice.Serialized
|
||||
@ -96,6 +96,7 @@ 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;
|
||||
@ -104,20 +105,22 @@ 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.Preimage,
|
||||
Preimage = sentInfo.Status.PaymentPreimage,
|
||||
PaymentHash = sentInfo.PaymentHash,
|
||||
CreatedAt = sentInfo.CreatedAt,
|
||||
Amount = sentInfo.Amount,
|
||||
AmountSent = sentInfo.Amount + sentInfo.FeesPaid,
|
||||
Fee = sentInfo.FeesPaid
|
||||
AmountSent = sentInfo.Amount + fees,
|
||||
Fee = fees
|
||||
};
|
||||
|
||||
switch (sentInfo.Status.type)
|
||||
switch (sentInfo.Status.Type)
|
||||
{
|
||||
case "pending":
|
||||
payment.Status = LightningPaymentStatus.Pending;
|
||||
@ -136,6 +139,16 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
return payment;
|
||||
}
|
||||
|
||||
public Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
async Task<LightningInvoice> ILightningClient.CreateInvoice(LightMoney amount, string description, TimeSpan expiry,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
@ -145,21 +158,22 @@ 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
|
||||
ExpiresAt = parsed.ExpiryDate,
|
||||
PaymentHash = result.PaymentHash
|
||||
};
|
||||
return invoice;
|
||||
}
|
||||
Task<LightningInvoice> ILightningClient.CreateInvoice(CreateInvoiceParams req, CancellationToken cancellation)
|
||||
{
|
||||
if (req.DescriptionHash != null)
|
||||
if (req.DescriptionHash is not null)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
throw new NotSupportedException("DescriptionHash isn't supported");
|
||||
}
|
||||
return (this as ILightningClient).CreateInvoice(req.Amount, req.Description, req.Expiry, cancellation);
|
||||
}
|
||||
@ -212,7 +226,7 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
{
|
||||
Opening =
|
||||
global.Offchain.WaitForFundingConfirmed +
|
||||
global.Offchain.WaitForFundingLocked +
|
||||
global.Offchain.WaitForChannelReady +
|
||||
global.Offchain.WaitForPublishFutureCommitment,
|
||||
Local = global.Offchain.Normal.ToLocal,
|
||||
Remote = usable.Sum(channel => channel.CanReceive),
|
||||
@ -233,37 +247,54 @@ 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, cancellation);
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
var uuid = await _eclairClient.PayInvoice(req, cts.Token);
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
var status = await _eclairClient.GetSentInfo(null, uuid, cancellation);
|
||||
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.FeesPaid
|
||||
});
|
||||
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":
|
||||
return new PayResponse(PayResult.CouldNotFindRoute);
|
||||
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, cancellation);
|
||||
await Task.Delay(200, cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -272,46 +303,61 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
{
|
||||
return new PayResponse(PayResult.Error, exception.Message);
|
||||
}
|
||||
|
||||
return new PayResponse(PayResult.CouldNotFindRoute);
|
||||
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 paymentHash = payParams.PaymentHash.ToString();
|
||||
var req = new SendToNodeRequest
|
||||
{
|
||||
NodeId = payParams.Destination.ToString(),
|
||||
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 uuid = await _eclairClient.SendToNode(req, cts.Token);
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
var status = await _eclairClient.GetSentInfo(paymentHash, uuid, cancellation);
|
||||
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.FeesPaid
|
||||
FeeAmount = sentInfo.Status.FeesPaid,
|
||||
PaymentHash = new uint256(sentInfo.PaymentHash),
|
||||
Preimage = new uint256(sentInfo.Status.PaymentPreimage),
|
||||
Status = LightningPaymentStatus.Complete
|
||||
});
|
||||
case "failed":
|
||||
return new PayResponse(PayResult.CouldNotFindRoute);
|
||||
var failure = sentInfo.Status.Failures.First();
|
||||
var result = failure.FailureMessage.Contains("route")
|
||||
? PayResult.CouldNotFindRoute
|
||||
: PayResult.Error;
|
||||
return new PayResponse(result, failure.FailureMessage);
|
||||
case "pending":
|
||||
await Task.Delay(200, cancellation);
|
||||
await Task.Delay(200, cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -320,8 +366,13 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
{
|
||||
return new PayResponse(PayResult.Error, exception.Message);
|
||||
}
|
||||
|
||||
return new PayResponse(PayResult.CouldNotFindRoute);
|
||||
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<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest,
|
||||
@ -330,15 +381,13 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
try
|
||||
{
|
||||
var result = await _eclairClient.Open(openChannelRequest.NodeInfo.NodeId,
|
||||
openChannelRequest.ChannelAmount.Satoshi, null, null,
|
||||
(openChannelRequest.FeeRate is null
|
||||
? (long?)null
|
||||
: Convert.ToInt64(openChannelRequest.FeeRate.SatoshiPerByte)), openChannelRequest.Private, null,
|
||||
cancellation);
|
||||
|
||||
openChannelRequest.ChannelAmount.Satoshi
|
||||
, null,
|
||||
Convert.ToInt64(openChannelRequest.FeeRate.SatoshiPerByte), null, cancellation);
|
||||
|
||||
if (result.Contains("created channel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string channelId = result.Replace("created channel", "").Trim();
|
||||
var channelId = result.Replace("created channel", "").Trim();
|
||||
var channel = await _eclairClient.Channel(channelId, cancellation);
|
||||
switch (channel.State)
|
||||
{
|
||||
@ -348,10 +397,7 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
case "WAIT_FOR_FUNDING_SIGNED":
|
||||
case "WAIT_FOR_FUNDING_LOCKED":
|
||||
case "WAIT_FOR_FUNDING_CONFIRMED":
|
||||
return new OpenChannelResponse(OpenChannelResult.NeedMoreConf)
|
||||
{
|
||||
ChannelId = channelId
|
||||
};
|
||||
return new OpenChannelResponse(OpenChannelResult.NeedMoreConf);
|
||||
}
|
||||
}
|
||||
|
||||
@ -387,6 +433,10 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
return ConnectionResult.Ok;
|
||||
return ConnectionResult.CouldNotConnect;
|
||||
}
|
||||
catch (System.TimeoutException)
|
||||
{
|
||||
return ConnectionResult.CouldNotConnect;
|
||||
}
|
||||
catch (EclairClient.EclairApiException)
|
||||
{
|
||||
return ConnectionResult.CouldNotConnect;
|
||||
@ -403,8 +453,10 @@ namespace BTCPayServer.Lightning.Eclair
|
||||
var channels = await _eclairClient.Channels(null, cancellation);
|
||||
return channels.Select(response =>
|
||||
{
|
||||
OutPoint.TryParse(response.Data.Commitments.CommitInput.OutPoint.Replace(":", "-"),
|
||||
out var outPoint);
|
||||
var outpointStr = response.Data?.Commitments?.CommitInput?.OutPoint?.Replace(":", "-");
|
||||
OutPoint outPoint = null;
|
||||
if (outpointStr != null)
|
||||
OutPoint.TryParse(outpointStr, out outPoint);
|
||||
|
||||
return new LightningChannel
|
||||
{
|
||||
@ -417,5 +469,16 @@ 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;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
src/BTCPayServer.Lightning.Eclair/Models/ChannelFlags.cs
Normal file
8
src/BTCPayServer.Lightning.Eclair/Models/ChannelFlags.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace BTCPayServer.Lightning.Eclair.Models
|
||||
{
|
||||
public enum ChannelFlags
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1
|
||||
}
|
||||
}
|
||||
@ -13,12 +13,9 @@ 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; }
|
||||
@ -26,9 +23,6 @@ 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using BTCPayServer.Lightning.Eclair.JsonConverters;
|
||||
using BTCPayServer.Lightning.JsonConverters;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
@ -28,19 +29,19 @@ namespace BTCPayServer.Lightning.Eclair.Models
|
||||
public class GlobalOffchainBalance
|
||||
{
|
||||
[JsonProperty("waitForFundingConfirmed")]
|
||||
[JsonConverter(typeof(LightMoneyJsonConverter))]
|
||||
[JsonConverter(typeof(EclairBtcJsonConverter))]
|
||||
public LightMoney WaitForFundingConfirmed { get; set; }
|
||||
|
||||
[JsonProperty("waitForFundingLocked")]
|
||||
[JsonConverter(typeof(LightMoneyJsonConverter))]
|
||||
public LightMoney WaitForFundingLocked { get; set; }
|
||||
[JsonProperty("waitForChannelReady")]
|
||||
[JsonConverter(typeof(EclairBtcJsonConverter))]
|
||||
public LightMoney WaitForChannelReady { get; set; }
|
||||
|
||||
[JsonProperty("waitForPublishFutureCommitment")]
|
||||
[JsonConverter(typeof(LightMoneyJsonConverter))]
|
||||
[JsonConverter(typeof(EclairBtcJsonConverter))]
|
||||
public LightMoney WaitForPublishFutureCommitment { get; set; }
|
||||
|
||||
[JsonProperty("negotiating")]
|
||||
[JsonConverter(typeof(LightMoneyJsonConverter))]
|
||||
[JsonConverter(typeof(EclairBtcJsonConverter))]
|
||||
public LightMoney Negotiating { get; set; }
|
||||
|
||||
[JsonProperty("normal")]
|
||||
@ -72,7 +73,7 @@ namespace BTCPayServer.Lightning.Eclair.Models
|
||||
public class EclairChannelBalance
|
||||
{
|
||||
[JsonProperty("toLocal")]
|
||||
[JsonConverter(typeof(LightMoneyJsonConverter))]
|
||||
[JsonConverter(typeof(EclairBtcJsonConverter))]
|
||||
public LightMoney ToLocal { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,6 @@ namespace BTCPayServer.Lightning.Eclair.Models
|
||||
public long FundingSatoshis { get; set; }
|
||||
public long? PushMsat { get; set; }
|
||||
public long? FundingFeerateSatByte { get; set; }
|
||||
public string ChannelType { get; set; }
|
||||
public bool? AnnounceChannel { get; set; }
|
||||
public int? OpenTimeoutSeconds { get; set; }
|
||||
public ChannelFlags? ChannelFlags { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,38 @@
|
||||
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 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ 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; }
|
||||
|
||||
9
src/BTCPayServer.Lightning.Eclair/PushNuget.sh
Executable file
9
src/BTCPayServer.Lightning.Eclair/PushNuget.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../Build/Common.csproj"></Import>
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.4.4</Version>
|
||||
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.7.1</Version>
|
||||
<LangVersion>10</LangVersion>
|
||||
<PackageId>BTCPayServer.Lightning.LND</PackageId>
|
||||
<Description>Client library for LND to build Lightning Network Apps in C#.</Description>
|
||||
@ -11,13 +11,17 @@
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageTags>lightning;bitcoin;lnd;lapps</PackageTags>
|
||||
</PropertyGroup>
|
||||
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj"/>
|
||||
<Import Project="../BTCPayServer.Lightning.Common/Common.csproj" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Threading.Channels" Version="4.5.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="8.0.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>
|
||||
|
||||
@ -7,13 +7,22 @@ namespace BTCPayServer.Lightning.LND
|
||||
{
|
||||
internal static class Extensions
|
||||
{
|
||||
public static LndError2 AsLNDError(this SwaggerException swagger)
|
||||
public static LNDError AsLNDError(this SwaggerException swagger)
|
||||
{
|
||||
var error = JsonConvert.DeserializeObject<LndError2>(swagger.Response);
|
||||
error.Error = error.Error ?? error.Message;
|
||||
if (error.Error == null)
|
||||
return null;
|
||||
return error;
|
||||
LNDError error;
|
||||
|
||||
try
|
||||
{
|
||||
error = JsonConvert.DeserializeObject<LNDError>(swagger.Response);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
var nested = JsonConvert.DeserializeObject<LNDNestedError>(swagger.Response);
|
||||
error = nested.Error;
|
||||
}
|
||||
|
||||
error.Error = error.Message;
|
||||
return error.Error == null ? null : error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
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;
|
||||
|
||||
@ -25,10 +28,13 @@ namespace BTCPayServer.Lightning.LND
|
||||
Stream _Body;
|
||||
StreamReader _Reader;
|
||||
Task _ListenLoop;
|
||||
private readonly Action<string> _log;
|
||||
private const int MaxConsecutiveNullReads = 5;
|
||||
|
||||
public LndInvoiceClientSession(LndSwaggerClient parent)
|
||||
public LndInvoiceClientSession(LndSwaggerClient parent, Action<string> log)
|
||||
{
|
||||
_Parent = parent;
|
||||
_log = log ?? ((_) => { });
|
||||
}
|
||||
|
||||
public Task StartListening()
|
||||
@ -62,11 +68,13 @@ 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();
|
||||
@ -76,7 +84,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
|
||||
@ -84,6 +92,13 @@ 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)
|
||||
@ -143,7 +158,8 @@ namespace BTCPayServer.Lightning.LND
|
||||
_Body = null;
|
||||
_Response?.Dispose();
|
||||
_Response = null;
|
||||
_Client?.Dispose();
|
||||
if (_Parent._DefaultHttpClient is null)
|
||||
_Client?.Dispose();
|
||||
_Client = null;
|
||||
if (waitLoop)
|
||||
_ListenLoop?.Wait();
|
||||
@ -161,12 +177,44 @@ namespace BTCPayServer.Lightning.LND
|
||||
Stream _Body;
|
||||
StreamReader _Reader;
|
||||
Task _ListenLoop;
|
||||
private readonly string _PaymentHash;
|
||||
private readonly Func<HttpRequestMessage> _requestBuilder;
|
||||
private readonly Action<string> _log;
|
||||
private const int MaxConsecutiveNullReads = 5;
|
||||
|
||||
public LndPaymentClientSession(LndSwaggerClient parent, 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)
|
||||
{
|
||||
_Parent = parent;
|
||||
_PaymentHash = paymentHash;
|
||||
_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;
|
||||
};
|
||||
}
|
||||
|
||||
public Task StartListening()
|
||||
@ -174,10 +222,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
try
|
||||
{
|
||||
_Client = _Parent.CreateHttpClient();
|
||||
_Client.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite);
|
||||
var paymentHash = _PaymentHash.HexStringToBase64UrlString();
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, WithTrailingSlash(_Parent.BaseUrl) + $"v2/router/track/{paymentHash}");
|
||||
_Parent._Authentication.AddAuthentication(request);
|
||||
var request = _requestBuilder();
|
||||
_ListenLoop = ListenLoop(request);
|
||||
}
|
||||
catch
|
||||
@ -201,21 +246,24 @@ 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 paymentString = JObject.Parse(line)["result"].ToString();
|
||||
LnrpcPayment parsed = _Parent.Deserialize<LnrpcPayment>(paymentString);
|
||||
var resultToken = JObject.Parse(line)["result"];
|
||||
LastFailureReason = resultToken["failure_reason"]?.ToString();
|
||||
LnrpcPayment parsed = _Parent.Deserialize<LnrpcPayment>(resultToken.ToString());
|
||||
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
|
||||
@ -223,6 +271,13 @@ 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)
|
||||
@ -282,7 +337,8 @@ namespace BTCPayServer.Lightning.LND
|
||||
_Body = null;
|
||||
_Response?.Dispose();
|
||||
_Response = null;
|
||||
_Client?.Dispose();
|
||||
if (_Parent._DefaultHttpClient is null)
|
||||
_Client?.Dispose();
|
||||
_Client = null;
|
||||
if (waitLoop)
|
||||
_ListenLoop?.Wait();
|
||||
@ -304,6 +360,8 @@ namespace BTCPayServer.Lightning.LND
|
||||
|
||||
}
|
||||
|
||||
public Action<string> Log { get; set; }
|
||||
|
||||
public Network Network
|
||||
{
|
||||
get;
|
||||
@ -320,12 +378,14 @@ 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
|
||||
{
|
||||
ValueMSat = strAmount,
|
||||
// null → field omitted from JSON (NullValueHandling.Ignore) → LND produces amountless bolt11
|
||||
ValueMSat = req.Amount == LightMoney.Zero
|
||||
? null
|
||||
: ConvertInv.ToString(req.Amount.ToUnit(LightMoneyUnit.MilliSatoshi)),
|
||||
Memo = req.Description,
|
||||
Description_hash = req.DescriptionHash?.ToBytes(false),
|
||||
Expiry = strExpiry,
|
||||
@ -335,21 +395,24 @@ namespace BTCPayServer.Lightning.LND
|
||||
|
||||
var invoice = new LightningInvoice
|
||||
{
|
||||
Id = BitString(resp.R_hash),
|
||||
Id = Encoders.Hex.EncodeData(resp.R_hash),
|
||||
Amount = req.Amount,
|
||||
BOLT11 = resp.Payment_request,
|
||||
Status = LightningInvoiceStatus.Unpaid,
|
||||
ExpiresAt = DateTimeOffset.UtcNow + req.Expiry
|
||||
ExpiresAt = DateTimeOffset.UtcNow + req.Expiry,
|
||||
PaymentHash = new uint256(resp.R_hash, false).ToString()
|
||||
};
|
||||
return invoice;
|
||||
}
|
||||
|
||||
public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = default)
|
||||
{
|
||||
var resp = await SwaggerClient.LookupInvoiceAsync(invoiceId, null, cancellation);
|
||||
var h = InvoiceIdToRHash(invoiceId);
|
||||
if (h is null)
|
||||
return;
|
||||
await SwaggerClient.CancelInvoiceAsync(new InvoicesrpcCancelInvoiceMsg
|
||||
{
|
||||
Payment_hash = resp.R_hash
|
||||
Payment_hash = h
|
||||
}, cancellation);
|
||||
}
|
||||
|
||||
@ -375,22 +438,21 @@ 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)
|
||||
@ -401,9 +463,13 @@ namespace BTCPayServer.Lightning.LND
|
||||
}
|
||||
return nodeInfo;
|
||||
}
|
||||
catch (SwaggerException ex) when (!string.IsNullOrEmpty(ex.Response))
|
||||
catch (SwaggerException ex) when (ex.AsLNDError() is {} lndError)
|
||||
{
|
||||
throw new Exception("LND threw an error: " + ex.Response);
|
||||
if (lndError.Code == 2 || lndError.Error.StartsWith("permission denied"))
|
||||
{
|
||||
throw new UnauthorizedAccessException(lndError.Error);
|
||||
}
|
||||
throw new LndException(lndError.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -419,9 +485,8 @@ namespace BTCPayServer.Lightning.LND
|
||||
var pendingResponse = pendingChannels.Result;
|
||||
|
||||
var closing = new LightMoney(0);
|
||||
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);
|
||||
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));
|
||||
|
||||
var onchain = new OnchainBalance
|
||||
{
|
||||
@ -440,11 +505,11 @@ namespace BTCPayServer.Lightning.LND
|
||||
return new LightningNodeBalance(onchain, offchain);
|
||||
}
|
||||
|
||||
async Task<LightningInvoice> ILightningClient.GetInvoice(string invoiceId, CancellationToken cancellation)
|
||||
async Task<LightningInvoice> GetInvoice(byte[] invoiceId, CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await SwaggerClient.LookupInvoiceAsync(invoiceId, null, cancellation);
|
||||
var resp = await SwaggerClient.LookupInvoiceAsync(invoiceId, cancellation);
|
||||
return resp.State?.Equals("CANCELED", StringComparison.InvariantCultureIgnoreCase) is true ? null : ConvertLndInvoice(resp);
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
@ -453,12 +518,37 @@ namespace BTCPayServer.Lightning.LND
|
||||
return null;
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.StatusCode == "500" && ex.AsLNDError() is LndError2 err && err.Error.StartsWith("encoding/hex", StringComparison.OrdinalIgnoreCase))
|
||||
(ex.StatusCode == "500" && ex.AsLNDError() is LNDError err && err.Error.StartsWith("encoding/hex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@ -471,25 +561,52 @@ namespace BTCPayServer.Lightning.LND
|
||||
}
|
||||
|
||||
async Task<LightningPayment> ILightningClient.GetPayment(string paymentHash, CancellationToken cancellation)
|
||||
{
|
||||
return await GetPayment(paymentHash, cancellation);
|
||||
}
|
||||
|
||||
async Task<LightningPayment> GetPayment(string paymentHash, CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var session = new LndPaymentClientSession(SwaggerClient, paymentHash);
|
||||
using var session = new LndPaymentClientSession(SwaggerClient, paymentHash, Log);
|
||||
await session.StartListening();
|
||||
var payment = await session.WaitPayment(cancellation);
|
||||
|
||||
return payment;
|
||||
}
|
||||
catch (SwaggerException ex)
|
||||
catch (LndException ex) when (ex.Error is { Code: 5 } lndError)
|
||||
{
|
||||
var errorString = JObject.Parse(ex.Response)["error"]["message"].ToString();
|
||||
throw new LndException(errorString);
|
||||
return null;
|
||||
}
|
||||
catch (LndException ex) when (ex.Error is { Message: "payment isn't initiated" } lndError)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default)
|
||||
{
|
||||
return await ListPayments(null, cancellation);
|
||||
}
|
||||
|
||||
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
|
||||
{
|
||||
var resp = await SwaggerClient.ListPaymentsAsync(request?.IncludePending, null, cancellation);
|
||||
var payments = resp.Payments.Select(ConvertLndPayment).ToArray();
|
||||
if (request is { OffsetIndex: { } })
|
||||
{
|
||||
// we need to filter client-side, because the LNDs offset works differently
|
||||
payments = payments.Where(payment =>
|
||||
!payment.CreatedAt.HasValue || payment.CreatedAt.Value.ToUnixTimeMilliseconds() >= request.OffsetIndex.Value).ToArray();
|
||||
}
|
||||
|
||||
return payments;
|
||||
}
|
||||
|
||||
async Task<ILightningInvoiceListener> ILightningClient.Listen(CancellationToken cancellation)
|
||||
{
|
||||
var session = new LndInvoiceClientSession(SwaggerClient);
|
||||
var session = new LndInvoiceClientSession(SwaggerClient, Log);
|
||||
await session.StartListening();
|
||||
return session;
|
||||
}
|
||||
@ -498,8 +615,9 @@ namespace BTCPayServer.Lightning.LND
|
||||
{
|
||||
var invoice = new LightningInvoice
|
||||
{
|
||||
// TODO: Verify id corresponds to R_hash
|
||||
Id = BitString(resp.R_hash),
|
||||
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,
|
||||
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,
|
||||
@ -510,7 +628,10 @@ 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);
|
||||
}
|
||||
|
||||
@ -545,6 +666,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
|
||||
payment.Status = resp.Status switch
|
||||
{
|
||||
"INITIATED" => LightningPaymentStatus.Pending,
|
||||
"IN_FLIGHT" => LightningPaymentStatus.Pending,
|
||||
"SUCCEEDED" => LightningPaymentStatus.Complete,
|
||||
"FAILED" => LightningPaymentStatus.Failed,
|
||||
@ -555,102 +677,162 @@ 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)
|
||||
{
|
||||
retry:
|
||||
// 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:
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
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 sendRequest = BuildRouterSendRequest(bolt11, payParams, timeout);
|
||||
using var session = new LndPaymentClientSession(SwaggerClient, sendRequest, Log);
|
||||
await session.StartListening();
|
||||
var payment = await session.WaitPayment(cts.Token);
|
||||
|
||||
var response = await SwaggerClient.SendPaymentSyncAsync(req, cancellation);
|
||||
if (string.IsNullOrEmpty(response.Payment_error) && response.Payment_preimage != null)
|
||||
switch (payment?.Status)
|
||||
{
|
||||
if (response.Payment_route != null)
|
||||
{
|
||||
case LightningPaymentStatus.Complete:
|
||||
return new PayResponse(PayResult.Ok, new PayDetails
|
||||
{
|
||||
TotalAmount = new LightMoney(response.Payment_route.Total_amt_msat),
|
||||
FeeAmount = new LightMoney(response.Payment_route.Total_fees_msat)
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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 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)
|
||||
};
|
||||
default:
|
||||
return new PayResponse(PayResult.Error, response.Payment_error);
|
||||
return new PayResponse(PayResult.Unknown);
|
||||
}
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError)
|
||||
catch (LndException ex)
|
||||
{
|
||||
if (lndError.Error.StartsWith("chain backend is still syncing"))
|
||||
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 (retryCount++ > 3)
|
||||
return new PayResponse(PayResult.Error, ex.Response);
|
||||
return new PayResponse(PayResult.Error, message);
|
||||
|
||||
await Task.Delay(1000, cancellation);
|
||||
goto retry;
|
||||
}
|
||||
if (lndError.Error.StartsWith("self-payments not allowed"))
|
||||
{
|
||||
return new PayResponse(PayResult.CouldNotFindRoute, ex.Response);
|
||||
}
|
||||
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);
|
||||
|
||||
throw new LndException(lndError.Error);
|
||||
throw;
|
||||
}
|
||||
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)
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
@ -681,30 +863,29 @@ retry:
|
||||
{
|
||||
Local_funding_amount = openChannelRequest.ChannelAmount.Satoshi.ToString(CultureInfo.InvariantCulture),
|
||||
Node_pubkey_string = openChannelRequest.NodeInfo.NodeId.ToString(),
|
||||
Private = openChannelRequest.Private
|
||||
};
|
||||
if (openChannelRequest.FeeRate != null)
|
||||
{
|
||||
req.Sat_per_byte = ((int)openChannelRequest.FeeRate.SatoshiPerByte).ToString();
|
||||
}
|
||||
await SwaggerClient.OpenChannelSyncAsync(req, cancellation);
|
||||
var result = await this.SwaggerClient.OpenChannelSyncAsync(req, cancellation);
|
||||
return new OpenChannelResponse(OpenChannelResult.Ok);
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError &&
|
||||
(ex.AsLNDError() is LNDError lndError &&
|
||||
(lndError.Error.StartsWith("peer is not connected") ||
|
||||
lndError.Error.EndsWith("is not online")))
|
||||
{
|
||||
return new OpenChannelResponse(OpenChannelResult.PeerNotConnected);
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError &&
|
||||
(ex.AsLNDError() is LNDError lndError &&
|
||||
lndError.Error.StartsWith("not enough witness outputs"))
|
||||
{
|
||||
return new OpenChannelResponse(OpenChannelResult.CannotAffordFunding);
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError &&
|
||||
(ex.AsLNDError() is LNDError lndError &&
|
||||
lndError.Code == 177)
|
||||
{
|
||||
var pendingChannels = await this.SwaggerClient.PendingChannelsAsync(cancellation);
|
||||
@ -715,7 +896,7 @@ retry:
|
||||
return new OpenChannelResponse(OpenChannelResult.AlreadyExists);
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError &&
|
||||
(ex.AsLNDError() is LNDError lndError &&
|
||||
lndError.Error.StartsWith("channels cannot be created before"))
|
||||
{
|
||||
if (retryCount++ > 3)
|
||||
@ -725,7 +906,7 @@ retry:
|
||||
goto retry;
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError &&
|
||||
(ex.AsLNDError() is LNDError lndError &&
|
||||
lndError.Error.StartsWith("chain backend is still syncing"))
|
||||
{
|
||||
if (retryCount++ > 3)
|
||||
@ -735,7 +916,7 @@ retry:
|
||||
goto retry;
|
||||
}
|
||||
catch (SwaggerException ex) when
|
||||
(ex.AsLNDError() is LndError2 lndError &&
|
||||
(ex.AsLNDError() is LNDError lndError &&
|
||||
lndError.Error.StartsWith("Number of pending channels exceed"))
|
||||
{
|
||||
return new OpenChannelResponse(OpenChannelResult.NeedMoreConf);
|
||||
@ -782,5 +963,37 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
151
src/BTCPayServer.Lightning.LND/LndConnectionStringHandler.cs
Normal file
151
src/BTCPayServer.Lightning.LND/LndConnectionStringHandler.cs
Normal file
@ -0,0 +1,151 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BTCPayServer.Lightning.LND
|
||||
{
|
||||
class LndError2
|
||||
public class LNDError
|
||||
{
|
||||
public string Error
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
public string Error { get; set; }
|
||||
public string Message { get; set; }
|
||||
public int Code
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
public int Code { get; set; }
|
||||
}
|
||||
|
||||
class LNDNestedError
|
||||
{
|
||||
public LNDError Error { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -24,13 +25,19 @@ 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 new LndAuthentication.MacaroonFileAuthentication(MacaroonFilePath);
|
||||
{
|
||||
return !string.IsNullOrEmpty(MacaroonDirectoryPath)
|
||||
? new LndAuthentication.MacaroonFileAuthentication(Path.Combine(MacaroonDirectoryPath, MacaroonFilePath))
|
||||
: new LndAuthentication.MacaroonFileAuthentication(MacaroonFilePath);
|
||||
}
|
||||
|
||||
return LndAuthentication.NullAuthentication.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
//----------------------
|
||||
//----------------------
|
||||
// <auto-generated>
|
||||
// Generated using the NSwag toolchain v11.11.1.0 (NJsonSchema v9.9.11.0 (Newtonsoft.Json v9.0.0.0)) (http://NSwag.org)
|
||||
// </auto-generated>
|
||||
@ -10,6 +10,7 @@ using Newtonsoft.Json.Linq;
|
||||
using System.Diagnostics;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using System.Text;
|
||||
|
||||
namespace BTCPayServer.Lightning.LND
|
||||
{
|
||||
@ -1565,9 +1566,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(string r_hash_str, byte[] r_hash)
|
||||
public System.Threading.Tasks.Task<LnrpcInvoice> LookupInvoiceAsync(byte[] r_hash)
|
||||
{
|
||||
return LookupInvoiceAsync(r_hash_str, r_hash, System.Threading.CancellationToken.None);
|
||||
return LookupInvoiceAsync(r_hash, System.Threading.CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>* lncli: `lookupinvoice`
|
||||
@ -1577,15 +1578,14 @@ 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(string r_hash_str, byte[] r_hash, System.Threading.CancellationToken cancellationToken)
|
||||
public async System.Threading.Tasks.Task<LnrpcInvoice> LookupInvoiceAsync(byte[] r_hash, System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
if (r_hash_str == null)
|
||||
throw new System.ArgumentNullException("r_hash_str");
|
||||
|
||||
if (r_hash is null)
|
||||
throw new ArgumentNullException(nameof(r_hash));
|
||||
var urlBuilder_ = new System.Text.StringBuilder();
|
||||
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_.Append(BaseUrl).Append($"/v1/invoice/?");
|
||||
var b64 = Convert.ToBase64String(r_hash);
|
||||
urlBuilder_.Append("r_hash=").Append(b64.Replace("+", "-").Replace("/", "_")).Append("&");
|
||||
urlBuilder_.Length--;
|
||||
|
||||
var client_ = _httpClient;
|
||||
@ -1967,20 +1967,28 @@ namespace BTCPayServer.Lightning.LND
|
||||
|
||||
/// <summary>* lncli: `listpayments`
|
||||
/// ListPayments returns a list of all outgoing payments.</summary>
|
||||
/// <exception cref="SwaggerException">A server side error occurred.</exception>
|
||||
public System.Threading.Tasks.Task<LnrpcListPaymentsResponse> ListPaymentsAsync()
|
||||
{
|
||||
return ListPaymentsAsync(System.Threading.CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>* lncli: `listpayments`
|
||||
/// ListPayments returns a list of all outgoing payments.</summary>
|
||||
/// <param name="include_pending">/ Toggles if all invoices should be returned, or only those that are currently unsettled.</param>
|
||||
/// <param name="index_offset">/ The index of an invoice that will be used as either the start or end of a query to determine which invoices should be returned in the response.</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<LnrpcListPaymentsResponse> ListPaymentsAsync(System.Threading.CancellationToken cancellationToken)
|
||||
public System.Threading.Tasks.Task<LnrpcListPaymentsResponse> ListPaymentsAsync(bool? include_pending, long? index_offset)
|
||||
{
|
||||
return ListPaymentsAsync(include_pending, index_offset, System.Threading.CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>* lncli: `listpayments`
|
||||
/// ListPayments returns a list of all outgoing payments.</summary>
|
||||
/// <param name="include_pending">/ Toggles if all invoices should be returned, or only those that are currently unsettled.</param>
|
||||
/// <param name="index_offset">/ The index of an invoice that will be used as either the start or end of a query to determine which invoices should be returned in the response.</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<LnrpcListPaymentsResponse> ListPaymentsAsync(bool? include_pending, long? index_offset, System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
var urlBuilder_ = new System.Text.StringBuilder();
|
||||
urlBuilder_.Append(BaseUrl).Append("/v1/payments");
|
||||
urlBuilder_.Append(BaseUrl).Append("/v1/payments?");
|
||||
if (include_pending.HasValue) urlBuilder_.Append("pending_only=").Append(System.Uri.EscapeDataString(System.Convert.ToString(include_pending.Value, System.Globalization.CultureInfo.InvariantCulture))).Append("&");
|
||||
if (index_offset.HasValue) urlBuilder_.Append("index_offset=").Append(System.Uri.EscapeDataString(System.Convert.ToString(index_offset.Value, System.Globalization.CultureInfo.InvariantCulture))).Append("&");
|
||||
urlBuilder_.Length--;
|
||||
|
||||
var client_ = _httpClient;
|
||||
try
|
||||
@ -2285,11 +2293,11 @@ namespace BTCPayServer.Lightning.LND
|
||||
await ConnectPeerAsync(body, cancellationToken);
|
||||
return ConnectionResult.Ok;
|
||||
}
|
||||
catch (SwaggerException ex) when (ex.AsLNDError() is LndError2 err && err.Error.Contains("already connected"))
|
||||
catch (SwaggerException ex) when (ex.AsLNDError() is LNDError err && err.Error.Contains("already connected"))
|
||||
{
|
||||
return ConnectionResult.Ok;
|
||||
}
|
||||
catch (SwaggerException ex) when (ex.AsLNDError() is LndError2 err)
|
||||
catch (SwaggerException ex) when (ex.AsLNDError() is LNDError err)
|
||||
{
|
||||
// Already connected error
|
||||
return ConnectionResult.CouldNotConnect;
|
||||
@ -2841,7 +2849,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
{
|
||||
private PendingChannelsResponsePendingChannel _channel;
|
||||
private string _closing_txid;
|
||||
private string _limbo_balance;
|
||||
private long _limbo_balance;
|
||||
private long? _maturity_height;
|
||||
private int? _blocks_til_maturity;
|
||||
private string _recovered_balance;
|
||||
@ -2876,7 +2884,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
}
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("limbo_balance", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
|
||||
public string Limbo_balance
|
||||
public long Limbo_balance
|
||||
{
|
||||
get { return _limbo_balance; }
|
||||
set
|
||||
@ -3180,7 +3188,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
public partial class PendingChannelsResponseWaitingCloseChannel : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
private PendingChannelsResponsePendingChannel _channel;
|
||||
private string _limbo_balance;
|
||||
private long _limbo_balance;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("channel", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
|
||||
public PendingChannelsResponsePendingChannel Channel
|
||||
@ -3197,7 +3205,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
}
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("limbo_balance", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
|
||||
public string Limbo_balance
|
||||
public long Limbo_balance
|
||||
{
|
||||
get { return _limbo_balance; }
|
||||
set
|
||||
@ -8898,6 +8906,7 @@ 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;
|
||||
|
||||
@ -8929,6 +8938,20 @@ 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
|
||||
{
|
||||
|
||||
@ -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,21 +40,9 @@ 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
|
||||
{
|
||||
HttpClient _DefaultHttpClient;
|
||||
internal HttpClient _DefaultHttpClient;
|
||||
public LndSwaggerClient(LndRestSettings settings) : this(settings, null)
|
||||
{
|
||||
|
||||
@ -75,7 +63,7 @@ namespace BTCPayServer.Lightning.LND
|
||||
return json;
|
||||
});
|
||||
}
|
||||
LndRestSettings _LndSettings;
|
||||
internal LndRestSettings _LndSettings;
|
||||
internal LndAuthentication _Authentication;
|
||||
|
||||
partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url)
|
||||
|
||||
9
src/BTCPayServer.Lightning.LND/PushNuget.sh
Executable file
9
src/BTCPayServer.Lightning.LND/PushNuget.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
88
src/BTCPayServer.Lightning.LNDhub/AsyncDuplicateLock.cs
Normal file
88
src/BTCPayServer.Lightning.LNDhub/AsyncDuplicateLock.cs
Normal file
@ -0,0 +1,88 @@
|
||||
#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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../Build/Common.csproj"></Import>
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.0.8</Version>
|
||||
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.7.1</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>
|
||||
@ -11,13 +12,17 @@
|
||||
<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 Include="System.Threading.Channels" Version="4.5.0" />
|
||||
<PackageReference Condition="'$(TargetFramework)' == 'netstandard2.0'" Include="System.Threading.Channels" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>BTCPayServer.Lightning.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -14,12 +14,18 @@ namespace BTCPayServer.Lightning.LNDhub.JsonConverters
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
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;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
|
||||
@ -37,7 +37,12 @@ namespace BTCPayServer.Lightning.LNDhub.JsonConverters
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
if (value != null)
|
||||
writer.WriteValue(((LightMoney)value).ToUnit(LightMoneyUnit.Satoshi));
|
||||
{
|
||||
// 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));
|
||||
}
|
||||
else
|
||||
writer.WriteNull();
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Lightning.LNDhub.JsonConverters
|
||||
{
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
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.JsonConverters;
|
||||
using NBitcoin.Crypto;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@ -24,12 +28,11 @@ 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 HttpClient();
|
||||
|
||||
private string AccessToken { get; set; }
|
||||
private string RefreshToken { get; set; }
|
||||
private static readonly HttpClient _sharedClient = new ();
|
||||
private static readonly ConcurrentDictionary<string, AuthResponse> _cache = new();
|
||||
public readonly string CacheKey;
|
||||
private static readonly AsyncDuplicateLock _locker = new();
|
||||
|
||||
public LndHubClient(Uri baseUri, string login, string password, Network network, HttpClient httpClient)
|
||||
{
|
||||
@ -39,10 +42,7 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
_baseUri = baseUri;
|
||||
_httpClient = httpClient ?? _sharedClient;
|
||||
|
||||
// JSON
|
||||
var serializerSettings = new JsonSerializerSettings();
|
||||
Serializer.RegisterFrontConverters(serializerSettings, network);
|
||||
_serializer = JsonSerializer.Create(serializerSettings);
|
||||
CacheKey = ConvertHelper.ToHexString(Hashes.SHA256(Encoding.UTF8.GetBytes(_baseUri+ _login + _password)));
|
||||
}
|
||||
|
||||
public async Task<CreateAccountResponse> CreateAccount(CancellationToken cancellation)
|
||||
@ -100,7 +100,7 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
};
|
||||
|
||||
if (payParams?.Amount != null)
|
||||
payload.Amount = (long)payParams.Amount.ToUnit(LightMoneyUnit.Satoshi);
|
||||
payload.Amount = payParams.Amount;
|
||||
|
||||
return await Post<PayInvoiceRequest, PaymentResponse>("payinvoice", payload, cancellation);
|
||||
}
|
||||
@ -131,7 +131,7 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
|
||||
var req = new HttpRequestMessage
|
||||
{
|
||||
RequestUri = new Uri($"{_baseUri}{path}"),
|
||||
RequestUri = new Uri($"{WithTrailingSlash(_baseUri.ToString())}{path}"),
|
||||
Method = method,
|
||||
Content = content
|
||||
};
|
||||
@ -139,13 +139,9 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
req.Headers.Add("User-Agent", "BTCPayServer.Lightning.LndHubClient");
|
||||
|
||||
if (path != "auth" && path != "create")
|
||||
if (!path.StartsWith("auth") && path != "create")
|
||||
{
|
||||
if (string.IsNullOrEmpty(AccessToken))
|
||||
{
|
||||
await Authorize(cancellation);
|
||||
}
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessToken(cancellation));
|
||||
}
|
||||
|
||||
var res = await _httpClient.SendAsync(req, cancellation);
|
||||
@ -155,9 +151,8 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
{
|
||||
var exception = new LndHubApiException(str);
|
||||
if (!exception.AuthenticationFailed || isAuthRetry) throw exception;
|
||||
|
||||
// unset auth tokens and retry
|
||||
AccessToken = RefreshToken = null;
|
||||
|
||||
await ClearAccessToken();
|
||||
return await Send<TRequest, TResponse>(method, path, payload, true, cancellation);
|
||||
}
|
||||
|
||||
@ -175,6 +170,24 @@ 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));
|
||||
}
|
||||
|
||||
@ -183,30 +196,76 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
|
||||
public async Task<ILightningInvoiceListener> CreateInvoiceSession(CancellationToken cancellation = default)
|
||||
{
|
||||
if (await Authorize(cancellation))
|
||||
{
|
||||
var streamUrl = WithTrailingSlash(_baseUri.ToString()) + "invoices/stream";
|
||||
var session = new LndHubInvoiceListener(this);
|
||||
await session.StartListening(streamUrl, AccessToken, cancellation);
|
||||
return session;
|
||||
}
|
||||
|
||||
return null;
|
||||
var at = await GetAccessToken(cancellation);
|
||||
return at is null ? null : new LndHubInvoiceListener(this, cancellation);
|
||||
}
|
||||
|
||||
private async Task<bool> Authorize(CancellationToken cancellation = default)
|
||||
private async Task ClearAccessToken()
|
||||
{
|
||||
var payload = new AuthRequest { Login = _login, Password = _password };
|
||||
var response = await Post<AuthRequest, AuthResponse>("auth", payload, cancellation);
|
||||
using var release = await _locker.LockAsync(CacheKey);
|
||||
_cache.TryRemove(CacheKey, out _);
|
||||
}
|
||||
|
||||
AccessToken = response.AccessToken;
|
||||
RefreshToken = response.RefreshToken;
|
||||
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 (cached.Expiry <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
_cache.TryRemove(CacheKey, out _);
|
||||
}
|
||||
else if (cached.Expiry - DateTimeOffset.UtcNow > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
return cached.AccessToken;
|
||||
}
|
||||
|
||||
return !string.IsNullOrEmpty(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);
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
private static byte[] ParseBase64WithoutPadding(string base64)
|
||||
{
|
||||
switch (base64.Length % 4)
|
||||
{
|
||||
case 2: base64 += "=="; break;
|
||||
case 3: base64 += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
private static string WithTrailingSlash(string str) =>
|
||||
str.EndsWith("/", StringComparison.InvariantCulture) ? str :str + "/";
|
||||
str.EndsWith("/", StringComparison.InvariantCulture) ? str : str + "/";
|
||||
|
||||
private class EmptyRequestModel
|
||||
{
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
@ -7,16 +8,15 @@ using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Lightning.LNDhub.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Lightning.LndHub
|
||||
{
|
||||
public class LndHubInvoiceListener : ILightningInvoiceListener
|
||||
{
|
||||
private readonly LndHubClient _client;
|
||||
private readonly Channel<LightningInvoice> _invoices = Channel.CreateBounded<LightningInvoice>(50);
|
||||
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
||||
private readonly Channel<LightningInvoice> _invoices = Channel.CreateUnbounded<LightningInvoice>();
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private HttpClient _httpClient;
|
||||
private HttpResponseMessage _response;
|
||||
private Stream _body;
|
||||
@ -24,37 +24,12 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
private Task _listenLoop;
|
||||
private readonly List<string> _paidInvoiceIds;
|
||||
|
||||
public LndHubInvoiceListener(LndHubClient lndHubClient)
|
||||
public LndHubInvoiceListener(LndHubClient lndHubClient, CancellationToken cancellation)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
_client = lndHubClient;
|
||||
_paidInvoiceIds = new List<string>();
|
||||
}
|
||||
|
||||
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;
|
||||
_listenLoop = ListenLoop();
|
||||
}
|
||||
|
||||
public async Task<LightningInvoice> WaitInvoice(CancellationToken cancellation)
|
||||
@ -79,26 +54,39 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
static readonly AsyncDuplicateLock _locker = new();
|
||||
static readonly ConcurrentDictionary<string, InvoiceData[]> _activeListeners = new();
|
||||
|
||||
private async Task ListenLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
retry:
|
||||
while (!_cts.IsCancellationRequested)
|
||||
var releaser = await _locker.LockOrBustAsync(_client.CacheKey, _cts.Token);
|
||||
if (releaser is null)
|
||||
{
|
||||
var invoicesData = await _client.GetInvoices(_cts.Token);
|
||||
foreach (var data in invoicesData)
|
||||
while (!_cts.IsCancellationRequested &&releaser is null)
|
||||
{
|
||||
var invoice = LndHubUtil.ToLightningInvoice(data);
|
||||
if (invoice.PaidAt != null && !_paidInvoiceIds.Contains(invoice.Id))
|
||||
if (_activeListeners.TryGetValue(_client.CacheKey, out var invoicesData))
|
||||
{
|
||||
await _invoices.Writer.WriteAsync(invoice, _cts.Token);
|
||||
_paidInvoiceIds.Add(invoice.Id);
|
||||
await HandleInvoicesData(invoicesData);
|
||||
}
|
||||
releaser = await _locker.LockOrBustAsync(_client.CacheKey, _cts.Token);
|
||||
|
||||
if(releaser is null)
|
||||
await Task.Delay(2500, _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(2500, _cts.Token);
|
||||
goto retry;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch when (_cts.IsCancellationRequested)
|
||||
@ -110,69 +98,27 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activeListeners.TryRemove(_client.CacheKey, out _);
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
private async Task HandleInvoicesData(IEnumerable<InvoiceData> invoicesData)
|
||||
{
|
||||
try
|
||||
foreach (var data in invoicesData)
|
||||
{
|
||||
_response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation);
|
||||
_body = await _response.Content.ReadAsStreamAsync();
|
||||
_reader = new StreamReader(_body);
|
||||
while(!cancellation.IsCancellationRequested)
|
||||
var invoice = LndHubUtil.ToLightningInvoice(data);
|
||||
if (invoice.PaidAt != null && !_paidInvoiceIds.Contains(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);
|
||||
}
|
||||
await _invoices.Writer.WriteAsync(invoice, _cts.Token);
|
||||
_paidInvoiceIds.Add(invoice.Id);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
@ -10,23 +10,31 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
{
|
||||
public class LndHubLightningClient : ILightningClient
|
||||
{
|
||||
private readonly LndHubClient _client;
|
||||
public readonly LndHubClient Client;
|
||||
internal readonly Uri _baseUri;
|
||||
internal readonly string _login;
|
||||
internal readonly string _password;
|
||||
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);
|
||||
var data = await Client.GetInfo(cancellation);
|
||||
if (data == null)
|
||||
throw new NotSupportedException("The LNDHub instance does not support GetInfo");
|
||||
|
||||
var nodeInfo = new LightningNodeInformation
|
||||
{
|
||||
@ -39,10 +47,13 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
InactiveChannelsCount = data.InactiveChannelsCount,
|
||||
PendingChannelsCount = data.PendingChannelsCount
|
||||
};
|
||||
foreach (var nodeUri in data.Uris)
|
||||
if (data.Uris != null)
|
||||
{
|
||||
if (NodeInfo.TryParse(nodeUri, out var info))
|
||||
nodeInfo.NodeInfoList.Add(info);
|
||||
foreach (var nodeUri in data.Uris)
|
||||
{
|
||||
if (NodeInfo.TryParse(nodeUri, out var info))
|
||||
nodeInfo.NodeInfoList.Add(info);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeInfo;
|
||||
@ -50,7 +61,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
|
||||
@ -60,24 +71,29 @@ 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 Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default)
|
||||
public async Task<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation = default)
|
||||
{
|
||||
return ListInvoices(null, cancellation);
|
||||
return await GetInvoice(paymentHash.ToString(), cancellation);
|
||||
}
|
||||
|
||||
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = default)
|
||||
{
|
||||
return await 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
|
||||
@ -90,11 +106,29 @@ 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);
|
||||
}
|
||||
|
||||
public async Task<LightningPayment[]> ListPayments(CancellationToken cancellation = default)
|
||||
{
|
||||
return await ListPayments(null, cancellation);
|
||||
}
|
||||
|
||||
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request, CancellationToken cancellation = default)
|
||||
{
|
||||
var payments = await Client.GetTransactions(cancellation);
|
||||
if (request != null)
|
||||
{
|
||||
// we need to filter client-side, because LNDhub does not support these filters
|
||||
payments = payments.Where(payment =>
|
||||
((request.IncludePending.HasValue && request.IncludePending.Value) || LndHubUtil.ToLightningPaymentStatus(payment) != LightningPaymentStatus.Pending) &&
|
||||
(!request.OffsetIndex.HasValue || !payment.Timestamp.HasValue || payment.Timestamp.Value.ToUnixTimeMilliseconds() >= request.OffsetIndex.Value)).ToArray();
|
||||
}
|
||||
return payments.Select(LndHubUtil.ToLightningPayment).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);
|
||||
@ -102,10 +136,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.ToString(), cancellation);
|
||||
return await GetInvoice(invoice.Id, cancellation);
|
||||
}
|
||||
|
||||
public async Task<PayResponse> Pay(string bolt11, CancellationToken cancellation = default)
|
||||
@ -119,14 +153,18 @@ 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 - payAmount;
|
||||
var feeAmount = response.PaymentRoute?.FeeMsat ??
|
||||
(totalAmount is null ? null : totalAmount - payAmount);
|
||||
|
||||
return new PayResponse(PayResult.Ok, new PayDetails
|
||||
{
|
||||
TotalAmount = totalAmount,
|
||||
FeeAmount = feeAmount
|
||||
FeeAmount = feeAmount,
|
||||
Preimage = response.PaymentPreimage,
|
||||
PaymentHash = response.PaymentHash ?? pr.PaymentHash,
|
||||
Status = LightningPaymentStatus.Complete
|
||||
});
|
||||
}
|
||||
catch (LndHubClient.LndHubApiException exception)
|
||||
@ -144,7 +182,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)
|
||||
@ -166,5 +204,17 @@ 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" : "")}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using BTCPayServer.Lightning.LNDhub.Models;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Lightning.LndHub
|
||||
{
|
||||
@ -19,7 +18,8 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
Status = status,
|
||||
ExpiresAt = expiresAt.GetValueOrDefault(),
|
||||
Amount = data.Amount,
|
||||
AmountReceived = data.IsPaid ? data.Amount : null
|
||||
AmountReceived = data.IsPaid ? data.Amount : null,
|
||||
PaymentHash = data.PaymentHash
|
||||
};
|
||||
|
||||
if (data.IsPaid)
|
||||
@ -46,7 +46,7 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
Id = paymentHash,
|
||||
PaymentHash = paymentHash,
|
||||
Preimage = data.PaymentPreimage,
|
||||
Status = LightningPaymentStatus.Complete,
|
||||
Status = ToLightningPaymentStatus(data),
|
||||
CreatedAt = data.Timestamp,
|
||||
Amount = data.Value - data.Fee,
|
||||
AmountSent = data.Value,
|
||||
@ -55,5 +55,12 @@ namespace BTCPayServer.Lightning.LndHub
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
internal static LightningPaymentStatus ToLightningPaymentStatus(TransactionData data)
|
||||
{
|
||||
return data.Value != null && data.Fee != null
|
||||
? LightningPaymentStatus.Complete
|
||||
: LightningPaymentStatus.Pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Lightning.LNDhub.Models
|
||||
@ -9,5 +10,8 @@ namespace BTCPayServer.Lightning.LNDhub.Models
|
||||
|
||||
[JsonProperty("refresh_token")]
|
||||
public string RefreshToken { get; set; }
|
||||
|
||||
[JsonProperty("expiry")]
|
||||
public DateTimeOffset? Expiry { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,6 +28,11 @@ namespace BTCPayServer.Lightning.LNDhub.Models
|
||||
|
||||
[JsonProperty("decoded")]
|
||||
public PaymentData Decoded { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalProperties { get; set; }
|
||||
|
||||
|
||||
}
|
||||
|
||||
public class PaymentRoute
|
||||
|
||||
9
src/BTCPayServer.Lightning.LNDhub/PushNuget.sh
Executable file
9
src/BTCPayServer.Lightning.LNDhub/PushNuget.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
|
||||
<Version>1.3.14</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>
|
||||
@ -1,199 +0,0 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,251 +0,0 @@
|
||||
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 new LightningPayment
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
catch (LNbankClient.LNbankApiException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Lightning.LNbank.Models
|
||||
{
|
||||
public class ConnectNodeRequest
|
||||
{
|
||||
[JsonProperty("nodeURI")]
|
||||
public string NodeURI { get; set; }
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
namespace BTCPayServer.Lightning.LNbank.Models
|
||||
{
|
||||
public class PayInvoiceRequest
|
||||
{
|
||||
public string PaymentRequest { get; set; }
|
||||
public double? MaxFeePercent { get; set; }
|
||||
public long? MaxFeeFlat { get; set; }
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
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
|
||||
@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@ -0,0 +1,2 @@
|
||||
<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>
|
||||
@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
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; }
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user