Compare commits

..

193 Commits

Author SHA1 Message Date
Nicolas Dorier
cb7e70c1ca
Remove LNDHub testing (can't run container anymore) 2026-06-24 16:56:20 +09:00
Nicolas Dorier
da5a0c9011
Revert "Remove LNDHub support (#179)"
This reverts commit 190c7be392.
2026-06-24 16:28:49 +09:00
rockstardev
40e4baf6af
Merge pull request #181 from btcpayserver/feat/lnd-0.21.0
Bumping LND to 0.21.0-beta
2026-06-22 08:08:19 +02:00
rockstardev
42e52c877c
Bumping LND to 0.21.0-beta 2026-06-22 08:02:12 +02:00
Nicolas Dorier
175bdf4d90
bump 2026-06-21 19:31:30 +09:00
Nicolas Dorier
d51e7701f8
Bump core-lightning 2026-06-21 12:34:37 +09:00
Nicolas Dorier
d3071ad752
Cleanup PushNuget 2026-06-19 11:44:17 +09:00
Nicolas Dorier
eb6487a920
bump 2026-06-19 11:41:43 +09:00
Nicolas Dorier
22559ea196
Remove LNBank support 2026-06-19 11:34:39 +09:00
Nicolas Dorier
86ce7c702d
Remove LNBank support 2026-06-19 11:20:18 +09:00
Nicolas Dorier
a819e8eed5
Various improvement to Phoenixd backend (#180) 2026-06-19 11:13:13 +09:00
Nicolas Dorier
190c7be392
Remove LNDHub support (#179)
* Fix: phoenixd would return msat rounded amount with CreateInvoice

* Remove LNDHub support
2026-06-19 09:45:21 +09:00
Nicolas Dorier
3455330dcb
Fix: phoenixd would return msat rounded amount with CreateInvoice 2026-06-19 09:34:48 +09:00
Nicolas Dorier
6ee05f0431
Bump versions 2026-06-09 16:43:24 +09:00
Nicolas Dorier
422181eccd
Bump CI to .NET10 2026-06-09 16:39:07 +09:00
Nicolas Dorier
7ffe1d1645
Bump to .NET10.0 2026-06-09 16:37:20 +09:00
Nicolas Dorier
0055736b6d
Set default fee limit 2026-06-09 16:35:32 +09:00
wario_is_here
9f55ced739 LND: don't set amt_msat when the invoice already has an amount
routerrpc.SendPaymentV2 rejects amt/amt_msat for non-zero amount invoices
(unlike the old SendPaymentSync). Only set amt_msat for amountless invoices
and keysend.
2026-06-08 19:45:10 +02:00
wario_is_here
952e54ba73 LND: pay via routerrpc SendPaymentV2 instead of removed SendPaymentSync
LND 0.21.0 removed lnrpc.SendPaymentSync (REST POST /v1/channels/transactions),
so LndClient.Pay failed against 0.21.0 nodes and payments were never sent.
Switch the send path to POST /v2/router/send (SendPaymentV2), streaming the
result over the existing payment session, and map failure_reason to PayResult.
Also recognise the new INITIATED payment status.
2026-06-08 18:53:49 +02:00
Nicolas Dorier
c3f6c0607c
Add comment 2026-04-24 12:54:12 +09:00
Nicolas Dorier
8983ab70b8
Fix nuget push 2026-04-20 17:20:11 +09:00
Nicolas Dorier
0c0ec5eba5
bump 2026-04-20 16:42:04 +09:00
Nicolas Dorier
0b3b120070
Fix: Phoenxid incorrectly calculating AmountReceived (Fix #176) 2026-04-20 16:39:12 +09:00
rockstardev
5f3226908c
Merge pull request #177 from btcpayserver/feat/lnd-0.19.3-1
Bumping LND to 0.19.3-beta-1
2026-03-25 15:47:00 -05:00
rockstardev
1078e1be8d Bumping LND to 0.19.3-beta-1 2026-03-25 20:42:19 +00:00
Nicolas Dorier
77b139e826
bump 2026-03-23 16:23:47 +09:00
bigg-bb
af165f4cfa
lnd: support amountless bolt11 invoice creation (#175)
Made-with: Cursor

Co-authored-by: bigg-bb <>
2026-03-23 16:22:37 +09:00
Nicolas Dorier
04db053d16
fix typo 2026-02-10 16:40:39 +09:00
Nicolas Dorier
79b652b8d6
bump 2026-02-10 16:37:58 +09:00
Nicolas Dorier
9248d3ea89
Add --api-key 2026-02-10 16:33:57 +09:00
Nicolas Dorier
b90ee8676e
Add PushNuget.sh 2026-02-10 16:26:05 +09:00
Nicolas Dorier
2d2a42962f
bump 2026-02-10 16:17:13 +09:00
rockstardev
d361f633c8
Fix: LND listener would enter infinite loop when LND was restarted 2026-02-10 16:16:00 +09:00
rockstardev
b674167416
Merge pull request #173 from btcpayserver/feat/lnd-0.19.3
Bumping LND to 0.19.3-beta
2025-09-18 23:28:08 -05:00
rockstardev
1aeb2ab3fd
Bumping LND to 0.19.3-beta 2025-09-18 23:17:27 -05:00
nicolas.dorier
8e1b399680
bump cln 2025-07-16 15:53:00 +09:00
rockstardev
8c71e11ba6
Merge pull request #171 from petzsch/feat/lnd-0.19.1
Bumping LND to 0.19.1-beta
2025-06-16 18:25:57 +02:00
Markus Petzsch
88ec6c9aac Bumping LND to 0.19.1-beta 2025-06-16 17:01:07 +02:00
nicolas.dorier
b8019e5e80
bump 2025-06-15 10:23:54 +09:00
rockstardev
385e7f3e5d
Merge pull request #170 from armelinw/phoenixd-fix
Fix chain to be more generic
2025-06-13 23:21:14 +02:00
Armelin Weisse
e876a00f7d
Fix compiler warning and add a comment 2025-06-10 14:58:30 +02:00
Armelin Weisse
91ab59551c
Fix chain to be more generic 2025-06-10 12:37:10 +02:00
nicolas.dorier
c8d1260cc3
bump 2025-05-20 22:24:36 +09:00
armelinw
c972863c77
Add support for Phoenixd (#169)
* Add support for Phoenixd

* Add tests for Phoenixd

* Fix secrets for tests

* Revert "Fix secrets for tests"

This reverts commit 8fe8166eb1001a894ca0f7b7ecb8c94661a27c10.

* Revert "Add tests for Phoenixd"

This reverts commit dc1ad31d4a8b62ae6b79ac8cbde197c95d2c168a.

* Add tests for connection strings

* Fix PayResult value

* Add granular PayResult handling

* Fix PayResult default return value
2025-05-20 22:23:03 +09:00
nicolas.dorier
c269b49567
bump cln 2025-03-31 21:44:48 +09:00
rockstardev
9886308144
Merge pull request #167 from btcpayserver/feat/lnd-0.18.5
Bumping LND to 0.18.5-beta
2025-02-18 23:04:24 -05:00
rockstardev
281bb28a54
Bumping LND to 0.18.5-beta 2025-02-18 22:00:40 -06:00
rockstardev
c631f2bb0e
Merge pull request #166 from btcpayserver/feat/lnd-0.18.4
Bumping LND to 0.18.4-beta
2025-01-07 20:15:40 -05:00
rockstardev
3fde7ec628
Bumping LND to 0.18.4-beta 2025-01-07 19:00:20 -06:00
nicolas.dorier
870daa2720
Bump deps 2024-11-30 10:48:18 +09:00
nicolas.dorier
eac093e03f
bump 2024-11-13 14:34:28 +09:00
nicolas.dorier
fc53710f56
LND and Eclair shouldn't crash of payment isn't found 2024-11-13 14:33:45 +09:00
nicolas.dorier
562105da1e
Fix flaky test 2024-10-25 21:48:55 +09:00
nicolas.dorier
a6bad1e901
ConnectChannel should only push 10% of funds on the other side 2024-10-25 19:45:37 +09:00
nicolas.dorier
2b176dc361
Fix: ConnectChannel should split amount when its too big 2024-10-23 00:05:17 +09:00
nicolas.dorier
c9430fd934
Fix flaky test 2024-10-22 23:11:43 +09:00
nicolas.dorier
cbc87a8e47
bump eclair 2024-10-22 22:59:10 +09:00
nicolas.dorier
80fc6d54a7
Fix eclair flakyness 2024-10-22 22:58:49 +09:00
nicolas.dorier
171d0cdaef
ConnectChannel push 50% of funds on one side of the channel 2024-10-22 22:43:11 +09:00
nicolas.dorier
4cac82ee70
Fix eclair spurious errors, dispose the httpresponse 2024-10-20 22:17:08 +09:00
nicolas.dorier
d2c6854e4c
bump CLN 2024-10-20 20:25:56 +09:00
nicolas.dorier
349ad4974e
Fix: test image of cln 2024-10-20 19:32:45 +09:00
nicolas.dorier
3499786509
Bump CLN 2024-10-20 19:29:47 +09:00
nicolas.dorier
74925edeea
fix warning 2024-10-18 23:05:02 +09:00
nicolas.dorier
38fc1e68a7
bump 2024-10-18 19:00:43 +09:00
nicolas.dorier
840d0a3a7f
Fix: Core lightning send error if explicit amount is set when it shouldn't be 2024-10-18 18:58:35 +09:00
nicolas.dorier
c04829b654
Fix logs un CreateChannel 2024-10-18 14:44:14 +09:00
rockstardev
077a4afc93
Merge pull request #164 from btcpayserver/feat/lnd-0.18.3
Bumping LND to 0.18.3-beta
2024-10-15 18:25:00 -05:00
rockstardev
4e79f720e0 Updating CircleCi image to make CI work 2024-10-15 18:16:08 -05:00
rockstardev
16882b4759 Bumping LND to 0.18.3-beta 2024-10-15 18:14:06 -05:00
rockstardev
1e7a3bd013 Bumping LND to 0.18.1-beta 2024-07-07 07:02:59 -05:00
Kukks
0627bec180
bymp cln 2024-06-12 19:28:29 +02:00
rockstardev
322e0979ab Bumping LND to 0.18.0-beta 2024-05-31 16:26:44 +02:00
nicolas.dorier
3e58958732
Bump clightning 2024-04-10 22:16:53 +09:00
nicolas.dorier
58b70042f2
Fix parsing of ShortChannelId 2024-04-10 22:16:03 +09:00
nicolas.dorier
c447bb8c0a
bump 2024-02-23 11:37:58 +09:00
Kukks
82e8cce170
Add missing certfilepath from LND conn string. fixes #160 2024-02-19 14:21:34 +01:00
rockstardev
d01eda5fe3 Bumping LND to 0.17.4-beta 2024-02-07 15:44:26 -05:00
rockstardev
5702bc24b5 Resetting language version to 10 since it broke Circle CI builder 2024-02-05 17:48:14 -05:00
rockstardev
b927fdb504 Bumping LND to 0.17.4-beta-rc1 2024-02-05 17:48:14 -05:00
nicolas.dorier
bf6916fe78
bump 2024-02-01 14:32:00 +09:00
nicolas.dorier
eb3bd0b824
Fix: Calculate OffChainBalance.Closing correctly (Fix #156) 2024-02-01 14:31:00 +09:00
rockstardev
bfecd34ede Bumping LND to 0.17.3-beta 2023-12-25 01:11:11 -05:00
nicolas.dorier
3b6dfd6e8a
Fix build 2023-12-20 17:48:17 +09:00
nicolas.dorier
647d5f35b7
bump 2023-12-20 17:47:09 +09:00
d11n
12a8df5f27
LNbank ToString fix and display names for Lightning connection types (#153)
* Add display names for Lightning connection types

* LNbank: Cleanups

* LNbank: Add ToString override
2023-12-20 17:43:26 +09:00
d11n
4bc69ba447
LND: Handle permission errors for GetInfo (#152)
The goal of this is to allow usage with non-admin macaroons in BTCPay Server.
2023-12-20 10:43:58 +09:00
nicolas.dorier
898af47a9f
Fix warnings 2023-12-04 18:34:06 +09:00
nicolas.dorier
4ce106dcee
bump 2023-12-04 18:30:52 +09:00
Kukks
f41b2bac43
async lock tests 2023-12-04 10:26:20 +01:00
Kukks
c14d655cfb
fix static 2023-12-03 18:51:24 +01:00
nicolas.dorier
a4369cd648
Do not catch all exception on Eclair ConnectTo 2023-12-03 21:16:27 +09:00
d11n
b2d45ac061
LNDhub: Fix missing null check for payment route (#150) 2023-12-03 21:10:37 +09:00
d11n
412330db13
Eclair: Catch all connection exceptions (#149)
Supposed to fix this test error:

```
  Failed BTCPayServer.Lightning.Tests.CommonTests.DoNotReportOkIfChannelCantConnect [1 m 45 s]
  Error Message:
   System.Threading.Tasks.TaskCanceledException : The request was canceled due to the configured HttpClient.Timeout of 100 seconds elapsing.
---- System.TimeoutException : The operation was canceled.
-------- System.Threading.Tasks.TaskCanceledException : The operation was canceled.
------------ System.IO.IOException : Unable to read data from the transport connection: Operation canceled.
---------------- System.Net.Sockets.SocketException : Operation canceled
```
2023-12-01 17:09:25 +01:00
Kukks
838bda2eb1
try parse lnbits lndhub fee 2023-11-30 12:09:56 +01:00
Kukks
8f242c3efe
fix NRE 2023-11-30 11:18:58 +01:00
Andrew Camilleri
94d869d2fb
Optimize LNDHub (#148)
* Optimize LNDHub

This option is so popular that is was ddosing Alby. We had some severe exessive roundtrips. This PR:
* Caches the access token/refresh token in memory, and the expiry time is extracted from the access token.
* If the expiry is close (5mins), we use the refresh token to get a new access token
* If there are multiple clients using the same user (url and credentials), it reuses the access token instead of asking for a new one
* If there are multiple invoice watchers, only one will actively request invoices, while the others will read from cache. As soon as it is inactive, another switches to fetching the invoices.

* Fix dispose

* Fix warnings

* Fix typo

* Syntactical improvements

* Re-add WithTrailingSlash

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-11-29 07:33:56 +01:00
Nicolas Dorier
e0484122f9
Bump dependencies (#147)
* bump

* Bump dependencies
2023-11-21 18:06:08 +09:00
nicolas.dorier
5e4edf1c77
Remove obsolete Pending_closing_channels to calculate closing balance 2023-11-21 16:17:09 +09:00
d11n
0f066243da
LNDhub fixes (#145)
* Cleanups

* Handle allowinsecure for lndhub scheme URLs

* Ensure trailing slash for base URI

* Add tests for lndhub scheme
2023-11-21 16:03:20 +09:00
nicolas.dorier
fc8b0dc1d9
Fix: Limbo balance on LND is in sat, not msat 2023-11-21 16:02:17 +09:00
rockstardev
f21f8534bf Bumping LND to 0.17.2-beta 2023-11-20 12:54:55 -08:00
rockstardev
9a22bb6536 Bumping LND to 0.17.1-beta 2023-11-14 19:02:32 -08:00
Andrew Camilleri
3a10b19f25
FIx issue with Charge lightmoney values (#143) 2023-10-26 10:44:45 +02:00
rockstardev
d56c58cd26 Bumping LND to 0.17.0-beta 2023-10-25 12:07:22 -07:00
nicolas.dorier
c7a6ca373f
bump libs 2023-10-19 21:20:27 +09:00
Andrew Camilleri
087be52f5b
Pluginize Lightning library (#141)
This PR makes this library pluginizable. What that means is that adding support for new Lightning nodes no longer requires this repository to be modified.
There are breaking changes in terms of integration, but they are minimal and all previous connection strings will work.
2023-10-19 21:18:23 +09:00
Kgothatso
dc2ae91d68
Explicitly set maxfee in CLN (#138)
* Explicitly set maxfee in CLN

From the CLN docs: `maxfee overrides both maxfeepercent and exemptfee defaults (and
if you specify maxfee you cannot specify either of those), and
creates an absolute limit on what fee we will pay. This allows you to
implement your own heuristics rather than the primitive ones used
here.`

* Set feePercent as null when maxFeeFlat is set

* Replace conversion with LightMoney
2023-10-06 15:57:06 +09:00
d11n
0fced9d0bc
LNDhub: Convert LightMoney to int instead of decimal (#140)
From the [LNDhub docs](https://github.com/BlueWallet/LndHub/blob/master/doc/Send-requirements.md): "All amounts are satoshis (int)"

Fixes dennisreimann/btcpayserver-plugin-lndhub-api#3.
2023-10-06 15:55:24 +09:00
d11n
9a2f19d094
Update LND image version (#139) 2023-09-08 11:16:40 +02:00
Nicolas Dorier
97e626a17c
bump clightning (#137) 2023-08-24 21:04:22 +09:00
nicolas.dorier
b34c1b222d
bump 2023-08-24 17:21:12 +09:00
Andrew Camilleri
7a8f27ae33
Fix order of parsing logic (#136)
Before, if supportLegacy was true, it would not attempt to parse lndhub style connections
2023-08-22 13:44:02 +02:00
nicolas.dorier
a5a65cef9c
bump 2023-08-16 23:24:37 +01:00
Kukks
40638e1434
fix lndhub sending when amount is explicit 2023-08-15 15:24:58 +02:00
nicolas.dorier
d6e4e50762
Fix error from clightning if explicit pay amount is same as BOLT11 2023-07-24 18:48:44 +09:00
rockstardev
10f05eca94 Bumping LND to 0.16.4-beta 2023-07-07 10:59:31 -05:00
rockstardev
82aa080bac
Bumping LND to 0.16.3-beta (#134) 2023-06-27 09:06:24 +09:00
nicolas.dorier
65998123e2
bump 2023-06-21 11:15:57 +09:00
Nicolas Dorier
227a022522
Use r_hash instead of r_hash_str for LND (#133) 2023-06-21 11:14:39 +09:00
nicolas.dorier
3e16c38d9d
bump 2023-06-19 18:55:47 +09:00
d11n
14667a7789
LNDhub: Improve GetInfo method (#132)
Details in btcpayserver/btcpayserver#5083
2023-06-19 18:54:06 +09:00
nicolas.dorier
0cae645e82
bump 2023-05-30 11:29:43 +09:00
nicolas.dorier
fb2d1ea966
bump 2023-05-30 11:28:15 +09:00
d11n
148b9b2810
LND: Fix custom record aggregation (#131) 2023-05-30 11:27:16 +09:00
d11n
e70757fcc5
Eclair: Fix build warning (#130)
Minor fix for this build warning in the Eclair module:

```
/src/BTCPayServer.Lightning.Eclair/EclairLightningClient.cs(450,25): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. [/src/BTCPayServer.Lightning.Eclair/BTCPayServer.Lightning.Eclair.csproj]
```
2023-05-25 20:47:43 +09:00
Nicolas Dorier
c5b5d4b4cb
Fix LNBank listening loop (#129) 2023-05-25 12:31:41 +02:00
nicolas.dorier
bc9013d2ef
Fix breaking change with clightning 2023-05-12 17:03:18 +09:00
nicolas.dorier
6c27c486d1
Remove lightning charge 2023-05-12 16:13:02 +09:00
nicolas.dorier
94dd05d5f0
Bump C-Lightning 2023-05-12 16:02:49 +09:00
rockstardev
78f4da934a Bumping LND to 0.16.2-beta 2023-04-29 08:56:59 -05:00
rockstardev
caee0440ce
Bumping LND to 0.16.1-beta (#127) 2023-04-27 13:17:17 +09:00
nicolas.dorier
e31f6f76f9
bump 2023-04-24 17:49:30 +09:00
nicolas.dorier
5bda969251
Return null if invoiceId is incorrect for LND in GetInvoice 2023-04-24 17:46:51 +09:00
rockstardev
13f8eaccc3
Bumping LND to 0.16.0-beta (#125) 2023-04-13 14:48:09 +09:00
nicolas.dorier
ee7e28d1b1
bump 2023-04-07 08:32:20 +09:00
d11n
93401484a9
CLN: Return last result in GetPayment (#124)
There might be multiple ones if the first try failed.
2023-04-07 08:31:07 +09:00
nicolas.dorier
a33c5d4178
bump clightning 2023-03-05 11:09:13 +09:00
nicolas.dorier
a730daa82f
Fix NRE in eclair client 2023-03-04 16:26:18 +09:00
nicolas.dorier
3556108584
Bump cligthning 2023-03-04 16:14:36 +09:00
Nicolas Dorier
e9aa10a607
Revert github actions for nuget package push and tag (#123) 2023-02-21 14:29:36 +09:00
nicolas.dorier
93596ed195
bump 2023-02-21 10:24:22 +09:00
d11n
0fc87e9ab4
LNDhub: Make InvoiceData compatible with LNDhub.go (#122)
Handles the case `r_hash` as a string. LNDhub sends it as a Buffer object, LNDhub.go as string. Closes btcpayserver/btcpayserver#4658.
2023-02-21 10:23:30 +09:00
nicolas.dorier
67db217cd0
Add README for nuget package
Some checks failed
Publish nugets for BTCPay lib / build (push) Has been cancelled
2023-02-08 21:04:20 +09:00
nicolas.dorier
76db2bd4ab
Fix possible NRE in lnhub client 2023-02-08 20:48:02 +09:00
nicolas.dorier
772331cdd4
Fix project name in ci 2023-02-07 10:27:47 +09:00
Andrew Camilleri
0a289a22df
Add Github Actions to publish and tag btcpay ln libraries (#108)
* Add Github Actions to publish and tag btcpay ln libraries

* Apply suggestions from code review

* Apply suggestions from code review

* Remove PushNuget scripts

* Remove github repository push

* Add missing packages

* Remove PACKAGE_NAME

---------

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-02-07 10:25:35 +09:00
nicolas.dorier
da62575f2e
bump 2023-02-02 16:42:49 +09:00
nicolas.dorier
fd565efc12
bump 2023-02-02 16:41:40 +09:00
d11n
c5c129242b
CLN + LNbank: Minor updates (#121)
* CLN: PaymentSecret != Preimage

* LNbank: Lookup invoice by payment hash
2023-02-02 16:40:03 +09:00
nicolas.dorier
12a8aa12f5
bump 2023-01-11 10:07:31 +09:00
d11n
c4b1339490
Payment hash updates (#120)
* CLN: Allow fetching invoice by payment hash with string

* LND: Fix invoice response if there is no preimage
2023-01-11 10:05:44 +09:00
nicolas.dorier
334656e197
bump 2023-01-10 11:52:54 +09:00
d11n
3c6e4a8313
Add payment hash and preimage to LightningInvoice (#119)
Prerequisite for btcpayserver/btcpayserver#4475.
2023-01-10 11:51:35 +09:00
nicolas.dorier
c909a3821a
bump 2023-01-06 21:37:52 +09:00
d11n
a9e7d3e363
Payment hash (#117)
* Payment hash

- Add GetInvoice by payment hash
- PayResponse: Add payment hash, preimage and status to payment details

* Add test on preimage endianness

* Fix lndclient

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-01-06 21:36:41 +09:00
nicolas.dorier
bfdb8e08e1
bump libs 2023-01-05 21:48:07 +09:00
nicolas.dorier
af8c680838
Downgrade NBitcoin 2023-01-05 21:47:20 +09:00
d11n
d4804baa4e
LNbank: Add missing DescriptionHashOnly parameter (#118)
* Upgrade NBitcoin to the version used in BTCPay Server

* Add missing DescriptionHashOnly parameter

Fixes btcpayserver/btcpayserver#4496
2023-01-05 21:41:52 +09:00
nicolas.dorier
5ea90f4204
bump 2022-12-23 16:45:23 +09:00
d11n
d87ab0b585
LNbank fixes (#116)
* Upgrade Logging Abstractions

* Add TimeSpan conversion

* Improve error handling

* Updates from code review
2022-12-23 16:44:02 +09:00
nicolas.dorier
00b2d374e6
bump 2022-12-22 21:48:56 +09:00
nicolas.dorier
38cc376c44
[Tests] Make sure channels are connected in DoNotReportOkIfChannelCantConnect 2022-12-22 21:39:11 +09:00
nicolas.dorier
99f80ca987
Make NonParallelizableCollectionDefinition public 2022-12-22 21:20:44 +09:00
nicolas.dorier
947e95e17d
Handle ConnectChannels in tests better if channel isn't active 2022-12-22 21:20:26 +09:00
nicolas.dorier
b7c8f88c26
bump xunit 2022-12-22 20:38:53 +09:00
nicolas.dorier
1d9a32c266
Decrease verbosity of tests 2022-12-22 20:20:30 +09:00
nicolas.dorier
edb9ce3368
Do not run tests in parallel 2022-12-22 20:14:07 +09:00
nicolas.dorier
f556fafd46
flaky 2022-12-22 19:07:06 +09:00
d11n
8cb4a656d5
LightMoney: Fix float conversion (#114)
Eclair denominates global balance amounts in BTC and Charge seems to export large amounts as floats.

The solution is to separate the two clearly and fix the float conversion in the `LightMoneyJsonConverter`.

Fixes btcpayserver/btcpayserver#4383.
2022-12-22 15:31:43 +09:00
d11n
0450a9207b
Eclair updates (#112)
Extracted these from #111:

- Incorporate payment timeout pattern from #106
- Improve failure response
- Fix missing payment info (some [`getsentinfo` properties](https://acinq.github.io/eclair/#getsentinfo) weren't correct)
- Upgrade containers to 0.8.0
2022-12-15 15:25:17 +09:00
nicolas.dorier
52601db6be
Make connect channels faster 2022-12-14 11:44:29 +09:00
nicolas.dorier
51ef487d8d
bump clightning 2022-12-14 10:58:01 +09:00
Nicolas Dorier
7c2be5bf15
Introduce CreateInvoiceParams.DescriptionHashOnly (#110) 2022-12-07 18:44:29 +09:00
nicolas.dorier
0dd59d0aa3
bump 2022-12-06 20:58:48 +09:00
nicolas.dorier
5ae595eb2c
Fixing clightning support 2022-12-06 20:57:37 +09:00
nicolas.dorier
b5eeca42b3
bump Lightning.Common Newtonsoft package 2022-12-05 19:17:57 +09:00
nicolas.dorier
d6274b83b2
bump clightning 2022-12-05 19:16:44 +09:00
nicolas.dorier
d7f957b3ab
bump 2022-12-05 11:18:37 +09:00
nicolas.dorier
250b76f97a
Format and Parse NodeInfo with IPv6 correctly (Fix https://github.com/btcpayserver/btcpayserver/issues/4245) 2022-12-05 11:14:37 +09:00
d11n
21fc136dc7
Add ListPayments (#109) 2022-12-05 10:40:32 +09:00
nicolas.dorier
028364c9ba
Revert "Bump c-ligthning"
This reverts commit f3917fe3f8.
2022-11-29 15:54:52 +09:00
nicolas.dorier
f3917fe3f8
Bump c-ligthning 2022-11-29 15:51:55 +09:00
nicolas.dorier
c5e0e6b637
bump bitcoin core 2022-11-28 21:42:29 +09:00
nicolas.dorier
ea00e7dded
bump 2022-11-26 22:22:06 +09:00
nicolas.dorier
87d74b1ab1
Fix LNDClient crashing if using GetPayment twice in a row with a defaulthttpclient 2022-11-26 22:19:42 +09:00
rockstardev
8a874b314f
Merge pull request #107 from btcpayserver/feat/lnd-0.15.4-beta-1
Bumping LND to 0.15.4-beta-1
2022-11-04 14:32:28 -05:00
rockstardev
6c2bfc787d
Bumping LND to 0.15.4-beta-1 2022-11-04 10:55:25 -05:00
d11n
ca85529b34
LND/CLN: Timeout payments and respond gracefully (#106)
* LND/CLN: Timeout payments and respond gracefully

Times out invoice payments after 30 seconds and returns the new `PayResult.Unknown` in those cases.

Clients like the Greenfield API can then call/poll `GetPayment` to get the state and result for pending payments.

For context see [this comment](https://github.com/btcpayserver/btcpayserver/issues/3781#issuecomment-1293446246).

* Use IsCancellationRequested in exception handler

* Make SendTimeout configurable

* bump

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2022-10-31 18:28:17 +09:00
d11n
9a8b609438
LND: Improve error handling (#105)
Also handles cases with nested error responses. Came across this while debugging btcpayserver/btcpayserver#3781.
2022-10-28 17:23:05 +09:00
nicolas.dorier
3945c3937f
Bump libs 2022-10-26 13:32:21 +09:00
nicolas.dorier
92a3c7bf8d
Fix parsing of NodeInfo if no port specified 2022-10-26 13:23:34 +09:00
nicolas.dorier
9682222208
Update bitcoin core and LND in docker-compose 2022-10-19 22:03:10 +09:00
123 changed files with 3798 additions and 3203 deletions

View File

@ -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:

View File

@ -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

View File

@ -20,9 +20,7 @@ Here is a description of all packages:
* `BTCPayServer.Lightning.Common` exposes common classes and `ILightningClient` [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.Common.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.Common)
* `BTCPayServer.Lightning.LND` exposes easy to use LND clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.LND.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.LND)
* `BTCPayServer.Lightning.CLightning` exposes easy to use clightning clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.CLightning.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.CLightning)
* `BTCPayServer.Lightning.Charge` exposes easy to use Charge clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.Charge.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.Charge)
* `BTCPayServer.Lightning.Eclair` exposes easy to use Eclair clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.Eclair.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.Eclair)
* `BTCPayServer.Lightning.LNbank` exposes easy to use LNbank clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.LNbank.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.LNbank)
* `BTCPayServer.Lightning.LNDhub` exposes easy to use LNDhub clients [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.LNDhub.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.LNDhub)
If you develop an app, we advise you to reference `BTCPayServer.Lightning.All` [![NuGet](https://img.shields.io/nuget/v/BTCPayServer.Lightning.All.svg)](https://www.nuget.org/packages/BTCPayServer.Lightning.All).
@ -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

View File

@ -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>

View File

@ -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)

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View 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";
}

View 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

View File

@ -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>

View File

@ -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

View File

@ -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);
}
}

View File

@ -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; }
}
}

View File

@ -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);
}
}

View File

@ -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; }
}
}

View 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; }
}
}

View File

@ -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; }
}
}

View 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

View File

@ -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;

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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));
}
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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>

View 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
}
}

View File

@ -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; }
}

View File

@ -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;
}
}
}

View File

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

View File

@ -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
};
}

View File

@ -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));
}
}
}

View File

@ -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; }

View File

@ -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; }

View File

@ -0,0 +1,7 @@
namespace BTCPayServer.Lightning;
public class ListPaymentsParams
{
public bool? IncludePending { get; set; }
public long? OffsetIndex { get; set; }
}

View File

@ -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);

View File

@ -19,8 +19,6 @@ namespace BTCPayServer.Lightning
{
get; set;
}
public bool? Private { get; set; }
public static void AssertIsSane(OpenChannelRequest openChannelRequest)
{
if (openChannelRequest == null)

View File

@ -19,7 +19,5 @@ namespace BTCPayServer.Lightning
{
get; set;
}
public string ChannelId { get; set; }
}
}

View File

@ -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);
}

View File

@ -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; }
}
}

View 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

View File

@ -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>

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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));
}
}
}

View File

@ -0,0 +1,8 @@
namespace BTCPayServer.Lightning.Eclair.Models
{
public enum ChannelFlags
{
Private = 0,
Public = 1
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }

View 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

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View 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);
}
}

View File

@ -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; }
}
}

View File

@ -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;
}
}

View File

@ -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
{

View File

@ -23,7 +23,7 @@ namespace BTCPayServer.Lightning.LND
{
}
public LndException(LndError error) : base(error.Message)
public LndException(LNDError error) : base(error.Message)
{
if (error == null)
throw new ArgumentNullException(nameof(error));
@ -31,8 +31,8 @@ namespace BTCPayServer.Lightning.LND
}
private readonly LndError _Error;
public LndError Error
private readonly LNDError _Error;
public LNDError Error
{
get
{
@ -40,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)

View 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

View 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();
}
}
}

View File

@ -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>

View File

@ -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)

View File

@ -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();
}

View File

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

View File

@ -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
{

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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" : "")}";
}
}
}

View File

@ -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;
}
}
}

View File

@ -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; }
}
}

View File

@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Lightning.JsonConverters;
using BTCPayServer.Lightning.LNDhub.JsonConverters;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Lightning.LNDhub.Models
{
@ -28,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

View 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

View File

@ -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>

View File

@ -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;
}
}
}
}

View File

@ -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();
}
}
}

View File

@ -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
};
}
}

View File

@ -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; }
}
}

View File

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

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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);
}
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}

View File

@ -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