This commit is contained in:
daywalker90 2025-04-02 17:38:52 +02:00
commit 2529bb5863
No known key found for this signature in database
35 changed files with 6788 additions and 0 deletions

188
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,188 @@
name: CI
# Cancel duplicate jobs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
workflow_call:
inputs:
cln-version:
required: true
type: string
pyln-version:
required: true
type: string
tagged-release:
required: true
type: boolean
jobs:
build:
name: Test CLN=${{ inputs.cln-version }}, OS=${{ matrix.os }}, PY=${{ matrix.python-version }}, BCD=${{ matrix.bitcoind-version }}, EXP=${{ matrix.experimental }}, DEP=${{ matrix.deprecated }}
strategy:
fail-fast: false
matrix:
bitcoind-version: ["28.0"]
experimental: [1]
deprecated: [0]
python-version: ["3.8", "3.11"]
os: ["ubuntu-24.04"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- 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 chown -R $USER /usr/local/libexec
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Extract exact python and os version
id: exact_versions
run: |
PYTHON_VERSION=$(python --version 2>&1 | grep -oP '(?<=Python )\d+\.\d+(\.\d+)?')
echo "Python version: $PYTHON_VERSION"
echo "python_version=$PYTHON_VERSION" >> "$GITHUB_OUTPUT"
OS_VERSION=$(lsb_release -rs)
echo "OS version: $OS_VERSION"
echo "os_version=$OS_VERSION" >> $GITHUB_OUTPUT
- name: Cache CLN
id: cache-cln
uses: actions/cache@v4
with:
path: |
/usr/local/bin/lightning*
/usr/local/libexec/c-lightning
key: cache-cln-${{ inputs.cln-version }}-${{ steps.exact_versions.outputs.os_version }}
- name: Cache bitcoind
id: cache-bitcoind
uses: actions/cache@v4
with:
path: /usr/local/bin/bitcoin*
key: cache-bitcoind-${{ matrix.bitcoind-version }}-${{ steps.exact_versions.outputs.os_version }}
- name: Cache python dependencies
id: cache-python
uses: actions/cache@v4
with:
path: venv
key: cache-python-${{ steps.exact_versions.outputs.python_version }}-${{ steps.exact_versions.outputs.os_version }}-${{ inputs.pyln-version }}-${{ hashFiles('tests/requirements.txt') }}
- name: Download Bitcoin ${{ matrix.bitcoind-version }} & install binaries
if: ${{ steps.cache-bitcoind.outputs.cache-hit != 'true' }}
run: |
export BITCOIND_VERSION=${{ matrix.bitcoind-version }}
if [[ "${{ matrix.os }}" =~ "ubuntu" ]]; then
export TARGET_ARCH="x86_64-linux-gnu"
fi
if [[ "${{ matrix.os }}" =~ "macos" ]]; then
export TARGET_ARCH="x86_64-apple-darwin"
fi
wget 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: Download Core Lightning ${{ inputs.cln-version }} & install binaries
if: ${{ contains(matrix.os, 'ubuntu') && steps.cache-cln.outputs.cache-hit != 'true' }}
run: |
url=$(curl -s https://api.github.com/repos/ElementsProject/lightning/releases/tags/${{ inputs.cln-version }} \
| jq '.assets[] | select(.name | contains("22.04")) | .browser_download_url' \
| tr -d '\"')
wget $url
sudo tar -xvf ${url##*/} -C /usr/local --strip-components=2
echo "CLN_VERSION=$(lightningd --version)" >> "$GITHUB_OUTPUT"
- name: Set up Rust
if: ${{ inputs.tagged-release == false}}
uses: dtolnay/rust-toolchain@stable
- name: Set up protoc
if: ${{ inputs.tagged-release == false}} || contains(matrix.os, 'macos') && ${{ steps.cache-cln.outputs.cache-hit != 'true' }}
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout Core Lightning ${{ inputs.cln-version }}
if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }}
uses: actions/checkout@v4
with:
repository: 'ElementsProject/lightning'
path: 'lightning'
ref: ${{ inputs.cln-version }}
submodules: 'recursive'
- name: Install System dependencies
run: |
if [[ "${{ matrix.os }}" =~ "macos" ]]; then
brew install autoconf automake libtool gnu-sed gettext libsodium sqlite
fi
- name: Install Python dependencies
if: ${{ steps.cache-python.outputs.cache-hit != 'true' }}
run: |
python -m venv venv
source venv/bin/activate
python -m pip install -U pip poetry wheel
pip3 install "pyln-proto<=${{ inputs.pyln-version }}" "pyln-client<=${{ inputs.pyln-version }}" "pyln-testing<=${{ inputs.pyln-version }}"
pip3 install pytest-xdist pytest-test-groups pytest-timeout
if [ -f "tests/requirements.txt" ]; then
pip3 install -r tests/requirements.txt
fi
- name: Compile Core Lightning ${{ inputs.cln-version }} & install binaries
if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }}
run: |
export EXPERIMENTAL_FEATURES=${{ matrix.experimental }}
export COMPAT=${{ matrix.deprecated }}
export VALGRIND=0
source venv/bin/activate
cd lightning
poetry lock
poetry install
./configure --disable-valgrind
poetry run make
sudo make install
- name: Get plugin binary
run: |
source venv/bin/activate
if ${{ inputs.tagged-release }}; then
cd tests
./setup.sh
cd ..
else
if [ -d "proto" ]; then
python -m grpc_tools.protoc --proto_path="proto" --python_out="tests" --grpc_python_out="tests" proto/*.proto
fi
cargo build
fi
- name: Run tests
run: |
export CLN_PATH=${{ github.workspace }}/lightning
export COMPAT=${{ matrix.deprecated }}
export EXPERIMENTAL_FEATURES=${{ matrix.experimental }}
export SLOW_MACHINE=1
export TEST_DEBUG=1
export TRAVIS=1
export VALGRIND=0
export PYTEST_TIMEOUT=600
source venv/bin/activate
pytest -n=5 tests/test_*.py

14
.github/workflows/latest_v24.08.yml vendored Normal file
View file

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

14
.github/workflows/latest_v24.11.yml vendored Normal file
View file

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

14
.github/workflows/latest_v25.02.yml vendored Normal file
View file

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

24
.github/workflows/main_v24.08.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: main on CLN v24.08.2
on:
push:
branches:
- main
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
- 'README.md'
- 'LICENSE'
- '.gitignore'
- 'coffee.yml'
- 'tests/setup.sh'
pull_request:
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v24.08.2"
pyln-version: "24.08"
tagged-release: false

24
.github/workflows/main_v24.11.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: main on CLN v24.11
on:
push:
branches:
- main
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
- 'README.md'
- 'LICENSE'
- '.gitignore'
- 'coffee.yml'
- 'tests/setup.sh'
pull_request:
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v24.11"
pyln-version: "24.11"
tagged-release: false

24
.github/workflows/main_v25.02.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: main on CLN v25.02
on:
push:
branches:
- main
paths-ignore:
- 'tools/**'
- 'CHANGELOG.md'
- 'README.md'
- 'LICENSE'
- '.gitignore'
- 'coffee.yml'
- 'tests/setup.sh'
pull_request:
workflow_dispatch:
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
cln-version: "v25.02"
pyln-version: "25.02"
tagged-release: false

104
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,104 @@
name: Build and release
on:
push:
tags:
- 'v*'
jobs:
build:
name: build release binaries on ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ["ubuntu-24.04"]
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 }}
release:
name: Github Release
needs: [build]
runs-on: "ubuntu-24.04"
permissions:
contents: write
steps:
- name: Get semver version from tag
id: tag_name
run: echo "current_version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v4
- name: Get Changelog Entry
id: changelog_reader
uses: mindsers/changelog-reader-action@v2
with:
validation_level: warn
version: ${{ steps.tag_name.outputs.current_version }}
path: ./CHANGELOG.md
- name: Download Artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Release
uses: ncipollo/release-action@v1
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"

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target/
/tests/__pycache__/
/venv/
/result/

2118
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
name = "cln-nip47"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
[dependencies]
anyhow = "1"
log = { version = "0.4", features = ['std'] }
log-panics = "2"
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" }
parking_lot = "0.12"
nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "f7122f5", features = ["nip47", "nip04", "nip44"]}
# nostr-sdk = { version = "0.40", features = ["nip47", "nip04", "nip44"]}
uuid = { version = "1", features = ["v4"]}
hex = "0.4"
regex = "1"
[profile.optimized]
inherits = "release"
strip = "debuginfo"
codegen-units = 1
lto = "fat"
debug = false

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 daywalker90
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

90
README.md Normal file
View file

@ -0,0 +1,90 @@
[![latest release on CLN v25.02](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml) [![latest release on CLN v24.11](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml) [![latest release on CLN v24.08.2](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml)
[![main on CLN v25.02](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml) [![main on CLN v24.11](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml) [![main on CLN v24.08.2](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.08.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.08.yml)
# 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).
* [Installation](#installation)
* [Building](#building)
* [Documentation](#documentation)
# Installation
For general plugin installation instructions see the plugins repo [README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation)
Release binaries for
* x86_64-linux
* armv7-linux (Raspberry Pi 32bit)
* aarch64-linux (Raspberry Pi 64bit)
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:
```
git clone https://github.com/daywalker90/cln-nip47.git
```
Install a recent rust version ([rustup](https://rustup.rs/) is recommended) and in the ``cln-nip47`` folder run:
```
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.
# Documentation
## Options
* `nip47-relays`: Specify the relays that you want to use with your NWC. Can be set multiple times to use multiple relays, but it is highly recommended to use your own relay since public relays may limit content length, amount of public keys per IP or require unsupported things like proof of work or payments. Each NWC you create is a separate public key and the ``list_transactions`` method can have quite a large content length! You must set this atleast one time.
## Methods
* **nip47-create** *label* [*budget_msat*] [*interval*]
* create a new NWC string (`uri`) with the currently configured relays. For example: ``nip47-create mynwc 10000 1d`` will let you spend 10 satoshis every day using that NWC
* ***label***: a label to identify this NWC
* ***budget_msat***: optional. Set an absolute budget in msat that this NWC is allowed to use. This will also be your balance in your wallet. If you ***don't*** set this, the NWC will be allowed to use your ***whole*** node balance and show that aswell in your wallet! Set it to ``0`` to disable paying anything with this NWC
* ***interval***: optional. Set an amount of time after which the budget will be refreshed ***to*** the amount specified in ``budget_msat``, e.g.:``5seconds`` or ``4weeks``. Supported time units are:
* seconds: "second", "seconds", "sec", "secs", "s"
* minutes: "minute", "minutes", "min", "mins", "m"
* hours: "hour", "hours", "h"
* days: "day", "days", "d"
* weeks: "week", "weeks", "w"
* **nip47-revoke** *label*
* revoke and remove all data related to a previously created NWC with ``label``
* ***label***: the label the NWC was created with
* **nip47-budget** *label* [*budget_msat*] [*interval*]
* update/add an existing NWC budget a new NWC string with the currently configured relays. For example: ``nip47-create mynwc 10000 1d`` will let you spend 10 satoshis every day using that NWC
* ***label***: a label to identify this NWC
* ***budget_msat***: optional. Set an absolute budget in msat that this NWC is allowed to use. This will also be your balance in your wallet. If you ***don't*** set this, the NWC will be allowed to use your ***whole*** node balance and show that aswell in your wallet! Set it to ``0`` to disable paying anything with this NWC
* ***interval***: optional. Set an amount of time after which the budget will be refreshed ***to*** the amount specified in ``budget_msat``, e.g.:``5seconds`` or ``4weeks``. Supported time units are the same as in ``nip47-create``
* **nip47-list** [*label*]
* list all NWC configurations or just the one with ``label``
* ***label***: optional. The label the NWC was created with
## 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``)
## Supported NWC notifications
* ``payment_received``
* ``payment_sent``
## Supported content encryption:
* [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md)
* [NIP-44v2](https://github.com/nostr-protocol/nips/blob/master/44.md)

9
coffee.yml Normal file
View file

@ -0,0 +1,9 @@
plugin:
name: cln-nip47
version: 0.1.0
lang: rust
install: |
cargo build --release && cp target/release/cln-nip47 . && cargo clean
main: cln-nip47
tipping:
bolt12: lno1pgykxmrw94hxjup5xutzzquqaupqnlcmgmpc5d7dgrmp85w6u046fqdfp9ze6mq5xjsw2mja3s

82
flake.lock generated Normal file
View file

@ -0,0 +1,82 @@
{
"nodes": {
"crane": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1721058578,
"narHash": "sha256-fs/PVa3H5dS1//4BjecWi3nitXm5fRObx0JxXIAo+JA=",
"owner": "ipetkov",
"repo": "crane",
"rev": "17e5109bb1d9fb393d70fba80988f7d70d1ded1a",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1721116560,
"narHash": "sha256-++TYlGMAJM1Q+0nMVaWBSEvEUjRs7ZGiNQOpqbQApCU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9355fa86e6f27422963132c2c9aeedb0fb963d93",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

68
flake.nix Normal file
View file

@ -0,0 +1,68 @@
{
description = "Build a cargo project without extra checks";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, crane, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
craneLib = crane.mkLib pkgs;
# Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates
commonArgs = {
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
buildInputs = [
# Add additional build inputs here
] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
};
my-crate = craneLib.buildPackage (commonArgs // {
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Additional environment variables or build phases/hooks can be set
# here *without* rebuilding all dependency crates
# MY_CUSTOM_VAR = "some value";
});
in
{
checks = {
inherit my-crate;
};
packages.default = my-crate;
apps.default = flake-utils.lib.mkApp {
drv = my-crate;
};
devShells.default = craneLib.devShell {
# Inherit inputs from checks.
checks = self.checks.${system};
# Additional dev-shell environment variables can be set directly
# MY_CUSTOM_DEVELOPMENT_VAR = "something else";
# Extra inputs can be added here; cargo and rustc are provided by default.
packages = [
# pkgs.ripgrep
];
};
});
}

132
src/main.rs Normal file
View file

@ -0,0 +1,132 @@
use std::{path::Path, time::Duration};
use anyhow::anyhow;
use cln_plugin::{
options::{ConfigOption, StringArrayConfigOption},
Builder, Plugin,
};
use cln_rpc::{model::requests::ListdatastoreRequest, ClnRpc};
use nostr_sdk::*;
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 structs::PluginState;
use tokio::time;
use util::load_nwc_store;
mod nwc;
mod nwc_balance;
mod nwc_info;
mod nwc_invoice;
mod nwc_keysend;
mod nwc_lookups;
mod nwc_notifications;
mod nwc_pay;
mod parse;
mod rpc;
mod structs;
mod tasks;
mod util;
const OPT_RELAYS: StringArrayConfigOption = ConfigOption::new_str_arr_no_default(
"nip47-relays",
"Nostr relays used for nwc. Can be stated multiple times.",
);
pub const PLUGIN_NAME: &str = "cln-nip47";
#[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",
);
log_panics::init();
let state = PluginState::default();
let confplugin = match Builder::new(tokio::io::stdin(), tokio::io::stdout())
.option(OPT_RELAYS)
.rpcmethod("nip47-create", "Create a new nwc", nwc_create)
.rpcmethod("nip47-revoke", "Revoke a nwc", nwc_revoke)
.rpcmethod("nip47-budget", "Set budget of a nwc", nwc_budget)
.rpcmethod("nip47-list", "List all nwc connections", nwc_list)
.subscribe("shutdown", shutdown_handler)
.subscribe("invoice_payment", payment_received_handler)
.subscribe("sendpay_success", payment_sent_handler)
.dynamic()
.configure()
.await?
{
Some(plugin) => {
match read_startup_options(&plugin, &state).await {
Ok(()) => &(),
Err(e) => return plugin.disable(format!("{}", e).as_str()).await,
};
log::debug!("read startup options done");
plugin
}
None => return Err(anyhow!("Error configuring cln-nip47!")),
};
let plugin = confplugin.start(state).await?;
{
let _guard = 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;
match load_nwcs(plugin.clone()).await {
Ok(_) => log::info!("All NWC's loaded"),
Err(e) => {
println!(
"{}",
serde_json::json!({"jsonrpc": "2.0",
"method": "log",
"params": {"level":"warn", "message":e.to_string()}})
);
return Err(anyhow!(e));
}
}
}
plugin.join().await
}
async fn shutdown_handler(
plugin: Plugin<PluginState>,
_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;
}
std::process::exit(0)
}
async fn load_nwcs(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let labels = rpc
.call_typed(&ListdatastoreRequest {
key: Some(vec![PLUGIN_NAME.to_owned()]),
})
.await?;
for datastore in labels.datastore.into_iter() {
let label = datastore.key.last().unwrap();
let nwc_store = load_nwc_store(&mut rpc, label).await?;
let client = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
let mut client_handles = plugin.state().handles.lock().await;
client_handles.insert(
label.clone(),
(client, Keys::new(nwc_store.uri.secret).public_key()),
);
}
Ok(())
}

443
src/nwc.rs Normal file
View file

@ -0,0 +1,443 @@
use std::time::Duration;
use crate::nwc_balance::get_balance;
use crate::nwc_info::get_info;
use crate::nwc_invoice::make_invoice;
use crate::nwc_keysend::{multi_pay_keysend, pay_keysend};
use crate::nwc_lookups::{list_transactions, lookup_invoice};
use crate::nwc_pay::{multi_pay_invoice, pay_invoice};
use crate::structs::{NwcStore, PluginState};
use crate::tasks::budget_task;
use cln_plugin::Plugin;
use nostr_sdk::nips::*;
use nostr_sdk::Client;
use nostr_sdk::*;
use tokio::sync::oneshot;
use tokio::time;
pub async fn run_nwc(
plugin: Plugin<PluginState>,
label: String,
nwc_store: NwcStore,
) -> Result<client::Client, client::Error> {
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).public_key();
let client = Client::new(wallet_keys.clone());
log::debug!("relay_count:{}", nwc_store.uri.relays.len());
for relay in nwc_store.uri.relays.iter() {
log::debug!("Adding relay: {}", relay);
client.add_relay(relay).await?;
}
if nwc_store.interval_config.is_some() {
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
plugin.state().budget_jobs.lock().insert(label.clone(), tx);
}
let client_clone = client.clone();
tokio::spawn(async move {
loop {
client_clone.connect().await;
client_clone
.wait_for_connection(Duration::from_secs(30))
.await;
let relays = client_clone.relays().await;
let mut connected = false;
for (url, relay) in relays {
if relay.status() == RelayStatus::Connected {
connected = true;
} else {
log::info!("Could not connect to {}", url)
}
}
if !connected {
log::warn!("Could not connect to any relays!");
time::sleep(Duration::from_secs(5)).await;
continue;
}
let info_event = match EventBuilder::new(
Kind::WalletConnectInfo,
"pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_invoice \
lookup_invoice list_transactions get_balance get_info",
)
.tag(Tag::parse(vec!["encryption", "nip44_v2 nip04"]).unwrap())
.tag(Tag::parse(vec!["notifications", "payment_received payment_sent"]).unwrap())
.sign_with_keys(&wallet_keys)
{
Ok(o) => o,
Err(e) => {
log::warn!("Could not sign info_event! {}", e);
time::sleep(Duration::from_secs(5)).await;
continue;
}
};
log::debug!("info_event:{:?}", info_event);
let send_result = match client_clone.send_event(&info_event).await {
Ok(o) => o,
Err(e) => {
log::warn!("Could not send info_event! {}", e);
client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
}
};
if send_result.success.is_empty() {
log::warn!(
"None of the relays received the info_event! {}",
send_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
);
client_clone.disconnect().await;
time::sleep(Duration::from_secs(5)).await;
continue;
}
let filter = Filter::new()
.kind(Kind::WalletConnectRequest)
.author(client_pubkey);
match client_clone.subscribe(filter, None).await {
Ok(_o) => (),
Err(e) => {
log::warn!("Could not subscribe to nwc events! {}", e);
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();
let label_clone = label.clone();
let wallet_keys_clone = wallet_keys.clone();
nwc_request_handler(
notification,
client_clone_handler,
plugin_clone,
label_clone,
wallet_keys_clone,
client_pubkey,
)
})
.await
{
Ok(()) => {
log::info!("NWC handler for `{}` stopped", label);
break;
}
Err(e) => log::warn!("NWC handler for `{}` had an error: {}", label, e),
};
}
});
Ok(client)
}
async fn nwc_request_handler(
notification: RelayPoolNotification,
client: client::Client,
plugin: Plugin<PluginState>,
label: String,
wallet_keys: Keys,
client_pubkey: PublicKey,
) -> Result<bool> {
let (relay_url, subscription_id, event) = match notification {
RelayPoolNotification::Event {
relay_url,
subscription_id,
event,
} => (relay_url, subscription_id, event),
RelayPoolNotification::Message {
relay_url: _,
message: _,
} => return Ok(false),
RelayPoolNotification::Shutdown => return Ok(true),
};
if let Some(expi) = event.tags.expiration() {
if *expi < Timestamp::now() {
return Ok(false);
}
}
log::debug!(
"relay_url:{} subscription_id:{} {:?}",
relay_url,
subscription_id,
event
);
let use_nip44;
let content = match nip44::decrypt(wallet_keys.secret_key(), &client_pubkey, &event.content) {
Ok(o) => {
use_nip44 = true;
o
}
Err(e) => {
log::debug!("Could not decrypt using NIP-44:{}. Trying NIP-04", e);
match nip04::decrypt(wallet_keys.secret_key(), &client_pubkey, &event.content) {
Ok(o) => {
use_nip44 = false;
o
}
Err(e) => {
log::warn!("Could not decrypt using NIP-04 or NIP-44:{}", e);
return Ok(false);
}
}
}
};
log::debug!("Decrypted:{}", content);
let request: nip47::Request = match serde_json::from_str(&content) {
Ok(o) => o,
Err(e) => {
log::warn!("Error parsing nip47::Request! {}", e);
return Ok(false);
}
};
let responses = match request.params {
nip47::RequestParams::PayInvoice(pay_invoice_request) => {
vec![
match pay_invoice(plugin.clone(), pay_invoice_request, &label).await {
Ok((o, id)) => (
nip47::Response {
result_type: nip47::Method::PayInvoice,
error: None,
result: Some(nip47::ResponseResult::PayInvoice(o)),
},
id,
),
Err((e, id)) => (
nip47::Response {
result_type: nip47::Method::PayInvoice,
error: Some(e),
result: None,
},
id,
),
},
]
}
nip47::RequestParams::MultiPayInvoice(multi_pay_invoice_request) => {
multi_pay_invoice(plugin.clone(), multi_pay_invoice_request, &label).await
}
nip47::RequestParams::PayKeysend(pay_keysend_request) => {
let id = if let Some(i) = pay_keysend_request.id.clone() {
i
} else {
pay_keysend_request.pubkey.clone()
};
vec![
match pay_keysend(plugin.clone(), pay_keysend_request, &label).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::PayKeysend,
error: None,
result: Some(nip47::ResponseResult::PayKeysend(o)),
},
id,
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::PayKeysend,
error: Some(e),
result: None,
},
id,
),
},
]
}
nip47::RequestParams::MultiPayKeysend(multi_pay_keysend_request) => {
multi_pay_keysend(plugin.clone(), multi_pay_keysend_request, &label).await
}
nip47::RequestParams::MakeInvoice(make_invoice_request) => {
vec![
match make_invoice(plugin.clone(), make_invoice_request).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::MakeInvoice,
error: None,
result: Some(nip47::ResponseResult::MakeInvoice(o)),
},
String::new(),
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::MakeInvoice,
error: Some(e),
result: None,
},
String::new(),
),
},
]
}
nip47::RequestParams::LookupInvoice(lookup_invoice_request) => {
vec![
match lookup_invoice(plugin.clone(), lookup_invoice_request).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::LookupInvoice,
error: None,
result: Some(nip47::ResponseResult::LookupInvoice(o)),
},
String::new(),
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::LookupInvoice,
error: Some(e),
result: None,
},
String::new(),
),
},
]
}
nip47::RequestParams::ListTransactions(list_transactions_request) => {
vec![
match list_transactions(plugin.clone(), list_transactions_request).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::ListTransactions,
error: None,
result: Some(nip47::ResponseResult::ListTransactions(o)),
},
String::new(),
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::ListTransactions,
error: Some(e),
result: None,
},
String::new(),
),
},
]
}
nip47::RequestParams::GetBalance => {
vec![match get_balance(plugin.clone(), &label).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::GetBalance,
error: None,
result: Some(nip47::ResponseResult::GetBalance(o)),
},
String::new(),
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::GetBalance,
error: Some(e),
result: None,
},
String::new(),
),
}]
}
nip47::RequestParams::GetInfo => {
vec![match get_info(plugin.clone()).await {
Ok(o) => (
nip47::Response {
result_type: nip47::Method::GetInfo,
error: None,
result: Some(nip47::ResponseResult::GetInfo(o)),
},
String::new(),
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::GetInfo,
error: Some(e),
result: None,
},
String::new(),
),
}]
}
};
for (response, id) in responses.into_iter() {
let response_str = match serde_json::to_string(&response) {
Ok(o) => o,
Err(e) => {
log::warn!("Error serializing response! {}", e);
continue;
}
};
log::debug!("RESPONSE:{}", response_str);
let content = if use_nip44 {
match nip44::encrypt(
wallet_keys.secret_key(),
&client_pubkey,
response_str,
nip44::Version::V2,
) {
Ok(o) => o,
Err(e) => {
log::warn!("Error encrypting response with nip44! {}", e);
continue;
}
}
} else {
match nip04::encrypt(wallet_keys.secret_key(), &client_pubkey, response_str) {
Ok(o) => o,
Err(e) => {
log::warn!("Error encrypting response with nip04! {}", e);
continue;
}
}
};
let mut response_builder = EventBuilder::new(Kind::WalletConnectResponse, content)
.tag(Tag::event(event.id))
.tag(Tag::public_key(client_pubkey));
if !id.is_empty() {
response_builder = response_builder.tag(Tag::custom(
TagKind::SingleLetter(SingleLetterTag {
character: Alphabet::D,
uppercase: false,
}),
vec![id],
));
}
let response_event = match response_builder.sign_with_keys(&wallet_keys) {
Ok(o) => o,
Err(e) => {
log::warn!("Error signing reponse event! {}", e);
continue;
}
};
let send_result = match client.send_event(&response_event).await {
Ok(o) => o,
Err(e) => {
log::warn!("Error sending response event! {}", e);
continue;
}
};
if send_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nwc response: {}",
send_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
);
continue;
}
log::debug!("SENT RESPONSE {:?}", response_event);
}
Ok(false)
}

53
src/nwc_balance.rs Normal file
View file

@ -0,0 +1,53 @@
use std::path::Path;
use cln_plugin::Plugin;
use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState, ClnRpc};
use nostr_sdk::nips::*;
use crate::{structs::PluginState, util::load_nwc_store};
pub async fn get_balance(
plugin: Plugin<PluginState>,
label: &String,
) -> Result<nip47::GetBalanceResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let nwc_store = load_nwc_store(&mut rpc, label)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let balance = if let Some(bdgt_amt) = nwc_store.budget_msat {
bdgt_amt
} else {
let listpeerchannels = rpc
.call_typed(&ListpeerchannelsRequest { id: None })
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut amount_msat = 0;
for chan in listpeerchannels.channels {
if chan.state == ChannelState::CHANNELD_NORMAL
|| chan.state == ChannelState::CHANNELD_AWAITING_SPLICE
{
if let Some(spend) = chan.spendable_msat {
amount_msat += spend.msat()
}
}
}
amount_msat
};
Ok(nip47::GetBalanceResponse { balance })
}

63
src/nwc_info.rs Normal file
View file

@ -0,0 +1,63 @@
use std::{path::Path, str::FromStr};
use cln_plugin::Plugin;
use cln_rpc::{model::requests::GetinfoRequest, ClnRpc};
use nostr_sdk::nips::*;
use nostr_sdk::*;
use crate::structs::PluginState;
pub async fn get_info(
plugin: Plugin<PluginState>,
) -> Result<nip47::GetInfoResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let get_info = rpc
.call_typed(&GetinfoRequest {})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let pubkey =
nostr_sdk::secp256k1::PublicKey::from_str(&get_info.id.to_string()).map_err(|e| {
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}
})?;
let network = match get_info.network.as_str() {
"bitcoin" => "mainnet".to_owned(),
_ => get_info.network,
};
Ok(nip47::GetInfoResponse {
alias: get_info.alias,
color: Some(get_info.color),
pubkey: Some(pubkey),
network: Some(network),
block_height: Some(get_info.blockheight),
block_hash: None,
methods: vec![
"pay_invoice".to_owned(),
"multi_pay_invoice".to_owned(),
"pay_keysend".to_owned(),
"multi_pay_keysend".to_owned(),
"make_invoice".to_owned(),
"lookup_invoice".to_owned(),
"list_transactions".to_owned(),
"get_balance".to_owned(),
"get_info".to_owned(),
],
notifications: vec!["payment_received".to_owned(), "payment_sent".to_owned()],
})
}

74
src/nwc_invoice.rs Normal file
View file

@ -0,0 +1,74 @@
use std::{path::Path, str::FromStr};
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::InvoiceRequest,
primitives::{Amount, AmountOrAny, Sha256},
ClnRpc,
};
use nostr_sdk::nips::*;
use uuid::Uuid;
use crate::structs::PluginState;
pub async fn make_invoice(
plugin: Plugin<PluginState>,
params: nip47::MakeInvoiceRequest,
) -> Result<nip47::MakeInvoiceResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let mut deschashonly = None;
if let Some(d_hash) = params.description_hash {
if params.description.is_none() {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Must have description when using description_hash".to_owned(),
});
}
let description = params.description.as_ref().unwrap();
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(),
});
}
deschashonly = Some(true)
}
match rpc
.call_typed(&InvoiceRequest {
cltv: None,
deschashonly,
expiry: params.expiry,
preimage: None,
exposeprivatechannels: None,
fallbacks: None,
amount_msat: AmountOrAny::Amount(Amount::from_msat(params.amount)),
description: params.description.unwrap_or("NWC make_invoice".to_owned()),
label: Uuid::new_v4().to_string(),
})
.await
{
Ok(o) => Ok(nip47::MakeInvoiceResponse {
invoice: o.bolt11,
payment_hash: o.payment_hash.to_string(),
}),
Err(e) => Err(nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
}),
}
}

157
src/nwc_keysend.rs Normal file
View file

@ -0,0 +1,157 @@
use std::{path::Path, str::FromStr, time::Duration};
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::KeysendRequest,
primitives::{Amount, PublicKey, TlvEntry, TlvStream},
ClnRpc,
};
use nostr_sdk::nips::*;
use tokio::time;
use crate::{
structs::PluginState,
util::{budget_amount_check, load_nwc_store, update_nwc_store},
};
pub async fn pay_keysend(
plugin: Plugin<PluginState>,
params: nip47::PayKeysendRequest,
label: &String,
) -> Result<nip47::PayKeysendResponse, nip47::NIP47Error> {
let _guard = plugin.state().rpc_lock.lock().await;
if params.preimage.is_some() {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "CLN generates the preimage itself!".to_owned(),
});
}
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
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 mut extratlvs = TlvStream {
entries: Vec::new(),
};
for tlv in params.tlv_records {
extratlvs.entries.push(TlvEntry {
typ: tlv.tlv_type,
value: tlv.value.as_bytes().to_owned(),
});
}
let extratlvs = if extratlvs.entries.is_empty() {
None
} else {
Some(extratlvs)
};
match rpc
.call_typed(&KeysendRequest {
exemptfee: None,
extratlvs,
label: None,
maxdelay: None,
maxfee: None,
maxfeepercent: None,
retry_for: None,
routehints: None,
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(),
})?;
}
let preimage = hex::encode(o.payment_preimage.to_vec());
Ok(nip47::PayKeysendResponse { preimage })
}
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(),
}),
},
}
}
pub async fn multi_pay_keysend(
plugin: Plugin<PluginState>,
params: nip47::MultiPayKeysendRequest,
label: &String,
) -> Vec<(nip47::Response, 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)),
},
id,
),
Err(e) => (
nip47::Response {
result_type: nip47::Method::MultiPayKeysend,
error: Some(e),
result: None,
},
id,
),
};
responses.push(response_res);
time::sleep(Duration::from_millis(100)).await;
}
responses
}

504
src/nwc_lookups.rs Normal file
View file

@ -0,0 +1,504 @@
use std::{cmp::Reverse, path::Path, str::FromStr};
use cln_plugin::Plugin;
use cln_rpc::{
model::{
requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest},
responses::{ListinvoicesInvoicesStatus, ListpaysPaysStatus},
},
primitives::Sha256,
ClnRpc,
};
use nostr_sdk::nips::*;
use nostr_sdk::*;
use crate::structs::PluginState;
pub async fn lookup_invoice(
plugin: Plugin<PluginState>,
params: nip47::LookupInvoiceRequest,
) -> Result<nip47::LookupInvoiceResponse, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if params.payment_hash.is_none() && params.invoice.is_none() {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Neither invoice nor payment_hash given".to_owned(),
});
}
let not_invoice_err = Err(nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: "Not an invoice or invalid invoice".to_owned(),
});
let invoice = if params.payment_hash.is_some() && params.invoice.is_some() {
None
} else {
params.invoice
};
let invoices = rpc
.call_typed(&ListinvoicesRequest {
index: None,
invstring: invoice.clone(),
label: None,
limit: None,
offer_id: None,
payment_hash: params.payment_hash.clone(),
start: None,
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?
.invoices;
if invoices.len() == 1 {
let invoice_response = invoices.first().cloned().unwrap();
let invstring = if invoice_response.bolt11.is_some() {
invoice_response.bolt11.unwrap()
} else {
invoice_response.bolt12.unwrap()
};
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if !invoice_decoded.valid {
return not_invoice_err;
}
let description = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.offer_description
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description,
_ => return not_invoice_err,
};
let description_hash = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description_hash.map(|h| h.to_string())
}
_ => return not_invoice_err,
};
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 if let Some(a) = invoice_response.amount_msat {
a.msat()
} else {
// amount: `any` but have to put a value...
0
}
}
_ => return not_invoice_err,
};
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 not_invoice_err,
};
let preimage = invoice_response
.payment_preimage
.map(|p| hex::encode(p.to_vec()));
Ok(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: Some(invstring),
description,
description_hash,
preimage,
payment_hash: invoice_response.payment_hash.to_string(),
amount,
fees_paid: 0,
created_at,
expires_at: Some(Timestamp::from_secs(invoice_response.expires_at)),
settled_at: invoice_response.paid_at.map(Timestamp::from_secs),
metadata: None,
})
} else {
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 {
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 {
return Err(nip47::NIP47Error {
code: nip47::ErrorCode::NotFound,
message: "Transaction not found".to_owned(),
});
}
let list_pay = pays.first().unwrap().clone();
let invstring = if list_pay.bolt11.is_some() {
list_pay.bolt11.unwrap()
} else {
list_pay.bolt12.unwrap()
};
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if !invoice_decoded.valid {
return not_invoice_err;
}
let description = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.offer_description
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description,
_ => return not_invoice_err,
};
let description_hash = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description_hash.map(|h| h.to_string())
}
_ => return not_invoice_err,
};
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 if let Some(amt) = list_pay.amount_msat {
amt.msat()
} else {
return not_invoice_err;
}
}
_ => return not_invoice_err,
};
let fees_paid = list_pay.amount_sent_msat.unwrap().msat() - amount;
let preimage = list_pay.preimage.map(|p| hex::encode(p.to_vec()));
Ok(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Outgoing),
invoice: Some(invstring),
description,
description_hash,
preimage,
payment_hash: list_pay.payment_hash.to_string(),
amount,
fees_paid,
created_at: Timestamp::from_secs(list_pay.created_at),
expires_at: None,
settled_at: list_pay.completed_at.map(Timestamp::from_secs),
metadata: None,
})
}
}
pub async fn list_transactions(
plugin: Plugin<PluginState>,
params: nip47::ListTransactionsRequest,
) -> Result<Vec<nip47::LookupInvoiceResponse>, nip47::NIP47Error> {
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
let (query_invoices, query_payments) = match params.transaction_type {
Some(t) => match t {
nip47::TransactionType::Incoming => (true, false),
nip47::TransactionType::Outgoing => (false, true),
},
None => (true, true),
};
let from = params.from.map(|f| f.as_u64());
let until = params.until.map(|f| f.as_u64());
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;
for list_invoice in list_invoices.into_iter() {
if list_invoice.status == ListinvoicesInvoicesStatus::EXPIRED {
continue;
}
if !unpaid && list_invoice.status == ListinvoicesInvoicesStatus::UNPAID {
continue;
}
let invstring = if list_invoice.bolt11.is_some() {
list_invoice.bolt11.unwrap()
} else {
list_invoice.bolt12.unwrap()
};
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if !invoice_decoded.valid {
continue;
}
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())
}
_ => continue,
};
if let Some(f) = from {
if created_at.as_u64() < f {
continue;
}
}
if let Some(u) = until {
if created_at.as_u64() > u {
continue;
}
}
let description = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.offer_description
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description
}
_ => continue,
};
let description_hash = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description_hash.map(|h| h.to_string())
}
_ => continue,
};
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
}
}
_ => continue,
};
let expires_at = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.invoice_relative_expiry.map(|e_at| {
Timestamp::from_secs(
invoice_decoded.invoice_created_at.unwrap() + (e_at as u64),
)
})
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded
.expiry
.map(|e_at| Timestamp::from_secs(invoice_decoded.created_at.unwrap() + e_at)),
_ => continue,
};
let preimage = list_invoice
.payment_preimage
.map(|p| hex::encode(p.to_vec()));
transactions.push(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: Some(invstring),
description,
description_hash,
preimage,
payment_hash: list_invoice.payment_hash.to_string(),
amount,
fees_paid: 0,
created_at,
expires_at,
settled_at: list_invoice.paid_at.map(Timestamp::from_secs),
metadata: None,
});
}
}
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.into_iter() {
if list_pay.status != ListpaysPaysStatus::COMPLETE {
continue;
}
let invstring = if list_pay.bolt11.is_some() {
list_pay.bolt11.unwrap()
} else {
list_pay.bolt12.unwrap()
};
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await
.map_err(|e| nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
})?;
if !invoice_decoded.valid {
continue;
}
let description = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.offer_description
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description
}
_ => continue,
};
let description_hash = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description_hash.map(|h| h.to_string())
}
_ => continue,
};
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 if let Some(amt) = list_pay.amount_msat {
amt.msat()
} else {
continue;
}
}
_ => continue,
};
let fees_paid = list_pay.amount_sent_msat.unwrap().msat() - amount;
let preimage = list_pay.preimage.map(|p| hex::encode(p.to_vec()));
transactions.push(nip47::LookupInvoiceResponse {
transaction_type: Some(nip47::TransactionType::Outgoing),
invoice: Some(invstring),
description,
description_hash,
preimage,
payment_hash: list_pay.payment_hash.to_string(),
amount,
fees_paid,
created_at: Timestamp::from_secs(list_pay.created_at),
expires_at: None,
settled_at: list_pay.completed_at.map(Timestamp::from_secs),
metadata: None,
});
}
}
transactions.sort_by_key(|t| Reverse(t.created_at));
if let Some(l) = params.limit {
if transactions.len() > (l as usize) {
transactions = transactions.drain(0..(l as usize)).collect()
}
}
Ok(transactions)
}

353
src/nwc_notifications.rs Normal file
View file

@ -0,0 +1,353 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::model::requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest};
use cln_rpc::model::responses::ListpaysPaysStatus;
use cln_rpc::primitives::Sha256;
use cln_rpc::ClnRpc;
use crate::structs::PluginState;
use nostr_sdk::nips::*;
use nostr_sdk::*;
pub async fn payment_received_handler(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<(), anyhow::Error> {
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 mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let invoice_resp = rpc
.call_typed(&ListinvoicesRequest {
index: None,
invstring: None,
label: Some(label.to_owned()),
limit: None,
offer_id: None,
payment_hash: None,
start: None,
})
.await?
.invoices;
let invoice = invoice_resp
.first()
.ok_or_else(|| anyhow!("invoice not found"))?;
let invstring = if invoice.bolt11.is_some() {
invoice.bolt11.as_ref().unwrap()
} else {
invoice.bolt12.as_ref().unwrap()
};
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await?;
let not_invoice_err = Err(anyhow!("Not an invoice or invalid invoice".to_owned()));
if !invoice_decoded.valid {
return not_invoice_err;
}
let description = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description,
_ => return not_invoice_err,
};
let description_hash = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description_hash.map(|h| h.to_string())
}
_ => return not_invoice_err,
};
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 if let Some(a) = invoice.amount_msat {
a.msat()
} else {
// amount: `any` but have to put a value...
0
}
}
_ => return not_invoice_err,
};
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 not_invoice_err,
};
let preimage = hex::encode(
invoice
.payment_preimage
.ok_or_else(|| anyhow!("missing preimage from paid invoice"))?
.to_vec(),
);
let settled_at = Timestamp::from_secs(
invoice
.paid_at
.ok_or_else(|| anyhow!("paid invoice missing paid_at time"))?,
);
let clients = plugin.state().handles.lock().await;
for (client, client_pubkey) in clients.values() {
let signer = client.signer().await?;
let content = nip47::Notification {
notification_type: nip47::NotificationType::PaymentReceived,
notification: nip47::NotificationResult::PaymentReceived(nip47::PaymentNotification {
transaction_type: Some(nip47::TransactionType::Incoming),
invoice: invstring.clone(),
description: description.clone(),
description_hash: description_hash.clone(),
preimage: preimage.clone(),
payment_hash: invoice.payment_hash.to_string(),
amount,
fees_paid: 0,
created_at,
expires_at: None,
settled_at,
metadata: None,
}),
};
let notification = serde_json::to_string(&content)?;
log::debug!("NOTIFICATION: {}", notification);
let content_encrypted_nip04 = signer.nip04_encrypt(client_pubkey, &notification).await?;
let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04)
.tag(Tag::public_key(*client_pubkey))
.sign(&signer)
.await?;
let nip04_result = client.send_event(&event_nip04).await?;
if nip04_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nip04 notification: {}",
nip04_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
)
}
log::debug!("NIP04 NOTIFICATION SENT: {:?}", event_nip04);
let content_encrypted_nip44 = signer.nip44_encrypt(client_pubkey, &notification).await?;
let event_nip44 = EventBuilder::new(Kind::from_u16(23197), content_encrypted_nip44)
.tag(Tag::public_key(*client_pubkey))
.sign(&signer)
.await?;
let nip44_result = client.send_event(&event_nip44).await?;
if nip44_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nip44 notification: {}",
nip44_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
)
}
log::debug!("NIP44 NOTIFICATION SENT: {:?}", event_nip44);
}
Ok(())
}
pub async fn payment_sent_handler(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<(), anyhow::Error> {
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 mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let pays_resp = rpc
.call_typed(&ListpaysRequest {
bolt11: None,
index: None,
limit: None,
payment_hash: Some(Sha256::from_str(&payment_hash)?),
start: None,
status: None,
})
.await?
.pays;
let pay = pays_resp
.first()
.ok_or_else(|| anyhow!("payment not found"))?;
if pay.status != ListpaysPaysStatus::COMPLETE {
return Err(anyhow!("Payment not complete"));
}
let invstring = if let Some(b11) = &pay.bolt11 {
b11
} else if let Some(b12) = &pay.bolt12 {
b12
} else {
&String::new()
};
let description;
let description_hash;
let amount;
let created_at = Timestamp::from_secs(pay.created_at);
let preimage = hex::encode(
pay.preimage
.ok_or_else(|| anyhow!("missing preimage from paid invoice"))?
.to_vec(),
);
let settled_at = Timestamp::from_secs(pay.completed_at.unwrap());
if !invstring.is_empty() {
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: invstring.clone(),
})
.await?;
let not_invoice_err = Err(anyhow!("Not an invoice".to_owned()));
if !invoice_decoded.valid {
return not_invoice_err;
}
description = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.offer_description
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description,
_ => return not_invoice_err,
};
description_hash = match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None,
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.description_hash.map(|h| h.to_string())
}
_ => return not_invoice_err,
};
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 if let Some(a) = pay.amount_msat {
a.msat()
} else {
// amount: `any` but have to put a value...
0
}
}
_ => return not_invoice_err,
};
} else {
description = pay.description.clone();
description_hash = None;
amount = if let Some(amt) = pay.amount_msat {
amt.msat()
} else {
// Amount missing but required
0
}
}
let fees_paid = pay.amount_sent_msat.unwrap().msat() - amount;
let clients = plugin.state().handles.lock().await;
for (client, client_pubkey) in clients.values() {
let signer = client.signer().await?;
let content = nip47::Notification {
notification_type: nip47::NotificationType::PaymentSent,
notification: nip47::NotificationResult::PaymentSent(nip47::PaymentNotification {
transaction_type: Some(nip47::TransactionType::Outgoing),
invoice: invstring.clone(),
description: description.clone(),
description_hash: description_hash.clone(),
preimage: preimage.clone(),
payment_hash: pay.payment_hash.to_string(),
amount,
fees_paid,
created_at,
expires_at: None,
settled_at,
metadata: None,
}),
};
let notification = serde_json::to_string(&content)?;
log::debug!("NOTIFICATION: {}", notification);
let content_encrypted_nip04 = signer.nip04_encrypt(client_pubkey, &notification).await?;
let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04)
.tag(Tag::public_key(*client_pubkey))
.sign(&signer)
.await?;
let nip04_result = client.send_event(&event_nip04).await?;
if nip04_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nip04 notification: {}",
nip04_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
)
}
log::debug!("NIP04 NOTIFICATION SENT: {:?}", event_nip04);
let content_encrypted_nip44 = signer.nip44_encrypt(client_pubkey, &notification).await?;
let event_nip44 = EventBuilder::new(Kind::from_u16(23197), content_encrypted_nip44)
.tag(Tag::public_key(*client_pubkey))
.sign(&signer)
.await?;
let nip44_result = client.send_event(&event_nip44).await?;
if nip44_result.success.is_empty() {
log::warn!(
"None of the relays accepted our nip44 notification: {}",
nip44_result
.failed
.into_values()
.collect::<Vec<String>>()
.join(", ")
)
}
log::debug!("NIP44 NOTIFICATION SENT: {:?}", event_nip44);
}
Ok(())
}

222
src/nwc_pay.rs Normal file
View file

@ -0,0 +1,222 @@
use std::{path::Path, time::Duration};
use cln_plugin::Plugin;
use cln_rpc::{
model::requests::{DecodeRequest, PayRequest},
primitives::Amount,
ClnRpc,
};
use nostr_sdk::nips::*;
use tokio::time;
use crate::{
structs::PluginState,
util::{budget_amount_check, load_nwc_store, update_nwc_store},
};
pub async fn pay_invoice(
plugin: Plugin<PluginState>,
params: nip47::PayInvoiceRequest,
label: &String,
) -> Result<(nip47::PayInvoiceResponse, String), (nip47::NIP47Error, String)> {
let _guard = plugin.state().rpc_lock.lock().await;
let id = params.id.clone().unwrap_or_default();
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await
.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
id.clone(),
)
})?;
let invoice_decoded = rpc
.call_typed(&DecodeRequest {
string: params.invoice.clone(),
})
.await
.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
id.clone(),
)
})?;
let not_invoice_error = Err((
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: "Not an invoice or invalid invoice".to_owned(),
},
id.clone(),
));
if !invoice_decoded.valid {
return not_invoice_error;
}
let id = if let Some(i) = params.id {
i
} else {
match invoice_decoded.item_type {
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
invoice_decoded.invoice_payment_hash.unwrap().to_string()
}
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
invoice_decoded.payment_hash.unwrap().to_string()
}
_ => return not_invoice_error,
}
};
let invoice_amt_msat = 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 => {
invoice_decoded.amount_msat.unwrap().msat()
}
_ => return not_invoice_error,
};
let mut nwc_store = load_nwc_store(&mut rpc, label).await.map_err(|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
id.clone(),
)
})?;
budget_amount_check(params.amount, Some(invoice_amt_msat), nwc_store.budget_msat).map_err(
|e| {
(
nip47::NIP47Error {
code: nip47::ErrorCode::QuotaExceeded,
message: e.to_string(),
},
id.clone(),
)
},
)?;
match rpc
.call_typed(&PayRequest {
amount_msat: params.amount.map(Amount::from_msat),
description: None,
exemptfee: None,
label: None,
localinvreqid: None,
maxdelay: None,
maxfee: None,
maxfeepercent: None,
partial_msat: None,
retry_for: None,
riskfactor: None,
exclude: None,
bolt11: params.invoice,
})
.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(),
},
id.clone(),
)
})?;
}
let preimage = hex::encode(o.payment_preimage.to_vec());
Ok((nip47::PayInvoiceResponse { preimage }, id))
}
Err(e) => match e.code {
Some(c) => match c {
201 | 207 | 219 => Err((
nip47::NIP47Error {
code: nip47::ErrorCode::Other,
message: e.to_string(),
},
id,
)),
203 | 205 | 209 | 210 => Err((
nip47::NIP47Error {
code: nip47::ErrorCode::PaymentFailed,
message: e.to_string(),
},
id,
)),
206 => Err((
nip47::NIP47Error {
code: nip47::ErrorCode::InsufficientBalance,
message: e.to_string(),
},
id,
)),
_ => Err((
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
id,
)),
},
None => Err((
nip47::NIP47Error {
code: nip47::ErrorCode::Internal,
message: e.to_string(),
},
id,
)),
},
}
}
pub async fn multi_pay_invoice(
plugin: Plugin<PluginState>,
params: nip47::MultiPayInvoiceRequest,
label: &String,
) -> Vec<(nip47::Response, 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
}

56
src/parse.rs Normal file
View file

@ -0,0 +1,56 @@
use anyhow::anyhow;
use cln_plugin::ConfiguredPlugin;
use crate::{
structs::{PluginState, TimeUnit},
OPT_RELAYS,
};
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() {
relays
} else {
return Err(anyhow!(
"Empty `{}` option, must specify atleast one relay url!",
OPT_RELAYS.name()
));
}
} else {
return Err(anyhow!(
"`{}` not set, must specify atleast one relay url!",
OPT_RELAYS.name()
));
};
let mut config = state.config.lock();
for relay in relays_str.into_iter() {
log::debug!("RELAY:{}", relay);
config.relays.push(nostr_sdk::RelayUrl::parse(&relay)?);
}
Ok(())
}
pub fn parse_time_period(input: &str) -> Result<u64, anyhow::Error> {
let re = regex::Regex::new(r"(\d+)\s*([a-zA-Z]+)")?;
if let Some(caps) = re.captures(input) {
let value: u64 = caps[1].parse()?;
let unit = &caps[2].to_lowercase();
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),
}
} else {
Err(anyhow!(format!("Unsupported time unit: {}", unit)))
}
} else {
Err(anyhow!("Invalid time format: {}", input))
}
}

335
src/rpc.rs Normal file
View file

@ -0,0 +1,335 @@
use std::path::Path;
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::model::requests::{
DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest,
};
use cln_rpc::ClnRpc;
use nostr_sdk::nips::nip47::*;
use nostr_sdk::*;
use serde_json::json;
use tokio::sync::oneshot;
use crate::nwc::run_nwc;
use crate::parse::parse_time_period;
use crate::structs::{BudgetIntervalConfig, NwcStore, PluginState};
use crate::tasks::budget_task;
use crate::util::{load_nwc_store, update_nwc_store};
use crate::PLUGIN_NAME;
pub async fn nwc_create(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let (label, budget_msat, interval_secs) = parse_full_args(args)?;
let config = plugin.state().config.lock().clone();
let wallet_keys = Keys::generate();
let client_keys = Keys::generate();
let uri = NostrWalletConnectURI::new(
wallet_keys.public_key(),
config.relays.clone(),
client_keys.secret_key().clone(),
None,
);
let mut result = serde_json::Map::new();
result.insert("uri".to_owned(), serde_json::Value::String(uri.to_string()));
result.insert("label".to_owned(), serde_json::Value::String(label.clone()));
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let interval_config = if let Some(bgt_msat) = budget_msat {
result.insert(
"budget_msat".to_owned(),
serde_json::Value::Number(bgt_msat.into()),
);
if let Some(interval) = interval_secs {
let conf = BudgetIntervalConfig {
interval_secs: interval,
reset_budget_msat: bgt_msat,
last_reset: Timestamp::now().as_u64(),
};
result.insert("interval_config".to_owned(), serde_json::to_value(&conf)?);
Some(conf)
} else {
None
}
} else {
None
};
let nwc_store = NwcStore {
uri: uri.clone(),
walletkey: wallet_keys.secret_key().to_secret_hex(),
budget_msat,
interval_config,
};
rpc.call_typed(&DatastoreRequest {
generation: None,
hex: None,
mode: Some(DatastoreMode::MUST_CREATE),
string: Some(serde_json::to_string(&nwc_store)?),
key: vec![PLUGIN_NAME.to_owned(), label.clone()],
})
.await?;
let client = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
let mut locked_handles = plugin.state().handles.lock().await;
locked_handles.insert(
label.clone(),
(client, Keys::new(nwc_store.uri.secret).public_key()),
);
Ok(serde_json::Value::Object(result))
}
pub async fn nwc_revoke(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let label = parse_revoke_args(args)?;
{
let mut locked_handles = plugin.state().handles.lock().await;
if let Some((client, _client_pubkey)) = locked_handles.remove(&label) {
client.shutdown().await;
}
let mut budget_jobs = plugin.state().budget_jobs.lock();
let job = budget_jobs.remove(&label);
if let Some(j) = job {
let _ = j.send(());
}
}
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
rpc.call_typed(&DeldatastoreRequest {
generation: None,
key: vec![PLUGIN_NAME.to_owned(), label.clone()],
})
.await?;
Ok(json!({"revoked":label}))
}
pub async fn nwc_budget(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let (label, budget_msat, interval_secs) = parse_full_args(args)?;
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
{
let mut budget_jobs = plugin.state().budget_jobs.lock();
let job = budget_jobs.remove(&label);
if let Some(j) = job {
let _ = j.send(());
}
}
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
if let Some(budget) = budget_msat {
nwc_store.budget_msat = Some(budget);
if let Some(interval) = interval_secs {
let interval_config = BudgetIntervalConfig {
interval_secs: interval,
reset_budget_msat: budget,
last_reset: Timestamp::now().as_u64(),
};
nwc_store.interval_config = Some(interval_config.clone());
} else {
nwc_store.interval_config = None;
}
} else {
nwc_store.budget_msat = None;
nwc_store.interval_config = None;
}
if nwc_store.interval_config.is_some() {
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
plugin.state().budget_jobs.lock().insert(label.clone(), tx);
}
update_nwc_store(&mut rpc, &label, nwc_store).await?;
Ok(json!({"budget_updated":label}))
}
pub async fn nwc_list(
plugin: Plugin<PluginState>,
args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let _guard = plugin.state().rpc_lock.lock().await;
let label = parse_list_args(args)?;
let mut rpc = ClnRpc::new(
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
)
.await?;
let mut nwcs = Vec::new();
if let Some(lbl) = label {
let nwc_store = load_nwc_store(&mut rpc, &lbl).await?;
nwcs.push(serde_json::to_value(nwc_store)?);
} else {
let nwcs_store = rpc
.call_typed(&ListdatastoreRequest {
key: Some(vec![PLUGIN_NAME.to_owned()]),
})
.await?
.datastore;
for datastore in nwcs_store.into_iter() {
let label = datastore.key.last().unwrap().to_owned();
let nwc_store = load_nwc_store(&mut rpc, &label).await?;
nwcs.push(serde_json::to_value(nwc_store)?);
}
}
Ok(serde_json::Value::Array(nwcs))
}
fn parse_full_args(
args: serde_json::Value,
) -> Result<(String, Option<u64>, Option<u64>), anyhow::Error> {
match args {
serde_json::Value::String(s) => Ok((s, None, None)),
serde_json::Value::Array(values) => {
let label = values
.first()
.ok_or_else(|| anyhow!("label missing"))?
.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned();
let budget_msat = if let Some(b) = values.get(1) {
Some(
b.as_u64()
.ok_or_else(|| anyhow!("budget_msat is not an integer"))?,
)
} else {
None
};
let interval_secs = if let Some(t) = values.get(2) {
Some(parse_time_period(
t.as_str()
.ok_or_else(|| anyhow!("interval is not a string"))?,
)?)
} else {
None
};
if interval_secs.is_some() && budget_msat.is_none() {
return Err(anyhow!("Must set `budget_msat` if you use `interval`"));
}
Ok((label, budget_msat, interval_secs))
}
serde_json::Value::Object(map) => {
let label = map
.get("label")
.ok_or_else(|| anyhow!("label missing"))?
.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned();
let budget_msat = if let Some(b) = map.get("budget_msat") {
Some(
b.as_u64()
.ok_or_else(|| anyhow!("budget_msat is not an integer"))?,
)
} else {
None
};
let interval_secs = if let Some(t) = map.get("interval") {
Some(parse_time_period(
t.as_str()
.ok_or_else(|| anyhow!("interval is not a string"))?,
)?)
} else {
None
};
if interval_secs.is_some() && budget_msat.is_none() {
return Err(anyhow!("Must set `budget_msat` if you use `interval`"));
}
Ok((label, budget_msat, interval_secs))
}
_ => Err(anyhow!("Invalid argument type")),
}
}
fn parse_revoke_args(args: serde_json::Value) -> Result<String, anyhow::Error> {
match args {
serde_json::Value::String(s) => Ok(s),
serde_json::Value::Array(values) => {
let label = values
.first()
.ok_or_else(|| anyhow!("label missing"))?
.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned();
Ok(label)
}
serde_json::Value::Object(map) => {
let label = map
.get("label")
.ok_or_else(|| anyhow!("label missing"))?
.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned();
Ok(label)
}
_ => Err(anyhow!("Invalid argument type")),
}
}
fn parse_list_args(args: serde_json::Value) -> Result<Option<String>, anyhow::Error> {
match args {
serde_json::Value::String(s) => Ok(Some(s)),
serde_json::Value::Array(values) => {
let label = if let Some(v) = values.first() {
Some(
v.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned(),
)
} else {
None
};
Ok(label)
}
serde_json::Value::Object(map) => {
let label = if let Some(v) = map.get("label") {
Some(
v.as_str()
.ok_or_else(|| anyhow!("label is not a string"))?
.to_owned(),
)
} else {
None
};
Ok(label)
}
_ => Err(anyhow!("Invalid argument type")),
}
}

76
src/structs.rs Normal file
View file

@ -0,0 +1,76 @@
use std::{collections::HashMap, str::FromStr, sync::Arc};
use nostr_sdk::client;
use nostr_sdk::nips::nip47;
use nostr_sdk::nostr;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
#[derive(Clone)]
pub struct PluginState {
pub config: Arc<Mutex<Config>>,
pub handles: Arc<tokio::sync::Mutex<HashMap<String, (client::Client, nostr::PublicKey)>>>,
pub rpc_lock: Arc<tokio::sync::Mutex<()>>,
pub budget_jobs: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
}
impl PluginState {
pub fn default() -> PluginState {
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(())),
budget_jobs: Arc::new(Mutex::new(HashMap::new())),
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub relays: Vec<nostr_sdk::RelayUrl>,
}
impl Config {
pub fn default() -> Config {
Config { relays: Vec::new() }
}
}
#[derive(Debug)]
pub enum TimeUnit {
Second,
Minute,
Hour,
Day,
Week,
}
impl FromStr for TimeUnit {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"second" | "seconds" | "sec" | "secs" | "s" => Ok(TimeUnit::Second),
"minute" | "minutes" | "min" | "mins" | "m" => Ok(TimeUnit::Minute),
"hour" | "hours" | "h" => Ok(TimeUnit::Hour),
"day" | "days" | "d" => Ok(TimeUnit::Day),
"week" | "weeks" | "w" => Ok(TimeUnit::Week),
_ => Err(format!("Unsupported time unit: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetIntervalConfig {
pub interval_secs: u64,
pub reset_budget_msat: u64,
pub last_reset: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NwcStore {
pub uri: nip47::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>,
}

59
src/tasks.rs Normal file
View file

@ -0,0 +1,59 @@
use std::{path::Path, time::Duration};
use anyhow::anyhow;
use cln_plugin::Plugin;
use cln_rpc::ClnRpc;
use nostr_sdk::Timestamp;
use tokio::{sync::oneshot, time};
use crate::{
structs::PluginState,
util::{load_nwc_store, update_nwc_store},
};
pub async fn budget_task(
mut rx: oneshot::Receiver<()>,
plugin: Plugin<PluginState>,
label: String,
) -> 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_u64();
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_u64();
update_nwc_store(&mut rpc, &label, nwc_store).await?;
log::info!("Done refreshing budget for {}",label);
}
}
}
Ok(())
}

98
src/util.rs Normal file
View file

@ -0,0 +1,98 @@
use anyhow::anyhow;
use cln_rpc::{
model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest},
ClnRpc,
};
use crate::{structs::NwcStore, PLUGIN_NAME};
pub fn budget_amount_check(
request_amt_msat: Option<u64>,
invoice_amt_msat: Option<u64>,
budget_msat: Option<u64>,
) -> Result<(), anyhow::Error> {
log::debug!(
"checking budget and amounts for request:{:?} invoice:{:?} budget:{:?}",
request_amt_msat,
invoice_amt_msat,
budget_msat
);
if request_amt_msat.is_none() && invoice_amt_msat.is_none() {
return Err(anyhow!("No amount given to check budget against!"));
}
if let Some(req_amt) = request_amt_msat {
if let Some(inv_amt) = invoice_amt_msat {
if req_amt != inv_amt {
return Err(anyhow!("Amount from request and invoice differ!"));
}
}
}
if let Some(bdgt_msat) = budget_msat {
if let Some(req_amt) = request_amt_msat {
if bdgt_msat < req_amt {
return Err(anyhow!("Payment exceeds budget!"));
}
}
if let Some(inv_amt) = invoice_amt_msat {
if bdgt_msat < inv_amt {
return Err(anyhow!("Payment exceeds budget!"));
}
}
}
Ok(())
}
pub async fn load_nwc_store(rpc: &mut ClnRpc, label: &String) -> Result<NwcStore, anyhow::Error> {
let nwc_store_store = rpc
.call_typed(&ListdatastoreRequest {
key: Some(vec![PLUGIN_NAME.to_owned(), label.clone()]),
})
.await?
.datastore;
let nwc_store_str = nwc_store_store
.first()
.ok_or_else(|| anyhow!("No datastore found for: {}", label))?
.string
.as_ref()
.ok_or_else(|| anyhow!("Malformed nwc_store datastore: missing string"))?;
let nwc_store: NwcStore = serde_json::from_str(nwc_store_str)?;
log::debug!("loaded nwc store for label:{}", label);
Ok(nwc_store)
}
pub async fn update_nwc_store(
rpc: &mut ClnRpc,
label: &String,
nwc_store: NwcStore,
) -> Result<(), anyhow::Error> {
rpc.call_typed(&DatastoreRequest {
key: vec![PLUGIN_NAME.to_owned(), label.clone()],
generation: None,
hex: None,
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
string: Some(serde_json::to_string(&nwc_store)?),
})
.await?;
log::debug!("stored nwc store for label:{}", label);
Ok(())
}
#[test]
fn test_budget_check() {
assert!(budget_amount_check(Some(1), Some(1), Some(2)).is_ok());
assert!(budget_amount_check(Some(1), Some(2), Some(2)).is_err());
assert!(budget_amount_check(Some(2), Some(2), Some(1)).is_err());
assert!(budget_amount_check(Some(2), None, None).is_ok());
assert!(budget_amount_check(Some(2), None, Some(2)).is_ok());
assert!(budget_amount_check(None, None, None).is_err());
assert!(budget_amount_check(None, None, Some(2)).is_err());
assert!(budget_amount_check(Some(0), None, Some(1)).is_ok());
assert!(budget_amount_check(Some(0), None, Some(0)).is_ok());
assert!(budget_amount_check(None, Some(0), Some(1)).is_ok());
assert!(budget_amount_check(None, Some(0), Some(0)).is_ok());
assert!(budget_amount_check(Some(0), Some(0), Some(1)).is_ok());
assert!(budget_amount_check(Some(0), Some(0), Some(0)).is_ok());
}

4
tests/requirements.txt Normal file
View file

@ -0,0 +1,4 @@
pytest-asyncio<0.24
nostr-sdk
PyYAML
nostr_relay

100
tests/setup.sh Executable file
View file

@ -0,0 +1,100 @@
#!/bin/bash
set -x
# Get the directory of the script
script_dir=$(dirname -- "$(readlink -f -- "$0")")
cargo_toml_path="$script_dir/../Cargo.toml"
# Use grep and awk to extract the name and version
name=$(awk -F'=' '/^\[package\]/ { in_package = 1 } in_package && /name/ { gsub(/[" ]/, "", $2); print $2; exit }' "$cargo_toml_path")
version=$(awk -F'=' '/^\[package\]/ { in_package = 1 } in_package && /version/ { gsub(/[" ]/, "", $2); print $2; exit }' "$cargo_toml_path")
get_platform_file_end() {
machine=$(uname -m)
kernel=$(uname -s)
case $kernel in
Darwin)
echo 'universal-apple-darwin.zip'
;;
Linux)
case $machine in
x86_64)
echo 'x86_64-linux-gnu.tar.gz'
;;
armv7l)
echo 'armv7-linux-gnueabihf.tar.gz'
;;
aarch64)
echo 'aarch64-linux-gnu.tar.gz'
;;
*)
echo "No self-compiled binary found and unsupported release-architecture: $machine" >&2
exit 1
;;
esac
;;
*)
echo "No self-compiled binary found and unsupported OS: $kernel" >&2
exit 1
;;
esac
}
platform_file_end=$(get_platform_file_end)
archive_file=$name-v$version-$platform_file_end
github_url="https://github.com/daywalker90/$name/releases/download/v$version/$archive_file"
# Download the archive using curl
if ! curl -L "$github_url" -o "$script_dir/$archive_file"; then
echo "Error downloading the file from $github_url" >&2
# exit 1
fi
# Extract the contents
if [[ $archive_file == *.tar.gz ]]; then
if ! tar -xzvf "$script_dir/$archive_file" -C "$script_dir"; then
echo "Error extracting the contents of $archive_file" >&2
# exit 1
fi
elif [[ $archive_file == *.zip ]]; then
if ! unzip "$script_dir/$archive_file" -d "$script_dir"; then
echo "Error extracting the contents of $archive_file" >&2
# exit 1
fi
else
echo "Unknown archive format or unsupported file extension: $archive_file" >&2
# exit 1
fi
# Function to check if a Python package is installed
check_package() {
python_exec="$1"
package_name="$2"
if $python_exec -c "import $package_name" &> /dev/null; then
return 0
else
return 1
fi
}
proto_path="$script_dir/../proto"
if [ -d "$proto_path" ]; then
# Check if the package is installed in the first Python executable
if check_package "$TEST_DIR/bin/python3" "grpc"; then
python_exec="$TEST_DIR/bin/python3"
elif check_package "python3" "grpc"; then
python_exec="python3"
else
echo "Error: Package 'grpcio' is not installed" >&2
exit 1
fi
# Generate grpc files
if ! "$python_exec" -m grpc_tools.protoc --proto_path="$proto_path" --python_out=$script_dir --grpc_python_out=$script_dir $proto_path/*.proto; then
echo "Error generating grpc files" >&2
exit 1
fi
fi

1090
tests/test_clnnwc.py Normal file

File diff suppressed because it is too large Load diff

63
tests/util.py Normal file
View file

@ -0,0 +1,63 @@
import logging
import os
import random
import string
from pathlib import Path
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"
@pytest.fixture
def get_plugin(directory):
if COMPILED_PATH.is_file():
return COMPILED_PATH
elif DOWNLOAD_PATH.is_file():
return DOWNLOAD_PATH
else:
raise ValueError("No files were found.")
def generate_random_label():
label_length = 8
random_label = "".join(
random.choice(string.ascii_letters) for _ in range(label_length)
)
return random_label
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()
for i, line in enumerate(lines):
if line.startswith(option_name):
lines[i] = option_name + "=" + option_value + "\n"
with open(lightning_dir + "/config", "w") as file:
file.writelines(lines)
def experimental_anchors_check(node_factory):
l1 = node_factory.get_node()
version = l1.rpc.getinfo()["version"]
if version.startswith("v23"):
return True
else:
return False

72
tools/tag-release.sh Executable file
View file

@ -0,0 +1,72 @@
#!/bin/bash
# Function to check if a string matches semantic versioning pattern
is_semver() {
if [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
return 0
else
return 1
fi
}
# Function to check if a file contains a specific version
file_contains_version() {
local version="$1"
local file="$2"
if grep -q "$version" "$file"; then
return 0
else
return 1
fi
}
# Function to check if there are pending changes in Git
has_pending_changes() {
if [ -n "$(git status --porcelain)" ]; then
return 0
else
return 1
fi
}
# Main script
if [ $# -ne 1 ]; then
echo "Usage: $0 <version>"
exit 1
fi
version="$1"
if ! is_semver "$version"; then
echo "Invalid semantic version: $version"
exit 1
fi
if ! file_contains_version "$version" "CHANGELOG.md"; then
echo "Version $version not found in CHANGELOG.md"
exit 1
fi
# Extract version from Cargo.toml [package] section
cargo_version=$(awk -F '"' '/^\[package\]/ {p=1} p && /version/ {print $2; exit}' Cargo.toml)
coffee_version=$(grep '^[[:space:]]*version:' coffee.yml | awk '{print $2}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ "$cargo_version" != "$version" ]; then
echo "Version $version does not match the version in Cargo.toml"
exit 1
fi
if [ "$coffee_version" != "$version" ]; then
echo "Version $version does not match the version in coffee.yml"
exit 1
fi
# Check for pending changes
if has_pending_changes; then
echo "There are pending changes in the repository. Please commit or stash them before tagging."
exit 1
fi
# If the version exists in both files, tag the current commit
git tag -a "v$version" -m "Version $version"