mirror of
https://github.com/daywalker90/cln-nip47.git
synced 2026-08-13 12:33:43 +02:00
init
This commit is contained in:
commit
2529bb5863
35 changed files with 6788 additions and 0 deletions
188
.github/workflows/ci.yml
vendored
Normal file
188
.github/workflows/ci.yml
vendored
Normal 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
14
.github/workflows/latest_v24.08.yml
vendored
Normal 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
14
.github/workflows/latest_v24.11.yml
vendored
Normal 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
14
.github/workflows/latest_v25.02.yml
vendored
Normal 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
24
.github/workflows/main_v24.08.yml
vendored
Normal 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
24
.github/workflows/main_v24.11.yml
vendored
Normal 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
24
.github/workflows/main_v25.02.yml
vendored
Normal 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
104
.github/workflows/release.yml
vendored
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/target/
|
||||
/tests/__pycache__/
|
||||
/venv/
|
||||
/result/
|
||||
2118
Cargo.lock
generated
Normal file
2118
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal 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
21
LICENSE
Normal 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
90
README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
[](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml) [](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml) [](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml)
|
||||
|
||||
[](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml) [](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml) [](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
9
coffee.yml
Normal 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
82
flake.lock
generated
Normal 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
68
flake.nix
Normal 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
132
src/main.rs
Normal 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
443
src/nwc.rs
Normal 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
53
src/nwc_balance.rs
Normal 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
63
src/nwc_info.rs
Normal 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
74
src/nwc_invoice.rs
Normal 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
157
src/nwc_keysend.rs
Normal 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(¶ms.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
504
src/nwc_lookups.rs
Normal 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
353
src/nwc_notifications.rs
Normal 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, ¬ification).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, ¬ification).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, ¬ification).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, ¬ification).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
222
src/nwc_pay.rs
Normal 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
56
src/parse.rs
Normal 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
335
src/rpc.rs
Normal 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
76
src/structs.rs
Normal 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
59
src/tasks.rs
Normal 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
98
src/util.rs
Normal 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
4
tests/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pytest-asyncio<0.24
|
||||
nostr-sdk
|
||||
PyYAML
|
||||
nostr_relay
|
||||
100
tests/setup.sh
Executable file
100
tests/setup.sh
Executable 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
1090
tests/test_clnnwc.py
Normal file
File diff suppressed because it is too large
Load diff
63
tests/util.py
Normal file
63
tests/util.py
Normal 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
72
tools/tag-release.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue