Compare commits

...

92 commits

Author SHA1 Message Date
daywalker90
27768bb95d CI: fix mkdir error
Some checks failed
master on CLN v25.09.x / call-ci (push) Has been cancelled
master on CLN v25.12.x / call-ci (push) Has been cancelled
master on CLN v26.04.x / call-ci (push) Has been cancelled
master on CLN v26.06.x / call-ci (push) Has been cancelled
2026-08-12 00:27:11 +02:00
daywalker90
7c100cf5d2 add default relays
Some checks failed
master on CLN v25.09.x / call-ci (push) Has been cancelled
master on CLN v25.12.x / call-ci (push) Has been cancelled
master on CLN v26.04.x / call-ci (push) Has been cancelled
master on CLN v26.06.x / call-ci (push) Has been cancelled
2026-08-09 20:15:55 +02:00
daywalker90
7241ae39d3 bump to version 0.2.0 2026-08-09 20:15:55 +02:00
daywalker90
cc7d075bf4 CHANGELOG update 2026-08-09 20:15:55 +02:00
daywalker90
e98d4a1a54 refactor read to receive in code to match docs 2026-08-09 18:01:53 +02:00
daywalker90
7d132a60e4 add note for global notifications 2026-08-09 18:01:53 +02:00
daywalker90
d93a03014b fix reuse of retries counter in check_hold_support 2026-08-09 18:01:53 +02:00
daywalker90
1d5bd7c821 remove since filter because we now dedup all events by id 2026-08-09 18:01:53 +02:00
daywalker90
a26eb7a539 fix nwc-create rpc erroring if running nwc failed, log instead since we already persist it 2026-08-09 18:01:53 +02:00
daywalker90
101234e8ef fix error type if payment route was too expensive 2026-08-09 18:01:53 +02:00
daywalker90
ae4e00967d fix paying of amount-less invoices with a request amount 2026-08-09 18:01:53 +02:00
daywalker90
ffd9bd21e9 fix legacy keysend tlv byte decoding 2026-08-09 18:01:53 +02:00
daywalker90
102de7345d fix holdinvoice always having 0 deadline 2026-08-09 18:01:53 +02:00
daywalker90
2fcc48ce34 don't fail sending notifications on the first client error 2026-08-09 18:01:53 +02:00
daywalker90
2044151b4a use cln-rpc structs for notification deserialization 2026-08-09 18:01:53 +02:00
daywalker90
e175198c45 misc: fix log levels, substraction overflow, invoice valid check 2026-08-09 15:56:34 +02:00
daywalker90
b31f42691f fix unaccepted hold invoices in the accepted handler
holdinvoice_accepted_handler waited on the hold plugin track stream and
sent a HoldInvoiceAccepted notification afterwards, without checking the
reason the stream ended. When an invoice was cancelled, the stream just closes and the handler still sent
a spurious accepted notification.

Only send the notification if the stream actually reported the invoice
as accepted.

The hold plugin has no expired state: an invoice that expires without
ever being accepted stays Unpaid and the track stream never ends, so the
handler previously hung forever, holding a task and a gRPC stream open.

Pass the invoice's expiry into the handler and time out the track stream
at that point (capped), so it gives up on expired invoices and never
sends a spurious accepted notification. When re-spawning pending
invoices at startup, derive the expiry from the decoded bolt11.

Add test_hold_invoice_expiry which creates an invoice with a short
expiry and asserts the handler gives up without sending the notification.
2026-08-09 15:39:01 +02:00
daywalker90
29c1ace33c bound list_transactions work to prevent DoS
list_transactions was a DoS vector: it loaded the full invoice and
payment history, made one decode rpc-call per entry, and re-serialized
the whole result set for every trimmed entry (O(n^2)), all while holding
the global rpc lock that serializes every other nwc request and command.

- run list_transactions on a dedicated rpc connection so it no longer
  stalls the rest of the plugin
- page backwards through listinvoices by created_index instead of
  fetching the whole history, using the wait rpc to get the newest index
- only decode the kept candidates and honor the from/until and unpaid
  filters while collecting them
- cap work per request at MAX_TRANSACTIONS (500) regardless of node
  history size
- limit pays and hold invoices to the same bound before decoding
- make trim_to_size a single serialization pass instead of O(n^2)
2026-08-09 14:24:15 +02:00
daywalker90
c1bb7606cf reserve budget during in-flight payments, drop lock while paying 2026-08-09 12:29:09 +02:00
daywalker90
f79db424cd determine available methods from help rpc isntead of version string 2026-08-09 12:29:09 +02:00
daywalker90
b2078bab81 remove budget task, update budget on demand 2026-08-09 12:29:09 +02:00
daywalker90
651b11b229 return error on unsupported invoice type lookup for holdinvoices
Some checks are pending
master on CLN v25.09.x / call-ci (push) Waiting to run
master on CLN v25.12.x / call-ci (push) Waiting to run
master on CLN v26.04.x / call-ci (push) Waiting to run
master on CLN v26.06.x / call-ci (push) Waiting to run
2026-08-08 17:43:02 +02:00
daywalker90
408b015d91 improve handling of stale or duplicate events 2026-08-08 17:43:02 +02:00
daywalker90
6ff3069fa8 README: update binaries notes 2026-08-08 15:19:11 +02:00
daywalker90
76f10bc2c5 README: update CLN version 2026-08-08 15:19:11 +02:00
dependabot[bot]
66edfb7c5e Bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:18:53 +02:00
dependabot[bot]
0a078e0b76 Bump actions/cache from 5 to 6
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:16:39 +02:00
dependabot[bot]
07bf8288f5 Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:16:06 +02:00
daywalker90
18c00d3442 SECURITY.md: init 2026-08-08 15:08:48 +02:00
daywalker90
5c65a94c13 add retry logic for hold server connect 2026-08-08 15:06:37 +02:00
daywalker90
f5b64586fc tests: update to nostr sdk 0.45 2026-08-08 15:06:37 +02:00
daywalker90
edd95056ab update for nostr 0.45 release version 2026-08-08 15:06:37 +02:00
daywalker90
6d984a0900 CI: download hold binary 2026-08-08 15:06:37 +02:00
daywalker90
61c07f6079 refactor, clippy and ruff fixes 2026-08-08 15:06:37 +02:00
daywalker90
f6517cab7e update dependencies 2026-08-08 15:06:37 +02:00
daywalker90
f85b2efcb3 update dependencies, upgrade nostr and cln dependencies 2026-08-08 15:06:37 +02:00
daywalker90
fb972c5ed1 CI: use specific python versions per CLN version 2026-08-08 15:06:37 +02:00
daywalker90
af306edd3a use xkeysend on CLN v26.06+ 2026-08-08 15:06:37 +02:00
daywalker90
b16db82693 more nostr v0.45 prep 2026-08-08 15:06:37 +02:00
daywalker90
6690c9d49d removed multi_* methods 2026-08-08 15:06:37 +02:00
daywalker90
78ac7cac05 prepare for nostr 0.45 2026-08-08 15:06:37 +02:00
daywalker90
5c5f49de29 wait for hold certs to be generated 2026-08-08 15:06:37 +02:00
daywalker90
c956338bcd add support for holdinvoices 2026-08-08 15:06:37 +02:00
dependabot[bot]
140d295eec Bump zerofrom from 0.1.7 to 0.1.8 in the all-dependencies group
Bumps the all-dependencies group with 1 update: [zerofrom](https://github.com/unicode-org/icu4x).


Updates `zerofrom` from 0.1.7 to 0.1.8
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits)

---
updated-dependencies:
- dependency-name: zerofrom
  dependency-version: 0.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 11:13:23 +02:00
dependabot[bot]
004e403d48 Bump the all-dependencies group with 3 updates
Bumps the all-dependencies group with 3 updates: [tokio](https://github.com/tokio-rs/tokio), [bitcoin](https://github.com/rust-bitcoin/rust-bitcoin) and [cc](https://github.com/rust-lang/cc-rs).


Updates `tokio` from 1.52.1 to 1.52.3
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

Updates `bitcoin` from 0.32.8 to 0.32.9
- [Release notes](https://github.com/rust-bitcoin/rust-bitcoin/releases)
- [Changelog](https://github.com/rust-bitcoin/rust-bitcoin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-bitcoin/rust-bitcoin/compare/bitcoin-0.32.8...bitcoin-0.32.9)

Updates `cc` from 1.2.61 to 1.2.62
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.61...cc-v1.2.62)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: bitcoin
  dependency-version: 0.32.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: cc
  dependency-version: 1.2.62
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 11:24:31 +02:00
dependabot[bot]
a2781bee20 Bump rustls from 0.23.39 to 0.23.40 in the all-dependencies group
Bumps the all-dependencies group with 1 update: [rustls](https://github.com/rustls/rustls).


Updates `rustls` from 0.23.39 to 0.23.40
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.39...v/0.23.40)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.40
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 09:34:45 +02:00
dependabot[bot]
0c2a2a15fb Bump the all-dependencies group with 4 updates
Bumps the all-dependencies group with 4 updates: [cc](https://github.com/rust-lang/cc-rs), [data-encoding](https://github.com/ia0/data-encoding), [libc](https://github.com/rust-lang/libc) and [rustls-pki-types](https://github.com/rustls/pki-types).


Updates `cc` from 1.2.60 to 1.2.61
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.60...cc-v1.2.61)

Updates `data-encoding` from 2.10.0 to 2.11.0
- [Commits](https://github.com/ia0/data-encoding/compare/v2.10.0...v2.11.0)

Updates `libc` from 0.2.185 to 0.2.186
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.186/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.185...0.2.186)

Updates `rustls-pki-types` from 1.14.0 to 1.14.1
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.14.0...v/1.14.1)

---
updated-dependencies:
- dependency-name: cc
  dependency-version: 1.2.61
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: data-encoding
  dependency-version: 2.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all-dependencies
- dependency-name: libc
  dependency-version: 0.2.186
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: rustls-pki-types
  dependency-version: 1.14.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 09:07:15 +02:00
dependabot[bot]
07159082d8 Bump the all-dependencies group with 2 updates
Bumps the all-dependencies group with 2 updates: [bitcoin-units](https://github.com/rust-bitcoin/rust-bitcoin) and [typenum](https://github.com/paholg/typenum).


Updates `bitcoin-units` from 0.1.2 to 0.1.3
- [Release notes](https://github.com/rust-bitcoin/rust-bitcoin/releases)
- [Changelog](https://github.com/rust-bitcoin/rust-bitcoin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-bitcoin/rust-bitcoin/compare/io-0.1.2...bitcoin-units-0.1.3)

Updates `typenum` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/paholg/typenum/releases)
- [Changelog](https://github.com/paholg/typenum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/paholg/typenum/compare/v1.19.0...v1.20.0)

---
updated-dependencies:
- dependency-name: bitcoin-units
  dependency-version: 0.1.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: typenum
  dependency-version: 1.20.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 13:40:36 +02:00
daywalker90
7ec1e68d6c fix budget race condition 2026-04-23 11:04:47 +02:00
daywalker90
ce0e1fe585 Cargo.lock: update tokio to v1.52.1 2026-04-23 11:04:47 +02:00
dependabot[bot]
aff911ed90 Bump the all-dependencies group with 10 updates
Bumps the all-dependencies group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [tokio](https://github.com/tokio-rs/tokio) | `1.50.0` | `1.52.0` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.23.0` | `1.23.1` |
| [bitflags](https://github.com/bitflags/bitflags) | `2.11.0` | `2.11.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.58` | `1.2.60` |
| [indexmap](https://github.com/indexmap-rs/indexmap) | `2.13.1` | `2.14.0` |
| [libc](https://github.com/rust-lang/libc) | `0.2.184` | `0.2.185` |
| [lru](https://github.com/jeromefroe/lru-rs) | `0.16.3` | `0.16.4` |
| [rustls](https://github.com/rustls/rustls) | `0.23.37` | `0.23.38` |
| [semver](https://github.com/dtolnay/semver) | `1.0.27` | `1.0.28` |
| [tokio-macros](https://github.com/tokio-rs/tokio) | `2.6.1` | `2.7.0` |


Updates `tokio` from 1.50.0 to 1.52.0
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.52.0)

Updates `uuid` from 1.23.0 to 1.23.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.0...v1.23.1)

Updates `bitflags` from 2.11.0 to 2.11.1
- [Release notes](https://github.com/bitflags/bitflags/releases)
- [Changelog](https://github.com/bitflags/bitflags/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitflags/bitflags/compare/2.11.0...2.11.1)

Updates `cc` from 1.2.58 to 1.2.60
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.58...cc-v1.2.60)

Updates `indexmap` from 2.13.1 to 2.14.0
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.13.1...2.14.0)

Updates `libc` from 0.2.184 to 0.2.185
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.185/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.184...0.2.185)

Updates `lru` from 0.16.3 to 0.16.4
- [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jeromefroe/lru-rs/compare/0.16.3...0.16.4)

Updates `rustls` from 0.23.37 to 0.23.38
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.38)

Updates `semver` from 1.0.27 to 1.0.28
- [Release notes](https://github.com/dtolnay/semver/releases)
- [Commits](https://github.com/dtolnay/semver/compare/1.0.27...1.0.28)

Updates `tokio-macros` from 2.6.1 to 2.7.0
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-macros-2.6.1...tokio-macros-2.7.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-dependencies
- dependency-name: uuid
  dependency-version: 1.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: bitflags
  dependency-version: 2.11.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: cc
  dependency-version: 1.2.60
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: indexmap
  dependency-version: 2.14.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all-dependencies
- dependency-name: libc
  dependency-version: 0.2.185
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: lru
  dependency-version: 0.16.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: rustls
  dependency-version: 0.23.38
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: semver
  dependency-version: 1.0.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: all-dependencies
- dependency-name: tokio-macros
  dependency-version: 2.7.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: all-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 11:04:47 +02:00
dependabot[bot]
f26b82bb51 Bump the cargo group across 1 directory with 2 updates
Bumps the cargo group with 2 updates in the / directory: [rand](https://github.com/rust-random/rand) and [rustls-webpki](https://github.com/rustls/webpki).


Updates `rand` from 0.8.5 to 0.8.6
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.8.6/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.8.5...0.8.6)

Updates `rustls-webpki` from 0.103.10 to 0.103.13
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.13)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.8.6
  dependency-type: indirect
  dependency-group: cargo
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 00:40:13 +02:00
daywalker90
80926ba9b7
CI: update to min python 3.10 and bitcoind 31.0 2026-04-23 00:20:49 +02:00
daywalker90
3caf173a86
CI: update to CLN v26.04 2026-04-23 00:20:12 +02:00
daywalker90
f13c30452e
CI: add cargo updates to dependabot 2026-04-23 00:17:58 +02:00
daywalker90
c4cbcbe4d2
tests: update python min to 3.10 2026-04-23 00:17:36 +02:00
daywalker90
99ba77568d
add linker config for cargo 2026-04-03 10:30:45 +02:00
daywalker90
4b43fe5b0c
CI: updated release workflow to support macOS 2026-04-03 10:16:59 +02:00
daywalker90
804c19f945
prepare 0.1.8 2026-04-03 10:16:21 +02:00
daywalker90
01b48646a6
upgrade cln dependencies and rust edition 2026-04-03 10:13:47 +02:00
daywalker90
4fdc9e0265
tests: upgrade dependencies 2026-04-03 10:13:22 +02:00
daywalker90
9763b91d2e
tests: allow for more warn logs 2026-03-02 17:15:37 +01:00
dependabot[bot]
919c50060f Bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 15:52:53 +01:00
dependabot[bot]
24ccad1ae5 Bump actions/download-artifact from 7 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 15:52:44 +01:00
dependabot[bot]
cccc9e38db Bump the uv group across 1 directory with 2 updates
Bumps the uv group with 2 updates in the /tests directory: [flask](https://github.com/pallets/flask) and [werkzeug](https://github.com/pallets/werkzeug).


Updates `flask` from 3.1.2 to 3.1.3
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/3.1.2...3.1.3)

Updates `werkzeug` from 3.1.4 to 3.1.6
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/3.1.4...3.1.6)

---
updated-dependencies:
- dependency-name: flask
  dependency-version: 3.1.3
  dependency-type: indirect
  dependency-group: uv
- dependency-name: werkzeug
  dependency-version: 3.1.6
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 13:21:00 +01:00
daywalker90
a7c209594a
CI: retry options and save cache only on success 2026-03-02 13:11:56 +01:00
dependabot[bot]
fee812eda7 Bump cryptography in /tests in the uv group across 1 directory
Bumps the uv group with 1 update in the /tests directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.3 to 46.0.5
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-11 12:39:15 +01:00
dependabot[bot]
1da7d6447d Bump urllib3 in /tests in the uv group across 1 directory
Bumps the uv group with 1 update in the /tests directory: [urllib3](https://github.com/urllib3/urllib3).


Updates `urllib3` from 2.6.2 to 2.6.3
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.2...2.6.3)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.6.3
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:06:20 +01:00
dependabot[bot]
c7cd5434bc Bump bytes from 1.11.0 to 1.11.1 in the cargo group across 1 directory
Bumps the cargo group with 1 update in the / directory: [bytes](https://github.com/tokio-rs/bytes).


Updates `bytes` from 1.11.0 to 1.11.1
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:02:17 +01:00
daywalker90
be81402c99
tests: remove relay terminate delay again 2026-01-22 12:43:34 +01:00
daywalker90
46d8e05b04
tests: force get_unused_port useage and allow unclean shutdown of relay 2026-01-22 12:35:40 +01:00
daywalker90
b56c1d2869
CI: verify MSRV 2026-01-15 13:47:03 +01:00
daywalker90
d8f5f105d4
CI: update to bitcoind 30.2 2026-01-09 11:50:36 +01:00
dependabot[bot]
e97b7cf88e Bump lru from 0.16.2 to 0.16.3 in the cargo group across 1 directory
Bumps the cargo group with 1 update in the / directory: [lru](https://github.com/jeromefroe/lru-rs).


Updates `lru` from 0.16.2 to 0.16.3
- [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jeromefroe/lru-rs/compare/0.16.2...0.16.3)

---
updated-dependencies:
- dependency-name: lru
  dependency-version: 0.16.3
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-07 21:50:39 +01:00
daywalker90
600a577458
CI: make releases immutable 2026-01-05 17:01:37 +01:00
dependabot[bot]
f084379b1e Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-05 13:22:00 +01:00
dependabot[bot]
a3a92fc841 Bump actions/download-artifact from 4 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-05 13:21:51 +01:00
dependabot[bot]
d7c2cd4911 Bump actions/upload-artifact from 4 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-05 13:21:04 +01:00
daywalker90
08b5009d54
switch branch name to master 2026-01-05 13:17:07 +01:00
daywalker90
320b68c8c7
Configure Dependabot for GitHub Actions 2026-01-05 12:16:03 +01:00
daywalker90
727f710646 tests: use nostr-rs-relay instead of nostr_relay 2025-12-31 23:56:36 +01:00
daywalker90
7dff9f83e7
tests: move pyproject to tests 2025-12-09 16:23:34 +01:00
daywalker90
65ec413753
README: update for new CLN v25.12 2025-12-08 23:30:01 +01:00
daywalker90
d76ad4e89f
CI: upgrade to CLN v25.12 2025-12-08 23:13:41 +01:00
daywalker90
be201f906c
CI: add cargo test 2025-12-02 13:51:28 +01:00
daywalker90
3fdef99ba6
bump to 0.1.7 2025-11-27 16:21:09 +01:00
daywalker90
72664ca045
fix payment_sent notification not working properly with failed attempts 2025-11-27 16:17:11 +01:00
daywalker90
52f4b5b48b
tests: require python v3.9.2 2025-11-14 13:26:51 +01:00
daywalker90
11107c6745
start processing events a second older for tests/restarts 2025-11-12 14:00:48 +01:00
daywalker90
68000fae96
upgrade cln crates to 0.5 2025-11-12 13:33:30 +01:00
daywalker90
3aec0ea517
tests: try to fix address already in use flake 2025-11-10 16:33:51 +01:00
daywalker90
bd5fa21e6f
add rustfmt.toml and apply it 2025-11-10 15:36:56 +01:00
49 changed files with 6660 additions and 5198 deletions

View file

@ -1,2 +1,8 @@
[resolver]
incompatible-rust-versions = "fallback"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

37
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,37 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "UTC"
groups:
all-dependencies:
patterns:
- "*"
cooldown:
default-days: 7
open-pull-requests-limit: 1
versioning-strategy: "auto"
labels:
- "dependencies"
- "rust"
allow:
- dependency-type: "all"

View file

@ -17,6 +17,12 @@ on:
tagged-release:
required: true
type: boolean
python-version-1:
required: true
type: string
python-version-2:
required: true
type: string
jobs:
build:
@ -24,27 +30,25 @@ jobs:
strategy:
fail-fast: false
matrix:
bitcoind-version: ["30.0"]
bitcoind-version: ["31.0"]
experimental: [1]
deprecated: [0]
python-version: ["3.9", "3.13"]
python-version: [ "${{ inputs.python-version-1 }}", "${{ inputs.python-version-2 }}"]
os: ["ubuntu-latest"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Create cache paths
run: |
sudo mkdir /usr/local/libexec
sudo mkdir /usr/local/libexec/c-lightning
sudo mkdir /usr/local/libexec/c-lightning/plugins
sudo mkdir -p /usr/local/libexec/c-lightning/plugins
sudo chown -R $USER /usr/local/libexec
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
@ -60,7 +64,7 @@ jobs:
- name: Restore CLN cache
id: cache-cln
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: |
/usr/local/bin/lightning*
@ -69,13 +73,13 @@ jobs:
- name: Restore bitcoind cache
id: cache-bitcoind
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: /usr/local/bin/bitcoin*
key: cache-bitcoind-${{ matrix.bitcoind-version }}-${{ steps.exact_versions.outputs.os_version }}
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
@ -85,15 +89,15 @@ jobs:
run: |
export BITCOIND_VERSION=${{ matrix.bitcoind-version }}
export TARGET_ARCH="x86_64-linux-gnu"
wget https://bitcoincore.org/bin/bitcoin-core-${BITCOIND_VERSION}/bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz
wget --tries=5 --waitretry=5 --retry-connrefused --timeout=20 --continue https://bitcoincore.org/bin/bitcoin-core-${BITCOIND_VERSION}/bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz
tar -xzf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz
sudo mv bitcoin-${BITCOIND_VERSION}/bin/* /usr/local/bin
rm -rf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz bitcoin-${BITCOIND_VERSION}
- name: Save bitcoind cache
id: save-cache-bitcoind
if : ${{ always() && steps.cache-bitcoind.outputs.cache-hit != 'true'}}
uses: actions/cache/save@v4
if : ${{ success() && steps.cache-bitcoind.outputs.cache-hit != 'true'}}
uses: actions/cache/save@v6
with:
path: /usr/local/bin/bitcoin*
key: ${{ steps.cache-bitcoind.outputs.cache-primary-key }}
@ -104,14 +108,14 @@ jobs:
url=$(curl -f --retry 3 --retry-delay 3 -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/ElementsProject/lightning/releases/tags/${{ inputs.cln-version }} \
| jq '.assets[] | select(.name | contains("24.04")) | .browser_download_url' \
| tr -d '\"')
wget $url
wget --tries=5 --waitretry=5 --retry-connrefused --timeout=20 --continue $url
sudo tar -xvf ${url##*/} -C /usr/local --strip-components=2
echo "CLN_VERSION=$(lightningd --version)" >> "$GITHUB_OUTPUT"
- name: Save CLN cache
id: save-cache-cln
if : ${{ always() && steps.cache-cln.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
if : ${{ success() && steps.cache-cln.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v6
with:
path: |
/usr/local/bin/lightning*
@ -119,14 +123,22 @@ jobs:
key: ${{ steps.cache-cln.outputs.cache-primary-key }}
- name: Set up Rust
if: ${{ inputs.tagged-release == false}}
if: ${{ inputs.tagged-release == false }}
uses: dtolnay/rust-toolchain@stable
- name: Set up protoc
if: ${{ inputs.tagged-release == false}} && ${{ steps.cache-cln.outputs.cache-hit != 'true' }}
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install cargo-binstall
if: ${{ inputs.tagged-release == false }}
uses: taiki-e/install-action@cargo-binstall
- name: Install cargo-msrv
if: ${{ inputs.tagged-release == false }}
env:
GITHUB_TOKEN: ${{ github.token }}
run: cargo binstall --no-confirm cargo-msrv
- name: Verify MSRV
if: ${{ inputs.tagged-release == false }}
run: cargo msrv verify
- name: Get plugin binary
run: |
@ -139,7 +151,15 @@ jobs:
uv run python -m grpc_tools.protoc --proto_path="proto" --python_out="tests" --grpc_python_out="tests" proto/*.proto
fi
url=$(curl -f --retry 3 --retry-delay 3 -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/BoltzExchange/hold/releases/latest \
| jq '.assets[] | select(.name | endswith("linux-amd64.tar.gz")) | .browser_download_url' \
| tr -d '\"')
wget $url
tar -xvf ${url##*/} -C tests
mv "tests/build/hold-linux-amd64" "tests/hold"
cargo build
cargo test
fi
- name: Run tests
@ -153,5 +173,7 @@ jobs:
export VALGRIND=0
export PYTEST_TIMEOUT=600
cd tests
tar -xf nostr-rs-relay.tar.gz
uv add --dev pyln-testing==${{ inputs.pyln-version }} pyln-client==${{ inputs.pyln-version }} pyln-proto==${{ inputs.pyln-version }}
uv run -v pytest -n=10 tests/test_*.py
uv run -v pytest -n=10

View file

@ -1,14 +0,0 @@
name: Latest release on CLN v24.11.x
on:
release:
types: [published, edited]
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v24.11.2"
pyln-version: "24.11.1"
tagged-release: true

View file

@ -1,14 +0,0 @@
name: Latest release on CLN v25.02.x
on:
release:
types: [published, edited]
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.02.1"
pyln-version: "25.2.1"
tagged-release: true

View file

@ -1,14 +0,0 @@
name: Latest release on CLN v25.05.x
on:
release:
types: [published, edited]
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.05"
pyln-version: "25.5"
tagged-release: true

View file

@ -9,6 +9,8 @@ jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.09.2"
pyln-version: "25.9.2"
cln-version: "v25.09.3"
pyln-version: "25.9.3"
tagged-release: true
python-version-1: "3.10"
python-version-2: "3.12"

16
.github/workflows/latest_v25.12.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Latest release on CLN v25.12.x
on:
release:
types: [published, edited]
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.12.1"
pyln-version: "25.12.1"
tagged-release: true
python-version-1: "3.10"
python-version-2: "3.12"

16
.github/workflows/latest_v26.04.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Latest release on CLN v26.04.x
on:
release:
types: [published, edited]
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v26.04"
pyln-version: "26.4"
tagged-release: true
python-version-1: "3.10"
python-version-2: "3.12"

16
.github/workflows/latest_v26.06.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Latest release on CLN v26.06.x
on:
release:
types: [published, edited]
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v26.06.6"
pyln-version: "26.6.6"
tagged-release: true
python-version-1: "3.10"
python-version-2: "3.14"

View file

@ -1,9 +1,9 @@
name: main on CLN v25.02.x
name: master on CLN v25.09.x
on:
push:
branches:
- main
- master
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
@ -19,6 +19,8 @@ jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.02.1"
pyln-version: "25.2.1"
tagged-release: false
cln-version: "v25.09.3"
pyln-version: "25.9.3"
tagged-release: false
python-version-1: "3.10"
python-version-2: "3.12"

View file

@ -1,9 +1,9 @@
name: main on CLN v24.11.x
name: master on CLN v25.12.x
on:
push:
branches:
- main
- master
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
@ -19,6 +19,8 @@ jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v24.11.2"
pyln-version: "24.11.1"
tagged-release: false
cln-version: "v25.12.1"
pyln-version: "25.12.1"
tagged-release: false
python-version-1: "3.10"
python-version-2: "3.12"

View file

@ -1,9 +1,9 @@
name: main on CLN v25.09.x
name: master on CLN v26.04.x
on:
push:
branches:
- main
- master
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
@ -19,6 +19,8 @@ jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.09.2"
pyln-version: "25.9.2"
cln-version: "v26.04"
pyln-version: "26.4"
tagged-release: false
python-version-1: "3.10"
python-version-2: "3.12"

View file

@ -1,9 +1,9 @@
name: main on CLN v25.05.x
name: master on CLN v26.06.x
on:
push:
branches:
- main
- master
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
@ -19,6 +19,8 @@ jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.05"
pyln-version: "25.5"
tagged-release: false
cln-version: "v26.06.6"
pyln-version: "26.6.6"
tagged-release: false
python-version-1: "3.10"
python-version-2: "3.14"

View file

@ -5,76 +5,128 @@ on:
- 'v*'
jobs:
build:
name: build release binaries on ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install rust
id: rust
uses: dtolnay/rust-toolchain@stable
- name: Install cross
if: contains(matrix.os, 'ubuntu')
run: |
cargo install cross --git https://github.com/cross-rs/cross
- name: Build unix
id: unix_build
if: contains(matrix.os, 'ubuntu')
run: |
cross build --profile optimized --locked --target x86_64-unknown-linux-gnu
cross build --profile optimized --locked --target armv7-unknown-linux-gnueabihf
cross build --profile optimized --locked --target aarch64-unknown-linux-gnu
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-aarch64-linux-gnu.tar.gz" --transform 's|.*/||' "target/aarch64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}"
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-armv7-linux-gnueabihf.tar.gz" --transform 's|.*/||' "target/armv7-unknown-linux-gnueabihf/optimized/${{ github.event.repository.name }}"
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-x86_64-linux-gnu.tar.gz" --transform 's|.*/||' "target/x86_64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}"
ls -alh
- name: Build macos
id: macos_build
if: contains(matrix.os, 'macos')
run: |
rustup target add aarch64-apple-darwin
export CROSSBUILD_MACOS_SDK="macosx13.1"
export SDKROOT=$(xcrun -sdk $CROSSBUILD_MACOS_SDK --show-sdk-path)
export MACOSX_DEPLOYMENT_TARGET=12.0
cargo build --profile optimized --locked --target=x86_64-apple-darwin
cargo build --profile optimized --locked --target=aarch64-apple-darwin
lipo -create -output target/${{ github.event.repository.name }} target/aarch64-apple-darwin/optimized/${{ github.event.repository.name }} target/x86_64-apple-darwin/optimized/${{ github.event.repository.name }}
ditto -c -k --sequesterRsrc target/${{ github.event.repository.name }} ${{ github.event.repository.name }}-${{github.ref_name}}-universal-apple-darwin.zip
otool -l target/aarch64-apple-darwin/optimized/${{ github.event.repository.name }} | grep -A 5 LC_BUILD_VERSION
otool -l target/x86_64-apple-darwin/optimized/${{ github.event.repository.name }} | grep -A 5 LC_BUILD_VERSION
echo "macos_version=$MACOSX_DEPLOYMENT_TARGET" >> "$GITHUB_OUTPUT"
echo $(xcodebuild -showsdks)
ls -alh
- name: Upload unix artifacts
uses: actions/upload-artifact@v4
if: contains(matrix.os, 'ubuntu')
with:
name: unix-binaries
path: |
${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz
- name: Upload macos artifacts
uses: actions/upload-artifact@v4
if: contains(matrix.os, 'macos')
with:
name: macos-binaries
path: |
${{ github.event.repository.name }}-${{github.ref_name}}-universal-apple-darwin.zip
- name: Get rust version
id: rversion
run: |
echo "rust_version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
outputs:
rust-version: ${{ steps.rversion.outputs.rust_version }}
macos-version: ${{ steps.macos_build.outputs.macos_version }}
build-linux:
name: Build Linux binaries (glibc)
runs-on: ubuntu-22.04
outputs:
glibc-version: ${{ steps.versions.outputs.glibc-version }}
rust-version: ${{ steps.versions.outputs.rust-version }}
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, armv7-unknown-linux-gnueabihf
- name: Install cross-compilation dependencies
run: |
sudo apt update
sudo apt install -y gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
- name: Build Linux binaries (release)
run: |
cargo build --profile optimized --locked --target x86_64-unknown-linux-gnu
cargo build --profile optimized --locked --target aarch64-unknown-linux-gnu
cargo build --profile optimized --locked --target armv7-unknown-linux-gnueabihf
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-aarch64-linux-gnu.tar.gz" --transform 's|.*/||' "target/aarch64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}"
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-armv7-linux-gnueabihf.tar.gz" --transform 's|.*/||' "target/armv7-unknown-linux-gnueabihf/optimized/${{ github.event.repository.name }}"
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-x86_64-linux-gnu.tar.gz" --transform 's|.*/||' "target/x86_64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}"
ls -alh *.tar.gz
- name: Get versions
id: versions
run: |
echo "rust-version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
GLIBC_VER=$(ldd --version | awk '/ldd/{print $NF}')
echo "glibc-version=$GLIBC_VER" >> "$GITHUB_OUTPUT"
- name: Upload Linux artifacts
uses: actions/upload-artifact@v7
with:
name: linux-binaries
path: |
${{ github.event.repository.name }}-${{ github.ref_name }}-*.tar.gz
build-macos:
name: Build macOS universal binary
runs-on: macos-latest
outputs:
macos-deployment-target: ${{ steps.build.outputs.macos-deployment-target }}
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-apple-darwin, aarch64-apple-darwin
- name: Build macOS universal (release)
id: build
env:
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: |
cargo build --profile optimized --locked --target x86_64-apple-darwin
cargo build --profile optimized --locked --target aarch64-apple-darwin
BINARY_NAME="${{ github.event.repository.name }}"
TAG="${{ github.ref_name }}"
lipo -create -output "${BINARY_NAME}" \
target/x86_64-apple-darwin/optimized/"${BINARY_NAME}" \
target/aarch64-apple-darwin/optimized/"${BINARY_NAME}"
zip "${BINARY_NAME}-${TAG}-universal-apple-darwin.zip" "${BINARY_NAME}"
echo "macos-deployment-target=$MACOSX_DEPLOYMENT_TARGET" >> "$GITHUB_OUTPUT"
ls -alh *.zip
- name: Verify macOS deployment target
env:
EXPECTED_TARGET: ${{ steps.build.outputs.macos-deployment-target }}
run: |
BINARY="${{ github.event.repository.name }}"
echo "Verifying deployment target for universal binary: $BINARY"
echo "Expected minimum macOS version: $EXPECTED_TARGET"
echo
# Check x86_64 slice
X86_MINOS=$(otool -l -arch x86_64 "$BINARY" | awk '
/LC_BUILD_VERSION/ || /LC_VERSION_MIN_MACOSX/ {getline; getline; getline; if ($1 == "minos") print $2}
')
echo "x86_64 slice minos: $X86_MINOS"
# Check arm64 slice
ARM64_MINOS=$(otool -l -arch arm64 "$BINARY" | awk '
/LC_BUILD_VERSION/ || /LC_VERSION_MIN_MACOSX/ {getline; getline; getline; if ($1 == "minos") print $2}
')
echo "arm64 slice minos: $ARM64_MINOS"
# Fail if either doesn't match
if [ "$X86_MINOS" != "$EXPECTED_TARGET" ] || [ "$ARM64_MINOS" != "$EXPECTED_TARGET" ]; then
echo "ERROR: Deployment target mismatch!"
echo "Expected: $EXPECTED_TARGET"
echo "Got: x86_64=$X86_MINOS, arm64=$ARM64_MINOS"
exit 1
fi
echo "Success: Both architectures have deployment target $EXPECTED_TARGET"
- name: Upload macOS artifacts
uses: actions/upload-artifact@v7
with:
name: macos-binaries
path: |
${{ github.event.repository.name }}-${{ github.ref_name }}-universal-apple-darwin.zip
release:
name: Github Release
needs: [build]
needs: [build-linux, build-macos]
runs-on: "ubuntu-latest"
permissions:
contents: write
@ -83,7 +135,7 @@ jobs:
id: tag_name
run: echo "current_version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Get Changelog Entry
id: changelog_reader
uses: mindsers/changelog-reader-action@v2
@ -92,7 +144,7 @@ jobs:
version: ${{ steps.tag_name.outputs.current_version }}
path: ./CHANGELOG.md
- name: Download Artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
merge-multiple: true
- name: Release
@ -100,5 +152,6 @@ jobs:
with:
allowUpdates: false
artifactErrorsFailBuild: true
body: "${{ steps.changelog_reader.outputs.changes }} \n\n### Release binaries info\n\n- Release binaries were built using rust ${{ needs.build.outputs.rust-version }}\n- Linux release binaries require glibc>=2.31"
artifacts: "${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz"
body: "${{ steps.changelog_reader.outputs.changes }} \n\n### Release binaries info\n\n- Release binaries were built using rust ${{ needs.build-linux.outputs.rust-version }}\n- Linux release binaries require glibc>=${{ needs.build-linux.outputs.glibc-version }} (check yours with ``ldd --version``)\n- macOS universal binary (x86_64 + aarch64), requires macOS >= ${{ needs.build-macos.outputs.macos-deployment-target }} (deployment target)"
artifacts: "${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz,${{ github.event.repository.name }}-${{github.ref_name}}-*.zip"
immutableCreate: true

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
/result/
/tests/holdinvoice
/tests/hold
/tests/nostr-rs-relay

4
.rustfmt.toml Normal file
View file

@ -0,0 +1,4 @@
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
imports_layout = "HorizontalVertical"
unstable_features = true

View file

@ -1,5 +1,45 @@
# Changelog
## [0.2.0] 2026-08-09
### Added
- holdinvoice methods: ``make_hold_invoice``, ``cancel_hold_invoice``, ``settle_hold_invoice``
- holdinvoice notification: ``hold_invoice_accepted``
### Changed
- there are now five default relays that will be used if ``nip47-relays`` is not set
- `pay_keysend` will use the new CLN `xkeysend` command using CLN v26.06+
- Budget is updated on demand instead of by a background task
- `list_transactions` is bounded (at most 500 transactions and ~128kB response) to prevent DoS
- Upgrade ``nostr_sdk`` and CLN dependencies
### Removed
- ``multi_pay_invoice`` and ``multi_pay_keysend``, they were removed from the spec
### Fixed
- ``nip47-create`` no longer fails if the NWC fails to start, the error is only logged, since the NWC at that point is already persisted to the DB
- Paying amount-less invoices with a request amount
- Legacy keysend TLV byte decoding
- Report the correct error when the payment route is too expensive
- Notifications are sent per client, so one failing client no longer prevents the others from receiving them
- Available payment methods are now determined from the node's RPC instead of it's (custom) version string
- Deduplication of request events instead of filtering unreliably
## [0.1.9] 2026-04-23
### Fixed
- ``nip47-budget``: race condition where new budget settings would sometimes be ignored
## [0.1.8] 2026-04-03
### Changed
- updated cln dependencies
## [0.1.7] 2025-11-27
### Fixed
- If there were failed payment attempts before success the ``payment_sent`` notification might not have been sent
## [0.1.6] 2025-11-10
### Added

1495
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
[package]
name = "cln-nip47"
version = "0.1.6"
edition = "2021"
rust-version = "1.85"
version = "0.2.0"
edition = "2024"
rust-version = "1.85.0"
[dependencies]
anyhow = "1"
@ -12,14 +12,22 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "sync", "rt-multi-thread"] }
cln-rpc = "0.4"
# cln-rpc = { path = "../lightning/cln-rpc/", version = "^0.4" }
cln-plugin = "0.4"
# cln-plugin = { path = "../lightning/plugins/", version = "^0.4" }
cln-rpc = "0.7"
# cln-rpc = { path = "../lightning/cln-rpc/", version = "^0.6" }
cln-plugin = "0.7"
# cln-plugin = { path = "../lightning/plugins/", version = "^0.6" }
parking_lot = "0.12"
# nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "f7122f5", features = ["nip47", "nip04", "nip44"]}
nostr-sdk = { version = "0.44", features = ["nip47", "nip04", "nip44"] }
# # nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "ff5be7d1416ea201887a02f648795010a3cbdf49" }
# nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "ff5be7d1416ea201887a02f648795010a3cbdf49", features = [
# "nip47",
# "nip04",
# "nip44",
# ] }
nostr = { version = "0.45.1", features = ["nip47", "nip04", "nip44"] }
nostr-sdk = { version = "0.45.1" }
futures = "0.3"
uuid = { version = "1", features = ["v4"] }
@ -27,6 +35,18 @@ hex = "0.4"
regex = "1"
tonic = { version = "0.14", default-features = false, features = [
"codegen",
"transport",
"tls-ring",
] }
prost = "0.14"
tonic-prost = "0.14"
[build-dependencies]
tonic-prost-build = "0.14"
protoc-bin-vendored = "3"
[profile.optimized]
inherits = "release"

101
README.md
View file

@ -1,49 +1,49 @@
<table border="0">
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml/badge.svg?branch=main">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml/badge.svg?branch=main">
</a>
</td>
</tr>
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml/badge.svg?branch=main">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml/badge.svg?branch=main">
</a>
</td>
</tr>
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.05.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.05.yml/badge.svg?branch=main">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.05.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.05.yml/badge.svg?branch=main">
</a>
</td>
</tr>
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.09.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.09.yml/badge.svg?branch=main">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.09.yml/badge.svg?branch=master">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.09.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.09.yml/badge.svg?branch=main">
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.09.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.09.yml/badge.svg?branch=master">
</a>
</td>
</tr>
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.12.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.12.yml/badge.svg?branch=master">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.12.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.12.yml/badge.svg?branch=master">
</a>
</td>
</tr>
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.04.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.04.yml/badge.svg?branch=master">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.04.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.04.yml/badge.svg?branch=master">
</a>
</td>
</tr>
<tr>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.06.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.06.yml/badge.svg?branch=master">
</a>
</td>
<td>
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.06.yml">
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.06.yml/badge.svg?branch=master">
</a>
</td>
</tr>
@ -51,7 +51,7 @@
# cln-nip47
A core lightning plugin to connect wallets via Nostr Wallet Connect (NWC) as specified in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md).
A core lightning plugin to connect wallets via Nostr Wallet Connect (NWC) as specified in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md). It is intended to be used by a single user, since all notifications got to all NWC's.
* [Installation](#installation)
* [Building](#building)
@ -64,11 +64,10 @@ Release binaries for
* x86_64-linux
* armv7-linux (Raspberry Pi 32bit)
* aarch64-linux (Raspberry Pi 64bit)
* universal-apple-darwin (macOS)
can be found on the [release](https://github.com/daywalker90/cln-nip47/releases) page. If you are unsure about your architecture you can run ``uname -m``.
They require ``glibc>=2.31``, which you can check with ``ldd --version``.
# Building
You can build the plugin yourself instead of using the release binaries.
First clone the repo:
@ -85,7 +84,7 @@ cargo build --release
After that the binary will be here: ``target/release/cln-nip47``
Note: Release binaries are built using ``cross`` and the ``optimized`` profile.
Note: Release binaries are built with the ``optimized`` profile.
# Documentation
@ -98,7 +97,12 @@ It is highly recommended to use your own private relay since public relays may l
For a private relay you can for example use [nostr-rs-relay](https://github.com/scsibug/nostr-rs-relay) with ``pubkey_whitelist`` set to both ``clientkey_public`` and ``walletkey_public`` (returned from ``nip47-create``/``nip47-list``).
## Options
* ``nip47-relays``: Specify the relays that you want to use with your NWC. Can be set multiple times to use multiple relays. NWC's you create will save these and even if you add or remove relays keep the relays from the moment you created that NWC. You must set this atleast one time.
* ``nip47-relays``: Specify the relays that you want to use with your NWC. Can be set multiple times to use multiple relays. NWC's you create will save these and even if you add or remove relays keep the relays from the moment you created that NWC. If you don't set this yourself these default relays will be used:
* ``wss://nos.lol``
* ``wss://relay.primal.net``
* ``wss://relay.getalby.com/v1``
* ``wss://relay.nostr.net``
* ``wss://relay.snort.social``
* ``nip47-notifications``: Enable/disable nip47 notifications. Default is enabled (``true``)
## Methods
@ -127,20 +131,25 @@ For a private relay you can for example use [nostr-rs-relay](https://github.com/
* list all NWC configurations or just the one with ``label``
* ***label***: optional. The label the NWC was created with
## Holdinvoice support
For methods or notifications related to holdinvoices you need v0.3.2+ of [hold](https://github.com/BoltzExchange/hold) with enabled grpc (which is the default just make sure the port is free)
## Supported NWC methods
* ``pay_invoice``
* ``multi_pay_invoice``
* ``pay_keysend`` (no ``preimage`` in request allowed since CLN only supports generating it itself)
* ``multi_pay_keysend`` (no ``preimage`` in request allowed since CLN only supports generating it itself)
* ``make_invoice``
* ``lookup_invoice``
* ``list_transactions``
* ``get_balance``
* ``get_info`` (no ``block_hash``)
* ``make_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support))
* ``cancel_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support))
* ``settle_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support))
## Supported NWC notifications
* ``payment_received``
* ``payment_sent``
* ``hold_invoice_accepted`` (requires [Holdinvoice support](#holdinvoice_support))
## Supported content encryption:
* [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md)

27
SECURITY.md Normal file
View file

@ -0,0 +1,27 @@
# Security Policy
## Supported Versions
Only the latest version is supported.
## Reporting a Vulnerability
To report security vulnerabilities, please send an email to:
- `daywalker990@gmx.de`
Note: This email address is exclusively for vulnerability reporting.
For all other inquiries/communication, please use the Github issues.
## Signatures For Releases
The following keys may be used to communicate sensitive information to
developers:
| Name | Email | Fingerprint |
|------|-------|-------------|
| daywalker90 | `daywalker990@gmx.de` | 8A07 9421 A871 D0B1 0835 1193 7AB4 802E D5A6 39F3 |
You can import a key by running the following command with that individuals fingerprint:
`gpg --keyserver hkps://keys.openpgp.org --recv-keys "<fingerprint>"`.
Ensure that you put quotes around fingerprints containing spaces.

8
build.rs Normal file
View file

@ -0,0 +1,8 @@
fn main() {
let protoc = protoc_bin_vendored::protoc_bin_path().unwrap();
unsafe { std::env::set_var("PROTOC", protoc) };
tonic_prost_build::configure()
.protoc_arg("--experimental_allow_proto3_optional")
.compile_protos(&["protos/hold.proto"], &["protos"])
.unwrap_or_else(|e| panic!("Could not build protos: {e}"));
}

View file

@ -1,6 +1,6 @@
plugin:
name: cln-nip47
version: 0.1.6
version: 0.2.0
lang: rust
install: |
cargo build --release && cp target/release/cln-nip47 . && cargo clean

185
protos/hold.proto Normal file
View file

@ -0,0 +1,185 @@
syntax = "proto3";
package hold;
service Hold {
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
rpc Invoice (InvoiceRequest) returns (InvoiceResponse) {}
rpc Inject (InjectRequest) returns (InjectResponse) {}
rpc List (ListRequest) returns (ListResponse) {}
rpc Settle (SettleRequest) returns (SettleResponse) {}
rpc Cancel (CancelRequest) returns (CancelResponse) {}
// Cleans cancelled invoices
rpc Clean (CleanRequest) returns (CleanResponse) {}
rpc Track (TrackRequest) returns (stream TrackResponse) {}
rpc TrackAll (TrackAllRequest) returns (stream TrackAllResponse) {}
rpc OnionMessages (stream OnionMessageResponse) returns (stream OnionMessage) {}
}
enum HookAction {
Continue = 0;
Resolve = 1;
}
message GetInfoRequest {}
message GetInfoResponse {
string version = 1;
}
message Hop {
bytes public_key = 1;
uint64 short_channel_id = 2;
uint64 base_fee = 3;
uint64 ppm_fee = 4;
uint64 cltv_expiry_delta = 5;
}
message RoutingHint {
repeated Hop hops = 1;
}
message InvoiceRequest {
bytes payment_hash = 1;
uint64 amount_msat = 2;
oneof description {
string memo = 3;
bytes hash = 4;
}
optional uint64 expiry = 5;
optional uint64 min_final_cltv_expiry = 6;
repeated RoutingHint routing_hints = 7;
}
message InvoiceResponse {
string bolt11 = 1;
}
message InjectRequest {
string invoice = 1;
optional uint64 min_cltv_expiry = 2;
}
message InjectResponse {}
message ListRequest {
message Pagination {
// Inclusive
int64 index_start = 1;
uint64 limit = 2;
}
oneof constraint {
bytes payment_hash = 1;
Pagination pagination = 2;
}
}
enum InvoiceState {
UNPAID = 0;
ACCEPTED = 1;
PAID = 2;
CANCELLED = 3;
}
message Htlc {
int64 id = 1;
InvoiceState state = 2;
string scid = 3;
uint64 channel_id = 4;
uint64 msat = 5;
uint64 created_at = 6;
optional uint64 cltv_expiry = 7;
}
message Invoice {
int64 id = 1;
bytes payment_hash = 2;
optional bytes preimage = 3;
string invoice = 4;
InvoiceState state = 5;
uint64 created_at = 6;
optional uint64 settled_at = 8;
repeated Htlc htlcs = 7;
optional uint64 min_cltv_expiry = 9;
}
message ListResponse {
repeated Invoice invoices = 1;
}
message SettleRequest {
bytes payment_preimage = 1;
}
message SettleResponse {}
message CancelRequest {
bytes payment_hash = 1;
}
message CancelResponse {}
message CleanRequest {
// Clean everything older than age seconds
optional uint64 age = 1;
}
message CleanResponse {
uint64 cleaned = 1;
}
message TrackRequest {
bytes payment_hash = 1;
}
message TrackResponse {
InvoiceState state = 1;
}
message TrackAllRequest {
repeated bytes payment_hashes = 1;
}
message TrackAllResponse {
bytes payment_hash = 1;
string bolt11 = 2;
InvoiceState state = 3;
}
message OnionMessage {
message ReplyBlindedPath {
message Hop {
optional bytes blinded_node_id = 1;
optional bytes encrypted_recipient_data = 2;
}
optional bytes first_node_id = 1;
optional string first_scid = 2;
optional uint64 first_scid_dir = 3;
optional bytes first_path_key = 4;
repeated Hop hops = 5;
}
message UnknownField {
uint64 number = 1;
bytes value = 2;
}
uint64 id = 1;
optional bytes pathsecret = 2;
optional ReplyBlindedPath reply_blindedpath = 3;
optional bytes invoice_request = 4;
optional bytes invoice = 5;
optional bytes invoice_error = 6;
repeated UnknownField unknown_fields = 7;
}
message OnionMessageResponse {
uint64 id = 1;
HookAction action = 2;
}

View file

@ -1,21 +1,38 @@
use std::{path::Path, time::Duration};
use std::{
path::{Path, PathBuf},
str::FromStr,
time::Duration,
};
use anyhow::anyhow;
use cln_plugin::{
Builder,
Plugin,
options::{ConfigOption, DefaultBooleanConfigOption, StringArrayConfigOption},
Builder, Plugin,
};
use cln_rpc::{model::requests::ListdatastoreRequest, ClnRpc};
use nostr_sdk::nips::nip47;
use cln_rpc::{
ClnRpc,
model::{
requests::{DecodeRequest, ListdatastoreRequest},
responses::DecodeType,
},
};
use nostr::nips::nip47;
use nwc::run_nwc;
use nwc_notifications::{payment_received_handler, payment_sent_handler};
use parse::read_startup_options;
use rpc::{nwc_budget, nwc_create, nwc_list, nwc_revoke};
use serde_json::json;
use structs::PluginState;
use tokio::time;
use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity};
use util::{load_nwc_store, update_nwc_store};
use crate::{
hold::{InvoiceState, ListRequest, hold_client::HoldClient},
nwc_notifications::holdinvoice_accepted_handler,
};
mod nwc;
mod nwc_balance;
mod nwc_hold;
@ -31,6 +48,11 @@ mod structs;
mod tasks;
mod util;
pub const STARTUP_DELAY: u64 = 1;
pub mod hold {
tonic::include_proto!("hold");
}
const OPT_RELAYS: StringArrayConfigOption = ConfigOption::new_str_arr_no_default(
"nip47-relays",
"Nostr relays used for nwc. Can be stated multiple times.",
@ -41,30 +63,35 @@ const OPT_NOTIFICATIONS: DefaultBooleanConfigOption = ConfigOption::new_bool_wit
"Enable/disable nip47-notifications. Default is `true`",
);
pub const PLUGIN_NAME: &str = "cln-nip47";
pub const WALLET_READ_METHODS: [nip47::Method; 5] = [
pub const WALLET_READ_OR_RECEIVE_METHODS: [nip47::Method; 5] = [
nip47::Method::MakeInvoice,
nip47::Method::LookupInvoice,
nip47::Method::ListTransactions,
nip47::Method::GetBalance,
nip47::Method::GetInfo,
];
pub const WALLET_PAY_METHODS: [nip47::Method; 4] = [
nip47::Method::PayInvoice,
nip47::Method::MultiPayInvoice,
nip47::Method::PayKeysend,
nip47::Method::MultiPayKeysend,
pub const WALLET_PAY_METHODS: [nip47::Method; 2] =
[nip47::Method::PayInvoice, nip47::Method::PayKeysend];
pub const WALLET_HOLD_METHODS: [nip47::Method; 3] = [
nip47::Method::MakeHoldInvoice,
nip47::Method::CancelHoldInvoice,
nip47::Method::SettleHoldInvoice,
];
pub const WALLET_NOTIFICATIONS: [nip47::NotificationType; 2] = [
nip47::NotificationType::PaymentReceived,
nip47::NotificationType::PaymentSent,
];
pub const WALLET_HOLD_NOTIFICATIONS: [nip47::NotificationType; 1] =
[nip47::NotificationType::HoldInvoiceAccepted];
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
std::env::set_var(
"CLN_PLUGIN_LOG",
"cln_plugin=info,cln_rpc=info,cln_nip47=debug,info",
);
unsafe {
std::env::set_var(
"CLN_PLUGIN_LOG",
"cln_plugin=info,cln_rpc=info,cln_nip47=trace,info",
);
};
log_panics::init();
let state;
@ -105,12 +132,22 @@ async fn main() -> Result<(), anyhow::Error> {
};
let plugin = confplugin.start(state).await?;
match check_hold_support(plugin.clone()).await {
Ok(()) => {
log::info!("Hold support activated, loading pending invoices...");
if let Err(e) = load_pending_hold_invoices(plugin.clone()).await {
log::error!("Error loading pending hold invoices: {e}");
}
}
Err(e) => log::info!("Hold support not activated: {e}"),
}
{
let mut rpc = plugin.state().rpc_lock.lock().await;
// Make sure incase of rapid nip47-create and plugin restarts info_events
// have a different timestamp and therefore ID so relays don't disconnect us
time::sleep(Duration::from_secs(1)).await;
time::sleep(Duration::from_secs(STARTUP_DELAY)).await;
match load_nwcs(plugin.clone(), &mut rpc).await {
Ok(()) => log::info!("All NWC's loaded"),
@ -126,6 +163,16 @@ async fn main() -> Result<(), anyhow::Error> {
}
}
let plugin_clone_cleanup = plugin.clone();
tokio::spawn(async move {
loop {
if let Err(e) = tasks::cleanup_event_ids(plugin_clone_cleanup.clone()).await {
log::warn!("Error in cleanup_event_ids thread: {e}");
}
time::sleep(Duration::from_secs(10)).await;
}
});
plugin.join().await
}
@ -134,8 +181,8 @@ async fn shutdown_handler(
_args: serde_json::Value,
) -> Result<(), anyhow::Error> {
let mut locked_handles = plugin.state().handles.lock().await;
for (_x, (client, _client_pubkey)) in locked_handles.drain() {
client.shutdown().await;
for (_x, wallet_service) in locked_handles.drain() {
wallet_service.client.shutdown().await;
}
std::process::exit(0)
}
@ -159,7 +206,186 @@ async fn load_nwcs(plugin: Plugin<PluginState>, rpc: &mut ClnRpc) -> Result<(),
}
}
// We don't keep track of inflight payments succeeding
// while the plugin is dynamically restarted
if nwc_store.reserved_msat != 0 {
log::warn!(
"Releasing {} msat leftover budget reservation for {label}",
nwc_store.reserved_msat
);
nwc_store.reserved_msat = 0;
update_nwc_store(rpc, label, nwc_store.clone()).await?;
}
run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
}
Ok(())
}
async fn load_pending_hold_invoices(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
let mut hold_client = plugin.state().hold_client.lock().clone().unwrap();
let invoices_request = ListRequest { constraint: None };
let invoices = hold_client
.list(invoices_request)
.await?
.into_inner()
.invoices;
let mut rpc = plugin.state().rpc_lock.lock().await;
for invoice in invoices {
if invoice.state() == InvoiceState::Accepted || invoice.state() == InvoiceState::Unpaid {
// Bound the accepted handler by the invoice's expiry. The hold
// plugin has no expired state, so without this it would wait
// forever on an invoice that expires while Unpaid.
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invoice.invoice.clone(),
})
.await?;
if !invoice_decoded.valid {
log::warn!(
"Skipping hold invoice {}, could not decode it",
hex::encode(&invoice.payment_hash)
);
continue;
}
let (created_at, expiry) = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => (
invoice_decoded.invoice_created_at,
invoice_decoded.invoice_relative_expiry.map(u64::from),
),
DecodeType::BOLT11_INVOICE => (invoice_decoded.created_at, invoice_decoded.expiry),
_ => continue,
};
let (Some(created_at), Some(expiry)) = (created_at, expiry) else {
log::warn!(
"Skipping hold invoice {}: missing creation time or expiry",
hex::encode(&invoice.payment_hash)
);
continue;
};
let expires_at = created_at.saturating_add(expiry);
log::debug!(
"Starting holdinvoice accepted handler for {}",
hex::encode(&invoice.payment_hash)
);
tokio::spawn(holdinvoice_accepted_handler(
plugin.clone(),
invoice.payment_hash,
expires_at,
));
}
}
Ok(())
}
async fn check_hold_support(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
let mut rpc = plugin.state().rpc_lock.lock().await;
let hold_grpc_host_response: serde_json::Value = rpc
.call_raw("listconfigs", &json!({"config": "hold-grpc-host"}))
.await?;
let Some(hold_grpc_host_configs) = hold_grpc_host_response.get("configs") else {
return Err(anyhow!("Unsopprted listconfigs response!"));
};
let Some(hold_grpc_host_config) = hold_grpc_host_configs.get("hold-grpc-host") else {
return Err(anyhow!("hold-grpc-host config not found"));
};
let Some(hold_grpc_host_value) = hold_grpc_host_config.get("value_str") else {
return Err(anyhow!("hold-grpc-host config not a string"));
};
let Some(hold_grpc_host) = hold_grpc_host_value.as_str() else {
return Err(anyhow!("hold-grpc-host config not convertable to string"));
};
let hold_grpc_port_response: serde_json::Value = rpc
.call_raw("listconfigs", &json!({"config": "hold-grpc-port"}))
.await?;
let Some(hold_grpc_port_configs) = hold_grpc_port_response.get("configs") else {
return Err(anyhow!("Unsopprted listconfigs response!"));
};
let Some(hold_grpc_port_config) = hold_grpc_port_configs.get("hold-grpc-port") else {
return Err(anyhow!("hold-grpc-port config not found"));
};
let Some(hold_grpc_port_value) = hold_grpc_port_config.get("value_int") else {
return Err(anyhow!("hold-grpc-port config not a number"));
};
let hold_grpc_port = if let Some(hgh) = hold_grpc_port_value.as_u64() {
u16::try_from(hgh)?
} else {
return Err(anyhow!("hold-grpc-port config not convertable to integer"));
};
let cert_dir = PathBuf::from_str(&plugin.configuration().lightning_dir)?.join("hold");
log::debug!(
"Searching {} for hold plugin certs",
cert_dir.to_str().unwrap()
);
let cert_max_retries = 10;
let mut cert_retries = 0;
while cert_retries < cert_max_retries && !do_certificates_exist(&cert_dir) {
log::debug!("Hold certificates incomplete. Waiting...");
time::sleep(Duration::from_millis(500)).await;
cert_retries += 1;
}
let ca_cert = tokio::fs::read(cert_dir.join("ca.pem")).await?;
let client_cert = tokio::fs::read(cert_dir.join("client.pem")).await?;
let client_key = tokio::fs::read(cert_dir.join("client-key.pem")).await?;
let identity = Identity::from_pem(client_cert, client_key);
let ca = Certificate::from_pem(ca_cert);
let tls_config = ClientTlsConfig::new()
.ca_certificate(ca)
.identity(identity)
.domain_name("hold");
let endpoint = Endpoint::from_shared(format!("https://{hold_grpc_host}:{hold_grpc_port}"))?
.tls_config(tls_config)?
.keep_alive_while_idle(true)
.connect_timeout(Duration::from_secs(5));
let chan_max_retries = 20;
let mut chan_retries = 0;
let channel = loop {
match endpoint.connect().await {
Ok(channel) => break channel,
Err(e) if chan_retries < chan_max_retries => {
chan_retries += 1;
log::debug!(
"Hold gRPC server not ready, retrying ({chan_retries}/{chan_max_retries}): {e}"
);
time::sleep(Duration::from_millis(500)).await;
}
Err(e) => {
return Err(anyhow!("Hold gRPC server did not become ready: {e}"));
}
}
};
*plugin.state().hold_client.lock() = Some(HoldClient::new(channel));
Ok(())
}
fn do_certificates_exist(cert_dir: &Path) -> bool {
let required_files = ["client.pem", "client-key.pem", "ca.pem"];
required_files.iter().all(|file| {
let path = cert_dir.join(file);
path.exists() && path.metadata().is_ok_and(|m| m.len() > 0)
})
}

View file

@ -1,81 +1,75 @@
use std::borrow::Cow;
use std::time::Duration;
use crate::nwc_balance::get_balance_response;
use crate::nwc_hold::{
cancel_hold_invoice_response, make_hold_invoice_response, settle_hold_invoice_response,
};
use crate::nwc_info::get_info_response;
use crate::nwc_invoice::make_invoice_response;
use crate::nwc_keysend::{multi_pay_keysend, pay_keysend_response};
use crate::nwc_lookups::{list_transactions_response, lookup_invoice_response};
use crate::nwc_pay::{multi_pay_invoice, pay_invoice_response};
use crate::structs::{NwcStore, PluginState};
use crate::tasks::budget_task;
use crate::util::{build_capabilities, build_notifications_vec, is_read_only_nwc};
use crate::OPT_NOTIFICATIONS;
use anyhow::anyhow;
use cln_plugin::Plugin;
use nostr_sdk::client;
use nostr_sdk::nips::nip04;
use nostr_sdk::nips::nip44;
use nostr_sdk::nips::nip47;
use nostr_sdk::nostr::Filter;
use nostr_sdk::nostr::Kind;
use nostr_sdk::nostr::Tag;
use nostr_sdk::Alphabet;
use nostr_sdk::Client;
use nostr_sdk::Event;
use nostr_sdk::EventBuilder;
use nostr_sdk::EventId;
use nostr_sdk::Keys;
use nostr_sdk::PublicKey;
use nostr_sdk::RelayPoolNotification;
use nostr_sdk::RelayStatus;
use nostr_sdk::SecretKey;
use nostr_sdk::SignerError;
use nostr_sdk::SingleLetterTag;
use nostr_sdk::TagKind;
use nostr_sdk::Timestamp;
use tokio::sync::oneshot;
use futures::StreamExt;
use nostr::{
event::{Event, EventBuilder, EventId, FinalizeEventAsync, Kind, Tag},
filter::Filter,
key::{Keys, PublicKey, SecretKey},
nips::{nip04, nip44, nip47},
types::Timestamp,
};
use nostr_sdk::{
client::{self, Client, ClientNotification},
error::Error,
relay::RelayStatus,
};
use tokio::time;
use crate::{
OPT_NOTIFICATIONS,
nwc_balance::get_balance_response,
nwc_hold::{
cancel_hold_invoice_response,
make_hold_invoice_response,
settle_hold_invoice_response,
},
nwc_info::get_info_response,
nwc_invoice::make_invoice_response,
nwc_keysend::pay_keysend_response,
nwc_lookups::{list_transactions_response, lookup_invoice_response},
nwc_pay::pay_invoice_response,
structs::{ID_MAX_AGE, NwcStore, PluginState, WalletService},
util::{build_capabilities, build_notifications_vec, is_read_only_nwc, save_event_id},
};
#[allow(clippy::too_many_lines)]
pub async fn run_nwc(
plugin: Plugin<PluginState>,
label: String,
nwc_store: NwcStore,
) -> Result<(), client::Error> {
) -> Result<(), Error> {
let (method_capabilities, _) = build_capabilities(is_read_only_nwc(&nwc_store), &plugin);
let wallet_keys = Keys::new(
SecretKey::from_hex(&nwc_store.walletkey)
.map_err(|e| client::Error::Signer(SignerError::backend(e)))?,
);
let client_pubkey = Keys::new(nwc_store.uri.secret.clone()).public_key();
let wallet_keys = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
let client_keys = Keys::new(nwc_store.uri.secret.clone());
let client = Client::new(wallet_keys.clone());
let nostr_client = Client::new();
log::debug!("relay_count:{}", nwc_store.uri.relays.len());
for relay in &nwc_store.uri.relays {
log::debug!("Adding relay: {relay}");
client.add_relay(relay).await?;
nostr_client.add_relay(relay).await?;
}
if nwc_store.interval_config.is_some() {
start_nwc_budget_job(&plugin, label.clone());
log::debug!("NWC {label} uses an interval budget");
}
let client_clone = client.clone();
let nostr_client_clone = nostr_client.clone();
let plugin_clone = plugin.clone();
let label_clone = label.clone();
let client_keys_clone = client_keys.clone();
let wallet_keys_clone = wallet_keys.clone();
tokio::spawn(async move {
loop {
client_clone.connect().await;
client_clone
.wait_for_connection(Duration::from_secs(30))
nostr_client_clone
.connect()
.and_wait(Duration::from_secs(30))
.await;
let relays = client_clone.relays().await;
let relays = nostr_client_clone.relays().await;
if relays.is_empty() {
log::info!("No more relays left, we probably shut down. Exiting...");
break;
@ -96,62 +90,67 @@ pub async fn run_nwc(
if let Err(e) = send_nwc_info_event(
plugin_clone.clone(),
client_clone.clone(),
nostr_client_clone.clone(),
method_capabilities.clone(),
wallet_keys.clone(),
wallet_keys_clone.clone(),
)
.await
{
log::warn!("{e}");
client_clone.disconnect().await;
nostr_client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
}
let filter = Filter::new()
.kind(Kind::WalletConnectRequest)
.author(client_pubkey)
.since(Timestamp::now() - 1);
.author(client_keys_clone.public_key());
if let Err(e) = client_clone.subscribe(filter, None).await {
log::warn!("Could not subscribe to nwc events! {e}");
client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
let mut notifications = nostr_client_clone.notifications();
match nostr_client_clone.subscribe(filter).await {
Ok(o) => {
if o.success.is_empty() {
log::warn!("Could not subscribe to any relay!");
nostr_client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
}
}
Err(e) => {
log::warn!("Error subscribing to relays: {e}");
nostr_client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
}
}
let client_clone_handler = client_clone.clone();
match client_clone
.handle_notifications(|notification| {
let client_clone_handler = client_clone_handler.clone();
let plugin_clone = plugin_clone.clone();
let label_clone = label_clone.clone();
let wallet_keys_clone = wallet_keys.clone();
nwc_request_handler(
notification,
client_clone_handler,
plugin_clone,
label_clone,
wallet_keys_clone,
client_pubkey,
)
})
while let Some(notification) = notifications.next().await {
if let Err(e) = nwc_request_handler(
notification,
&nostr_client_clone,
&plugin_clone,
&label_clone,
&wallet_keys_clone,
client_keys_clone.public_key(),
)
.await
{
Ok(()) => {
log::info!("NWC handler for `{label_clone}` stopped");
break;
{
log::warn!("NWC handler for `{label_clone}` had an error: {e}");
}
Err(e) => log::warn!("NWC handler for `{label_clone}` had an error: {e}"),
};
}
{};
}
});
let mut locked_handles = plugin.state().handles.lock().await;
locked_handles.insert(
label.clone(),
(client, Keys::new(nwc_store.uri.secret).public_key()),
);
let wallet_service = WalletService {
client: nostr_client,
client_pubkey: client_keys.public_key(),
wallet_secret: wallet_keys,
};
locked_handles.insert(label.clone(), wallet_service);
Ok(())
}
@ -174,7 +173,7 @@ pub async fn send_nwc_info_event(
.tag(Tag::parse(vec!["notifications", &notification_capabilities]).unwrap());
}
let info_event = match info_event_builder.sign_with_keys(&wallet_keys) {
let info_event = match info_event_builder.finalize_async(&wallet_keys).await {
Ok(o) => o,
Err(e) => {
return Err(anyhow!("Could not sign info_event! {e}"));
@ -202,95 +201,103 @@ pub async fn send_nwc_info_event(
pub async fn stop_nwc(plugin: Plugin<PluginState>, label: &String) {
let mut locked_handles = plugin.state().handles.lock().await;
if let Some((client, _client_pubkey)) = locked_handles.remove(label) {
client.shutdown().await;
}
stop_nwc_budget_job(&plugin, label);
}
pub fn start_nwc_budget_job(plugin: &Plugin<PluginState>, label: String) {
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
plugin.state().budget_jobs.lock().insert(label, tx);
}
pub fn stop_nwc_budget_job(plugin: &Plugin<PluginState>, label: &String) {
let mut budget_jobs = plugin.state().budget_jobs.lock();
let job = budget_jobs.remove(label);
if let Some(j) = job {
let _ = j.send(());
if let Some(wallet_service) = locked_handles.remove(label) {
wallet_service.client.shutdown().await;
}
}
#[allow(clippy::too_many_lines)]
async fn nwc_request_handler(
notification: RelayPoolNotification,
client: client::Client,
plugin: Plugin<PluginState>,
label: String,
wallet_keys: Keys,
notification: ClientNotification,
nostr_client: &client::Client,
plugin: &Plugin<PluginState>,
label: &str,
wallet_keys: &Keys,
client_pubkey: PublicKey,
) -> Result<bool, Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>> {
let (relay_url, subscription_id, event) = match notification {
RelayPoolNotification::Event {
ClientNotification::Event {
relay_url,
subscription_id,
event,
} => (relay_url, subscription_id, event),
RelayPoolNotification::Message {
ClientNotification::Message {
relay_url: _,
message: _,
} => return Ok(false),
RelayPoolNotification::Shutdown => return Ok(true),
}
| ClientNotification::Shutdown => return Ok(()),
};
if let Some(expi) = event.tags.expiration() {
if *expi < Timestamp::now() {
return Ok(false);
}
}
log::debug!("relay_url:{relay_url} subscription_id:{subscription_id} {event:?}");
log::trace!("relay_url:{relay_url} subscription_id:{subscription_id} {event:?}");
let mut use_nip44 = check_nip44_support(&event);
let request = decrypt_request(&event.content, &wallet_keys, &client_pubkey, &mut use_nip44)?;
let request = decrypt_request(&event.content, wallet_keys, &client_pubkey, &mut use_nip44)?;
let responses = match request.params {
nip47::RequestParams::PayInvoice(pay_invoice_request) => {
pay_invoice_response(plugin.clone(), pay_invoice_request, &label).await
let responses = if event.tags.expiration().is_some()
&& event.tags.expiration().unwrap() < Timestamp::now()
{
vec![(
nip47::Response {
result_type: request.method,
error: Some(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Event expired".to_owned(),
}),
result: None,
},
None,
)]
} else if event.created_at.as_secs() < (Timestamp::now().as_secs() - ID_MAX_AGE) {
vec![(
nip47::Response {
result_type: request.method,
error: Some(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Event created too far in the past".to_owned(),
}),
result: None,
},
None,
)]
} else {
{
let mut rpc = plugin.state().rpc_lock.lock().await;
save_event_id(&mut rpc, event.id.to_hex(), event.created_at).await?;
}
nip47::RequestParams::MultiPayInvoice(multi_pay_invoice_request) => {
multi_pay_invoice(plugin.clone(), multi_pay_invoice_request, &label).await
}
nip47::RequestParams::PayKeysend(pay_keysend_request) => {
pay_keysend_response(plugin, pay_keysend_request, &label).await
}
nip47::RequestParams::MultiPayKeysend(multi_pay_keysend_request) => {
multi_pay_keysend(plugin.clone(), multi_pay_keysend_request, &label).await
}
nip47::RequestParams::MakeInvoice(make_invoice_request) => {
make_invoice_response(plugin.clone(), make_invoice_request).await
}
nip47::RequestParams::LookupInvoice(lookup_invoice_request) => {
lookup_invoice_response(plugin.clone(), lookup_invoice_request).await
}
nip47::RequestParams::ListTransactions(list_transactions_request) => {
list_transactions_response(plugin.clone(), list_transactions_request).await
}
nip47::RequestParams::GetBalance => get_balance_response(plugin.clone(), &label).await,
nip47::RequestParams::GetInfo => get_info_response(plugin.clone(), &label).await,
nip47::RequestParams::MakeHoldInvoice(make_hold_invoice_request) => {
make_hold_invoice_response(plugin.clone(), make_hold_invoice_request, &label).await
}
nip47::RequestParams::CancelHoldInvoice(cancel_hold_invoice_request) => {
cancel_hold_invoice_response(plugin.clone(), cancel_hold_invoice_request, &label).await
}
nip47::RequestParams::SettleHoldInvoice(settle_hold_invoice_request) => {
settle_hold_invoice_response(plugin.clone(), settle_hold_invoice_request, &label).await
match request.params {
nip47::RequestParams::PayInvoice(pay_invoice_request) => {
pay_invoice_response(plugin.clone(), pay_invoice_request, label).await
}
nip47::RequestParams::PayKeysend(pay_keysend_request) => {
pay_keysend_response(plugin.clone(), pay_keysend_request, label).await
}
nip47::RequestParams::MakeInvoice(make_invoice_request) => {
make_invoice_response(plugin.clone(), make_invoice_request).await
}
nip47::RequestParams::LookupInvoice(lookup_invoice_request) => {
lookup_invoice_response(plugin.clone(), lookup_invoice_request).await
}
nip47::RequestParams::ListTransactions(list_transactions_request) => {
list_transactions_response(plugin.clone(), list_transactions_request).await
}
nip47::RequestParams::GetBalance => get_balance_response(plugin.clone(), label).await,
nip47::RequestParams::GetInfo => get_info_response(plugin.clone(), label).await,
nip47::RequestParams::MakeHoldInvoice(make_hold_invoice_request) => {
make_hold_invoice_response(plugin.clone(), make_hold_invoice_request).await
}
nip47::RequestParams::CancelHoldInvoice(cancel_hold_invoice_request) => {
cancel_hold_invoice_response(plugin.clone(), cancel_hold_invoice_request).await
}
nip47::RequestParams::SettleHoldInvoice(settle_hold_invoice_request) => {
settle_hold_invoice_response(plugin.clone(), settle_hold_invoice_request).await
}
}
};
for (response, id) in responses {
let content =
match encrypt_response_content(&response, &wallet_keys, &client_pubkey, use_nip44) {
match encrypt_response_content(&response, wallet_keys, &client_pubkey, use_nip44) {
Ok(o) => o,
Err(e) => {
log::warn!("{e}");
@ -299,7 +306,7 @@ async fn nwc_request_handler(
};
let response_event =
match build_response_event(event.id, content, &wallet_keys, client_pubkey, id) {
match build_response_event(event.id, content, wallet_keys, client_pubkey, id).await {
Ok(o) => o,
Err(e) => {
log::warn!("Error signing reponse event! {e}");
@ -307,7 +314,7 @@ async fn nwc_request_handler(
}
};
let send_result = match client.send_event(&response_event).await {
let send_result = match nostr_client.send_event(&response_event).await {
Ok(o) => o,
Err(e) => {
log::warn!("Error sending response event! {e}");
@ -325,20 +332,25 @@ async fn nwc_request_handler(
);
continue;
}
log::debug!("SENT RESPONSE {response_event:?}");
for success in send_result.success {
log::trace!("Sent to {}", success.0);
}
for failure in send_result.failed {
log::trace!("Failed to send to {}: {}", failure.0, failure.1);
}
log::trace!("SENT RESPONSE {response_event:?}");
}
Ok(false)
Ok(())
}
fn check_nip44_support(event: &Event) -> bool {
if let Some(encryption_tag) = event
.tags
.find(TagKind::Custom(Cow::Borrowed("encryption")))
{
if let Some(enc_tag_content) = encryption_tag.content() {
if enc_tag_content.contains("nip44_v2") {
return true;
for tag in event.tags.iter() {
if tag.kind() == "encryption" {
if let Some(enc_tag_content) = tag.content() {
if enc_tag_content.contains("nip44_v2") {
return true;
}
}
}
}
@ -386,7 +398,7 @@ fn decrypt_request(
}
}
};
log::debug!("Decrypted (nip44_v2:{use_nip44}):{content}");
log::trace!("Decrypted (nip44_v2:{use_nip44}):{content}");
let request: nip47::Request = match serde_json::from_str(&content) {
Ok(o) => o,
Err(e) => {
@ -409,7 +421,7 @@ fn encrypt_response_content(
return Err(anyhow!("Error serializing response! {e}"));
}
};
log::debug!("RESPONSE:{response_str}");
log::trace!("RESPONSE:{response_str}");
if use_nip44 {
match nip44::encrypt(
wallet_keys.secret_key(),
@ -428,7 +440,7 @@ fn encrypt_response_content(
}
}
fn build_response_event(
async fn build_response_event(
event_id: EventId,
content: String,
wallet_keys: &Keys,
@ -439,15 +451,9 @@ fn build_response_event(
.tag(Tag::event(event_id))
.tag(Tag::public_key(client_pubkey));
if let Some(i) = id {
response_builder = response_builder.tag(Tag::custom(
TagKind::SingleLetter(SingleLetterTag {
character: Alphabet::D,
uppercase: false,
}),
vec![i],
));
response_builder = response_builder.tag(Tag::identifier(i));
}
match response_builder.sign_with_keys(wallet_keys) {
match response_builder.finalize_async(wallet_keys).await {
Ok(o) => Ok(o),
Err(e) => Err(e.into()),
}

View file

@ -1,8 +1,11 @@
use cln_plugin::Plugin;
use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState};
use nostr_sdk::nips::nip47;
use nostr::nips::nip47;
use crate::{structs::PluginState, util::load_nwc_store};
use crate::{
structs::PluginState,
util::{get_budget_msat, load_nwc_store},
};
pub async fn get_balance_response(
plugin: Plugin<PluginState>,
@ -40,11 +43,15 @@ async fn get_balance(
message: e.to_string(),
})?;
let balance = if let Some(bdgt_amt) = nwc_store.budget_msat {
let balance = if let Some(bdgt_amt) = get_budget_msat(&nwc_store) {
bdgt_amt
} else {
let listpeerchannels = rpc
.call_typed(&ListpeerchannelsRequest { id: None })
.call_typed(&ListpeerchannelsRequest {
id: None,
short_channel_id: None,
channel_id: None,
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,

View file

@ -1,58 +1,248 @@
use cln_plugin::Plugin;
use std::str::FromStr;
use crate::structs::PluginState;
use nostr_sdk::nips::nip47;
use cln_plugin::Plugin;
use cln_rpc::primitives::Sha256;
use nostr::{nips::nip47, types::Timestamp};
use crate::{
hold::{CancelRequest, InvoiceRequest, SettleRequest, invoice_request::Description},
nwc_notifications::holdinvoice_accepted_handler,
structs::PluginState,
};
pub async fn make_hold_invoice_response(
_plugin: Plugin<PluginState>,
_params: nip47::MakeHoldInvoiceRequest,
_label: &str,
plugin: Plugin<PluginState>,
params: nip47::MakeHoldInvoiceRequest,
) -> Vec<(nip47::Response, Option<String>)> {
vec![(
nip47::Response {
result_type: nip47::Method::MakeHoldInvoice,
error: Some(nip47::NIP47Error {
code: nip47::ErrorCode::NotImplemented,
message: "Not implemented".to_owned(),
}),
result: None,
},
None,
)]
vec![match make_hold_invoice(plugin, params).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::MakeHoldInvoice,
error: None,
result: Some(nip47::ResponseResult::MakeHoldInvoice(o)),
},
None,
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::MakeHoldInvoice,
error: Some(e),
result: None,
},
None,
),
}]
}
async fn make_hold_invoice(
plugin: Plugin<PluginState>,
params: nip47::MakeHoldInvoiceRequest,
) -> Result<nip47::MakeHoldInvoiceResponse, nip47::NIP47Error> {
let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::NotImplemented,
message: "No hold plugin found".to_owned(),
});
};
let description: Option<Description> = if let Some(d_hash) = &params.description_hash {
if let Some(description) = &params.description {
let my_description_hash = Sha256::const_hash(description.as_bytes());
let description_hash = Sha256::from_str(d_hash).map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if my_description_hash != description_hash {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "description_hash not matching description".to_owned(),
});
}
}
let desc_hash_bytes = match hex::decode(d_hash) {
Ok(p) => p,
Err(_e) => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Could not convert description hash to bytes".to_owned(),
});
}
};
Some(Description::Hash(desc_hash_bytes))
} else {
params
.description
.as_ref()
.map(|desc| Description::Memo(desc.clone()))
};
let payment_hash = match hex::decode(&params.payment_hash) {
Ok(p) => p,
Err(_e) => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Invalid payment hash".to_owned(),
});
}
};
let expiry = params.expiry.unwrap_or(60 * 60);
let holdinvoice_request = InvoiceRequest {
payment_hash: payment_hash.clone(),
amount_msat: params.amount,
expiry: Some(expiry),
min_final_cltv_expiry: params.min_cltv_expiry_delta.map(u64::from),
routing_hints: Vec::new(),
description,
};
let holdinvoice = hold_client
.invoice(holdinvoice_request)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: format!("Error creating hold invoice: {e}"),
})?
.into_inner();
let expires_at = Timestamp::now() + expiry;
let response = nip47::MakeHoldInvoiceResponse {
invoice: Some(holdinvoice.bolt11),
transaction_type: nip47::TransactionType::Incoming,
description: params.description,
description_hash: params.description_hash,
amount: params.amount,
created_at: Timestamp::now(),
expires_at,
metadata: None,
payment_hash: params.payment_hash,
};
tokio::spawn(holdinvoice_accepted_handler(
plugin,
payment_hash,
expires_at.as_secs(),
));
Ok(response)
}
pub async fn cancel_hold_invoice_response(
_plugin: Plugin<PluginState>,
_params: nip47::CancelHoldInvoiceRequest,
_label: &str,
plugin: Plugin<PluginState>,
params: nip47::CancelHoldInvoiceRequest,
) -> Vec<(nip47::Response, Option<String>)> {
vec![(
nip47::Response {
result_type: nip47::Method::MakeHoldInvoice,
error: Some(nip47::NIP47Error {
code: nip47::ErrorCode::NotImplemented,
message: "Not implemented".to_owned(),
}),
result: None,
},
None,
)]
vec![match cancel_hold_invoice(plugin, params).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::CancelHoldInvoice,
error: None,
result: Some(nip47::ResponseResult::CancelHoldInvoice(o)),
},
None,
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::CancelHoldInvoice,
error: Some(e),
result: None,
},
None,
),
}]
}
async fn cancel_hold_invoice(
plugin: Plugin<PluginState>,
params: nip47::CancelHoldInvoiceRequest,
) -> Result<nip47::CancelHoldInvoiceResponse, nip47::NIP47Error> {
let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::NotImplemented,
message: "No hold plugin found".to_owned(),
});
};
let payment_hash = match hex::decode(&params.payment_hash) {
Ok(p) => p,
Err(_e) => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Invalid payment hash".to_owned(),
});
}
};
let hold_cancel_request = CancelRequest { payment_hash };
let hold_cancel_response = hold_client.cancel(hold_cancel_request).await;
match hold_cancel_response {
Ok(_o) => Ok(nip47::CancelHoldInvoiceResponse {}),
Err(e) => Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}),
}
}
pub async fn settle_hold_invoice_response(
_plugin: Plugin<PluginState>,
_params: nip47::SettleHoldInvoiceRequest,
_label: &str,
plugin: Plugin<PluginState>,
params: nip47::SettleHoldInvoiceRequest,
) -> Vec<(nip47::Response, Option<String>)> {
vec![(
nip47::Response {
result_type: nip47::Method::MakeHoldInvoice,
error: Some(nip47::NIP47Error {
code: nip47::ErrorCode::NotImplemented,
message: "Not implemented".to_owned(),
}),
result: None,
},
None,
)]
vec![match settle_hold_invoice(plugin, params).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::SettleHoldInvoice,
error: None,
result: Some(nip47::ResponseResult::SettleHoldInvoice(o)),
},
None,
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::SettleHoldInvoice,
error: Some(e),
result: None,
},
None,
),
}]
}
async fn settle_hold_invoice(
plugin: Plugin<PluginState>,
params: nip47::SettleHoldInvoiceRequest,
) -> Result<nip47::SettleHoldInvoiceResponse, nip47::NIP47Error> {
let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::NotImplemented,
message: "No hold plugin found".to_owned(),
});
};
let preimage = match hex::decode(&params.preimage) {
Ok(p) => p,
Err(_e) => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Invalid preimage".to_owned(),
});
}
};
let hold_settle_request = SettleRequest {
payment_preimage: preimage,
};
let hold_settle_response = hold_client.settle(hold_settle_request).await;
match hold_settle_response {
Ok(_o) => Ok(nip47::SettleHoldInvoiceResponse {}),
Err(e) => Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}),
}
}

View file

@ -1,9 +1,11 @@
use cln_plugin::Plugin;
use cln_rpc::model::requests::GetinfoRequest;
use nostr::nips::nip47;
use crate::structs::PluginState;
use crate::util::{build_methods_vec, build_notifications_vec, is_read_only_nwc, load_nwc_store};
use nostr_sdk::nips::nip47;
use crate::{
structs::PluginState,
util::{build_methods_vec, build_notifications_vec, is_read_only_nwc, load_nwc_store},
};
pub async fn get_info_response(
plugin: Plugin<PluginState>,
@ -62,7 +64,7 @@ async fn get_info(
let methods = build_methods_vec(is_read_only_nwc(&nwc_store), &plugin);
Ok(nip47::GetInfoResponse {
alias: get_info.alias,
alias: Some(get_info.alias),
color: Some(get_info.color),
pubkey: Some(pubkey),
network: Some(network),

View file

@ -5,7 +5,7 @@ use cln_rpc::{
model::requests::InvoiceRequest,
primitives::{Amount, AmountOrAny, Sha256},
};
use nostr_sdk::nips::nip47;
use nostr::{nips::nip47, types::Timestamp};
use uuid::Uuid;
use crate::structs::PluginState;
@ -94,8 +94,8 @@ async fn make_invoice(
description_hash: params.description_hash,
preimage: None,
amount: Some(params.amount),
created_at: Some(nostr_sdk::nostr::Timestamp::now()),
expires_at: Some(nostr_sdk::nostr::Timestamp::from_secs(o.expires_at)),
created_at: Some(Timestamp::now()),
expires_at: Some(Timestamp::from_secs(o.expires_at)),
}),
Err(e) => Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,

View file

@ -1,18 +1,30 @@
use std::{str::FromStr, time::Duration};
use std::{collections::HashMap, str::FromStr};
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::KeysendRequest,
primitives::{Amount, PublicKey, TlvEntry, TlvStream},
ClnRpc,
RpcError,
model::requests::{KeysendRequest, XkeysendRequest},
primitives::{Amount, PublicKey, Secret, TlvEntry, TlvStream},
};
use nostr_sdk::nips::nip47;
use tokio::time;
use nostr::nips::nip47::{self};
use crate::{
structs::PluginState,
util::{budget_amount_check, load_nwc_store, update_nwc_store},
util::{
budget_amount_check,
get_budget_msat,
load_nwc_store,
payment_fee_reserve_msat,
refund_budget,
reserve_budget,
rpc_socket_path,
settle_budget,
},
};
pub const XKEYSEND_COMMAND: &str = "xkeysend";
pub async fn pay_keysend_response(
plugin: Plugin<PluginState>,
params: nip47::PayKeysendRequest,
@ -49,8 +61,6 @@ async fn pay_keysend(
params: nip47::PayKeysendRequest,
label: &str,
) -> Result<nip47::PayKeysendResponse, nip47::NIP47Error> {
let mut rpc = plugin.state().rpc_lock.lock().await;
if params.preimage.is_some() {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
@ -58,32 +68,142 @@ async fn pay_keysend(
});
}
let mut nwc_store = load_nwc_store(&mut rpc, label)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
budget_amount_check(Some(params.amount), None, nwc_store.budget_msat).map_err(|e| {
nip47::NIP47Error {
code: nip47::ErrorCode::QuotaExceeded,
message: e.to_string(),
}
})?;
let pubkey = PublicKey::from_str(&params.pubkey).map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: e.to_string(),
})?;
let reservation = {
let mut rpc = plugin.state().rpc_lock.lock().await;
let nwc_store = load_nwc_store(&mut rpc, label)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
budget_amount_check(Some(params.amount), None, get_budget_msat(&nwc_store)).map_err(
|e| nip47::NIP47Error {
code: nip47::ErrorCode::QuotaExceeded,
message: e.to_string(),
},
)?;
// Reserve amount plus worst case fee so concurrent payments can never
// exceed the budget.
if get_budget_msat(&nwc_store).unwrap_or(u64::MAX)
< params
.amount
.saturating_add(payment_fee_reserve_msat(params.amount))
{
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::QuotaExceeded,
message: "Payment and estimated fees exceed the available budget".to_owned(),
});
}
reserve_budget(&mut rpc, label, &nwc_store, params.amount)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
};
let has_xkeysend = plugin.state().config.lock().has_xkeysend;
let mut pay_rpc =
ClnRpc::new(rpc_socket_path(&plugin))
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: format!("Could not connect to lightningd: {e}"),
})?;
let pay_result = if has_xkeysend {
xkeysend(&mut pay_rpc, &params, pubkey).await
} else {
keysend(&mut pay_rpc, &params, pubkey).await
};
match pay_result {
Ok((amount_sent_msat, amount_msat, preimage)) => {
let mut rpc = plugin.state().rpc_lock.lock().await;
if let Err(e) = settle_budget(&mut rpc, label, reservation, amount_sent_msat).await {
log::error!("Error updating budget after successful keysend: {e}");
}
let preimage = hex::encode(preimage.to_vec());
let fees_paid = amount_sent_msat.saturating_sub(amount_msat);
Ok(nip47::PayKeysendResponse {
preimage,
fees_paid: Some(fees_paid),
})
}
Err(e) => {
let mut rpc = plugin.state().rpc_lock.lock().await;
if let Err(refund_err) = refund_budget(&mut rpc, label, reservation).await {
log::error!(
"Error refunding budget reservation after failed keysend: {refund_err}"
);
}
Err(map_keysend_error(&e, has_xkeysend))
}
}
}
async fn xkeysend(
pay_rpc: &mut ClnRpc,
params: &nip47::PayKeysendRequest,
pubkey: PublicKey,
) -> Result<(u64, u64, Secret), RpcError> {
let mut extratlvs = HashMap::with_capacity(params.tlv_records.len());
for tlv in &params.tlv_records {
extratlvs.insert(tlv.tlv_type.to_string(), tlv.value.clone());
}
let extratlvs = if extratlvs.is_empty() {
None
} else {
Some(extratlvs)
};
let o = pay_rpc
.call_typed(&XkeysendRequest {
extratlvs,
label: None,
maxdelay: None,
maxfee: None,
retry_for: None,
layers: None,
amount_msat: Amount::from_msat(params.amount),
destination: pubkey,
})
.await?;
Ok((
o.amount_sent_msat.msat(),
o.amount_msat.msat(),
o.payment_preimage,
))
}
async fn keysend(
pay_rpc: &mut ClnRpc,
params: &nip47::PayKeysendRequest,
pubkey: PublicKey,
) -> Result<(u64, u64, Secret), RpcError> {
let mut extratlvs = TlvStream {
entries: Vec::new(),
};
for tlv in params.tlv_records {
for tlv in &params.tlv_records {
extratlvs.entries.push(TlvEntry {
typ: tlv.tlv_type,
value: tlv.value.as_bytes().to_owned(),
value: hex::decode(&tlv.value).map_err(|e| RpcError {
code: Some(-32700),
message: format!("Could not decode tlv bytes: {e}"),
data: None,
})?,
});
}
let extratlvs = if extratlvs.entries.is_empty() {
@ -92,7 +212,7 @@ async fn pay_keysend(
Some(extratlvs)
};
match rpc
let o = pay_rpc
.call_typed(&KeysendRequest {
exemptfee: None,
extratlvs,
@ -105,80 +225,48 @@ async fn pay_keysend(
amount_msat: Amount::from_msat(params.amount),
destination: pubkey,
})
.await
{
Ok(o) => {
if let Some(ref mut bdg) = nwc_store.budget_msat {
*bdg = bdg.saturating_sub(o.amount_sent_msat.msat());
update_nwc_store(&mut rpc, label, nwc_store)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
}
.await?;
let preimage = hex::encode(o.payment_preimage.to_vec());
let fees_paid = o.amount_sent_msat.msat() - o.amount_msat.msat();
Ok(nip47::PayKeysendResponse {
preimage,
fees_paid: Some(fees_paid),
})
}
Err(e) => match e.code {
Some(c) => match c {
203 | 205 | 210 => Err(nip47::NIP47Error {
code: nip47::ErrorCode::PaymentFailed,
message: e.to_string(),
}),
206 => Err(nip47::NIP47Error {
code: nip47::ErrorCode::InsufficientBalance,
message: e.to_string(),
}),
_ => Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}),
},
None => Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}),
},
}
Ok((
o.amount_sent_msat.msat(),
o.amount_msat.msat(),
o.payment_preimage,
))
}
pub async fn multi_pay_keysend(
plugin: Plugin<PluginState>,
params: nip47::MultiPayKeysendRequest,
label: &str,
) -> Vec<(nip47::Response, Option<String>)> {
let mut responses = Vec::new();
for pay in params.keysends {
let result = pay_keysend(plugin.clone(), pay.clone(), label).await;
let id = if let Some(i) = pay.id { i } else { pay.pubkey };
let response_res = match result {
Ok(resp) => (
nip47::Response {
result_type: nip47::Method::MultiPayKeysend,
error: None,
result: Some(nip47::ResponseResult::MultiPayKeysend(resp)),
},
Some(id),
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::MultiPayKeysend,
error: Some(e),
result: None,
},
Some(id),
),
fn map_keysend_error(e: &RpcError, is_xkeysend: bool) -> nip47::NIP47Error {
let Some(c) = e.code else {
return nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
};
responses.push(response_res);
time::sleep(Duration::from_millis(100)).await;
};
let failed_codes = if is_xkeysend {
vec![203, 205, 207, 219]
} else {
vec![203, 205, 210]
};
if failed_codes.contains(&c) {
nip47::NIP47Error {
code: nip47::ErrorCode::PaymentFailed,
message: e.to_string(),
}
} else if is_xkeysend && c == 209 {
nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: e.to_string(),
}
} else if !is_xkeysend && c == 206 {
nip47::NIP47Error {
code: nip47::ErrorCode::InsufficientBalance,
message: e.to_string(),
}
} else {
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}
}
responses
}

View file

@ -2,19 +2,43 @@ use std::{cmp::Reverse, str::FromStr};
use cln_plugin::Plugin;
use cln_rpc::{
ClnRpc,
model::{
requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest},
requests::{
DecodeRequest,
ListinvoicesIndex,
ListinvoicesRequest,
ListpaysRequest,
WaitIndexname,
WaitRequest,
WaitSubsystem,
},
responses::{
ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPays, ListpaysPaysStatus,
DecodeResponse,
DecodeType,
ListinvoicesInvoices,
ListinvoicesInvoicesStatus,
ListpaysPays,
ListpaysPaysStatus,
},
},
primitives::Sha256,
ClnRpc,
};
use nostr_sdk::nips::nip47;
use nostr_sdk::Timestamp;
use nostr::{nips::nip47, types::Timestamp};
use tonic::transport::Channel;
use crate::structs::{PluginState, NOT_INV_ERR};
use crate::{
hold::{self, ListRequest, hold_client::HoldClient, list_request::Constraint},
structs::{NOT_INV_ERR, PluginState},
util::rpc_socket_path,
};
#[allow(clippy::doc_markdown)]
/// Hard bound on the number of transactions a single `list_transactions`
/// request will fetch, decode and return (DoS protection).
const MAX_TRANSACTIONS: usize = 500;
/// Bound on the size of a `list_transactions` response in bytes.
const RESPONSE_LIMIT_BYTES: usize = 127 * 1024;
pub async fn lookup_invoice_response(
plugin: Plugin<PluginState>,
@ -56,7 +80,7 @@ async fn lookup_invoice(
let invoice = if params.payment_hash.is_some() && params.invoice.is_some() {
None
} else {
params.invoice
params.invoice.clone()
};
let invoices = rpc
@ -79,46 +103,275 @@ async fn lookup_invoice(
if invoices.len() == 1 {
let invoice_response = invoices.into_iter().next().unwrap();
make_lookup_response_from_listinvoices(&mut rpc, invoice_response).await
return make_lookup_response_from_listinvoices(&mut rpc, invoice_response).await;
}
let payment_hash_hash = if let Some(hash) = &params.payment_hash {
if let Ok(res) = Sha256::from_str(hash) {
Some(res)
} else {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: "Could not convert payment hash".to_owned(),
});
}
} else {
let payment_hash_hash = if let Some(hash) = params.payment_hash {
if let Ok(res) = Sha256::from_str(&hash) {
Some(res)
None
};
let pays = rpc
.call_typed(&ListpaysRequest {
bolt11: invoice,
index: None,
limit: None,
payment_hash: payment_hash_hash,
start: None,
status: None,
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.pays;
if pays.len() == 1 {
let list_pay = pays.into_iter().next().unwrap();
return make_lookup_response_from_listpays(&mut rpc, list_pay).await;
}
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
if holdinvoice_support {
let mut hold_client = plugin.state().hold_client.lock().clone().unwrap();
return lookup_holdinvoice(&mut hold_client, &mut rpc, params).await;
}
Err(nip47::NIP47Error {
code: nip47::ErrorCode::NotFound,
message: "Transaction not found".to_owned(),
})
}
async fn lookup_holdinvoice(
hold_client: &mut HoldClient<Channel>,
rpc: &mut ClnRpc,
params: nip47::LookupInvoiceRequest,
) -> Result<nip47::LookupInvoiceResponse, nip47::NIP47Error> {
log::debug!("Looking up hold invoice for params {params:#?}");
let (hold_invoice, invoice_decoded) =
get_and_decode_holdinvoice(rpc, hold_client, params).await?;
let not_invoice_err = Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: NOT_INV_ERR.to_owned(),
});
let description = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description,
DecodeType::BOLT11_INVOICE => invoice_decoded.description,
_ => return not_invoice_err,
};
let description_hash = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => None,
DecodeType::BOLT11_INVOICE => invoice_decoded.description_hash.map(|h| h.to_string()),
_ => return not_invoice_err,
};
let created_at = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => {
Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap())
}
DecodeType::BOLT11_INVOICE => Timestamp::from_secs(invoice_decoded.created_at.unwrap()),
_ => return not_invoice_err,
};
let amount = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded.invoice_amount_msat.unwrap().msat(),
DecodeType::BOLT11_INVOICE => {
if let Some(amt) = invoice_decoded.amount_msat {
amt.msat()
} else {
// amount: `any` but have to put a value...
0
}
}
_ => return not_invoice_err,
};
let expires_at = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded
.invoice_relative_expiry
.map(|e_at| created_at + Timestamp::from_secs(u64::from(e_at))),
DecodeType::BOLT11_INVOICE => invoice_decoded
.expiry
.map(|e_at| created_at + Timestamp::from_secs(e_at)),
_ => return not_invoice_err,
};
let state = match hold_invoice.state() {
hold::InvoiceState::Unpaid => nip47::TransactionState::Pending,
hold::InvoiceState::Accepted => nip47::TransactionState::Accepted,
hold::InvoiceState::Paid => nip47::TransactionState::Settled,
hold::InvoiceState::Cancelled => nip47::TransactionState::Expired,
};
let settled_at = if hold_invoice.settled_at() != 0 {
Some(Timestamp::from_secs(hold_invoice.settled_at()))
} else {
None
};
let preimage = if hold_invoice.state() == hold::InvoiceState::Paid {
Some(hex::encode(hold_invoice.preimage()))
} else {
None
};
Ok(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: Some(hold_invoice.invoice.clone()),
description,
description_hash,
preimage,
payment_hash: hex::encode(hold_invoice.payment_hash),
amount,
fees_paid: 0,
created_at,
expires_at,
settled_at,
metadata: None,
state: Some(state),
})
}
#[allow(clippy::too_many_lines)]
async fn get_and_decode_holdinvoice(
rpc: &mut ClnRpc,
hold_client: &mut HoldClient<Channel>,
params: nip47::LookupInvoiceRequest,
) -> Result<(hold::Invoice, DecodeResponse), nip47::NIP47Error> {
if let Some(ph) = &params.payment_hash {
let payment_hash_hash = match hex::decode(ph) {
Ok(p) => p,
Err(_e) => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: "Could not convert payment hash".to_owned(),
code: nip47::ErrorCode::Other,
message: "Invalid payment hash".to_owned(),
});
}
} else {
None
};
let list_request = ListRequest {
constraint: Some(Constraint::PaymentHash(payment_hash_hash)),
};
let hold_lookup = hold_client
.list(list_request)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: format!("Could not fetch hold invoice: {e}"),
})?
.into_inner();
let pays = rpc
.call_typed(&ListpaysRequest {
bolt11: invoice,
index: None,
limit: None,
payment_hash: payment_hash_hash,
start: None,
status: None,
if hold_lookup.invoices.len() != 1 {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Transaction not found".to_owned(),
});
}
let hold_invoice = hold_lookup.invoices.into_iter().next().unwrap();
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: hold_invoice.invoice.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.pays;
if pays.len() != 1 {
})?;
if !invoice_decoded.valid {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::NotFound,
code: nip47::ErrorCode::Other,
message: "Invalid invoice decoded".to_owned(),
});
}
Ok((hold_invoice, invoice_decoded))
} else {
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: params.invoice.unwrap(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if !invoice_decoded.valid {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Invalid invoice decoded".to_owned(),
});
}
let ph = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded.invoice_payment_hash.unwrap(),
DecodeType::BOLT11_INVOICE => invoice_decoded.payment_hash.unwrap().to_string(),
_ => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Not a supported invoice type".to_owned(),
});
}
};
let payment_hash_hash = match hex::decode(&ph) {
Ok(p) => p,
Err(_e) => {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Invalid payment hash in invoice".to_owned(),
});
}
};
let list_request = ListRequest {
constraint: Some(Constraint::PaymentHash(payment_hash_hash)),
};
let hold_lookup = hold_client
.list(list_request)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: format!("Could not fetch hold invoice: {e}"),
})?
.into_inner();
if hold_lookup.invoices.len() != 1 {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Transaction not found".to_owned(),
});
}
let list_pay = pays.into_iter().next().unwrap();
make_lookup_response_from_listpays(&mut rpc, list_pay).await
let hold_invoice = hold_lookup.invoices.into_iter().next().unwrap();
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: hold_invoice.invoice.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if !invoice_decoded.valid {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Invalid invoice decoded".to_owned(),
});
}
Ok((hold_invoice, invoice_decoded))
}
}
@ -150,7 +403,37 @@ async fn list_transactions(
plugin: Plugin<PluginState>,
params: nip47::ListTransactionsRequest,
) -> Result<Vec<nip47::LookupInvoiceResponse>, nip47::NIP47Error> {
let mut rpc = plugin.state().rpc_lock.lock().await;
if params.limit == Some(0) {
return Ok(Vec::new());
}
let mut rpc = ClnRpc::new(rpc_socket_path(&plugin))
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: format!("Could not connect to lightningd: {e}"),
})?;
let limit = usize::try_from(params.limit.unwrap_or(MAX_TRANSACTIONS as u64)).map_err(|e| {
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: format!("32-bit usize limit exceeded: {e}"),
}
})?;
let offset = usize::try_from(params.offset.unwrap_or(0)).map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: format!("32-bit usize limit exceeded: {e}"),
})?;
let want = limit.saturating_add(offset);
// Enough candidates from each source to satisfy the requested window even
// if some get filtered out again by `from`/`until`/`unpaid`.
let per_source = want
.saturating_mul(2)
.saturating_add(16)
.min(MAX_TRANSACTIONS);
let from = params.from.map(|t| t.as_secs());
let until = params.until.map(|t| t.as_secs());
let unpaid = params.unpaid.unwrap_or(false);
let (query_invoices, query_payments) = match params.transaction_type {
Some(t) => match t {
@ -160,62 +443,29 @@ async fn list_transactions(
None => (true, true),
};
let unpaid = params.unpaid.unwrap_or(false);
let mut transactions: Vec<nip47::LookupInvoiceResponse> = Vec::new();
if query_invoices {
let list_invoices = rpc
.call_typed(&ListinvoicesRequest {
index: None,
invstring: None,
label: None,
limit: None,
offer_id: None,
payment_hash: None,
start: None,
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.invoices;
let invoices = collect_invoices(&mut rpc, per_source, from, until, unpaid).await?;
transactions.extend(invoices);
for list_invoice in list_invoices {
if !unpaid && list_invoice.status == ListinvoicesInvoicesStatus::UNPAID {
continue;
}
match make_lookup_response_from_listinvoices(&mut rpc, list_invoice).await {
Ok(t) => transactions.push(t),
Err(_e) => (),
}
let hold_client = plugin.state().hold_client.lock().clone();
if let Some(mut hold_client) = hold_client {
list_holdinvoices_to_transactions(
&mut hold_client,
&mut rpc,
per_source,
from,
until,
&mut transactions,
)
.await?;
}
}
if query_payments {
let list_pays = rpc
.call_typed(&ListpaysRequest {
bolt11: None,
index: None,
limit: None,
payment_hash: None,
start: None,
status: None,
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.pays;
for list_pay in list_pays {
match make_lookup_response_from_listpays(&mut rpc, list_pay).await {
Ok(t) => transactions.push(t),
Err(_e) => (),
}
}
let pays = collect_pays(&mut rpc, per_source, from, until).await?;
transactions.extend(pays);
}
transactions.sort_by_key(|t| Reverse(t.created_at));
@ -238,11 +488,354 @@ async fn list_transactions(
}
}
transactions = trim_to_size(transactions, 127 * 1024);
transactions = trim_to_size(transactions, RESPONSE_LIMIT_BYTES);
Ok(transactions)
}
async fn max_invoice_created_index(rpc: &mut ClnRpc) -> Result<Option<u64>, cln_rpc::RpcError> {
let current_index = rpc
.call_typed(&WaitRequest {
indexname: WaitIndexname::CREATED,
subsystem: WaitSubsystem::INVOICES,
nextvalue: 0,
})
.await?
.created
.ok_or_else(|| cln_rpc::RpcError {
code: Some(-32700),
message: "Missing created field in wait response".to_owned(),
data: None,
})?;
if current_index == 0 {
return Ok(None);
}
Ok(Some(current_index))
}
async fn first_invoice_created_index(rpc: &mut ClnRpc) -> Result<u64, cln_rpc::RpcError> {
let first_index = rpc
.call_typed(&ListinvoicesRequest {
label: None,
invstring: None,
payment_hash: None,
offer_id: None,
index: Some(ListinvoicesIndex::CREATED),
start: Some(0),
limit: Some(1),
})
.await?
.invoices
.first()
.map_or(0, |i| i.created_index);
Ok(first_index)
}
/// Collect up to `per_source` most recent paid/public invoices within
/// `[from, until]` by paging backwards through `created_index`, decoding only
/// what is needed. Bounded by `MAX_TRANSACTIONS` regardless of node history.
async fn collect_invoices(
rpc: &mut ClnRpc,
per_source: usize,
from: Option<u64>,
until: Option<u64>,
unpaid: bool,
) -> Result<Vec<nip47::LookupInvoiceResponse>, nip47::NIP47Error> {
const PAGE: u32 = 200;
const MAX_RAW_ROWS: usize = MAX_TRANSACTIONS * 8;
let Some(max_idx) = max_invoice_created_index(rpc)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
else {
return Ok(Vec::new());
};
let first_index = first_invoice_created_index(rpc)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut out = Vec::new();
let mut raw_seen = 0usize;
let mut start_excl = max_idx.saturating_add(1);
loop {
let page_start = start_excl.saturating_sub(u64::from(PAGE));
let rows = rpc
.call_typed(&ListinvoicesRequest {
index: Some(ListinvoicesIndex::CREATED),
invstring: None,
label: None,
limit: Some(PAGE),
offer_id: None,
payment_hash: None,
start: Some(page_start),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.invoices;
// Process newest first within the page.
for row in rows.into_iter().rev() {
raw_seen += 1;
if raw_seen > MAX_RAW_ROWS {
return Ok(out);
}
if row.created_index >= start_excl {
continue;
}
if !unpaid && row.status == ListinvoicesInvoicesStatus::UNPAID {
continue;
}
match make_lookup_response_from_listinvoices(rpc, row).await {
Ok(tx) => {
let created = tx.created_at.as_secs();
if let Some(f) = from {
// Newest first, so everything older is also out of range.
if created < f {
return Ok(out);
}
}
if let Some(u) = until {
if created > u {
continue;
}
}
out.push(tx);
if out.len() >= per_source {
return Ok(out);
}
}
Err(_e) => (),
}
}
if page_start == 0 || page_start < first_index {
break;
}
start_excl = page_start;
}
Ok(out)
}
/// Collect up to `per_source` most recent payments within `[from, until]`,
/// decoding only the kept subset. `listpays` returns `created_at` directly, so
/// the whole payment history can be filtered before any decode.
async fn collect_pays(
rpc: &mut ClnRpc,
per_source: usize,
from: Option<u64>,
until: Option<u64>,
) -> Result<Vec<nip47::LookupInvoiceResponse>, nip47::NIP47Error> {
let pays = rpc
.call_typed(&ListpaysRequest {
bolt11: None,
index: None,
limit: None,
payment_hash: None,
start: None,
status: None,
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.pays;
// Newest first.
let mut pays = pays;
pays.sort_by_key(|p| Reverse((p.created_at, p.created_index.unwrap_or(0))));
let mut out = Vec::new();
for pay in pays {
if out.len() >= per_source {
break;
}
if let Some(u) = until {
if pay.created_at > u {
continue;
}
}
if let Some(f) = from {
// Newest first, so the rest are also out of range.
if pay.created_at < f {
break;
}
}
match make_lookup_response_from_listpays(rpc, pay).await {
Ok(tx) => out.push(tx),
Err(_e) => (),
}
}
Ok(out)
}
async fn list_holdinvoices_to_transactions(
hold_client: &mut HoldClient<Channel>,
rpc: &mut ClnRpc,
per_source: usize,
from: Option<u64>,
until: Option<u64>,
transactions: &mut Vec<nip47::LookupInvoiceResponse>,
) -> Result<(), nip47::NIP47Error> {
let lookup_request = ListRequest { constraint: None };
let hold_lookup = hold_client
.list(lookup_request)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: format!("Could not fetch hold invoices: {e}"),
})?
.into_inner();
// Decode only the most recent `per_source` hold invoices (newest first).
let mut hold_invoices = hold_lookup.invoices;
hold_invoices.sort_by_key(|inv| Reverse(inv.created_at));
let mut kept = 0usize;
for hold_invoice in hold_invoices {
if kept >= per_source {
break;
}
if let Some(u) = until {
if hold_invoice.created_at > u {
continue;
}
}
if let Some(f) = from {
// Newest first, so the rest are also out of range.
if hold_invoice.created_at < f {
break;
}
}
match make_lookup_response_from_holdinvoice(rpc, &hold_invoice).await {
Ok(tx) => {
transactions.push(tx);
kept += 1;
}
Err(_e) => (),
}
}
Ok(())
}
async fn make_lookup_response_from_holdinvoice(
rpc: &mut ClnRpc,
hold_invoice: &hold::Invoice,
) -> Result<nip47::LookupInvoiceResponse, nip47::NIP47Error> {
let not_invoice_err = Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: NOT_INV_ERR.to_owned(),
});
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: hold_invoice.invoice.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let description = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description,
DecodeType::BOLT11_INVOICE => invoice_decoded.description,
_ => return not_invoice_err,
};
let description_hash = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => None,
DecodeType::BOLT11_INVOICE => invoice_decoded.description_hash.map(|h| h.to_string()),
_ => return not_invoice_err,
};
let created_at = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => {
Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap())
}
DecodeType::BOLT11_INVOICE => Timestamp::from_secs(invoice_decoded.created_at.unwrap()),
_ => return not_invoice_err,
};
let amount = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded.invoice_amount_msat.unwrap().msat(),
DecodeType::BOLT11_INVOICE => {
if let Some(amt) = invoice_decoded.amount_msat {
amt.msat()
} else {
// amount: `any` but have to put a value...
0
}
}
_ => return not_invoice_err,
};
let expires_at = match invoice_decoded.item_type {
DecodeType::BOLT12_INVOICE => invoice_decoded
.invoice_relative_expiry
.map(|e_at| created_at + Timestamp::from_secs(u64::from(e_at))),
DecodeType::BOLT11_INVOICE => invoice_decoded
.expiry
.map(|e_at| created_at + Timestamp::from_secs(e_at)),
_ => return not_invoice_err,
};
let state = match hold_invoice.state() {
hold::InvoiceState::Unpaid => nip47::TransactionState::Pending,
hold::InvoiceState::Accepted => nip47::TransactionState::Accepted,
hold::InvoiceState::Paid => nip47::TransactionState::Settled,
hold::InvoiceState::Cancelled => nip47::TransactionState::Expired,
};
let settled_at = if hold_invoice.settled_at() != 0 {
Some(Timestamp::from_secs(hold_invoice.settled_at()))
} else {
None
};
let preimage = if hold_invoice.state() == hold::InvoiceState::Paid {
Some(hex::encode(hold_invoice.preimage()))
} else {
None
};
Ok(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: Some(hold_invoice.invoice.clone()),
description,
description_hash,
preimage,
payment_hash: hex::encode(hold_invoice.payment_hash.clone()),
amount,
fees_paid: 0,
created_at,
expires_at,
settled_at,
metadata: None,
state: Some(state),
})
}
async fn make_lookup_response_from_listinvoices(
rpc: &mut ClnRpc,
list_invoice: ListinvoicesInvoices,
@ -252,10 +845,12 @@ async fn make_lookup_response_from_listinvoices(
message: NOT_INV_ERR.to_owned(),
});
let invstring = if list_invoice.bolt11.is_some() {
list_invoice.bolt11.as_ref().unwrap()
let invstring = if let Some(bolt11) = list_invoice.bolt11 {
bolt11
} else if let Some(bolt12) = list_invoice.bolt12 {
bolt12
} else {
list_invoice.bolt12.as_ref().unwrap()
return not_invoice_err;
};
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
@ -323,7 +918,7 @@ async fn make_lookup_response_from_listinvoices(
Ok(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: Some(invstring.to_owned()),
invoice: Some(invstring),
description,
description_hash,
preimage,
@ -444,30 +1039,36 @@ async fn make_lookup_response_from_listpays(
})
}
/// Keep only the newest transactions that fit within `max_size` bytes.
/// Transactions must be sorted newest first. Serializes each transaction only
/// once and chunks the remaining (O(n), not O(n^2)).
fn trim_to_size(
mut transactions: Vec<nip47::LookupInvoiceResponse>,
max_size: usize,
) -> Vec<nip47::LookupInvoiceResponse> {
let length_before = transactions.len();
while !transactions.is_empty() {
match serde_json::to_vec(&transactions) {
let mut total_size = 0usize;
let mut keep = 0usize;
for tx in &transactions {
match serde_json::to_vec(tx) {
Ok(serialized) => {
if serialized.len() <= max_size {
log::info!(
"Trimmed {} transactions to stay under {}bytes",
length_before - transactions.len(),
max_size
);
return transactions;
total_size += serialized.len();
if total_size > max_size {
break;
}
transactions.pop();
}
Err(e) => {
log::warn!("Failed to serialize transactions: {e}");
return transactions;
log::warn!("Failed to serialize transaction: {e}");
break;
}
}
keep += 1;
}
Vec::new()
let trimmed = transactions.len() - keep;
if trimmed > 0 {
log::info!("Trimmed {trimmed} transactions to stay under {max_size} bytes");
}
transactions.truncate(keep);
transactions
}

View file

@ -2,23 +2,38 @@ use std::str::FromStr;
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::model::requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest};
use cln_rpc::model::responses::{
DecodeResponse, ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPays,
ListpaysPaysStatus,
use cln_rpc::{
ClnRpc,
Notification,
model::{
requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest, ListpeerchannelsRequest},
responses::{
DecodeResponse,
ListinvoicesInvoices,
ListinvoicesInvoicesStatus,
ListpaysPays,
ListpaysPaysStatus,
},
},
notifications::{InvoicePaymentNotification, SendPaySuccessNotification},
primitives::Sha256,
};
use nostr::{
event::{EventBuilder, FinalizeEventAsync, Kind, Tag},
nips::{nip04, nip44, nip47},
types::Timestamp,
};
use cln_rpc::primitives::Sha256;
use cln_rpc::ClnRpc;
use crate::structs::{PluginState, NOT_INV_ERR};
use crate::OPT_NOTIFICATIONS;
use crate::{
OPT_NOTIFICATIONS,
hold::{InvoiceState, ListRequest, TrackRequest, list_request::Constraint},
structs::{NOT_INV_ERR, PluginState, WalletService},
};
use nostr_sdk::nips::nip47;
use nostr_sdk::nostr::key::PublicKey;
use nostr_sdk::nostr::EventBuilder;
use nostr_sdk::nostr::Kind;
use nostr_sdk::nostr::Tag;
use nostr_sdk::{Client, Timestamp};
/// Upper bound on how long a `holdinvoice_accepted_handler` waits for an
/// invoice to be accepted, so a client cannot pin a task and a gRPC stream
/// open forever with an absurd expiry.
const MAX_HOLD_WAIT_SECS: u64 = 24 * 60 * 60;
pub async fn payment_received_handler(
plugin: Plugin<PluginState>,
@ -27,13 +42,12 @@ pub async fn payment_received_handler(
if !plugin.option(&OPT_NOTIFICATIONS).unwrap() {
return Ok(());
}
let label = args
.get("invoice_payment")
.ok_or_else(|| anyhow!("Malformed invoice_payment notification: missing invoice_payment"))?
.get("label")
.ok_or_else(|| anyhow!("Malformed invoice_payment notification: missing label"))?
.as_str()
.ok_or_else(|| anyhow!("label not a string"))?;
let notif: Notification = serde_json::from_value(args)?;
let inv_pay_notif: InvoicePaymentNotification = match notif {
Notification::InvoicePayment(invoice_payment_notification) => invoice_payment_notification,
_ => return Err(anyhow!("Wrong notification type, expected invoice_payment")),
};
let mut rpc = plugin.state().rpc_lock.lock().await;
@ -41,7 +55,7 @@ pub async fn payment_received_handler(
.call_typed(&ListinvoicesRequest {
index: None,
invstring: None,
label: Some(label.to_owned()),
label: Some(inv_pay_notif.label),
limit: None,
offer_id: None,
payment_hash: None,
@ -53,25 +67,41 @@ pub async fn payment_received_handler(
let invoice = invoice_resp
.first()
.ok_or_else(|| anyhow!("invoice not found"))?;
let invstring = if invoice.bolt11.is_some() {
invoice.bolt11.as_ref().unwrap()
let invstring = if let Some(bolt11) = &invoice.bolt11 {
bolt11.clone()
} else if let Some(bolt12) = &invoice.bolt12 {
bolt12.clone()
} else {
invoice.bolt12.as_ref().unwrap()
return Err(anyhow!(
"Listinvoices has neither returned bolt11 or bolt12 field"
));
};
let payment_hash_str = hex::encode(invoice.payment_hash);
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await?;
if !invoice_decoded.valid {
return Err(anyhow!("Invalid invoice decoded for {payment_hash_str}"));
}
let notification =
make_payment_received_from_listinvoices(invoice, invstring, invoice_decoded)?;
let clients = plugin.state().handles.lock().await;
for (client, client_pubkey) in clients.values() {
send_notification(&notification, client, client_pubkey).await?;
for wallet_service in clients.values() {
if let Err(e) = send_notification(&notification, wallet_service).await {
log::warn!(
"Failed sending payment_received notification for {payment_hash_str} \
to client {}: {e}",
wallet_service.client_pubkey
);
}
}
Ok(())
@ -79,7 +109,7 @@ pub async fn payment_received_handler(
fn make_payment_received_from_listinvoices(
invoice: &ListinvoicesInvoices,
invstring: &str,
invstring: String,
invoice_decoded: DecodeResponse,
) -> Result<String, anyhow::Error> {
let not_invoice_err = Err(anyhow!(NOT_INV_ERR.to_owned()));
@ -147,7 +177,7 @@ fn make_payment_received_from_listinvoices(
notification_type: nip47::NotificationType::PaymentReceived,
notification: nip47::NotificationResult::PaymentReceived(nip47::PaymentNotification {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: invstring.to_owned(),
invoice: invstring,
description: description.clone(),
description_hash: description_hash.clone(),
preimage: preimage.clone(),
@ -174,18 +204,19 @@ pub async fn payment_sent_handler(
if !plugin.option(&OPT_NOTIFICATIONS).unwrap() {
return Ok(());
}
let payment_hash = args
.get("sendpay_success")
.ok_or_else(|| anyhow!("Malformed sendpay_success notification: missing sendpay_success"))?
.get("payment_hash")
.ok_or_else(|| anyhow!("Malformed sendpay_success notification: missing payment_hash"))?
.as_str()
.ok_or_else(|| anyhow!("payment_hash not a string"))?
.to_owned();
let notif: Notification = serde_json::from_value(args)?;
let send_pay_notif: SendPaySuccessNotification = match notif {
Notification::SendPaySuccess(send_pay_success_notification) => {
send_pay_success_notification
}
_ => return Err(anyhow!("Wrong notification type, expected sendpay_success")),
};
let payment_hash = hex::encode(send_pay_notif.payment_hash);
let mut rpc = plugin.state().rpc_lock.lock().await;
let pays_resp = rpc
let mut pays_resp = rpc
.call_typed(&ListpaysRequest {
bolt11: None,
index: None,
@ -197,20 +228,24 @@ pub async fn payment_sent_handler(
.await?
.pays;
pays_resp.retain(|p| p.status == ListpaysPaysStatus::COMPLETE);
let pay = pays_resp
.first()
.ok_or_else(|| anyhow!("payment not found"))?;
if pay.status != ListpaysPaysStatus::COMPLETE {
return Err(anyhow!("Payment not complete"));
}
.ok_or_else(|| anyhow!("complete payment not found"))?;
let notification = make_payment_sent_from_listpays(pay, &mut rpc).await?;
let clients = plugin.state().handles.lock().await;
for (client, client_pubkey) in clients.values() {
send_notification(&notification, client, client_pubkey).await?;
for wallet_service in clients.values() {
if let Err(e) = send_notification(&notification, wallet_service).await {
log::warn!(
"Failed sending payment_sent notification for {payment_hash} to\
client {}: {e}",
wallet_service.client_pubkey
);
}
}
Ok(())
@ -331,17 +366,19 @@ async fn make_payment_sent_from_listpays(
async fn send_notification(
notification: &String,
client: &Client,
client_pubkey: &PublicKey,
wallet_service: &WalletService,
) -> Result<(), anyhow::Error> {
let signer = client.signer().await?;
log::debug!("NOTIFICATION: {notification}");
let content_encrypted_nip04 = signer.nip04_encrypt(client_pubkey, notification).await?;
log::trace!("NOTIFICATION: {notification}");
let content_encrypted_nip04 = nip04::encrypt(
wallet_service.wallet_secret.secret_key(),
&wallet_service.client_pubkey,
notification,
)?;
let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04)
.tag(Tag::public_key(*client_pubkey))
.sign(&signer)
.tag(Tag::public_key(wallet_service.client_pubkey))
.finalize_async(&wallet_service.wallet_secret)
.await?;
let nip04_result = client.send_event(&event_nip04).await?;
let nip04_result = wallet_service.client.send_event(&event_nip04).await?;
if nip04_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nip04 notification: {}",
@ -352,14 +389,19 @@ async fn send_notification(
.join(", ")
);
}
log::debug!("NIP04 NOTIFICATION SENT: {event_nip04:?}");
log::trace!("NIP04 NOTIFICATION SENT: {event_nip04:?}");
let content_encrypted_nip44 = signer.nip44_encrypt(client_pubkey, notification).await?;
let content_encrypted_nip44 = nip44::encrypt(
wallet_service.wallet_secret.secret_key(),
&wallet_service.client_pubkey,
notification,
nip44::Version::V2,
)?;
let event_nip44 = EventBuilder::new(Kind::from_u16(23197), content_encrypted_nip44)
.tag(Tag::public_key(*client_pubkey))
.sign(&signer)
.tag(Tag::public_key(wallet_service.client_pubkey))
.finalize_async(&wallet_service.wallet_secret)
.await?;
let nip44_result = client.send_event(&event_nip44).await?;
let nip44_result = wallet_service.client.send_event(&event_nip44).await?;
if nip44_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nip44 notification: {}",
@ -370,7 +412,177 @@ async fn send_notification(
.join(", ")
);
}
log::debug!("NIP44 NOTIFICATION SENT: {event_nip44:?}");
log::trace!("NIP44 NOTIFICATION SENT: {event_nip44:?}");
Ok(())
}
#[allow(clippy::too_many_lines)]
pub async fn holdinvoice_accepted_handler(
plugin: Plugin<PluginState>,
payment_hash: Vec<u8>,
expires_at: u64,
) -> Result<(), anyhow::Error> {
let payment_hash_str = hex::encode(&payment_hash);
let mut hold_client = plugin.state().hold_client.lock().clone().unwrap();
let track_request = TrackRequest {
payment_hash: payment_hash.clone(),
};
let mut track_stream = hold_client.track(track_request).await?.into_inner();
// The hold plugin has no expired state: an invoice that expires without
// being accepted stays in the Unpaid state and the track stream never ends
// on its own. Bound the wait by the invoice's expiry (capped) so we do not
// hold this task and gRPC stream open forever.
let wait_duration = expires_at
.saturating_sub(Timestamp::now().as_secs())
.min(MAX_HOLD_WAIT_SECS);
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(wait_duration);
let mut accepted = false;
loop {
match tokio::time::timeout_at(deadline, track_stream.message()).await {
Err(_elapsed) => {
log::debug!("Hold invoice {payment_hash_str} expired before being accepted");
break;
}
Ok(Err(e)) => return Err(e.into()),
// The stream ended without an Accepted state: the invoice was
// cancelled before it ever got accepted.
Ok(Ok(None)) => break,
Ok(Ok(Some(response))) => {
log::debug!("Invoice status: {}", response.state().as_str_name());
match response.state() {
InvoiceState::Accepted => {
accepted = true;
break;
}
InvoiceState::Paid | InvoiceState::Cancelled => break,
InvoiceState::Unpaid => (),
}
}
}
}
if !accepted {
log::debug!("Hold invoice {payment_hash_str} was not accepted, skipping notification");
return Ok(());
}
let list_request = ListRequest {
constraint: Some(Constraint::PaymentHash(payment_hash.clone())),
};
let hold_lookup = hold_client.list(list_request).await?.into_inner();
if hold_lookup.invoices.len() != 1 {
return Err(anyhow!("hold plugin did not return exactly one invoice"));
}
let hold_invoice = hold_lookup.invoices.first().unwrap();
let mut rpc = plugin.state().rpc_lock.lock().await;
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: hold_invoice.invoice.clone(),
})
.await?;
let amount = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.invoice_amount_msat.unwrap().msat()
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
if let Some(amt) = invoice_decoded.amount_msat {
amt.msat()
} else {
// amount: `any` but have to put a value...
0
}
}
_ => return Err(anyhow!("hold plugin did not return an invoice string")),
};
let created_at = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap())
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
Timestamp::from_secs(invoice_decoded.created_at.unwrap())
}
_ => return Err(anyhow!("hold plugin did not return an invoice string")),
};
let expires_at = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
created_at
+ Timestamp::from_secs(u64::from(invoice_decoded.invoice_relative_expiry.unwrap()))
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
created_at + Timestamp::from_secs(invoice_decoded.expiry.unwrap())
}
_ => return Err(anyhow!("hold plugin did not return an invoice string")),
};
let list_peer_channels = rpc
.call_typed(&ListpeerchannelsRequest {
id: None,
short_channel_id: None,
channel_id: None,
})
.await?
.channels;
let payment_hash_hash = Sha256::from_str(&payment_hash_str)?;
let mut lowest_htlc_expiry = u32::MAX;
for peer in list_peer_channels {
if let Some(htlcs) = peer.htlcs {
for htlc in htlcs {
if htlc.payment_hash != payment_hash_hash {
continue;
}
if htlc.expiry < lowest_htlc_expiry {
lowest_htlc_expiry = htlc.expiry;
}
}
}
}
let clients = plugin.state().handles.lock().await;
let content = nip47::Notification {
notification_type: nip47::NotificationType::HoldInvoiceAccepted,
notification: nip47::NotificationResult::HoldInvoiceAccepted(
nip47::HoldInvoiceAcceptedNotification {
transaction_type: nip47::TransactionType::Incoming,
invoice: hold_invoice.invoice.clone(),
description: None,
description_hash: None,
payment_hash: hex::encode(&hold_invoice.payment_hash),
amount,
created_at,
expires_at,
settle_deadline: lowest_htlc_expiry,
metadata: None,
state: Some(nip47::TransactionState::Accepted),
},
),
};
let notification = serde_json::to_string(&content).unwrap();
for wallet_service in clients.values() {
if let Err(e) = send_notification(&notification, wallet_service).await {
log::warn!(
"Failed sending hold_invoice_accepted {payment_hash_str} notification\
to client {}: {e}",
wallet_service.client_pubkey
);
}
}
Ok(())
}

View file

@ -1,22 +1,31 @@
use std::time::Duration;
use cln_plugin::Plugin;
use cln_rpc::{
ClnRpc,
RpcError,
model::{
requests::{DecodeRequest, PayRequest, XpayRequest},
responses::DecodeResponse,
},
primitives::{Amount, Secret},
ClnRpc, RpcError,
};
use nostr_sdk::nips::nip47;
use tokio::time;
use nostr::nips::nip47;
use crate::{
structs::{NwcStore, PluginState, NOT_INV_ERR},
util::{at_or_above_version, budget_amount_check, load_nwc_store, update_nwc_store},
structs::{NOT_INV_ERR, NwcStore, PluginState},
util::{
budget_amount_check,
get_budget_msat,
load_nwc_store,
payment_fee_reserve_msat,
refund_budget,
reserve_budget,
rpc_socket_path,
settle_budget,
},
};
pub const XPAY_COMMAND: &str = "xpay";
pub async fn pay_invoice_response(
plugin: Plugin<PluginState>,
params: nip47::PayInvoiceRequest,
@ -47,24 +56,118 @@ async fn pay_invoice(
params: nip47::PayInvoiceRequest,
label: &str,
) -> Result<(nip47::PayInvoiceResponse, Option<String>), (nip47::NIP47Error, Option<String>)> {
let mut rpc = plugin.state().rpc_lock.lock().await;
let (id, reservation) = {
let mut rpc = plugin.state().rpc_lock.lock().await;
let decoded_invoice = decode_and_validate_invoice(&mut rpc, &params).await?;
let decoded_invoice = decode_and_validate_invoice(&mut rpc, &params).await?;
let id = get_payment_id(&params, &decoded_invoice)?;
let id = get_payment_id(&params, &decoded_invoice)?;
let invoice_amt_msat = get_invoice_amount_msat(&decoded_invoice, &id)?;
let invoice_amt_msat = get_invoice_amount_msat(&decoded_invoice);
let nwc_store =
load_nwc_and_check_budget(&mut rpc, label, &params, invoice_amt_msat, &id).await?;
let nwc_store =
load_nwc_and_check_budget(&mut rpc, label, &params, invoice_amt_msat, &id).await?;
let my_version = plugin.state().config.lock().clone().my_cln_version;
let use_xpay = check_cln_version(&my_version, &id)?;
let amt_msat = match (params.amount, invoice_amt_msat) {
(None, None) => {
return Err((
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: "No amount found in request or invoice".to_owned(),
},
Some(id.clone()),
));
}
(None, Some(b)) => b,
(Some(a), None) => a,
(Some(a), Some(b)) => {
if a != b {
return Err((
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: "request amount does not match invoice amount".to_owned(),
},
Some(id.clone()),
));
}
a
}
};
if use_xpay {
pay_with_xpay_full(&mut rpc, params, label, nwc_store, &id).await
// Reserve the invoice amount plus the worst case fee so that no
// combination of concurrent payments can exceed the budget and so that
// balance queries during the payment reflect the reserved amount.
if get_budget_msat(&nwc_store).unwrap_or(u64::MAX)
< amt_msat.saturating_add(payment_fee_reserve_msat(amt_msat))
{
return Err((
nip47::NIP47Error {
code: nip47::ErrorCode::QuotaExceeded,
message: "Payment and estimated fees exceed the available budget".to_owned(),
},
Some(id),
));
}
let reservation = reserve_budget(&mut rpc, label, &nwc_store, amt_msat)
.await
.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
Some(id.clone()),
)
})?;
(id, reservation)
};
let has_xpay = plugin.state().config.lock().has_xpay;
let mut pay_rpc = ClnRpc::new(rpc_socket_path(&plugin)).await.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: format!("Could not connect to lightningd: {e}"),
},
Some(id.clone()),
)
})?;
let pay_result = if has_xpay {
pay_with_xpay(&mut pay_rpc, &params).await
} else {
pay_with_legacy_full(&mut rpc, params, label, nwc_store, &id).await
pay_with_legacy(&mut pay_rpc, &params).await
};
match pay_result {
Ok((amount_sent_msat, amount_msat, preimage)) => {
let mut rpc = plugin.state().rpc_lock.lock().await;
if let Err(e) = settle_budget(&mut rpc, label, reservation, amount_sent_msat).await {
log::error!("Error updating budget after successful payment: {e}");
}
let preimage_str = hex::encode(preimage.to_vec());
let fees_paid = amount_sent_msat.saturating_sub(amount_msat);
Ok((
nip47::PayInvoiceResponse {
preimage: preimage_str,
fees_paid: Some(fees_paid),
},
Some(id),
))
}
Err(e) => {
let mut rpc = plugin.state().rpc_lock.lock().await;
if let Err(refund_err) = refund_budget(&mut rpc, label, reservation).await {
log::error!(
"Error refunding budget reservation after failed payment: {refund_err}"
);
}
Err(map_cln_error_to_nip47(&e, &id, has_xpay))
}
}
}
@ -138,30 +241,15 @@ fn get_payment_id(
Ok(id)
}
fn get_invoice_amount_msat(
decoded_invoice: &DecodeResponse,
id: &str,
) -> Result<u64, (nip47::NIP47Error, Option<String>)> {
decoded_invoice
.amount_msat
.as_ref()
.ok_or_else(|| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: "Missing amount_msat in decoded invoice".to_owned(),
},
Some(id.to_owned()),
)
})
.map(Amount::msat)
fn get_invoice_amount_msat(decoded_invoice: &DecodeResponse) -> Option<u64> {
decoded_invoice.amount_msat.as_ref().map(Amount::msat)
}
async fn load_nwc_and_check_budget(
rpc: &mut ClnRpc,
label: &str,
params: &nip47::PayInvoiceRequest,
invoice_amt_msat: u64,
invoice_amt_msat: Option<u64>,
id: &str,
) -> Result<NwcStore, (nip47::NIP47Error, Option<String>)> {
let nwc_store = load_nwc_store(rpc, label).await.map_err(|e| {
@ -174,7 +262,7 @@ async fn load_nwc_and_check_budget(
)
})?;
budget_amount_check(params.amount, Some(invoice_amt_msat), nwc_store.budget_msat).map_err(
budget_amount_check(params.amount, invoice_amt_msat, get_budget_msat(&nwc_store)).map_err(
|e| {
(
nip47::NIP47Error {
@ -189,56 +277,6 @@ async fn load_nwc_and_check_budget(
Ok(nwc_store)
}
fn check_cln_version(
my_version: &str,
id: &str,
) -> Result<bool, (nip47::NIP47Error, Option<String>)> {
at_or_above_version(my_version, "24.11").map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
Some(id.to_owned()),
)
})
}
async fn update_budget_and_create_response(
rpc: &mut ClnRpc,
label: &str,
nwc_store: &mut NwcStore,
amount_sent_msat: u64,
amount_msat: u64,
preimage: Secret,
id: &str,
) -> Result<(nip47::PayInvoiceResponse, Option<String>), (nip47::NIP47Error, Option<String>)> {
if let Some(ref mut bdg) = nwc_store.budget_msat {
*bdg = bdg.saturating_sub(amount_sent_msat);
update_nwc_store(rpc, label, nwc_store.clone())
.await
.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
Some(id.to_owned()),
)
})?;
}
let preimage_str = hex::encode(preimage.to_vec());
let fees_paid = amount_sent_msat - amount_msat;
Ok((
nip47::PayInvoiceResponse {
preimage: preimage_str,
fees_paid: Some(fees_paid),
},
Some(id.to_owned()),
))
}
fn map_cln_error_to_nip47(
e: &RpcError,
id: &str,
@ -276,8 +314,8 @@ fn map_cln_error_to_nip47(
} else if !is_xpay && c == 206 {
(
nip47::NIP47Error {
code: nip47::ErrorCode::InsufficientBalance,
message: e.to_string(),
code: nip47::ErrorCode::PaymentFailed,
message: format!("Route too expensive: {e}"),
},
Some(id.to_owned()),
)
@ -301,14 +339,11 @@ fn map_cln_error_to_nip47(
}
}
async fn pay_with_xpay_full(
rpc: &mut ClnRpc,
params: nip47::PayInvoiceRequest,
label: &str,
mut nwc_store: NwcStore,
id: &str,
) -> Result<(nip47::PayInvoiceResponse, Option<String>), (nip47::NIP47Error, Option<String>)> {
let payment_result = rpc
async fn pay_with_xpay(
pay_rpc: &mut ClnRpc,
params: &nip47::PayInvoiceRequest,
) -> Result<(u64, u64, Secret), RpcError> {
let payment_result = pay_rpc
.call_typed(&XpayRequest {
amount_msat: params.amount.map(Amount::from_msat),
maxdelay: None,
@ -316,35 +351,26 @@ async fn pay_with_xpay_full(
partial_msat: None,
retry_for: None,
layers: None,
invstring: params.invoice,
invstring: params.invoice.clone(),
payer_note: None,
dev_use_shadow: None,
label: None,
localinvreqid: None,
})
.await
.map_err(|e| map_cln_error_to_nip47(&e, id, true))?;
.await?;
let amount_sent_msat = payment_result.amount_sent_msat.msat();
let amount_msat = payment_result.amount_msat.msat();
let preimage = payment_result.payment_preimage;
update_budget_and_create_response(
rpc,
label,
&mut nwc_store,
amount_sent_msat,
amount_msat,
preimage,
id,
)
.await
Ok((
payment_result.amount_sent_msat.msat(),
payment_result.amount_msat.msat(),
payment_result.payment_preimage,
))
}
async fn pay_with_legacy_full(
rpc: &mut ClnRpc,
params: nip47::PayInvoiceRequest,
label: &str,
mut nwc_store: NwcStore,
id: &str,
) -> Result<(nip47::PayInvoiceResponse, Option<String>), (nip47::NIP47Error, Option<String>)> {
let payment_result = rpc
async fn pay_with_legacy(
pay_rpc: &mut ClnRpc,
params: &nip47::PayInvoiceRequest,
) -> Result<(u64, u64, Secret), RpcError> {
let payment_result = pay_rpc
.call_typed(&PayRequest {
amount_msat: params.amount.map(Amount::from_msat),
description: None,
@ -358,55 +384,13 @@ async fn pay_with_legacy_full(
retry_for: None,
riskfactor: None,
exclude: None,
bolt11: params.invoice,
bolt11: params.invoice.clone(),
})
.await
.map_err(|e| map_cln_error_to_nip47(&e, id, false))?;
.await?;
let amount_sent_msat = payment_result.amount_sent_msat.msat();
let amount_msat = payment_result.amount_msat.msat();
let preimage = payment_result.payment_preimage;
update_budget_and_create_response(
rpc,
label,
&mut nwc_store,
amount_sent_msat,
amount_msat,
preimage,
id,
)
.await
}
pub async fn multi_pay_invoice(
plugin: Plugin<PluginState>,
params: nip47::MultiPayInvoiceRequest,
label: &str,
) -> Vec<(nip47::Response, Option<String>)> {
let mut responses = Vec::new();
for pay in params.invoices {
let result = pay_invoice(plugin.clone(), pay, label).await;
let response_res = match result {
Ok((resp, id)) => (
nip47::Response {
result_type: nip47::Method::MultiPayInvoice,
error: None,
result: Some(nip47::ResponseResult::MultiPayInvoice(resp)),
},
id,
),
Err((e, id)) => (
nip47::Response {
result_type: nip47::Method::MultiPayInvoice,
error: Some(e),
result: None,
},
id,
),
};
responses.push(response_res);
time::sleep(Duration::from_millis(100)).await;
}
responses
Ok((
payment_result.amount_sent_msat.msat(),
payment_result.amount_msat.msat(),
payment_result.payment_preimage,
))
}

View file

@ -1,43 +1,72 @@
use std::path::Path;
use std::{collections::HashSet, path::Path};
use anyhow::anyhow;
use cln_plugin::ConfiguredPlugin;
use cln_rpc::{model::requests::GetinfoRequest, ClnRpc};
use cln_rpc::{ClnRpc, model::requests::HelpRequest};
use nostr::types::RelayUrl;
use crate::{
structs::{PluginState, TimeUnit},
OPT_RELAYS,
nwc_keysend::XKEYSEND_COMMAND,
nwc_pay::XPAY_COMMAND,
structs::{PluginState, TimeUnit},
};
pub async fn read_startup_options(
plugin: &ConfiguredPlugin<PluginState, tokio::io::Stdin, tokio::io::Stdout>,
state: &PluginState,
) -> Result<(), anyhow::Error> {
let relays_str = if let Some(relays) = plugin.option(&OPT_RELAYS).unwrap() {
if relays.is_empty() {
return Err(anyhow!(
"Empty `{}` option, must specify atleast one relay url!",
OPT_RELAYS.name()
));
}
relays
let relays_str = if plugin
.option(&OPT_RELAYS)
.unwrap()
.is_none_or(|v| v.is_empty())
{
vec![
"wss://nos.lol".to_owned(),
"wss://relay.primal.net".to_owned(),
"wss://relay.getalby.com/v1".to_owned(),
"wss://relay.nostr.net".to_owned(),
"wss://relay.snort.social".to_owned(),
]
} else {
return Err(anyhow!(
"`{}` not set, must specify atleast one relay url!",
OPT_RELAYS.name()
));
plugin.option(&OPT_RELAYS).unwrap().unwrap()
};
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let version = rpc.call_typed(&GetinfoRequest {}).await?.version;
let help = rpc.call_typed(&HelpRequest { command: None }).await?;
let mut config = state.config.lock();
config.my_cln_version = version;
let mut available_commands = HashSet::new();
for command in &help.help {
if let Some(method) = command.command.split_ascii_whitespace().next() {
available_commands.insert(method);
}
}
log::debug!(
"Found {} commands available: {}",
available_commands.len(),
available_commands
.iter()
.copied()
.collect::<Vec<_>>()
.join(" ")
);
config.has_xkeysend = available_commands.contains(XKEYSEND_COMMAND);
config.has_xpay = available_commands.contains(XPAY_COMMAND);
log::debug!(
"Using xpay:{} xkeysend:{}",
config.has_xpay,
config.has_xkeysend
);
for relay in relays_str {
log::debug!("RELAY:{relay}");
config.relays.push(nostr_sdk::RelayUrl::parse(&relay)?);
config.relays.push(RelayUrl::parse(&relay)?);
}
Ok(())
}
@ -51,10 +80,10 @@ pub fn parse_time_period(input: &str) -> Result<u64, anyhow::Error> {
if let Ok(time_unit) = unit.parse() {
match time_unit {
TimeUnit::Second => Ok(value),
TimeUnit::Minute => Ok(value * 60),
TimeUnit::Hour => Ok(value * 60 * 60),
TimeUnit::Day => Ok(value * 60 * 60 * 24),
TimeUnit::Week => Ok(value * 60 * 60 * 24 * 7),
TimeUnit::Minute => Ok(value.saturating_mul(60)),
TimeUnit::Hour => Ok(value.saturating_mul(60 * 60)),
TimeUnit::Day => Ok(value.saturating_mul(60 * 60 * 24)),
TimeUnit::Week => Ok(value.saturating_mul(60 * 60 * 24 * 7)),
}
} else {
Err(anyhow!(format!("Unsupported time unit: {unit}")))

View file

@ -1,22 +1,31 @@
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::model::requests::{
DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest,
DatastoreMode,
DatastoreRequest,
DeldatastoreRequest,
ListdatastoreRequest,
};
use nostr::{
key::{Keys, SecretKey},
nips::nip47::NostrWalletConnectUri,
types::Timestamp,
};
use nostr_sdk::nips::nip47::NostrWalletConnectURI;
use nostr_sdk::Keys;
use nostr_sdk::SecretKey;
use nostr_sdk::Timestamp;
use serde_json::json;
use crate::nwc::{
run_nwc, send_nwc_info_event, start_nwc_budget_job, stop_nwc, stop_nwc_budget_job,
use crate::{
PLUGIN_NAME,
nwc::{run_nwc, send_nwc_info_event, stop_nwc},
parse::parse_time_period,
structs::{BudgetIntervalConfig, NwcStore, PluginState},
util::{
build_capabilities,
get_budget_msat,
is_read_only_nwc,
load_nwc_store,
update_nwc_store,
},
};
use crate::parse::parse_time_period;
use crate::structs::{BudgetIntervalConfig, NwcStore, PluginState};
use crate::util::build_capabilities;
use crate::util::{is_read_only_nwc, load_nwc_store, update_nwc_store};
use crate::PLUGIN_NAME;
pub async fn nwc_create(
plugin: Plugin<PluginState>,
@ -30,7 +39,7 @@ pub async fn nwc_create(
let wallet_keys = Keys::generate();
let client_keys = Keys::generate();
let uri = NostrWalletConnectURI::new(
let uri = NostrWalletConnectUri::new(
wallet_keys.public_key(),
config.relays.clone(),
client_keys.secret_key().clone(),
@ -59,6 +68,7 @@ pub async fn nwc_create(
interval_secs: interval,
reset_budget_msat: bgt_msat,
last_reset: Timestamp::now().as_secs(),
spend_since_last_reset: 0,
};
result.insert("interval_config".to_owned(), serde_json::to_value(&conf)?);
Some(conf)
@ -73,6 +83,7 @@ pub async fn nwc_create(
walletkey: wallet_keys.secret_key().to_secret_hex(),
budget_msat,
interval_config,
reserved_msat: 0,
};
rpc.call_typed(&DatastoreRequest {
@ -84,7 +95,9 @@ pub async fn nwc_create(
})
.await?;
run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
if let Err(e) = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await {
log::warn!("Failed running nwc: {e}");
}
Ok(serde_json::Value::Object(result))
}
@ -116,8 +129,6 @@ pub async fn nwc_budget(
let (label, budget_msat, interval_secs) = parse_full_args(args)?;
stop_nwc_budget_job(&plugin, &label);
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
let is_old_nwc_read_only = is_read_only_nwc(&nwc_store);
@ -129,6 +140,7 @@ pub async fn nwc_budget(
interval_secs: interval,
reset_budget_msat: budget,
last_reset: Timestamp::now().as_secs(),
spend_since_last_reset: 0,
};
nwc_store.interval_config = Some(interval_config.clone());
} else {
@ -141,10 +153,6 @@ pub async fn nwc_budget(
let is_new_nwc_read_only = is_read_only_nwc(&nwc_store);
if nwc_store.interval_config.is_some() {
start_nwc_budget_job(&plugin, label.clone());
}
update_nwc_store(&mut rpc, &label, nwc_store.clone()).await?;
if is_old_nwc_read_only != is_new_nwc_read_only {
@ -156,7 +164,7 @@ pub async fn nwc_budget(
clients
.get(&label)
.ok_or_else(|| anyhow!("No client found for label: {label}"))?
.0
.client
.clone(),
method_capabilities,
wallet_keys,
@ -178,7 +186,8 @@ pub async fn nwc_list(
let mut nwcs = Vec::new();
if let Some(lbl) = label {
let nwc_store = load_nwc_store(&mut rpc, &lbl).await?;
let mut nwc_store = load_nwc_store(&mut rpc, &lbl).await?;
nwc_store.budget_msat = get_budget_msat(&nwc_store);
let wallet_key = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
let client_key = Keys::new(nwc_store.uri.secret.clone());
let mut nwc_json = json!(nwc_store);
@ -203,7 +212,8 @@ pub async fn nwc_list(
for datastore in all_stored_nwcs {
let label = datastore.key.last().unwrap().to_owned();
let nwc_store = load_nwc_store(&mut rpc, &label).await?;
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
nwc_store.budget_msat = get_budget_msat(&nwc_store);
let wallet_key = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
let client_key = Keys::new(nwc_store.uri.secret.clone());
let mut nwc_json = json!(nwc_store);

View file

@ -1,21 +1,28 @@
use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc};
use cln_rpc::ClnRpc;
use nostr_sdk::client;
use nostr_sdk::nips::nip47;
use nostr_sdk::nostr;
use nostr::{
key::{Keys, PublicKey},
nips::nip47::NostrWalletConnectUri,
types::RelayUrl,
};
use nostr_sdk::client::Client;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use tonic::transport::Channel;
use crate::hold::hold_client::HoldClient;
pub const NOT_INV_ERR: &str = "Not an invoice or invalid invoice";
pub const ID_STORE: &str = "eventids";
pub const ID_MAX_AGE: u64 = 7_200;
#[derive(Clone)]
pub struct PluginState {
pub config: Arc<Mutex<Config>>,
pub handles: Arc<tokio::sync::Mutex<HashMap<String, (client::Client, nostr::PublicKey)>>>,
pub handles: Arc<tokio::sync::Mutex<HashMap<String, WalletService>>>,
pub rpc_lock: Arc<tokio::sync::Mutex<ClnRpc>>,
pub budget_jobs: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
pub hold_client: Arc<Mutex<Option<HoldClient<Channel>>>>,
}
impl PluginState {
pub async fn new(path: PathBuf) -> Result<PluginState, anyhow::Error> {
@ -23,21 +30,29 @@ impl PluginState {
config: Arc::new(Mutex::new(Config::default())),
handles: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
rpc_lock: Arc::new(tokio::sync::Mutex::new(ClnRpc::new(path).await?)),
budget_jobs: Arc::new(Mutex::new(HashMap::new())),
hold_client: Arc::new(Mutex::new(None)),
})
}
}
pub struct WalletService {
pub client: Client,
pub client_pubkey: PublicKey,
pub wallet_secret: Keys,
}
#[derive(Clone, Debug)]
pub struct Config {
pub relays: Vec<nostr_sdk::RelayUrl>,
pub my_cln_version: String,
pub relays: Vec<RelayUrl>,
pub has_xkeysend: bool,
pub has_xpay: bool,
}
impl Config {
pub fn default() -> Config {
Config {
relays: Vec::new(),
my_cln_version: String::new(),
has_xkeysend: false,
has_xpay: false,
}
}
}
@ -70,14 +85,18 @@ pub struct BudgetIntervalConfig {
pub interval_secs: u64,
pub reset_budget_msat: u64,
pub last_reset: u64,
#[serde(default)]
pub spend_since_last_reset: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NwcStore {
pub uri: nip47::NostrWalletConnectURI,
pub uri: NostrWalletConnectUri,
pub walletkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_msat: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval_config: Option<BudgetIntervalConfig>,
#[serde(default)]
pub reserved_msat: u64,
}

View file

@ -1,59 +1,49 @@
use std::{path::Path, time::Duration};
use std::{path::Path, str::FromStr, time::Duration};
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::ClnRpc;
use nostr_sdk::Timestamp;
use tokio::{sync::oneshot, time};
use cln_rpc::{
ClnRpc,
model::requests::{DeldatastoreRequest, ListdatastoreRequest},
};
use nostr::types::Timestamp;
use crate::{
structs::PluginState,
util::{load_nwc_store, update_nwc_store},
PLUGIN_NAME,
structs::{ID_MAX_AGE, ID_STORE, PluginState},
};
pub async fn budget_task(
mut rx: oneshot::Receiver<()>,
plugin: Plugin<PluginState>,
label: String,
) -> Result<(), anyhow::Error> {
pub async fn cleanup_event_ids(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
loop {
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
let interval_config = nwc_store
.interval_config
.as_mut()
.ok_or_else(|| anyhow!("interval_config disappeared!"))?;
let now = Timestamp::now().as_secs();
log::debug!(
"interval:{} now:{} prev:{}",
interval_config.interval_secs,
now,
interval_config.last_reset
);
let next_reset = std::cmp::max(
interval_config
.interval_secs
.saturating_sub(now.saturating_sub(interval_config.last_reset)),
1,
);
tokio::select! {
_ = &mut rx => {
log::info!("Stopping budget task for {label}");
break;
}
() = time::sleep(Duration::from_secs(next_reset)) => {
log::info!("Refreshing budget for {label}");
*nwc_store.budget_msat
.as_mut()
.ok_or_else(||anyhow!("budget_msat missing"))? = interval_config.reset_budget_msat;
interval_config.last_reset = Timestamp::now().as_secs();
update_nwc_store(&mut rpc, &label, nwc_store).await?;
log::info!("Done refreshing budget for {label}");
{
let ids = rpc
.call_typed(&ListdatastoreRequest {
key: Some(vec![format!("{}-{}", PLUGIN_NAME, ID_STORE)]),
})
.await?
.datastore;
let now = Timestamp::now();
for id in ids {
if id.string.is_none() {
continue;
}
let timestamp = Timestamp::from_str(&id.string.unwrap())?;
if now.as_secs().saturating_sub(timestamp.as_secs()) > ID_MAX_AGE {
rpc.call_typed(&DeldatastoreRequest {
generation: None,
key: id.key.clone(),
})
.await?;
log::debug!("Cleaned up event id: {}", id.key.last().unwrap());
}
}
}
tokio::time::sleep(Duration::from_secs(120)).await;
}
Ok(())
}

View file

@ -1,17 +1,76 @@
use std::{
cmp::max,
path::{Path, PathBuf},
};
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest},
ClnRpc,
model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest},
};
use nostr_sdk::nips::nip47;
use nostr::{nips::nip47, types::Timestamp};
use crate::{
structs::{NwcStore, PluginState},
OPT_NOTIFICATIONS, PLUGIN_NAME, WALLET_NOTIFICATIONS, WALLET_PAY_METHODS, WALLET_READ_METHODS,
OPT_NOTIFICATIONS,
PLUGIN_NAME,
WALLET_HOLD_METHODS,
WALLET_HOLD_NOTIFICATIONS,
WALLET_NOTIFICATIONS,
WALLET_PAY_METHODS,
WALLET_READ_OR_RECEIVE_METHODS,
structs::{ID_STORE, NwcStore, PluginState},
};
/// CLN's default maximum fee for a payment is `max(5000msat, 1% of amount)`,
/// so reserving this much guarantees a settled payment never exceeds the budget.
pub const MIN_FEE_RESERVE_MSAT: u64 = 5_000;
pub fn payment_fee_reserve_msat(amount_msat: u64) -> u64 {
max(MIN_FEE_RESERVE_MSAT, amount_msat.saturating_div(100))
}
pub fn get_budget_msat(nwc_store: &NwcStore) -> Option<u64> {
match nwc_store.budget_msat {
Some(b) => {
let available = if let Some(conf) = &nwc_store.interval_config {
let now = Timestamp::now().as_secs();
let spend = if now.saturating_sub(conf.last_reset) >= conf.interval_secs {
0
} else {
conf.spend_since_last_reset
};
conf.reset_budget_msat.saturating_sub(spend)
} else {
b
};
Some(available.saturating_sub(nwc_store.reserved_msat))
}
None => None,
}
}
pub fn update_budget_msat(nwc_store: &mut NwcStore, amount_spent_msat: u64) {
if let Some(bdg) = nwc_store.budget_msat.as_mut() {
if let Some(conf) = nwc_store.interval_config.as_mut() {
let now = Timestamp::now().as_secs();
if now.saturating_sub(conf.last_reset) >= conf.interval_secs {
conf.last_reset = now;
conf.spend_since_last_reset = amount_spent_msat;
} else {
conf.spend_since_last_reset = conf
.spend_since_last_reset
.saturating_add(amount_spent_msat);
}
*bdg = conf
.reset_budget_msat
.saturating_sub(conf.spend_since_last_reset);
} else {
*bdg = bdg.saturating_sub(amount_spent_msat);
}
}
}
pub fn budget_amount_check(
request_amt_msat: Option<u64>,
invoice_amt_msat: Option<u64>,
@ -83,6 +142,72 @@ pub async fn update_nwc_store(
Ok(())
}
pub fn rpc_socket_path(plugin: &Plugin<PluginState>) -> PathBuf {
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file)
}
/// Reserve the payment amount plus the worst case fee so that no combination
/// of concurrent payments can exceed the budget. Must be called while holding
/// the global rpc lock. Returns the reserved amount (0 if there is no budget).
pub async fn reserve_budget(
rpc: &mut ClnRpc,
label: &str,
nwc_store: &NwcStore,
amount_msat: u64,
) -> Result<u64, anyhow::Error> {
if nwc_store.budget_msat.is_none() {
return Ok(0);
}
let reservation = amount_msat.saturating_add(payment_fee_reserve_msat(amount_msat));
let mut new_store = nwc_store.clone();
new_store.reserved_msat = new_store.reserved_msat.saturating_add(reservation);
update_nwc_store(rpc, label, new_store).await?;
Ok(reservation)
}
/// Release the reservation of a payment that did not succeed. Must be called
/// while holding the global rpc lock.
pub async fn refund_budget(
rpc: &mut ClnRpc,
label: &str,
reservation: u64,
) -> Result<(), anyhow::Error> {
if reservation == 0 {
return Ok(());
}
let mut nwc_store = load_nwc_store(rpc, label).await?;
nwc_store.reserved_msat = nwc_store.reserved_msat.saturating_sub(reservation);
update_nwc_store(rpc, label, nwc_store).await?;
Ok(())
}
/// Record the actual amount spent by a successful payment and release the
/// remainder of the reservation. Must be called while holding the global rpc
/// lock. Charges the real spend first so that an error leaves the budget
/// conservatively low rather than too high.
pub async fn settle_budget(
rpc: &mut ClnRpc,
label: &str,
reservation: u64,
amount_spent_msat: u64,
) -> Result<(), anyhow::Error> {
// A zero reservation only happens when the nwc has no budget at all.
if reservation == 0 {
return Ok(());
}
let mut nwc_store = load_nwc_store(rpc, label).await?;
update_budget_msat(&mut nwc_store, amount_spent_msat);
nwc_store.reserved_msat = nwc_store.reserved_msat.saturating_sub(reservation);
update_nwc_store(rpc, label, nwc_store).await?;
Ok(())
}
pub fn is_read_only_nwc(nwc_store: &NwcStore) -> bool {
if let Some(budget_msat) = nwc_store.budget_msat {
if budget_msat == 0 && nwc_store.interval_config.is_none() {
@ -92,40 +217,42 @@ pub fn is_read_only_nwc(nwc_store: &NwcStore) -> bool {
false
}
pub fn at_or_above_version(my_version: &str, min_version: &str) -> Result<bool, anyhow::Error> {
let clean_start_my_version = my_version
.split_once('v')
.ok_or_else(|| anyhow!("Could not find v in version string"))?
.1;
let full_clean_my_version: String = clean_start_my_version
.chars()
.take_while(|x| x.is_ascii_digit() || *x == '.')
.collect();
let my_version_parts: Vec<&str> = full_clean_my_version.split('.').collect();
let min_version_parts: Vec<&str> = min_version.split('.').collect();
if my_version_parts.len() <= 1 || my_version_parts.len() > 3 {
return Err(anyhow!("Version string parse error: {my_version}"));
}
for (my, min) in my_version_parts.iter().zip(min_version_parts.iter()) {
let my_num: u32 = my.parse()?;
let min_num: u32 = min.parse()?;
if my_num != min_num {
return Ok(my_num > min_num);
}
}
Ok(my_version_parts.len() >= min_version_parts.len())
pub async fn save_event_id(
rpc: &mut ClnRpc,
id: String,
timestamp: Timestamp,
) -> Result<(), anyhow::Error> {
rpc.call_typed(&DatastoreRequest {
key: vec![format!("{}-{}", PLUGIN_NAME, ID_STORE), id.clone()],
generation: None,
hex: None,
mode: Some(DatastoreMode::MUST_CREATE),
string: Some(timestamp.to_string()),
})
.await?;
log::debug!("stored event id:{id}");
Ok(())
}
pub fn build_capabilities(is_read_only: bool, plugin: &Plugin<PluginState>) -> (String, String) {
let mut methods = WALLET_READ_METHODS.map(|m| m.to_string()).join(" ");
if !is_read_only {
pub fn build_capabilities(is_receive_only: bool, plugin: &Plugin<PluginState>) -> (String, String) {
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
let mut methods = WALLET_READ_OR_RECEIVE_METHODS
.map(|m| m.to_string())
.join(" ");
if !is_receive_only {
methods.push(' ');
methods.push_str(WALLET_PAY_METHODS.map(|m| m.to_string()).join(" ").as_str());
}
if holdinvoice_support {
methods.push(' ');
methods.push_str(
WALLET_HOLD_METHODS
.map(|m| m.to_string())
.join(" ")
.as_str(),
);
}
let mut notifications = String::new();
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
@ -135,23 +262,44 @@ pub fn build_capabilities(is_read_only: bool, plugin: &Plugin<PluginState>) -> (
.join(" ")
.as_str(),
);
if holdinvoice_support {
notifications.push(' ');
notifications.push_str(
WALLET_HOLD_NOTIFICATIONS
.map(|m| m.to_string())
.join(" ")
.as_str(),
);
}
}
(methods, notifications)
}
pub fn build_methods_vec(is_read_only: bool, _plugin: &Plugin<PluginState>) -> Vec<nip47::Method> {
let mut methods = WALLET_READ_METHODS.to_vec();
if !is_read_only {
pub fn build_methods_vec(
is_receive_only: bool,
plugin: &Plugin<PluginState>,
) -> Vec<nip47::Method> {
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
let mut methods = WALLET_READ_OR_RECEIVE_METHODS.to_vec();
if !is_receive_only {
methods.extend_from_slice(&WALLET_PAY_METHODS);
}
if holdinvoice_support {
methods.extend_from_slice(&WALLET_HOLD_METHODS);
}
methods
}
pub fn build_notifications_vec(plugin: &Plugin<PluginState>) -> Vec<String> {
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
let mut notifications = Vec::new();
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
notifications.extend_from_slice(&WALLET_NOTIFICATIONS.map(|m| m.to_string()));
if holdinvoice_support {
notifications.extend_from_slice(&WALLET_HOLD_NOTIFICATIONS.map(|m| m.to_string()));
}
}
notifications
}
@ -173,3 +321,105 @@ fn test_budget_check() {
assert!(budget_amount_check(Some(0), Some(0), Some(1)).is_ok());
assert!(budget_amount_check(Some(0), Some(0), Some(0)).is_ok());
}
#[test]
fn test_budget_interval_helpers() {
use crate::structs::BudgetIntervalConfig;
let now = Timestamp::now().as_secs();
let conf = BudgetIntervalConfig {
interval_secs: 10,
reset_budget_msat: 1000,
last_reset: now,
spend_since_last_reset: 0,
};
let mut store = NwcStore {
uri: nostr::nips::nip47::NostrWalletConnectUri::new(
nostr::key::Keys::generate().public_key(),
vec![],
nostr::key::Keys::generate().secret_key().clone(),
None,
),
walletkey: "test".to_owned(),
budget_msat: Some(1000),
interval_config: Some(conf),
reserved_msat: 0,
};
assert_eq!(get_budget_msat(&store), Some(1000));
update_budget_msat(&mut store, 300);
assert_eq!(
store
.interval_config
.as_ref()
.unwrap()
.spend_since_last_reset,
300
);
assert_eq!(get_budget_msat(&store), Some(700));
update_budget_msat(&mut store, 500);
assert_eq!(get_budget_msat(&store), Some(200));
store.interval_config.as_mut().unwrap().last_reset = now.saturating_sub(20);
let old_last_reset = store.interval_config.as_ref().unwrap().last_reset;
assert_eq!(get_budget_msat(&store), Some(1000));
update_budget_msat(&mut store, 400);
assert!(store.interval_config.as_ref().unwrap().last_reset > old_last_reset);
assert_eq!(
store
.interval_config
.as_ref()
.unwrap()
.spend_since_last_reset,
400
);
assert_eq!(get_budget_msat(&store), Some(600));
}
#[test]
fn test_payment_fee_reserve() {
// fee reserve is at least 5000msat
assert_eq!(payment_fee_reserve_msat(0), 5000);
assert_eq!(payment_fee_reserve_msat(1000), 5000);
assert_eq!(payment_fee_reserve_msat(499_999), 5000);
// and 1% of the amount above that
assert_eq!(payment_fee_reserve_msat(500_000), 5000);
assert_eq!(payment_fee_reserve_msat(1_000_000), 10_000);
assert_eq!(payment_fee_reserve_msat(123_456_789), 1_234_567);
}
#[test]
fn test_budget_reservations() {
let store = NwcStore {
uri: nostr::nips::nip47::NostrWalletConnectUri::new(
nostr::key::Keys::generate().public_key(),
vec![],
nostr::key::Keys::generate().secret_key().clone(),
None,
),
walletkey: "test".to_owned(),
budget_msat: Some(10_000),
interval_config: None,
reserved_msat: 0,
};
// reserving reduces the available budget
let mut stored = store.clone();
stored.reserved_msat = 1_500;
assert_eq!(get_budget_msat(&stored), Some(8_500));
// a full spend plus reservation can never be under budget
let full_reservation =
stored.clone().budget_msat.unwrap() + payment_fee_reserve_msat(store.budget_msat.unwrap());
stored.reserved_msat = full_reservation;
assert_eq!(get_budget_msat(&stored), Some(0));
// reservations do not affect a store without a budget
let mut no_budget = store;
no_budget.budget_msat = None;
no_budget.reserved_msat = 1_000;
assert_eq!(get_budget_msat(&no_budget), None);
}

12
tests/config.toml Normal file
View file

@ -0,0 +1,12 @@
[info]
relay_url = "ws://127.0.0.1/"
name = "nostr-rs-relay"
description = "test relay"
[options]
reject_future_seconds = 1800
[limits]
limit_scrapers = false
[network]
address = "127.0.0.1"

50
tests/conftest.py Normal file
View file

@ -0,0 +1,50 @@
import os
import pytest
import tempfile
import shutil
from pathlib import Path
import subprocess
from pyln.testing.fixtures import * # noqa: F403
@pytest.fixture
def nostr_relay(worker_id, node_factory):
port = node_factory.get_unused_port()
config_path = Path(__file__).parent / "config.toml"
if not config_path.exists():
raise FileNotFoundError(f"config.toml not found at {config_path}")
temp_dir = Path(tempfile.mkdtemp())
# Copy your original config.toml into it
original_config = Path(__file__).parent / "config.toml"
temp_config = temp_dir / "config.toml"
shutil.copy(original_config, temp_config)
with temp_config.open("a") as f:
f.write(f"port = {port}\n")
proc = subprocess.Popen(
["./nostr-rs-relay", "--config", str(temp_config), "--db", str(temp_dir)],
cwd=Path(__file__).parent,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=os.environ | {"RUST_LOG": "warn,nostr_rs_relay=debug"},
)
try:
import time
time.sleep(1.0)
ws_url = f"ws://127.0.0.1:{port}"
yield ws_url
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()

BIN
tests/nostr-rs-relay.tar.gz Normal file

Binary file not shown.

View file

@ -1,19 +1,17 @@
[project]
name = "cln-nip47"
version = "0.1.0"
requires-python = ">=3.9"
requires-python = ">=3.10"
description = "python dependencies for running tests"
[dependency-groups]
dev = [
"pytest>=7.4,<9",
"pytest>=8,<10",
"pytest-asyncio>=0.23.8,<2",
"pytest-xdist>=3.7,<4",
"pytest-timeout>=2.4,<3",
"nostr-sdk>=0.44",
"PyYAML>=6.0.2,<7",
"nostr_relay>=1.14",
"pyln-testing>=24.11.1",
"pyln-client>=24.11.1",
"pyln-proto>=24.11.1",
"nostr-sdk>=0.45",
"pyln-testing>=25.9",
"pyln-client>=25.9",
"pyln-proto>=25.9",
]

View file

@ -68,6 +68,11 @@ else
exit 1
fi
if ! tar -xzvf "$script_dir/nostr-rs-relay.tar.gz" -C "$script_dir"; then
echo "Error extracting the contents of nostr-rs-relay.tar.gz" >&2
exit 1
fi
proto_path="$script_dir/../proto"
if [ -d "$proto_path" ]; then
# Generate grpc files
@ -76,3 +81,17 @@ if [ -d "$proto_path" ]; then
exit 1
fi
fi
hold_url="https://github.com/BoltzExchange/hold/releases/download/v0.3.3/hold-linux-amd64.tar.gz"
if ! curl -L "$hold_url" -o "$script_dir/hold-linux-amd64.tar.gz"; then
echo "Error downloading the file from $hold_url" >&2
exit 1
fi
if ! tar -xzvf "$script_dir/hold-linux-amd64.tar.gz" -C "$script_dir"; then
echo "Error extracting the contents of hold-linux-amd64.tar.gz" >&2
exit 1
fi
mv "$script_dir/build/hold-linux-amd64" "$script_dir/hold"

1626
tests/test_cln-nip47.py Executable file → Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,21 @@
import logging
import os
import random
import string
from pathlib import Path
from hashlib import sha256
import pytest
RUST_PROFILE = os.environ.get("RUST_PROFILE", "debug")
COMPILED_PATH = Path.cwd() / "target" / RUST_PROFILE / "cln-nip47"
DOWNLOAD_PATH = Path.cwd() / "tests" / "cln-nip47"
plugin_dir = Path(__file__).parent.parent.resolve()
COMPILED_PATH = plugin_dir / "target" / RUST_PROFILE / "cln-nip47"
DOWNLOAD_PATH = plugin_dir / "tests" / "cln-nip47"
DOWNLOAD_HOLD_PATH = plugin_dir / "tests" / "hold"
@pytest.fixture
def get_plugin(directory):
def get_plugin():
if COMPILED_PATH.is_file():
return COMPILED_PATH
elif DOWNLOAD_PATH.is_file():
@ -21,6 +24,14 @@ def get_plugin(directory):
raise ValueError("No files were found.")
@pytest.fixture
def get_hold():
if DOWNLOAD_HOLD_PATH.is_file():
return DOWNLOAD_HOLD_PATH
else:
raise ValueError("No files were found.")
def generate_random_label():
label_length = 8
random_label = "".join(
@ -33,15 +44,6 @@ def generate_random_number():
return random.randint(1, 20_000_000_000_000_00_000)
def pay_with_thread(rpc, bolt11):
LOGGER = logging.getLogger(__name__)
try:
rpc.dev_pay(bolt11, dev_use_shadow=False)
except Exception as e:
LOGGER.info(f"holdinvoice: Error paying payment hash:{e}")
pass
def update_config_file_option(lightning_dir, option_name, option_value):
with open(lightning_dir + "/config", "r") as file:
lines = file.readlines()

1432
tests/uv.lock generated Normal file

File diff suppressed because it is too large Load diff

3090
uv.lock generated

File diff suppressed because it is too large Load diff