mirror of
https://github.com/daywalker90/cln-nip47.git
synced 2026-08-13 12:33:43 +02:00
Compare commits
133 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27768bb95d | ||
|
|
7c100cf5d2 | ||
|
|
7241ae39d3 | ||
|
|
cc7d075bf4 | ||
|
|
e98d4a1a54 | ||
|
|
7d132a60e4 | ||
|
|
d93a03014b | ||
|
|
1d5bd7c821 | ||
|
|
a26eb7a539 | ||
|
|
101234e8ef | ||
|
|
ae4e00967d | ||
|
|
ffd9bd21e9 | ||
|
|
102de7345d | ||
|
|
2fcc48ce34 | ||
|
|
2044151b4a | ||
|
|
e175198c45 | ||
|
|
b31f42691f | ||
|
|
29c1ace33c | ||
|
|
c1bb7606cf | ||
|
|
f79db424cd | ||
|
|
b2078bab81 | ||
|
|
651b11b229 | ||
|
|
408b015d91 | ||
|
|
6ff3069fa8 | ||
|
|
76f10bc2c5 | ||
|
|
66edfb7c5e | ||
|
|
0a078e0b76 | ||
|
|
07bf8288f5 | ||
|
|
18c00d3442 | ||
|
|
5c65a94c13 | ||
|
|
f5b64586fc | ||
|
|
edd95056ab | ||
|
|
6d984a0900 | ||
|
|
61c07f6079 | ||
|
|
f6517cab7e | ||
|
|
f85b2efcb3 | ||
|
|
fb972c5ed1 | ||
|
|
af306edd3a | ||
|
|
b16db82693 | ||
|
|
6690c9d49d | ||
|
|
78ac7cac05 | ||
|
|
5c5f49de29 | ||
|
|
c956338bcd | ||
|
|
140d295eec | ||
|
|
004e403d48 | ||
|
|
a2781bee20 | ||
|
|
0c2a2a15fb | ||
|
|
07159082d8 | ||
|
|
7ec1e68d6c | ||
|
|
ce0e1fe585 | ||
|
|
aff911ed90 | ||
|
|
f26b82bb51 | ||
|
|
80926ba9b7 | ||
|
|
3caf173a86 | ||
|
|
f13c30452e | ||
|
|
c4cbcbe4d2 | ||
|
|
99ba77568d | ||
|
|
4b43fe5b0c | ||
|
|
804c19f945 | ||
|
|
01b48646a6 | ||
|
|
4fdc9e0265 | ||
|
|
9763b91d2e | ||
|
|
919c50060f | ||
|
|
24ccad1ae5 | ||
|
|
cccc9e38db | ||
|
|
a7c209594a | ||
|
|
fee812eda7 | ||
|
|
1da7d6447d | ||
|
|
c7cd5434bc | ||
|
|
be81402c99 | ||
|
|
46d8e05b04 | ||
|
|
b56c1d2869 | ||
|
|
d8f5f105d4 | ||
|
|
e97b7cf88e | ||
|
|
600a577458 | ||
|
|
f084379b1e | ||
|
|
a3a92fc841 | ||
|
|
d7c2cd4911 | ||
|
|
08b5009d54 | ||
|
|
320b68c8c7 | ||
|
|
727f710646 | ||
|
|
7dff9f83e7 | ||
|
|
65ec413753 | ||
|
|
d76ad4e89f | ||
|
|
be201f906c | ||
|
|
3fdef99ba6 | ||
|
|
72664ca045 | ||
|
|
52f4b5b48b | ||
|
|
11107c6745 | ||
|
|
68000fae96 | ||
|
|
3aec0ea517 | ||
|
|
bd5fa21e6f | ||
|
|
c609f22331 | ||
|
|
243be6ab84 | ||
|
|
3331842ce1 | ||
|
|
5a8f45511d | ||
|
|
53a3d37f02 | ||
|
|
4cc965bf0b | ||
|
|
ac2f4b1acd | ||
|
|
a462e625a3 | ||
|
|
061b27653c | ||
|
|
66e6378a72 | ||
|
|
be0ed8a43e | ||
|
|
0b302a125f | ||
|
|
5c8565f94d | ||
|
|
c03ae945d6 | ||
|
|
c6194691c0 | ||
|
|
4f201e938a | ||
|
|
adbaa048dd | ||
|
|
f6d63920e5 | ||
|
|
9edf9ff69d | ||
|
|
6e7363dd19 | ||
|
|
9c6d57e6df | ||
|
|
de0b28ff1e | ||
|
|
080acfde71 | ||
|
|
b4114920ac | ||
|
|
76c8176079 | ||
|
|
23cd95cb10 | ||
|
|
6fc9965c75 | ||
|
|
90d2233a10 | ||
|
|
776b1671e6 | ||
|
|
cf36ec6365 | ||
|
|
7c78f40d16 | ||
|
|
0edf9dadfd | ||
|
|
f3ecc395ec | ||
|
|
dd63430145 | ||
|
|
4ddefe50ec | ||
|
|
3f6bb2f78f | ||
|
|
cb253594e5 | ||
|
|
2d4302e23e | ||
|
|
675e625dcd | ||
|
|
0a9eb7cd82 | ||
|
|
ae8817e7a6 |
53 changed files with 8551 additions and 3809 deletions
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[resolver]
|
||||
incompatible-rust-versions = "fallback"
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
37
.github/dependabot.yml
vendored
Normal file
37
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
timezone: "UTC"
|
||||
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
open-pull-requests-limit: 1
|
||||
|
||||
versioning-strategy: "auto"
|
||||
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "rust"
|
||||
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
143
.github/workflows/ci.yml
vendored
143
.github/workflows/ci.yml
vendored
|
|
@ -17,6 +17,12 @@ on:
|
|||
tagged-release:
|
||||
required: true
|
||||
type: boolean
|
||||
python-version-1:
|
||||
required: true
|
||||
type: string
|
||||
python-version-2:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -24,27 +30,25 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
bitcoind-version: ["29.0"]
|
||||
bitcoind-version: ["31.0"]
|
||||
experimental: [1]
|
||||
deprecated: [0]
|
||||
python-version: ["3.9", "3.12"]
|
||||
os: ["ubuntu-24.04"]
|
||||
python-version: [ "${{ inputs.python-version-1 }}", "${{ inputs.python-version-2 }}"]
|
||||
os: ["ubuntu-latest"]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Create cache paths
|
||||
run: |
|
||||
sudo mkdir /usr/local/libexec
|
||||
sudo mkdir /usr/local/libexec/c-lightning
|
||||
sudo mkdir /usr/local/libexec/c-lightning/plugins
|
||||
sudo mkdir -p /usr/local/libexec/c-lightning/plugins
|
||||
sudo chown -R $USER /usr/local/libexec
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
|
@ -58,120 +62,104 @@ jobs:
|
|||
echo "OS version: $OS_VERSION"
|
||||
echo "os_version=$OS_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache CLN
|
||||
- name: Restore CLN cache
|
||||
id: cache-cln
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache/restore@v6
|
||||
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
|
||||
- name: Restore bitcoind cache
|
||||
id: cache-bitcoind
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: /usr/local/bin/bitcoin*
|
||||
key: cache-bitcoind-${{ matrix.bitcoind-version }}-${{ steps.exact_versions.outputs.os_version }}
|
||||
|
||||
- name: Cache python dependencies
|
||||
id: cache-python
|
||||
uses: actions/cache@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
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') }}
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "**/pyproject.toml"
|
||||
|
||||
- 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
|
||||
export TARGET_ARCH="x86_64-linux-gnu"
|
||||
wget --tries=5 --waitretry=5 --retry-connrefused --timeout=20 --continue https://bitcoincore.org/bin/bitcoin-core-${BITCOIND_VERSION}/bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz
|
||||
tar -xzf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz
|
||||
sudo mv bitcoin-${BITCOIND_VERSION}/bin/* /usr/local/bin
|
||||
rm -rf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz bitcoin-${BITCOIND_VERSION}
|
||||
|
||||
- name: Save bitcoind cache
|
||||
id: save-cache-bitcoind
|
||||
if : ${{ success() && steps.cache-bitcoind.outputs.cache-hit != 'true'}}
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: /usr/local/bin/bitcoin*
|
||||
key: ${{ steps.cache-bitcoind.outputs.cache-primary-key }}
|
||||
|
||||
- name: Download Core Lightning ${{ inputs.cln-version }} & install binaries
|
||||
if: ${{ contains(matrix.os, 'ubuntu') && steps.cache-cln.outputs.cache-hit != 'true' }}
|
||||
run: |
|
||||
url=$(curl -f --retry 3 --retry-delay 3 -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/ElementsProject/lightning/releases/tags/${{ inputs.cln-version }} \
|
||||
| jq '.assets[] | select(.name | contains("24.04")) | .browser_download_url' \
|
||||
| tr -d '\"')
|
||||
wget $url
|
||||
wget --tries=5 --waitretry=5 --retry-connrefused --timeout=20 --continue $url
|
||||
sudo tar -xvf ${url##*/} -C /usr/local --strip-components=2
|
||||
echo "CLN_VERSION=$(lightningd --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Save CLN cache
|
||||
id: save-cache-cln
|
||||
if : ${{ success() && steps.cache-cln.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bin/lightning*
|
||||
/usr/local/libexec/c-lightning
|
||||
key: ${{ steps.cache-cln.outputs.cache-primary-key }}
|
||||
|
||||
- name: Set up Rust
|
||||
if: ${{ inputs.tagged-release == false}}
|
||||
if: ${{ inputs.tagged-release == false }}
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set up protoc
|
||||
if: ${{ inputs.tagged-release == false}} || contains(matrix.os, 'macos') && ${{ steps.cache-cln.outputs.cache-hit != 'true' }}
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install cargo-binstall
|
||||
if: ${{ inputs.tagged-release == false }}
|
||||
uses: taiki-e/install-action@cargo-binstall
|
||||
|
||||
- name: 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 cargo-msrv
|
||||
if: ${{ inputs.tagged-release == false }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: cargo binstall --no-confirm cargo-msrv
|
||||
|
||||
- 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: Verify MSRV
|
||||
if: ${{ inputs.tagged-release == false }}
|
||||
run: cargo msrv verify
|
||||
|
||||
- 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
|
||||
uv run python -m grpc_tools.protoc --proto_path="proto" --python_out="tests" --grpc_python_out="tests" proto/*.proto
|
||||
fi
|
||||
|
||||
url=$(curl -f --retry 3 --retry-delay 3 -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/BoltzExchange/hold/releases/latest \
|
||||
| jq '.assets[] | select(.name | endswith("linux-amd64.tar.gz")) | .browser_download_url' \
|
||||
| tr -d '\"')
|
||||
wget $url
|
||||
tar -xvf ${url##*/} -C tests
|
||||
mv "tests/build/hold-linux-amd64" "tests/hold"
|
||||
|
||||
cargo build
|
||||
cargo test
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
|
|
@ -184,5 +172,8 @@ jobs:
|
|||
export TRAVIS=1
|
||||
export VALGRIND=0
|
||||
export PYTEST_TIMEOUT=600
|
||||
source venv/bin/activate
|
||||
pytest -n=5 tests/test_*.py
|
||||
|
||||
cd tests
|
||||
tar -xf nostr-rs-relay.tar.gz
|
||||
uv add --dev pyln-testing==${{ inputs.pyln-version }} pyln-client==${{ inputs.pyln-version }} pyln-proto==${{ inputs.pyln-version }}
|
||||
uv run -v pytest -n=10
|
||||
|
|
|
|||
14
.github/workflows/latest_v24.08.yml
vendored
14
.github/workflows/latest_v24.08.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
name: Latest release on CLN v24.08.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v24.08.2"
|
||||
pyln-version: "24.8.2"
|
||||
tagged-release: true
|
||||
14
.github/workflows/latest_v24.11.yml
vendored
14
.github/workflows/latest_v24.11.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
name: Latest release on CLN v24.11.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v24.11.2"
|
||||
pyln-version: "24.11.1"
|
||||
tagged-release: true
|
||||
14
.github/workflows/latest_v25.02.yml
vendored
14
.github/workflows/latest_v25.02.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
name: Latest release on CLN v25.02.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v25.02.1"
|
||||
pyln-version: "25.2.1"
|
||||
tagged-release: true
|
||||
14
.github/workflows/latest_v25.05.yml
vendored
14
.github/workflows/latest_v25.05.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
name: Latest release on CLN v25.05.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v25.05"
|
||||
pyln-version: "25.5"
|
||||
tagged-release: true
|
||||
16
.github/workflows/latest_v25.09.yml
vendored
Normal file
16
.github/workflows/latest_v25.09.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: Latest release on CLN v25.09.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v25.09.3"
|
||||
pyln-version: "25.9.3"
|
||||
tagged-release: true
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.12"
|
||||
16
.github/workflows/latest_v25.12.yml
vendored
Normal file
16
.github/workflows/latest_v25.12.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: Latest release on CLN v25.12.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v25.12.1"
|
||||
pyln-version: "25.12.1"
|
||||
tagged-release: true
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.12"
|
||||
16
.github/workflows/latest_v26.04.yml
vendored
Normal file
16
.github/workflows/latest_v26.04.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: Latest release on CLN v26.04.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v26.04"
|
||||
pyln-version: "26.4"
|
||||
tagged-release: true
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.12"
|
||||
16
.github/workflows/latest_v26.06.yml
vendored
Normal file
16
.github/workflows/latest_v26.06.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: Latest release on CLN v26.06.x
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v26.06.6"
|
||||
pyln-version: "26.6.6"
|
||||
tagged-release: true
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.14"
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
name: main on CLN v24.11.x
|
||||
name: master on CLN v25.09.x
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'tools/**'
|
||||
- 'CHANGELOG.md'
|
||||
|
|
@ -19,6 +19,8 @@ jobs:
|
|||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v24.11.2"
|
||||
pyln-version: "24.11.1"
|
||||
tagged-release: false
|
||||
cln-version: "v25.09.3"
|
||||
pyln-version: "25.9.3"
|
||||
tagged-release: false
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.12"
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
name: main on CLN v25.05.x
|
||||
name: master on CLN v25.12.x
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'tools/**'
|
||||
- 'CHANGELOG.md'
|
||||
|
|
@ -19,6 +19,8 @@ jobs:
|
|||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v25.05"
|
||||
pyln-version: "25.5"
|
||||
tagged-release: false
|
||||
cln-version: "v25.12.1"
|
||||
pyln-version: "25.12.1"
|
||||
tagged-release: false
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.12"
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
name: main on CLN v24.08.x
|
||||
name: master on CLN v26.04.x
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'tools/**'
|
||||
- 'CHANGELOG.md'
|
||||
|
|
@ -19,6 +19,8 @@ jobs:
|
|||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v24.08.2"
|
||||
pyln-version: "24.8.2"
|
||||
tagged-release: false
|
||||
cln-version: "v26.04"
|
||||
pyln-version: "26.4"
|
||||
tagged-release: false
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.12"
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
name: main on CLN v25.02.x
|
||||
name: master on CLN v26.06.x
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'tools/**'
|
||||
- 'CHANGELOG.md'
|
||||
|
|
@ -19,6 +19,8 @@ jobs:
|
|||
call-ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
cln-version: "v25.02.1"
|
||||
pyln-version: "25.2.1"
|
||||
tagged-release: false
|
||||
cln-version: "v26.06.6"
|
||||
pyln-version: "26.6.6"
|
||||
tagged-release: false
|
||||
python-version-1: "3.10"
|
||||
python-version-2: "3.14"
|
||||
197
.github/workflows/release.yml
vendored
197
.github/workflows/release.yml
vendored
|
|
@ -5,77 +5,129 @@ on:
|
|||
- '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 }}
|
||||
build-linux:
|
||||
name: Build Linux binaries (glibc)
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
glibc-version: ${{ steps.versions.outputs.glibc-version }}
|
||||
rust-version: ${{ steps.versions.outputs.rust-version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, armv7-unknown-linux-gnueabihf
|
||||
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
||||
|
||||
- name: Build Linux binaries (release)
|
||||
run: |
|
||||
cargo build --profile optimized --locked --target x86_64-unknown-linux-gnu
|
||||
cargo build --profile optimized --locked --target aarch64-unknown-linux-gnu
|
||||
cargo build --profile optimized --locked --target armv7-unknown-linux-gnueabihf
|
||||
|
||||
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-aarch64-linux-gnu.tar.gz" --transform 's|.*/||' "target/aarch64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}"
|
||||
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-armv7-linux-gnueabihf.tar.gz" --transform 's|.*/||' "target/armv7-unknown-linux-gnueabihf/optimized/${{ github.event.repository.name }}"
|
||||
tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-x86_64-linux-gnu.tar.gz" --transform 's|.*/||' "target/x86_64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}"
|
||||
|
||||
ls -alh *.tar.gz
|
||||
|
||||
- name: Get versions
|
||||
id: versions
|
||||
run: |
|
||||
echo "rust-version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
GLIBC_VER=$(ldd --version | awk '/ldd/{print $NF}')
|
||||
echo "glibc-version=$GLIBC_VER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: linux-binaries
|
||||
path: |
|
||||
${{ github.event.repository.name }}-${{ github.ref_name }}-*.tar.gz
|
||||
build-macos:
|
||||
name: Build macOS universal binary
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
macos-deployment-target: ${{ steps.build.outputs.macos-deployment-target }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-apple-darwin, aarch64-apple-darwin
|
||||
|
||||
- name: Build macOS universal (release)
|
||||
id: build
|
||||
env:
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
run: |
|
||||
cargo build --profile optimized --locked --target x86_64-apple-darwin
|
||||
cargo build --profile optimized --locked --target aarch64-apple-darwin
|
||||
|
||||
BINARY_NAME="${{ github.event.repository.name }}"
|
||||
TAG="${{ github.ref_name }}"
|
||||
|
||||
lipo -create -output "${BINARY_NAME}" \
|
||||
target/x86_64-apple-darwin/optimized/"${BINARY_NAME}" \
|
||||
target/aarch64-apple-darwin/optimized/"${BINARY_NAME}"
|
||||
|
||||
zip "${BINARY_NAME}-${TAG}-universal-apple-darwin.zip" "${BINARY_NAME}"
|
||||
|
||||
echo "macos-deployment-target=$MACOSX_DEPLOYMENT_TARGET" >> "$GITHUB_OUTPUT"
|
||||
|
||||
ls -alh *.zip
|
||||
|
||||
- name: Verify macOS deployment target
|
||||
env:
|
||||
EXPECTED_TARGET: ${{ steps.build.outputs.macos-deployment-target }}
|
||||
run: |
|
||||
BINARY="${{ github.event.repository.name }}"
|
||||
|
||||
echo "Verifying deployment target for universal binary: $BINARY"
|
||||
echo "Expected minimum macOS version: $EXPECTED_TARGET"
|
||||
echo
|
||||
|
||||
# Check x86_64 slice
|
||||
X86_MINOS=$(otool -l -arch x86_64 "$BINARY" | awk '
|
||||
/LC_BUILD_VERSION/ || /LC_VERSION_MIN_MACOSX/ {getline; getline; getline; if ($1 == "minos") print $2}
|
||||
')
|
||||
echo "x86_64 slice minos: $X86_MINOS"
|
||||
|
||||
# Check arm64 slice
|
||||
ARM64_MINOS=$(otool -l -arch arm64 "$BINARY" | awk '
|
||||
/LC_BUILD_VERSION/ || /LC_VERSION_MIN_MACOSX/ {getline; getline; getline; if ($1 == "minos") print $2}
|
||||
')
|
||||
echo "arm64 slice minos: $ARM64_MINOS"
|
||||
|
||||
# Fail if either doesn't match
|
||||
if [ "$X86_MINOS" != "$EXPECTED_TARGET" ] || [ "$ARM64_MINOS" != "$EXPECTED_TARGET" ]; then
|
||||
echo "ERROR: Deployment target mismatch!"
|
||||
echo "Expected: $EXPECTED_TARGET"
|
||||
echo "Got: x86_64=$X86_MINOS, arm64=$ARM64_MINOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Success: Both architectures have deployment target $EXPECTED_TARGET"
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: macos-binaries
|
||||
path: |
|
||||
${{ github.event.repository.name }}-${{ github.ref_name }}-universal-apple-darwin.zip
|
||||
|
||||
release:
|
||||
name: Github Release
|
||||
needs: [build]
|
||||
runs-on: "ubuntu-24.04"
|
||||
needs: [build-linux, build-macos]
|
||||
runs-on: "ubuntu-latest"
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
|
@ -83,7 +135,7 @@ jobs:
|
|||
id: tag_name
|
||||
run: echo "current_version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Get Changelog Entry
|
||||
id: changelog_reader
|
||||
uses: mindsers/changelog-reader-action@v2
|
||||
|
|
@ -92,7 +144,7 @@ jobs:
|
|||
version: ${{ steps.tag_name.outputs.current_version }}
|
||||
path: ./CHANGELOG.md
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
merge-multiple: true
|
||||
- name: Release
|
||||
|
|
@ -100,5 +152,6 @@ jobs:
|
|||
with:
|
||||
allowUpdates: false
|
||||
artifactErrorsFailBuild: true
|
||||
body: "${{ steps.changelog_reader.outputs.changes }} \n\n### Release binaries info\n\n- Release binaries were built using rust ${{ needs.build.outputs.rust-version }}\n- Linux release binaries require glibc>=2.31"
|
||||
artifacts: "${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz"
|
||||
body: "${{ steps.changelog_reader.outputs.changes }} \n\n### Release binaries info\n\n- Release binaries were built using rust ${{ needs.build-linux.outputs.rust-version }}\n- Linux release binaries require glibc>=${{ needs.build-linux.outputs.glibc-version }} (check yours with ``ldd --version``)\n- macOS universal binary (x86_64 + aarch64), requires macOS >= ${{ needs.build-macos.outputs.macos-deployment-target }} (deployment target)"
|
||||
artifacts: "${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz,${{ github.event.repository.name }}-${{github.ref_name}}-*.zip"
|
||||
immutableCreate: true
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,4 +2,6 @@
|
|||
/tests/__pycache__/
|
||||
/venv/
|
||||
/result/
|
||||
/tests/holdinvoice
|
||||
/tests/holdinvoice
|
||||
/tests/hold
|
||||
/tests/nostr-rs-relay
|
||||
|
|
|
|||
4
.rustfmt.toml
Normal file
4
.rustfmt.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
imports_granularity = "Crate"
|
||||
group_imports = "StdExternalCrate"
|
||||
imports_layout = "HorizontalVertical"
|
||||
unstable_features = true
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -1,5 +1,60 @@
|
|||
# Changelog
|
||||
|
||||
## [0.2.0] 2026-08-09
|
||||
|
||||
### Added
|
||||
- holdinvoice methods: ``make_hold_invoice``, ``cancel_hold_invoice``, ``settle_hold_invoice``
|
||||
- holdinvoice notification: ``hold_invoice_accepted``
|
||||
|
||||
### Changed
|
||||
- there are now five default relays that will be used if ``nip47-relays`` is not set
|
||||
- `pay_keysend` will use the new CLN `xkeysend` command using CLN v26.06+
|
||||
- Budget is updated on demand instead of by a background task
|
||||
- `list_transactions` is bounded (at most 500 transactions and ~128kB response) to prevent DoS
|
||||
- Upgrade ``nostr_sdk`` and CLN dependencies
|
||||
|
||||
### Removed
|
||||
- ``multi_pay_invoice`` and ``multi_pay_keysend``, they were removed from the spec
|
||||
|
||||
### Fixed
|
||||
- ``nip47-create`` no longer fails if the NWC fails to start, the error is only logged, since the NWC at that point is already persisted to the DB
|
||||
- Paying amount-less invoices with a request amount
|
||||
- Legacy keysend TLV byte decoding
|
||||
- Report the correct error when the payment route is too expensive
|
||||
- Notifications are sent per client, so one failing client no longer prevents the others from receiving them
|
||||
- Available payment methods are now determined from the node's RPC instead of it's (custom) version string
|
||||
- Deduplication of request events instead of filtering unreliably
|
||||
|
||||
## [0.1.9] 2026-04-23
|
||||
|
||||
### Fixed
|
||||
- ``nip47-budget``: race condition where new budget settings would sometimes be ignored
|
||||
|
||||
## [0.1.8] 2026-04-03
|
||||
|
||||
### Changed
|
||||
- updated cln dependencies
|
||||
|
||||
## [0.1.7] 2025-11-27
|
||||
|
||||
### Fixed
|
||||
- If there were failed payment attempts before success the ``payment_sent`` notification might not have been sent
|
||||
|
||||
## [0.1.6] 2025-11-10
|
||||
|
||||
### Added
|
||||
- Include pending and failed payments in ``list_transactions``, now that ``state`` exists wallets can display these more meaningfully
|
||||
- Include expired invoices in ``list_transactions``, now that ``state`` exists wallets can display these more meaningfully
|
||||
|
||||
### Changed
|
||||
- Upgrade ``nostr_sdk`` to ``v0.44`` and implement new fields in sync with the ``nip47`` spec, e.g. ``state`` for transactions
|
||||
- Only process events that were created after ``cln-nip47`` started so it does not sent duplicate responses
|
||||
|
||||
### Fixed
|
||||
- Some minor fixes around ``bolt11`` invoices with 0 amount (aka any amount)
|
||||
- Don't ignore `offset` parameter in ``list_transactions``
|
||||
- Also add ``notifications`` to the ``info_event``'s ``content`` if enabled to be in line with the spec
|
||||
|
||||
## [0.1.5] 2025-07-24
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
1725
Cargo.lock
generated
1725
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
38
Cargo.toml
38
Cargo.toml
|
|
@ -1,8 +1,8 @@
|
|||
[package]
|
||||
name = "cln-nip47"
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
|
@ -12,14 +12,22 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_json = "1"
|
||||
|
||||
tokio = { version = "1", features = ["fs", "sync", "rt-multi-thread"] }
|
||||
cln-rpc = "0.4"
|
||||
# cln-rpc = { path = "../lightning/cln-rpc/", version = "^0.4" }
|
||||
cln-plugin = "0.4"
|
||||
# cln-plugin = { path = "../lightning/plugins/", version = "^0.4" }
|
||||
cln-rpc = "0.7"
|
||||
# cln-rpc = { path = "../lightning/cln-rpc/", version = "^0.6" }
|
||||
cln-plugin = "0.7"
|
||||
# cln-plugin = { path = "../lightning/plugins/", version = "^0.6" }
|
||||
parking_lot = "0.12"
|
||||
|
||||
# nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "f7122f5", features = ["nip47", "nip04", "nip44"]}
|
||||
nostr-sdk = { version = "0.42", features = ["nip47", "nip04", "nip44"] }
|
||||
# # nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "ff5be7d1416ea201887a02f648795010a3cbdf49" }
|
||||
# nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "ff5be7d1416ea201887a02f648795010a3cbdf49", features = [
|
||||
# "nip47",
|
||||
# "nip04",
|
||||
# "nip44",
|
||||
# ] }
|
||||
nostr = { version = "0.45.1", features = ["nip47", "nip04", "nip44"] }
|
||||
nostr-sdk = { version = "0.45.1" }
|
||||
|
||||
futures = "0.3"
|
||||
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
|
|
@ -27,6 +35,18 @@ hex = "0.4"
|
|||
|
||||
regex = "1"
|
||||
|
||||
tonic = { version = "0.14", default-features = false, features = [
|
||||
"codegen",
|
||||
"transport",
|
||||
"tls-ring",
|
||||
] }
|
||||
prost = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
|
||||
[profile.optimized]
|
||||
inherits = "release"
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -1,49 +1,49 @@
|
|||
<table border="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.09.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.09.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.08.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.08.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.09.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.09.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.12.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.12.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.12.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v25.12.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.04.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.04.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.04.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.04.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.05.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.05.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.06.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v26.06.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.05.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.05.yml/badge.svg?branch=main">
|
||||
<a href="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.06.yml">
|
||||
<img src="https://github.com/daywalker90/cln-nip47/actions/workflows/master_v26.06.yml/badge.svg?branch=master">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
|
||||
# cln-nip47
|
||||
A core lightning plugin to connect wallets via Nostr Wallet Connect (NWC) as specified in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md).
|
||||
A core lightning plugin to connect wallets via Nostr Wallet Connect (NWC) as specified in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md). It is intended to be used by a single user, since all notifications got to all NWC's.
|
||||
|
||||
* [Installation](#installation)
|
||||
* [Building](#building)
|
||||
|
|
@ -64,11 +64,10 @@ Release binaries for
|
|||
* x86_64-linux
|
||||
* armv7-linux (Raspberry Pi 32bit)
|
||||
* aarch64-linux (Raspberry Pi 64bit)
|
||||
* universal-apple-darwin (macOS)
|
||||
|
||||
can be found on the [release](https://github.com/daywalker90/cln-nip47/releases) page. If you are unsure about your architecture you can run ``uname -m``.
|
||||
|
||||
They require ``glibc>=2.31``, which you can check with ``ldd --version``.
|
||||
|
||||
# Building
|
||||
You can build the plugin yourself instead of using the release binaries.
|
||||
First clone the repo:
|
||||
|
|
@ -85,7 +84,7 @@ cargo build --release
|
|||
|
||||
After that the binary will be here: ``target/release/cln-nip47``
|
||||
|
||||
Note: Release binaries are built using ``cross`` and the ``optimized`` profile.
|
||||
Note: Release binaries are built with the ``optimized`` profile.
|
||||
|
||||
# Documentation
|
||||
|
||||
|
|
@ -98,7 +97,12 @@ It is highly recommended to use your own private relay since public relays may l
|
|||
For a private relay you can for example use [nostr-rs-relay](https://github.com/scsibug/nostr-rs-relay) with ``pubkey_whitelist`` set to both ``clientkey_public`` and ``walletkey_public`` (returned from ``nip47-create``/``nip47-list``).
|
||||
|
||||
## Options
|
||||
* ``nip47-relays``: Specify the relays that you want to use with your NWC. Can be set multiple times to use multiple relays. NWC's you create will save these and even if you add or remove relays keep the relays from the moment you created that NWC. You must set this atleast one time.
|
||||
* ``nip47-relays``: Specify the relays that you want to use with your NWC. Can be set multiple times to use multiple relays. NWC's you create will save these and even if you add or remove relays keep the relays from the moment you created that NWC. If you don't set this yourself these default relays will be used:
|
||||
* ``wss://nos.lol``
|
||||
* ``wss://relay.primal.net``
|
||||
* ``wss://relay.getalby.com/v1``
|
||||
* ``wss://relay.nostr.net``
|
||||
* ``wss://relay.snort.social``
|
||||
* ``nip47-notifications``: Enable/disable nip47 notifications. Default is enabled (``true``)
|
||||
|
||||
## Methods
|
||||
|
|
@ -127,21 +131,26 @@ For a private relay you can for example use [nostr-rs-relay](https://github.com/
|
|||
* list all NWC configurations or just the one with ``label``
|
||||
* ***label***: optional. The label the NWC was created with
|
||||
|
||||
## Holdinvoice support
|
||||
For methods or notifications related to holdinvoices you need v0.3.2+ of [hold](https://github.com/BoltzExchange/hold) with enabled grpc (which is the default just make sure the port is free)
|
||||
|
||||
## Supported NWC methods
|
||||
* ``pay_invoice``
|
||||
* ``multi_pay_invoice``
|
||||
* ``pay_keysend`` (no ``preimage`` in request allowed since CLN only supports generating it itself)
|
||||
* ``multi_pay_keysend`` (no ``preimage`` in request allowed since CLN only supports generating it itself)
|
||||
* ``make_invoice``
|
||||
* ``lookup_invoice``
|
||||
* ``list_transactions``
|
||||
* ``get_balance``
|
||||
* ``get_info`` (no ``block_hash``)
|
||||
* ``make_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support))
|
||||
* ``cancel_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support))
|
||||
* ``settle_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support))
|
||||
|
||||
## Supported NWC notifications
|
||||
* ``payment_received``
|
||||
* ``payment_sent``
|
||||
* ``hold_invoice_accepted`` (requires [Holdinvoice support](#holdinvoice_support))
|
||||
|
||||
## Supported content encryption:
|
||||
* [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md)
|
||||
* [NIP-44v2](https://github.com/nostr-protocol/nips/blob/master/44.md)
|
||||
* [NIP-44v2](https://github.com/nostr-protocol/nips/blob/master/44.md)
|
||||
|
|
|
|||
27
SECURITY.md
Normal file
27
SECURITY.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest version is supported.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report security vulnerabilities, please send an email to:
|
||||
- `daywalker990@gmx.de`
|
||||
|
||||
Note: This email address is exclusively for vulnerability reporting.
|
||||
|
||||
For all other inquiries/communication, please use the Github issues.
|
||||
|
||||
## Signatures For Releases
|
||||
|
||||
The following keys may be used to communicate sensitive information to
|
||||
developers:
|
||||
|
||||
| Name | Email | Fingerprint |
|
||||
|------|-------|-------------|
|
||||
| daywalker90 | `daywalker990@gmx.de` | 8A07 9421 A871 D0B1 0835 1193 7AB4 802E D5A6 39F3 |
|
||||
|
||||
You can import a key by running the following command with that individual’s fingerprint:
|
||||
`gpg --keyserver hkps://keys.openpgp.org --recv-keys "<fingerprint>"`.
|
||||
Ensure that you put quotes around fingerprints containing spaces.
|
||||
8
build.rs
Normal file
8
build.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fn main() {
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().unwrap();
|
||||
unsafe { std::env::set_var("PROTOC", protoc) };
|
||||
tonic_prost_build::configure()
|
||||
.protoc_arg("--experimental_allow_proto3_optional")
|
||||
.compile_protos(&["protos/hold.proto"], &["protos"])
|
||||
.unwrap_or_else(|e| panic!("Could not build protos: {e}"));
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
plugin:
|
||||
name: cln-nip47
|
||||
version: 0.1.5
|
||||
version: 0.2.0
|
||||
lang: rust
|
||||
install: |
|
||||
cargo build --release && cp target/release/cln-nip47 . && cargo clean
|
||||
|
|
|
|||
23
flake.lock
generated
23
flake.lock
generated
|
|
@ -1,17 +1,12 @@
|
|||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1721058578,
|
||||
"narHash": "sha256-fs/PVa3H5dS1//4BjecWi3nitXm5fRObx0JxXIAo+JA=",
|
||||
"lastModified": 1755993354,
|
||||
"narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "17e5109bb1d9fb393d70fba80988f7d70d1ded1a",
|
||||
"rev": "25bd41b24426c7734278c2ff02e53258851db914",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -25,11 +20,11 @@
|
|||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -40,11 +35,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1721116560,
|
||||
"narHash": "sha256-++TYlGMAJM1Q+0nMVaWBSEvEUjRs7ZGiNQOpqbQApCU=",
|
||||
"lastModified": 1756159630,
|
||||
"narHash": "sha256-ohMvsjtSVdT/bruXf5ClBh8ZYXRmD4krmjKrXhEvwMg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9355fa86e6f27422963132c2c9aeedb0fb963d93",
|
||||
"rev": "84c256e42600cb0fdf25763b48d28df2f25a0c8b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@
|
|||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
crane = {
|
||||
url = "github:ipetkov/crane";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
crane.url = "github:ipetkov/crane";
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
|
|
|||
185
protos/hold.proto
Normal file
185
protos/hold.proto
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package hold;
|
||||
|
||||
service Hold {
|
||||
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
|
||||
|
||||
rpc Invoice (InvoiceRequest) returns (InvoiceResponse) {}
|
||||
rpc Inject (InjectRequest) returns (InjectResponse) {}
|
||||
|
||||
rpc List (ListRequest) returns (ListResponse) {}
|
||||
|
||||
rpc Settle (SettleRequest) returns (SettleResponse) {}
|
||||
rpc Cancel (CancelRequest) returns (CancelResponse) {}
|
||||
|
||||
// Cleans cancelled invoices
|
||||
rpc Clean (CleanRequest) returns (CleanResponse) {}
|
||||
|
||||
rpc Track (TrackRequest) returns (stream TrackResponse) {}
|
||||
rpc TrackAll (TrackAllRequest) returns (stream TrackAllResponse) {}
|
||||
|
||||
rpc OnionMessages (stream OnionMessageResponse) returns (stream OnionMessage) {}
|
||||
}
|
||||
|
||||
enum HookAction {
|
||||
Continue = 0;
|
||||
Resolve = 1;
|
||||
}
|
||||
|
||||
message GetInfoRequest {}
|
||||
message GetInfoResponse {
|
||||
string version = 1;
|
||||
}
|
||||
|
||||
message Hop {
|
||||
bytes public_key = 1;
|
||||
uint64 short_channel_id = 2;
|
||||
uint64 base_fee = 3;
|
||||
uint64 ppm_fee = 4;
|
||||
uint64 cltv_expiry_delta = 5;
|
||||
}
|
||||
|
||||
message RoutingHint {
|
||||
repeated Hop hops = 1;
|
||||
}
|
||||
|
||||
message InvoiceRequest {
|
||||
bytes payment_hash = 1;
|
||||
uint64 amount_msat = 2;
|
||||
|
||||
oneof description {
|
||||
string memo = 3;
|
||||
bytes hash = 4;
|
||||
}
|
||||
|
||||
optional uint64 expiry = 5;
|
||||
optional uint64 min_final_cltv_expiry = 6;
|
||||
repeated RoutingHint routing_hints = 7;
|
||||
}
|
||||
message InvoiceResponse {
|
||||
string bolt11 = 1;
|
||||
}
|
||||
|
||||
message InjectRequest {
|
||||
string invoice = 1;
|
||||
optional uint64 min_cltv_expiry = 2;
|
||||
}
|
||||
message InjectResponse {}
|
||||
|
||||
message ListRequest {
|
||||
message Pagination {
|
||||
// Inclusive
|
||||
int64 index_start = 1;
|
||||
uint64 limit = 2;
|
||||
}
|
||||
|
||||
oneof constraint {
|
||||
bytes payment_hash = 1;
|
||||
Pagination pagination = 2;
|
||||
}
|
||||
}
|
||||
|
||||
enum InvoiceState {
|
||||
UNPAID = 0;
|
||||
ACCEPTED = 1;
|
||||
PAID = 2;
|
||||
CANCELLED = 3;
|
||||
}
|
||||
|
||||
message Htlc {
|
||||
int64 id = 1;
|
||||
InvoiceState state = 2;
|
||||
string scid = 3;
|
||||
uint64 channel_id = 4;
|
||||
uint64 msat = 5;
|
||||
uint64 created_at = 6;
|
||||
optional uint64 cltv_expiry = 7;
|
||||
}
|
||||
|
||||
message Invoice {
|
||||
int64 id = 1;
|
||||
bytes payment_hash = 2;
|
||||
optional bytes preimage = 3;
|
||||
string invoice = 4;
|
||||
InvoiceState state = 5;
|
||||
uint64 created_at = 6;
|
||||
optional uint64 settled_at = 8;
|
||||
|
||||
repeated Htlc htlcs = 7;
|
||||
|
||||
optional uint64 min_cltv_expiry = 9;
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
repeated Invoice invoices = 1;
|
||||
}
|
||||
|
||||
message SettleRequest {
|
||||
bytes payment_preimage = 1;
|
||||
}
|
||||
message SettleResponse {}
|
||||
|
||||
message CancelRequest {
|
||||
bytes payment_hash = 1;
|
||||
}
|
||||
message CancelResponse {}
|
||||
|
||||
message CleanRequest {
|
||||
// Clean everything older than age seconds
|
||||
optional uint64 age = 1;
|
||||
}
|
||||
message CleanResponse {
|
||||
uint64 cleaned = 1;
|
||||
}
|
||||
|
||||
message TrackRequest {
|
||||
bytes payment_hash = 1;
|
||||
}
|
||||
|
||||
message TrackResponse {
|
||||
InvoiceState state = 1;
|
||||
}
|
||||
|
||||
message TrackAllRequest {
|
||||
repeated bytes payment_hashes = 1;
|
||||
}
|
||||
|
||||
message TrackAllResponse {
|
||||
bytes payment_hash = 1;
|
||||
string bolt11 = 2;
|
||||
InvoiceState state = 3;
|
||||
}
|
||||
|
||||
message OnionMessage {
|
||||
message ReplyBlindedPath {
|
||||
message Hop {
|
||||
optional bytes blinded_node_id = 1;
|
||||
optional bytes encrypted_recipient_data = 2;
|
||||
}
|
||||
|
||||
optional bytes first_node_id = 1;
|
||||
optional string first_scid = 2;
|
||||
optional uint64 first_scid_dir = 3;
|
||||
optional bytes first_path_key = 4;
|
||||
repeated Hop hops = 5;
|
||||
}
|
||||
|
||||
message UnknownField {
|
||||
uint64 number = 1;
|
||||
bytes value = 2;
|
||||
}
|
||||
|
||||
uint64 id = 1;
|
||||
optional bytes pathsecret = 2;
|
||||
optional ReplyBlindedPath reply_blindedpath = 3;
|
||||
optional bytes invoice_request = 4;
|
||||
optional bytes invoice = 5;
|
||||
optional bytes invoice_error = 6;
|
||||
repeated UnknownField unknown_fields = 7;
|
||||
}
|
||||
|
||||
message OnionMessageResponse {
|
||||
uint64 id = 1;
|
||||
HookAction action = 2;
|
||||
}
|
||||
290
src/main.rs
290
src/main.rs
|
|
@ -1,23 +1,41 @@
|
|||
use std::{path::Path, time::Duration};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use cln_plugin::{
|
||||
Builder,
|
||||
Plugin,
|
||||
options::{ConfigOption, DefaultBooleanConfigOption, StringArrayConfigOption},
|
||||
Builder, Plugin,
|
||||
};
|
||||
use cln_rpc::{model::requests::ListdatastoreRequest, ClnRpc};
|
||||
|
||||
use nostr_sdk::*;
|
||||
use cln_rpc::{
|
||||
ClnRpc,
|
||||
model::{
|
||||
requests::{DecodeRequest, ListdatastoreRequest},
|
||||
responses::DecodeType,
|
||||
},
|
||||
};
|
||||
use nostr::nips::nip47;
|
||||
use nwc::run_nwc;
|
||||
use nwc_notifications::{payment_received_handler, payment_sent_handler};
|
||||
use parse::read_startup_options;
|
||||
use rpc::{nwc_budget, nwc_create, nwc_list, nwc_revoke};
|
||||
use serde_json::json;
|
||||
use structs::PluginState;
|
||||
use tokio::time;
|
||||
use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity};
|
||||
use util::{load_nwc_store, update_nwc_store};
|
||||
|
||||
use crate::{
|
||||
hold::{InvoiceState, ListRequest, hold_client::HoldClient},
|
||||
nwc_notifications::holdinvoice_accepted_handler,
|
||||
};
|
||||
|
||||
mod nwc;
|
||||
mod nwc_balance;
|
||||
mod nwc_hold;
|
||||
mod nwc_info;
|
||||
mod nwc_invoice;
|
||||
mod nwc_keysend;
|
||||
|
|
@ -30,6 +48,11 @@ mod structs;
|
|||
mod tasks;
|
||||
mod util;
|
||||
|
||||
pub const STARTUP_DELAY: u64 = 1;
|
||||
pub mod hold {
|
||||
tonic::include_proto!("hold");
|
||||
}
|
||||
|
||||
const OPT_RELAYS: StringArrayConfigOption = ConfigOption::new_str_arr_no_default(
|
||||
"nip47-relays",
|
||||
"Nostr relays used for nwc. Can be stated multiple times.",
|
||||
|
|
@ -40,31 +63,35 @@ const OPT_NOTIFICATIONS: DefaultBooleanConfigOption = ConfigOption::new_bool_wit
|
|||
"Enable/disable nip47-notifications. Default is `true`",
|
||||
);
|
||||
pub const PLUGIN_NAME: &str = "cln-nip47";
|
||||
pub const WALLET_READ_METHODS: [&str; 5] = [
|
||||
"make_invoice",
|
||||
"lookup_invoice",
|
||||
"list_transactions",
|
||||
"get_balance",
|
||||
"get_info",
|
||||
pub const WALLET_READ_OR_RECEIVE_METHODS: [nip47::Method; 5] = [
|
||||
nip47::Method::MakeInvoice,
|
||||
nip47::Method::LookupInvoice,
|
||||
nip47::Method::ListTransactions,
|
||||
nip47::Method::GetBalance,
|
||||
nip47::Method::GetInfo,
|
||||
];
|
||||
pub const WALLET_ALL_METHODS: [&str; 9] = [
|
||||
"pay_invoice",
|
||||
"multi_pay_invoice",
|
||||
"pay_keysend",
|
||||
"multi_pay_keysend",
|
||||
WALLET_READ_METHODS[0],
|
||||
WALLET_READ_METHODS[1],
|
||||
WALLET_READ_METHODS[2],
|
||||
WALLET_READ_METHODS[3],
|
||||
WALLET_READ_METHODS[4],
|
||||
pub const WALLET_PAY_METHODS: [nip47::Method; 2] =
|
||||
[nip47::Method::PayInvoice, nip47::Method::PayKeysend];
|
||||
pub const WALLET_HOLD_METHODS: [nip47::Method; 3] = [
|
||||
nip47::Method::MakeHoldInvoice,
|
||||
nip47::Method::CancelHoldInvoice,
|
||||
nip47::Method::SettleHoldInvoice,
|
||||
];
|
||||
pub const WALLET_NOTIFICATIONS: [nip47::NotificationType; 2] = [
|
||||
nip47::NotificationType::PaymentReceived,
|
||||
nip47::NotificationType::PaymentSent,
|
||||
];
|
||||
pub const WALLET_HOLD_NOTIFICATIONS: [nip47::NotificationType; 1] =
|
||||
[nip47::NotificationType::HoldInvoiceAccepted];
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
std::env::set_var(
|
||||
"CLN_PLUGIN_LOG",
|
||||
"cln_plugin=info,cln_rpc=info,cln_nip47=debug,info",
|
||||
);
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"CLN_PLUGIN_LOG",
|
||||
"cln_plugin=info,cln_rpc=info,cln_nip47=trace,info",
|
||||
);
|
||||
};
|
||||
log_panics::init();
|
||||
|
||||
let state;
|
||||
|
|
@ -90,13 +117,13 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
return plugin
|
||||
.disable(format!("Error connecting to cln rpc: {}", e).as_str())
|
||||
.disable(format!("Error connecting to cln rpc: {e}").as_str())
|
||||
.await;
|
||||
}
|
||||
};
|
||||
match read_startup_options(&plugin, &state).await {
|
||||
Ok(()) => &(),
|
||||
Err(e) => return plugin.disable(format!("{}", e).as_str()).await,
|
||||
Err(e) => return plugin.disable(format!("{e}").as_str()).await,
|
||||
};
|
||||
log::debug!("read startup options done");
|
||||
plugin
|
||||
|
|
@ -105,15 +132,25 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
};
|
||||
let plugin = confplugin.start(state).await?;
|
||||
|
||||
match check_hold_support(plugin.clone()).await {
|
||||
Ok(()) => {
|
||||
log::info!("Hold support activated, loading pending invoices...");
|
||||
if let Err(e) = load_pending_hold_invoices(plugin.clone()).await {
|
||||
log::error!("Error loading pending hold invoices: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::info!("Hold support not activated: {e}"),
|
||||
}
|
||||
|
||||
{
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
// Make sure incase of rapid nip47-create and plugin restarts info_events
|
||||
// have a different timestamp and therefore ID so relays don't disconnect us
|
||||
time::sleep(Duration::from_secs(1)).await;
|
||||
time::sleep(Duration::from_secs(STARTUP_DELAY)).await;
|
||||
|
||||
match load_nwcs(plugin.clone(), &mut rpc).await {
|
||||
Ok(_) => log::info!("All NWC's loaded"),
|
||||
Ok(()) => log::info!("All NWC's loaded"),
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{}",
|
||||
|
|
@ -126,6 +163,16 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
}
|
||||
}
|
||||
|
||||
let plugin_clone_cleanup = plugin.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Err(e) = tasks::cleanup_event_ids(plugin_clone_cleanup.clone()).await {
|
||||
log::warn!("Error in cleanup_event_ids thread: {e}");
|
||||
}
|
||||
time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.join().await
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +181,8 @@ async fn shutdown_handler(
|
|||
_args: serde_json::Value,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let mut locked_handles = plugin.state().handles.lock().await;
|
||||
for (_x, (client, _client_pubkey)) in locked_handles.drain() {
|
||||
client.shutdown().await;
|
||||
for (_x, wallet_service) in locked_handles.drain() {
|
||||
wallet_service.client.shutdown().await;
|
||||
}
|
||||
std::process::exit(0)
|
||||
}
|
||||
|
|
@ -146,7 +193,7 @@ async fn load_nwcs(plugin: Plugin<PluginState>, rpc: &mut ClnRpc) -> Result<(),
|
|||
key: Some(vec![PLUGIN_NAME.to_owned()]),
|
||||
})
|
||||
.await?;
|
||||
for datastore in labels.datastore.into_iter() {
|
||||
for datastore in labels.datastore {
|
||||
let label = datastore.key.last().unwrap();
|
||||
let mut nwc_store = load_nwc_store(rpc, label).await?;
|
||||
|
||||
|
|
@ -159,7 +206,186 @@ async fn load_nwcs(plugin: Plugin<PluginState>, rpc: &mut ClnRpc) -> Result<(),
|
|||
}
|
||||
}
|
||||
|
||||
// We don't keep track of inflight payments succeeding
|
||||
// while the plugin is dynamically restarted
|
||||
if nwc_store.reserved_msat != 0 {
|
||||
log::warn!(
|
||||
"Releasing {} msat leftover budget reservation for {label}",
|
||||
nwc_store.reserved_msat
|
||||
);
|
||||
nwc_store.reserved_msat = 0;
|
||||
update_nwc_store(rpc, label, nwc_store.clone()).await?;
|
||||
}
|
||||
|
||||
run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_pending_hold_invoices(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
|
||||
let mut hold_client = plugin.state().hold_client.lock().clone().unwrap();
|
||||
let invoices_request = ListRequest { constraint: None };
|
||||
let invoices = hold_client
|
||||
.list(invoices_request)
|
||||
.await?
|
||||
.into_inner()
|
||||
.invoices;
|
||||
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
for invoice in invoices {
|
||||
if invoice.state() == InvoiceState::Accepted || invoice.state() == InvoiceState::Unpaid {
|
||||
// Bound the accepted handler by the invoice's expiry. The hold
|
||||
// plugin has no expired state, so without this it would wait
|
||||
// forever on an invoice that expires while Unpaid.
|
||||
let invoice_decoded = rpc
|
||||
.call_typed(&DecodeRequest {
|
||||
string: invoice.invoice.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
if !invoice_decoded.valid {
|
||||
log::warn!(
|
||||
"Skipping hold invoice {}, could not decode it",
|
||||
hex::encode(&invoice.payment_hash)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (created_at, expiry) = match invoice_decoded.item_type {
|
||||
DecodeType::BOLT12_INVOICE => (
|
||||
invoice_decoded.invoice_created_at,
|
||||
invoice_decoded.invoice_relative_expiry.map(u64::from),
|
||||
),
|
||||
DecodeType::BOLT11_INVOICE => (invoice_decoded.created_at, invoice_decoded.expiry),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (Some(created_at), Some(expiry)) = (created_at, expiry) else {
|
||||
log::warn!(
|
||||
"Skipping hold invoice {}: missing creation time or expiry",
|
||||
hex::encode(&invoice.payment_hash)
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let expires_at = created_at.saturating_add(expiry);
|
||||
|
||||
log::debug!(
|
||||
"Starting holdinvoice accepted handler for {}",
|
||||
hex::encode(&invoice.payment_hash)
|
||||
);
|
||||
tokio::spawn(holdinvoice_accepted_handler(
|
||||
plugin.clone(),
|
||||
invoice.payment_hash,
|
||||
expires_at,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_hold_support(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
let hold_grpc_host_response: serde_json::Value = rpc
|
||||
.call_raw("listconfigs", &json!({"config": "hold-grpc-host"}))
|
||||
.await?;
|
||||
|
||||
let Some(hold_grpc_host_configs) = hold_grpc_host_response.get("configs") else {
|
||||
return Err(anyhow!("Unsopprted listconfigs response!"));
|
||||
};
|
||||
let Some(hold_grpc_host_config) = hold_grpc_host_configs.get("hold-grpc-host") else {
|
||||
return Err(anyhow!("hold-grpc-host config not found"));
|
||||
};
|
||||
let Some(hold_grpc_host_value) = hold_grpc_host_config.get("value_str") else {
|
||||
return Err(anyhow!("hold-grpc-host config not a string"));
|
||||
};
|
||||
let Some(hold_grpc_host) = hold_grpc_host_value.as_str() else {
|
||||
return Err(anyhow!("hold-grpc-host config not convertable to string"));
|
||||
};
|
||||
|
||||
let hold_grpc_port_response: serde_json::Value = rpc
|
||||
.call_raw("listconfigs", &json!({"config": "hold-grpc-port"}))
|
||||
.await?;
|
||||
let Some(hold_grpc_port_configs) = hold_grpc_port_response.get("configs") else {
|
||||
return Err(anyhow!("Unsopprted listconfigs response!"));
|
||||
};
|
||||
let Some(hold_grpc_port_config) = hold_grpc_port_configs.get("hold-grpc-port") else {
|
||||
return Err(anyhow!("hold-grpc-port config not found"));
|
||||
};
|
||||
let Some(hold_grpc_port_value) = hold_grpc_port_config.get("value_int") else {
|
||||
return Err(anyhow!("hold-grpc-port config not a number"));
|
||||
};
|
||||
let hold_grpc_port = if let Some(hgh) = hold_grpc_port_value.as_u64() {
|
||||
u16::try_from(hgh)?
|
||||
} else {
|
||||
return Err(anyhow!("hold-grpc-port config not convertable to integer"));
|
||||
};
|
||||
|
||||
let cert_dir = PathBuf::from_str(&plugin.configuration().lightning_dir)?.join("hold");
|
||||
|
||||
log::debug!(
|
||||
"Searching {} for hold plugin certs",
|
||||
cert_dir.to_str().unwrap()
|
||||
);
|
||||
|
||||
let cert_max_retries = 10;
|
||||
let mut cert_retries = 0;
|
||||
while cert_retries < cert_max_retries && !do_certificates_exist(&cert_dir) {
|
||||
log::debug!("Hold certificates incomplete. Waiting...");
|
||||
time::sleep(Duration::from_millis(500)).await;
|
||||
cert_retries += 1;
|
||||
}
|
||||
|
||||
let ca_cert = tokio::fs::read(cert_dir.join("ca.pem")).await?;
|
||||
let client_cert = tokio::fs::read(cert_dir.join("client.pem")).await?;
|
||||
let client_key = tokio::fs::read(cert_dir.join("client-key.pem")).await?;
|
||||
|
||||
let identity = Identity::from_pem(client_cert, client_key);
|
||||
|
||||
let ca = Certificate::from_pem(ca_cert);
|
||||
|
||||
let tls_config = ClientTlsConfig::new()
|
||||
.ca_certificate(ca)
|
||||
.identity(identity)
|
||||
.domain_name("hold");
|
||||
|
||||
let endpoint = Endpoint::from_shared(format!("https://{hold_grpc_host}:{hold_grpc_port}"))?
|
||||
.tls_config(tls_config)?
|
||||
.keep_alive_while_idle(true)
|
||||
.connect_timeout(Duration::from_secs(5));
|
||||
|
||||
let chan_max_retries = 20;
|
||||
let mut chan_retries = 0;
|
||||
|
||||
let channel = loop {
|
||||
match endpoint.connect().await {
|
||||
Ok(channel) => break channel,
|
||||
Err(e) if chan_retries < chan_max_retries => {
|
||||
chan_retries += 1;
|
||||
|
||||
log::debug!(
|
||||
"Hold gRPC server not ready, retrying ({chan_retries}/{chan_max_retries}): {e}"
|
||||
);
|
||||
|
||||
time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Hold gRPC server did not become ready: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
*plugin.state().hold_client.lock() = Some(HoldClient::new(channel));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn do_certificates_exist(cert_dir: &Path) -> bool {
|
||||
let required_files = ["client.pem", "client-key.pem", "ca.pem"];
|
||||
|
||||
required_files.iter().all(|file| {
|
||||
let path = cert_dir.join(file);
|
||||
path.exists() && path.metadata().is_ok_and(|m| m.len() > 0)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
691
src/nwc.rs
691
src/nwc.rs
|
|
@ -1,63 +1,75 @@
|
|||
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 crate::util::is_read_only_nwc;
|
||||
use crate::{OPT_NOTIFICATIONS, WALLET_ALL_METHODS, WALLET_READ_METHODS};
|
||||
use anyhow::anyhow;
|
||||
use cln_plugin::Plugin;
|
||||
use nostr_sdk::nips::*;
|
||||
use nostr_sdk::Client;
|
||||
use nostr_sdk::*;
|
||||
use tokio::sync::oneshot;
|
||||
use futures::StreamExt;
|
||||
use nostr::{
|
||||
event::{Event, EventBuilder, EventId, FinalizeEventAsync, Kind, Tag},
|
||||
filter::Filter,
|
||||
key::{Keys, PublicKey, SecretKey},
|
||||
nips::{nip04, nip44, nip47},
|
||||
types::Timestamp,
|
||||
};
|
||||
use nostr_sdk::{
|
||||
client::{self, Client, ClientNotification},
|
||||
error::Error,
|
||||
relay::RelayStatus,
|
||||
};
|
||||
use tokio::time;
|
||||
|
||||
use crate::{
|
||||
OPT_NOTIFICATIONS,
|
||||
nwc_balance::get_balance_response,
|
||||
nwc_hold::{
|
||||
cancel_hold_invoice_response,
|
||||
make_hold_invoice_response,
|
||||
settle_hold_invoice_response,
|
||||
},
|
||||
nwc_info::get_info_response,
|
||||
nwc_invoice::make_invoice_response,
|
||||
nwc_keysend::pay_keysend_response,
|
||||
nwc_lookups::{list_transactions_response, lookup_invoice_response},
|
||||
nwc_pay::pay_invoice_response,
|
||||
structs::{ID_MAX_AGE, NwcStore, PluginState, WalletService},
|
||||
util::{build_capabilities, build_notifications_vec, is_read_only_nwc, save_event_id},
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn run_nwc(
|
||||
plugin: Plugin<PluginState>,
|
||||
label: String,
|
||||
nwc_store: NwcStore,
|
||||
) -> Result<(), client::Error> {
|
||||
let capabilities = if is_read_only_nwc(&nwc_store) {
|
||||
WALLET_READ_METHODS.join(" ")
|
||||
} else {
|
||||
WALLET_ALL_METHODS.join(" ")
|
||||
};
|
||||
) -> Result<(), Error> {
|
||||
let (method_capabilities, _) = build_capabilities(is_read_only_nwc(&nwc_store), &plugin);
|
||||
|
||||
let wallet_keys = Keys::new(
|
||||
SecretKey::from_hex(&nwc_store.walletkey)
|
||||
.map_err(|e| client::Error::Signer(SignerError::backend(e)))?,
|
||||
);
|
||||
let client_pubkey = Keys::new(nwc_store.uri.secret.clone()).public_key();
|
||||
let wallet_keys = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
|
||||
let client_keys = Keys::new(nwc_store.uri.secret.clone());
|
||||
|
||||
let client = Client::new(wallet_keys.clone());
|
||||
let nostr_client = Client::new();
|
||||
|
||||
log::debug!("relay_count:{}", nwc_store.uri.relays.len());
|
||||
|
||||
for relay in nwc_store.uri.relays.iter() {
|
||||
log::debug!("Adding relay: {}", relay);
|
||||
client.add_relay(relay).await?;
|
||||
for relay in &nwc_store.uri.relays {
|
||||
log::debug!("Adding relay: {relay}");
|
||||
nostr_client.add_relay(relay).await?;
|
||||
}
|
||||
|
||||
if nwc_store.interval_config.is_some() {
|
||||
start_nwc_budget_job(plugin.clone(), label.clone());
|
||||
log::debug!("NWC {label} uses an interval budget");
|
||||
}
|
||||
|
||||
let client_clone = client.clone();
|
||||
let nostr_client_clone = nostr_client.clone();
|
||||
let plugin_clone = plugin.clone();
|
||||
let label_clone = label.clone();
|
||||
let client_keys_clone = client_keys.clone();
|
||||
let wallet_keys_clone = wallet_keys.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
client_clone.connect().await;
|
||||
client_clone
|
||||
.wait_for_connection(Duration::from_secs(30))
|
||||
nostr_client_clone
|
||||
.connect()
|
||||
.and_wait(Duration::from_secs(30))
|
||||
.await;
|
||||
let relays = client_clone.relays().await;
|
||||
let relays = nostr_client_clone.relays().await;
|
||||
if relays.is_empty() {
|
||||
log::info!("No more relays left, we probably shut down. Exiting...");
|
||||
break;
|
||||
|
|
@ -67,7 +79,7 @@ pub async fn run_nwc(
|
|||
if relay.status() == RelayStatus::Connected {
|
||||
connected = true;
|
||||
} else {
|
||||
log::info!("Could not connect to {}", url)
|
||||
log::info!("Could not connect to {url}");
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
|
|
@ -77,90 +89,101 @@ pub async fn run_nwc(
|
|||
}
|
||||
|
||||
if let Err(e) = send_nwc_info_event(
|
||||
client_clone.clone(),
|
||||
plugin_clone.option(&OPT_NOTIFICATIONS).unwrap(),
|
||||
capabilities.clone(),
|
||||
wallet_keys.clone(),
|
||||
plugin_clone.clone(),
|
||||
nostr_client_clone.clone(),
|
||||
method_capabilities.clone(),
|
||||
wallet_keys_clone.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("{}", e);
|
||||
client_clone.disconnect().await;
|
||||
log::warn!("{e}");
|
||||
nostr_client_clone.disconnect().await;
|
||||
time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::WalletConnectRequest)
|
||||
.author(client_pubkey);
|
||||
.author(client_keys_clone.public_key());
|
||||
|
||||
if let Err(e) = client_clone.subscribe(filter, None).await {
|
||||
log::warn!("Could not subscribe to nwc events! {}", e);
|
||||
client_clone.disconnect().await;
|
||||
time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
};
|
||||
let mut notifications = nostr_client_clone.notifications();
|
||||
|
||||
let client_clone_handler = client_clone.clone();
|
||||
match client_clone
|
||||
.handle_notifications(|notification| {
|
||||
let client_clone_handler = client_clone_handler.clone();
|
||||
let plugin_clone = plugin_clone.clone();
|
||||
let label_clone = label_clone.clone();
|
||||
let wallet_keys_clone = wallet_keys.clone();
|
||||
nwc_request_handler(
|
||||
notification,
|
||||
client_clone_handler,
|
||||
plugin_clone,
|
||||
label_clone,
|
||||
wallet_keys_clone,
|
||||
client_pubkey,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("NWC handler for `{}` stopped", label_clone);
|
||||
break;
|
||||
match nostr_client_clone.subscribe(filter).await {
|
||||
Ok(o) => {
|
||||
if o.success.is_empty() {
|
||||
log::warn!("Could not subscribe to any relay!");
|
||||
nostr_client_clone.disconnect().await;
|
||||
time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("NWC handler for `{}` had an error: {}", label_clone, e),
|
||||
};
|
||||
Err(e) => {
|
||||
log::warn!("Error subscribing to relays: {e}");
|
||||
nostr_client_clone.disconnect().await;
|
||||
time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let Err(e) = nwc_request_handler(
|
||||
notification,
|
||||
&nostr_client_clone,
|
||||
&plugin_clone,
|
||||
&label_clone,
|
||||
&wallet_keys_clone,
|
||||
client_keys_clone.public_key(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("NWC handler for `{label_clone}` had an error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
{};
|
||||
}
|
||||
});
|
||||
|
||||
let mut locked_handles = plugin.state().handles.lock().await;
|
||||
locked_handles.insert(
|
||||
label.clone(),
|
||||
(client, Keys::new(nwc_store.uri.secret).public_key()),
|
||||
);
|
||||
let wallet_service = WalletService {
|
||||
client: nostr_client,
|
||||
client_pubkey: client_keys.public_key(),
|
||||
wallet_secret: wallet_keys,
|
||||
};
|
||||
locked_handles.insert(label.clone(), wallet_service);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_nwc_info_event(
|
||||
plugin: Plugin<PluginState>,
|
||||
client: Client,
|
||||
notifications: bool,
|
||||
capabilities: String,
|
||||
wallet_keys: Keys,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let mut info_event_builder = EventBuilder::new(Kind::WalletConnectInfo, capabilities.clone())
|
||||
let mut capabilities = capabilities;
|
||||
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
capabilities.push_str(" notifications");
|
||||
}
|
||||
let mut info_event_builder = EventBuilder::new(Kind::WalletConnectInfo, capabilities)
|
||||
.tag(Tag::parse(vec!["encryption", "nip44_v2 nip04"]).unwrap());
|
||||
|
||||
if notifications {
|
||||
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
let notification_capabilities = build_notifications_vec(&plugin).join(" ");
|
||||
info_event_builder = info_event_builder
|
||||
.tag(Tag::parse(vec!["notifications", "payment_received payment_sent"]).unwrap())
|
||||
.tag(Tag::parse(vec!["notifications", ¬ification_capabilities]).unwrap());
|
||||
}
|
||||
|
||||
let info_event = match info_event_builder.sign_with_keys(&wallet_keys) {
|
||||
let info_event = match info_event_builder.finalize_async(&wallet_keys).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Could not sign info_event! {}", e));
|
||||
return Err(anyhow!("Could not sign info_event! {e}"));
|
||||
}
|
||||
};
|
||||
log::debug!("info_event:{:?}", info_event);
|
||||
log::debug!("info_event:{info_event:?}");
|
||||
let send_result = match client.send_event(&info_event).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Could not send info_event! {}", e));
|
||||
return Err(anyhow!("Could not send info_event! {e}"));
|
||||
}
|
||||
};
|
||||
if send_result.success.is_empty() {
|
||||
|
|
@ -178,306 +201,123 @@ pub async fn send_nwc_info_event(
|
|||
|
||||
pub async fn stop_nwc(plugin: Plugin<PluginState>, label: &String) {
|
||||
let mut locked_handles = plugin.state().handles.lock().await;
|
||||
if let Some((client, _client_pubkey)) = locked_handles.remove(label) {
|
||||
client.shutdown().await;
|
||||
}
|
||||
|
||||
stop_nwc_budget_job(plugin.clone(), label);
|
||||
}
|
||||
|
||||
pub fn start_nwc_budget_job(plugin: Plugin<PluginState>, label: String) {
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
tokio::spawn(budget_task(rx, plugin.clone(), label.clone()));
|
||||
plugin.state().budget_jobs.lock().insert(label, tx);
|
||||
}
|
||||
|
||||
pub fn stop_nwc_budget_job(plugin: Plugin<PluginState>, label: &String) {
|
||||
let mut budget_jobs = plugin.state().budget_jobs.lock();
|
||||
let job = budget_jobs.remove(label);
|
||||
if let Some(j) = job {
|
||||
let _ = j.send(());
|
||||
if let Some(wallet_service) = locked_handles.remove(label) {
|
||||
wallet_service.client.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn nwc_request_handler(
|
||||
notification: RelayPoolNotification,
|
||||
client: client::Client,
|
||||
plugin: Plugin<PluginState>,
|
||||
label: String,
|
||||
wallet_keys: Keys,
|
||||
notification: ClientNotification,
|
||||
nostr_client: &client::Client,
|
||||
plugin: &Plugin<PluginState>,
|
||||
label: &str,
|
||||
wallet_keys: &Keys,
|
||||
client_pubkey: PublicKey,
|
||||
) -> Result<bool> {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (relay_url, subscription_id, event) = match notification {
|
||||
RelayPoolNotification::Event {
|
||||
ClientNotification::Event {
|
||||
relay_url,
|
||||
subscription_id,
|
||||
event,
|
||||
} => (relay_url, subscription_id, event),
|
||||
RelayPoolNotification::Message {
|
||||
ClientNotification::Message {
|
||||
relay_url: _,
|
||||
message: _,
|
||||
} => return Ok(false),
|
||||
RelayPoolNotification::Shutdown => return Ok(true),
|
||||
}
|
||||
| ClientNotification::Shutdown => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(expi) = event.tags.expiration() {
|
||||
if *expi < Timestamp::now() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
log::debug!(
|
||||
"relay_url:{} 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);
|
||||
}
|
||||
};
|
||||
log::trace!("relay_url:{relay_url} subscription_id:{subscription_id} {event:?}");
|
||||
let mut use_nip44 = check_nip44_support(&event);
|
||||
|
||||
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(), &label).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 request = decrypt_request(&event.content, wallet_keys, &client_pubkey, &mut use_nip44)?;
|
||||
|
||||
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,
|
||||
let responses = if event.tags.expiration().is_some()
|
||||
&& event.tags.expiration().unwrap() < Timestamp::now()
|
||||
{
|
||||
vec![(
|
||||
nip47::Response {
|
||||
result_type: request.method,
|
||||
error: Some(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "Event expired".to_owned(),
|
||||
}),
|
||||
vec![id],
|
||||
));
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
)]
|
||||
} else if event.created_at.as_secs() < (Timestamp::now().as_secs() - ID_MAX_AGE) {
|
||||
vec![(
|
||||
nip47::Response {
|
||||
result_type: request.method,
|
||||
error: Some(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "Event created too far in the past".to_owned(),
|
||||
}),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
)]
|
||||
} else {
|
||||
{
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
save_event_id(&mut rpc, event.id.to_hex(), event.created_at).await?;
|
||||
}
|
||||
let response_event = match response_builder.sign_with_keys(&wallet_keys) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("Error signing reponse event! {}", e);
|
||||
continue;
|
||||
|
||||
match request.params {
|
||||
nip47::RequestParams::PayInvoice(pay_invoice_request) => {
|
||||
pay_invoice_response(plugin.clone(), pay_invoice_request, label).await
|
||||
}
|
||||
};
|
||||
let send_result = match client.send_event(&response_event).await {
|
||||
nip47::RequestParams::PayKeysend(pay_keysend_request) => {
|
||||
pay_keysend_response(plugin.clone(), pay_keysend_request, label).await
|
||||
}
|
||||
nip47::RequestParams::MakeInvoice(make_invoice_request) => {
|
||||
make_invoice_response(plugin.clone(), make_invoice_request).await
|
||||
}
|
||||
nip47::RequestParams::LookupInvoice(lookup_invoice_request) => {
|
||||
lookup_invoice_response(plugin.clone(), lookup_invoice_request).await
|
||||
}
|
||||
nip47::RequestParams::ListTransactions(list_transactions_request) => {
|
||||
list_transactions_response(plugin.clone(), list_transactions_request).await
|
||||
}
|
||||
nip47::RequestParams::GetBalance => get_balance_response(plugin.clone(), label).await,
|
||||
nip47::RequestParams::GetInfo => get_info_response(plugin.clone(), label).await,
|
||||
nip47::RequestParams::MakeHoldInvoice(make_hold_invoice_request) => {
|
||||
make_hold_invoice_response(plugin.clone(), make_hold_invoice_request).await
|
||||
}
|
||||
nip47::RequestParams::CancelHoldInvoice(cancel_hold_invoice_request) => {
|
||||
cancel_hold_invoice_response(plugin.clone(), cancel_hold_invoice_request).await
|
||||
}
|
||||
nip47::RequestParams::SettleHoldInvoice(settle_hold_invoice_request) => {
|
||||
settle_hold_invoice_response(plugin.clone(), settle_hold_invoice_request).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (response, id) in responses {
|
||||
let content =
|
||||
match encrypt_response_content(&response, wallet_keys, &client_pubkey, use_nip44) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("{e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response_event =
|
||||
match build_response_event(event.id, content, wallet_keys, client_pubkey, id).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("Error signing reponse event! {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let send_result = match nostr_client.send_event(&response_event).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("Error sending response event! {}", e);
|
||||
log::warn!("Error sending response event! {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
|
@ -492,8 +332,129 @@ async fn nwc_request_handler(
|
|||
);
|
||||
continue;
|
||||
}
|
||||
log::debug!("SENT RESPONSE {:?}", response_event);
|
||||
for success in send_result.success {
|
||||
log::trace!("Sent to {}", success.0);
|
||||
}
|
||||
for failure in send_result.failed {
|
||||
log::trace!("Failed to send to {}: {}", failure.0, failure.1);
|
||||
}
|
||||
log::trace!("SENT RESPONSE {response_event:?}");
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_nip44_support(event: &Event) -> bool {
|
||||
for tag in event.tags.iter() {
|
||||
if tag.kind() == "encryption" {
|
||||
if let Some(enc_tag_content) = tag.content() {
|
||||
if enc_tag_content.contains("nip44_v2") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn decrypt_request(
|
||||
event_content: &str,
|
||||
wallet_keys: &Keys,
|
||||
client_pubkey: &PublicKey,
|
||||
use_nip44: &mut bool,
|
||||
) -> Result<nip47::Request, anyhow::Error> {
|
||||
let content = if *use_nip44 {
|
||||
match nip44::decrypt(wallet_keys.secret_key(), client_pubkey, event_content) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::debug!("Could not decrypt using NIP-44:{e}. Trying NIP-04");
|
||||
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 Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match nip04::decrypt(wallet_keys.secret_key(), client_pubkey, event_content) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::debug!("Could not decrypt using NIP-04:{e}. Trying NIP-44");
|
||||
match nip44::decrypt(wallet_keys.secret_key(), client_pubkey, event_content) {
|
||||
Ok(o) => {
|
||||
*use_nip44 = true;
|
||||
o
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not decrypt using NIP-04 or NIP-44:{e}");
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
log::trace!("Decrypted (nip44_v2:{use_nip44}):{content}");
|
||||
let request: nip47::Request = match serde_json::from_str(&content) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("Error parsing nip47::Request! {e}");
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn encrypt_response_content(
|
||||
response: &nip47::Response,
|
||||
wallet_keys: &Keys,
|
||||
client_pubkey: &PublicKey,
|
||||
use_nip44: bool,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let response_str = match serde_json::to_string(&response) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Error serializing response! {e}"));
|
||||
}
|
||||
};
|
||||
log::trace!("RESPONSE:{response_str}");
|
||||
if use_nip44 {
|
||||
match nip44::encrypt(
|
||||
wallet_keys.secret_key(),
|
||||
client_pubkey,
|
||||
response_str,
|
||||
nip44::Version::V2,
|
||||
) {
|
||||
Ok(o) => Ok(o),
|
||||
Err(e) => Err(anyhow!("Error encrypting response with nip44! {e}")),
|
||||
}
|
||||
} else {
|
||||
match nip04::encrypt(wallet_keys.secret_key(), client_pubkey, response_str) {
|
||||
Ok(o) => Ok(o),
|
||||
Err(e) => Err(anyhow!("Error encrypting response with nip04! {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_response_event(
|
||||
event_id: EventId,
|
||||
content: String,
|
||||
wallet_keys: &Keys,
|
||||
client_pubkey: PublicKey,
|
||||
id: Option<String>,
|
||||
) -> Result<Event, anyhow::Error> {
|
||||
let mut response_builder = EventBuilder::new(Kind::WalletConnectResponse, content)
|
||||
.tag(Tag::event(event_id))
|
||||
.tag(Tag::public_key(client_pubkey));
|
||||
if let Some(i) = id {
|
||||
response_builder = response_builder.tag(Tag::identifier(i));
|
||||
}
|
||||
match response_builder.finalize_async(wallet_keys).await {
|
||||
Ok(o) => Ok(o),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,38 @@
|
|||
use cln_plugin::Plugin;
|
||||
use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState};
|
||||
use nostr_sdk::nips::*;
|
||||
use nostr::nips::nip47;
|
||||
|
||||
use crate::{structs::PluginState, util::load_nwc_store};
|
||||
use crate::{
|
||||
structs::PluginState,
|
||||
util::{get_budget_msat, load_nwc_store},
|
||||
};
|
||||
|
||||
pub async fn get_balance(
|
||||
pub async fn get_balance_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
label: &String,
|
||||
label: &str,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match get_balance(plugin, label).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::GetBalance,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::GetBalance(o)),
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::GetBalance,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
),
|
||||
}]
|
||||
}
|
||||
async fn get_balance(
|
||||
plugin: Plugin<PluginState>,
|
||||
label: &str,
|
||||
) -> Result<nip47::GetBalanceResponse, nip47::NIP47Error> {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
|
|
@ -17,11 +43,15 @@ pub async fn get_balance(
|
|||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let balance = if let Some(bdgt_amt) = nwc_store.budget_msat {
|
||||
let balance = if let Some(bdgt_amt) = get_budget_msat(&nwc_store) {
|
||||
bdgt_amt
|
||||
} else {
|
||||
let listpeerchannels = rpc
|
||||
.call_typed(&ListpeerchannelsRequest { id: None })
|
||||
.call_typed(&ListpeerchannelsRequest {
|
||||
id: None,
|
||||
short_channel_id: None,
|
||||
channel_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
|
|
@ -34,7 +64,7 @@ pub async fn get_balance(
|
|||
|| chan.state == ChannelState::CHANNELD_AWAITING_SPLICE
|
||||
{
|
||||
if let Some(spend) = chan.spendable_msat {
|
||||
amount_msat += spend.msat()
|
||||
amount_msat += spend.msat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
248
src/nwc_hold.rs
Normal file
248
src/nwc_hold.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::primitives::Sha256;
|
||||
use nostr::{nips::nip47, types::Timestamp};
|
||||
|
||||
use crate::{
|
||||
hold::{CancelRequest, InvoiceRequest, SettleRequest, invoice_request::Description},
|
||||
nwc_notifications::holdinvoice_accepted_handler,
|
||||
structs::PluginState,
|
||||
};
|
||||
|
||||
pub async fn make_hold_invoice_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::MakeHoldInvoiceRequest,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match make_hold_invoice(plugin, params).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::MakeHoldInvoice,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::MakeHoldInvoice(o)),
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::MakeHoldInvoice,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn make_hold_invoice(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::MakeHoldInvoiceRequest,
|
||||
) -> Result<nip47::MakeHoldInvoiceResponse, nip47::NIP47Error> {
|
||||
let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::NotImplemented,
|
||||
message: "No hold plugin found".to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
let description: Option<Description> = if let Some(d_hash) = ¶ms.description_hash {
|
||||
if let Some(description) = ¶ms.description {
|
||||
let my_description_hash = Sha256::const_hash(description.as_bytes());
|
||||
let description_hash = Sha256::from_str(d_hash).map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
if my_description_hash != description_hash {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "description_hash not matching description".to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let desc_hash_bytes = match hex::decode(d_hash) {
|
||||
Ok(p) => p,
|
||||
Err(_e) => {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "Could not convert description hash to bytes".to_owned(),
|
||||
});
|
||||
}
|
||||
};
|
||||
Some(Description::Hash(desc_hash_bytes))
|
||||
} else {
|
||||
params
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|desc| Description::Memo(desc.clone()))
|
||||
};
|
||||
|
||||
let payment_hash = match hex::decode(¶ms.payment_hash) {
|
||||
Ok(p) => p,
|
||||
Err(_e) => {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "Invalid payment hash".to_owned(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let expiry = params.expiry.unwrap_or(60 * 60);
|
||||
|
||||
let holdinvoice_request = InvoiceRequest {
|
||||
payment_hash: payment_hash.clone(),
|
||||
amount_msat: params.amount,
|
||||
expiry: Some(expiry),
|
||||
min_final_cltv_expiry: params.min_cltv_expiry_delta.map(u64::from),
|
||||
routing_hints: Vec::new(),
|
||||
description,
|
||||
};
|
||||
|
||||
let holdinvoice = hold_client
|
||||
.invoice(holdinvoice_request)
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: format!("Error creating hold invoice: {e}"),
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
let expires_at = Timestamp::now() + expiry;
|
||||
|
||||
let response = nip47::MakeHoldInvoiceResponse {
|
||||
invoice: Some(holdinvoice.bolt11),
|
||||
transaction_type: nip47::TransactionType::Incoming,
|
||||
description: params.description,
|
||||
description_hash: params.description_hash,
|
||||
amount: params.amount,
|
||||
created_at: Timestamp::now(),
|
||||
expires_at,
|
||||
metadata: None,
|
||||
payment_hash: params.payment_hash,
|
||||
};
|
||||
|
||||
tokio::spawn(holdinvoice_accepted_handler(
|
||||
plugin,
|
||||
payment_hash,
|
||||
expires_at.as_secs(),
|
||||
));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn cancel_hold_invoice_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::CancelHoldInvoiceRequest,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match cancel_hold_invoice(plugin, params).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::CancelHoldInvoice,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::CancelHoldInvoice(o)),
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::CancelHoldInvoice,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn cancel_hold_invoice(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::CancelHoldInvoiceRequest,
|
||||
) -> Result<nip47::CancelHoldInvoiceResponse, nip47::NIP47Error> {
|
||||
let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::NotImplemented,
|
||||
message: "No hold plugin found".to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
let payment_hash = match hex::decode(¶ms.payment_hash) {
|
||||
Ok(p) => p,
|
||||
Err(_e) => {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "Invalid payment hash".to_owned(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let hold_cancel_request = CancelRequest { payment_hash };
|
||||
|
||||
let hold_cancel_response = hold_client.cancel(hold_cancel_request).await;
|
||||
|
||||
match hold_cancel_response {
|
||||
Ok(_o) => Ok(nip47::CancelHoldInvoiceResponse {}),
|
||||
Err(e) => Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn settle_hold_invoice_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::SettleHoldInvoiceRequest,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match settle_hold_invoice(plugin, params).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::SettleHoldInvoice,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::SettleHoldInvoice(o)),
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::SettleHoldInvoice,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn settle_hold_invoice(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::SettleHoldInvoiceRequest,
|
||||
) -> Result<nip47::SettleHoldInvoiceResponse, nip47::NIP47Error> {
|
||||
let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::NotImplemented,
|
||||
message: "No hold plugin found".to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
let preimage = match hex::decode(¶ms.preimage) {
|
||||
Ok(p) => p,
|
||||
Err(_e) => {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: "Invalid preimage".to_owned(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let hold_settle_request = SettleRequest {
|
||||
payment_preimage: preimage,
|
||||
};
|
||||
|
||||
let hold_settle_response = hold_client.settle(hold_settle_request).await;
|
||||
|
||||
match hold_settle_response {
|
||||
Ok(_o) => Ok(nip47::SettleHoldInvoiceResponse {}),
|
||||
Err(e) => Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,39 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::model::requests::GetinfoRequest;
|
||||
use nostr_sdk::nips::*;
|
||||
use nostr_sdk::*;
|
||||
use nostr::nips::nip47;
|
||||
|
||||
use crate::structs::PluginState;
|
||||
use crate::util::{is_read_only_nwc, load_nwc_store};
|
||||
use crate::{OPT_NOTIFICATIONS, WALLET_ALL_METHODS, WALLET_READ_METHODS};
|
||||
use crate::{
|
||||
structs::PluginState,
|
||||
util::{build_methods_vec, build_notifications_vec, is_read_only_nwc, load_nwc_store},
|
||||
};
|
||||
|
||||
pub async fn get_info(
|
||||
pub async fn get_info_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
label: &String,
|
||||
label: &str,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match get_info(plugin, label).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::GetInfo,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::GetInfo(o)),
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::GetInfo,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn get_info(
|
||||
plugin: Plugin<PluginState>,
|
||||
label: &str,
|
||||
) -> Result<nip47::GetInfoResponse, nip47::NIP47Error> {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
|
|
@ -23,23 +45,12 @@ pub async fn get_info(
|
|||
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 pubkey = get_info.id.to_string();
|
||||
|
||||
let network = match get_info.network.as_str() {
|
||||
"bitcoin" => "mainnet".to_owned(),
|
||||
_ => get_info.network,
|
||||
};
|
||||
let notifications = if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
vec!["payment_received".to_owned(), "payment_sent".to_owned()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let nwc_store = load_nwc_store(&mut rpc, label)
|
||||
.await
|
||||
|
|
@ -48,20 +59,12 @@ pub async fn get_info(
|
|||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let methods = if is_read_only_nwc(&nwc_store) {
|
||||
WALLET_READ_METHODS
|
||||
.into_iter()
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
} else {
|
||||
WALLET_ALL_METHODS
|
||||
.into_iter()
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
};
|
||||
let notifications = build_notifications_vec(&plugin);
|
||||
|
||||
let methods = build_methods_vec(is_read_only_nwc(&nwc_store), &plugin);
|
||||
|
||||
Ok(nip47::GetInfoResponse {
|
||||
alias: get_info.alias,
|
||||
alias: Some(get_info.alias),
|
||||
color: Some(get_info.color),
|
||||
pubkey: Some(pubkey),
|
||||
network: Some(network),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,36 @@ use cln_rpc::{
|
|||
model::requests::InvoiceRequest,
|
||||
primitives::{Amount, AmountOrAny, Sha256},
|
||||
};
|
||||
use nostr_sdk::nips::*;
|
||||
use nostr::{nips::nip47, types::Timestamp};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::structs::PluginState;
|
||||
|
||||
pub async fn make_invoice(
|
||||
pub async fn make_invoice_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::MakeInvoiceRequest,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match make_invoice(plugin, params).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::MakeInvoice,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::MakeInvoice(o)),
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::MakeInvoice,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
None,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn make_invoice(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::MakeInvoiceRequest,
|
||||
) -> Result<nip47::MakeInvoiceResponse, nip47::NIP47Error> {
|
||||
|
|
@ -18,7 +42,7 @@ pub async fn make_invoice(
|
|||
|
||||
let mut deschashonly = None;
|
||||
|
||||
if let Some(d_hash) = params.description_hash {
|
||||
if let Some(d_hash) = ¶ms.description_hash {
|
||||
if params.description.is_none() {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
|
|
@ -27,7 +51,7 @@ pub async fn make_invoice(
|
|||
}
|
||||
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 {
|
||||
let description_hash = Sha256::from_str(d_hash).map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
|
@ -37,9 +61,15 @@ pub async fn make_invoice(
|
|||
message: "description_hash not matching description".to_owned(),
|
||||
});
|
||||
}
|
||||
deschashonly = Some(true)
|
||||
deschashonly = Some(true);
|
||||
}
|
||||
|
||||
let amount_msat = if params.amount == 0 {
|
||||
AmountOrAny::Any
|
||||
} else {
|
||||
AmountOrAny::Amount(Amount::from_msat(params.amount))
|
||||
};
|
||||
|
||||
match rpc
|
||||
.call_typed(&InvoiceRequest {
|
||||
cltv: None,
|
||||
|
|
@ -48,15 +78,24 @@ pub async fn make_invoice(
|
|||
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()),
|
||||
amount_msat,
|
||||
description: params
|
||||
.description
|
||||
.clone()
|
||||
.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(),
|
||||
payment_hash: Some(o.payment_hash.to_string()),
|
||||
description: params.description,
|
||||
description_hash: params.description_hash,
|
||||
preimage: None,
|
||||
amount: Some(params.amount),
|
||||
created_at: Some(Timestamp::now()),
|
||||
expires_at: Some(Timestamp::from_secs(o.expires_at)),
|
||||
}),
|
||||
Err(e) => Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,66 @@
|
|||
use std::{str::FromStr, time::Duration};
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::{
|
||||
model::requests::KeysendRequest,
|
||||
primitives::{Amount, PublicKey, TlvEntry, TlvStream},
|
||||
ClnRpc,
|
||||
RpcError,
|
||||
model::requests::{KeysendRequest, XkeysendRequest},
|
||||
primitives::{Amount, PublicKey, Secret, TlvEntry, TlvStream},
|
||||
};
|
||||
use nostr_sdk::nips::*;
|
||||
use tokio::time;
|
||||
use nostr::nips::nip47::{self};
|
||||
|
||||
use crate::{
|
||||
structs::PluginState,
|
||||
util::{budget_amount_check, load_nwc_store, update_nwc_store},
|
||||
util::{
|
||||
budget_amount_check,
|
||||
get_budget_msat,
|
||||
load_nwc_store,
|
||||
payment_fee_reserve_msat,
|
||||
refund_budget,
|
||||
reserve_budget,
|
||||
rpc_socket_path,
|
||||
settle_budget,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn pay_keysend(
|
||||
pub const XKEYSEND_COMMAND: &str = "xkeysend";
|
||||
|
||||
pub async fn pay_keysend_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::PayKeysendRequest,
|
||||
label: &String,
|
||||
) -> Result<nip47::PayKeysendResponse, nip47::NIP47Error> {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
label: &str,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
let id = if let Some(i) = params.id.clone() {
|
||||
i
|
||||
} else {
|
||||
params.pubkey.clone()
|
||||
};
|
||||
|
||||
vec![match pay_keysend(plugin, params, label).await {
|
||||
Ok(o) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::PayKeysend,
|
||||
error: None,
|
||||
result: Some(nip47::ResponseResult::PayKeysend(o)),
|
||||
},
|
||||
Some(id),
|
||||
),
|
||||
Err(e) => (
|
||||
nip47::Response {
|
||||
result_type: nip47::Method::PayKeysend,
|
||||
error: Some(e),
|
||||
result: None,
|
||||
},
|
||||
Some(id),
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn pay_keysend(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::PayKeysendRequest,
|
||||
label: &str,
|
||||
) -> Result<nip47::PayKeysendResponse, nip47::NIP47Error> {
|
||||
if params.preimage.is_some() {
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
|
|
@ -27,32 +68,142 @@ pub async fn pay_keysend(
|
|||
});
|
||||
}
|
||||
|
||||
let mut nwc_store = load_nwc_store(&mut rpc, label)
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
budget_amount_check(Some(params.amount), None, nwc_store.budget_msat).map_err(|e| {
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::QuotaExceeded,
|
||||
message: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let pubkey = PublicKey::from_str(¶ms.pubkey).map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let reservation = {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
let nwc_store = load_nwc_store(&mut rpc, label)
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
budget_amount_check(Some(params.amount), None, get_budget_msat(&nwc_store)).map_err(
|
||||
|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::QuotaExceeded,
|
||||
message: e.to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
// Reserve amount plus worst case fee so concurrent payments can never
|
||||
// exceed the budget.
|
||||
if get_budget_msat(&nwc_store).unwrap_or(u64::MAX)
|
||||
< params
|
||||
.amount
|
||||
.saturating_add(payment_fee_reserve_msat(params.amount))
|
||||
{
|
||||
return Err(nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::QuotaExceeded,
|
||||
message: "Payment and estimated fees exceed the available budget".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
reserve_budget(&mut rpc, label, &nwc_store, params.amount)
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
let has_xkeysend = plugin.state().config.lock().has_xkeysend;
|
||||
let mut pay_rpc =
|
||||
ClnRpc::new(rpc_socket_path(&plugin))
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: format!("Could not connect to lightningd: {e}"),
|
||||
})?;
|
||||
|
||||
let pay_result = if has_xkeysend {
|
||||
xkeysend(&mut pay_rpc, ¶ms, pubkey).await
|
||||
} else {
|
||||
keysend(&mut pay_rpc, ¶ms, pubkey).await
|
||||
};
|
||||
|
||||
match pay_result {
|
||||
Ok((amount_sent_msat, amount_msat, preimage)) => {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
if let Err(e) = settle_budget(&mut rpc, label, reservation, amount_sent_msat).await {
|
||||
log::error!("Error updating budget after successful keysend: {e}");
|
||||
}
|
||||
|
||||
let preimage = hex::encode(preimage.to_vec());
|
||||
let fees_paid = amount_sent_msat.saturating_sub(amount_msat);
|
||||
|
||||
Ok(nip47::PayKeysendResponse {
|
||||
preimage,
|
||||
fees_paid: Some(fees_paid),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
if let Err(refund_err) = refund_budget(&mut rpc, label, reservation).await {
|
||||
log::error!(
|
||||
"Error refunding budget reservation after failed keysend: {refund_err}"
|
||||
);
|
||||
}
|
||||
Err(map_keysend_error(&e, has_xkeysend))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn xkeysend(
|
||||
pay_rpc: &mut ClnRpc,
|
||||
params: &nip47::PayKeysendRequest,
|
||||
pubkey: PublicKey,
|
||||
) -> Result<(u64, u64, Secret), RpcError> {
|
||||
let mut extratlvs = HashMap::with_capacity(params.tlv_records.len());
|
||||
for tlv in ¶ms.tlv_records {
|
||||
extratlvs.insert(tlv.tlv_type.to_string(), tlv.value.clone());
|
||||
}
|
||||
let extratlvs = if extratlvs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(extratlvs)
|
||||
};
|
||||
|
||||
let o = pay_rpc
|
||||
.call_typed(&XkeysendRequest {
|
||||
extratlvs,
|
||||
label: None,
|
||||
maxdelay: None,
|
||||
maxfee: None,
|
||||
retry_for: None,
|
||||
layers: None,
|
||||
amount_msat: Amount::from_msat(params.amount),
|
||||
destination: pubkey,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
o.amount_sent_msat.msat(),
|
||||
o.amount_msat.msat(),
|
||||
o.payment_preimage,
|
||||
))
|
||||
}
|
||||
|
||||
async fn keysend(
|
||||
pay_rpc: &mut ClnRpc,
|
||||
params: &nip47::PayKeysendRequest,
|
||||
pubkey: PublicKey,
|
||||
) -> Result<(u64, u64, Secret), RpcError> {
|
||||
let mut extratlvs = TlvStream {
|
||||
entries: Vec::new(),
|
||||
};
|
||||
for tlv in params.tlv_records {
|
||||
for tlv in ¶ms.tlv_records {
|
||||
extratlvs.entries.push(TlvEntry {
|
||||
typ: tlv.tlv_type,
|
||||
value: tlv.value.as_bytes().to_owned(),
|
||||
value: hex::decode(&tlv.value).map_err(|e| RpcError {
|
||||
code: Some(-32700),
|
||||
message: format!("Could not decode tlv bytes: {e}"),
|
||||
data: None,
|
||||
})?,
|
||||
});
|
||||
}
|
||||
let extratlvs = if extratlvs.entries.is_empty() {
|
||||
|
|
@ -61,7 +212,7 @@ pub async fn pay_keysend(
|
|||
Some(extratlvs)
|
||||
};
|
||||
|
||||
match rpc
|
||||
let o = pay_rpc
|
||||
.call_typed(&KeysendRequest {
|
||||
exemptfee: None,
|
||||
extratlvs,
|
||||
|
|
@ -74,74 +225,48 @@ pub async fn pay_keysend(
|
|||
amount_msat: Amount::from_msat(params.amount),
|
||||
destination: pubkey,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(o) => {
|
||||
if let Some(ref mut bdg) = nwc_store.budget_msat {
|
||||
*bdg = bdg.saturating_sub(o.amount_sent_msat.msat());
|
||||
update_nwc_store(&mut rpc, label, nwc_store)
|
||||
.await
|
||||
.map_err(|e| nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
.await?;
|
||||
|
||||
let preimage = hex::encode(o.payment_preimage.to_vec());
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
Ok((
|
||||
o.amount_sent_msat.msat(),
|
||||
o.amount_msat.msat(),
|
||||
o.payment_preimage,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn multi_pay_keysend(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::MultiPayKeysendRequest,
|
||||
label: &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,
|
||||
),
|
||||
fn map_keysend_error(e: &RpcError, is_xkeysend: bool) -> nip47::NIP47Error {
|
||||
let Some(c) = e.code else {
|
||||
return nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
};
|
||||
responses.push(response_res);
|
||||
time::sleep(Duration::from_millis(100)).await;
|
||||
};
|
||||
|
||||
let failed_codes = if is_xkeysend {
|
||||
vec![203, 205, 207, 219]
|
||||
} else {
|
||||
vec![203, 205, 210]
|
||||
};
|
||||
|
||||
if failed_codes.contains(&c) {
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::PaymentFailed,
|
||||
message: e.to_string(),
|
||||
}
|
||||
} else if is_xkeysend && c == 209 {
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: e.to_string(),
|
||||
}
|
||||
} else if !is_xkeysend && c == 206 {
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::InsufficientBalance,
|
||||
message: e.to_string(),
|
||||
}
|
||||
} else {
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
responses
|
||||
}
|
||||
|
|
|
|||
1357
src/nwc_lookups.rs
1357
src/nwc_lookups.rs
File diff suppressed because it is too large
Load diff
|
|
@ -2,15 +2,38 @@ use std::str::FromStr;
|
|||
|
||||
use anyhow::anyhow;
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::model::requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest};
|
||||
use cln_rpc::model::responses::ListpaysPaysStatus;
|
||||
use cln_rpc::primitives::Sha256;
|
||||
use cln_rpc::{
|
||||
ClnRpc,
|
||||
Notification,
|
||||
model::{
|
||||
requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest, ListpeerchannelsRequest},
|
||||
responses::{
|
||||
DecodeResponse,
|
||||
ListinvoicesInvoices,
|
||||
ListinvoicesInvoicesStatus,
|
||||
ListpaysPays,
|
||||
ListpaysPaysStatus,
|
||||
},
|
||||
},
|
||||
notifications::{InvoicePaymentNotification, SendPaySuccessNotification},
|
||||
primitives::Sha256,
|
||||
};
|
||||
use nostr::{
|
||||
event::{EventBuilder, FinalizeEventAsync, Kind, Tag},
|
||||
nips::{nip04, nip44, nip47},
|
||||
types::Timestamp,
|
||||
};
|
||||
|
||||
use crate::structs::PluginState;
|
||||
use crate::OPT_NOTIFICATIONS;
|
||||
use crate::{
|
||||
OPT_NOTIFICATIONS,
|
||||
hold::{InvoiceState, ListRequest, TrackRequest, list_request::Constraint},
|
||||
structs::{NOT_INV_ERR, PluginState, WalletService},
|
||||
};
|
||||
|
||||
use nostr_sdk::nips::*;
|
||||
use nostr_sdk::*;
|
||||
/// Upper bound on how long a `holdinvoice_accepted_handler` waits for an
|
||||
/// invoice to be accepted, so a client cannot pin a task and a gRPC stream
|
||||
/// open forever with an absurd expiry.
|
||||
const MAX_HOLD_WAIT_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
pub async fn payment_received_handler(
|
||||
plugin: Plugin<PluginState>,
|
||||
|
|
@ -19,13 +42,12 @@ pub async fn payment_received_handler(
|
|||
if !plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
return Ok(());
|
||||
}
|
||||
let label = args
|
||||
.get("invoice_payment")
|
||||
.ok_or_else(|| anyhow!("Malformed invoice_payment notification: missing invoice_payment"))?
|
||||
.get("label")
|
||||
.ok_or_else(|| anyhow!("Malformed invoice_payment notification: missing label"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("label not a string"))?;
|
||||
|
||||
let notif: Notification = serde_json::from_value(args)?;
|
||||
let inv_pay_notif: InvoicePaymentNotification = match notif {
|
||||
Notification::InvoicePayment(invoice_payment_notification) => invoice_payment_notification,
|
||||
_ => return Err(anyhow!("Wrong notification type, expected invoice_payment")),
|
||||
};
|
||||
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
|
|
@ -33,7 +55,7 @@ pub async fn payment_received_handler(
|
|||
.call_typed(&ListinvoicesRequest {
|
||||
index: None,
|
||||
invstring: None,
|
||||
label: Some(label.to_owned()),
|
||||
label: Some(inv_pay_notif.label),
|
||||
limit: None,
|
||||
offer_id: None,
|
||||
payment_hash: None,
|
||||
|
|
@ -45,19 +67,52 @@ pub async fn payment_received_handler(
|
|||
let invoice = invoice_resp
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("invoice not found"))?;
|
||||
let invstring = if invoice.bolt11.is_some() {
|
||||
invoice.bolt11.as_ref().unwrap()
|
||||
let invstring = if let Some(bolt11) = &invoice.bolt11 {
|
||||
bolt11.clone()
|
||||
} else if let Some(bolt12) = &invoice.bolt12 {
|
||||
bolt12.clone()
|
||||
} else {
|
||||
invoice.bolt12.as_ref().unwrap()
|
||||
return Err(anyhow!(
|
||||
"Listinvoices has neither returned bolt11 or bolt12 field"
|
||||
));
|
||||
};
|
||||
|
||||
let payment_hash_str = hex::encode(invoice.payment_hash);
|
||||
|
||||
let invoice_decoded = rpc
|
||||
.call_typed(&DecodeRequest {
|
||||
string: invstring.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let not_invoice_err = Err(anyhow!("Not an invoice or invalid invoice".to_owned()));
|
||||
if !invoice_decoded.valid {
|
||||
return Err(anyhow!("Invalid invoice decoded for {payment_hash_str}"));
|
||||
}
|
||||
|
||||
let notification =
|
||||
make_payment_received_from_listinvoices(invoice, invstring, invoice_decoded)?;
|
||||
|
||||
let clients = plugin.state().handles.lock().await;
|
||||
|
||||
for wallet_service in clients.values() {
|
||||
if let Err(e) = send_notification(¬ification, wallet_service).await {
|
||||
log::warn!(
|
||||
"Failed sending payment_received notification for {payment_hash_str} \
|
||||
to client {}: {e}",
|
||||
wallet_service.client_pubkey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_payment_received_from_listinvoices(
|
||||
invoice: &ListinvoicesInvoices,
|
||||
invstring: String,
|
||||
invoice_decoded: DecodeResponse,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let not_invoice_err = Err(anyhow!(NOT_INV_ERR.to_owned()));
|
||||
|
||||
if !invoice_decoded.valid {
|
||||
return not_invoice_err;
|
||||
|
|
@ -112,67 +167,34 @@ pub async fn payment_received_handler(
|
|||
.ok_or_else(|| anyhow!("paid invoice missing paid_at time"))?,
|
||||
);
|
||||
|
||||
let clients = plugin.state().handles.lock().await;
|
||||
let state = match invoice.status {
|
||||
ListinvoicesInvoicesStatus::UNPAID => nip47::TransactionState::Pending,
|
||||
ListinvoicesInvoicesStatus::PAID => nip47::TransactionState::Settled,
|
||||
ListinvoicesInvoicesStatus::EXPIRED => nip47::TransactionState::Expired,
|
||||
};
|
||||
|
||||
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 = nip47::Notification {
|
||||
notification_type: nip47::NotificationType::PaymentReceived,
|
||||
notification: nip47::NotificationResult::PaymentReceived(nip47::PaymentNotification {
|
||||
transaction_type: Some(nip47::TransactionType::Incoming),
|
||||
invoice: invstring,
|
||||
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,
|
||||
state: Some(state),
|
||||
}),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
let notification = serde_json::to_string(&content)?;
|
||||
|
||||
Ok(())
|
||||
Ok(notification)
|
||||
}
|
||||
|
||||
pub async fn payment_sent_handler(
|
||||
|
|
@ -182,18 +204,19 @@ pub async fn payment_sent_handler(
|
|||
if !plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
return Ok(());
|
||||
}
|
||||
let payment_hash = args
|
||||
.get("sendpay_success")
|
||||
.ok_or_else(|| anyhow!("Malformed sendpay_success notification: missing sendpay_success"))?
|
||||
.get("payment_hash")
|
||||
.ok_or_else(|| anyhow!("Malformed sendpay_success notification: missing payment_hash"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("payment_hash not a string"))?
|
||||
.to_owned();
|
||||
|
||||
let notif: Notification = serde_json::from_value(args)?;
|
||||
let send_pay_notif: SendPaySuccessNotification = match notif {
|
||||
Notification::SendPaySuccess(send_pay_success_notification) => {
|
||||
send_pay_success_notification
|
||||
}
|
||||
_ => return Err(anyhow!("Wrong notification type, expected sendpay_success")),
|
||||
};
|
||||
let payment_hash = hex::encode(send_pay_notif.payment_hash);
|
||||
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
let pays_resp = rpc
|
||||
let mut pays_resp = rpc
|
||||
.call_typed(&ListpaysRequest {
|
||||
bolt11: None,
|
||||
index: None,
|
||||
|
|
@ -205,14 +228,33 @@ pub async fn payment_sent_handler(
|
|||
.await?
|
||||
.pays;
|
||||
|
||||
pays_resp.retain(|p| p.status == ListpaysPaysStatus::COMPLETE);
|
||||
|
||||
let pay = pays_resp
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("payment not found"))?;
|
||||
.ok_or_else(|| anyhow!("complete payment not found"))?;
|
||||
|
||||
if pay.status != ListpaysPaysStatus::COMPLETE {
|
||||
return Err(anyhow!("Payment not complete"));
|
||||
let notification = make_payment_sent_from_listpays(pay, &mut rpc).await?;
|
||||
|
||||
let clients = plugin.state().handles.lock().await;
|
||||
|
||||
for wallet_service in clients.values() {
|
||||
if let Err(e) = send_notification(¬ification, wallet_service).await {
|
||||
log::warn!(
|
||||
"Failed sending payment_sent notification for {payment_hash} to\
|
||||
client {}: {e}",
|
||||
wallet_service.client_pubkey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn make_payment_sent_from_listpays(
|
||||
pay: &ListpaysPays,
|
||||
rpc: &mut ClnRpc,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let invstring = if let Some(b11) = &pay.bolt11 {
|
||||
b11
|
||||
} else if let Some(b12) = &pay.bolt12 {
|
||||
|
|
@ -232,14 +274,23 @@ pub async fn payment_sent_handler(
|
|||
);
|
||||
let settled_at = Timestamp::from_secs(pay.completed_at.unwrap());
|
||||
|
||||
if !invstring.is_empty() {
|
||||
if invstring.is_empty() {
|
||||
description = pay.description.clone();
|
||||
description_hash = None;
|
||||
amount = if let Some(amt) = pay.amount_msat {
|
||||
amt.msat()
|
||||
} else {
|
||||
// Amount missing but required
|
||||
0
|
||||
}
|
||||
} else {
|
||||
let invoice_decoded = rpc
|
||||
.call_typed(&DecodeRequest {
|
||||
string: invstring.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let not_invoice_err = Err(anyhow!("Not an invoice".to_owned()));
|
||||
let not_invoice_err = Err(anyhow!(NOT_INV_ERR.to_owned()));
|
||||
|
||||
if !invoice_decoded.valid {
|
||||
return not_invoice_err;
|
||||
|
|
@ -275,78 +326,263 @@ pub async fn payment_sent_handler(
|
|||
}
|
||||
_ => return not_invoice_err,
|
||||
};
|
||||
}
|
||||
|
||||
let fees_paid = if let Some(amt_sent) = pay.amount_sent_msat {
|
||||
amt_sent.msat() - amount
|
||||
} else {
|
||||
description = pay.description.clone();
|
||||
description_hash = None;
|
||||
amount = if let Some(amt) = pay.amount_msat {
|
||||
amt.msat()
|
||||
} else {
|
||||
// Amount missing but required
|
||||
0
|
||||
}
|
||||
0
|
||||
};
|
||||
|
||||
let state = match pay.status {
|
||||
ListpaysPaysStatus::PENDING => nip47::TransactionState::Pending,
|
||||
ListpaysPaysStatus::FAILED => nip47::TransactionState::Failed,
|
||||
ListpaysPaysStatus::COMPLETE => nip47::TransactionState::Settled,
|
||||
};
|
||||
|
||||
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,
|
||||
state: Some(state),
|
||||
}),
|
||||
};
|
||||
|
||||
let notification = serde_json::to_string(&content)?;
|
||||
|
||||
Ok(notification)
|
||||
}
|
||||
|
||||
async fn send_notification(
|
||||
notification: &String,
|
||||
wallet_service: &WalletService,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
log::trace!("NOTIFICATION: {notification}");
|
||||
let content_encrypted_nip04 = nip04::encrypt(
|
||||
wallet_service.wallet_secret.secret_key(),
|
||||
&wallet_service.client_pubkey,
|
||||
notification,
|
||||
)?;
|
||||
let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04)
|
||||
.tag(Tag::public_key(wallet_service.client_pubkey))
|
||||
.finalize_async(&wallet_service.wallet_secret)
|
||||
.await?;
|
||||
let nip04_result = wallet_service.client.send_event(&event_nip04).await?;
|
||||
if nip04_result.success.is_empty() {
|
||||
log::warn!(
|
||||
"None of the relays accepted our nip04 notification: {}",
|
||||
nip04_result
|
||||
.failed
|
||||
.into_values()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
log::trace!("NIP04 NOTIFICATION SENT: {event_nip04:?}");
|
||||
|
||||
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);
|
||||
let content_encrypted_nip44 = nip44::encrypt(
|
||||
wallet_service.wallet_secret.secret_key(),
|
||||
&wallet_service.client_pubkey,
|
||||
notification,
|
||||
nip44::Version::V2,
|
||||
)?;
|
||||
let event_nip44 = EventBuilder::new(Kind::from_u16(23197), content_encrypted_nip44)
|
||||
.tag(Tag::public_key(wallet_service.client_pubkey))
|
||||
.finalize_async(&wallet_service.wallet_secret)
|
||||
.await?;
|
||||
let nip44_result = wallet_service.client.send_event(&event_nip44).await?;
|
||||
if nip44_result.success.is_empty() {
|
||||
log::warn!(
|
||||
"None of the relays accepted our nip44 notification: {}",
|
||||
nip44_result
|
||||
.failed
|
||||
.into_values()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
log::trace!("NIP44 NOTIFICATION SENT: {event_nip44:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn holdinvoice_accepted_handler(
|
||||
plugin: Plugin<PluginState>,
|
||||
payment_hash: Vec<u8>,
|
||||
expires_at: u64,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let payment_hash_str = hex::encode(&payment_hash);
|
||||
|
||||
let mut hold_client = plugin.state().hold_client.lock().clone().unwrap();
|
||||
|
||||
let track_request = TrackRequest {
|
||||
payment_hash: payment_hash.clone(),
|
||||
};
|
||||
let mut track_stream = hold_client.track(track_request).await?.into_inner();
|
||||
|
||||
// The hold plugin has no expired state: an invoice that expires without
|
||||
// being accepted stays in the Unpaid state and the track stream never ends
|
||||
// on its own. Bound the wait by the invoice's expiry (capped) so we do not
|
||||
// hold this task and gRPC stream open forever.
|
||||
let wait_duration = expires_at
|
||||
.saturating_sub(Timestamp::now().as_secs())
|
||||
.min(MAX_HOLD_WAIT_SECS);
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(wait_duration);
|
||||
|
||||
let mut accepted = false;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, track_stream.message()).await {
|
||||
Err(_elapsed) => {
|
||||
log::debug!("Hold invoice {payment_hash_str} expired before being accepted");
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
// The stream ended without an Accepted state: the invoice was
|
||||
// cancelled before it ever got accepted.
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Ok(Some(response))) => {
|
||||
log::debug!("Invoice status: {}", response.state().as_str_name());
|
||||
match response.state() {
|
||||
InvoiceState::Accepted => {
|
||||
accepted = true;
|
||||
break;
|
||||
}
|
||||
InvoiceState::Paid | InvoiceState::Cancelled => break,
|
||||
InvoiceState::Unpaid => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !accepted {
|
||||
log::debug!("Hold invoice {payment_hash_str} was not accepted, skipping notification");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let list_request = ListRequest {
|
||||
constraint: Some(Constraint::PaymentHash(payment_hash.clone())),
|
||||
};
|
||||
|
||||
let hold_lookup = hold_client.list(list_request).await?.into_inner();
|
||||
|
||||
if hold_lookup.invoices.len() != 1 {
|
||||
return Err(anyhow!("hold plugin did not return exactly one invoice"));
|
||||
}
|
||||
|
||||
let hold_invoice = hold_lookup.invoices.first().unwrap();
|
||||
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
let invoice_decoded = rpc
|
||||
.call_typed(&DecodeRequest {
|
||||
string: hold_invoice.invoice.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let amount = match invoice_decoded.item_type {
|
||||
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
|
||||
invoice_decoded.invoice_amount_msat.unwrap().msat()
|
||||
}
|
||||
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
|
||||
if let Some(amt) = invoice_decoded.amount_msat {
|
||||
amt.msat()
|
||||
} else {
|
||||
// amount: `any` but have to put a value...
|
||||
0
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow!("hold plugin did not return an invoice string")),
|
||||
};
|
||||
|
||||
let created_at = match invoice_decoded.item_type {
|
||||
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
|
||||
Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap())
|
||||
}
|
||||
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
|
||||
Timestamp::from_secs(invoice_decoded.created_at.unwrap())
|
||||
}
|
||||
_ => return Err(anyhow!("hold plugin did not return an invoice string")),
|
||||
};
|
||||
|
||||
let expires_at = match invoice_decoded.item_type {
|
||||
cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => {
|
||||
created_at
|
||||
+ Timestamp::from_secs(u64::from(invoice_decoded.invoice_relative_expiry.unwrap()))
|
||||
}
|
||||
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => {
|
||||
created_at + Timestamp::from_secs(invoice_decoded.expiry.unwrap())
|
||||
}
|
||||
_ => return Err(anyhow!("hold plugin did not return an invoice string")),
|
||||
};
|
||||
|
||||
let list_peer_channels = rpc
|
||||
.call_typed(&ListpeerchannelsRequest {
|
||||
id: None,
|
||||
short_channel_id: None,
|
||||
channel_id: None,
|
||||
})
|
||||
.await?
|
||||
.channels;
|
||||
|
||||
let payment_hash_hash = Sha256::from_str(&payment_hash_str)?;
|
||||
|
||||
let mut lowest_htlc_expiry = u32::MAX;
|
||||
|
||||
for peer in list_peer_channels {
|
||||
if let Some(htlcs) = peer.htlcs {
|
||||
for htlc in htlcs {
|
||||
if htlc.payment_hash != payment_hash_hash {
|
||||
continue;
|
||||
}
|
||||
if htlc.expiry < lowest_htlc_expiry {
|
||||
lowest_htlc_expiry = htlc.expiry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let clients = plugin.state().handles.lock().await;
|
||||
|
||||
let content = nip47::Notification {
|
||||
notification_type: nip47::NotificationType::HoldInvoiceAccepted,
|
||||
notification: nip47::NotificationResult::HoldInvoiceAccepted(
|
||||
nip47::HoldInvoiceAcceptedNotification {
|
||||
transaction_type: nip47::TransactionType::Incoming,
|
||||
invoice: hold_invoice.invoice.clone(),
|
||||
description: None,
|
||||
description_hash: None,
|
||||
payment_hash: hex::encode(&hold_invoice.payment_hash),
|
||||
amount,
|
||||
created_at,
|
||||
expires_at,
|
||||
settle_deadline: lowest_htlc_expiry,
|
||||
metadata: None,
|
||||
state: Some(nip47::TransactionState::Accepted),
|
||||
},
|
||||
),
|
||||
};
|
||||
let notification = serde_json::to_string(&content).unwrap();
|
||||
|
||||
for wallet_service in clients.values() {
|
||||
if let Err(e) = send_notification(¬ification, wallet_service).await {
|
||||
log::warn!(
|
||||
"Failed sending hold_invoice_accepted {payment_hash_str} notification\
|
||||
to client {}: {e}",
|
||||
wallet_service.client_pubkey
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
562
src/nwc_pay.rs
562
src/nwc_pay.rs
|
|
@ -1,27 +1,180 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::{
|
||||
model::requests::{DecodeRequest, PayRequest, XpayRequest},
|
||||
primitives::Amount,
|
||||
ClnRpc,
|
||||
RpcError,
|
||||
model::{
|
||||
requests::{DecodeRequest, PayRequest, XpayRequest},
|
||||
responses::DecodeResponse,
|
||||
},
|
||||
primitives::{Amount, Secret},
|
||||
};
|
||||
use nostr_sdk::nips::*;
|
||||
use tokio::time;
|
||||
use nostr::nips::nip47;
|
||||
|
||||
use crate::{
|
||||
structs::PluginState,
|
||||
util::{at_or_above_version, budget_amount_check, load_nwc_store, update_nwc_store},
|
||||
structs::{NOT_INV_ERR, NwcStore, PluginState},
|
||||
util::{
|
||||
budget_amount_check,
|
||||
get_budget_msat,
|
||||
load_nwc_store,
|
||||
payment_fee_reserve_msat,
|
||||
refund_budget,
|
||||
reserve_budget,
|
||||
rpc_socket_path,
|
||||
settle_budget,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn pay_invoice(
|
||||
pub const XPAY_COMMAND: &str = "xpay";
|
||||
|
||||
pub async fn pay_invoice_response(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::PayInvoiceRequest,
|
||||
label: &String,
|
||||
) -> Result<(nip47::PayInvoiceResponse, String), (nip47::NIP47Error, String)> {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
label: &str,
|
||||
) -> Vec<(nip47::Response, Option<String>)> {
|
||||
vec![match pay_invoice(plugin, params, 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,
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
let id = params.id.clone().unwrap_or_default();
|
||||
async fn pay_invoice(
|
||||
plugin: Plugin<PluginState>,
|
||||
params: nip47::PayInvoiceRequest,
|
||||
label: &str,
|
||||
) -> Result<(nip47::PayInvoiceResponse, Option<String>), (nip47::NIP47Error, Option<String>)> {
|
||||
let (id, reservation) = {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
|
||||
let decoded_invoice = decode_and_validate_invoice(&mut rpc, ¶ms).await?;
|
||||
|
||||
let id = get_payment_id(¶ms, &decoded_invoice)?;
|
||||
|
||||
let invoice_amt_msat = get_invoice_amount_msat(&decoded_invoice);
|
||||
|
||||
let nwc_store =
|
||||
load_nwc_and_check_budget(&mut rpc, label, ¶ms, invoice_amt_msat, &id).await?;
|
||||
|
||||
let amt_msat = match (params.amount, invoice_amt_msat) {
|
||||
(None, None) => {
|
||||
return Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: "No amount found in request or invoice".to_owned(),
|
||||
},
|
||||
Some(id.clone()),
|
||||
));
|
||||
}
|
||||
(None, Some(b)) => b,
|
||||
(Some(a), None) => a,
|
||||
(Some(a), Some(b)) => {
|
||||
if a != b {
|
||||
return Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: "request amount does not match invoice amount".to_owned(),
|
||||
},
|
||||
Some(id.clone()),
|
||||
));
|
||||
}
|
||||
a
|
||||
}
|
||||
};
|
||||
|
||||
// Reserve the invoice amount plus the worst case fee so that no
|
||||
// combination of concurrent payments can exceed the budget and so that
|
||||
// balance queries during the payment reflect the reserved amount.
|
||||
if get_budget_msat(&nwc_store).unwrap_or(u64::MAX)
|
||||
< amt_msat.saturating_add(payment_fee_reserve_msat(amt_msat))
|
||||
{
|
||||
return Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::QuotaExceeded,
|
||||
message: "Payment and estimated fees exceed the available budget".to_owned(),
|
||||
},
|
||||
Some(id),
|
||||
));
|
||||
}
|
||||
|
||||
let reservation = reserve_budget(&mut rpc, label, &nwc_store, amt_msat)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
},
|
||||
Some(id.clone()),
|
||||
)
|
||||
})?;
|
||||
|
||||
(id, reservation)
|
||||
};
|
||||
|
||||
let has_xpay = plugin.state().config.lock().has_xpay;
|
||||
|
||||
let mut pay_rpc = ClnRpc::new(rpc_socket_path(&plugin)).await.map_err(|e| {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: format!("Could not connect to lightningd: {e}"),
|
||||
},
|
||||
Some(id.clone()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pay_result = if has_xpay {
|
||||
pay_with_xpay(&mut pay_rpc, ¶ms).await
|
||||
} else {
|
||||
pay_with_legacy(&mut pay_rpc, ¶ms).await
|
||||
};
|
||||
|
||||
match pay_result {
|
||||
Ok((amount_sent_msat, amount_msat, preimage)) => {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
if let Err(e) = settle_budget(&mut rpc, label, reservation, amount_sent_msat).await {
|
||||
log::error!("Error updating budget after successful payment: {e}");
|
||||
}
|
||||
|
||||
let preimage_str = hex::encode(preimage.to_vec());
|
||||
let fees_paid = amount_sent_msat.saturating_sub(amount_msat);
|
||||
Ok((
|
||||
nip47::PayInvoiceResponse {
|
||||
preimage: preimage_str,
|
||||
fees_paid: Some(fees_paid),
|
||||
},
|
||||
Some(id),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
let mut rpc = plugin.state().rpc_lock.lock().await;
|
||||
if let Err(refund_err) = refund_budget(&mut rpc, label, reservation).await {
|
||||
log::error!(
|
||||
"Error refunding budget reservation after failed payment: {refund_err}"
|
||||
);
|
||||
}
|
||||
Err(map_cln_error_to_nip47(&e, &id, has_xpay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn decode_and_validate_invoice(
|
||||
rpc: &mut ClnRpc,
|
||||
params: &nip47::PayInvoiceRequest,
|
||||
) -> Result<DecodeResponse, (nip47::NIP47Error, Option<String>)> {
|
||||
let invoice_decoded = rpc
|
||||
.call_typed(&DecodeRequest {
|
||||
string: params.invoice.clone(),
|
||||
|
|
@ -33,252 +186,211 @@ pub async fn pay_invoice(
|
|||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
},
|
||||
id.clone(),
|
||||
params.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;
|
||||
return Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: NOT_INV_ERR.to_owned(),
|
||||
},
|
||||
params.id.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = if let Some(i) = params.id {
|
||||
i
|
||||
if !matches!(
|
||||
invoice_decoded.item_type,
|
||||
cln_rpc::model::responses::DecodeType::BOLT11_INVOICE
|
||||
) {
|
||||
return Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: NOT_INV_ERR.to_owned(),
|
||||
},
|
||||
params.id.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(invoice_decoded)
|
||||
}
|
||||
|
||||
fn get_payment_id(
|
||||
params: &nip47::PayInvoiceRequest,
|
||||
decoded_invoice: &DecodeResponse,
|
||||
) -> Result<String, (nip47::NIP47Error, Option<String>)> {
|
||||
let id = if let Some(i) = ¶ms.id {
|
||||
i.clone()
|
||||
} 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,
|
||||
}
|
||||
decoded_invoice
|
||||
.payment_hash
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: "payment_hash missing in decoded invoice".to_owned(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
})?
|
||||
.to_string()
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
let mut nwc_store = load_nwc_store(&mut rpc, label).await.map_err(|e| {
|
||||
fn get_invoice_amount_msat(decoded_invoice: &DecodeResponse) -> Option<u64> {
|
||||
decoded_invoice.amount_msat.as_ref().map(Amount::msat)
|
||||
}
|
||||
|
||||
async fn load_nwc_and_check_budget(
|
||||
rpc: &mut ClnRpc,
|
||||
label: &str,
|
||||
params: &nip47::PayInvoiceRequest,
|
||||
invoice_amt_msat: Option<u64>,
|
||||
id: &str,
|
||||
) -> Result<NwcStore, (nip47::NIP47Error, Option<String>)> {
|
||||
let nwc_store = load_nwc_store(rpc, label).await.map_err(|e| {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
},
|
||||
id.clone(),
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
})?;
|
||||
|
||||
budget_amount_check(params.amount, Some(invoice_amt_msat), nwc_store.budget_msat).map_err(
|
||||
budget_amount_check(params.amount, invoice_amt_msat, get_budget_msat(&nwc_store)).map_err(
|
||||
|e| {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::QuotaExceeded,
|
||||
message: e.to_string(),
|
||||
},
|
||||
id.clone(),
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
let my_version = plugin.state().config.lock().clone().my_cln_version;
|
||||
Ok(nwc_store)
|
||||
}
|
||||
|
||||
if at_or_above_version(&my_version, "24.11").map_err(|e| {
|
||||
(
|
||||
fn map_cln_error_to_nip47(
|
||||
e: &RpcError,
|
||||
id: &str,
|
||||
is_xpay: bool,
|
||||
) -> (nip47::NIP47Error, Option<String>) {
|
||||
match e.code {
|
||||
Some(c) => {
|
||||
let other_codes = if is_xpay {
|
||||
vec![207, 219]
|
||||
} else {
|
||||
vec![201, 207, 219]
|
||||
};
|
||||
let failed_codes = if is_xpay {
|
||||
vec![203, 205, 209]
|
||||
} else {
|
||||
vec![203, 205, 209, 210]
|
||||
};
|
||||
|
||||
if other_codes.contains(&c) {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: e.to_string(),
|
||||
},
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
} else if failed_codes.contains(&c) {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::PaymentFailed,
|
||||
message: e.to_string(),
|
||||
},
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
} else if !is_xpay && c == 206 {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::PaymentFailed,
|
||||
message: format!("Route too expensive: {e}"),
|
||||
},
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
},
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
}
|
||||
}
|
||||
None => (
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Internal,
|
||||
message: e.to_string(),
|
||||
},
|
||||
id.clone(),
|
||||
)
|
||||
})? {
|
||||
match rpc
|
||||
.call_typed(&XpayRequest {
|
||||
amount_msat: params.amount.map(Amount::from_msat),
|
||||
maxdelay: None,
|
||||
maxfee: None,
|
||||
partial_msat: None,
|
||||
retry_for: None,
|
||||
layers: None,
|
||||
invstring: 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 {
|
||||
207 | 219 => Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::Other,
|
||||
message: e.to_string(),
|
||||
},
|
||||
id,
|
||||
)),
|
||||
203 | 205 | 209 => Err((
|
||||
nip47::NIP47Error {
|
||||
code: nip47::ErrorCode::PaymentFailed,
|
||||
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,
|
||||
)),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
)),
|
||||
},
|
||||
}
|
||||
Some(id.to_owned()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
async fn pay_with_xpay(
|
||||
pay_rpc: &mut ClnRpc,
|
||||
params: &nip47::PayInvoiceRequest,
|
||||
) -> Result<(u64, u64, Secret), RpcError> {
|
||||
let payment_result = pay_rpc
|
||||
.call_typed(&XpayRequest {
|
||||
amount_msat: params.amount.map(Amount::from_msat),
|
||||
maxdelay: None,
|
||||
maxfee: None,
|
||||
partial_msat: None,
|
||||
retry_for: None,
|
||||
layers: None,
|
||||
invstring: params.invoice.clone(),
|
||||
payer_note: None,
|
||||
dev_use_shadow: None,
|
||||
label: None,
|
||||
localinvreqid: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
payment_result.amount_sent_msat.msat(),
|
||||
payment_result.amount_msat.msat(),
|
||||
payment_result.payment_preimage,
|
||||
))
|
||||
}
|
||||
|
||||
async fn pay_with_legacy(
|
||||
pay_rpc: &mut ClnRpc,
|
||||
params: &nip47::PayInvoiceRequest,
|
||||
) -> Result<(u64, u64, Secret), RpcError> {
|
||||
let payment_result = pay_rpc
|
||||
.call_typed(&PayRequest {
|
||||
amount_msat: params.amount.map(Amount::from_msat),
|
||||
description: None,
|
||||
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.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
payment_result.amount_sent_msat.msat(),
|
||||
payment_result.amount_msat.msat(),
|
||||
payment_result.payment_preimage,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
82
src/parse.rs
82
src/parse.rs
|
|
@ -1,44 +1,72 @@
|
|||
use std::path::Path;
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use cln_plugin::ConfiguredPlugin;
|
||||
use cln_rpc::{model::requests::GetinfoRequest, ClnRpc};
|
||||
use cln_rpc::{ClnRpc, model::requests::HelpRequest};
|
||||
use nostr::types::RelayUrl;
|
||||
|
||||
use crate::{
|
||||
structs::{PluginState, TimeUnit},
|
||||
OPT_RELAYS,
|
||||
nwc_keysend::XKEYSEND_COMMAND,
|
||||
nwc_pay::XPAY_COMMAND,
|
||||
structs::{PluginState, TimeUnit},
|
||||
};
|
||||
|
||||
pub async fn read_startup_options(
|
||||
plugin: &ConfiguredPlugin<PluginState, tokio::io::Stdin, tokio::io::Stdout>,
|
||||
state: &PluginState,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let relays_str = if let Some(relays) = plugin.option(&OPT_RELAYS).unwrap() {
|
||||
if !relays.is_empty() {
|
||||
relays
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Empty `{}` option, must specify atleast one relay url!",
|
||||
OPT_RELAYS.name()
|
||||
));
|
||||
}
|
||||
let relays_str = if plugin
|
||||
.option(&OPT_RELAYS)
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_empty())
|
||||
{
|
||||
vec![
|
||||
"wss://nos.lol".to_owned(),
|
||||
"wss://relay.primal.net".to_owned(),
|
||||
"wss://relay.getalby.com/v1".to_owned(),
|
||||
"wss://relay.nostr.net".to_owned(),
|
||||
"wss://relay.snort.social".to_owned(),
|
||||
]
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"`{}` not set, must specify atleast one relay url!",
|
||||
OPT_RELAYS.name()
|
||||
));
|
||||
plugin.option(&OPT_RELAYS).unwrap().unwrap()
|
||||
};
|
||||
|
||||
let mut rpc = ClnRpc::new(
|
||||
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
|
||||
)
|
||||
.await?;
|
||||
let version = rpc.call_typed(&GetinfoRequest {}).await?.version;
|
||||
let help = rpc.call_typed(&HelpRequest { command: None }).await?;
|
||||
|
||||
let mut config = state.config.lock();
|
||||
config.my_cln_version = version;
|
||||
for relay in relays_str.into_iter() {
|
||||
log::debug!("RELAY:{}", relay);
|
||||
config.relays.push(nostr_sdk::RelayUrl::parse(&relay)?);
|
||||
|
||||
let mut available_commands = HashSet::new();
|
||||
for command in &help.help {
|
||||
if let Some(method) = command.command.split_ascii_whitespace().next() {
|
||||
available_commands.insert(method);
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Found {} commands available: {}",
|
||||
available_commands.len(),
|
||||
available_commands
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
|
||||
config.has_xkeysend = available_commands.contains(XKEYSEND_COMMAND);
|
||||
config.has_xpay = available_commands.contains(XPAY_COMMAND);
|
||||
log::debug!(
|
||||
"Using xpay:{} xkeysend:{}",
|
||||
config.has_xpay,
|
||||
config.has_xkeysend
|
||||
);
|
||||
for relay in relays_str {
|
||||
log::debug!("RELAY:{relay}");
|
||||
config.relays.push(RelayUrl::parse(&relay)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -52,15 +80,15 @@ pub fn parse_time_period(input: &str) -> Result<u64, anyhow::Error> {
|
|||
if let Ok(time_unit) = unit.parse() {
|
||||
match time_unit {
|
||||
TimeUnit::Second => Ok(value),
|
||||
TimeUnit::Minute => Ok(value * 60),
|
||||
TimeUnit::Hour => Ok(value * 60 * 60),
|
||||
TimeUnit::Day => Ok(value * 60 * 60 * 24),
|
||||
TimeUnit::Week => Ok(value * 60 * 60 * 24 * 7),
|
||||
TimeUnit::Minute => Ok(value.saturating_mul(60)),
|
||||
TimeUnit::Hour => Ok(value.saturating_mul(60 * 60)),
|
||||
TimeUnit::Day => Ok(value.saturating_mul(60 * 60 * 24)),
|
||||
TimeUnit::Week => Ok(value.saturating_mul(60 * 60 * 24 * 7)),
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!(format!("Unsupported time unit: {}", unit)))
|
||||
Err(anyhow!(format!("Unsupported time unit: {unit}")))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Invalid time format: {}", input))
|
||||
Err(anyhow!("Invalid time format: {input}"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/rpc.rs
73
src/rpc.rs
|
|
@ -1,19 +1,31 @@
|
|||
use anyhow::anyhow;
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::model::requests::{
|
||||
DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest,
|
||||
DatastoreMode,
|
||||
DatastoreRequest,
|
||||
DeldatastoreRequest,
|
||||
ListdatastoreRequest,
|
||||
};
|
||||
use nostr::{
|
||||
key::{Keys, SecretKey},
|
||||
nips::nip47::NostrWalletConnectUri,
|
||||
types::Timestamp,
|
||||
};
|
||||
use nostr_sdk::nips::nip47::*;
|
||||
use nostr_sdk::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::nwc::{
|
||||
run_nwc, send_nwc_info_event, start_nwc_budget_job, stop_nwc, stop_nwc_budget_job,
|
||||
use crate::{
|
||||
PLUGIN_NAME,
|
||||
nwc::{run_nwc, send_nwc_info_event, stop_nwc},
|
||||
parse::parse_time_period,
|
||||
structs::{BudgetIntervalConfig, NwcStore, PluginState},
|
||||
util::{
|
||||
build_capabilities,
|
||||
get_budget_msat,
|
||||
is_read_only_nwc,
|
||||
load_nwc_store,
|
||||
update_nwc_store,
|
||||
},
|
||||
};
|
||||
use crate::parse::parse_time_period;
|
||||
use crate::structs::{BudgetIntervalConfig, NwcStore, PluginState};
|
||||
use crate::util::{is_read_only_nwc, load_nwc_store, update_nwc_store};
|
||||
use crate::{OPT_NOTIFICATIONS, PLUGIN_NAME, WALLET_ALL_METHODS, WALLET_READ_METHODS};
|
||||
|
||||
pub async fn nwc_create(
|
||||
plugin: Plugin<PluginState>,
|
||||
|
|
@ -27,7 +39,7 @@ pub async fn nwc_create(
|
|||
|
||||
let wallet_keys = Keys::generate();
|
||||
let client_keys = Keys::generate();
|
||||
let uri = NostrWalletConnectURI::new(
|
||||
let uri = NostrWalletConnectUri::new(
|
||||
wallet_keys.public_key(),
|
||||
config.relays.clone(),
|
||||
client_keys.secret_key().clone(),
|
||||
|
|
@ -55,7 +67,8 @@ pub async fn nwc_create(
|
|||
let conf = BudgetIntervalConfig {
|
||||
interval_secs: interval,
|
||||
reset_budget_msat: bgt_msat,
|
||||
last_reset: Timestamp::now().as_u64(),
|
||||
last_reset: Timestamp::now().as_secs(),
|
||||
spend_since_last_reset: 0,
|
||||
};
|
||||
result.insert("interval_config".to_owned(), serde_json::to_value(&conf)?);
|
||||
Some(conf)
|
||||
|
|
@ -70,6 +83,7 @@ pub async fn nwc_create(
|
|||
walletkey: wallet_keys.secret_key().to_secret_hex(),
|
||||
budget_msat,
|
||||
interval_config,
|
||||
reserved_msat: 0,
|
||||
};
|
||||
|
||||
rpc.call_typed(&DatastoreRequest {
|
||||
|
|
@ -81,7 +95,9 @@ pub async fn nwc_create(
|
|||
})
|
||||
.await?;
|
||||
|
||||
run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?;
|
||||
if let Err(e) = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await {
|
||||
log::warn!("Failed running nwc: {e}");
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Object(result))
|
||||
}
|
||||
|
|
@ -113,8 +129,6 @@ pub async fn nwc_budget(
|
|||
|
||||
let (label, budget_msat, interval_secs) = parse_full_args(args)?;
|
||||
|
||||
stop_nwc_budget_job(plugin.clone(), &label);
|
||||
|
||||
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
|
||||
|
||||
let is_old_nwc_read_only = is_read_only_nwc(&nwc_store);
|
||||
|
|
@ -125,7 +139,8 @@ pub async fn nwc_budget(
|
|||
let interval_config = BudgetIntervalConfig {
|
||||
interval_secs: interval,
|
||||
reset_budget_msat: budget,
|
||||
last_reset: Timestamp::now().as_u64(),
|
||||
last_reset: Timestamp::now().as_secs(),
|
||||
spend_since_last_reset: 0,
|
||||
};
|
||||
nwc_store.interval_config = Some(interval_config.clone());
|
||||
} else {
|
||||
|
|
@ -138,28 +153,20 @@ pub async fn nwc_budget(
|
|||
|
||||
let is_new_nwc_read_only = is_read_only_nwc(&nwc_store);
|
||||
|
||||
if nwc_store.interval_config.is_some() {
|
||||
start_nwc_budget_job(plugin.clone(), label.clone());
|
||||
}
|
||||
|
||||
update_nwc_store(&mut rpc, &label, nwc_store.clone()).await?;
|
||||
|
||||
if is_old_nwc_read_only != is_new_nwc_read_only {
|
||||
let wallet_keys = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
|
||||
let capabilities = if is_new_nwc_read_only {
|
||||
WALLET_READ_METHODS.join(" ")
|
||||
} else {
|
||||
WALLET_ALL_METHODS.join(" ")
|
||||
};
|
||||
let (method_capabilities, _) = build_capabilities(is_new_nwc_read_only, &plugin);
|
||||
let clients = plugin.state().handles.lock().await;
|
||||
send_nwc_info_event(
|
||||
plugin.clone(),
|
||||
clients
|
||||
.get(&label)
|
||||
.ok_or_else(|| anyhow!("No client found for label: {}", label))?
|
||||
.0
|
||||
.ok_or_else(|| anyhow!("No client found for label: {label}"))?
|
||||
.client
|
||||
.clone(),
|
||||
plugin.option(&OPT_NOTIFICATIONS).unwrap(),
|
||||
capabilities,
|
||||
method_capabilities,
|
||||
wallet_keys,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -179,7 +186,8 @@ pub async fn nwc_list(
|
|||
let mut nwcs = Vec::new();
|
||||
|
||||
if let Some(lbl) = label {
|
||||
let nwc_store = load_nwc_store(&mut rpc, &lbl).await?;
|
||||
let mut nwc_store = load_nwc_store(&mut rpc, &lbl).await?;
|
||||
nwc_store.budget_msat = get_budget_msat(&nwc_store);
|
||||
let wallet_key = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
|
||||
let client_key = Keys::new(nwc_store.uri.secret.clone());
|
||||
let mut nwc_json = json!(nwc_store);
|
||||
|
|
@ -195,16 +203,17 @@ pub async fn nwc_list(
|
|||
});
|
||||
nwcs.push(json!({lbl:nwc_json}));
|
||||
} else {
|
||||
let nwcs_store = rpc
|
||||
let all_stored_nwcs = rpc
|
||||
.call_typed(&ListdatastoreRequest {
|
||||
key: Some(vec![PLUGIN_NAME.to_owned()]),
|
||||
})
|
||||
.await?
|
||||
.datastore;
|
||||
|
||||
for datastore in nwcs_store.into_iter() {
|
||||
for datastore in all_stored_nwcs {
|
||||
let label = datastore.key.last().unwrap().to_owned();
|
||||
let nwc_store = load_nwc_store(&mut rpc, &label).await?;
|
||||
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
|
||||
nwc_store.budget_msat = get_budget_msat(&nwc_store);
|
||||
let wallet_key = Keys::new(SecretKey::from_hex(&nwc_store.walletkey)?);
|
||||
let client_key = Keys::new(nwc_store.uri.secret.clone());
|
||||
let mut nwc_json = json!(nwc_store);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc};
|
||||
|
||||
use cln_rpc::ClnRpc;
|
||||
use nostr_sdk::client;
|
||||
use nostr_sdk::nips::nip47;
|
||||
use nostr_sdk::nostr;
|
||||
use nostr::{
|
||||
key::{Keys, PublicKey},
|
||||
nips::nip47::NostrWalletConnectUri,
|
||||
types::RelayUrl,
|
||||
};
|
||||
use nostr_sdk::client::Client;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::oneshot;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use crate::hold::hold_client::HoldClient;
|
||||
|
||||
pub const NOT_INV_ERR: &str = "Not an invoice or invalid invoice";
|
||||
pub const ID_STORE: &str = "eventids";
|
||||
pub const ID_MAX_AGE: u64 = 7_200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginState {
|
||||
pub config: Arc<Mutex<Config>>,
|
||||
pub handles: Arc<tokio::sync::Mutex<HashMap<String, (client::Client, nostr::PublicKey)>>>,
|
||||
pub handles: Arc<tokio::sync::Mutex<HashMap<String, WalletService>>>,
|
||||
pub rpc_lock: Arc<tokio::sync::Mutex<ClnRpc>>,
|
||||
pub budget_jobs: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
|
||||
pub hold_client: Arc<Mutex<Option<HoldClient<Channel>>>>,
|
||||
}
|
||||
impl PluginState {
|
||||
pub async fn new(path: PathBuf) -> Result<PluginState, anyhow::Error> {
|
||||
|
|
@ -21,21 +30,29 @@ impl PluginState {
|
|||
config: Arc::new(Mutex::new(Config::default())),
|
||||
handles: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
rpc_lock: Arc::new(tokio::sync::Mutex::new(ClnRpc::new(path).await?)),
|
||||
budget_jobs: Arc::new(Mutex::new(HashMap::new())),
|
||||
hold_client: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WalletService {
|
||||
pub client: Client,
|
||||
pub client_pubkey: PublicKey,
|
||||
pub wallet_secret: Keys,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub relays: Vec<nostr_sdk::RelayUrl>,
|
||||
pub my_cln_version: String,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
pub has_xkeysend: bool,
|
||||
pub has_xpay: bool,
|
||||
}
|
||||
impl Config {
|
||||
pub fn default() -> Config {
|
||||
Config {
|
||||
relays: Vec::new(),
|
||||
my_cln_version: String::new(),
|
||||
has_xkeysend: false,
|
||||
has_xpay: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +75,7 @@ impl FromStr for TimeUnit {
|
|||
"hour" | "hours" | "h" => Ok(TimeUnit::Hour),
|
||||
"day" | "days" | "d" => Ok(TimeUnit::Day),
|
||||
"week" | "weeks" | "w" => Ok(TimeUnit::Week),
|
||||
_ => Err(format!("Unsupported time unit: {}", s)),
|
||||
_ => Err(format!("Unsupported time unit: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,14 +85,18 @@ pub struct BudgetIntervalConfig {
|
|||
pub interval_secs: u64,
|
||||
pub reset_budget_msat: u64,
|
||||
pub last_reset: u64,
|
||||
#[serde(default)]
|
||||
pub spend_since_last_reset: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NwcStore {
|
||||
pub uri: nip47::NostrWalletConnectURI,
|
||||
pub uri: NostrWalletConnectUri,
|
||||
pub walletkey: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub budget_msat: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interval_config: Option<BudgetIntervalConfig>,
|
||||
#[serde(default)]
|
||||
pub reserved_msat: u64,
|
||||
}
|
||||
|
|
|
|||
78
src/tasks.rs
78
src/tasks.rs
|
|
@ -1,59 +1,49 @@
|
|||
use std::{path::Path, time::Duration};
|
||||
use std::{path::Path, str::FromStr, time::Duration};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::ClnRpc;
|
||||
use nostr_sdk::Timestamp;
|
||||
use tokio::{sync::oneshot, time};
|
||||
use cln_rpc::{
|
||||
ClnRpc,
|
||||
model::requests::{DeldatastoreRequest, ListdatastoreRequest},
|
||||
};
|
||||
use nostr::types::Timestamp;
|
||||
|
||||
use crate::{
|
||||
structs::PluginState,
|
||||
util::{load_nwc_store, update_nwc_store},
|
||||
PLUGIN_NAME,
|
||||
structs::{ID_MAX_AGE, ID_STORE, PluginState},
|
||||
};
|
||||
|
||||
pub async fn budget_task(
|
||||
mut rx: oneshot::Receiver<()>,
|
||||
plugin: Plugin<PluginState>,
|
||||
label: String,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
pub async fn cleanup_event_ids(plugin: Plugin<PluginState>) -> Result<(), anyhow::Error> {
|
||||
let mut rpc = ClnRpc::new(
|
||||
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file),
|
||||
)
|
||||
.await?;
|
||||
|
||||
loop {
|
||||
let mut nwc_store = load_nwc_store(&mut rpc, &label).await?;
|
||||
let interval_config = nwc_store
|
||||
.interval_config
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("interval_config disappeared!"))?;
|
||||
let now = Timestamp::now().as_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);
|
||||
{
|
||||
let ids = rpc
|
||||
.call_typed(&ListdatastoreRequest {
|
||||
key: Some(vec![format!("{}-{}", PLUGIN_NAME, ID_STORE)]),
|
||||
})
|
||||
.await?
|
||||
.datastore;
|
||||
|
||||
let now = Timestamp::now();
|
||||
for id in ids {
|
||||
if id.string.is_none() {
|
||||
continue;
|
||||
}
|
||||
let timestamp = Timestamp::from_str(&id.string.unwrap())?;
|
||||
if now.as_secs().saturating_sub(timestamp.as_secs()) > ID_MAX_AGE {
|
||||
rpc.call_typed(&DeldatastoreRequest {
|
||||
generation: None,
|
||||
key: id.key.clone(),
|
||||
})
|
||||
.await?;
|
||||
log::debug!("Cleaned up event id: {}", id.key.last().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
360
src/util.rs
360
src/util.rs
|
|
@ -1,10 +1,75 @@
|
|||
use anyhow::anyhow;
|
||||
use cln_rpc::{
|
||||
model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest},
|
||||
ClnRpc,
|
||||
use std::{
|
||||
cmp::max,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::{structs::NwcStore, PLUGIN_NAME};
|
||||
use anyhow::anyhow;
|
||||
use cln_plugin::Plugin;
|
||||
use cln_rpc::{
|
||||
ClnRpc,
|
||||
model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest},
|
||||
};
|
||||
use nostr::{nips::nip47, types::Timestamp};
|
||||
|
||||
use crate::{
|
||||
OPT_NOTIFICATIONS,
|
||||
PLUGIN_NAME,
|
||||
WALLET_HOLD_METHODS,
|
||||
WALLET_HOLD_NOTIFICATIONS,
|
||||
WALLET_NOTIFICATIONS,
|
||||
WALLET_PAY_METHODS,
|
||||
WALLET_READ_OR_RECEIVE_METHODS,
|
||||
structs::{ID_STORE, NwcStore, PluginState},
|
||||
};
|
||||
|
||||
/// CLN's default maximum fee for a payment is `max(5000msat, 1% of amount)`,
|
||||
/// so reserving this much guarantees a settled payment never exceeds the budget.
|
||||
pub const MIN_FEE_RESERVE_MSAT: u64 = 5_000;
|
||||
|
||||
pub fn payment_fee_reserve_msat(amount_msat: u64) -> u64 {
|
||||
max(MIN_FEE_RESERVE_MSAT, amount_msat.saturating_div(100))
|
||||
}
|
||||
|
||||
pub fn get_budget_msat(nwc_store: &NwcStore) -> Option<u64> {
|
||||
match nwc_store.budget_msat {
|
||||
Some(b) => {
|
||||
let available = if let Some(conf) = &nwc_store.interval_config {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let spend = if now.saturating_sub(conf.last_reset) >= conf.interval_secs {
|
||||
0
|
||||
} else {
|
||||
conf.spend_since_last_reset
|
||||
};
|
||||
conf.reset_budget_msat.saturating_sub(spend)
|
||||
} else {
|
||||
b
|
||||
};
|
||||
Some(available.saturating_sub(nwc_store.reserved_msat))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_budget_msat(nwc_store: &mut NwcStore, amount_spent_msat: u64) {
|
||||
if let Some(bdg) = nwc_store.budget_msat.as_mut() {
|
||||
if let Some(conf) = nwc_store.interval_config.as_mut() {
|
||||
let now = Timestamp::now().as_secs();
|
||||
if now.saturating_sub(conf.last_reset) >= conf.interval_secs {
|
||||
conf.last_reset = now;
|
||||
conf.spend_since_last_reset = amount_spent_msat;
|
||||
} else {
|
||||
conf.spend_since_last_reset = conf
|
||||
.spend_since_last_reset
|
||||
.saturating_add(amount_spent_msat);
|
||||
}
|
||||
*bdg = conf
|
||||
.reset_budget_msat
|
||||
.saturating_sub(conf.spend_since_last_reset);
|
||||
} else {
|
||||
*bdg = bdg.saturating_sub(amount_spent_msat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn budget_amount_check(
|
||||
request_amt_msat: Option<u64>,
|
||||
|
|
@ -12,10 +77,8 @@ pub fn budget_amount_check(
|
|||
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
|
||||
"checking budget and amounts for request:{request_amt_msat:?} \
|
||||
invoice:{invoice_amt_msat:?} budget:{budget_msat:?}"
|
||||
);
|
||||
if request_amt_msat.is_none() && invoice_amt_msat.is_none() {
|
||||
return Err(anyhow!("No amount given to check budget against!"));
|
||||
|
|
@ -44,38 +107,104 @@ pub fn budget_amount_check(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_nwc_store(rpc: &mut ClnRpc, label: &String) -> Result<NwcStore, anyhow::Error> {
|
||||
pub async fn load_nwc_store(rpc: &mut ClnRpc, label: &str) -> Result<NwcStore, anyhow::Error> {
|
||||
let nwc_store_store = rpc
|
||||
.call_typed(&ListdatastoreRequest {
|
||||
key: Some(vec![PLUGIN_NAME.to_owned(), label.clone()]),
|
||||
key: Some(vec![PLUGIN_NAME.to_owned(), label.to_owned()]),
|
||||
})
|
||||
.await?
|
||||
.datastore;
|
||||
let nwc_store_str = nwc_store_store
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("No datastore found for: {}", label))?
|
||||
.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);
|
||||
log::debug!("loaded nwc store for label:{label}");
|
||||
Ok(nwc_store)
|
||||
}
|
||||
|
||||
pub async fn update_nwc_store(
|
||||
rpc: &mut ClnRpc,
|
||||
label: &String,
|
||||
label: &str,
|
||||
nwc_store: NwcStore,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
rpc.call_typed(&DatastoreRequest {
|
||||
key: vec![PLUGIN_NAME.to_owned(), label.clone()],
|
||||
key: vec![PLUGIN_NAME.to_owned(), label.to_owned()],
|
||||
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);
|
||||
log::debug!("stored nwc store for label:{label}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rpc_socket_path(plugin: &Plugin<PluginState>) -> PathBuf {
|
||||
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file)
|
||||
}
|
||||
|
||||
/// Reserve the payment amount plus the worst case fee so that no combination
|
||||
/// of concurrent payments can exceed the budget. Must be called while holding
|
||||
/// the global rpc lock. Returns the reserved amount (0 if there is no budget).
|
||||
pub async fn reserve_budget(
|
||||
rpc: &mut ClnRpc,
|
||||
label: &str,
|
||||
nwc_store: &NwcStore,
|
||||
amount_msat: u64,
|
||||
) -> Result<u64, anyhow::Error> {
|
||||
if nwc_store.budget_msat.is_none() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let reservation = amount_msat.saturating_add(payment_fee_reserve_msat(amount_msat));
|
||||
let mut new_store = nwc_store.clone();
|
||||
new_store.reserved_msat = new_store.reserved_msat.saturating_add(reservation);
|
||||
update_nwc_store(rpc, label, new_store).await?;
|
||||
|
||||
Ok(reservation)
|
||||
}
|
||||
|
||||
/// Release the reservation of a payment that did not succeed. Must be called
|
||||
/// while holding the global rpc lock.
|
||||
pub async fn refund_budget(
|
||||
rpc: &mut ClnRpc,
|
||||
label: &str,
|
||||
reservation: u64,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
if reservation == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut nwc_store = load_nwc_store(rpc, label).await?;
|
||||
nwc_store.reserved_msat = nwc_store.reserved_msat.saturating_sub(reservation);
|
||||
update_nwc_store(rpc, label, nwc_store).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the actual amount spent by a successful payment and release the
|
||||
/// remainder of the reservation. Must be called while holding the global rpc
|
||||
/// lock. Charges the real spend first so that an error leaves the budget
|
||||
/// conservatively low rather than too high.
|
||||
pub async fn settle_budget(
|
||||
rpc: &mut ClnRpc,
|
||||
label: &str,
|
||||
reservation: u64,
|
||||
amount_spent_msat: u64,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
// A zero reservation only happens when the nwc has no budget at all.
|
||||
if reservation == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut nwc_store = load_nwc_store(rpc, label).await?;
|
||||
update_budget_msat(&mut nwc_store, amount_spent_msat);
|
||||
nwc_store.reserved_msat = nwc_store.reserved_msat.saturating_sub(reservation);
|
||||
update_nwc_store(rpc, label, nwc_store).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -88,32 +217,91 @@ pub fn is_read_only_nwc(nwc_store: &NwcStore) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
pub fn at_or_above_version(my_version: &str, min_version: &str) -> Result<bool, anyhow::Error> {
|
||||
let clean_start_my_version = my_version
|
||||
.split_once('v')
|
||||
.ok_or_else(|| anyhow!("Could not find v in version string"))?
|
||||
.1;
|
||||
let full_clean_my_version: String = clean_start_my_version
|
||||
.chars()
|
||||
.take_while(|x| x.is_ascii_digit() || *x == '.')
|
||||
.collect();
|
||||
pub async fn save_event_id(
|
||||
rpc: &mut ClnRpc,
|
||||
id: String,
|
||||
timestamp: Timestamp,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
rpc.call_typed(&DatastoreRequest {
|
||||
key: vec![format!("{}-{}", PLUGIN_NAME, ID_STORE), id.clone()],
|
||||
generation: None,
|
||||
hex: None,
|
||||
mode: Some(DatastoreMode::MUST_CREATE),
|
||||
string: Some(timestamp.to_string()),
|
||||
})
|
||||
.await?;
|
||||
log::debug!("stored event id:{id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let my_version_parts: Vec<&str> = full_clean_my_version.split('.').collect();
|
||||
let min_version_parts: Vec<&str> = min_version.split('.').collect();
|
||||
pub fn build_capabilities(is_receive_only: bool, plugin: &Plugin<PluginState>) -> (String, String) {
|
||||
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
|
||||
|
||||
if my_version_parts.len() <= 1 || my_version_parts.len() > 3 {
|
||||
return Err(anyhow!("Version string parse error: {}", my_version));
|
||||
let mut methods = WALLET_READ_OR_RECEIVE_METHODS
|
||||
.map(|m| m.to_string())
|
||||
.join(" ");
|
||||
if !is_receive_only {
|
||||
methods.push(' ');
|
||||
methods.push_str(WALLET_PAY_METHODS.map(|m| m.to_string()).join(" ").as_str());
|
||||
}
|
||||
if holdinvoice_support {
|
||||
methods.push(' ');
|
||||
methods.push_str(
|
||||
WALLET_HOLD_METHODS
|
||||
.map(|m| m.to_string())
|
||||
.join(" ")
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
for (my, min) in my_version_parts.iter().zip(min_version_parts.iter()) {
|
||||
let my_num: u32 = my.parse()?;
|
||||
let min_num: u32 = min.parse()?;
|
||||
|
||||
if my_num != min_num {
|
||||
return Ok(my_num > min_num);
|
||||
let mut notifications = String::new();
|
||||
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
notifications.push_str(
|
||||
WALLET_NOTIFICATIONS
|
||||
.map(|m| m.to_string())
|
||||
.join(" ")
|
||||
.as_str(),
|
||||
);
|
||||
if holdinvoice_support {
|
||||
notifications.push(' ');
|
||||
notifications.push_str(
|
||||
WALLET_HOLD_NOTIFICATIONS
|
||||
.map(|m| m.to_string())
|
||||
.join(" ")
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(my_version_parts.len() >= min_version_parts.len())
|
||||
(methods, notifications)
|
||||
}
|
||||
|
||||
pub fn build_methods_vec(
|
||||
is_receive_only: bool,
|
||||
plugin: &Plugin<PluginState>,
|
||||
) -> Vec<nip47::Method> {
|
||||
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
|
||||
let mut methods = WALLET_READ_OR_RECEIVE_METHODS.to_vec();
|
||||
if !is_receive_only {
|
||||
methods.extend_from_slice(&WALLET_PAY_METHODS);
|
||||
}
|
||||
if holdinvoice_support {
|
||||
methods.extend_from_slice(&WALLET_HOLD_METHODS);
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
pub fn build_notifications_vec(plugin: &Plugin<PluginState>) -> Vec<String> {
|
||||
let holdinvoice_support = plugin.state().hold_client.lock().is_some();
|
||||
|
||||
let mut notifications = Vec::new();
|
||||
if plugin.option(&OPT_NOTIFICATIONS).unwrap() {
|
||||
notifications.extend_from_slice(&WALLET_NOTIFICATIONS.map(|m| m.to_string()));
|
||||
if holdinvoice_support {
|
||||
notifications.extend_from_slice(&WALLET_HOLD_NOTIFICATIONS.map(|m| m.to_string()));
|
||||
}
|
||||
}
|
||||
notifications
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -133,3 +321,105 @@ fn test_budget_check() {
|
|||
assert!(budget_amount_check(Some(0), Some(0), Some(1)).is_ok());
|
||||
assert!(budget_amount_check(Some(0), Some(0), Some(0)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_budget_interval_helpers() {
|
||||
use crate::structs::BudgetIntervalConfig;
|
||||
|
||||
let now = Timestamp::now().as_secs();
|
||||
let conf = BudgetIntervalConfig {
|
||||
interval_secs: 10,
|
||||
reset_budget_msat: 1000,
|
||||
last_reset: now,
|
||||
spend_since_last_reset: 0,
|
||||
};
|
||||
let mut store = NwcStore {
|
||||
uri: nostr::nips::nip47::NostrWalletConnectUri::new(
|
||||
nostr::key::Keys::generate().public_key(),
|
||||
vec![],
|
||||
nostr::key::Keys::generate().secret_key().clone(),
|
||||
None,
|
||||
),
|
||||
walletkey: "test".to_owned(),
|
||||
budget_msat: Some(1000),
|
||||
interval_config: Some(conf),
|
||||
reserved_msat: 0,
|
||||
};
|
||||
|
||||
assert_eq!(get_budget_msat(&store), Some(1000));
|
||||
|
||||
update_budget_msat(&mut store, 300);
|
||||
assert_eq!(
|
||||
store
|
||||
.interval_config
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.spend_since_last_reset,
|
||||
300
|
||||
);
|
||||
assert_eq!(get_budget_msat(&store), Some(700));
|
||||
|
||||
update_budget_msat(&mut store, 500);
|
||||
assert_eq!(get_budget_msat(&store), Some(200));
|
||||
|
||||
store.interval_config.as_mut().unwrap().last_reset = now.saturating_sub(20);
|
||||
let old_last_reset = store.interval_config.as_ref().unwrap().last_reset;
|
||||
assert_eq!(get_budget_msat(&store), Some(1000));
|
||||
|
||||
update_budget_msat(&mut store, 400);
|
||||
assert!(store.interval_config.as_ref().unwrap().last_reset > old_last_reset);
|
||||
assert_eq!(
|
||||
store
|
||||
.interval_config
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.spend_since_last_reset,
|
||||
400
|
||||
);
|
||||
assert_eq!(get_budget_msat(&store), Some(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_payment_fee_reserve() {
|
||||
// fee reserve is at least 5000msat
|
||||
assert_eq!(payment_fee_reserve_msat(0), 5000);
|
||||
assert_eq!(payment_fee_reserve_msat(1000), 5000);
|
||||
assert_eq!(payment_fee_reserve_msat(499_999), 5000);
|
||||
// and 1% of the amount above that
|
||||
assert_eq!(payment_fee_reserve_msat(500_000), 5000);
|
||||
assert_eq!(payment_fee_reserve_msat(1_000_000), 10_000);
|
||||
assert_eq!(payment_fee_reserve_msat(123_456_789), 1_234_567);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_budget_reservations() {
|
||||
let store = NwcStore {
|
||||
uri: nostr::nips::nip47::NostrWalletConnectUri::new(
|
||||
nostr::key::Keys::generate().public_key(),
|
||||
vec![],
|
||||
nostr::key::Keys::generate().secret_key().clone(),
|
||||
None,
|
||||
),
|
||||
walletkey: "test".to_owned(),
|
||||
budget_msat: Some(10_000),
|
||||
interval_config: None,
|
||||
reserved_msat: 0,
|
||||
};
|
||||
|
||||
// reserving reduces the available budget
|
||||
let mut stored = store.clone();
|
||||
stored.reserved_msat = 1_500;
|
||||
assert_eq!(get_budget_msat(&stored), Some(8_500));
|
||||
|
||||
// a full spend plus reservation can never be under budget
|
||||
let full_reservation =
|
||||
stored.clone().budget_msat.unwrap() + payment_fee_reserve_msat(store.budget_msat.unwrap());
|
||||
stored.reserved_msat = full_reservation;
|
||||
assert_eq!(get_budget_msat(&stored), Some(0));
|
||||
|
||||
// reservations do not affect a store without a budget
|
||||
let mut no_budget = store;
|
||||
no_budget.budget_msat = None;
|
||||
no_budget.reserved_msat = 1_000;
|
||||
assert_eq!(get_budget_msat(&no_budget), None);
|
||||
}
|
||||
|
|
|
|||
12
tests/config.toml
Normal file
12
tests/config.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
[info]
|
||||
relay_url = "ws://127.0.0.1/"
|
||||
name = "nostr-rs-relay"
|
||||
description = "test relay"
|
||||
|
||||
[options]
|
||||
reject_future_seconds = 1800
|
||||
[limits]
|
||||
limit_scrapers = false
|
||||
[network]
|
||||
address = "127.0.0.1"
|
||||
50
tests/conftest.py
Normal file
50
tests/conftest.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from pyln.testing.fixtures import * # noqa: F403
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nostr_relay(worker_id, node_factory):
|
||||
port = node_factory.get_unused_port()
|
||||
|
||||
config_path = Path(__file__).parent / "config.toml"
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"config.toml not found at {config_path}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
# Copy your original config.toml into it
|
||||
original_config = Path(__file__).parent / "config.toml"
|
||||
temp_config = temp_dir / "config.toml"
|
||||
shutil.copy(original_config, temp_config)
|
||||
|
||||
with temp_config.open("a") as f:
|
||||
f.write(f"port = {port}\n")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["./nostr-rs-relay", "--config", str(temp_config), "--db", str(temp_dir)],
|
||||
cwd=Path(__file__).parent,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=os.environ | {"RUST_LOG": "warn,nostr_rs_relay=debug"},
|
||||
)
|
||||
|
||||
try:
|
||||
import time
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
ws_url = f"ws://127.0.0.1:{port}"
|
||||
yield ws_url
|
||||
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
BIN
tests/nostr-rs-relay.tar.gz
Normal file
BIN
tests/nostr-rs-relay.tar.gz
Normal file
Binary file not shown.
17
tests/pyproject.toml
Normal file
17
tests/pyproject.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[project]
|
||||
name = "cln-nip47"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
description = "python dependencies for running tests"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8,<10",
|
||||
"pytest-asyncio>=0.23.8,<2",
|
||||
"pytest-xdist>=3.7,<4",
|
||||
"pytest-timeout>=2.4,<3",
|
||||
"nostr-sdk>=0.45",
|
||||
"pyln-testing>=25.9",
|
||||
"pyln-client>=25.9",
|
||||
"pyln-proto>=25.9",
|
||||
]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
pytest-asyncio<0.24
|
||||
nostr-sdk
|
||||
PyYAML
|
||||
nostr_relay
|
||||
|
|
@ -49,52 +49,49 @@ github_url="https://github.com/daywalker90/$name/releases/download/v$version/$ar
|
|||
# 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
|
||||
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
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Unknown archive format or unsupported file extension: $archive_file" >&2
|
||||
# exit 1
|
||||
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
|
||||
}
|
||||
if ! tar -xzvf "$script_dir/nostr-rs-relay.tar.gz" -C "$script_dir"; then
|
||||
echo "Error extracting the contents of nostr-rs-relay.tar.gz" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
proto_path="$script_dir/../proto"
|
||||
if [ -d "$proto_path" ]; then
|
||||
# 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
|
||||
if ! uv run python -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
|
||||
|
||||
hold_url="https://github.com/BoltzExchange/hold/releases/download/v0.3.3/hold-linux-amd64.tar.gz"
|
||||
|
||||
if ! curl -L "$hold_url" -o "$script_dir/hold-linux-amd64.tar.gz"; then
|
||||
echo "Error downloading the file from $hold_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! tar -xzvf "$script_dir/hold-linux-amd64.tar.gz" -C "$script_dir"; then
|
||||
echo "Error extracting the contents of hold-linux-amd64.tar.gz" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$script_dir/build/hold-linux-amd64" "$script_dir/hold"
|
||||
|
|
|
|||
1913
tests/test_cln-nip47.py
Normal file
1913
tests/test_cln-nip47.py
Normal file
File diff suppressed because it is too large
Load diff
1352
tests/test_clnnwc.py
1352
tests/test_clnnwc.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +1,21 @@
|
|||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from pathlib import Path
|
||||
from hashlib import sha256
|
||||
|
||||
import pytest
|
||||
|
||||
RUST_PROFILE = os.environ.get("RUST_PROFILE", "debug")
|
||||
COMPILED_PATH = Path.cwd() / "target" / RUST_PROFILE / "cln-nip47"
|
||||
DOWNLOAD_PATH = Path.cwd() / "tests" / "cln-nip47"
|
||||
plugin_dir = Path(__file__).parent.parent.resolve()
|
||||
COMPILED_PATH = plugin_dir / "target" / RUST_PROFILE / "cln-nip47"
|
||||
DOWNLOAD_PATH = plugin_dir / "tests" / "cln-nip47"
|
||||
|
||||
DOWNLOAD_HOLD_PATH = plugin_dir / "tests" / "hold"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_plugin(directory):
|
||||
def get_plugin():
|
||||
if COMPILED_PATH.is_file():
|
||||
return COMPILED_PATH
|
||||
elif DOWNLOAD_PATH.is_file():
|
||||
|
|
@ -21,6 +24,14 @@ def get_plugin(directory):
|
|||
raise ValueError("No files were found.")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_hold():
|
||||
if DOWNLOAD_HOLD_PATH.is_file():
|
||||
return DOWNLOAD_HOLD_PATH
|
||||
else:
|
||||
raise ValueError("No files were found.")
|
||||
|
||||
|
||||
def generate_random_label():
|
||||
label_length = 8
|
||||
random_label = "".join(
|
||||
|
|
@ -33,15 +44,6 @@ def generate_random_number():
|
|||
return random.randint(1, 20_000_000_000_000_00_000)
|
||||
|
||||
|
||||
def pay_with_thread(rpc, bolt11):
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
try:
|
||||
rpc.dev_pay(bolt11, dev_use_shadow=False)
|
||||
except Exception as e:
|
||||
LOGGER.info(f"holdinvoice: Error paying payment hash:{e}")
|
||||
pass
|
||||
|
||||
|
||||
def update_config_file_option(lightning_dir, option_name, option_value):
|
||||
with open(lightning_dir + "/config", "r") as file:
|
||||
lines = file.readlines()
|
||||
|
|
|
|||
1432
tests/uv.lock
generated
Normal file
1432
tests/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue