From 2529bb58639a0a63b2d2a9bf05f29cfb612dee76 Mon Sep 17 00:00:00 2001 From: daywalker90 <8257956+daywalker90@users.noreply.github.com> Date: Wed, 2 Apr 2025 17:38:52 +0200 Subject: [PATCH] init --- .github/workflows/ci.yml | 188 +++ .github/workflows/latest_v24.08.yml | 14 + .github/workflows/latest_v24.11.yml | 14 + .github/workflows/latest_v25.02.yml | 14 + .github/workflows/main_v24.08.yml | 24 + .github/workflows/main_v24.11.yml | 24 + .github/workflows/main_v25.02.yml | 24 + .github/workflows/release.yml | 104 ++ .gitignore | 4 + Cargo.lock | 2118 +++++++++++++++++++++++++++ Cargo.toml | 36 + LICENSE | 21 + README.md | 90 ++ coffee.yml | 9 + flake.lock | 82 ++ flake.nix | 68 + src/main.rs | 132 ++ src/nwc.rs | 443 ++++++ src/nwc_balance.rs | 53 + src/nwc_info.rs | 63 + src/nwc_invoice.rs | 74 + src/nwc_keysend.rs | 157 ++ src/nwc_lookups.rs | 504 +++++++ src/nwc_notifications.rs | 353 +++++ src/nwc_pay.rs | 222 +++ src/parse.rs | 56 + src/rpc.rs | 335 +++++ src/structs.rs | 76 + src/tasks.rs | 59 + src/util.rs | 98 ++ tests/requirements.txt | 4 + tests/setup.sh | 100 ++ tests/test_clnnwc.py | 1090 ++++++++++++++ tests/util.py | 63 + tools/tag-release.sh | 72 + 35 files changed, 6788 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/latest_v24.08.yml create mode 100644 .github/workflows/latest_v24.11.yml create mode 100644 .github/workflows/latest_v25.02.yml create mode 100644 .github/workflows/main_v24.08.yml create mode 100644 .github/workflows/main_v24.11.yml create mode 100644 .github/workflows/main_v25.02.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 coffee.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/main.rs create mode 100644 src/nwc.rs create mode 100644 src/nwc_balance.rs create mode 100644 src/nwc_info.rs create mode 100644 src/nwc_invoice.rs create mode 100644 src/nwc_keysend.rs create mode 100644 src/nwc_lookups.rs create mode 100644 src/nwc_notifications.rs create mode 100644 src/nwc_pay.rs create mode 100644 src/parse.rs create mode 100644 src/rpc.rs create mode 100644 src/structs.rs create mode 100644 src/tasks.rs create mode 100644 src/util.rs create mode 100644 tests/requirements.txt create mode 100755 tests/setup.sh create mode 100644 tests/test_clnnwc.py create mode 100644 tests/util.py create mode 100755 tools/tag-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9a75df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,188 @@ +name: CI + +# Cancel duplicate jobs +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + workflow_call: + inputs: + cln-version: + required: true + type: string + pyln-version: + required: true + type: string + tagged-release: + required: true + type: boolean + +jobs: + build: + name: Test CLN=${{ inputs.cln-version }}, OS=${{ matrix.os }}, PY=${{ matrix.python-version }}, BCD=${{ matrix.bitcoind-version }}, EXP=${{ matrix.experimental }}, DEP=${{ matrix.deprecated }} + strategy: + fail-fast: false + matrix: + bitcoind-version: ["28.0"] + experimental: [1] + deprecated: [0] + python-version: ["3.8", "3.11"] + os: ["ubuntu-24.04"] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create cache paths + run: | + sudo mkdir /usr/local/libexec + sudo mkdir /usr/local/libexec/c-lightning + sudo mkdir /usr/local/libexec/c-lightning/plugins + sudo chown -R $USER /usr/local/libexec + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Extract exact python and os version + id: exact_versions + run: | + PYTHON_VERSION=$(python --version 2>&1 | grep -oP '(?<=Python )\d+\.\d+(\.\d+)?') + echo "Python version: $PYTHON_VERSION" + echo "python_version=$PYTHON_VERSION" >> "$GITHUB_OUTPUT" + OS_VERSION=$(lsb_release -rs) + echo "OS version: $OS_VERSION" + echo "os_version=$OS_VERSION" >> $GITHUB_OUTPUT + + - name: Cache CLN + id: cache-cln + uses: actions/cache@v4 + with: + path: | + /usr/local/bin/lightning* + /usr/local/libexec/c-lightning + key: cache-cln-${{ inputs.cln-version }}-${{ steps.exact_versions.outputs.os_version }} + + - name: Cache bitcoind + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: /usr/local/bin/bitcoin* + key: cache-bitcoind-${{ matrix.bitcoind-version }}-${{ steps.exact_versions.outputs.os_version }} + + - name: Cache python dependencies + id: cache-python + uses: actions/cache@v4 + with: + path: venv + key: cache-python-${{ steps.exact_versions.outputs.python_version }}-${{ steps.exact_versions.outputs.os_version }}-${{ inputs.pyln-version }}-${{ hashFiles('tests/requirements.txt') }} + + - name: Download Bitcoin ${{ matrix.bitcoind-version }} & install binaries + if: ${{ steps.cache-bitcoind.outputs.cache-hit != 'true' }} + run: | + export BITCOIND_VERSION=${{ matrix.bitcoind-version }} + if [[ "${{ matrix.os }}" =~ "ubuntu" ]]; then + export TARGET_ARCH="x86_64-linux-gnu" + fi + if [[ "${{ matrix.os }}" =~ "macos" ]]; then + export TARGET_ARCH="x86_64-apple-darwin" + fi + wget https://bitcoincore.org/bin/bitcoin-core-${BITCOIND_VERSION}/bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz + tar -xzf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz + sudo mv bitcoin-${BITCOIND_VERSION}/bin/* /usr/local/bin + rm -rf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz bitcoin-${BITCOIND_VERSION} + + - name: Download Core Lightning ${{ inputs.cln-version }} & install binaries + if: ${{ contains(matrix.os, 'ubuntu') && steps.cache-cln.outputs.cache-hit != 'true' }} + run: | + url=$(curl -s https://api.github.com/repos/ElementsProject/lightning/releases/tags/${{ inputs.cln-version }} \ + | jq '.assets[] | select(.name | contains("22.04")) | .browser_download_url' \ + | tr -d '\"') + wget $url + sudo tar -xvf ${url##*/} -C /usr/local --strip-components=2 + echo "CLN_VERSION=$(lightningd --version)" >> "$GITHUB_OUTPUT" + + - name: Set up Rust + if: ${{ inputs.tagged-release == false}} + uses: dtolnay/rust-toolchain@stable + + - name: Set up protoc + if: ${{ inputs.tagged-release == false}} || contains(matrix.os, 'macos') && ${{ steps.cache-cln.outputs.cache-hit != 'true' }} + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Checkout Core Lightning ${{ inputs.cln-version }} + if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }} + uses: actions/checkout@v4 + with: + repository: 'ElementsProject/lightning' + path: 'lightning' + ref: ${{ inputs.cln-version }} + submodules: 'recursive' + + - name: Install System dependencies + run: | + if [[ "${{ matrix.os }}" =~ "macos" ]]; then + brew install autoconf automake libtool gnu-sed gettext libsodium sqlite + fi + + - name: Install Python dependencies + if: ${{ steps.cache-python.outputs.cache-hit != 'true' }} + run: | + python -m venv venv + source venv/bin/activate + python -m pip install -U pip poetry wheel + pip3 install "pyln-proto<=${{ inputs.pyln-version }}" "pyln-client<=${{ inputs.pyln-version }}" "pyln-testing<=${{ inputs.pyln-version }}" + pip3 install pytest-xdist pytest-test-groups pytest-timeout + if [ -f "tests/requirements.txt" ]; then + pip3 install -r tests/requirements.txt + fi + + - name: Compile Core Lightning ${{ inputs.cln-version }} & install binaries + if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }} + run: | + export EXPERIMENTAL_FEATURES=${{ matrix.experimental }} + export COMPAT=${{ matrix.deprecated }} + export VALGRIND=0 + source venv/bin/activate + + cd lightning + + poetry lock + poetry install + ./configure --disable-valgrind + poetry run make + sudo make install + + - name: Get plugin binary + run: | + source venv/bin/activate + if ${{ inputs.tagged-release }}; then + cd tests + ./setup.sh + cd .. + else + if [ -d "proto" ]; then + python -m grpc_tools.protoc --proto_path="proto" --python_out="tests" --grpc_python_out="tests" proto/*.proto + fi + + cargo build + fi + + - name: Run tests + run: | + export CLN_PATH=${{ github.workspace }}/lightning + export COMPAT=${{ matrix.deprecated }} + export EXPERIMENTAL_FEATURES=${{ matrix.experimental }} + export SLOW_MACHINE=1 + export TEST_DEBUG=1 + export TRAVIS=1 + export VALGRIND=0 + export PYTEST_TIMEOUT=600 + source venv/bin/activate + pytest -n=5 tests/test_*.py diff --git a/.github/workflows/latest_v24.08.yml b/.github/workflows/latest_v24.08.yml new file mode 100644 index 0000000..945a6e6 --- /dev/null +++ b/.github/workflows/latest_v24.08.yml @@ -0,0 +1,14 @@ +name: latest release on CLN v24.08.2 + +on: + release: + types: [published, edited] + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v24.08.2" + pyln-version: "24.08" + tagged-release: true \ No newline at end of file diff --git a/.github/workflows/latest_v24.11.yml b/.github/workflows/latest_v24.11.yml new file mode 100644 index 0000000..ae52fc9 --- /dev/null +++ b/.github/workflows/latest_v24.11.yml @@ -0,0 +1,14 @@ +name: latest release on CLN v24.11 + +on: + release: + types: [published, edited] + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v24.11" + pyln-version: "24.11" + tagged-release: true \ No newline at end of file diff --git a/.github/workflows/latest_v25.02.yml b/.github/workflows/latest_v25.02.yml new file mode 100644 index 0000000..9bf4874 --- /dev/null +++ b/.github/workflows/latest_v25.02.yml @@ -0,0 +1,14 @@ +name: latest release on CLN v25.02 + +on: + release: + types: [published, edited] + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v25.02" + pyln-version: "25.02" + tagged-release: true \ No newline at end of file diff --git a/.github/workflows/main_v24.08.yml b/.github/workflows/main_v24.08.yml new file mode 100644 index 0000000..4430f02 --- /dev/null +++ b/.github/workflows/main_v24.08.yml @@ -0,0 +1,24 @@ +name: main on CLN v24.08.2 + +on: + push: + branches: + - main + paths-ignore: + - 'tools/**' + - 'CHANGELOG.md' + - 'README.md' + - 'LICENSE' + - '.gitignore' + - 'coffee.yml' + - 'tests/setup.sh' + pull_request: + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v24.08.2" + pyln-version: "24.08" + tagged-release: false \ No newline at end of file diff --git a/.github/workflows/main_v24.11.yml b/.github/workflows/main_v24.11.yml new file mode 100644 index 0000000..fd68399 --- /dev/null +++ b/.github/workflows/main_v24.11.yml @@ -0,0 +1,24 @@ +name: main on CLN v24.11 + +on: + push: + branches: + - main + paths-ignore: + - 'tools/**' + - 'CHANGELOG.md' + - 'README.md' + - 'LICENSE' + - '.gitignore' + - 'coffee.yml' + - 'tests/setup.sh' + pull_request: + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v24.11" + pyln-version: "24.11" + tagged-release: false \ No newline at end of file diff --git a/.github/workflows/main_v25.02.yml b/.github/workflows/main_v25.02.yml new file mode 100644 index 0000000..da11ee0 --- /dev/null +++ b/.github/workflows/main_v25.02.yml @@ -0,0 +1,24 @@ +name: main on CLN v25.02 + +on: + push: + branches: + - main + paths-ignore: + - 'tools/**' + - 'CHANGELOG.md' + - 'README.md' + - 'LICENSE' + - '.gitignore' + - 'coffee.yml' + - 'tests/setup.sh' + pull_request: + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v25.02" + pyln-version: "25.02" + tagged-release: false \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4fbf8d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Build and release +on: + push: + tags: + - 'v*' + +jobs: + build: + name: build release binaries on ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-24.04"] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install rust + id: rust + uses: dtolnay/rust-toolchain@stable + - name: Install cross + if: contains(matrix.os, 'ubuntu') + run: | + cargo install cross --git https://github.com/cross-rs/cross + - name: Build unix + id: unix_build + if: contains(matrix.os, 'ubuntu') + run: | + cross build --profile optimized --locked --target x86_64-unknown-linux-gnu + cross build --profile optimized --locked --target armv7-unknown-linux-gnueabihf + cross build --profile optimized --locked --target aarch64-unknown-linux-gnu + tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-aarch64-linux-gnu.tar.gz" --transform 's|.*/||' "target/aarch64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}" + tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-armv7-linux-gnueabihf.tar.gz" --transform 's|.*/||' "target/armv7-unknown-linux-gnueabihf/optimized/${{ github.event.repository.name }}" + tar -czf "${{ github.event.repository.name }}-${{github.ref_name}}-x86_64-linux-gnu.tar.gz" --transform 's|.*/||' "target/x86_64-unknown-linux-gnu/optimized/${{ github.event.repository.name }}" + ls -alh + - name: Build macos + id: macos_build + if: contains(matrix.os, 'macos') + run: | + rustup target add aarch64-apple-darwin + export CROSSBUILD_MACOS_SDK="macosx13.1" + export SDKROOT=$(xcrun -sdk $CROSSBUILD_MACOS_SDK --show-sdk-path) + export MACOSX_DEPLOYMENT_TARGET=12.0 + cargo build --profile optimized --locked --target=x86_64-apple-darwin + cargo build --profile optimized --locked --target=aarch64-apple-darwin + lipo -create -output target/${{ github.event.repository.name }} target/aarch64-apple-darwin/optimized/${{ github.event.repository.name }} target/x86_64-apple-darwin/optimized/${{ github.event.repository.name }} + ditto -c -k --sequesterRsrc target/${{ github.event.repository.name }} ${{ github.event.repository.name }}-${{github.ref_name}}-universal-apple-darwin.zip + otool -l target/aarch64-apple-darwin/optimized/${{ github.event.repository.name }} | grep -A 5 LC_BUILD_VERSION + otool -l target/x86_64-apple-darwin/optimized/${{ github.event.repository.name }} | grep -A 5 LC_BUILD_VERSION + echo "macos_version=$MACOSX_DEPLOYMENT_TARGET" >> "$GITHUB_OUTPUT" + echo $(xcodebuild -showsdks) + ls -alh + - name: Upload unix artifacts + uses: actions/upload-artifact@v4 + if: contains(matrix.os, 'ubuntu') + with: + name: unix-binaries + path: | + ${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz + - name: Upload macos artifacts + uses: actions/upload-artifact@v4 + if: contains(matrix.os, 'macos') + with: + name: macos-binaries + path: | + ${{ github.event.repository.name }}-${{github.ref_name}}-universal-apple-darwin.zip + - name: Get rust version + id: rversion + run: | + echo "rust_version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + outputs: + rust-version: ${{ steps.rversion.outputs.rust_version }} + macos-version: ${{ steps.macos_build.outputs.macos_version }} + + release: + name: Github Release + needs: [build] + runs-on: "ubuntu-24.04" + permissions: + contents: write + steps: + - name: Get semver version from tag + id: tag_name + run: echo "current_version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + - name: Checkout code + uses: actions/checkout@v4 + - name: Get Changelog Entry + id: changelog_reader + uses: mindsers/changelog-reader-action@v2 + with: + validation_level: warn + version: ${{ steps.tag_name.outputs.current_version }} + path: ./CHANGELOG.md + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + - name: Release + uses: ncipollo/release-action@v1 + with: + allowUpdates: false + artifactErrorsFailBuild: true + body: "${{ steps.changelog_reader.outputs.changes }} \n\n### Release binaries info\n\n- Release binaries were built using rust ${{ needs.build.outputs.rust-version }}\n- Linux release binaries require glibc>=2.31" + artifacts: "${{ github.event.repository.name }}-${{github.ref_name}}-*.tar.gz" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3cdfca --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/tests/__pycache__/ +/venv/ +/result/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e8af7a6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2118 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + +[[package]] +name = "bech32" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" + +[[package]] +name = "bip39" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" +dependencies = [ + "bitcoin_hashes 0.13.0", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c85783c2fe40083ea54a33aa2f0ba58831d90fcd190f5bdc47e74e84d2a96ae" +dependencies = [ + "bech32 0.10.0-beta", + "bitcoin-internals", + "bitcoin_hashes 0.13.0", + "hex-conservative 0.1.2", + "hex_lit", + "secp256k1 0.28.2", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative 0.1.2", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.1", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cln-nip47" +version = "0.1.0" +dependencies = [ + "anyhow", + "cln-plugin", + "cln-rpc", + "hex", + "log", + "log-panics", + "nostr-sdk", + "parking_lot", + "regex", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "cln-plugin" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a0f6827e3c3dfa7f2e92bb0591b5be44c82b945ff88f534857b185810414ce" +dependencies = [ + "anyhow", + "bytes", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cln-rpc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4436e58f1fccb1faf69df9ac436ae9304b5c200c7d92e6c4229826bdd0a8d0d" +dependencies = [ + "anyhow", + "bitcoin", + "bytes", + "futures-util", + "hex", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + +[[package]] +name = "hex-conservative" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "log-panics" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dd8546191c1850ecf67d22f5ff00a935b890d0e84713159a55495cc2ac5f" +dependencies = [ + "log", +] + +[[package]] +name = "lru" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "negentropy" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" + +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "nostr" +version = "0.40.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=f7122f5#f7122f55c07368cd814f22593375ba3be49aeb8e" +dependencies = [ + "aes", + "base64", + "bech32 0.11.0", + "bip39", + "bitcoin_hashes 0.14.0", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.15", + "instant", + "regex", + "scrypt", + "secp256k1 0.29.1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.40.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=f7122f5#f7122f55c07368cd814f22593375ba3be49aeb8e" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.40.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=f7122f5#f7122f55c07368cd814f22593375ba3be49aeb8e" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "lru", + "negentropy 0.3.1", + "negentropy 0.5.0", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.40.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=f7122f5#f7122f55c07368cd814f22593375ba3be49aeb8e" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustls" +version = "0.23.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" + +[[package]] +name = "rustls-webpki" +version = "0.103.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes 0.13.0", + "secp256k1-sys 0.9.2", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.5", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots", +] + +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.0", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fcad9a8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "cln-nip47" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" + +[dependencies] +anyhow = "1" +log = { version = "0.4", features = ['std'] } +log-panics = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +tokio = { version = "1", features = ["fs","sync","rt-multi-thread"] } +cln-rpc = "0.4" +# cln-rpc = { path="../../lightning/cln-rpc/", version = "^0.4" } +cln-plugin = "0.4" +# cln-plugin = { path="../../lightning/plugins/", version = "^0.4" } +parking_lot = "0.12" + +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "f7122f5", features = ["nip47", "nip04", "nip44"]} +# nostr-sdk = { version = "0.40", features = ["nip47", "nip04", "nip44"]} + +uuid = { version = "1", features = ["v4"]} + +hex = "0.4" + +regex = "1" + + +[profile.optimized] +inherits = "release" +strip = "debuginfo" +codegen-units = 1 +lto = "fat" +debug = false \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1dd8faa --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 daywalker90 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f410178 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +[![latest release on CLN v25.02](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v25.02.yml) [![latest release on CLN v24.11](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.11.yml) [![latest release on CLN v24.08.2](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/latest_v24.08.yml) + +[![main on CLN v25.02](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v25.02.yml) [![main on CLN v24.11](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.11.yml) [![main on CLN v24.08.2](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.08.yml/badge.svg?branch=main)](https://github.com/daywalker90/cln-nip47/actions/workflows/main_v24.08.yml) + +# cln-nip47 +A core lightning plugin to connect wallets via Nostr Wallet Connect (NWC) as specified in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md). + +* [Installation](#installation) +* [Building](#building) +* [Documentation](#documentation) + +# Installation +For general plugin installation instructions see the plugins repo [README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation) + +Release binaries for +* x86_64-linux +* armv7-linux (Raspberry Pi 32bit) +* aarch64-linux (Raspberry Pi 64bit) + +can be found on the [release](https://github.com/daywalker90/cln-nip47/releases) page. If you are unsure about your architecture you can run ``uname -m``. + +They require ``glibc>=2.31``, which you can check with ``ldd --version``. + +# Building +You can build the plugin yourself instead of using the release binaries. +First clone the repo: + +``` +git clone https://github.com/daywalker90/cln-nip47.git +``` + +Install a recent rust version ([rustup](https://rustup.rs/) is recommended) and in the ``cln-nip47`` folder run: + +``` +cargo build --release +``` + +After that the binary will be here: ``target/release/cln-nip47`` + +Note: Release binaries are built using ``cross`` and the ``optimized`` profile. + +# Documentation + +## Options +* `nip47-relays`: Specify the relays that you want to use with your NWC. Can be set multiple times to use multiple relays, but it is highly recommended to use your own relay since public relays may limit content length, amount of public keys per IP or require unsupported things like proof of work or payments. Each NWC you create is a separate public key and the ``list_transactions`` method can have quite a large content length! You must set this atleast one time. + +## Methods +* **nip47-create** *label* [*budget_msat*] [*interval*] + * create a new NWC string (`uri`) with the currently configured relays. For example: ``nip47-create mynwc 10000 1d`` will let you spend 10 satoshis every day using that NWC + * ***label***: a label to identify this NWC + * ***budget_msat***: optional. Set an absolute budget in msat that this NWC is allowed to use. This will also be your balance in your wallet. If you ***don't*** set this, the NWC will be allowed to use your ***whole*** node balance and show that aswell in your wallet! Set it to ``0`` to disable paying anything with this NWC + * ***interval***: optional. Set an amount of time after which the budget will be refreshed ***to*** the amount specified in ``budget_msat``, e.g.:``5seconds`` or ``4weeks``. Supported time units are: + * seconds: "second", "seconds", "sec", "secs", "s" + * minutes: "minute", "minutes", "min", "mins", "m" + * hours: "hour", "hours", "h" + * days: "day", "days", "d" + * weeks: "week", "weeks", "w" + +* **nip47-revoke** *label* + * revoke and remove all data related to a previously created NWC with ``label`` + * ***label***: the label the NWC was created with + +* **nip47-budget** *label* [*budget_msat*] [*interval*] + * update/add an existing NWC budget a new NWC string with the currently configured relays. For example: ``nip47-create mynwc 10000 1d`` will let you spend 10 satoshis every day using that NWC + * ***label***: a label to identify this NWC + * ***budget_msat***: optional. Set an absolute budget in msat that this NWC is allowed to use. This will also be your balance in your wallet. If you ***don't*** set this, the NWC will be allowed to use your ***whole*** node balance and show that aswell in your wallet! Set it to ``0`` to disable paying anything with this NWC + * ***interval***: optional. Set an amount of time after which the budget will be refreshed ***to*** the amount specified in ``budget_msat``, e.g.:``5seconds`` or ``4weeks``. Supported time units are the same as in ``nip47-create`` + +* **nip47-list** [*label*] + * list all NWC configurations or just the one with ``label`` + * ***label***: optional. The label the NWC was created with + +## Supported NWC methods +* ``pay_invoice`` +* ``multi_pay_invoice`` +* ``pay_keysend`` (no ``preimage`` in request allowed since CLN only supports generating it itself) +* ``multi_pay_keysend`` (no ``preimage`` in request allowed since CLN only supports generating it itself) +* ``make_invoice`` +* ``lookup_invoice`` +* ``list_transactions`` +* ``get_balance`` +* ``get_info`` (no ``block_hash``) + +## Supported NWC notifications +* ``payment_received`` +* ``payment_sent`` + +## Supported content encryption: +* [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) +* [NIP-44v2](https://github.com/nostr-protocol/nips/blob/master/44.md) \ No newline at end of file diff --git a/coffee.yml b/coffee.yml new file mode 100644 index 0000000..e3dbf1a --- /dev/null +++ b/coffee.yml @@ -0,0 +1,9 @@ +plugin: + name: cln-nip47 + version: 0.1.0 + lang: rust + install: | + cargo build --release && cp target/release/cln-nip47 . && cargo clean + main: cln-nip47 +tipping: + bolt12: lno1pgykxmrw94hxjup5xutzzquqaupqnlcmgmpc5d7dgrmp85w6u046fqdfp9ze6mq5xjsw2mja3s diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4df37d2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "crane": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1721058578, + "narHash": "sha256-fs/PVa3H5dS1//4BjecWi3nitXm5fRObx0JxXIAo+JA=", + "owner": "ipetkov", + "repo": "crane", + "rev": "17e5109bb1d9fb393d70fba80988f7d70d1ded1a", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1721116560, + "narHash": "sha256-++TYlGMAJM1Q+0nMVaWBSEvEUjRs7ZGiNQOpqbQApCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9355fa86e6f27422963132c2c9aeedb0fb963d93", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a533264 --- /dev/null +++ b/flake.nix @@ -0,0 +1,68 @@ +{ + description = "Build a cargo project without extra checks"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + crane = { + url = "github:ipetkov/crane"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, crane, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + craneLib = crane.mkLib pkgs; + + # Common arguments can be set here to avoid repeating them later + # Note: changes here will rebuild all dependency crates + commonArgs = { + src = craneLib.cleanCargoSource ./.; + strictDeps = true; + + buildInputs = [ + # Add additional build inputs here + ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + # Additional darwin specific inputs can be set here + pkgs.libiconv + ]; + }; + + my-crate = craneLib.buildPackage (commonArgs // { + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + # Additional environment variables or build phases/hooks can be set + # here *without* rebuilding all dependency crates + # MY_CUSTOM_VAR = "some value"; + }); + in + { + checks = { + inherit my-crate; + }; + + packages.default = my-crate; + + apps.default = flake-utils.lib.mkApp { + drv = my-crate; + }; + + devShells.default = craneLib.devShell { + # Inherit inputs from checks. + checks = self.checks.${system}; + + # Additional dev-shell environment variables can be set directly + # MY_CUSTOM_DEVELOPMENT_VAR = "something else"; + + # Extra inputs can be added here; cargo and rustc are provided by default. + packages = [ + # pkgs.ripgrep + ]; + }; + }); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f5f98e8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,132 @@ +use std::{path::Path, time::Duration}; + +use anyhow::anyhow; +use cln_plugin::{ + options::{ConfigOption, StringArrayConfigOption}, + Builder, Plugin, +}; +use cln_rpc::{model::requests::ListdatastoreRequest, ClnRpc}; + +use nostr_sdk::*; +use nwc::run_nwc; +use nwc_notifications::{payment_received_handler, payment_sent_handler}; +use parse::read_startup_options; +use rpc::{nwc_budget, nwc_create, nwc_list, nwc_revoke}; +use structs::PluginState; +use tokio::time; +use util::load_nwc_store; + +mod nwc; +mod nwc_balance; +mod nwc_info; +mod nwc_invoice; +mod nwc_keysend; +mod nwc_lookups; +mod nwc_notifications; +mod nwc_pay; +mod parse; +mod rpc; +mod structs; +mod tasks; +mod util; + +const OPT_RELAYS: StringArrayConfigOption = ConfigOption::new_str_arr_no_default( + "nip47-relays", + "Nostr relays used for nwc. Can be stated multiple times.", +); +pub const PLUGIN_NAME: &str = "cln-nip47"; + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + std::env::set_var( + "CLN_PLUGIN_LOG", + "cln_plugin=info,cln_rpc=info,cln_nip47=debug,info", + ); + log_panics::init(); + + let state = PluginState::default(); + + let confplugin = match Builder::new(tokio::io::stdin(), tokio::io::stdout()) + .option(OPT_RELAYS) + .rpcmethod("nip47-create", "Create a new nwc", nwc_create) + .rpcmethod("nip47-revoke", "Revoke a nwc", nwc_revoke) + .rpcmethod("nip47-budget", "Set budget of a nwc", nwc_budget) + .rpcmethod("nip47-list", "List all nwc connections", nwc_list) + .subscribe("shutdown", shutdown_handler) + .subscribe("invoice_payment", payment_received_handler) + .subscribe("sendpay_success", payment_sent_handler) + .dynamic() + .configure() + .await? + { + Some(plugin) => { + match read_startup_options(&plugin, &state).await { + Ok(()) => &(), + Err(e) => return plugin.disable(format!("{}", e).as_str()).await, + }; + log::debug!("read startup options done"); + plugin + } + None => return Err(anyhow!("Error configuring cln-nip47!")), + }; + let plugin = confplugin.start(state).await?; + + { + let _guard = plugin.state().rpc_lock.lock().await; + + // Make sure incase of rapid nip47-create and plugin restarts info_events + // have a different timestamp and therefore ID so relays don't disconnect us + time::sleep(Duration::from_secs(1)).await; + + match load_nwcs(plugin.clone()).await { + Ok(_) => log::info!("All NWC's loaded"), + Err(e) => { + println!( + "{}", + serde_json::json!({"jsonrpc": "2.0", + "method": "log", + "params": {"level":"warn", "message":e.to_string()}}) + ); + return Err(anyhow!(e)); + } + } + } + + plugin.join().await +} + +async fn shutdown_handler( + plugin: Plugin, + _args: serde_json::Value, +) -> Result<(), anyhow::Error> { + let mut locked_handles = plugin.state().handles.lock().await; + for (_x, (client, _client_pubkey)) in locked_handles.drain() { + client.shutdown().await; + } + std::process::exit(0) +} + +async fn load_nwcs(plugin: Plugin) -> Result<(), anyhow::Error> { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + let labels = rpc + .call_typed(&ListdatastoreRequest { + key: Some(vec![PLUGIN_NAME.to_owned()]), + }) + .await?; + for datastore in labels.datastore.into_iter() { + let label = datastore.key.last().unwrap(); + let nwc_store = load_nwc_store(&mut rpc, label).await?; + + let client = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?; + + let mut client_handles = plugin.state().handles.lock().await; + client_handles.insert( + label.clone(), + (client, Keys::new(nwc_store.uri.secret).public_key()), + ); + } + Ok(()) +} diff --git a/src/nwc.rs b/src/nwc.rs new file mode 100644 index 0000000..efe768d --- /dev/null +++ b/src/nwc.rs @@ -0,0 +1,443 @@ +use std::time::Duration; + +use crate::nwc_balance::get_balance; +use crate::nwc_info::get_info; +use crate::nwc_invoice::make_invoice; +use crate::nwc_keysend::{multi_pay_keysend, pay_keysend}; +use crate::nwc_lookups::{list_transactions, lookup_invoice}; +use crate::nwc_pay::{multi_pay_invoice, pay_invoice}; +use crate::structs::{NwcStore, PluginState}; +use crate::tasks::budget_task; +use cln_plugin::Plugin; +use nostr_sdk::nips::*; +use nostr_sdk::Client; +use nostr_sdk::*; +use tokio::sync::oneshot; +use tokio::time; + +pub async fn run_nwc( + plugin: Plugin, + label: String, + nwc_store: NwcStore, +) -> Result { + let wallet_keys = Keys::new( + SecretKey::from_hex(&nwc_store.walletkey) + .map_err(|e| client::Error::Signer(SignerError::backend(e)))?, + ); + let client_pubkey = Keys::new(nwc_store.uri.secret).public_key(); + + let client = Client::new(wallet_keys.clone()); + + log::debug!("relay_count:{}", nwc_store.uri.relays.len()); + + for relay in nwc_store.uri.relays.iter() { + log::debug!("Adding relay: {}", relay); + client.add_relay(relay).await?; + } + + if nwc_store.interval_config.is_some() { + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(budget_task(rx, plugin.clone(), label.clone())); + plugin.state().budget_jobs.lock().insert(label.clone(), tx); + } + + let client_clone = client.clone(); + tokio::spawn(async move { + loop { + client_clone.connect().await; + client_clone + .wait_for_connection(Duration::from_secs(30)) + .await; + let relays = client_clone.relays().await; + let mut connected = false; + for (url, relay) in relays { + if relay.status() == RelayStatus::Connected { + connected = true; + } else { + log::info!("Could not connect to {}", url) + } + } + if !connected { + log::warn!("Could not connect to any relays!"); + time::sleep(Duration::from_secs(5)).await; + continue; + } + let info_event = match EventBuilder::new( + Kind::WalletConnectInfo, + "pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_invoice \ + lookup_invoice list_transactions get_balance get_info", + ) + .tag(Tag::parse(vec!["encryption", "nip44_v2 nip04"]).unwrap()) + .tag(Tag::parse(vec!["notifications", "payment_received payment_sent"]).unwrap()) + .sign_with_keys(&wallet_keys) + { + Ok(o) => o, + Err(e) => { + log::warn!("Could not sign info_event! {}", e); + time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + log::debug!("info_event:{:?}", info_event); + let send_result = match client_clone.send_event(&info_event).await { + Ok(o) => o, + Err(e) => { + log::warn!("Could not send info_event! {}", e); + client_clone.disconnect().await; + time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + if send_result.success.is_empty() { + log::warn!( + "None of the relays received the info_event! {}", + send_result + .failed + .into_values() + .collect::>() + .join(", ") + ); + client_clone.disconnect().await; + time::sleep(Duration::from_secs(5)).await; + continue; + } + + let filter = Filter::new() + .kind(Kind::WalletConnectRequest) + .author(client_pubkey); + + match client_clone.subscribe(filter, None).await { + Ok(_o) => (), + Err(e) => { + log::warn!("Could not subscribe to nwc events! {}", e); + time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + let client_clone_handler = client_clone.clone(); + match client_clone + .handle_notifications(|notification| { + let client_clone_handler = client_clone_handler.clone(); + let plugin_clone = plugin.clone(); + let label_clone = label.clone(); + let wallet_keys_clone = wallet_keys.clone(); + nwc_request_handler( + notification, + client_clone_handler, + plugin_clone, + label_clone, + wallet_keys_clone, + client_pubkey, + ) + }) + .await + { + Ok(()) => { + log::info!("NWC handler for `{}` stopped", label); + break; + } + Err(e) => log::warn!("NWC handler for `{}` had an error: {}", label, e), + }; + } + }); + Ok(client) +} + +async fn nwc_request_handler( + notification: RelayPoolNotification, + client: client::Client, + plugin: Plugin, + label: String, + wallet_keys: Keys, + client_pubkey: PublicKey, +) -> Result { + let (relay_url, subscription_id, event) = match notification { + RelayPoolNotification::Event { + relay_url, + subscription_id, + event, + } => (relay_url, subscription_id, event), + RelayPoolNotification::Message { + relay_url: _, + message: _, + } => return Ok(false), + RelayPoolNotification::Shutdown => return Ok(true), + }; + + if let Some(expi) = event.tags.expiration() { + if *expi < Timestamp::now() { + return Ok(false); + } + } + log::debug!( + "relay_url:{} subscription_id:{} {:?}", + relay_url, + subscription_id, + event + ); + let use_nip44; + let content = match nip44::decrypt(wallet_keys.secret_key(), &client_pubkey, &event.content) { + Ok(o) => { + use_nip44 = true; + o + } + Err(e) => { + log::debug!("Could not decrypt using NIP-44:{}. Trying NIP-04", e); + match nip04::decrypt(wallet_keys.secret_key(), &client_pubkey, &event.content) { + Ok(o) => { + use_nip44 = false; + o + } + Err(e) => { + log::warn!("Could not decrypt using NIP-04 or NIP-44:{}", e); + return Ok(false); + } + } + } + }; + log::debug!("Decrypted:{}", content); + let request: nip47::Request = match serde_json::from_str(&content) { + Ok(o) => o, + Err(e) => { + log::warn!("Error parsing nip47::Request! {}", e); + return Ok(false); + } + }; + + let responses = match request.params { + nip47::RequestParams::PayInvoice(pay_invoice_request) => { + vec![ + match pay_invoice(plugin.clone(), pay_invoice_request, &label).await { + Ok((o, id)) => ( + nip47::Response { + result_type: nip47::Method::PayInvoice, + error: None, + result: Some(nip47::ResponseResult::PayInvoice(o)), + }, + id, + ), + Err((e, id)) => ( + nip47::Response { + result_type: nip47::Method::PayInvoice, + error: Some(e), + result: None, + }, + id, + ), + }, + ] + } + nip47::RequestParams::MultiPayInvoice(multi_pay_invoice_request) => { + multi_pay_invoice(plugin.clone(), multi_pay_invoice_request, &label).await + } + nip47::RequestParams::PayKeysend(pay_keysend_request) => { + let id = if let Some(i) = pay_keysend_request.id.clone() { + i + } else { + pay_keysend_request.pubkey.clone() + }; + vec![ + match pay_keysend(plugin.clone(), pay_keysend_request, &label).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::PayKeysend, + error: None, + result: Some(nip47::ResponseResult::PayKeysend(o)), + }, + id, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::PayKeysend, + error: Some(e), + result: None, + }, + id, + ), + }, + ] + } + nip47::RequestParams::MultiPayKeysend(multi_pay_keysend_request) => { + multi_pay_keysend(plugin.clone(), multi_pay_keysend_request, &label).await + } + nip47::RequestParams::MakeInvoice(make_invoice_request) => { + vec![ + match make_invoice(plugin.clone(), make_invoice_request).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::MakeInvoice, + error: None, + result: Some(nip47::ResponseResult::MakeInvoice(o)), + }, + String::new(), + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::MakeInvoice, + error: Some(e), + result: None, + }, + String::new(), + ), + }, + ] + } + nip47::RequestParams::LookupInvoice(lookup_invoice_request) => { + vec![ + match lookup_invoice(plugin.clone(), lookup_invoice_request).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::LookupInvoice, + error: None, + result: Some(nip47::ResponseResult::LookupInvoice(o)), + }, + String::new(), + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::LookupInvoice, + error: Some(e), + result: None, + }, + String::new(), + ), + }, + ] + } + nip47::RequestParams::ListTransactions(list_transactions_request) => { + vec![ + match list_transactions(plugin.clone(), list_transactions_request).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::ListTransactions, + error: None, + result: Some(nip47::ResponseResult::ListTransactions(o)), + }, + String::new(), + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::ListTransactions, + error: Some(e), + result: None, + }, + String::new(), + ), + }, + ] + } + nip47::RequestParams::GetBalance => { + vec![match get_balance(plugin.clone(), &label).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::GetBalance, + error: None, + result: Some(nip47::ResponseResult::GetBalance(o)), + }, + String::new(), + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::GetBalance, + error: Some(e), + result: None, + }, + String::new(), + ), + }] + } + nip47::RequestParams::GetInfo => { + vec![match get_info(plugin.clone()).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::GetInfo, + error: None, + result: Some(nip47::ResponseResult::GetInfo(o)), + }, + String::new(), + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::GetInfo, + error: Some(e), + result: None, + }, + String::new(), + ), + }] + } + }; + for (response, id) in responses.into_iter() { + let response_str = match serde_json::to_string(&response) { + Ok(o) => o, + Err(e) => { + log::warn!("Error serializing response! {}", e); + continue; + } + }; + log::debug!("RESPONSE:{}", response_str); + + let content = if use_nip44 { + match nip44::encrypt( + wallet_keys.secret_key(), + &client_pubkey, + response_str, + nip44::Version::V2, + ) { + Ok(o) => o, + Err(e) => { + log::warn!("Error encrypting response with nip44! {}", e); + continue; + } + } + } else { + match nip04::encrypt(wallet_keys.secret_key(), &client_pubkey, response_str) { + Ok(o) => o, + Err(e) => { + log::warn!("Error encrypting response with nip04! {}", e); + continue; + } + } + }; + let mut response_builder = EventBuilder::new(Kind::WalletConnectResponse, content) + .tag(Tag::event(event.id)) + .tag(Tag::public_key(client_pubkey)); + if !id.is_empty() { + response_builder = response_builder.tag(Tag::custom( + TagKind::SingleLetter(SingleLetterTag { + character: Alphabet::D, + uppercase: false, + }), + vec![id], + )); + } + let response_event = match response_builder.sign_with_keys(&wallet_keys) { + Ok(o) => o, + Err(e) => { + log::warn!("Error signing reponse event! {}", e); + continue; + } + }; + let send_result = match client.send_event(&response_event).await { + Ok(o) => o, + Err(e) => { + log::warn!("Error sending response event! {}", e); + continue; + } + }; + if send_result.success.is_empty() { + log::warn!( + "None of the relays accepted our nwc response: {}", + send_result + .failed + .into_values() + .collect::>() + .join(", ") + ); + continue; + } + log::debug!("SENT RESPONSE {:?}", response_event); + } + + Ok(false) +} diff --git a/src/nwc_balance.rs b/src/nwc_balance.rs new file mode 100644 index 0000000..fb891e0 --- /dev/null +++ b/src/nwc_balance.rs @@ -0,0 +1,53 @@ +use std::path::Path; + +use cln_plugin::Plugin; +use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState, ClnRpc}; +use nostr_sdk::nips::*; + +use crate::{structs::PluginState, util::load_nwc_store}; + +pub async fn get_balance( + plugin: Plugin, + label: &String, +) -> Result { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let nwc_store = load_nwc_store(&mut rpc, label) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let balance = if let Some(bdgt_amt) = nwc_store.budget_msat { + bdgt_amt + } else { + let listpeerchannels = rpc + .call_typed(&ListpeerchannelsRequest { id: None }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let mut amount_msat = 0; + for chan in listpeerchannels.channels { + if chan.state == ChannelState::CHANNELD_NORMAL + || chan.state == ChannelState::CHANNELD_AWAITING_SPLICE + { + if let Some(spend) = chan.spendable_msat { + amount_msat += spend.msat() + } + } + } + amount_msat + }; + Ok(nip47::GetBalanceResponse { balance }) +} diff --git a/src/nwc_info.rs b/src/nwc_info.rs new file mode 100644 index 0000000..a659da8 --- /dev/null +++ b/src/nwc_info.rs @@ -0,0 +1,63 @@ +use std::{path::Path, str::FromStr}; + +use cln_plugin::Plugin; +use cln_rpc::{model::requests::GetinfoRequest, ClnRpc}; +use nostr_sdk::nips::*; +use nostr_sdk::*; + +use crate::structs::PluginState; + +pub async fn get_info( + plugin: Plugin, +) -> Result { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let get_info = rpc + .call_typed(&GetinfoRequest {}) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let pubkey = + nostr_sdk::secp256k1::PublicKey::from_str(&get_info.id.to_string()).map_err(|e| { + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + } + })?; + + let network = match get_info.network.as_str() { + "bitcoin" => "mainnet".to_owned(), + _ => get_info.network, + }; + + Ok(nip47::GetInfoResponse { + alias: get_info.alias, + color: Some(get_info.color), + pubkey: Some(pubkey), + network: Some(network), + block_height: Some(get_info.blockheight), + block_hash: None, + methods: vec![ + "pay_invoice".to_owned(), + "multi_pay_invoice".to_owned(), + "pay_keysend".to_owned(), + "multi_pay_keysend".to_owned(), + "make_invoice".to_owned(), + "lookup_invoice".to_owned(), + "list_transactions".to_owned(), + "get_balance".to_owned(), + "get_info".to_owned(), + ], + notifications: vec!["payment_received".to_owned(), "payment_sent".to_owned()], + }) +} diff --git a/src/nwc_invoice.rs b/src/nwc_invoice.rs new file mode 100644 index 0000000..271f98b --- /dev/null +++ b/src/nwc_invoice.rs @@ -0,0 +1,74 @@ +use std::{path::Path, str::FromStr}; + +use cln_plugin::Plugin; +use cln_rpc::{ + model::requests::InvoiceRequest, + primitives::{Amount, AmountOrAny, Sha256}, + ClnRpc, +}; +use nostr_sdk::nips::*; +use uuid::Uuid; + +use crate::structs::PluginState; + +pub async fn make_invoice( + plugin: Plugin, + params: nip47::MakeInvoiceRequest, +) -> Result { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let mut deschashonly = None; + + if let Some(d_hash) = params.description_hash { + if params.description.is_none() { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Must have description when using description_hash".to_owned(), + }); + } + let description = params.description.as_ref().unwrap(); + let my_description_hash = Sha256::const_hash(description.as_bytes()); + let description_hash = Sha256::from_str(&d_hash).map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + if my_description_hash != description_hash { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "description_hash not matching description".to_owned(), + }); + } + deschashonly = Some(true) + } + + match rpc + .call_typed(&InvoiceRequest { + cltv: None, + deschashonly, + expiry: params.expiry, + preimage: None, + exposeprivatechannels: None, + fallbacks: None, + amount_msat: AmountOrAny::Amount(Amount::from_msat(params.amount)), + description: params.description.unwrap_or("NWC make_invoice".to_owned()), + label: Uuid::new_v4().to_string(), + }) + .await + { + Ok(o) => Ok(nip47::MakeInvoiceResponse { + invoice: o.bolt11, + payment_hash: o.payment_hash.to_string(), + }), + Err(e) => Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }), + } +} diff --git a/src/nwc_keysend.rs b/src/nwc_keysend.rs new file mode 100644 index 0000000..6d3f5e5 --- /dev/null +++ b/src/nwc_keysend.rs @@ -0,0 +1,157 @@ +use std::{path::Path, str::FromStr, time::Duration}; + +use cln_plugin::Plugin; +use cln_rpc::{ + model::requests::KeysendRequest, + primitives::{Amount, PublicKey, TlvEntry, TlvStream}, + ClnRpc, +}; +use nostr_sdk::nips::*; +use tokio::time; + +use crate::{ + structs::PluginState, + util::{budget_amount_check, load_nwc_store, update_nwc_store}, +}; + +pub async fn pay_keysend( + plugin: Plugin, + params: nip47::PayKeysendRequest, + label: &String, +) -> Result { + let _guard = plugin.state().rpc_lock.lock().await; + + if params.preimage.is_some() { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "CLN generates the preimage itself!".to_owned(), + }); + } + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let mut nwc_store = load_nwc_store(&mut rpc, label) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + budget_amount_check(Some(params.amount), None, nwc_store.budget_msat).map_err(|e| { + nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: e.to_string(), + } + })?; + + let pubkey = PublicKey::from_str(¶ms.pubkey).map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: e.to_string(), + })?; + + let mut extratlvs = TlvStream { + entries: Vec::new(), + }; + for tlv in params.tlv_records { + extratlvs.entries.push(TlvEntry { + typ: tlv.tlv_type, + value: tlv.value.as_bytes().to_owned(), + }); + } + let extratlvs = if extratlvs.entries.is_empty() { + None + } else { + Some(extratlvs) + }; + + match rpc + .call_typed(&KeysendRequest { + exemptfee: None, + extratlvs, + label: None, + maxdelay: None, + maxfee: None, + maxfeepercent: None, + retry_for: None, + routehints: None, + amount_msat: Amount::from_msat(params.amount), + destination: pubkey, + }) + .await + { + Ok(o) => { + if let Some(ref mut bdg) = nwc_store.budget_msat { + *bdg = bdg.saturating_sub(o.amount_sent_msat.msat()); + update_nwc_store(&mut rpc, label, nwc_store) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + } + + let preimage = hex::encode(o.payment_preimage.to_vec()); + Ok(nip47::PayKeysendResponse { preimage }) + } + Err(e) => match e.code { + Some(c) => match c { + 203 | 205 | 210 => Err(nip47::NIP47Error { + code: nip47::ErrorCode::PaymentFailed, + message: e.to_string(), + }), + 206 => Err(nip47::NIP47Error { + code: nip47::ErrorCode::InsufficientBalance, + message: e.to_string(), + }), + _ => Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }), + }, + None => Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }), + }, + } +} + +pub async fn multi_pay_keysend( + plugin: Plugin, + params: nip47::MultiPayKeysendRequest, + label: &String, +) -> Vec<(nip47::Response, String)> { + let mut responses = Vec::new(); + for pay in params.keysends { + let result = pay_keysend(plugin.clone(), pay.clone(), label).await; + let id = if let Some(i) = pay.id { i } else { pay.pubkey }; + let response_res = match result { + Ok(resp) => ( + nip47::Response { + result_type: nip47::Method::MultiPayKeysend, + error: None, + result: Some(nip47::ResponseResult::MultiPayKeysend(resp)), + }, + id, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::MultiPayKeysend, + error: Some(e), + result: None, + }, + id, + ), + }; + responses.push(response_res); + time::sleep(Duration::from_millis(100)).await; + } + responses +} diff --git a/src/nwc_lookups.rs b/src/nwc_lookups.rs new file mode 100644 index 0000000..fb1edf0 --- /dev/null +++ b/src/nwc_lookups.rs @@ -0,0 +1,504 @@ +use std::{cmp::Reverse, path::Path, str::FromStr}; + +use cln_plugin::Plugin; +use cln_rpc::{ + model::{ + requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest}, + responses::{ListinvoicesInvoicesStatus, ListpaysPaysStatus}, + }, + primitives::Sha256, + ClnRpc, +}; +use nostr_sdk::nips::*; +use nostr_sdk::*; + +use crate::structs::PluginState; + +pub async fn lookup_invoice( + plugin: Plugin, + params: nip47::LookupInvoiceRequest, +) -> Result { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + if params.payment_hash.is_none() && params.invoice.is_none() { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Neither invoice nor payment_hash given".to_owned(), + }); + } + + let not_invoice_err = Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Not an invoice or invalid invoice".to_owned(), + }); + + let invoice = if params.payment_hash.is_some() && params.invoice.is_some() { + None + } else { + params.invoice + }; + + let invoices = rpc + .call_typed(&ListinvoicesRequest { + index: None, + invstring: invoice.clone(), + label: None, + limit: None, + offer_id: None, + payment_hash: params.payment_hash.clone(), + start: None, + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })? + .invoices; + + if invoices.len() == 1 { + let invoice_response = invoices.first().cloned().unwrap(); + let invstring = if invoice_response.bolt11.is_some() { + invoice_response.bolt11.unwrap() + } else { + invoice_response.bolt12.unwrap() + }; + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + if !invoice_decoded.valid { + return not_invoice_err; + } + + let description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.offer_description + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => return not_invoice_err, + }; + let description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => return not_invoice_err, + }; + + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else if let Some(a) = invoice_response.amount_msat { + a.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => return not_invoice_err, + }; + + let created_at = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap()) + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + Timestamp::from_secs(invoice_decoded.created_at.unwrap()) + } + _ => return not_invoice_err, + }; + + let preimage = invoice_response + .payment_preimage + .map(|p| hex::encode(p.to_vec())); + + Ok(nip47::LookupInvoiceResponse { + transaction_type: Some(nip47::TransactionType::Incoming), + invoice: Some(invstring), + description, + description_hash, + preimage, + payment_hash: invoice_response.payment_hash.to_string(), + amount, + fees_paid: 0, + created_at, + expires_at: Some(Timestamp::from_secs(invoice_response.expires_at)), + settled_at: invoice_response.paid_at.map(Timestamp::from_secs), + metadata: None, + }) + } else { + let payment_hash_hash = if let Some(hash) = params.payment_hash { + if let Ok(res) = Sha256::from_str(&hash) { + Some(res) + } else { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: "Could not convert payment hash".to_owned(), + }); + } + } else { + None + }; + + let pays = rpc + .call_typed(&ListpaysRequest { + bolt11: invoice, + index: None, + limit: None, + payment_hash: payment_hash_hash, + start: None, + status: None, + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })? + .pays; + + if pays.len() != 1 { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::NotFound, + message: "Transaction not found".to_owned(), + }); + } + let list_pay = pays.first().unwrap().clone(); + let invstring = if list_pay.bolt11.is_some() { + list_pay.bolt11.unwrap() + } else { + list_pay.bolt12.unwrap() + }; + + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + if !invoice_decoded.valid { + return not_invoice_err; + } + let description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.offer_description + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => return not_invoice_err, + }; + let description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => return not_invoice_err, + }; + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else if let Some(amt) = list_pay.amount_msat { + amt.msat() + } else { + return not_invoice_err; + } + } + _ => return not_invoice_err, + }; + let fees_paid = list_pay.amount_sent_msat.unwrap().msat() - amount; + let preimage = list_pay.preimage.map(|p| hex::encode(p.to_vec())); + + Ok(nip47::LookupInvoiceResponse { + transaction_type: Some(nip47::TransactionType::Outgoing), + invoice: Some(invstring), + description, + description_hash, + preimage, + payment_hash: list_pay.payment_hash.to_string(), + amount, + fees_paid, + created_at: Timestamp::from_secs(list_pay.created_at), + expires_at: None, + settled_at: list_pay.completed_at.map(Timestamp::from_secs), + metadata: None, + }) + } +} + +pub async fn list_transactions( + plugin: Plugin, + params: nip47::ListTransactionsRequest, +) -> Result, nip47::NIP47Error> { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let (query_invoices, query_payments) = match params.transaction_type { + Some(t) => match t { + nip47::TransactionType::Incoming => (true, false), + nip47::TransactionType::Outgoing => (false, true), + }, + None => (true, true), + }; + + let from = params.from.map(|f| f.as_u64()); + let until = params.until.map(|f| f.as_u64()); + let unpaid = params.unpaid.unwrap_or(false); + + let mut transactions: Vec = Vec::new(); + if query_invoices { + let list_invoices = rpc + .call_typed(&ListinvoicesRequest { + index: None, + invstring: None, + label: None, + limit: None, + offer_id: None, + payment_hash: None, + start: None, + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })? + .invoices; + + for list_invoice in list_invoices.into_iter() { + if list_invoice.status == ListinvoicesInvoicesStatus::EXPIRED { + continue; + } + if !unpaid && list_invoice.status == ListinvoicesInvoicesStatus::UNPAID { + continue; + } + let invstring = if list_invoice.bolt11.is_some() { + list_invoice.bolt11.unwrap() + } else { + list_invoice.bolt12.unwrap() + }; + + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + if !invoice_decoded.valid { + continue; + } + let created_at = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap()) + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + Timestamp::from_secs(invoice_decoded.created_at.unwrap()) + } + _ => continue, + }; + if let Some(f) = from { + if created_at.as_u64() < f { + continue; + } + } + if let Some(u) = until { + if created_at.as_u64() > u { + continue; + } + } + let description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.offer_description + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description + } + _ => continue, + }; + let description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => continue, + }; + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => continue, + }; + let expires_at = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_relative_expiry.map(|e_at| { + Timestamp::from_secs( + invoice_decoded.invoice_created_at.unwrap() + (e_at as u64), + ) + }) + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded + .expiry + .map(|e_at| Timestamp::from_secs(invoice_decoded.created_at.unwrap() + e_at)), + _ => continue, + }; + let preimage = list_invoice + .payment_preimage + .map(|p| hex::encode(p.to_vec())); + + transactions.push(nip47::LookupInvoiceResponse { + transaction_type: Some(nip47::TransactionType::Incoming), + invoice: Some(invstring), + description, + description_hash, + preimage, + payment_hash: list_invoice.payment_hash.to_string(), + amount, + fees_paid: 0, + created_at, + expires_at, + settled_at: list_invoice.paid_at.map(Timestamp::from_secs), + metadata: None, + }); + } + } + + if query_payments { + let list_pays = rpc + .call_typed(&ListpaysRequest { + bolt11: None, + index: None, + limit: None, + payment_hash: None, + start: None, + status: None, + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })? + .pays; + + for list_pay in list_pays.into_iter() { + if list_pay.status != ListpaysPaysStatus::COMPLETE { + continue; + } + let invstring = if list_pay.bolt11.is_some() { + list_pay.bolt11.unwrap() + } else { + list_pay.bolt12.unwrap() + }; + + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + if !invoice_decoded.valid { + continue; + } + let description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.offer_description + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description + } + _ => continue, + }; + let description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => continue, + }; + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else if let Some(amt) = list_pay.amount_msat { + amt.msat() + } else { + continue; + } + } + _ => continue, + }; + let fees_paid = list_pay.amount_sent_msat.unwrap().msat() - amount; + let preimage = list_pay.preimage.map(|p| hex::encode(p.to_vec())); + + transactions.push(nip47::LookupInvoiceResponse { + transaction_type: Some(nip47::TransactionType::Outgoing), + invoice: Some(invstring), + description, + description_hash, + preimage, + payment_hash: list_pay.payment_hash.to_string(), + amount, + fees_paid, + created_at: Timestamp::from_secs(list_pay.created_at), + expires_at: None, + settled_at: list_pay.completed_at.map(Timestamp::from_secs), + metadata: None, + }); + } + } + + transactions.sort_by_key(|t| Reverse(t.created_at)); + + if let Some(l) = params.limit { + if transactions.len() > (l as usize) { + transactions = transactions.drain(0..(l as usize)).collect() + } + } + + Ok(transactions) +} diff --git a/src/nwc_notifications.rs b/src/nwc_notifications.rs new file mode 100644 index 0000000..5455fd0 --- /dev/null +++ b/src/nwc_notifications.rs @@ -0,0 +1,353 @@ +use std::path::Path; +use std::str::FromStr; + +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::model::requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest}; +use cln_rpc::model::responses::ListpaysPaysStatus; +use cln_rpc::primitives::Sha256; +use cln_rpc::ClnRpc; + +use crate::structs::PluginState; + +use nostr_sdk::nips::*; +use nostr_sdk::*; + +pub async fn payment_received_handler( + plugin: Plugin, + args: serde_json::Value, +) -> Result<(), anyhow::Error> { + let label = args + .get("invoice_payment") + .ok_or_else(|| anyhow!("Malformed invoice_payment notification: missing invoice_payment"))? + .get("label") + .ok_or_else(|| anyhow!("Malformed invoice_payment notification: missing label"))? + .as_str() + .ok_or_else(|| anyhow!("label not a string"))?; + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + + let invoice_resp = rpc + .call_typed(&ListinvoicesRequest { + index: None, + invstring: None, + label: Some(label.to_owned()), + limit: None, + offer_id: None, + payment_hash: None, + start: None, + }) + .await? + .invoices; + + let invoice = invoice_resp + .first() + .ok_or_else(|| anyhow!("invoice not found"))?; + let invstring = if invoice.bolt11.is_some() { + invoice.bolt11.as_ref().unwrap() + } else { + invoice.bolt12.as_ref().unwrap() + }; + + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await?; + + let not_invoice_err = Err(anyhow!("Not an invoice or invalid invoice".to_owned())); + + if !invoice_decoded.valid { + return not_invoice_err; + } + + let description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => return not_invoice_err, + }; + let description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => return not_invoice_err, + }; + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else if let Some(a) = invoice.amount_msat { + a.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => return not_invoice_err, + }; + let created_at = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap()) + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + Timestamp::from_secs(invoice_decoded.created_at.unwrap()) + } + _ => return not_invoice_err, + }; + let preimage = hex::encode( + invoice + .payment_preimage + .ok_or_else(|| anyhow!("missing preimage from paid invoice"))? + .to_vec(), + ); + let settled_at = Timestamp::from_secs( + invoice + .paid_at + .ok_or_else(|| anyhow!("paid invoice missing paid_at time"))?, + ); + + let clients = plugin.state().handles.lock().await; + + for (client, client_pubkey) in clients.values() { + let signer = client.signer().await?; + let content = nip47::Notification { + notification_type: nip47::NotificationType::PaymentReceived, + notification: nip47::NotificationResult::PaymentReceived(nip47::PaymentNotification { + transaction_type: Some(nip47::TransactionType::Incoming), + invoice: invstring.clone(), + description: description.clone(), + description_hash: description_hash.clone(), + preimage: preimage.clone(), + payment_hash: invoice.payment_hash.to_string(), + amount, + fees_paid: 0, + created_at, + expires_at: None, + settled_at, + metadata: None, + }), + }; + let notification = serde_json::to_string(&content)?; + log::debug!("NOTIFICATION: {}", notification); + let content_encrypted_nip04 = signer.nip04_encrypt(client_pubkey, ¬ification).await?; + let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04) + .tag(Tag::public_key(*client_pubkey)) + .sign(&signer) + .await?; + let nip04_result = client.send_event(&event_nip04).await?; + if nip04_result.success.is_empty() { + log::warn!( + "None of the relays accepted our nip04 notification: {}", + nip04_result + .failed + .into_values() + .collect::>() + .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::>() + .join(", ") + ) + } + log::debug!("NIP44 NOTIFICATION SENT: {:?}", event_nip44); + } + + Ok(()) +} + +pub async fn payment_sent_handler( + plugin: Plugin, + args: serde_json::Value, +) -> Result<(), anyhow::Error> { + let payment_hash = args + .get("sendpay_success") + .ok_or_else(|| anyhow!("Malformed sendpay_success notification: missing sendpay_success"))? + .get("payment_hash") + .ok_or_else(|| anyhow!("Malformed sendpay_success notification: missing payment_hash"))? + .as_str() + .ok_or_else(|| anyhow!("payment_hash not a string"))? + .to_owned(); + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + + let pays_resp = rpc + .call_typed(&ListpaysRequest { + bolt11: None, + index: None, + limit: None, + payment_hash: Some(Sha256::from_str(&payment_hash)?), + start: None, + status: None, + }) + .await? + .pays; + + let pay = pays_resp + .first() + .ok_or_else(|| anyhow!("payment not found"))?; + + if pay.status != ListpaysPaysStatus::COMPLETE { + return Err(anyhow!("Payment not complete")); + } + + let invstring = if let Some(b11) = &pay.bolt11 { + b11 + } else if let Some(b12) = &pay.bolt12 { + b12 + } else { + &String::new() + }; + + let description; + let description_hash; + let amount; + let created_at = Timestamp::from_secs(pay.created_at); + let preimage = hex::encode( + pay.preimage + .ok_or_else(|| anyhow!("missing preimage from paid invoice"))? + .to_vec(), + ); + let settled_at = Timestamp::from_secs(pay.completed_at.unwrap()); + + if !invstring.is_empty() { + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: invstring.clone(), + }) + .await?; + + let not_invoice_err = Err(anyhow!("Not an invoice".to_owned())); + + if !invoice_decoded.valid { + return not_invoice_err; + } + + description = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.offer_description + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => return not_invoice_err, + }; + description_hash = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => None, + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => return not_invoice_err, + }; + amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else if let Some(a) = pay.amount_msat { + a.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => return not_invoice_err, + }; + } else { + description = pay.description.clone(); + description_hash = None; + amount = if let Some(amt) = pay.amount_msat { + amt.msat() + } else { + // Amount missing but required + 0 + } + } + + let fees_paid = pay.amount_sent_msat.unwrap().msat() - amount; + + let clients = plugin.state().handles.lock().await; + + for (client, client_pubkey) in clients.values() { + let signer = client.signer().await?; + let content = nip47::Notification { + notification_type: nip47::NotificationType::PaymentSent, + notification: nip47::NotificationResult::PaymentSent(nip47::PaymentNotification { + transaction_type: Some(nip47::TransactionType::Outgoing), + invoice: invstring.clone(), + description: description.clone(), + description_hash: description_hash.clone(), + preimage: preimage.clone(), + payment_hash: pay.payment_hash.to_string(), + amount, + fees_paid, + created_at, + expires_at: None, + settled_at, + metadata: None, + }), + }; + let notification = serde_json::to_string(&content)?; + log::debug!("NOTIFICATION: {}", notification); + let content_encrypted_nip04 = signer.nip04_encrypt(client_pubkey, ¬ification).await?; + let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04) + .tag(Tag::public_key(*client_pubkey)) + .sign(&signer) + .await?; + let nip04_result = client.send_event(&event_nip04).await?; + if nip04_result.success.is_empty() { + log::warn!( + "None of the relays accepted our nip04 notification: {}", + nip04_result + .failed + .into_values() + .collect::>() + .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::>() + .join(", ") + ) + } + log::debug!("NIP44 NOTIFICATION SENT: {:?}", event_nip44); + } + + Ok(()) +} diff --git a/src/nwc_pay.rs b/src/nwc_pay.rs new file mode 100644 index 0000000..50f281b --- /dev/null +++ b/src/nwc_pay.rs @@ -0,0 +1,222 @@ +use std::{path::Path, time::Duration}; + +use cln_plugin::Plugin; +use cln_rpc::{ + model::requests::{DecodeRequest, PayRequest}, + primitives::Amount, + ClnRpc, +}; +use nostr_sdk::nips::*; +use tokio::time; + +use crate::{ + structs::PluginState, + util::{budget_amount_check, load_nwc_store, update_nwc_store}, +}; + +pub async fn pay_invoice( + plugin: Plugin, + params: nip47::PayInvoiceRequest, + label: &String, +) -> Result<(nip47::PayInvoiceResponse, String), (nip47::NIP47Error, String)> { + let _guard = plugin.state().rpc_lock.lock().await; + + let id = params.id.clone().unwrap_or_default(); + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await + .map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + id.clone(), + ) + })?; + + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: params.invoice.clone(), + }) + .await + .map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + id.clone(), + ) + })?; + + let not_invoice_error = Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: "Not an invoice or invalid invoice".to_owned(), + }, + id.clone(), + )); + + if !invoice_decoded.valid { + return not_invoice_error; + } + + let id = if let Some(i) = params.id { + i + } else { + match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_payment_hash.unwrap().to_string() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.payment_hash.unwrap().to_string() + } + _ => return not_invoice_error, + } + }; + + let invoice_amt_msat = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + invoice_decoded.amount_msat.unwrap().msat() + } + _ => return not_invoice_error, + }; + + let mut nwc_store = load_nwc_store(&mut rpc, label).await.map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + id.clone(), + ) + })?; + + budget_amount_check(params.amount, Some(invoice_amt_msat), nwc_store.budget_msat).map_err( + |e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: e.to_string(), + }, + id.clone(), + ) + }, + )?; + + match rpc + .call_typed(&PayRequest { + amount_msat: params.amount.map(Amount::from_msat), + description: None, + exemptfee: None, + label: None, + localinvreqid: None, + maxdelay: None, + maxfee: None, + maxfeepercent: None, + partial_msat: None, + retry_for: None, + riskfactor: None, + exclude: None, + bolt11: params.invoice, + }) + .await + { + Ok(o) => { + if let Some(ref mut bdg) = nwc_store.budget_msat { + *bdg = bdg.saturating_sub(o.amount_sent_msat.msat()); + update_nwc_store(&mut rpc, label, nwc_store) + .await + .map_err(|e| { + ( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + id.clone(), + ) + })?; + } + + let preimage = hex::encode(o.payment_preimage.to_vec()); + Ok((nip47::PayInvoiceResponse { preimage }, id)) + } + Err(e) => match e.code { + Some(c) => match c { + 201 | 207 | 219 => Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: e.to_string(), + }, + id, + )), + 203 | 205 | 209 | 210 => Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::PaymentFailed, + message: e.to_string(), + }, + id, + )), + 206 => Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::InsufficientBalance, + message: e.to_string(), + }, + id, + )), + _ => Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + id, + )), + }, + None => Err(( + nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }, + id, + )), + }, + } +} + +pub async fn multi_pay_invoice( + plugin: Plugin, + 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 +} diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..21aab31 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,56 @@ +use anyhow::anyhow; +use cln_plugin::ConfiguredPlugin; + +use crate::{ + structs::{PluginState, TimeUnit}, + OPT_RELAYS, +}; + +pub async fn read_startup_options( + plugin: &ConfiguredPlugin, + state: &PluginState, +) -> Result<(), anyhow::Error> { + let relays_str = if let Some(relays) = plugin.option(&OPT_RELAYS).unwrap() { + if !relays.is_empty() { + relays + } else { + return Err(anyhow!( + "Empty `{}` option, must specify atleast one relay url!", + OPT_RELAYS.name() + )); + } + } else { + return Err(anyhow!( + "`{}` not set, must specify atleast one relay url!", + OPT_RELAYS.name() + )); + }; + let mut config = state.config.lock(); + for relay in relays_str.into_iter() { + log::debug!("RELAY:{}", relay); + config.relays.push(nostr_sdk::RelayUrl::parse(&relay)?); + } + Ok(()) +} + +pub fn parse_time_period(input: &str) -> Result { + let re = regex::Regex::new(r"(\d+)\s*([a-zA-Z]+)")?; + if let Some(caps) = re.captures(input) { + let value: u64 = caps[1].parse()?; + let unit = &caps[2].to_lowercase(); + + if let Ok(time_unit) = unit.parse() { + match time_unit { + TimeUnit::Second => Ok(value), + TimeUnit::Minute => Ok(value * 60), + TimeUnit::Hour => Ok(value * 60 * 60), + TimeUnit::Day => Ok(value * 60 * 60 * 24), + TimeUnit::Week => Ok(value * 60 * 60 * 24 * 7), + } + } else { + Err(anyhow!(format!("Unsupported time unit: {}", unit))) + } + } else { + Err(anyhow!("Invalid time format: {}", input)) + } +} diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..7c3fb97 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,335 @@ +use std::path::Path; + +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::model::requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, +}; +use cln_rpc::ClnRpc; +use nostr_sdk::nips::nip47::*; +use nostr_sdk::*; +use serde_json::json; +use tokio::sync::oneshot; + +use crate::nwc::run_nwc; +use crate::parse::parse_time_period; +use crate::structs::{BudgetIntervalConfig, NwcStore, PluginState}; +use crate::tasks::budget_task; +use crate::util::{load_nwc_store, update_nwc_store}; +use crate::PLUGIN_NAME; + +pub async fn nwc_create( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let _guard = plugin.state().rpc_lock.lock().await; + + let (label, budget_msat, interval_secs) = parse_full_args(args)?; + + let config = plugin.state().config.lock().clone(); + + let wallet_keys = Keys::generate(); + let client_keys = Keys::generate(); + let uri = NostrWalletConnectURI::new( + wallet_keys.public_key(), + config.relays.clone(), + client_keys.secret_key().clone(), + None, + ); + + let mut result = serde_json::Map::new(); + result.insert("uri".to_owned(), serde_json::Value::String(uri.to_string())); + result.insert("label".to_owned(), serde_json::Value::String(label.clone())); + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + + let interval_config = if let Some(bgt_msat) = budget_msat { + result.insert( + "budget_msat".to_owned(), + serde_json::Value::Number(bgt_msat.into()), + ); + if let Some(interval) = interval_secs { + let conf = BudgetIntervalConfig { + interval_secs: interval, + reset_budget_msat: bgt_msat, + last_reset: Timestamp::now().as_u64(), + }; + result.insert("interval_config".to_owned(), serde_json::to_value(&conf)?); + Some(conf) + } else { + None + } + } else { + None + }; + let nwc_store = NwcStore { + uri: uri.clone(), + walletkey: wallet_keys.secret_key().to_secret_hex(), + budget_msat, + interval_config, + }; + + rpc.call_typed(&DatastoreRequest { + generation: None, + hex: None, + mode: Some(DatastoreMode::MUST_CREATE), + string: Some(serde_json::to_string(&nwc_store)?), + key: vec![PLUGIN_NAME.to_owned(), label.clone()], + }) + .await?; + + let client = run_nwc(plugin.clone(), label.clone(), nwc_store.clone()).await?; + let mut locked_handles = plugin.state().handles.lock().await; + locked_handles.insert( + label.clone(), + (client, Keys::new(nwc_store.uri.secret).public_key()), + ); + Ok(serde_json::Value::Object(result)) +} + +pub async fn nwc_revoke( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let _guard = plugin.state().rpc_lock.lock().await; + + let label = parse_revoke_args(args)?; + + { + let mut locked_handles = plugin.state().handles.lock().await; + if let Some((client, _client_pubkey)) = locked_handles.remove(&label) { + client.shutdown().await; + } + + let mut budget_jobs = plugin.state().budget_jobs.lock(); + let job = budget_jobs.remove(&label); + if let Some(j) = job { + let _ = j.send(()); + } + } + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + + rpc.call_typed(&DeldatastoreRequest { + generation: None, + key: vec![PLUGIN_NAME.to_owned(), label.clone()], + }) + .await?; + + Ok(json!({"revoked":label})) +} + +pub async fn nwc_budget( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let _guard = plugin.state().rpc_lock.lock().await; + + let (label, budget_msat, interval_secs) = parse_full_args(args)?; + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + + { + let mut budget_jobs = plugin.state().budget_jobs.lock(); + let job = budget_jobs.remove(&label); + if let Some(j) = job { + let _ = j.send(()); + } + } + + let mut nwc_store = load_nwc_store(&mut rpc, &label).await?; + + if let Some(budget) = budget_msat { + nwc_store.budget_msat = Some(budget); + if let Some(interval) = interval_secs { + let interval_config = BudgetIntervalConfig { + interval_secs: interval, + reset_budget_msat: budget, + last_reset: Timestamp::now().as_u64(), + }; + nwc_store.interval_config = Some(interval_config.clone()); + } else { + nwc_store.interval_config = None; + } + } else { + nwc_store.budget_msat = None; + nwc_store.interval_config = None; + } + + if nwc_store.interval_config.is_some() { + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(budget_task(rx, plugin.clone(), label.clone())); + plugin.state().budget_jobs.lock().insert(label.clone(), tx); + } + + update_nwc_store(&mut rpc, &label, nwc_store).await?; + + Ok(json!({"budget_updated":label})) +} + +pub async fn nwc_list( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let _guard = plugin.state().rpc_lock.lock().await; + + let label = parse_list_args(args)?; + + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + + let mut nwcs = Vec::new(); + + if let Some(lbl) = label { + let nwc_store = load_nwc_store(&mut rpc, &lbl).await?; + nwcs.push(serde_json::to_value(nwc_store)?); + } else { + let nwcs_store = rpc + .call_typed(&ListdatastoreRequest { + key: Some(vec![PLUGIN_NAME.to_owned()]), + }) + .await? + .datastore; + + for datastore in nwcs_store.into_iter() { + let label = datastore.key.last().unwrap().to_owned(); + let nwc_store = load_nwc_store(&mut rpc, &label).await?; + nwcs.push(serde_json::to_value(nwc_store)?); + } + } + Ok(serde_json::Value::Array(nwcs)) +} + +fn parse_full_args( + args: serde_json::Value, +) -> Result<(String, Option, Option), anyhow::Error> { + match args { + serde_json::Value::String(s) => Ok((s, None, None)), + serde_json::Value::Array(values) => { + let label = values + .first() + .ok_or_else(|| anyhow!("label missing"))? + .as_str() + .ok_or_else(|| anyhow!("label is not a string"))? + .to_owned(); + let budget_msat = if let Some(b) = values.get(1) { + Some( + b.as_u64() + .ok_or_else(|| anyhow!("budget_msat is not an integer"))?, + ) + } else { + None + }; + let interval_secs = if let Some(t) = values.get(2) { + Some(parse_time_period( + t.as_str() + .ok_or_else(|| anyhow!("interval is not a string"))?, + )?) + } else { + None + }; + if interval_secs.is_some() && budget_msat.is_none() { + return Err(anyhow!("Must set `budget_msat` if you use `interval`")); + } + Ok((label, budget_msat, interval_secs)) + } + serde_json::Value::Object(map) => { + let label = map + .get("label") + .ok_or_else(|| anyhow!("label missing"))? + .as_str() + .ok_or_else(|| anyhow!("label is not a string"))? + .to_owned(); + let budget_msat = if let Some(b) = map.get("budget_msat") { + Some( + b.as_u64() + .ok_or_else(|| anyhow!("budget_msat is not an integer"))?, + ) + } else { + None + }; + let interval_secs = if let Some(t) = map.get("interval") { + Some(parse_time_period( + t.as_str() + .ok_or_else(|| anyhow!("interval is not a string"))?, + )?) + } else { + None + }; + if interval_secs.is_some() && budget_msat.is_none() { + return Err(anyhow!("Must set `budget_msat` if you use `interval`")); + } + Ok((label, budget_msat, interval_secs)) + } + _ => Err(anyhow!("Invalid argument type")), + } +} + +fn parse_revoke_args(args: serde_json::Value) -> Result { + match args { + serde_json::Value::String(s) => Ok(s), + serde_json::Value::Array(values) => { + let label = values + .first() + .ok_or_else(|| anyhow!("label missing"))? + .as_str() + .ok_or_else(|| anyhow!("label is not a string"))? + .to_owned(); + Ok(label) + } + serde_json::Value::Object(map) => { + let label = map + .get("label") + .ok_or_else(|| anyhow!("label missing"))? + .as_str() + .ok_or_else(|| anyhow!("label is not a string"))? + .to_owned(); + Ok(label) + } + _ => Err(anyhow!("Invalid argument type")), + } +} + +fn parse_list_args(args: serde_json::Value) -> Result, anyhow::Error> { + match args { + serde_json::Value::String(s) => Ok(Some(s)), + serde_json::Value::Array(values) => { + let label = if let Some(v) = values.first() { + Some( + v.as_str() + .ok_or_else(|| anyhow!("label is not a string"))? + .to_owned(), + ) + } else { + None + }; + + Ok(label) + } + serde_json::Value::Object(map) => { + let label = if let Some(v) = map.get("label") { + Some( + v.as_str() + .ok_or_else(|| anyhow!("label is not a string"))? + .to_owned(), + ) + } else { + None + }; + + Ok(label) + } + _ => Err(anyhow!("Invalid argument type")), + } +} diff --git a/src/structs.rs b/src/structs.rs new file mode 100644 index 0000000..d34c5cc --- /dev/null +++ b/src/structs.rs @@ -0,0 +1,76 @@ +use std::{collections::HashMap, str::FromStr, sync::Arc}; + +use nostr_sdk::client; +use nostr_sdk::nips::nip47; +use nostr_sdk::nostr; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; + +#[derive(Clone)] +pub struct PluginState { + pub config: Arc>, + pub handles: Arc>>, + pub rpc_lock: Arc>, + pub budget_jobs: Arc>>>, +} +impl PluginState { + pub fn default() -> PluginState { + PluginState { + config: Arc::new(Mutex::new(Config::default())), + handles: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + rpc_lock: Arc::new(tokio::sync::Mutex::new(())), + budget_jobs: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +#[derive(Clone, Debug)] +pub struct Config { + pub relays: Vec, +} +impl Config { + pub fn default() -> Config { + Config { relays: Vec::new() } + } +} + +#[derive(Debug)] +pub enum TimeUnit { + Second, + Minute, + Hour, + Day, + Week, +} +impl FromStr for TimeUnit { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "second" | "seconds" | "sec" | "secs" | "s" => Ok(TimeUnit::Second), + "minute" | "minutes" | "min" | "mins" | "m" => Ok(TimeUnit::Minute), + "hour" | "hours" | "h" => Ok(TimeUnit::Hour), + "day" | "days" | "d" => Ok(TimeUnit::Day), + "week" | "weeks" | "w" => Ok(TimeUnit::Week), + _ => Err(format!("Unsupported time unit: {}", s)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BudgetIntervalConfig { + pub interval_secs: u64, + pub reset_budget_msat: u64, + pub last_reset: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NwcStore { + pub uri: nip47::NostrWalletConnectURI, + pub walletkey: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub budget_msat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interval_config: Option, +} diff --git a/src/tasks.rs b/src/tasks.rs new file mode 100644 index 0000000..d56ea7e --- /dev/null +++ b/src/tasks.rs @@ -0,0 +1,59 @@ +use std::{path::Path, time::Duration}; + +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::ClnRpc; +use nostr_sdk::Timestamp; +use tokio::{sync::oneshot, time}; + +use crate::{ + structs::PluginState, + util::{load_nwc_store, update_nwc_store}, +}; + +pub async fn budget_task( + mut rx: oneshot::Receiver<()>, + plugin: Plugin, + label: String, +) -> Result<(), anyhow::Error> { + let mut rpc = ClnRpc::new( + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file), + ) + .await?; + loop { + let mut nwc_store = load_nwc_store(&mut rpc, &label).await?; + let interval_config = nwc_store + .interval_config + .as_mut() + .ok_or_else(|| anyhow!("interval_config disappeared!"))?; + let now = Timestamp::now().as_u64(); + log::debug!( + "interval:{} now:{} prev:{}", + interval_config.interval_secs, + now, + interval_config.last_reset + ); + let next_reset = std::cmp::max( + interval_config + .interval_secs + .saturating_sub(now.saturating_sub(interval_config.last_reset)), + 1, + ); + tokio::select! { + _ = &mut rx => { + log::info!("Stopping budget task for {}", label); + break; + } + _ = time::sleep(Duration::from_secs(next_reset)) => { + log::info!("Refreshing budget for {}",label); + *nwc_store.budget_msat + .as_mut() + .ok_or_else(||anyhow!("budget_msat missing"))? = interval_config.reset_budget_msat; + interval_config.last_reset = Timestamp::now().as_u64(); + update_nwc_store(&mut rpc, &label, nwc_store).await?; + log::info!("Done refreshing budget for {}",label); + } + } + } + Ok(()) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..8a5abef --- /dev/null +++ b/src/util.rs @@ -0,0 +1,98 @@ +use anyhow::anyhow; +use cln_rpc::{ + model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest}, + ClnRpc, +}; + +use crate::{structs::NwcStore, PLUGIN_NAME}; + +pub fn budget_amount_check( + request_amt_msat: Option, + invoice_amt_msat: Option, + budget_msat: Option, +) -> Result<(), anyhow::Error> { + log::debug!( + "checking budget and amounts for request:{:?} invoice:{:?} budget:{:?}", + request_amt_msat, + invoice_amt_msat, + budget_msat + ); + if request_amt_msat.is_none() && invoice_amt_msat.is_none() { + return Err(anyhow!("No amount given to check budget against!")); + } + if let Some(req_amt) = request_amt_msat { + if let Some(inv_amt) = invoice_amt_msat { + if req_amt != inv_amt { + return Err(anyhow!("Amount from request and invoice differ!")); + } + } + } + + if let Some(bdgt_msat) = budget_msat { + if let Some(req_amt) = request_amt_msat { + if bdgt_msat < req_amt { + return Err(anyhow!("Payment exceeds budget!")); + } + } + if let Some(inv_amt) = invoice_amt_msat { + if bdgt_msat < inv_amt { + return Err(anyhow!("Payment exceeds budget!")); + } + } + } + + Ok(()) +} + +pub async fn load_nwc_store(rpc: &mut ClnRpc, label: &String) -> Result { + let nwc_store_store = rpc + .call_typed(&ListdatastoreRequest { + key: Some(vec![PLUGIN_NAME.to_owned(), label.clone()]), + }) + .await? + .datastore; + let nwc_store_str = nwc_store_store + .first() + .ok_or_else(|| anyhow!("No datastore found for: {}", label))? + .string + .as_ref() + .ok_or_else(|| anyhow!("Malformed nwc_store datastore: missing string"))?; + let nwc_store: NwcStore = serde_json::from_str(nwc_store_str)?; + log::debug!("loaded nwc store for label:{}", label); + Ok(nwc_store) +} + +pub async fn update_nwc_store( + rpc: &mut ClnRpc, + label: &String, + nwc_store: NwcStore, +) -> Result<(), anyhow::Error> { + rpc.call_typed(&DatastoreRequest { + key: vec![PLUGIN_NAME.to_owned(), label.clone()], + generation: None, + hex: None, + mode: Some(DatastoreMode::CREATE_OR_REPLACE), + string: Some(serde_json::to_string(&nwc_store)?), + }) + .await?; + log::debug!("stored nwc store for label:{}", label); + Ok(()) +} + +#[test] +fn test_budget_check() { + assert!(budget_amount_check(Some(1), Some(1), Some(2)).is_ok()); + assert!(budget_amount_check(Some(1), Some(2), Some(2)).is_err()); + assert!(budget_amount_check(Some(2), Some(2), Some(1)).is_err()); + assert!(budget_amount_check(Some(2), None, None).is_ok()); + assert!(budget_amount_check(Some(2), None, Some(2)).is_ok()); + + assert!(budget_amount_check(None, None, None).is_err()); + assert!(budget_amount_check(None, None, Some(2)).is_err()); + assert!(budget_amount_check(Some(0), None, Some(1)).is_ok()); + assert!(budget_amount_check(Some(0), None, Some(0)).is_ok()); + assert!(budget_amount_check(None, Some(0), Some(1)).is_ok()); + assert!(budget_amount_check(None, Some(0), Some(0)).is_ok()); + assert!(budget_amount_check(Some(0), Some(0), Some(1)).is_ok()); + assert!(budget_amount_check(Some(0), Some(0), Some(0)).is_ok()); +} diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..bb18cbc --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +pytest-asyncio<0.24 +nostr-sdk +PyYAML +nostr_relay diff --git a/tests/setup.sh b/tests/setup.sh new file mode 100755 index 0000000..8ae9603 --- /dev/null +++ b/tests/setup.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -x +# Get the directory of the script +script_dir=$(dirname -- "$(readlink -f -- "$0")") + +cargo_toml_path="$script_dir/../Cargo.toml" + +# Use grep and awk to extract the name and version +name=$(awk -F'=' '/^\[package\]/ { in_package = 1 } in_package && /name/ { gsub(/[" ]/, "", $2); print $2; exit }' "$cargo_toml_path") +version=$(awk -F'=' '/^\[package\]/ { in_package = 1 } in_package && /version/ { gsub(/[" ]/, "", $2); print $2; exit }' "$cargo_toml_path") + +get_platform_file_end() { + machine=$(uname -m) + kernel=$(uname -s) + + case $kernel in + Darwin) + echo 'universal-apple-darwin.zip' + ;; + Linux) + case $machine in + x86_64) + echo 'x86_64-linux-gnu.tar.gz' + ;; + armv7l) + echo 'armv7-linux-gnueabihf.tar.gz' + ;; + aarch64) + echo 'aarch64-linux-gnu.tar.gz' + ;; + *) + echo "No self-compiled binary found and unsupported release-architecture: $machine" >&2 + exit 1 + ;; + esac + ;; + *) + echo "No self-compiled binary found and unsupported OS: $kernel" >&2 + exit 1 + ;; + esac +} +platform_file_end=$(get_platform_file_end) +archive_file=$name-v$version-$platform_file_end + +github_url="https://github.com/daywalker90/$name/releases/download/v$version/$archive_file" + + +# Download the archive using curl +if ! curl -L "$github_url" -o "$script_dir/$archive_file"; then + echo "Error downloading the file from $github_url" >&2 + # exit 1 +fi + +# Extract the contents +if [[ $archive_file == *.tar.gz ]]; then + if ! tar -xzvf "$script_dir/$archive_file" -C "$script_dir"; then + echo "Error extracting the contents of $archive_file" >&2 + # exit 1 + fi +elif [[ $archive_file == *.zip ]]; then + if ! unzip "$script_dir/$archive_file" -d "$script_dir"; then + echo "Error extracting the contents of $archive_file" >&2 + # exit 1 + fi +else + echo "Unknown archive format or unsupported file extension: $archive_file" >&2 + # exit 1 +fi + + +# Function to check if a Python package is installed +check_package() { + python_exec="$1" + package_name="$2" + if $python_exec -c "import $package_name" &> /dev/null; then + return 0 + else + return 1 + fi +} + +proto_path="$script_dir/../proto" +if [ -d "$proto_path" ]; then + # Check if the package is installed in the first Python executable + if check_package "$TEST_DIR/bin/python3" "grpc"; then + python_exec="$TEST_DIR/bin/python3" + elif check_package "python3" "grpc"; then + python_exec="python3" + else + echo "Error: Package 'grpcio' is not installed" >&2 + exit 1 + fi + + # Generate grpc files + if ! "$python_exec" -m grpc_tools.protoc --proto_path="$proto_path" --python_out=$script_dir --grpc_python_out=$script_dir $proto_path/*.proto; then + echo "Error generating grpc files" >&2 + exit 1 + fi +fi diff --git a/tests/test_clnnwc.py b/tests/test_clnnwc.py new file mode 100644 index 0000000..ea45577 --- /dev/null +++ b/tests/test_clnnwc.py @@ -0,0 +1,1090 @@ +#!/usr/bin/python + +import hashlib +import importlib.resources as pkg_resources +import json +import logging +import socket +import subprocess +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from threading import Thread + +import pytest +import pytest_asyncio +import yaml +from pyln.testing.fixtures import * # noqa: F403 +from pyln.testing.utils import RpcError, wait_for +from util import generate_random_label, get_plugin # noqa: F401 + +if sys.version_info >= (3, 9): + from nostr_sdk import ( + Alphabet, + Client, + EventBuilder, + Filter, + Keys, + KeysendTlvRecord, + Kind, + ListTransactionsRequest, + LookupInvoiceRequest, + MakeInvoiceRequest, + NostrSdkError, + NostrSigner, + NostrWalletConnectUri, + Nwc, + PayInvoiceRequest, + PayKeysendRequest, + SingleLetterTag, + Tag, + TagKind, + ) + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_get_balance(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ], + ) + node_balance = l1.rpc.call("listpeerchannels", {})["channels"][0]["spendable_msat"] + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + balance = await nwc.get_balance() + assert balance == 3000 + + uri_str = l1.rpc.call("nip47-create", ["test2"])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + balance = await nwc.get_balance() + assert balance == node_balance + + uri_str = l1.rpc.call("nip47-create", ["test3", 0])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + balance = await nwc.get_balance() + assert balance == 0 + + with pytest.raises(RpcError, match="not an integer"): + uri_str = l1.rpc.call("nip47-create", ["test3", -1])["uri"] + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_get_info(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1 = node_factory.get_node( + options={ + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + ) + node_get_info = l1.rpc.call("getinfo", {}) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + get_info = await nwc.get_info() + assert get_info.alias == node_get_info["alias"] + assert get_info.block_height == node_get_info["blockheight"] + assert get_info.color == node_get_info["color"] + assert get_info.methods == [ + "pay_invoice", + "multi_pay_invoice", + "pay_keysend", + "multi_pay_keysend", + "make_invoice", + "lookup_invoice", + "list_transactions", + "get_balance", + "get_info", + ] + assert get_info.network == "regtest" + assert get_info.notifications == ["payment_received", "payment_sent"] + assert get_info.pubkey == node_get_info["id"] + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_make_invoice(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1 = node_factory.get_node( + options={ + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + invoice = await nwc.make_invoice( + MakeInvoiceRequest( + amount=3000, description="test1", description_hash=None, expiry=None + ) + ) + node_invoice = l1.rpc.call("decode", [invoice.invoice]) + assert invoice.payment_hash == node_invoice["payment_hash"] + assert node_invoice["amount_msat"] == 3000 + assert node_invoice["expiry"] == 604800 + assert node_invoice["description"] == "test1" + assert "description_hash" not in node_invoice + + invoice = await nwc.make_invoice( + MakeInvoiceRequest( + amount=3001, + description="test2", + description_hash=hashlib.sha256("test2".encode()).hexdigest(), + expiry=120, + ) + ) + node_invoice = l1.rpc.call("listinvoices", {"invstring": invoice.invoice})[ + "invoices" + ][0] + node_invoice_decode = l1.rpc.call("decode", [invoice.invoice]) + assert invoice.payment_hash == node_invoice["payment_hash"] + assert node_invoice["amount_msat"] == 3001 + assert node_invoice_decode["expiry"] == 120 + assert node_invoice["description"] == "test2" + assert ( + node_invoice_decode["description_hash"] + == hashlib.sha256("test2".encode()).hexdigest() + ) + with pytest.raises( + NostrSdkError.Generic, match="Must have description when using description_hash" + ): + await nwc.make_invoice( + MakeInvoiceRequest( + amount=3001, + description=None, + description_hash=hashlib.sha256("test2".encode()).hexdigest(), + expiry=120, + ) + ) + with pytest.raises( + NostrSdkError.Generic, match="description_hash not matching description" + ): + await nwc.make_invoice( + MakeInvoiceRequest( + amount=3001, + description="test1", + description_hash=hashlib.sha256("test2".encode()).hexdigest(), + expiry=120, + ) + ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_pay_keysend(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ], + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + result = await nwc.pay_keysend( + PayKeysendRequest( + id="id123", amount=1000, pubkey=l2.info["id"], preimage=None, tlv_records=[] + ) + ) + pay = l1.rpc.call("listpays", {})["pays"][0] + assert result.preimage == pay["preimage"] + + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_keysend( + PayKeysendRequest( + id="id123", + amount=2001, + pubkey=l2.info["id"], + preimage=None, + tlv_records=[KeysendTlvRecord(tlv_type=1234, value="a5c7e3d9b")], + ) + ) + with pytest.raises( + NostrSdkError.Generic, match="CLN generates the preimage itself" + ): + await nwc.pay_keysend( + PayKeysendRequest( + id="id123", + amount=2001, + pubkey=l2.info["id"], + preimage="or3ijro3ijroi", + tlv_records=[KeysendTlvRecord(tlv_type=1234, value="a5c7e3d9b")], + ) + ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_multi_keysend(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2, l3 = node_factory.line_graph( + 3, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + {"log-level": "debug"}, + ], + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3010])["uri"] + LOGGER.info(uri_str) + uri = NostrWalletConnectUri.parse(uri_str) + content = { + "method": "multi_pay_keysend", + "params": { + "keysends": [ + {"id": "4da52c32a1", "pubkey": l2.info["id"], "amount": 1000}, + {"id": "3da52c32a1", "pubkey": l3.info["id"], "amount": 2000}, + ], + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + await client.add_relay(f"ws://{url}") + await client.connect() + await client.send_event(event) + + content = { + "method": "multi_pay_keysend", + "params": { + "keysends": [ + {"id": "4da52c32a1", "pubkey": l2.info["id"], "amount": 5}, + {"id": "3da52c32a1", "pubkey": l3.info["id"], "amount": 5}, + ], + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + await client.add_relay(f"ws://{url}") + await client.connect() + await client.send_event(event) + + response_filter = Filter().kind(Kind(23195)).author(uri.public_key()) + events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10)) + start_time = datetime.now() + while events.len() < 4 and (datetime.now() - start_time) < timedelta(seconds=10): + time.sleep(1) + events = await client.fetch_events( + response_filter, timeout=timedelta(seconds=1) + ) + assert events.len() == 4 + error_events = [] + success_events = [] + for event in events.to_vec(): + LOGGER.info(event) + assert event.tags().find( + TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.D)) + ) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + if "result" in content and content["result"] is not None: + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 3 + assert len(error_events) == 1 + for content in success_events: + assert content["result_type"] == "multi_pay_keysend" + assert content["result"]["preimage"] is not None + for content in error_events: + assert content["result_type"] == "multi_pay_keysend" + assert content["error"]["message"] == "Payment exceeds budget!" + assert content["error"]["code"] == "QUOTA_EXCEEDED" + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_lookup_invoice(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2, l3 = node_factory.line_graph( + 3, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + {"log-level": "debug"}, + ], + ) + l1.rpc.call( + "pay", + { + "bolt11": l2.rpc.call( + "invoice", + { + "amount_msat": 500000000, + "label": generate_random_label(), + "description": "balancechannel", + }, + )["bolt11"] + }, + ) + wait_for( + lambda: l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 3001 + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + invoice = await nwc.make_invoice( + MakeInvoiceRequest( + amount=3000, description="test1", description_hash=None, expiry=None + ) + ) + + with pytest.raises( + NostrSdkError.Generic, match="Neither invoice nor payment_hash given" + ): + await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=None, + ) + ) + + invoice_rpc = l1.rpc.call("listinvoices", {"invstring": invoice.invoice})[ + "invoices" + ][0] + invoice_decode = l1.rpc.call("decode", [invoice.invoice]) + + invoice_lookup = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=invoice.payment_hash, + invoice=None, + ) + ) + assert invoice_lookup.invoice == invoice.invoice + assert invoice_lookup.amount == 3000 + assert invoice_lookup.description == "test1" + assert invoice_lookup.created_at.as_secs() == invoice_decode["created_at"] + assert invoice_lookup.description_hash is None + assert invoice_lookup.expires_at.as_secs() == invoice_rpc["expires_at"] + assert invoice_lookup.fees_paid == 0 + assert invoice_lookup.metadata is None + assert invoice_lookup.payment_hash == invoice_rpc["payment_hash"] + assert invoice_lookup.transaction_type.name == "INCOMING" + assert invoice_lookup.settled_at is None + + invoice_lookup = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=invoice.invoice, + ) + ) + assert invoice_lookup.invoice == invoice.invoice + assert invoice_lookup.amount == 3000 + assert invoice_lookup.description == "test1" + assert invoice_lookup.created_at.as_secs() == invoice_decode["created_at"] + assert invoice_lookup.description_hash is None + assert invoice_lookup.expires_at.as_secs() == invoice_rpc["expires_at"] + assert invoice_lookup.fees_paid == 0 + assert invoice_lookup.metadata is None + assert invoice_lookup.payment_hash == invoice_rpc["payment_hash"] + assert invoice_lookup.transaction_type.name == "INCOMING" + assert invoice_lookup.settled_at is None + + invoice = await nwc.make_invoice( + MakeInvoiceRequest( + amount=3001, + description="test2", + description_hash=hashlib.sha256("test2".encode()).hexdigest(), + expiry=1000, + ) + ) + + invoice_rpc = l1.rpc.call("listinvoices", {"invstring": invoice.invoice})[ + "invoices" + ][0] + invoice_decode = l1.rpc.call("decode", [invoice.invoice]) + + invoice_lookup = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=invoice.payment_hash, + invoice=None, + ) + ) + assert invoice_lookup.invoice == invoice.invoice + assert invoice_lookup.amount == 3001 + assert invoice_lookup.description is None + assert invoice_lookup.created_at.as_secs() == invoice_decode["created_at"] + assert ( + invoice_lookup.description_hash == hashlib.sha256("test2".encode()).hexdigest() + ) + assert invoice_lookup.expires_at.as_secs() == invoice_rpc["expires_at"] + assert invoice_lookup.fees_paid == 0 + assert invoice_lookup.metadata is None + assert invoice_lookup.payment_hash == invoice_rpc["payment_hash"] + assert invoice_lookup.transaction_type.name == "INCOMING" + assert invoice_lookup.settled_at is None + + l2.rpc.call("pay", {"bolt11": invoice.invoice}) + invoice_rpc = l1.rpc.call("listinvoices", {"invstring": invoice.invoice})[ + "invoices" + ][0] + invoice_lookup = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=invoice.payment_hash, + invoice=None, + ) + ) + assert invoice_lookup.invoice == invoice.invoice + assert invoice_lookup.amount == 3001 + assert invoice_lookup.description is None + assert invoice_lookup.created_at.as_secs() == invoice_decode["created_at"] + assert ( + invoice_lookup.description_hash == hashlib.sha256("test2".encode()).hexdigest() + ) + assert invoice_lookup.expires_at.as_secs() == invoice_rpc["expires_at"] + assert invoice_lookup.fees_paid == 0 + assert invoice_lookup.metadata is None + assert invoice_lookup.payment_hash == invoice_rpc["payment_hash"] + assert invoice_lookup.transaction_type.name == "INCOMING" + assert invoice_lookup.settled_at.as_secs() == invoice_rpc["paid_at"] + + invoice = l3.rpc.call( + "invoice", + { + "amount_msat": 4000, + "label": generate_random_label(), + "description": "outgoing", + }, + ) + invoice_decode = l3.rpc.call("decode", [invoice["bolt11"]]) + pay = l1.rpc.call("pay", {"bolt11": invoice["bolt11"]}) + invoice_rpc = l3.rpc.call("listinvoices", {"invstring": invoice["bolt11"]})[ + "invoices" + ][0] + invoice_lookup = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=pay["payment_hash"], + invoice=None, + ) + ) + assert invoice_lookup.invoice == invoice["bolt11"] + assert invoice_lookup.amount == 4000 + assert invoice_lookup.description == "outgoing" + assert invoice_lookup.created_at.as_secs() == invoice_decode["created_at"] + assert invoice_lookup.description_hash is None + assert invoice_lookup.expires_at is None + assert invoice_lookup.fees_paid == 1 + assert invoice_lookup.metadata is None + assert invoice_lookup.payment_hash == invoice_rpc["payment_hash"] + assert invoice_lookup.transaction_type.name == "OUTGOING" + assert invoice_lookup.settled_at.as_secs() == invoice_rpc["paid_at"] + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_list_transactions(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ], + ) + l1.rpc.call( + "pay", + { + "bolt11": l2.rpc.call( + "invoice", + { + "amount_msat": 500000000, + "label": generate_random_label(), + "description": "balancechannel", + }, + )["bolt11"] + }, + ) + wait_for( + lambda: l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 30001 + ) + uri_str = l1.rpc.call("nip47-create", ["test1"])["uri"] + LOGGER.info(uri_str) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + for i in range(10): + invoice = l2.rpc.call( + "invoice", + { + "label": generate_random_label(), + "description": "test1", + "amount_msat": 3000, + }, + ) + result = await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + assert result.preimage is not None + for i in range(10): + invoice = await nwc.make_invoice( + MakeInvoiceRequest( + amount=3000, description="test2", description_hash=None, expiry=None + ) + ) + result = l2.rpc.call("pay", [invoice.invoice]) + result = await nwc.list_transactions( + ListTransactionsRequest( + _from=None, + until=None, + limit=None, + offset=None, + unpaid=None, + transaction_type=None, + ) + ) + assert len(result) == 21 + for tx in result: + tx.description is not None + tx.invoice is not None + tx.amount is not None + tx.created_at is not None + tx.description_hash is None + tx.expires_at is None + tx.preimage is not None + tx.settled_at is not None + tx.metadata is None + tx.transaction_type is not None + tx.payment_hash is not None + tx.fees_paid is not None + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_notifications(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2, l3 = node_factory.line_graph( + 3, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + {"log-level": "debug"}, + ], + ) + uri_str = l1.rpc.call("nip47-create", ["test1"])["uri"] + LOGGER.info(uri_str) + + uri = NostrWalletConnectUri.parse(uri_str) + nwc = Nwc(uri) + + invoice = l3.rpc.call( + "invoice", + { + "label": generate_random_label(), + "description": "test1", + "amount_msat": 500000000, + }, + ) + pay1 = await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + invoice1_rpc = l3.rpc.call("listinvoices", {"invstring": invoice["bolt11"]})[ + "invoices" + ][0] + invoice1_decode = l3.rpc.call("decode", [invoice["bolt11"]]) + pay1_list = l1.rpc.call("listpays", {"bolt11": invoice["bolt11"]})["pays"][0] + + wait_for( + lambda: l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 3000 + ) + wait_for( + lambda: l3.rpc.call("listpeerchannels", [l2.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 3000 + ) + + result = await nwc.make_invoice( + MakeInvoiceRequest( + amount=3000, description="test2", description_hash=None, expiry=None + ) + ) + pay2 = l3.rpc.call("pay", {"bolt11": result.invoice}) + invoice2_list = l1.rpc.call("listinvoices", {"invstring": result.invoice})[ + "invoices" + ][0] + invoice2_decode = l3.rpc.call("decode", [result.invoice]) + + response_filter = Filter().kind(Kind(23196)).author(uri.public_key()) + events = await nostr_client.fetch_events( + response_filter, timeout=timedelta(seconds=10) + ) + start_time = datetime.now() + while events.len() < 2 and (datetime.now() - start_time) < timedelta(seconds=10): + time.sleep(1) + events = await nostr_client.fetch_events( + response_filter, timeout=timedelta(seconds=1) + ) + assert events.len() == 2 + signer = NostrSigner.keys(Keys(uri.secret())) + received_events = [] + sent_events = [] + for event in events.to_vec(): + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + if content["notification_type"] == "payment_received": + received_events.append(content) + if content["notification_type"] == "payment_sent": + sent_events.append(content) + assert content["notification"]["preimage"] is not None + assert len(received_events) == 1 + assert len(sent_events) == 1 + assert received_events[0]["notification"]["type"] == "incoming" + assert received_events[0]["notification"]["invoice"] == result.invoice + assert received_events[0]["notification"]["description"] == "test2" + assert "description_hash" not in received_events[0]["notification"] + assert received_events[0]["notification"]["preimage"] == pay2["payment_preimage"] + assert received_events[0]["notification"]["payment_hash"] == pay2["payment_hash"] + assert received_events[0]["notification"]["amount"] == 3000 + assert received_events[0]["notification"]["fees_paid"] == 0 + assert ( + received_events[0]["notification"]["created_at"] + == invoice2_decode["created_at"] + ) + assert "expires_at" not in received_events[0]["notification"] + assert received_events[0]["notification"]["settled_at"] == invoice2_list["paid_at"] + assert "metadata" not in received_events[0]["notification"] + + assert sent_events[0]["notification"]["type"] == "outgoing" + assert sent_events[0]["notification"]["invoice"] == invoice["bolt11"] + assert sent_events[0]["notification"]["description"] == "test1" + assert "description_hash" not in sent_events[0]["notification"] + assert sent_events[0]["notification"]["preimage"] == pay1.preimage + assert ( + sent_events[0]["notification"]["payment_hash"] == invoice1_rpc["payment_hash"] + ) + assert sent_events[0]["notification"]["amount"] == 500000000 + assert sent_events[0]["notification"]["fees_paid"] == 5001 + assert sent_events[0]["notification"]["created_at"] == invoice1_decode["created_at"] + assert "expires_at" not in sent_events[0]["notification"] + assert sent_events[0]["notification"]["settled_at"] == pay1_list["completed_at"] + assert "metadata" not in sent_events[0]["notification"] + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_pay_invoice(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ], + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3001])["uri"] + LOGGER.info(uri_str) + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + ) + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + result = await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + pay = l1.rpc.call("listpays", {"payment_hash": invoice["payment_hash"]})["pays"][0] + assert result.preimage == pay["preimage"] + + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test2", "amount_msat": 1}, + ) + with pytest.raises(NostrSdkError.Generic, match="msatoshi parameter unnecessary"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=1, invoice=invoice["bolt11"]) + ) + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test3", "amount_msat": 2}, + ) + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_multi_pay(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ], + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 30000])["uri"] + LOGGER.info(uri_str) + uri = NostrWalletConnectUri.parse(uri_str) + invoice1 = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + ) + invoice2 = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test2", "amount_msat": 4000}, + ) + invoice3 = l2.rpc.call( + "invoice", + { + "label": generate_random_label(), + "description": "test3", + "amount_msat": 23001, + }, + ) + content = { + "method": "multi_pay_invoice", + "params": { + "invoices": [ + {"id": "4da52c32a1", "invoice": invoice1["bolt11"]}, + {"id": "3da52c32a1", "invoice": invoice2["bolt11"]}, + {"id": "af3g2k2o11", "invoice": invoice3["bolt11"]}, + ], + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip44_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + await client.add_relay(f"ws://{url}") + await client.connect() + await client.send_event(event) + + response_filter = Filter().kind(Kind(23195)).author(uri.public_key()) + events = await client.fetch_events(response_filter, timeout=timedelta(seconds=10)) + start_time = datetime.now() + while events.len() < 3 and (datetime.now() - start_time) < timedelta(seconds=10): + time.sleep(1) + events = await client.fetch_events( + response_filter, timeout=timedelta(seconds=1) + ) + assert events.len() == 3 + success_pays = [] + error_pays = [] + for event in events.to_vec(): + LOGGER.info(event) + d_tag = event.tags().find( + TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.D)) + ) + content = await signer.nip44_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + assert content["result_type"] == "multi_pay_invoice" + if "result" in content and content["result"] is not None: + assert d_tag is not None + assert content["result"]["preimage"] is not None + success_pays.append(content) + if "error" in content and content["error"] is not None: + assert d_tag.content() == "af3g2k2o11" + assert content["error"]["code"] == "QUOTA_EXCEEDED" + assert content["error"]["message"] == "Payment exceeds budget!" + error_pays.append(content) + assert len(success_pays) == 2 + assert len(error_pays) == 1 + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher") +@pytest.mark.asyncio +async def test_persistency(node_factory, get_plugin, nostr_client): # noqa: F811 + nostr_client, relay_port = nostr_client + url = f"127.0.0.1:{relay_port}" + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "plugin": get_plugin, + "nip47-relays": f"ws://{url}", + }, + {"log-level": "debug"}, + ], + ) + uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] + LOGGER.info(uri_str) + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + ) + l1.rpc.call("plugin", {"subcommand": "stop", "plugin": "cln-nip47"}) + l1.rpc.call( + "plugin", + { + "subcommand": "start", + "plugin": str(get_plugin), + }, + ) + l1.daemon.wait_for_log("All NWC's loaded") + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + result = await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + assert result.preimage is not None + + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 1}, + ) + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + l1.rpc.call("plugin", {"subcommand": "stop", "plugin": "cln-nip47"}) + l1.rpc.call( + "plugin", + { + "subcommand": "start", + "plugin": str(get_plugin), + }, + ) + l1.daemon.wait_for_log("All NWC's loaded") + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + + revoke = l1.rpc.call("nip47-revoke", ["test1"]) + assert revoke["revoked"] == "test1" + + uri_str = l1.rpc.call("nip47-create", ["test1", 3000, "10sec"])["uri"] + nwc = Nwc(NostrWalletConnectUri.parse(uri_str)) + + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + ) + invoice_exceeded = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + ) + result = await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + assert result.preimage is not None + + list = l1.rpc.call("nip47-list", ["test1"])[0] + assert list["budget_msat"] == 0 + + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice_exceeded["bolt11"]) + ) + + time.sleep(11) + + list = l1.rpc.call("nip47-list", ["test1"])[0] + assert list["budget_msat"] == 3000 + + invoice = l2.rpc.call( + "invoice", + {"label": generate_random_label(), "description": "test1", "amount_msat": 3000}, + ) + result = await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice["bolt11"]) + ) + assert result.preimage is not None + + list = l1.rpc.call("nip47-list", ["test1"])[0] + assert list["budget_msat"] == 0 + + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice_exceeded["bolt11"]) + ) + + l1.rpc.call("plugin", {"subcommand": "stop", "plugin": "cln-nip47"}) + l1.rpc.call( + "plugin", + { + "subcommand": "start", + "plugin": str(get_plugin), + }, + ) + l1.daemon.wait_for_log("All NWC's loaded") + + with pytest.raises(NostrSdkError.Generic, match="Payment exceeds budget"): + await nwc.pay_invoice( + PayInvoiceRequest(id=None, amount=None, invoice=invoice_exceeded["bolt11"]) + ) + + time.sleep(11) + + list = l1.rpc.call("nip47-list", ["test1"])[0] + assert list["budget_msat"] == 3000 + + +@pytest_asyncio.fixture(scope="function") +async def nostr_client(nostr_relay): + port = nostr_relay + keys = Keys.generate() + signer = NostrSigner.keys(keys) + + client = Client(signer) + + relay_url = f"ws://127.0.0.1:{port}" + await client.add_relay(relay_url) + await client.connect() + + yield client, port + + await client.disconnect() + + +@pytest_asyncio.fixture(scope="module") +async def nostr_relay(test_base_dir): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + dynamic_port = s.getsockname()[1] + s.close() + + try: + config_file = pkg_resources.files("nostr_relay").joinpath("config.yaml") + except KeyError: + raise FileNotFoundError("config.yaml not found in the nostr package") + + with open(config_file, "r") as file: + config = yaml.safe_load(file) + + config["gunicorn"]["bind"] = f"127.0.0.1:{dynamic_port}" + config["authentication"]["valid_urls"] = [ + f"ws://localhost:{dynamic_port}", + f"ws://127.0.0.1:{dynamic_port}", + ] + sqlite_file = Path(test_base_dir) / "nostr.sqlite3" + config["storage"]["sqlalchemy.url"] = f"sqlite+aiosqlite:///{str(sqlite_file)}" + config["storage"]["validators"] = [ + "nostr_relay.validators.is_signed", + "nostr_relay.validators.is_recent", + "nostr_relay.validators.is_not_hellthread", + ] + + config_file = Path(test_base_dir) / "config.yaml" + + with open(config_file, "w") as file: + yaml.safe_dump(config, file) + + LOGGER.info(f"{config_file}") + process = subprocess.Popen( + ["nostr-relay", "-c", config_file, "serve"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + stdout_thread = Thread(target=log_pipe, args=(process.stdout, LOGGER, logging.INFO)) + stderr_thread = Thread( + target=log_pipe, args=(process.stderr, LOGGER, logging.ERROR) + ) + stdout_thread.start() + stderr_thread.start() + + time.sleep(2) + + yield dynamic_port + + process.terminate() + process.wait() + + stdout_thread.join() + stderr_thread.join() + + +def log_pipe(pipe, logger, log_level): + while True: + line = pipe.readline() + if not line: + break + logger.log(log_level, line.strip()) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..1f6fbf8 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,63 @@ +import logging +import os +import random +import string +from pathlib import Path + +import pytest + +RUST_PROFILE = os.environ.get("RUST_PROFILE", "debug") +COMPILED_PATH = Path.cwd() / "target" / RUST_PROFILE / "cln-nip47" +DOWNLOAD_PATH = Path.cwd() / "tests" / "cln-nip47" + + +@pytest.fixture +def get_plugin(directory): + if COMPILED_PATH.is_file(): + return COMPILED_PATH + elif DOWNLOAD_PATH.is_file(): + return DOWNLOAD_PATH + else: + raise ValueError("No files were found.") + + +def generate_random_label(): + label_length = 8 + random_label = "".join( + random.choice(string.ascii_letters) for _ in range(label_length) + ) + return random_label + + +def generate_random_number(): + return random.randint(1, 20_000_000_000_000_00_000) + + +def pay_with_thread(rpc, bolt11): + LOGGER = logging.getLogger(__name__) + try: + rpc.dev_pay(bolt11, dev_use_shadow=False) + except Exception as e: + LOGGER.info(f"holdinvoice: Error paying payment hash:{e}") + pass + + +def update_config_file_option(lightning_dir, option_name, option_value): + with open(lightning_dir + "/config", "r") as file: + lines = file.readlines() + + for i, line in enumerate(lines): + if line.startswith(option_name): + lines[i] = option_name + "=" + option_value + "\n" + + with open(lightning_dir + "/config", "w") as file: + file.writelines(lines) + + +def experimental_anchors_check(node_factory): + l1 = node_factory.get_node() + version = l1.rpc.getinfo()["version"] + if version.startswith("v23"): + return True + else: + return False diff --git a/tools/tag-release.sh b/tools/tag-release.sh new file mode 100755 index 0000000..fb98b3c --- /dev/null +++ b/tools/tag-release.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Function to check if a string matches semantic versioning pattern +is_semver() { + if [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + return 0 + else + return 1 + fi +} + +# Function to check if a file contains a specific version +file_contains_version() { + local version="$1" + local file="$2" + + if grep -q "$version" "$file"; then + return 0 + else + return 1 + fi +} + +# Function to check if there are pending changes in Git +has_pending_changes() { + if [ -n "$(git status --porcelain)" ]; then + return 0 + else + return 1 + fi +} + +# Main script +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +version="$1" + +if ! is_semver "$version"; then + echo "Invalid semantic version: $version" + exit 1 +fi + +if ! file_contains_version "$version" "CHANGELOG.md"; then + echo "Version $version not found in CHANGELOG.md" + exit 1 +fi + +# Extract version from Cargo.toml [package] section +cargo_version=$(awk -F '"' '/^\[package\]/ {p=1} p && /version/ {print $2; exit}' Cargo.toml) +coffee_version=$(grep '^[[:space:]]*version:' coffee.yml | awk '{print $2}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +if [ "$cargo_version" != "$version" ]; then + echo "Version $version does not match the version in Cargo.toml" + exit 1 +fi + +if [ "$coffee_version" != "$version" ]; then + echo "Version $version does not match the version in coffee.yml" + exit 1 +fi + +# Check for pending changes +if has_pending_changes; then + echo "There are pending changes in the repository. Please commit or stash them before tagging." + exit 1 +fi + +# If the version exists in both files, tag the current commit +git tag -a "v$version" -m "Version $version"