Compare commits
No commits in common. "main" and "v1.0.0" have entirely different histories.
45 changed files with 2426 additions and 6937 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "lnbits_nwc_provider",
|
"name": "lnbits_nwc_provider",
|
||||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
|
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-contrib/features/poetry:2": {}
|
||||||
|
},
|
||||||
"mounts": [
|
"mounts": [
|
||||||
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
|
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
|
||||||
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind",
|
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# workaround for devimage
|
|
||||||
sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \
|
|
||||||
sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \;
|
|
||||||
sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true
|
|
||||||
|
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
sudo apt install -y curl
|
sudo apt install -y curl
|
||||||
sudo apt-get install -y docker.io
|
sudo apt-get install -y docker.io
|
||||||
|
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
|
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
|
||||||
sudo bash /tmp/nodesource_setup.sh
|
sudo bash /tmp/nodesource_setup.sh
|
||||||
sudo apt-get install -y nodejs
|
sudo apt-get install -y nodejs
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,25 @@ echo $PYTHONPATH
|
||||||
CONTAINER_WORKSPACE_FOLDER=$1
|
CONTAINER_WORKSPACE_FOLDER=$1
|
||||||
cd $CONTAINER_WORKSPACE_FOLDER
|
cd $CONTAINER_WORKSPACE_FOLDER
|
||||||
|
|
||||||
# workaround for devimage
|
|
||||||
sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \
|
|
||||||
sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \;
|
|
||||||
sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true
|
|
||||||
|
|
||||||
cd $HOME
|
cd $HOME
|
||||||
echo $PWD
|
echo $PWD
|
||||||
if [ ! -d ./lnbits ] ; then
|
if [ ! -d ./lnbits ] ; then
|
||||||
git clone https://github.com/lnbits/lnbits.git lnbits
|
git clone https://github.com/lnbits/lnbits.git lnbits
|
||||||
fi
|
fi
|
||||||
cd lnbits
|
cd lnbits
|
||||||
echo $PWD
|
echo $PWD
|
||||||
git checkout dev
|
git checkout v1.0.0-rc7
|
||||||
poetry env use 3.12
|
poetry env use 3.12
|
||||||
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
|
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
|
||||||
ln -sf $POETRY_PYTHON_PATH /home/vscode/python
|
ln -sf $POETRY_PYTHON_PATH /home/vscode/python
|
||||||
make bundle
|
make bundle
|
||||||
poetry install --no-interaction
|
poetry install --no-interaction
|
||||||
mkdir -p lnbits/extensions/
|
mkdir -p lnbits/extensions/
|
||||||
if [ ! -d lnbits/extensions/nwcprovider ] ; then
|
if [ ! -d lnbits/extensions/nwcprovider ] ; then
|
||||||
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider
|
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd $CONTAINER_WORKSPACE_FOLDER
|
cd $CONTAINER_WORKSPACE_FOLDER
|
||||||
poetry install --no-interaction
|
poetry install --no-interaction
|
||||||
npm i prettier
|
npm i prettier
|
||||||
npm i pyright
|
npm i pyright
|
||||||
pip install uv
|
|
||||||
10
.github/workflows/lint.yml
vendored
10
.github/workflows/lint.yml
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
name: lint
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
|
|
||||||
52
.github/workflows/main.yml
vendored
Normal file
52
.github/workflows/main.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Create github release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes
|
||||||
|
pullrequest:
|
||||||
|
needs: [release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repository: lnbits/lnbits-extensions
|
||||||
|
path: "./lnbits-extensions"
|
||||||
|
|
||||||
|
- name: setup git user
|
||||||
|
run: |
|
||||||
|
git config --global user.name "alan"
|
||||||
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
- name: Create pull request in extensions repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repo_name: "${{ github.event.repository.name }}"
|
||||||
|
tag: "${{ github.ref_name }}"
|
||||||
|
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
||||||
|
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
|
||||||
|
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
|
||||||
|
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
|
||||||
|
run: |
|
||||||
|
cd lnbits-extensions
|
||||||
|
git checkout -b $branch
|
||||||
|
# if there is another open PR
|
||||||
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
git add -A
|
||||||
|
git commit -am "$title"
|
||||||
|
git push origin $branch
|
||||||
|
# check if pr exists before creating it
|
||||||
|
gh config set pager cat
|
||||||
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions
|
||||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
|
|
@ -1,57 +1,51 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- name: Create github release
|
- name: Create github release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref_name }}
|
tag: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
gh release create "$tag" --generate-notes
|
gh release create "$tag" --generate-notes
|
||||||
|
|
||||||
pullrequest:
|
pullrequest:
|
||||||
needs: [release]
|
needs: [release]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.EXT_GITHUB }}
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
repository: lnbits/lnbits-extensions
|
repository: lnbits/lnbits-extensions
|
||||||
path: './lnbits-extensions'
|
path: "./lnbits-extensions"
|
||||||
|
|
||||||
- name: setup git user
|
- name: setup git user
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "alan"
|
git config --global user.name "alan"
|
||||||
git config --global user.email "alan@lnbits.com"
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
|
||||||
- name: Create pull request in extensions repo
|
- name: Create pull request in extensions repo
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
repo_name: '${{ github.event.repository.name }}'
|
repo_name: "${{ github.event.repository.name }}"
|
||||||
tag: '${{ github.ref_name }}'
|
tag: "${{ github.ref_name }}"
|
||||||
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
||||||
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
|
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
|
||||||
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
|
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
|
||||||
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
|
||||||
run: |
|
run: |
|
||||||
cd lnbits-extensions
|
cd lnbits-extensions
|
||||||
git checkout -b $branch
|
git checkout -b $branch
|
||||||
|
|
||||||
# if there is another open PR
|
# if there is another open PR
|
||||||
git pull origin $branch || echo "branch does not exist"
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
|
||||||
sh util.sh update_extension $repo_name $tag
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
|
||||||
git add -A
|
git add -A
|
||||||
git commit -am "$title"
|
git commit -am "$title"
|
||||||
git push origin $branch
|
git push origin $branch
|
||||||
|
|
||||||
# check if pr exists before creating it
|
# check if pr exists before creating it
|
||||||
gh config set pager cat
|
gh config set pager cat
|
||||||
check=$(gh pr list -H $branch | wc -l)
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
|
|
||||||
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
|
|
@ -7,16 +7,15 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: "3.9"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -51,16 +50,3 @@ jobs:
|
||||||
pip install pytest-asyncio
|
pip install pytest-asyncio
|
||||||
cd $cdir
|
cd $cdir
|
||||||
pytest tests/integration/*.py -s
|
pytest tests/integration/*.py -s
|
||||||
|
|
||||||
- name: Show integration service logs
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker ps -a
|
|
||||||
docker logs --tail 200 lnbits_nwcprovider_ext_nostr_test || true
|
|
||||||
docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 200 /tmp/lnbits.log || true
|
|
||||||
|
|
||||||
- name: Stop integration services
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker rm -f lnbits_nwcprovider_ext_lnbits_test lnbits_nwcprovider_ext_nostr_test || true
|
|
||||||
docker network rm lnbits_nwcprovider_ext_test_network || true
|
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -8,5 +8,6 @@ node_modules
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
data
|
data
|
||||||
.vscode
|
.vscode
|
||||||
dump
|
package.json
|
||||||
.venv
|
package-lock.json
|
||||||
|
dump
|
||||||
12
.prettierrc
12
.prettierrc
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"insertPragma": false,
|
|
||||||
"printWidth": 80,
|
|
||||||
"proseWrap": "preserve",
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"bracketSpacing": false
|
|
||||||
}
|
|
||||||
24
Makefile
24
Makefile
|
|
@ -5,27 +5,27 @@ format: prettier black ruff
|
||||||
check: mypy pyright checkblack checkruff checkprettier
|
check: mypy pyright checkblack checkruff checkprettier
|
||||||
|
|
||||||
prettier:
|
prettier:
|
||||||
uv run ./node_modules/.bin/prettier --write .
|
poetry run ./node_modules/.bin/prettier --write .
|
||||||
pyright:
|
pyright:
|
||||||
uv run ./node_modules/.bin/pyright
|
poetry run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
uv run mypy .
|
poetry run mypy .
|
||||||
|
|
||||||
black:
|
black:
|
||||||
uv run black .
|
poetry run black .
|
||||||
|
|
||||||
ruff:
|
ruff:
|
||||||
uv run ruff check . --fix
|
poetry run ruff check . --fix
|
||||||
|
|
||||||
checkruff:
|
checkruff:
|
||||||
uv run ruff check .
|
poetry run ruff check .
|
||||||
|
|
||||||
checkprettier:
|
checkprettier:
|
||||||
uv run ./node_modules/.bin/prettier --check .
|
poetry run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
checkblack:
|
checkblack:
|
||||||
uv run black --check .
|
poetry run black --check .
|
||||||
|
|
||||||
checkeditorconfig:
|
checkeditorconfig:
|
||||||
editorconfig-checker
|
editorconfig-checker
|
||||||
|
|
@ -33,14 +33,14 @@ checkeditorconfig:
|
||||||
test:
|
test:
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest
|
poetry run pytest
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||||
uv run pre-commit install
|
poetry run pre-commit install
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
uv run pre-commit run --all-files
|
poetry run pre-commit run --all-files
|
||||||
|
|
||||||
|
|
||||||
checkbundle:
|
checkbundle:
|
||||||
|
|
|
||||||
72
README.md
72
README.md
|
|
@ -1,13 +1,3 @@
|
||||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
|
||||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
[](./LICENSE)
|
|
||||||
[](https://github.com/lnbits/lnbits)
|
|
||||||
|
|
||||||
# NWC Service Provider Extension for [LNbits](https://github.com/lnbits/lnbits)
|
# NWC Service Provider Extension for [LNbits](https://github.com/lnbits/lnbits)
|
||||||
|
|
||||||
Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
|
Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
|
||||||
|
|
@ -16,51 +6,9 @@ Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
|
||||||
|
|
||||||
Install the extension via the .env file or through the admin UI on your LNbits server. More details can be found [here](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).
|
Install the extension via the .env file or through the admin UI on your LNbits server. More details can be found [here](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).
|
||||||
|
|
||||||
# Configuration
|
## Configuration
|
||||||
|
|
||||||
The **LNbits NWC Service Provider** requires a one-time setup before it can be used.
|
Configure the extension from the "Settings" page in the top right menu when logged in as admin inside the extension page.
|
||||||
It relies on a Nostr relay, which can be either:
|
|
||||||
|
|
||||||
- The **LNbits Nostrclient** browser extension
|
|
||||||
- A **third-party Nostr relay** of your choice
|
|
||||||
|
|
||||||
## Relay Configuration
|
|
||||||
|
|
||||||
Before you can start using the extension, you need to configure a Nostr relay.
|
|
||||||
|
|
||||||
### Option 1: Use a third-party Nostr relay (recommended)
|
|
||||||
|
|
||||||
This is the easiest option for most users. It allows you to run LNbits on a private network while connecting to NWC apps through a public Nostr relay.
|
|
||||||
|
|
||||||
1. Choose a Nostr relay that supports NWC connections.
|
|
||||||
2. Navigate to the **NWC Service Provider admin page** at `/nwcprovider/admin` (requires LNbits admin privileges).
|
|
||||||
1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`).
|
|
||||||
2. Click **Save**.
|
|
||||||
|
|
||||||
### Option 2: Use the LNbits Nostrclient extension
|
|
||||||
|
|
||||||
> **Note:** This option only works if your LNbits instance is publicly accessible on the internet. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information.
|
|
||||||
|
|
||||||
1. Install the **Nostrclient** extension in your browser.
|
|
||||||
2. Open the extension.
|
|
||||||
1. Add at least one relay (e.g. `wss://relay.nostrconnect.com` is a good choice for NWC connections).
|
|
||||||
2. Open **Settings** and enable **Expose Public WebSocket**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Connecting a NWC App
|
|
||||||
|
|
||||||
1. In the **NWC Service Provider** extension, select the wallet you want to connect.
|
|
||||||
2. Click the **+** button to add a new connection.
|
|
||||||
3. Enter a description, expiry date (optional), permissions, and limits.
|
|
||||||
4. Click **Connect** to create the connection.
|
|
||||||
5. Use the generated **pairing URL** or **QR code** to connect your chosen app.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Extension Configuration
|
|
||||||
|
|
||||||
The configuration page of the NWC Service Provider extension is available at `/nwcprovider/admin` and requires LNbits admin privileges.
|
|
||||||
|
|
||||||
### Configuration Options:
|
### Configuration Options:
|
||||||
|
|
||||||
|
|
@ -79,9 +27,17 @@ The configuration page of the NWC Service Provider extension is available at `/n
|
||||||
>
|
>
|
||||||
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
|
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
|
||||||
|
|
||||||
## Powered by LNbits
|
### Using Nostrclient
|
||||||
|
|
||||||
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
The extension is preconfigured to connect to the nostrclient extension. Install it on the same LNbits instance and configure it to expose public websocket endpoints. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information.
|
||||||
|
|
||||||
[](https://shop.lnbits.com/)
|
### Using a Custom Relay
|
||||||
[](https://my.lnbits.com/login)
|
|
||||||
|
To use a custom relay, set the `relay` key to the relay URL (e.g., `wss://nostr.wine`) in the extension's Settings page.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Go to the extension page.
|
||||||
|
2. Select a wallet and click the plus button to create a new NWC connection.
|
||||||
|
3. Configure expiration, limits, and permissions.
|
||||||
|
4. A pairing URL will be generated for you to open, copy, or scan with the NWC app. Note that the pairing URL is shown only once, but you can delete and recreate the connection to get a new one.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def nwcprovider_start():
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"db",
|
"db",
|
||||||
"nwcprovider_ext",
|
"nwcprovider_ext",
|
||||||
"nwcprovider_start",
|
|
||||||
"nwcprovider_static_files",
|
"nwcprovider_static_files",
|
||||||
|
"nwcprovider_start",
|
||||||
"nwcprovider_stop",
|
"nwcprovider_stop",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
31
config.json
31
config.json
|
|
@ -1,12 +1,8 @@
|
||||||
{
|
{
|
||||||
"id": "nwcprovider",
|
|
||||||
"name": "NWC Service Provider",
|
"name": "NWC Service Provider",
|
||||||
"repo": "https://github.com/lnbits/nwcprovider",
|
|
||||||
"short_description": "A NWC service provider for LNbits",
|
"short_description": "A NWC service provider for LNbits",
|
||||||
"description": "",
|
|
||||||
"tile": "/nwcprovider/static/image/nwcprovider.png",
|
"tile": "/nwcprovider/static/image/nwcprovider.png",
|
||||||
"version": "1.1.3-aio.1",
|
"min_lnbits_version": "1.0.0",
|
||||||
"min_lnbits_version": "1.4.0",
|
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "Riccardo Balbo",
|
"name": "Riccardo Balbo",
|
||||||
|
|
@ -17,33 +13,20 @@
|
||||||
"name": "Ben Arc",
|
"name": "Ben Arc",
|
||||||
"uri": "https://github.com/arcbtc",
|
"uri": "https://github.com/arcbtc",
|
||||||
"role": "Dev"
|
"role": "Dev"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BlackCoffee",
|
|
||||||
"uri": "https://github.com/blackcoffeexbt",
|
|
||||||
"role": "Dev"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/nwc_thumbnail.png",
|
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/1.png"
|
||||||
"link": "https://www.youtube.com/watch?v=0c77d2q-_PQ"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/1.png"
|
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/2.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/2.png"
|
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/3.png"
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/3.png"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.md",
|
"description_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/description.md",
|
||||||
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/toc.md",
|
"terms_and_conditions_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/toc.md",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"paid_features": "",
|
|
||||||
"tags": ["Nostr", "Wallet"],
|
|
||||||
"donate": "",
|
|
||||||
"hidden": false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
crud.py
38
crud.py
|
|
@ -1,4 +1,5 @@
|
||||||
import time
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
|
@ -10,7 +11,6 @@ from .models import (
|
||||||
GetNWC,
|
GetNWC,
|
||||||
GetWalletNWC,
|
GetWalletNWC,
|
||||||
NWCBudget,
|
NWCBudget,
|
||||||
NWCConfig,
|
|
||||||
NWCKey,
|
NWCKey,
|
||||||
NWCNewBudget,
|
NWCNewBudget,
|
||||||
TrackedSpendNWC,
|
TrackedSpendNWC,
|
||||||
|
|
@ -71,8 +71,7 @@ async def delete_nwc(data: DeleteNWC) -> None:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(data.pubkey)
|
assert_valid_pubkey(data.pubkey)
|
||||||
if data.wallet:
|
assert_valid_wallet_id(data.wallet)
|
||||||
assert_valid_wallet_id(data.wallet)
|
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -81,12 +80,9 @@ async def delete_nwc(data: DeleteNWC) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_wallet_nwcs(data: GetWalletNWC) -> list[NWCKey]:
|
async def get_wallet_nwcs(data: GetWalletNWC) -> List[NWCKey]:
|
||||||
expires = int(time.time()) if not data.include_expired else -1
|
expires = int(time.time()) if not data.include_expired else -1
|
||||||
|
|
||||||
if not data.wallet:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_wallet_id(data.wallet)
|
assert_valid_wallet_id(data.wallet)
|
||||||
assert_valid_expiration_seconds(expires)
|
assert_valid_expiration_seconds(expires)
|
||||||
|
|
@ -105,7 +101,7 @@ async def get_wallet_nwcs(data: GetWalletNWC) -> list[NWCKey]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_nwc(data: GetNWC) -> NWCKey | None:
|
async def get_nwc(data: GetNWC) -> Optional[NWCKey]:
|
||||||
expires = int(time.time()) if not data.include_expired else -1
|
expires = int(time.time()) if not data.include_expired else -1
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -154,17 +150,17 @@ async def get_nwc(data: GetNWC) -> NWCKey | None:
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
async def get_budgets_nwc(data: GetBudgetsNWC) -> list[NWCBudget]:
|
async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(data.pubkey)
|
assert_valid_pubkey(data.pubkey)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
budgets = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT * FROM nwcprovider.budgets WHERE pubkey = :pubkey",
|
"SELECT * FROM nwcprovider.budgets WHERE pubkey = :pubkey",
|
||||||
{"pubkey": data.pubkey},
|
{"pubkey": data.pubkey},
|
||||||
model=NWCBudget,
|
|
||||||
)
|
)
|
||||||
|
budgets = [NWCBudget(**row) for row in rows]
|
||||||
if data.calculate_spent:
|
if data.calculate_spent:
|
||||||
for budget in budgets:
|
for budget in budgets:
|
||||||
last_cycle, next_cycle = budget.get_timestamp_range()
|
last_cycle, next_cycle = budget.get_timestamp_range()
|
||||||
|
|
@ -174,7 +170,7 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> list[NWCBudget]:
|
||||||
assert_valid_timestamp_seconds(next_cycle)
|
assert_valid_timestamp_seconds(next_cycle)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
result: dict = await db.fetchone(
|
tot_spent_in_range_msats = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT SUM(amount_msats) FROM nwcprovider.spent
|
SELECT SUM(amount_msats) FROM nwcprovider.spent
|
||||||
WHERE pubkey = :pubkey AND created_at >=
|
WHERE pubkey = :pubkey AND created_at >=
|
||||||
|
|
@ -186,7 +182,9 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> list[NWCBudget]:
|
||||||
"next_cycle": next_cycle,
|
"next_cycle": next_cycle,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
tot_spent_in_range_msats = next(iter(result.values())) or 0
|
tot_spent_in_range_msats = (
|
||||||
|
next(iter(tot_spent_in_range_msats.values())) or 0
|
||||||
|
)
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_msats(tot_spent_in_range_msats)
|
assert_valid_msats(tot_spent_in_range_msats)
|
||||||
|
|
@ -265,14 +263,12 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
|
||||||
|
|
||||||
|
|
||||||
async def get_config_nwc(key: str):
|
async def get_config_nwc(key: str):
|
||||||
config = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"SELECT * FROM nwcprovider.config WHERE key = :key",
|
"SELECT * FROM nwcprovider.config WHERE key = :key", {"key": key}
|
||||||
{"key": key},
|
|
||||||
model=NWCConfig,
|
|
||||||
)
|
)
|
||||||
if not config:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return config.value
|
return row["value"]
|
||||||
|
|
||||||
|
|
||||||
async def set_config_nwc(key: str, value: str):
|
async def set_config_nwc(key: str, value: str):
|
||||||
|
|
@ -293,5 +289,5 @@ async def set_config_nwc(key: str, value: str):
|
||||||
|
|
||||||
|
|
||||||
async def get_all_config_nwc():
|
async def get_all_config_nwc():
|
||||||
rows = await db.fetchall("SELECT * FROM nwcprovider.config", model=NWCConfig)
|
rows = await db.fetchall("SELECT * FROM nwcprovider.config")
|
||||||
return {row.key: row.value for row in rows}
|
return {row["key"]: row["value"] for row in rows}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
Connect your LNbits wallet to apps using the Nostr Wallet Connect protocol.
|
NWC Service Provider Extension for https://github.com/lnbits/lnbits
|
||||||
|
|
||||||
Its functions include:
|
Easily connect your LNbits wallets via https://nwc.dev/
|
||||||
|
|
||||||
- Exposing your wallet via the NWC protocol
|
|
||||||
- Connecting to NWC-compatible applications
|
|
||||||
- Managing wallet connection permissions
|
|
||||||
- Supporting the nwc.dev standard
|
|
||||||
|
|
||||||
Enables seamless integration with NWC-compatible apps like Alby, Amethyst, Jumble, Buho and other Nostr clients that support wallet connections.
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
execution_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
execution_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
async def enqueue(action):
|
async def enqueue(action):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from coincurve import PrivateKey
|
import secp256k1
|
||||||
|
|
||||||
|
|
||||||
async def m001_initial(db):
|
async def m001_initial(db):
|
||||||
|
|
@ -72,14 +72,13 @@ async def m003_default_config(db):
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
private_key = PrivateKey()
|
new_private_key = bytes.hex(secp256k1._gen_private_key())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO nwcprovider.config (key, value)
|
INSERT INTO nwcprovider.config (key, value) VALUES ('provider_key', :provider_key)
|
||||||
VALUES ('provider_key', :provider_key)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
""",
|
""",
|
||||||
{"provider_key": private_key.to_hex()},
|
{"provider_key": new_private_key},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -88,13 +87,12 @@ async def m004_default_config2(db):
|
||||||
Default config
|
Default config
|
||||||
"""
|
"""
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', :value)
|
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', :value)
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
""",
|
""",
|
||||||
{"value": ""},
|
{"value": ""},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def m005_key_last_used(db):
|
async def m005_key_last_used(db):
|
||||||
"""
|
"""
|
||||||
|
|
@ -113,8 +111,7 @@ async def m006_default_config3(db):
|
||||||
"""
|
"""
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO nwcprovider.config (key, value)
|
INSERT INTO nwcprovider.config (key, value) VALUES ('handle_missed_events', :value)
|
||||||
VALUES ('handle_missed_events', :value)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
""",
|
""",
|
||||||
{"value": "0"},
|
{"value": "0"},
|
||||||
|
|
|
||||||
39
models.py
39
models.py
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from typing import Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -16,14 +16,14 @@ class NWCKey(BaseModel):
|
||||||
created_at: int
|
created_at: int
|
||||||
last_used: int
|
last_used: int
|
||||||
|
|
||||||
def get_permissions(self) -> list[str]:
|
def get_permissions(self) -> List[str]:
|
||||||
try:
|
try:
|
||||||
return self.permissions.split(" ")
|
return self.permissions.split(" ")
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict[str, Any]) -> "NWCKey":
|
def from_row(cls, row: Dict[str, Any]) -> "NWCKey":
|
||||||
return cls(**row)
|
return cls(**row)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ class NWCBudget(BaseModel):
|
||||||
c = int(time.time())
|
c = int(time.time())
|
||||||
if self.refresh_window <= 0: # never refresh
|
if self.refresh_window <= 0: # never refresh
|
||||||
# return a timestamp in the future
|
# return a timestamp in the future
|
||||||
return self.created_at, c + 21000000
|
return c, c + 21000000
|
||||||
# calculate the next refresh timestamp
|
# calculate the next refresh timestamp
|
||||||
elapsed = c - self.created_at
|
elapsed = c - self.created_at
|
||||||
passed_cycles = elapsed // self.refresh_window
|
passed_cycles = elapsed // self.refresh_window
|
||||||
|
|
@ -53,7 +53,7 @@ class NWCBudget(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class NWCNewBudget(BaseModel):
|
class NWCNewBudget(BaseModel):
|
||||||
pubkey: str | None
|
pubkey: Optional[str]
|
||||||
budget_msats: int
|
budget_msats: int
|
||||||
refresh_window: int
|
refresh_window: int
|
||||||
created_at: int
|
created_at: int
|
||||||
|
|
@ -65,30 +65,30 @@ class CreateNWCKey(BaseModel):
|
||||||
wallet: str
|
wallet: str
|
||||||
description: str
|
description: str
|
||||||
expires_at: int
|
expires_at: int
|
||||||
permissions: list[str]
|
permissions: List[str]
|
||||||
budgets: list[NWCNewBudget] | None = None
|
budgets: Optional[List[NWCNewBudget]] = None
|
||||||
|
|
||||||
|
|
||||||
class DeleteNWC(BaseModel):
|
class DeleteNWC(BaseModel):
|
||||||
pubkey: str
|
pubkey: str
|
||||||
wallet: str | None = None
|
wallet: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class GetWalletNWC(BaseModel):
|
class GetWalletNWC(BaseModel):
|
||||||
wallet: str | None = None
|
wallet: Optional[str] = None
|
||||||
include_expired: bool | None = False
|
include_expired: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class GetNWC(BaseModel):
|
class GetNWC(BaseModel):
|
||||||
pubkey: str
|
pubkey: str
|
||||||
wallet: str | None = None
|
wallet: Optional[str] = None
|
||||||
include_expired: bool | None = False
|
include_expired: Optional[bool] = False
|
||||||
refresh_last_used: bool | None = False
|
refresh_last_used: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class GetBudgetsNWC(BaseModel):
|
class GetBudgetsNWC(BaseModel):
|
||||||
pubkey: str
|
pubkey: str
|
||||||
calculate_spent: bool | None = False
|
calculate_spent: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class TrackedSpendNWC(BaseModel):
|
class TrackedSpendNWC(BaseModel):
|
||||||
|
|
@ -98,17 +98,12 @@ class TrackedSpendNWC(BaseModel):
|
||||||
|
|
||||||
# API models
|
# API models
|
||||||
class NWCRegistrationRequest(BaseModel):
|
class NWCRegistrationRequest(BaseModel):
|
||||||
permissions: list[str]
|
permissions: List[str]
|
||||||
description: str
|
description: str
|
||||||
expires_at: int
|
expires_at: int
|
||||||
budgets: list[NWCNewBudget]
|
budgets: List[NWCNewBudget]
|
||||||
|
|
||||||
|
|
||||||
class NWCGetResponse(BaseModel):
|
class NWCGetResponse(BaseModel):
|
||||||
data: NWCKey
|
data: NWCKey
|
||||||
budgets: list[NWCBudget]
|
budgets: List[NWCBudget]
|
||||||
|
|
||||||
|
|
||||||
class NWCConfig(BaseModel):
|
|
||||||
key: str
|
|
||||||
value: str
|
|
||||||
|
|
|
||||||
277
nwcp.py
277
nwcp.py
|
|
@ -1,17 +1,19 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
|
||||||
from typing import Any, Union
|
|
||||||
|
|
||||||
from coincurve import PublicKeyXOnly
|
import secp256k1
|
||||||
|
import websockets.client as websockets
|
||||||
|
from Cryptodome import Random
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
from Cryptodome.Util.Padding import pad, unpad
|
||||||
from lnbits.helpers import encrypt_internal_message
|
from lnbits.helpers import encrypt_internal_message
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pynostr.key import PrivateKey
|
|
||||||
from websockets.legacy.client import connect
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimit:
|
class RateLimit:
|
||||||
|
|
@ -21,15 +23,14 @@ class RateLimit:
|
||||||
|
|
||||||
class MainSubscription:
|
class MainSubscription:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.requests_sub_id: str | None = None
|
self.requests_sub_id: Optional[str] = None
|
||||||
self.responses_sub_id: str | None = None
|
self.responses_sub_id: Optional[str] = None
|
||||||
self.requests_eose = False
|
self.requests_eose = False
|
||||||
self.responses_eose = False
|
self.responses_eose = False
|
||||||
self.events: dict[str, dict] = {}
|
self.events: Dict[str, Dict] = {}
|
||||||
self.responses: list[str] = []
|
self.responses: List[str] = []
|
||||||
self.seen_requests: dict[str, int] = {}
|
|
||||||
|
|
||||||
def get_stale(self) -> list[dict]:
|
def get_stale(self) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get all the pending events that do not have a response yet.
|
Get all the pending events that do not have a response yet.
|
||||||
"""
|
"""
|
||||||
|
|
@ -46,16 +47,15 @@ class MainSubscription:
|
||||||
if event_id not in self.responses:
|
if event_id not in self.responses:
|
||||||
self.responses.append(event_id)
|
self.responses.append(event_id)
|
||||||
|
|
||||||
def gc(self, expire: int | None = None):
|
def gc(self, expire: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
Garbage collection, remove all the events that have a response older
|
Garbage collection, remove all the events that have a response older
|
||||||
than expire seconds (defaults to 1 hour if 0 or None)
|
than expire seconds (defaults to 1 hour if 0 or None)
|
||||||
and all seen requests that are expired
|
|
||||||
"""
|
"""
|
||||||
expire = expire or 1 * 60 * 60
|
expire = expire or 1 * 60 * 60
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
deleted_ids = []
|
deleted_ids = []
|
||||||
for event_id, event in list(self.events.items()):
|
for [event_id, event] in self.events.items():
|
||||||
if event_id in self.responses:
|
if event_id in self.responses:
|
||||||
if now - event["created_at"] > expire:
|
if now - event["created_at"] > expire:
|
||||||
del self.events[event_id]
|
del self.events[event_id]
|
||||||
|
|
@ -67,11 +67,6 @@ class MainSubscription:
|
||||||
if len(deleted_ids) > 0:
|
if len(deleted_ids) > 0:
|
||||||
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
|
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
|
||||||
|
|
||||||
# Clean seen requests
|
|
||||||
for event_id, expiry in list(self.seen_requests.items()):
|
|
||||||
if expiry < now:
|
|
||||||
del self.seen_requests[event_id]
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
|
@ -79,8 +74,8 @@ class MainSubscription:
|
||||||
class NWCServiceProvider:
|
class NWCServiceProvider:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
private_key_hex: str | None = None,
|
private_key: Optional[str] = None,
|
||||||
relay: str | None = None,
|
relay: Optional[str] = None,
|
||||||
handle_missed_events: int = 0,
|
handle_missed_events: int = 0,
|
||||||
):
|
):
|
||||||
if not relay: # Connect to nostrclient
|
if not relay: # Connect to nostrclient
|
||||||
|
|
@ -94,31 +89,28 @@ class NWCServiceProvider:
|
||||||
)
|
)
|
||||||
self.relay = relay
|
self.relay = relay
|
||||||
|
|
||||||
if not private_key_hex: # Create random key
|
if not private_key: # Create random key
|
||||||
self.private_key = PrivateKey()
|
private_key = bytes.hex(secp256k1._gen_private_key())
|
||||||
self.private_key_hex = self.private_key.hex()
|
|
||||||
else:
|
|
||||||
self.private_key = PrivateKey.from_hex(private_key_hex)
|
|
||||||
self.private_key_hex = private_key_hex
|
|
||||||
|
|
||||||
self.public_key = self.private_key.public_key
|
self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key))
|
||||||
|
self.private_key_hex = private_key
|
||||||
|
self.public_key = self.private_key.pubkey
|
||||||
if not self.public_key:
|
if not self.public_key:
|
||||||
raise Exception("Invalid public key")
|
raise Exception("Invalid public key")
|
||||||
|
self.public_key_hex = self.public_key.serialize().hex()[2:]
|
||||||
self.public_key_hex = self.public_key.hex()
|
|
||||||
|
|
||||||
# List of supported methods
|
# List of supported methods
|
||||||
self.supported_methods: list[str] = []
|
self.supported_methods: List[str] = []
|
||||||
|
|
||||||
# Keep track of the number of subscriptions (used for unique subid)
|
# Keep track of the number of subscriptions (used for unique subid)
|
||||||
self.subscriptions_count: int = 0
|
self.subscriptions_count: int = 0
|
||||||
|
|
||||||
# Request listeners, listen to specific methods
|
# Request listeners, listen to specific methods
|
||||||
self.request_listeners: dict[
|
self.request_listeners: Dict[
|
||||||
str,
|
str,
|
||||||
Callable[
|
Callable[
|
||||||
[NWCServiceProvider, str, dict],
|
[NWCServiceProvider, str, Dict],
|
||||||
Awaitable[list[tuple[dict | None, dict | None, list]]],
|
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]],
|
||||||
],
|
],
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
|
|
@ -128,15 +120,9 @@ class NWCServiceProvider:
|
||||||
# Garbage collection loop
|
# Garbage collection loop
|
||||||
self.gc_task = None
|
self.gc_task = None
|
||||||
|
|
||||||
# Periodic info event resend loop
|
|
||||||
self.info_event_task = None
|
|
||||||
|
|
||||||
# Requests are handled independently from the relay receive loop.
|
|
||||||
self.request_tasks: set[asyncio.Task[list[dict]]] = set()
|
|
||||||
|
|
||||||
# Subscription
|
# Subscription
|
||||||
self.sub: MainSubscription | None = None
|
self.sub = None
|
||||||
self.rate_limit: dict[str, RateLimit] = {}
|
self.rate_limit: Dict[str, RateLimit] = {}
|
||||||
|
|
||||||
# websocket connection
|
# websocket connection
|
||||||
self.ws = None
|
self.ws = None
|
||||||
|
|
@ -152,8 +138,6 @@ class NWCServiceProvider:
|
||||||
# (handles reboots)
|
# (handles reboots)
|
||||||
self.handle_missed_events = handle_missed_events
|
self.handle_missed_events = handle_missed_events
|
||||||
|
|
||||||
self.event_max_age = self.handle_missed_events or 5 * 60
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"NWC Service is ready. relay: "
|
"NWC Service is ready. relay: "
|
||||||
+ str(self.relay)
|
+ str(self.relay)
|
||||||
|
|
@ -177,8 +161,8 @@ class NWCServiceProvider:
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
listener: Callable[
|
listener: Callable[
|
||||||
["NWCServiceProvider", str, dict],
|
["NWCServiceProvider", str, Dict],
|
||||||
Awaitable[list[tuple[dict | None, dict | None, list]]],
|
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]],
|
||||||
],
|
],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -200,9 +184,8 @@ class NWCServiceProvider:
|
||||||
"""
|
"""
|
||||||
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
|
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
|
||||||
self.gc_task = asyncio.create_task(self._gc_loop())
|
self.gc_task = asyncio.create_task(self._gc_loop())
|
||||||
self.info_event_task = asyncio.create_task(self._info_event_loop())
|
|
||||||
|
|
||||||
def _json_dumps(self, data: Union[dict, list]) -> str:
|
def _json_dumps(self, data: Union[Dict, list]) -> str:
|
||||||
"""
|
"""
|
||||||
Converts a Python dictionary to a JSON string with compact encoding.
|
Converts a Python dictionary to a JSON string with compact encoding.
|
||||||
|
|
||||||
|
|
@ -212,7 +195,7 @@ class NWCServiceProvider:
|
||||||
Returns:
|
Returns:
|
||||||
str: The compact JSON string.
|
str: The compact JSON string.
|
||||||
"""
|
"""
|
||||||
if isinstance(data, dict):
|
if isinstance(data, Dict):
|
||||||
data = {k: v for k, v in data.items() if v is not None}
|
data = {k: v for k, v in data.items() if v is not None}
|
||||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
@ -222,7 +205,7 @@ class NWCServiceProvider:
|
||||||
"""
|
"""
|
||||||
return self.shutdown or not settings.lnbits_running
|
return self.shutdown or not settings.lnbits_running
|
||||||
|
|
||||||
async def _send(self, data: list[Any]):
|
async def _send(self, data: List[Any]):
|
||||||
"""
|
"""
|
||||||
Sends data to the relay.
|
Sends data to the relay.
|
||||||
|
|
||||||
|
|
@ -266,7 +249,7 @@ class NWCServiceProvider:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def _ratelimit(self, unit: str, max_sleep_time: int = 120) -> None:
|
async def _ratelimit(self, unit: str, max_sleep_time: int = 120) -> None:
|
||||||
limit: RateLimit | None = self.rate_limit.get(unit)
|
limit: Optional[RateLimit] = self.rate_limit.get(unit)
|
||||||
if not limit:
|
if not limit:
|
||||||
self.rate_limit[unit] = limit = RateLimit()
|
self.rate_limit[unit] = limit = RateLimit()
|
||||||
|
|
||||||
|
|
@ -282,16 +265,11 @@ class NWCServiceProvider:
|
||||||
await asyncio.sleep(limit.backoff)
|
await asyncio.sleep(limit.backoff)
|
||||||
limit.last_attempt_time = int(time.time())
|
limit.last_attempt_time = int(time.time())
|
||||||
|
|
||||||
def _create_subscription(self) -> MainSubscription:
|
|
||||||
sub = MainSubscription()
|
|
||||||
self.sub = sub
|
|
||||||
return sub
|
|
||||||
|
|
||||||
async def _subscribe(self):
|
async def _subscribe(self):
|
||||||
"""
|
"""
|
||||||
[Re]Subscribe to receive nip 47 requests and responses from the relay
|
[Re]Subscribe to receive nip 47 requests and responses from the relay
|
||||||
"""
|
"""
|
||||||
sub = self._create_subscription()
|
self.sub = MainSubscription()
|
||||||
# Create requests subscription
|
# Create requests subscription
|
||||||
req_filter = {
|
req_filter = {
|
||||||
"kinds": [23194],
|
"kinds": [23194],
|
||||||
|
|
@ -299,32 +277,24 @@ class NWCServiceProvider:
|
||||||
# Since the last handle_missed_events seconds (handles reboots)
|
# Since the last handle_missed_events seconds (handles reboots)
|
||||||
"since": int(time.time()) - self.handle_missed_events,
|
"since": int(time.time()) - self.handle_missed_events,
|
||||||
}
|
}
|
||||||
sub.requests_sub_id = self._get_new_subid()
|
self.sub.requests_sub_id = self._get_new_subid()
|
||||||
# Create responses subscription (needed to track previosly responded requests)
|
# Create responses subscription (needed to track previosly responded requests)
|
||||||
res_filter = {
|
res_filter = {
|
||||||
"kinds": [23195],
|
"kinds": [23195],
|
||||||
"authors": [self.public_key_hex],
|
"authors": [self.public_key_hex],
|
||||||
"since": int(time.time()) - self.handle_missed_events,
|
"since": int(time.time()) - self.handle_missed_events,
|
||||||
}
|
}
|
||||||
sub.responses_sub_id = self._get_new_subid()
|
self.sub.responses_sub_id = self._get_new_subid()
|
||||||
# Subscribe
|
# Subscribe
|
||||||
await self._send(["REQ", sub.requests_sub_id, req_filter])
|
await self._send(["REQ", self.sub.requests_sub_id, req_filter])
|
||||||
await self._send(["REQ", sub.responses_sub_id, res_filter])
|
await self._send(["REQ", self.sub.responses_sub_id, res_filter])
|
||||||
|
|
||||||
async def _on_connection(self, _):
|
async def _on_connection(self, ws):
|
||||||
"""
|
"""
|
||||||
On connection callback, announce the service provider
|
On connection callback, announce the service provider
|
||||||
methods and subscribe to nip67 events.
|
methods and subscribe to nip67 events.
|
||||||
"""
|
"""
|
||||||
# Send info event
|
# Send info event
|
||||||
await self._send_info_event()
|
|
||||||
# Resubscribe to nwc events
|
|
||||||
await self._subscribe()
|
|
||||||
|
|
||||||
async def _send_info_event(self):
|
|
||||||
"""
|
|
||||||
Build and publish the NWC service info event (kind 13194).
|
|
||||||
"""
|
|
||||||
event = {
|
event = {
|
||||||
"kind": 13194,
|
"kind": 13194,
|
||||||
"content": " ".join(self.supported_methods),
|
"content": " ".join(self.supported_methods),
|
||||||
|
|
@ -333,48 +303,23 @@ class NWCServiceProvider:
|
||||||
}
|
}
|
||||||
self._sign_event(event)
|
self._sign_event(event)
|
||||||
await self._send(["EVENT", event])
|
await self._send(["EVENT", event])
|
||||||
|
# Resubscribe to nwc events
|
||||||
|
await self._subscribe()
|
||||||
|
|
||||||
async def _info_event_loop(self):
|
async def _handle_request(self, event: Dict) -> List[Dict]:
|
||||||
"""
|
|
||||||
Periodically resend the service info event (kind 13194) so that the
|
|
||||||
provider can recover if the relay silently dropped the event without
|
|
||||||
closing the WebSocket connection.
|
|
||||||
"""
|
|
||||||
while not self._is_shutting_down():
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
if self.connected and not self._is_shutting_down():
|
|
||||||
try:
|
|
||||||
await self._send_info_event()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error resending info event: " + str(e))
|
|
||||||
|
|
||||||
async def _handle_request(self, event: dict) -> list[dict]:
|
|
||||||
"""
|
"""
|
||||||
Handle a nwc request
|
Handle a nwc request
|
||||||
"""
|
"""
|
||||||
if not self.sub:
|
|
||||||
raise Exception("Subscription is not established")
|
|
||||||
sub = self.sub
|
|
||||||
|
|
||||||
expire = sub.seen_requests.get(event["id"])
|
|
||||||
if expire or event["created_at"] < int(time.time() - self.event_max_age):
|
|
||||||
raise Exception("Event is too old or already handled")
|
|
||||||
|
|
||||||
expiration = self._extract_expiration_from_tags(event["tags"])
|
|
||||||
if expiration <= 0:
|
|
||||||
expiration = int(time.time() + self.event_max_age)
|
|
||||||
sub.seen_requests[event["id"]] = expiration
|
|
||||||
|
|
||||||
nwc_pubkey = event["pubkey"]
|
nwc_pubkey = event["pubkey"]
|
||||||
content = event["content"]
|
content = event["content"]
|
||||||
# Decrypt the content
|
# Decrypt the content
|
||||||
content = self.private_key.decrypt_message(content, nwc_pubkey)
|
content = self._decrypt_content(content, nwc_pubkey)
|
||||||
# Deserialize content
|
# Deserialize content
|
||||||
content = json.loads(content)
|
content = json.loads(content)
|
||||||
# Handle request
|
# Handle request
|
||||||
method = content["method"]
|
method = content["method"]
|
||||||
listener = self.request_listeners.get(method, None)
|
listener = self.request_listeners.get(method, None)
|
||||||
outs: list[dict[str, Any]] = []
|
outs: List[Dict[str, Any]] = []
|
||||||
if not listener:
|
if not listener:
|
||||||
outs.append(
|
outs.append(
|
||||||
{
|
{
|
||||||
|
|
@ -392,7 +337,7 @@ class NWCServiceProvider:
|
||||||
for result in results:
|
for result in results:
|
||||||
r = result[0]
|
r = result[0]
|
||||||
e = result[1]
|
e = result[1]
|
||||||
t = result[2] if len(result) > 2 else []
|
t = result[2] if len(result) > 2 else None
|
||||||
out = {"result": r, "error": e, "tags": t}
|
out = {"result": r, "error": e, "tags": t}
|
||||||
outs.append(out)
|
outs.append(out)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -406,13 +351,11 @@ class NWCServiceProvider:
|
||||||
content["result"] = out["result"]
|
content["result"] = out["result"]
|
||||||
if "error" in out:
|
if "error" in out:
|
||||||
content["error"] = out["error"]
|
content["error"] = out["error"]
|
||||||
raw_tags = out.get("tags")
|
|
||||||
tags = list(raw_tags) if isinstance(raw_tags, list) else []
|
|
||||||
# Prepare response event
|
# Prepare response event
|
||||||
res: dict = {
|
res: Dict = {
|
||||||
"kind": 23195,
|
"kind": 23195,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"tags": tags,
|
"tags": out.get("tags", []),
|
||||||
"content": self._json_dumps(content),
|
"content": self._json_dumps(content),
|
||||||
}
|
}
|
||||||
# Reference request
|
# Reference request
|
||||||
|
|
@ -420,9 +363,7 @@ class NWCServiceProvider:
|
||||||
# Reference user
|
# Reference user
|
||||||
res["tags"].append(["p", nwc_pubkey])
|
res["tags"].append(["p", nwc_pubkey])
|
||||||
# Finalize response event
|
# Finalize response event
|
||||||
res["content"] = self.private_key.encrypt_message(
|
res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
|
||||||
res["content"], nwc_pubkey
|
|
||||||
)
|
|
||||||
self._sign_event(res)
|
self._sign_event(res)
|
||||||
|
|
||||||
# Register response for this request, so we knows it is not stale
|
# Register response for this request, so we knows it is not stale
|
||||||
|
|
@ -434,30 +375,6 @@ class NWCServiceProvider:
|
||||||
sent_events.append(res)
|
sent_events.append(res)
|
||||||
return sent_events
|
return sent_events
|
||||||
|
|
||||||
def _log_request_task_exception(self, task: asyncio.Future[list[dict]]) -> None:
|
|
||||||
if task.cancelled():
|
|
||||||
return
|
|
||||||
exception = task.exception()
|
|
||||||
if exception:
|
|
||||||
logger.error("Error handling request: " + str(exception))
|
|
||||||
|
|
||||||
def _dispatch_request(self, event: dict) -> None:
|
|
||||||
task = asyncio.create_task(self._handle_request(event))
|
|
||||||
self.request_tasks.add(task)
|
|
||||||
task.add_done_callback(self.request_tasks.discard)
|
|
||||||
task.add_done_callback(self._log_request_task_exception)
|
|
||||||
|
|
||||||
def _extract_expiration_from_tags(self, tags: list) -> int:
|
|
||||||
expiration = -1
|
|
||||||
for tag in tags:
|
|
||||||
try:
|
|
||||||
if tag[0] == "expiration" and len(tag) > 1:
|
|
||||||
expiration = int(tag[1])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return expiration
|
|
||||||
|
|
||||||
async def _on_event_message(self, msg):
|
async def _on_event_message(self, msg):
|
||||||
if not self.sub:
|
if not self.sub:
|
||||||
return
|
return
|
||||||
|
|
@ -467,7 +384,7 @@ class NWCServiceProvider:
|
||||||
if not self._verify_event(event):
|
if not self._verify_event(event):
|
||||||
raise Exception("Invalid event signature")
|
raise Exception("Invalid event signature")
|
||||||
tags = event["tags"]
|
tags = event["tags"]
|
||||||
expiration = self._extract_expiration_from_tags(tags)
|
expiration = int(next((tag for tag in tags if tag[0] == "expiration"), -1))
|
||||||
# Handle event expiration if the relay doesn't support nip 40
|
# Handle event expiration if the relay doesn't support nip 40
|
||||||
if expiration > 0 and expiration < int(time.time()):
|
if expiration > 0 and expiration < int(time.time()):
|
||||||
logger.debug("Event expired")
|
logger.debug("Event expired")
|
||||||
|
|
@ -486,7 +403,7 @@ class NWCServiceProvider:
|
||||||
# already handled or stale, all stale requests will be handled
|
# already handled or stale, all stale requests will be handled
|
||||||
# later when eose is received
|
# later when eose is received
|
||||||
if self.sub.requests_eose and self.sub.responses_eose:
|
if self.sub.requests_eose and self.sub.responses_eose:
|
||||||
self._dispatch_request(event)
|
await self._handle_request(event)
|
||||||
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
|
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
|
||||||
# Ensure the response is from this service provider
|
# Ensure the response is from this service provider
|
||||||
if event["pubkey"] != self.public_key_hex:
|
if event["pubkey"] != self.public_key_hex:
|
||||||
|
|
@ -514,7 +431,7 @@ class NWCServiceProvider:
|
||||||
if self.sub.requests_eose and self.sub.responses_eose:
|
if self.sub.requests_eose and self.sub.responses_eose:
|
||||||
stales = self.sub.get_stale()
|
stales = self.sub.get_stale()
|
||||||
for stale in stales:
|
for stale in stales:
|
||||||
self._dispatch_request(stale)
|
await self._handle_request(stale)
|
||||||
|
|
||||||
async def _on_closed_message(self, msg):
|
async def _on_closed_message(self, msg):
|
||||||
if not self.sub:
|
if not self.sub:
|
||||||
|
|
@ -534,7 +451,7 @@ class NWCServiceProvider:
|
||||||
await self._ratelimit("subscribing")
|
await self._ratelimit("subscribing")
|
||||||
await self._subscribe()
|
await self._subscribe()
|
||||||
|
|
||||||
async def _on_message(self, _, message: str):
|
async def _on_message(self, ws, message: str):
|
||||||
"""
|
"""
|
||||||
Handle incoming messages from the relay.
|
Handle incoming messages from the relay.
|
||||||
"""
|
"""
|
||||||
|
|
@ -567,7 +484,7 @@ class NWCServiceProvider:
|
||||||
): # Reconnect until the instance is shutting down
|
): # Reconnect until the instance is shutting down
|
||||||
logger.debug("Creating new connection...")
|
logger.debug("Creating new connection...")
|
||||||
try:
|
try:
|
||||||
async with connect(self.relay) as ws:
|
async with websockets.connect(self.relay) as ws:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.connected = True
|
self.connected = True
|
||||||
await self._on_connection(ws)
|
await self._on_connection(ws)
|
||||||
|
|
@ -595,7 +512,66 @@ class NWCServiceProvider:
|
||||||
logger.debug("Reconnecting to NWC relay...")
|
logger.debug("Reconnecting to NWC relay...")
|
||||||
await self._ratelimit("connecting")
|
await self._ratelimit("connecting")
|
||||||
|
|
||||||
def _verify_event(self, event: dict) -> bool:
|
def _encrypt_content(
|
||||||
|
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Encrypts the content for the given public key
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (str): The content to be encrypted.
|
||||||
|
pubkey_hex (str): The public key in hex format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The encrypted content.
|
||||||
|
"""
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
|
||||||
|
# random iv (16B)
|
||||||
|
if not iv_seed:
|
||||||
|
iv = Random.new().read(AES.block_size)
|
||||||
|
else:
|
||||||
|
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder="big")).digest()
|
||||||
|
iv = iv[: AES.block_size]
|
||||||
|
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
# padding
|
||||||
|
content_bytes = pad(content_bytes, AES.block_size)
|
||||||
|
|
||||||
|
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
|
||||||
|
iv_b64 = base64.b64encode(iv).decode("ascii")
|
||||||
|
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
|
||||||
|
return encrypted_content
|
||||||
|
|
||||||
|
def _decrypt_content(self, content: str, pubkey_hex: str) -> str:
|
||||||
|
"""
|
||||||
|
Decrypts the content for the given public key
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (str): The encrypted content.
|
||||||
|
pubkey_hex (str): The public key in hex format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The decrypted content.
|
||||||
|
"""
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
|
||||||
|
# extract iv and content
|
||||||
|
(encrypted_content_b64, iv_b64) = content.split("?iv=")
|
||||||
|
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
|
||||||
|
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||||
|
# Decrypt
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
decrypted_bytes = aes.decrypt(encrypted_content)
|
||||||
|
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
|
||||||
|
decrypted = decrypted_bytes.decode("utf-8")
|
||||||
|
return decrypted
|
||||||
|
|
||||||
|
def _verify_event(self, event: Dict) -> bool:
|
||||||
"""
|
"""
|
||||||
Verify the event signature
|
Verify the event signature
|
||||||
|
|
||||||
|
|
@ -619,12 +595,14 @@ class NWCServiceProvider:
|
||||||
if event_id != event["id"]: # Invalid event id
|
if event_id != event["id"]: # Invalid event id
|
||||||
return False
|
return False
|
||||||
pubkey_hex = event["pubkey"]
|
pubkey_hex = event["pubkey"]
|
||||||
pubkey = PublicKeyXOnly(bytes.fromhex(pubkey_hex))
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
if not pubkey.verify(bytes.fromhex(event["sig"]), bytes.fromhex(event_id)):
|
if not pubkey.schnorr_verify(
|
||||||
|
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _sign_event(self, event: dict) -> dict:
|
def _sign_event(self, event: Dict) -> Dict:
|
||||||
"""
|
"""
|
||||||
Signs the event (in place)
|
Signs the event (in place)
|
||||||
|
|
||||||
|
|
@ -649,8 +627,10 @@ class NWCServiceProvider:
|
||||||
event["id"] = event_id
|
event["id"] = event_id
|
||||||
event["pubkey"] = self.public_key_hex
|
event["pubkey"] = self.public_key_hex
|
||||||
|
|
||||||
signature = self.private_key.sign(bytes.fromhex(event_id))
|
signature = (
|
||||||
event["sig"] = signature.hex() # type: ignore
|
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
|
||||||
|
).hex()
|
||||||
|
event["sig"] = signature
|
||||||
return event
|
return event
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
|
|
@ -667,17 +647,6 @@ class NWCServiceProvider:
|
||||||
self.gc_task.cancel()
|
self.gc_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error closing gc loop: " + str(e))
|
logger.warning("Error closing gc loop: " + str(e))
|
||||||
try:
|
|
||||||
if self.info_event_task:
|
|
||||||
self.info_event_task.cancel()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error closing info event loop: " + str(e))
|
|
||||||
request_tasks = list(self.request_tasks)
|
|
||||||
for task in request_tasks:
|
|
||||||
task.cancel()
|
|
||||||
if request_tasks:
|
|
||||||
await asyncio.gather(*request_tasks, return_exceptions=True)
|
|
||||||
self.request_tasks.clear()
|
|
||||||
# close the websocket
|
# close the websocket
|
||||||
try:
|
try:
|
||||||
if self.ws:
|
if self.ws:
|
||||||
|
|
|
||||||
62
package-lock.json
generated
62
package-lock.json
generated
|
|
@ -1,62 +0,0 @@
|
||||||
{
|
|
||||||
"name": "nwcprovider",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "nwcprovider",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"prettier": "^3.8.1",
|
|
||||||
"pyright": "^1.1.408"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
|
||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pyright": {
|
|
||||||
"version": "1.1.408",
|
|
||||||
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.408.tgz",
|
|
||||||
"integrity": "sha512-N61pxaLLCsPcUuPPHMNIrGoZgGBgrbjBX5UqkaT5UV8NVZdL7ExsO6N3ectv1DzAUsLOzdlyqoYtX76u8eF4YA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"pyright": "index.js",
|
|
||||||
"pyright-langserver": "langserver.index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
package.json
15
package.json
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"name": "nwcprovider",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"prettier": "^3.8.1",
|
|
||||||
"pyright": "^1.1.408"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
paranoia.py
19
paranoia.py
|
|
@ -2,12 +2,6 @@
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
ENABLE_HARDENING = True
|
ENABLE_HARDENING = True
|
||||||
WHITELISTED_NON_PRINTABLE_CHARS = {
|
|
||||||
"\n", # newline
|
|
||||||
"\r", # carriage return
|
|
||||||
"\t", # tab
|
|
||||||
"\xa0", # non-breaking space ( )
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def panic(reason: str):
|
def panic(reason: str):
|
||||||
|
|
@ -23,17 +17,8 @@ def assert_printable(v: str):
|
||||||
return
|
return
|
||||||
if not isinstance(v, str):
|
if not isinstance(v, str):
|
||||||
panic("not a string " + str(v))
|
panic("not a string " + str(v))
|
||||||
for ch in v:
|
if not v.isprintable():
|
||||||
# check if printable
|
panic("string contains non-printable characters")
|
||||||
if ch.isprintable():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# check if whitelisted non-printable
|
|
||||||
if ch in WHITELISTED_NON_PRINTABLE_CHARS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Anything else is rejected
|
|
||||||
panic(f"string contains non-printable character: (0x{ord(ch):04X})")
|
|
||||||
|
|
||||||
|
|
||||||
# Check if number is valid int and not NaN
|
# Check if number is valid int and not NaN
|
||||||
|
|
|
||||||
4209
poetry.lock
generated
4209
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,41 +1,37 @@
|
||||||
[project]
|
[tool.poetry]
|
||||||
name = "nwcprovider"
|
name = "nwcprovider"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "A NWC service provider for LNbits."
|
description = "A NWC service provider for LNbits."
|
||||||
requires-python = ">=3.10,<3.13"
|
authors = ["Riccardo Balbo <oc@rblb.it>"]
|
||||||
authors = [{ name = "Riccardo Balbo", email = "oc@rblb.it" }]
|
|
||||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nwcprovider" }
|
|
||||||
dependencies = [ "lnbits>1" ]
|
|
||||||
|
|
||||||
[dependency-groups]
|
[tool.poetry.dependencies]
|
||||||
dev = [
|
python = "^3.10 | ^3.9"
|
||||||
"black",
|
lnbits = {version = "*", allow-prereleases = true}
|
||||||
"pytest-asyncio",
|
mypy = "^1.13.0"
|
||||||
"pytest",
|
|
||||||
"mypy",
|
|
||||||
"pre-commit",
|
|
||||||
"ruff",
|
|
||||||
"pytest-md",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry.group.dev.dependencies]
|
||||||
package-mode = false
|
black = "^24.3.0"
|
||||||
|
pytest-asyncio = "^0.21.0"
|
||||||
|
pytest = "^7.3.2"
|
||||||
|
mypy = "^1.5.1"
|
||||||
|
pre-commit = "^3.2.2"
|
||||||
|
ruff = "^0.3.2"
|
||||||
|
pytest-md = "^0.2.0"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
plugins = ["pydantic.mypy"]
|
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
"pynostr.*",
|
"lnbits.*",
|
||||||
|
"loguru.*",
|
||||||
|
"fastapi.*",
|
||||||
|
"pydantic.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = "True"
|
ignore_missing_imports = "True"
|
||||||
|
|
||||||
[tool.pydantic-mypy]
|
|
||||||
init_forbid_extra = true
|
|
||||||
init_typed = true
|
|
||||||
warn_required_dynamic_aliases = true
|
|
||||||
warn_untyped_fields = true
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
log_cli = false
|
log_cli = false
|
||||||
testpaths = [
|
testpaths = [
|
||||||
|
|
@ -83,8 +79,8 @@ classmethod-decorators = [
|
||||||
# [tool.ruff.lint.extend-per-file-ignores]
|
# [tool.ruff.lint.extend-per-file-ignores]
|
||||||
# "views_api.py" = ["F401"]
|
# "views_api.py" = ["F401"]
|
||||||
|
|
||||||
[tool.ruff.lint.mccabe]
|
# [tool.ruff.lint.mccabe]
|
||||||
max-complexity = 11
|
# max-complexity = 10
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-bugbear]
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 498 KiB |
|
|
@ -1,77 +1,87 @@
|
||||||
window.app = Vue.createApp({
|
window.app = Vue.createApp({
|
||||||
el: '#vue',
|
el: "#vue",
|
||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
delimiters: ['${', '}'],
|
delimiters: ["${", "}"],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
config: {},
|
entries: [],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
name: 'key',
|
name: "key",
|
||||||
required: true,
|
required: true,
|
||||||
label: 'Key',
|
label: "Key",
|
||||||
align: 'left',
|
align: "left",
|
||||||
field: row => row.key,
|
field: (row) => row.key,
|
||||||
sortable: true
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'value',
|
name: "value",
|
||||||
required: true,
|
required: true,
|
||||||
label: 'Value',
|
label: "Value",
|
||||||
align: 'left',
|
align: "left",
|
||||||
field: row => row.value,
|
field: (row) => row.value,
|
||||||
sortable: true
|
sortable: true,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
fetchConfig() {
|
fetchConfig() {
|
||||||
this.entries = []
|
this.entries = [];
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/nwcprovider/api/v1/config')
|
.request(
|
||||||
.then(response => {
|
"GET",
|
||||||
this.config = response.data
|
"/nwcprovider/api/v1/config",
|
||||||
console.log('Config fetched:', this.config)
|
this.g.user.wallets[0].adminkey,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const newEntries = [];
|
||||||
|
for (const [key, value] of Object.entries(response.data)) {
|
||||||
|
newEntries.push({
|
||||||
|
key: key,
|
||||||
|
value: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.entries.length = 0;
|
||||||
|
this.entries.push(...newEntries);
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
console.error('Error fetching config:', error)
|
console.error("Error fetching config:", error);
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
async saveConfig() {
|
async saveConfig() {
|
||||||
const data = {}
|
const data = {};
|
||||||
for (const [key, value] of Object.entries(this.config)) {
|
for (const entry of this.entries) {
|
||||||
data[key] = value
|
data[entry.key] = entry.value;
|
||||||
}
|
}
|
||||||
console.log('Saving config:', data)
|
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
"POST",
|
||||||
'/nwcprovider/api/v1/config',
|
"/nwcprovider/api/v1/config",
|
||||||
null,
|
this.g.user.wallets[0].adminkey,
|
||||||
data
|
data,
|
||||||
)
|
);
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: "positive",
|
||||||
message: 'Config saved!'
|
message: "Config saved!",
|
||||||
})
|
});
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'warning',
|
type: "warning",
|
||||||
message:
|
message:
|
||||||
'You need to restart the server for the changes to take effect!'
|
"You need to restart the server for the changes to take effect!",
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'negative',
|
type: "negative",
|
||||||
message: 'Error saving config: ' + String(error)
|
message: "Error saving config: " + String(error),
|
||||||
})
|
});
|
||||||
console.error('Error saving config:', error)
|
console.error("Error saving config:", error);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created: function () {
|
created: function () {
|
||||||
this.fetchConfig()
|
this.fetchConfig();
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
window.app = Vue.createApp({
|
window.app = Vue.createApp({
|
||||||
el: '#vue',
|
el: "#vue",
|
||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
delimiters: ['${', '}'],
|
delimiters: ["${", "}"],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
selectedWallet: null,
|
selectedWallet: null,
|
||||||
|
|
@ -10,244 +10,244 @@ window.app = Vue.createApp({
|
||||||
nwcsTable: {
|
nwcsTable: {
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
name: 'description',
|
name: "description",
|
||||||
align: 'left',
|
align: "left",
|
||||||
label: 'Description',
|
label: "Description",
|
||||||
field: 'description'
|
field: "description",
|
||||||
},
|
},
|
||||||
{name: 'status', align: 'left', label: 'Status', field: 'status'},
|
{ name: "status", align: "left", label: "Status", field: "status" },
|
||||||
{
|
{
|
||||||
name: 'last_used',
|
name: "last_used",
|
||||||
align: 'left',
|
align: "left",
|
||||||
label: 'Last used',
|
label: "Last used",
|
||||||
field: 'last_used'
|
field: "last_used",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'created_at',
|
name: "created_at",
|
||||||
align: 'left',
|
align: "left",
|
||||||
label: 'Created',
|
label: "Created",
|
||||||
field: 'created_at'
|
field: "created_at",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'expires_at',
|
name: "expires_at",
|
||||||
align: 'left',
|
align: "left",
|
||||||
label: 'Expires',
|
label: "Expires",
|
||||||
field: 'expires_at'
|
field: "expires_at",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
connectDialog: {
|
connectDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {}
|
data: {},
|
||||||
},
|
},
|
||||||
pairingDialog: {
|
pairingDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
pairingUrl: ''
|
pairingUrl: "",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
pairingQrDialog: {
|
pairingQrDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
pairingUrl: ''
|
pairingUrl: "",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
connectionInfoDialog: {
|
connectionInfoDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {}
|
data: {},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
showConnectDialog() {
|
showConnectDialog() {
|
||||||
const wallet = this.getWallet()
|
const wallet = this.getWallet();
|
||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'negative',
|
type: "negative",
|
||||||
message: 'Please select a wallet first'
|
message: "Please select a wallet first",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.connectDialog.show = true
|
this.connectDialog.show = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
openConnectionInfoDialog(data) {
|
openConnectionInfoDialog(data) {
|
||||||
this.connectionInfoDialog.data = data
|
this.connectionInfoDialog.data = data;
|
||||||
this.connectionInfoDialog.show = true
|
this.connectionInfoDialog.show = true;
|
||||||
},
|
},
|
||||||
closeConnectionInfoDialog() {
|
closeConnectionInfoDialog() {
|
||||||
this.connectionInfoDialog.show = false
|
this.connectionInfoDialog.show = false;
|
||||||
},
|
},
|
||||||
openPairingUrl() {
|
openPairingUrl() {
|
||||||
const url = this.pairingDialog.data.pairingUrl
|
const url = this.pairingDialog.data.pairingUrl;
|
||||||
if (url) window.open(url, '_blank')
|
if (url) window.open(url, "_blank");
|
||||||
},
|
},
|
||||||
go(url) {
|
go(url) {
|
||||||
window.open(url, '_blank')
|
window.open(url, "_blank");
|
||||||
},
|
},
|
||||||
async copyPairingUrl() {
|
async copyPairingUrl() {
|
||||||
const url = this.pairingDialog.data.pairingUrl
|
const url = this.pairingDialog.data.pairingUrl;
|
||||||
if (url) {
|
if (url) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url)
|
await navigator.clipboard.writeText(url);
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: "positive",
|
||||||
message: 'URL copied to clipboard'
|
message: "URL copied to clipboard",
|
||||||
})
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'negative',
|
type: "negative",
|
||||||
message: 'Failed to copy URL.'
|
message: "Failed to copy URL.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showPairingQR() {
|
showPairingQR() {
|
||||||
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl
|
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
|
||||||
this.pairingQrDialog.show = true
|
this.pairingQrDialog.show = true;
|
||||||
},
|
},
|
||||||
closePairingQrDialog() {
|
closePairingQrDialog() {
|
||||||
this.pairingQrDialog.show = false
|
this.pairingQrDialog.show = false;
|
||||||
},
|
},
|
||||||
loadConnectDialogData() {
|
loadConnectDialogData() {
|
||||||
this.connectDialog.data = {
|
this.connectDialog.data = {
|
||||||
description: '',
|
description: "",
|
||||||
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
|
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
|
||||||
neverExpires: true,
|
neverExpires: true,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
budgets: []
|
budgets: [],
|
||||||
}
|
};
|
||||||
for (const permission of this.nodePermissions) {
|
for (const permission of this.nodePermissions) {
|
||||||
this.connectDialog.data.permissions.push({
|
this.connectDialog.data.permissions.push({
|
||||||
key: permission.key,
|
key: permission.key,
|
||||||
name: permission.name,
|
name: permission.name,
|
||||||
value: permission.value
|
value: permission.value,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteBudget(index) {
|
deleteBudget(index) {
|
||||||
this.connectDialog.data.budgets.splice(index, 1)
|
this.connectDialog.data.budgets.splice(index, 1);
|
||||||
},
|
},
|
||||||
addBudget() {
|
addBudget() {
|
||||||
this.connectDialog.data.budgets.push({
|
this.connectDialog.data.budgets.push({
|
||||||
budget_sats: 1000,
|
budget_sats: 1000,
|
||||||
used_budget_sats: 0,
|
used_budget_sats: 0,
|
||||||
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
||||||
expiration: 'never'
|
expiration: "never",
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
closeConnectDialog() {
|
closeConnectDialog() {
|
||||||
this.connectDialog.show = false
|
this.connectDialog.show = false;
|
||||||
this.loadConnectDialogData()
|
this.loadConnectDialogData();
|
||||||
},
|
},
|
||||||
getWallet: function () {
|
getWallet: function () {
|
||||||
let wallet = undefined
|
let wallet = undefined;
|
||||||
for (let i = 0; i < this.g.user.wallets.length; i++) {
|
for (let i = 0; i < this.g.user.wallets.length; i++) {
|
||||||
if (this.g.user.wallets[i].id == this.selectedWallet) {
|
if (this.g.user.wallets[i].id == this.selectedWallet) {
|
||||||
wallet = this.g.user.wallets[i]
|
wallet = this.g.user.wallets[i];
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return wallet
|
return wallet;
|
||||||
},
|
},
|
||||||
async generateKeyPair() {
|
async generateKeyPair() {
|
||||||
while (!window.NobleSecp256k1) {
|
while (!window.NobleSecp256k1) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1))
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
}
|
}
|
||||||
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey()
|
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
|
||||||
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes)
|
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
|
||||||
const out = {
|
const out = {
|
||||||
privKeyBytes: privKeyBytes,
|
privKeyBytes: privKeyBytes,
|
||||||
pubKeyBytes: pubKeyBytes,
|
pubKeyBytes: pubKeyBytes,
|
||||||
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
|
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
|
||||||
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1))
|
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1)),
|
||||||
}
|
};
|
||||||
return out
|
return out;
|
||||||
},
|
},
|
||||||
deleteNWC: async function (pubkey) {
|
deleteNWC: async function (pubkey) {
|
||||||
Quasar.Dialog.create({
|
Quasar.Dialog.create({
|
||||||
title: 'Confirm Deletion',
|
title: "Confirm Deletion",
|
||||||
message: 'Are you sure you want to delete this connection?',
|
message: "Are you sure you want to delete this connection?",
|
||||||
cancel: true,
|
cancel: true,
|
||||||
persistent: true
|
persistent: true,
|
||||||
})
|
})
|
||||||
.onOk(async () => {
|
.onOk(async () => {
|
||||||
try {
|
try {
|
||||||
const wallet = this.getWallet()
|
const wallet = this.getWallet();
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'DELETE',
|
"DELETE",
|
||||||
`/nwcprovider/api/v1/nwc/${pubkey}`,
|
`/nwcprovider/api/v1/nwc/${pubkey}`,
|
||||||
wallet.adminkey
|
wallet.adminkey,
|
||||||
)
|
);
|
||||||
this.loadNwcs()
|
this.loadNwcs();
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: "positive",
|
||||||
message: 'Deleted successfully'
|
message: "Deleted successfully",
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onCancel(() => {
|
.onCancel(() => {
|
||||||
// User canceled the operation
|
// User canceled the operation
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
loadNwcs: async function () {
|
loadNwcs: async function () {
|
||||||
const wallet = this.getWallet()
|
const wallet = this.getWallet();
|
||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
this.nwcs = []
|
this.nwcs = [];
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
"GET",
|
||||||
'/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true',
|
"/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true",
|
||||||
wallet.adminkey
|
wallet.adminkey,
|
||||||
)
|
);
|
||||||
this.nwcs = response.data
|
this.nwcs = response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.nwcs = []
|
this.nwcs = [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
"GET",
|
||||||
'/nwcprovider/api/v1/permissions',
|
"/nwcprovider/api/v1/permissions",
|
||||||
wallet.adminkey
|
wallet.adminkey,
|
||||||
)
|
);
|
||||||
const permissions = []
|
const permissions = [];
|
||||||
for (const [key, value] of Object.entries(response.data)) {
|
for (const [key, value] of Object.entries(response.data)) {
|
||||||
permissions.push({
|
permissions.push({
|
||||||
key: key,
|
key: key,
|
||||||
name: value.name,
|
name: value.name,
|
||||||
value: value.default
|
value: value.default,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
this.nodePermissions = permissions
|
this.nodePermissions = permissions;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Lnbits.utils.notifyApiError(error)
|
Lnbits.utils.notifyApiError(error);
|
||||||
}
|
}
|
||||||
this.loadConnectDialogData()
|
this.loadConnectDialogData();
|
||||||
const newTableEntries = []
|
const newTableEntries = [];
|
||||||
for (const nwc of this.nwcs) {
|
for (const nwc of this.nwcs) {
|
||||||
const t = Quasar.date.formatDate(
|
const t = Quasar.date.formatDate(
|
||||||
new Date(nwc.data.created_at * 1000),
|
new Date(nwc.data.created_at * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
"YYYY-MM-DD HH:mm",
|
||||||
)
|
);
|
||||||
const e =
|
const e =
|
||||||
nwc.data.expires_at > 0
|
nwc.data.expires_at > 0
|
||||||
? Quasar.date.formatDate(
|
? Quasar.date.formatDate(
|
||||||
new Date(nwc.data.expires_at * 1000),
|
new Date(nwc.data.expires_at * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
"YYYY-MM-DD HH:mm",
|
||||||
)
|
)
|
||||||
: 'Never'
|
: "Never";
|
||||||
const l = Quasar.date.formatDate(
|
const l = Quasar.date.formatDate(
|
||||||
new Date(nwc.data.last_used * 1000),
|
new Date(nwc.data.last_used * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
"YYYY-MM-DD HH:mm",
|
||||||
)
|
);
|
||||||
const nwcTableEntry = {
|
const nwcTableEntry = {
|
||||||
description: nwc.data.description,
|
description: nwc.data.description,
|
||||||
created_at: t,
|
created_at: t,
|
||||||
|
|
@ -256,130 +256,131 @@ window.app = Vue.createApp({
|
||||||
pubkey: nwc.data.pubkey,
|
pubkey: nwc.data.pubkey,
|
||||||
permissions: nwc.data.permissions,
|
permissions: nwc.data.permissions,
|
||||||
budgets: [],
|
budgets: [],
|
||||||
status: 'Active'
|
status: "Active",
|
||||||
}
|
};
|
||||||
if (
|
if (
|
||||||
nwc.data.expires_at > 0 &&
|
nwc.data.expires_at > 0 &&
|
||||||
nwc.data.expires_at < new Date().getTime() / 1000
|
nwc.data.expires_at < new Date().getTime() / 1000
|
||||||
) {
|
) {
|
||||||
nwcTableEntry.status = 'Expired'
|
nwcTableEntry.status = "Expired";
|
||||||
}
|
}
|
||||||
for (const budget of nwc.budgets) {
|
for (const budget of nwc.budgets) {
|
||||||
const createdAt = Quasar.date.formatDate(
|
const createdAt = Quasar.date.formatDate(
|
||||||
new Date(budget.created_at * 1000),
|
new Date(budget.created_at * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
"YYYY-MM-DD HH:mm",
|
||||||
)
|
);
|
||||||
let refreshWindow = budget.refresh_window
|
let refreshWindow = budget.refresh_window;
|
||||||
if (refreshWindow <= 0) {
|
if (refreshWindow <= 0) {
|
||||||
refreshWindow = 'Never'
|
refreshWindow = "Never";
|
||||||
} else if (refreshWindow == 60 * 60 * 24) {
|
} else if (refreshWindow == 60 * 60 * 24) {
|
||||||
refreshWindow = 'Daily'
|
refreshWindow = "Daily";
|
||||||
} else if (refreshWindow == 60 * 60 * 24 * 7) {
|
} else if (refreshWindow == 60 * 60 * 24 * 7) {
|
||||||
refreshWindow = 'Weekly'
|
refreshWindow = "Weekly";
|
||||||
} else if (refreshWindow == 60 * 60 * 24 * 30) {
|
} else if (refreshWindow == 60 * 60 * 24 * 30) {
|
||||||
refreshWindow = 'Monthly'
|
refreshWindow = "Monthly";
|
||||||
} else if (refreshWindow == 60 * 60 * 24 * 365) {
|
} else if (refreshWindow == 60 * 60 * 24 * 365) {
|
||||||
refreshWindow = 'Yearly'
|
refreshWindow = "Yearly";
|
||||||
}
|
}
|
||||||
nwcTableEntry.budgets.push({
|
nwcTableEntry.budgets.push({
|
||||||
budget_sats: budget.budget_msats / 1000,
|
budget_sats: budget.budget_msats / 1000,
|
||||||
used_budget_sats: budget.used_budget_msats / 1000,
|
used_budget_sats: budget.used_budget_msats / 1000,
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
refresh_window: refreshWindow
|
refresh_window: refreshWindow,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
newTableEntries.push(nwcTableEntry)
|
newTableEntries.push(nwcTableEntry);
|
||||||
}
|
}
|
||||||
this.nwcEntries = newTableEntries
|
this.nwcEntries = newTableEntries;
|
||||||
},
|
},
|
||||||
closePairingDialog() {
|
closePairingDialog() {
|
||||||
this.pairingDialog.show = false
|
this.pairingDialog.show = false;
|
||||||
},
|
},
|
||||||
async showPairingDialog(secret) {
|
async showPairingDialog(secret) {
|
||||||
let response = await LNbits.api.request(
|
let response = await LNbits.api.request(
|
||||||
'GET',
|
"GET",
|
||||||
'/nwcprovider/api/v1/pairing/{SECRET}'
|
"/nwcprovider/api/v1/pairing/{SECRET}",
|
||||||
)
|
);
|
||||||
response = response.data
|
response = response.data;
|
||||||
response = response.replace('{SECRET}', secret)
|
response = response.replace("{SECRET}", secret);
|
||||||
this.pairingDialog.data.pairingUrl = response
|
this.pairingDialog.data.pairingUrl = response;
|
||||||
this.pairingDialog.show = true
|
this.pairingDialog.show = true;
|
||||||
},
|
},
|
||||||
async confirmConnectDialog() {
|
async confirmConnectDialog() {
|
||||||
const keyPair = await this.generateKeyPair()
|
const keyPair = await this.generateKeyPair();
|
||||||
// timestamp
|
// timestamp
|
||||||
let expires_at = 0
|
let expires_at = 0;
|
||||||
if (!this.connectDialog.data.neverExpires) {
|
if (!this.connectDialog.data.neverExpires) {
|
||||||
expires_at =
|
expires_at =
|
||||||
new Date(this.connectDialog.data.expires_at).getTime() / 1000
|
new Date(this.connectDialog.data.expires_at).getTime() / 1000;
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
permissions: [],
|
permissions: [],
|
||||||
description: this.connectDialog.data.description,
|
description: this.connectDialog.data.description,
|
||||||
expires_at: expires_at,
|
expires_at: expires_at,
|
||||||
budgets: []
|
budgets: [],
|
||||||
}
|
};
|
||||||
for (const permission of this.connectDialog.data.permissions) {
|
for (const permission of this.connectDialog.data.permissions) {
|
||||||
if (permission.value) data.permissions.push(permission.key)
|
if (permission.value) data.permissions.push(permission.key);
|
||||||
}
|
}
|
||||||
for (const budget of this.connectDialog.data.budgets) {
|
for (const budget of this.connectDialog.data.budgets) {
|
||||||
const budget_msats = budget.budget_sats * 1000
|
const budget_msats = budget.budget_sats * 1000;
|
||||||
let refresh_window = 0
|
let refresh_window = 0;
|
||||||
switch (budget.expiry) {
|
switch (budget.expiry) {
|
||||||
case 'Daily':
|
case "Daily":
|
||||||
refresh_window = 60 * 60 * 24
|
refresh_window = 60 * 60 * 24;
|
||||||
break
|
break;
|
||||||
case 'Weekly':
|
case "Weekly":
|
||||||
refresh_window = 60 * 60 * 24 * 7
|
refresh_window = 60 * 60 * 24 * 7;
|
||||||
break
|
break;
|
||||||
case 'Monthly':
|
case "Monthly":
|
||||||
refresh_window = 60 * 60 * 24 * 30
|
refresh_window = 60 * 60 * 24 * 30;
|
||||||
break
|
break;
|
||||||
case 'Yearly':
|
case "Yearly":
|
||||||
refresh_window = 60 * 60 * 24 * 365
|
refresh_window = 60 * 60 * 24 * 365;
|
||||||
break
|
break;
|
||||||
case 'Never':
|
case "Never":
|
||||||
refresh_window = 0
|
refresh_window = 0;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
data.budgets.push({
|
data.budgets.push({
|
||||||
budget_msats: budget_msats,
|
budget_msats: budget_msats,
|
||||||
refresh_window: refresh_window,
|
refresh_window: refresh_window,
|
||||||
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000
|
created_at:
|
||||||
})
|
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const wallet = this.getWallet()
|
const wallet = this.getWallet();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'PUT',
|
"PUT",
|
||||||
'/nwcprovider/api/v1/nwc/' + keyPair.pubKey,
|
"/nwcprovider/api/v1/nwc/" + keyPair.pubKey,
|
||||||
wallet.adminkey,
|
wallet.adminkey,
|
||||||
data
|
data,
|
||||||
)
|
);
|
||||||
this.closeConnectDialog()
|
this.closeConnectDialog();
|
||||||
if (
|
if (
|
||||||
!response.data ||
|
!response.data ||
|
||||||
!response.data.data ||
|
!response.data.data ||
|
||||||
!response.data.data.pubkey
|
!response.data.data.pubkey
|
||||||
) {
|
) {
|
||||||
LNbits.utils.notifyApiError('Error creating nwc pairing')
|
LNbits.utils.notifyApiError("Error creating nwc pairing");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
this.showPairingDialog(keyPair.privKey)
|
this.showPairingDialog(keyPair.privKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error);
|
||||||
}
|
}
|
||||||
this.loadNwcs()
|
this.loadNwcs();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created: function () {
|
created: function () {
|
||||||
this.loadNwcs()
|
this.loadNwcs();
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
selectedWallet(newValue, oldValue) {
|
selectedWallet(newValue, oldValue) {
|
||||||
this.loadNwcs()
|
this.loadNwcs();
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
479
static/js/noble-secp256k1.min.js
vendored
479
static/js/noble-secp256k1.min.js
vendored
|
|
@ -4,93 +4,93 @@ const B256 = 2n ** 256n,
|
||||||
N = B256 - 0x14551231950b75fc4402da1732fc9bebfn,
|
N = B256 - 0x14551231950b75fc4402da1732fc9bebfn,
|
||||||
Gx = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,
|
Gx = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,
|
||||||
Gy = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
|
Gy = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
|
||||||
CURVE = {p: P, n: N, a: 0n, b: 7n, Gx, Gy},
|
CURVE = { p: P, n: N, a: 0n, b: 7n, Gx, Gy },
|
||||||
fLen = 32,
|
fLen = 32,
|
||||||
crv = t => mod(mod(t * t) * t + CURVE.b),
|
crv = (t) => mod(mod(t * t) * t + CURVE.b),
|
||||||
err = (t = '') => {
|
err = (t = "") => {
|
||||||
throw new Error(t)
|
throw new Error(t);
|
||||||
},
|
},
|
||||||
big = t => 'bigint' == typeof t,
|
big = (t) => "bigint" == typeof t,
|
||||||
str = t => 'string' == typeof t,
|
str = (t) => "string" == typeof t,
|
||||||
fe = t => big(t) && 0n < t && t < P,
|
fe = (t) => big(t) && 0n < t && t < P,
|
||||||
ge = t => big(t) && 0n < t && t < N,
|
ge = (t) => big(t) && 0n < t && t < N,
|
||||||
isu8 = t =>
|
isu8 = (t) =>
|
||||||
t instanceof Uint8Array ||
|
t instanceof Uint8Array ||
|
||||||
(null != t && 'object' == typeof t && 'Uint8Array' === t.constructor.name),
|
(null != t && "object" == typeof t && "Uint8Array" === t.constructor.name),
|
||||||
au8 = (t, n) =>
|
au8 = (t, n) =>
|
||||||
!isu8(t) || ('number' == typeof n && n > 0 && t.length !== n)
|
!isu8(t) || ("number" == typeof n && n > 0 && t.length !== n)
|
||||||
? err('Uint8Array expected')
|
? err("Uint8Array expected")
|
||||||
: t,
|
: t,
|
||||||
u8n = t => new Uint8Array(t),
|
u8n = (t) => new Uint8Array(t),
|
||||||
toU8 = (t, n) => au8(str(t) ? h2b(t) : u8n(au8(t)), n),
|
toU8 = (t, n) => au8(str(t) ? h2b(t) : u8n(au8(t)), n),
|
||||||
mod = (t, n = P) => {
|
mod = (t, n = P) => {
|
||||||
let e = t % n
|
let e = t % n;
|
||||||
return e >= 0n ? e : n + e
|
return e >= 0n ? e : n + e;
|
||||||
},
|
},
|
||||||
isPoint = t => (t instanceof Point ? t : err('Point expected'))
|
isPoint = (t) => (t instanceof Point ? t : err("Point expected"));
|
||||||
class Point {
|
class Point {
|
||||||
constructor(t, n, e) {
|
constructor(t, n, e) {
|
||||||
;((this.px = t), (this.py = n), (this.pz = e))
|
(this.px = t), (this.py = n), (this.pz = e);
|
||||||
}
|
}
|
||||||
static fromAffine(t) {
|
static fromAffine(t) {
|
||||||
return 0n === t.x && 0n === t.y ? Point.ZERO : new Point(t.x, t.y, 1n)
|
return 0n === t.x && 0n === t.y ? Point.ZERO : new Point(t.x, t.y, 1n);
|
||||||
}
|
}
|
||||||
static fromHex(t) {
|
static fromHex(t) {
|
||||||
let n
|
let n;
|
||||||
const e = (t = toU8(t))[0],
|
const e = (t = toU8(t))[0],
|
||||||
r = t.subarray(1),
|
r = t.subarray(1),
|
||||||
o = slcNum(r, 0, 32),
|
o = slcNum(r, 0, 32),
|
||||||
i = t.length
|
i = t.length;
|
||||||
if (33 === i && [2, 3].includes(e)) {
|
if (33 === i && [2, 3].includes(e)) {
|
||||||
fe(o) || err('Point hex invalid: x not FE')
|
fe(o) || err("Point hex invalid: x not FE");
|
||||||
let t = sqrt(crv(o))
|
let t = sqrt(crv(o));
|
||||||
;(!(1 & ~e) !== (1n === (1n & t)) && (t = mod(-t)),
|
!(1 & ~e) !== (1n === (1n & t)) && (t = mod(-t)),
|
||||||
(n = new Point(o, t, 1n)))
|
(n = new Point(o, t, 1n));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
65 === i && 4 === e && (n = new Point(o, slcNum(r, 32, 64), 1n)),
|
65 === i && 4 === e && (n = new Point(o, slcNum(r, 32, 64), 1n)),
|
||||||
n ? n.ok() : err('Point is not on curve')
|
n ? n.ok() : err("Point is not on curve")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
static fromPrivateKey(t) {
|
static fromPrivateKey(t) {
|
||||||
return G.mul(toPriv(t))
|
return G.mul(toPriv(t));
|
||||||
}
|
}
|
||||||
get x() {
|
get x() {
|
||||||
return this.aff().x
|
return this.aff().x;
|
||||||
}
|
}
|
||||||
get y() {
|
get y() {
|
||||||
return this.aff().y
|
return this.aff().y;
|
||||||
}
|
}
|
||||||
equals(t) {
|
equals(t) {
|
||||||
const {px: n, py: e, pz: r} = this,
|
const { px: n, py: e, pz: r } = this,
|
||||||
{px: o, py: i, pz: s} = isPoint(t),
|
{ px: o, py: i, pz: s } = isPoint(t),
|
||||||
a = mod(n * s),
|
a = mod(n * s),
|
||||||
c = mod(o * r),
|
c = mod(o * r),
|
||||||
d = mod(e * s),
|
d = mod(e * s),
|
||||||
u = mod(i * r)
|
u = mod(i * r);
|
||||||
return a === c && d === u
|
return a === c && d === u;
|
||||||
}
|
}
|
||||||
negate() {
|
negate() {
|
||||||
return new Point(this.px, mod(-this.py), this.pz)
|
return new Point(this.px, mod(-this.py), this.pz);
|
||||||
}
|
}
|
||||||
double() {
|
double() {
|
||||||
return this.add(this)
|
return this.add(this);
|
||||||
}
|
}
|
||||||
add(t) {
|
add(t) {
|
||||||
const {px: n, py: e, pz: r} = this,
|
const { px: n, py: e, pz: r } = this,
|
||||||
{px: o, py: i, pz: s} = isPoint(t),
|
{ px: o, py: i, pz: s } = isPoint(t),
|
||||||
{a, b: c} = CURVE
|
{ a, b: c } = CURVE;
|
||||||
let d = 0n,
|
let d = 0n,
|
||||||
u = 0n,
|
u = 0n,
|
||||||
m = 0n
|
m = 0n;
|
||||||
const h = mod(3n * c)
|
const h = mod(3n * c);
|
||||||
let l = mod(n * o),
|
let l = mod(n * o),
|
||||||
y = mod(e * i),
|
y = mod(e * i),
|
||||||
f = mod(r * s),
|
f = mod(r * s),
|
||||||
p = mod(n + e),
|
p = mod(n + e),
|
||||||
b = mod(o + i)
|
b = mod(o + i);
|
||||||
;((p = mod(p * b)), (b = mod(l + y)), (p = mod(p - b)), (b = mod(n + r)))
|
(p = mod(p * b)), (b = mod(l + y)), (p = mod(p - b)), (b = mod(n + r));
|
||||||
let g = mod(o + s)
|
let g = mod(o + s);
|
||||||
return (
|
return (
|
||||||
(b = mod(b * g)),
|
(b = mod(b * g)),
|
||||||
(g = mod(l + f)),
|
(g = mod(l + f)),
|
||||||
|
|
@ -123,301 +123,300 @@ class Point {
|
||||||
(m = mod(g * m)),
|
(m = mod(g * m)),
|
||||||
(m = mod(m + l)),
|
(m = mod(m + l)),
|
||||||
new Point(d, u, m)
|
new Point(d, u, m)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
mul(t, n = !0) {
|
mul(t, n = !0) {
|
||||||
if (!n && 0n === t) return I
|
if (!n && 0n === t) return I;
|
||||||
if ((ge(t) || err('invalid scalar'), this.equals(G))) return wNAF(t).p
|
if ((ge(t) || err("invalid scalar"), this.equals(G))) return wNAF(t).p;
|
||||||
let e = I,
|
let e = I,
|
||||||
r = G
|
r = G;
|
||||||
for (let o = this; t > 0n; o = o.double(), t >>= 1n)
|
for (let o = this; t > 0n; o = o.double(), t >>= 1n)
|
||||||
1n & t ? (e = e.add(o)) : n && (r = r.add(o))
|
1n & t ? (e = e.add(o)) : n && (r = r.add(o));
|
||||||
return e
|
return e;
|
||||||
}
|
}
|
||||||
mulAddQUns(t, n, e) {
|
mulAddQUns(t, n, e) {
|
||||||
return this.mul(n, !1).add(t.mul(e, !1)).ok()
|
return this.mul(n, !1).add(t.mul(e, !1)).ok();
|
||||||
}
|
}
|
||||||
toAffine() {
|
toAffine() {
|
||||||
const {px: t, py: n, pz: e} = this
|
const { px: t, py: n, pz: e } = this;
|
||||||
if (this.equals(I)) return {x: 0n, y: 0n}
|
if (this.equals(I)) return { x: 0n, y: 0n };
|
||||||
if (1n === e) return {x: t, y: n}
|
if (1n === e) return { x: t, y: n };
|
||||||
const r = inv(e)
|
const r = inv(e);
|
||||||
return (
|
return (
|
||||||
1n !== mod(e * r) && err('invalid inverse'),
|
1n !== mod(e * r) && err("invalid inverse"),
|
||||||
{x: mod(t * r), y: mod(n * r)}
|
{ x: mod(t * r), y: mod(n * r) }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
assertValidity() {
|
assertValidity() {
|
||||||
const {x: t, y: n} = this.aff()
|
const { x: t, y: n } = this.aff();
|
||||||
return (
|
return (
|
||||||
(fe(t) && fe(n)) || err('Point invalid: x or y'),
|
(fe(t) && fe(n)) || err("Point invalid: x or y"),
|
||||||
mod(n * n) === crv(t) ? this : err('Point invalid: not on curve')
|
mod(n * n) === crv(t) ? this : err("Point invalid: not on curve")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
multiply(t) {
|
multiply(t) {
|
||||||
return this.mul(t)
|
return this.mul(t);
|
||||||
}
|
}
|
||||||
aff() {
|
aff() {
|
||||||
return this.toAffine()
|
return this.toAffine();
|
||||||
}
|
}
|
||||||
ok() {
|
ok() {
|
||||||
return this.assertValidity()
|
return this.assertValidity();
|
||||||
}
|
}
|
||||||
toHex(t = !0) {
|
toHex(t = !0) {
|
||||||
const {x: n, y: e} = this.aff()
|
const { x: n, y: e } = this.aff();
|
||||||
return (
|
return (
|
||||||
(t ? (0n === (1n & e) ? '02' : '03') : '04') + n2h(n) + (t ? '' : n2h(e))
|
(t ? (0n === (1n & e) ? "02" : "03") : "04") + n2h(n) + (t ? "" : n2h(e))
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
toRawBytes(t = !0) {
|
toRawBytes(t = !0) {
|
||||||
return h2b(this.toHex(t))
|
return h2b(this.toHex(t));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
;((Point.BASE = new Point(Gx, Gy, 1n)), (Point.ZERO = new Point(0n, 1n, 0n)))
|
(Point.BASE = new Point(Gx, Gy, 1n)), (Point.ZERO = new Point(0n, 1n, 0n));
|
||||||
const {BASE: G, ZERO: I} = Point,
|
const { BASE: G, ZERO: I } = Point,
|
||||||
padh = (t, n) => t.toString(16).padStart(n, '0'),
|
padh = (t, n) => t.toString(16).padStart(n, "0"),
|
||||||
b2h = t =>
|
b2h = (t) =>
|
||||||
Array.from(t)
|
Array.from(t)
|
||||||
.map(t => padh(t, 2))
|
.map((t) => padh(t, 2))
|
||||||
.join(''),
|
.join(""),
|
||||||
h2b = t => {
|
h2b = (t) => {
|
||||||
const n = t.length
|
const n = t.length;
|
||||||
;(!str(t) || n % 2) && err('hex invalid 1')
|
(!str(t) || n % 2) && err("hex invalid 1");
|
||||||
const e = u8n(n / 2)
|
const e = u8n(n / 2);
|
||||||
for (let n = 0; n < e.length; n++) {
|
for (let n = 0; n < e.length; n++) {
|
||||||
const r = 2 * n,
|
const r = 2 * n,
|
||||||
o = t.slice(r, r + 2),
|
o = t.slice(r, r + 2),
|
||||||
i = Number.parseInt(o, 16)
|
i = Number.parseInt(o, 16);
|
||||||
;((Number.isNaN(i) || i < 0) && err('hex invalid 2'), (e[n] = i))
|
(Number.isNaN(i) || i < 0) && err("hex invalid 2"), (e[n] = i);
|
||||||
}
|
}
|
||||||
return e
|
return e;
|
||||||
},
|
},
|
||||||
b2n = t => BigInt('0x' + (b2h(t) || '0')),
|
b2n = (t) => BigInt("0x" + (b2h(t) || "0")),
|
||||||
slcNum = (t, n, e) => b2n(t.slice(n, e)),
|
slcNum = (t, n, e) => b2n(t.slice(n, e)),
|
||||||
n2b = t =>
|
n2b = (t) =>
|
||||||
big(t) && t >= 0n && t < B256 ? h2b(padh(t, 64)) : err('bigint expected'),
|
big(t) && t >= 0n && t < B256 ? h2b(padh(t, 64)) : err("bigint expected"),
|
||||||
n2h = t => b2h(n2b(t)),
|
n2h = (t) => b2h(n2b(t)),
|
||||||
concatB = (...t) => {
|
concatB = (...t) => {
|
||||||
const n = u8n(t.reduce((t, n) => t + au8(n).length, 0))
|
const n = u8n(t.reduce((t, n) => t + au8(n).length, 0));
|
||||||
let e = 0
|
let e = 0;
|
||||||
return (
|
return (
|
||||||
t.forEach(t => {
|
t.forEach((t) => {
|
||||||
;(n.set(t, e), (e += t.length))
|
n.set(t, e), (e += t.length);
|
||||||
}),
|
}),
|
||||||
n
|
n
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
inv = (t, n = P) => {
|
inv = (t, n = P) => {
|
||||||
;(0n === t || n <= 0n) && err('no inverse n=' + t + ' mod=' + n)
|
(0n === t || n <= 0n) && err("no inverse n=" + t + " mod=" + n);
|
||||||
let e = mod(t, n),
|
let e = mod(t, n),
|
||||||
r = n,
|
r = n,
|
||||||
o = 0n,
|
o = 0n,
|
||||||
i = 1n,
|
i = 1n,
|
||||||
s = 1n,
|
s = 1n,
|
||||||
a = 0n
|
a = 0n;
|
||||||
for (; 0n !== e; ) {
|
for (; 0n !== e; ) {
|
||||||
const t = r / e,
|
const t = r / e,
|
||||||
n = r % e,
|
n = r % e,
|
||||||
c = o - s * t,
|
c = o - s * t,
|
||||||
d = i - a * t
|
d = i - a * t;
|
||||||
;((r = e), (e = n), (o = s), (i = a), (s = c), (a = d))
|
(r = e), (e = n), (o = s), (i = a), (s = c), (a = d);
|
||||||
}
|
}
|
||||||
return 1n === r ? mod(o, n) : err('no inverse')
|
return 1n === r ? mod(o, n) : err("no inverse");
|
||||||
},
|
},
|
||||||
sqrt = t => {
|
sqrt = (t) => {
|
||||||
let n = 1n
|
let n = 1n;
|
||||||
for (let e = t, r = (P + 1n) / 4n; r > 0n; r >>= 1n)
|
for (let e = t, r = (P + 1n) / 4n; r > 0n; r >>= 1n)
|
||||||
(1n & r && (n = (n * e) % P), (e = (e * e) % P))
|
1n & r && (n = (n * e) % P), (e = (e * e) % P);
|
||||||
return mod(n * n) === t ? n : err('sqrt invalid')
|
return mod(n * n) === t ? n : err("sqrt invalid");
|
||||||
},
|
},
|
||||||
toPriv = t => (
|
toPriv = (t) => (
|
||||||
big(t) || (t = b2n(toU8(t, 32))),
|
big(t) || (t = b2n(toU8(t, 32))),
|
||||||
ge(t) ? t : err('private key out of range')
|
ge(t) ? t : err("private key out of range")
|
||||||
),
|
),
|
||||||
moreThanHalfN = t => t > N >> 1n,
|
moreThanHalfN = (t) => t > N >> 1n,
|
||||||
getPublicKey = (t, n = !0) => Point.fromPrivateKey(t).toRawBytes(n)
|
getPublicKey = (t, n = !0) => Point.fromPrivateKey(t).toRawBytes(n);
|
||||||
class Signature {
|
class Signature {
|
||||||
constructor(t, n, e) {
|
constructor(t, n, e) {
|
||||||
;((this.r = t), (this.s = n), (this.recovery = e), this.assertValidity())
|
(this.r = t), (this.s = n), (this.recovery = e), this.assertValidity();
|
||||||
}
|
}
|
||||||
static fromCompact(t) {
|
static fromCompact(t) {
|
||||||
return (
|
return (
|
||||||
(t = toU8(t, 64)),
|
(t = toU8(t, 64)), new Signature(slcNum(t, 0, 32), slcNum(t, 32, 64))
|
||||||
new Signature(slcNum(t, 0, 32), slcNum(t, 32, 64))
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
assertValidity() {
|
assertValidity() {
|
||||||
return ge(this.r) && ge(this.s) ? this : err()
|
return ge(this.r) && ge(this.s) ? this : err();
|
||||||
}
|
}
|
||||||
addRecoveryBit(t) {
|
addRecoveryBit(t) {
|
||||||
return new Signature(this.r, this.s, t)
|
return new Signature(this.r, this.s, t);
|
||||||
}
|
}
|
||||||
hasHighS() {
|
hasHighS() {
|
||||||
return moreThanHalfN(this.s)
|
return moreThanHalfN(this.s);
|
||||||
}
|
}
|
||||||
normalizeS() {
|
normalizeS() {
|
||||||
return this.hasHighS()
|
return this.hasHighS()
|
||||||
? new Signature(this.r, mod(this.s, N), this.recovery)
|
? new Signature(this.r, mod(this.s, N), this.recovery)
|
||||||
: this
|
: this;
|
||||||
}
|
}
|
||||||
recoverPublicKey(t) {
|
recoverPublicKey(t) {
|
||||||
const {r: n, s: e, recovery: r} = this
|
const { r: n, s: e, recovery: r } = this;
|
||||||
;[0, 1, 2, 3].includes(r) || err('recovery id invalid')
|
[0, 1, 2, 3].includes(r) || err("recovery id invalid");
|
||||||
const o = bits2int_modN(toU8(t, 32)),
|
const o = bits2int_modN(toU8(t, 32)),
|
||||||
i = 2 === r || 3 === r ? n + N : n
|
i = 2 === r || 3 === r ? n + N : n;
|
||||||
i >= P && err('q.x invalid')
|
i >= P && err("q.x invalid");
|
||||||
const s = 1 & r ? '03' : '02',
|
const s = 1 & r ? "03" : "02",
|
||||||
a = Point.fromHex(s + n2h(i)),
|
a = Point.fromHex(s + n2h(i)),
|
||||||
c = inv(i, N),
|
c = inv(i, N),
|
||||||
d = mod(-o * c, N),
|
d = mod(-o * c, N),
|
||||||
u = mod(e * c, N)
|
u = mod(e * c, N);
|
||||||
return G.mulAddQUns(a, d, u)
|
return G.mulAddQUns(a, d, u);
|
||||||
}
|
}
|
||||||
toCompactRawBytes() {
|
toCompactRawBytes() {
|
||||||
return h2b(this.toCompactHex())
|
return h2b(this.toCompactHex());
|
||||||
}
|
}
|
||||||
toCompactHex() {
|
toCompactHex() {
|
||||||
return n2h(this.r) + n2h(this.s)
|
return n2h(this.r) + n2h(this.s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bits2int = t => {
|
const bits2int = (t) => {
|
||||||
const n = 8 * t.length - 256,
|
const n = 8 * t.length - 256,
|
||||||
e = b2n(t)
|
e = b2n(t);
|
||||||
return n > 0 ? e >> BigInt(n) : e
|
return n > 0 ? e >> BigInt(n) : e;
|
||||||
},
|
},
|
||||||
bits2int_modN = t => mod(bits2int(t), N),
|
bits2int_modN = (t) => mod(bits2int(t), N),
|
||||||
i2o = t => n2b(t),
|
i2o = (t) => n2b(t),
|
||||||
cr = () =>
|
cr = () =>
|
||||||
'object' == typeof globalThis && 'crypto' in globalThis
|
"object" == typeof globalThis && "crypto" in globalThis
|
||||||
? globalThis.crypto
|
? globalThis.crypto
|
||||||
: void 0
|
: void 0;
|
||||||
let _hmacSync
|
let _hmacSync;
|
||||||
const optS = {lowS: !0},
|
const optS = { lowS: !0 },
|
||||||
optV = {lowS: !0},
|
optV = { lowS: !0 },
|
||||||
prepSig = (t, n, e = optS) => {
|
prepSig = (t, n, e = optS) => {
|
||||||
;['der', 'recovered', 'canonical'].some(t => t in e) &&
|
["der", "recovered", "canonical"].some((t) => t in e) &&
|
||||||
err('sign() legacy options not supported')
|
err("sign() legacy options not supported");
|
||||||
let {lowS: r} = e
|
let { lowS: r } = e;
|
||||||
null == r && (r = !0)
|
null == r && (r = !0);
|
||||||
const o = bits2int_modN(toU8(t)),
|
const o = bits2int_modN(toU8(t)),
|
||||||
i = i2o(o),
|
i = i2o(o),
|
||||||
s = toPriv(n),
|
s = toPriv(n),
|
||||||
a = [i2o(s), i]
|
a = [i2o(s), i];
|
||||||
let c = e.extraEntropy
|
let c = e.extraEntropy;
|
||||||
if (c) {
|
if (c) {
|
||||||
!0 === c && (c = etc.randomBytes(32))
|
!0 === c && (c = etc.randomBytes(32));
|
||||||
const t = toU8(c)
|
const t = toU8(c);
|
||||||
;(32 !== t.length && err(), a.push(t))
|
32 !== t.length && err(), a.push(t);
|
||||||
}
|
}
|
||||||
const d = o
|
const d = o;
|
||||||
return {
|
return {
|
||||||
seed: concatB(...a),
|
seed: concatB(...a),
|
||||||
k2sig: t => {
|
k2sig: (t) => {
|
||||||
const n = bits2int(t)
|
const n = bits2int(t);
|
||||||
if (!ge(n)) return
|
if (!ge(n)) return;
|
||||||
const e = inv(n, N),
|
const e = inv(n, N),
|
||||||
o = G.mul(n).aff(),
|
o = G.mul(n).aff(),
|
||||||
i = mod(o.x, N)
|
i = mod(o.x, N);
|
||||||
if (0n === i) return
|
if (0n === i) return;
|
||||||
const a = mod(e * mod(d + mod(s * i, N), N), N)
|
const a = mod(e * mod(d + mod(s * i, N), N), N);
|
||||||
if (0n === a) return
|
if (0n === a) return;
|
||||||
let c = a,
|
let c = a,
|
||||||
u = (o.x === i ? 0 : 2) | Number(1n & o.y)
|
u = (o.x === i ? 0 : 2) | Number(1n & o.y);
|
||||||
return (
|
return (
|
||||||
r && moreThanHalfN(a) && ((c = mod(-a, N)), (u ^= 1)),
|
r && moreThanHalfN(a) && ((c = mod(-a, N)), (u ^= 1)),
|
||||||
new Signature(i, c, u)
|
new Signature(i, c, u)
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
function hmacDrbg(t) {
|
function hmacDrbg(t) {
|
||||||
let n = u8n(32),
|
let n = u8n(32),
|
||||||
e = u8n(32),
|
e = u8n(32),
|
||||||
r = 0
|
r = 0;
|
||||||
const o = () => {
|
const o = () => {
|
||||||
;(n.fill(1), e.fill(0), (r = 0))
|
n.fill(1), e.fill(0), (r = 0);
|
||||||
},
|
},
|
||||||
i = 'drbg: tried 1000 values'
|
i = "drbg: tried 1000 values";
|
||||||
if (t) {
|
if (t) {
|
||||||
const t = (...t) => etc.hmacSha256Async(e, n, ...t),
|
const t = (...t) => etc.hmacSha256Async(e, n, ...t),
|
||||||
s = async (r = u8n()) => {
|
s = async (r = u8n()) => {
|
||||||
;((e = await t(u8n([0]), r)),
|
(e = await t(u8n([0]), r)),
|
||||||
(n = await t()),
|
(n = await t()),
|
||||||
0 !== r.length && ((e = await t(u8n([1]), r)), (n = await t())))
|
0 !== r.length && ((e = await t(u8n([1]), r)), (n = await t()));
|
||||||
},
|
},
|
||||||
a = async () => (r++ >= 1e3 && err(i), (n = await t()), n)
|
a = async () => (r++ >= 1e3 && err(i), (n = await t()), n);
|
||||||
return async (t, n) => {
|
return async (t, n) => {
|
||||||
let e
|
let e;
|
||||||
for (o(), await s(t); !(e = n(await a())); ) await s()
|
for (o(), await s(t); !(e = n(await a())); ) await s();
|
||||||
return (o(), e)
|
return o(), e;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const t = (...t) => {
|
const t = (...t) => {
|
||||||
const r = _hmacSync
|
const r = _hmacSync;
|
||||||
return (r || err('etc.hmacSha256Sync not set'), r(e, n, ...t))
|
return r || err("etc.hmacSha256Sync not set"), r(e, n, ...t);
|
||||||
},
|
},
|
||||||
s = (r = u8n()) => {
|
s = (r = u8n()) => {
|
||||||
;((e = t(u8n([0]), r)),
|
(e = t(u8n([0]), r)),
|
||||||
(n = t()),
|
(n = t()),
|
||||||
0 !== r.length && ((e = t(u8n([1]), r)), (n = t())))
|
0 !== r.length && ((e = t(u8n([1]), r)), (n = t()));
|
||||||
},
|
},
|
||||||
a = () => (r++ >= 1e3 && err(i), (n = t()), n)
|
a = () => (r++ >= 1e3 && err(i), (n = t()), n);
|
||||||
return (t, n) => {
|
return (t, n) => {
|
||||||
let e
|
let e;
|
||||||
for (o(), s(t); !(e = n(a())); ) s()
|
for (o(), s(t); !(e = n(a())); ) s();
|
||||||
return (o(), e)
|
return o(), e;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const signAsync = async (t, n, e = optS) => {
|
const signAsync = async (t, n, e = optS) => {
|
||||||
const {seed: r, k2sig: o} = prepSig(t, n, e)
|
const { seed: r, k2sig: o } = prepSig(t, n, e);
|
||||||
return hmacDrbg(!0)(r, o)
|
return hmacDrbg(!0)(r, o);
|
||||||
},
|
},
|
||||||
sign = (t, n, e = optS) => {
|
sign = (t, n, e = optS) => {
|
||||||
const {seed: r, k2sig: o} = prepSig(t, n, e)
|
const { seed: r, k2sig: o } = prepSig(t, n, e);
|
||||||
return hmacDrbg(!1)(r, o)
|
return hmacDrbg(!1)(r, o);
|
||||||
},
|
},
|
||||||
verify = (t, n, e, r = optV) => {
|
verify = (t, n, e, r = optV) => {
|
||||||
let o,
|
let o,
|
||||||
i,
|
i,
|
||||||
s,
|
s,
|
||||||
{lowS: a} = r
|
{ lowS: a } = r;
|
||||||
;(null == a && (a = !0),
|
null == a && (a = !0),
|
||||||
'strict' in r && err('verify() legacy options not supported'))
|
"strict" in r && err("verify() legacy options not supported");
|
||||||
const c = t && 'object' == typeof t && 'r' in t
|
const c = t && "object" == typeof t && "r" in t;
|
||||||
c || 64 === toU8(t).length || err('signature must be 64 bytes')
|
c || 64 === toU8(t).length || err("signature must be 64 bytes");
|
||||||
try {
|
try {
|
||||||
;((o = c
|
(o = c
|
||||||
? new Signature(t.r, t.s).assertValidity()
|
? new Signature(t.r, t.s).assertValidity()
|
||||||
: Signature.fromCompact(t)),
|
: Signature.fromCompact(t)),
|
||||||
(i = bits2int_modN(toU8(n))),
|
(i = bits2int_modN(toU8(n))),
|
||||||
(s = e instanceof Point ? e.ok() : Point.fromHex(e)))
|
(s = e instanceof Point ? e.ok() : Point.fromHex(e));
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
return !1
|
return !1;
|
||||||
}
|
}
|
||||||
if (!o) return !1
|
if (!o) return !1;
|
||||||
const {r: d, s: u} = o
|
const { r: d, s: u } = o;
|
||||||
if (a && moreThanHalfN(u)) return !1
|
if (a && moreThanHalfN(u)) return !1;
|
||||||
let m
|
let m;
|
||||||
try {
|
try {
|
||||||
const t = inv(u, N),
|
const t = inv(u, N),
|
||||||
n = mod(i * t, N),
|
n = mod(i * t, N),
|
||||||
e = mod(d * t, N)
|
e = mod(d * t, N);
|
||||||
m = G.mulAddQUns(s, n, e).aff()
|
m = G.mulAddQUns(s, n, e).aff();
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
return !1
|
return !1;
|
||||||
}
|
}
|
||||||
if (!m) return !1
|
if (!m) return !1;
|
||||||
return mod(m.x, N) === d
|
return mod(m.x, N) === d;
|
||||||
},
|
},
|
||||||
getSharedSecret = (t, n, e = !0) =>
|
getSharedSecret = (t, n, e = !0) =>
|
||||||
Point.fromHex(n).mul(toPriv(t)).toRawBytes(e),
|
Point.fromHex(n).mul(toPriv(t)).toRawBytes(e),
|
||||||
hashToPrivateKey = t => {
|
hashToPrivateKey = (t) => {
|
||||||
;((t = toU8(t)).length < 40 || t.length > 1024) &&
|
((t = toU8(t)).length < 40 || t.length > 1024) &&
|
||||||
err('expected proper params')
|
err("expected proper params");
|
||||||
const n = mod(b2n(t), N - 1n) + 1n
|
const n = mod(b2n(t), N - 1n) + 1n;
|
||||||
return n2b(n)
|
return n2b(n);
|
||||||
},
|
},
|
||||||
etc = {
|
etc = {
|
||||||
hexToBytes: h2b,
|
hexToBytes: h2b,
|
||||||
|
|
@ -429,84 +428,84 @@ const signAsync = async (t, n, e = optS) => {
|
||||||
invert: inv,
|
invert: inv,
|
||||||
hmacSha256Async: async (t, ...n) => {
|
hmacSha256Async: async (t, ...n) => {
|
||||||
const e = cr(),
|
const e = cr(),
|
||||||
r = e && e.subtle
|
r = e && e.subtle;
|
||||||
if (!r) return err('etc.hmacSha256Async not set')
|
if (!r) return err("etc.hmacSha256Async not set");
|
||||||
const o = await r.importKey(
|
const o = await r.importKey(
|
||||||
'raw',
|
"raw",
|
||||||
t,
|
t,
|
||||||
{name: 'HMAC', hash: {name: 'SHA-256'}},
|
{ name: "HMAC", hash: { name: "SHA-256" } },
|
||||||
!1,
|
!1,
|
||||||
['sign']
|
["sign"],
|
||||||
)
|
);
|
||||||
return u8n(await r.sign('HMAC', o, concatB(...n)))
|
return u8n(await r.sign("HMAC", o, concatB(...n)));
|
||||||
},
|
},
|
||||||
hmacSha256Sync: _hmacSync,
|
hmacSha256Sync: _hmacSync,
|
||||||
hashToPrivateKey,
|
hashToPrivateKey,
|
||||||
randomBytes: (t = 32) => {
|
randomBytes: (t = 32) => {
|
||||||
const n = cr()
|
const n = cr();
|
||||||
return (
|
return (
|
||||||
(n && n.getRandomValues) ||
|
(n && n.getRandomValues) ||
|
||||||
err('crypto.getRandomValues must be defined'),
|
err("crypto.getRandomValues must be defined"),
|
||||||
n.getRandomValues(u8n(t))
|
n.getRandomValues(u8n(t))
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
utils = {
|
utils = {
|
||||||
normPrivateKeyToScalar: toPriv,
|
normPrivateKeyToScalar: toPriv,
|
||||||
isValidPrivateKey: t => {
|
isValidPrivateKey: (t) => {
|
||||||
try {
|
try {
|
||||||
return !!toPriv(t)
|
return !!toPriv(t);
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
return !1
|
return !1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
randomPrivateKey: () => hashToPrivateKey(etc.randomBytes(48)),
|
randomPrivateKey: () => hashToPrivateKey(etc.randomBytes(48)),
|
||||||
precompute: (t = 8, n = G) => (n.multiply(3n), n)
|
precompute: (t = 8, n = G) => (n.multiply(3n), n),
|
||||||
}
|
};
|
||||||
Object.defineProperties(etc, {
|
Object.defineProperties(etc, {
|
||||||
hmacSha256Sync: {
|
hmacSha256Sync: {
|
||||||
configurable: !1,
|
configurable: !1,
|
||||||
get: () => _hmacSync,
|
get: () => _hmacSync,
|
||||||
set(t) {
|
set(t) {
|
||||||
_hmacSync || (_hmacSync = t)
|
_hmacSync || (_hmacSync = t);
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
const W = 8,
|
const W = 8,
|
||||||
precompute = () => {
|
precompute = () => {
|
||||||
const t = []
|
const t = [];
|
||||||
let n = G,
|
let n = G,
|
||||||
e = n
|
e = n;
|
||||||
for (let r = 0; r < 33; r++) {
|
for (let r = 0; r < 33; r++) {
|
||||||
;((e = n), t.push(e))
|
(e = n), t.push(e);
|
||||||
for (let r = 1; r < 128; r++) ((e = e.add(n)), t.push(e))
|
for (let r = 1; r < 128; r++) (e = e.add(n)), t.push(e);
|
||||||
n = e.double()
|
n = e.double();
|
||||||
}
|
}
|
||||||
return t
|
return t;
|
||||||
}
|
};
|
||||||
let Gpows
|
let Gpows;
|
||||||
const wNAF = t => {
|
const wNAF = (t) => {
|
||||||
const n = Gpows || (Gpows = precompute()),
|
const n = Gpows || (Gpows = precompute()),
|
||||||
e = (t, n) => {
|
e = (t, n) => {
|
||||||
let e = n.negate()
|
let e = n.negate();
|
||||||
return t ? e : n
|
return t ? e : n;
|
||||||
}
|
};
|
||||||
let r = I,
|
let r = I,
|
||||||
o = G
|
o = G;
|
||||||
const i = BigInt(255),
|
const i = BigInt(255),
|
||||||
s = BigInt(8)
|
s = BigInt(8);
|
||||||
for (let a = 0; a < 33; a++) {
|
for (let a = 0; a < 33; a++) {
|
||||||
const c = 128 * a
|
const c = 128 * a;
|
||||||
let d = Number(t & i)
|
let d = Number(t & i);
|
||||||
;((t >>= s), d > 128 && ((d -= 256), (t += 1n)))
|
(t >>= s), d > 128 && ((d -= 256), (t += 1n));
|
||||||
const u = c,
|
const u = c,
|
||||||
m = c + Math.abs(d) - 1,
|
m = c + Math.abs(d) - 1,
|
||||||
h = a % 2 != 0,
|
h = a % 2 != 0,
|
||||||
l = d < 0
|
l = d < 0;
|
||||||
0 === d ? (o = o.add(e(h, n[u]))) : (r = r.add(e(l, n[m])))
|
0 === d ? (o = o.add(e(h, n[u]))) : (r = r.add(e(l, n[m])));
|
||||||
}
|
}
|
||||||
return {p: r, f: o}
|
return { p: r, f: o };
|
||||||
}
|
};
|
||||||
export {
|
export {
|
||||||
getPublicKey,
|
getPublicKey,
|
||||||
sign,
|
sign,
|
||||||
|
|
@ -517,5 +516,5 @@ export {
|
||||||
etc,
|
etc,
|
||||||
utils,
|
utils,
|
||||||
Point as ProjectivePoint,
|
Point as ProjectivePoint,
|
||||||
Signature
|
Signature,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
118
tasks.py
118
tasks.py
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from lnbits.core.crud import get_payments, get_wallet, get_wallet_payment
|
from lnbits.core.crud import get_payments, get_wallet, get_wallet_payment
|
||||||
|
|
@ -34,12 +34,8 @@ from .paranoia import (
|
||||||
)
|
)
|
||||||
from .permission import nwc_permissions
|
from .permission import nwc_permissions
|
||||||
|
|
||||||
PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS = 1.0
|
|
||||||
PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS = 60.0
|
|
||||||
PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER = 2.0
|
|
||||||
|
|
||||||
|
async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]:
|
||||||
async def _check(nwc: NWCKey | None, method: str) -> dict | None:
|
|
||||||
# check
|
# check
|
||||||
if not nwc:
|
if not nwc:
|
||||||
return {
|
return {
|
||||||
|
|
@ -50,8 +46,8 @@ async def _check(nwc: NWCKey | None, method: str) -> dict | None:
|
||||||
allowed = False
|
allowed = False
|
||||||
permissions = nwc.get_permissions()
|
permissions = nwc.get_permissions()
|
||||||
for p in permissions:
|
for p in permissions:
|
||||||
permissions_data: dict[str, Any] = nwc_permissions.get(p, {})
|
permissions_data: Dict[str, Any] = nwc_permissions.get(p, {})
|
||||||
allowed_methods: list[str] = permissions_data.get("methods", [])
|
allowed_methods: List[str] = permissions_data.get("methods", [])
|
||||||
if method in allowed_methods:
|
if method in allowed_methods:
|
||||||
allowed = True
|
allowed = True
|
||||||
break
|
break
|
||||||
|
|
@ -68,7 +64,7 @@ async def _process_invoice(
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
invoice: str,
|
invoice: str,
|
||||||
amount_msats: int,
|
amount_msats: int,
|
||||||
description: str | None = None,
|
description: Optional[str] = None,
|
||||||
):
|
):
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -84,7 +80,7 @@ async def _process_invoice(
|
||||||
payment = await pay_invoice(
|
payment = await pay_invoice(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
payment_request=invoice,
|
payment_request=invoice,
|
||||||
max_sat=ceil(amount_msats / 1000),
|
max_sat=int(ceil(amount_msats / 1000)),
|
||||||
description=description or "",
|
description=description or "",
|
||||||
)
|
)
|
||||||
return payment.payment_hash
|
return payment.payment_hash
|
||||||
|
|
@ -113,25 +109,12 @@ async def _process_invoice(
|
||||||
wait_for_preimage = (
|
wait_for_preimage = (
|
||||||
True # currently required by nip 47 specs, might change in future
|
True # currently required by nip 47 specs, might change in future
|
||||||
)
|
)
|
||||||
payment_status: PaymentStatus | None = None
|
payment_status: Optional[PaymentStatus] = None
|
||||||
poll_interval = PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS
|
|
||||||
while wait_for_preimage:
|
while wait_for_preimage:
|
||||||
payment_status = await check_transaction_status(wallet_id, payment_hash)
|
payment_status = await check_transaction_status(wallet_id, payment_hash)
|
||||||
if payment_status.success:
|
if payment_status.success:
|
||||||
break
|
break
|
||||||
if payment_status.failed:
|
await asyncio.sleep(0.05)
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": "PAYMENT_FAILED",
|
|
||||||
"message": "Payment failed.",
|
|
||||||
},
|
|
||||||
"in_budget": in_budget,
|
|
||||||
}
|
|
||||||
await asyncio.sleep(poll_interval)
|
|
||||||
poll_interval = min(
|
|
||||||
poll_interval * PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER,
|
|
||||||
PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS,
|
|
||||||
)
|
|
||||||
if not payment_status:
|
if not payment_status:
|
||||||
raise Exception("Payment status not found")
|
raise Exception("Payment status not found")
|
||||||
return {
|
return {
|
||||||
|
|
@ -145,8 +128,8 @@ async def _process_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_pay_invoice(
|
async def _on_pay_invoice(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -186,8 +169,8 @@ async def _on_pay_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_multi_pay_invoice(
|
async def _on_multi_pay_invoice(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -201,7 +184,7 @@ async def _on_multi_pay_invoice(
|
||||||
raise Exception("Pubkey has no associated wallet")
|
raise Exception("Pubkey has no associated wallet")
|
||||||
params = payload.get("params", {})
|
params = payload.get("params", {})
|
||||||
invoices = params.get("invoices", [])
|
invoices = params.get("invoices", [])
|
||||||
results: list[tuple[dict | None, dict | None, list]] = []
|
results: List[Tuple[Optional[Dict], Optional[Dict], List]] = []
|
||||||
|
|
||||||
# Ensures all invoices are provided
|
# Ensures all invoices are provided
|
||||||
for i in invoices:
|
for i in invoices:
|
||||||
|
|
@ -240,14 +223,13 @@ async def _on_multi_pay_invoice(
|
||||||
results.append(r)
|
results.append(r)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
|
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
|
||||||
await asyncio.sleep(0)
|
|
||||||
# await log_nwc(pubkey, payload)
|
# await log_nwc(pubkey, payload)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def _on_make_invoice(
|
async def _on_make_invoice(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -264,11 +246,9 @@ async def _on_make_invoice(
|
||||||
# Ensures amount is provided
|
# Ensures amount is provided
|
||||||
if not amount_msats:
|
if not amount_msats:
|
||||||
raise Exception("Missing amount")
|
raise Exception("Missing amount")
|
||||||
# Optional params may arrive as explicit JSON null, which dict.get does
|
description = params.get("description", "")
|
||||||
# not default, so coerce here rather than trusting the fallback.
|
description_hash = params.get("description_hash", None)
|
||||||
description = params.get("description") or ""
|
expiry = params.get("expiry", None)
|
||||||
description_hash = params.get("description_hash") or None
|
|
||||||
expiry = params.get("expiry") or None
|
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_msats(amount_msats)
|
assert_valid_msats(amount_msats)
|
||||||
|
|
@ -286,7 +266,7 @@ async def _on_make_invoice(
|
||||||
currency="sat",
|
currency="sat",
|
||||||
memo=description,
|
memo=description,
|
||||||
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
||||||
unhashed_description=description.encode("utf-8") if description else None,
|
unhashed_description=description.encode("utf-8"),
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
)
|
)
|
||||||
payment_hash = payment.payment_hash
|
payment_hash = payment.payment_hash
|
||||||
|
|
@ -318,8 +298,8 @@ async def _on_make_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_lookup_invoice(
|
async def _on_lookup_invoice(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -343,10 +323,8 @@ async def _on_lookup_invoice(
|
||||||
payment_hash = invoice_data.payment_hash
|
payment_hash = invoice_data.payment_hash
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
if payment_hash:
|
assert_valid_sha256(payment_hash)
|
||||||
assert_valid_sha256(payment_hash)
|
assert_valid_bolt11(invoice)
|
||||||
if invoice:
|
|
||||||
assert_valid_bolt11(invoice)
|
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
# Get payment data
|
# Get payment data
|
||||||
|
|
@ -356,12 +334,12 @@ async def _on_lookup_invoice(
|
||||||
invoice_data = bolt11_decode(payment.bolt11)
|
invoice_data = bolt11_decode(payment.bolt11)
|
||||||
is_settled = not payment.pending
|
is_settled = not payment.pending
|
||||||
timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
|
timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
|
||||||
expiry = int(payment.expiry.timestamp()) if payment.expiry else timestamp + 3600
|
expiry = int(payment.expiry.timestamp()) or timestamp + 3600
|
||||||
preimage = (
|
preimage = (
|
||||||
payment.preimage
|
payment.preimage
|
||||||
or "0000000000000000000000000000000000000000000000000000000000000000"
|
or "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
)
|
)
|
||||||
res: dict = {
|
res: Dict = {
|
||||||
"type": "outgoing" if payment.is_out else "incoming",
|
"type": "outgoing" if payment.is_out else "incoming",
|
||||||
"invoice": payment.bolt11,
|
"invoice": payment.bolt11,
|
||||||
"description": (
|
"description": (
|
||||||
|
|
@ -383,8 +361,8 @@ async def _on_lookup_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_list_transactions(
|
async def _on_list_transactions(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
@ -395,26 +373,27 @@ async def _on_list_transactions(
|
||||||
return [(None, error, [])]
|
return [(None, error, [])]
|
||||||
if not nwc:
|
if not nwc:
|
||||||
raise Exception("Pubkey has no associated wallet")
|
raise Exception("Pubkey has no associated wallet")
|
||||||
params = payload.get("params", 0)
|
tfrom = payload.get("from", 0)
|
||||||
tfrom = params.get("from") or 0
|
tto = payload.get("to", int(time.time()))
|
||||||
tuntil = params.get("until") or int(time.time())
|
limit = payload.get("limit", 10)
|
||||||
limit = params.get("limit") or 10
|
offset = payload.get("offset", 0)
|
||||||
offset = params.get("offset") or 0
|
unpaid = payload.get("unpaid", False)
|
||||||
unpaid = params.get("unpaid") or False
|
tx_type = payload.get("type", "")
|
||||||
tx_type = params.get("type") or ""
|
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_positive_int(tfrom)
|
assert_valid_positive_int(tfrom)
|
||||||
assert_valid_positive_int(tuntil)
|
assert_valid_positive_int(tto)
|
||||||
assert_valid_positive_int(limit)
|
assert_valid_positive_int(limit)
|
||||||
assert_valid_positive_int(offset)
|
assert_valid_positive_int(offset)
|
||||||
assert_boolean(unpaid)
|
assert_boolean(unpaid)
|
||||||
assert_sane_string(tx_type)
|
assert_sane_string(tx_type)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
|
values = []
|
||||||
filters: Filters = Filters()
|
filters: Filters = Filters()
|
||||||
filters.where(["time <= :tuntil"])
|
filters.where(["time <= ?"])
|
||||||
filters.values({"tuntil": tuntil})
|
values.append(tto)
|
||||||
|
filters.values(values)
|
||||||
history = await get_payments(
|
history = await get_payments(
|
||||||
wallet_id=nwc.wallet,
|
wallet_id=nwc.wallet,
|
||||||
complete=True,
|
complete=True,
|
||||||
|
|
@ -427,7 +406,7 @@ async def _on_list_transactions(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
transactions: list[dict] = []
|
transactions: List[Dict] = []
|
||||||
p: Payment
|
p: Payment
|
||||||
for p in history:
|
for p in history:
|
||||||
invoice_data = bolt11_decode(p.bolt11)
|
invoice_data = bolt11_decode(p.bolt11)
|
||||||
|
|
@ -437,11 +416,7 @@ async def _on_list_transactions(
|
||||||
{
|
{
|
||||||
"type": "outgoing" if p.is_out else "incoming",
|
"type": "outgoing" if p.is_out else "incoming",
|
||||||
"invoice": p.bolt11,
|
"invoice": p.bolt11,
|
||||||
# Fallback chain so a human-readable description reaches
|
"description": invoice_data.description,
|
||||||
# the NWC client. Mirror of `_on_lookup_invoice`
|
|
||||||
"description": (
|
|
||||||
(p.extra or {}).get("comment") or invoice_data.description or p.memo
|
|
||||||
),
|
|
||||||
"description_hash": invoice_data.description_hash,
|
"description_hash": invoice_data.description_hash,
|
||||||
"preimage": p.preimage if is_settled or p.is_in else None,
|
"preimage": p.preimage if is_settled or p.is_in else None,
|
||||||
"payment_hash": p.payment_hash,
|
"payment_hash": p.payment_hash,
|
||||||
|
|
@ -452,14 +427,13 @@ async def _on_list_transactions(
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0)
|
|
||||||
# await log_nwc(pubkey, payload)
|
# await log_nwc(pubkey, payload)
|
||||||
return [({"transactions": transactions}, None, [])]
|
return [({"transactions": transactions}, None, [])]
|
||||||
|
|
||||||
|
|
||||||
async def _on_get_balance(
|
async def _on_get_balance(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -481,8 +455,8 @@ async def _on_get_balance(
|
||||||
|
|
||||||
|
|
||||||
async def _on_get_info(
|
async def _on_get_info(
|
||||||
sp: NWCServiceProvider, pubkey: str, payload: dict
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
) -> list[tuple[dict | None, dict | None, list]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -500,8 +474,8 @@ async def _on_get_info(
|
||||||
account_methods = []
|
account_methods = []
|
||||||
for spm in sp_methods:
|
for spm in sp_methods:
|
||||||
for p in permissions:
|
for p in permissions:
|
||||||
permissions_data: dict[str, Any] = nwc_permissions.get(p, {})
|
permissions_data: Dict[str, Any] = nwc_permissions.get(p, {})
|
||||||
allowed_methods: list[str] = permissions_data.get("methods", [])
|
allowed_methods: List[str] = permissions_data.get("methods", [])
|
||||||
if spm in allowed_methods:
|
if spm in allowed_methods:
|
||||||
account_methods.append(spm)
|
account_methods.append(spm)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -6,56 +6,21 @@
|
||||||
<div class="col-12 q-gutter-y-md">
|
<div class="col-12 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center wrap q-mb-md">
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h5 class="text-subtitle1 q-my-none">
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
NWC Service Provider Configuration
|
NWC Service Provider - Config
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-markup-table flat wrap-cells="true">
|
<q-markup-table flat>
|
||||||
<tbody>
|
<tbody>
|
||||||
<q-tr>
|
<q-tr v-for="entry in entries" :key="entry.key">
|
||||||
<q-td>
|
<q-td>
|
||||||
<q-input
|
<span> ${entry.key} </span>
|
||||||
v-model="config.relay"
|
|
||||||
label="Nostr Relay URL"
|
|
||||||
filled
|
|
||||||
wrap
|
|
||||||
:hint="'URL of the Nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to use the Nostr Client extension'"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
|
||||||
<q-tr>
|
|
||||||
<q-td>
|
<q-td>
|
||||||
<q-input
|
<q-input v-model="entry.value" />
|
||||||
filled
|
|
||||||
label="Relay Alias"
|
|
||||||
v-model="config.relay_alias"
|
|
||||||
:hint="'Relay URL to display in pairing URLs. If your relay has a different public URL than the one set in \'Nostr Relay URL\' set it here.'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<q-tr>
|
|
||||||
<q-td>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
label="NWC Provider Secret Key "
|
|
||||||
v-model="config.provider_key"
|
|
||||||
:hint="'The secret key for the NWC Service Provider. You don\'t need to change this unless your key has been compromised.'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<q-tr>
|
|
||||||
<q-td>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
label="Time Period To Handle Missed Events"
|
|
||||||
v-model="config.handle_missed_events"
|
|
||||||
type="number"
|
|
||||||
:hint="'Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality.'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
%} {% block scripts %} {{ window_vars(user) }}
|
%} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as NobleSecp256k1 from '/nwcprovider/static/js/noble-secp256k1.min.js'
|
import * as NobleSecp256k1 from "/nwcprovider/static/js/noble-secp256k1.min.js";
|
||||||
window.NobleSecp256k1 = NobleSecp256k1
|
window.NobleSecp256k1 = NobleSecp256k1;
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ static_url_for('nwcprovider/static', path='js/index.js') }}"></script>
|
<script src="{{ static_url_for('nwcprovider/static', path='js/index.js') }}"></script>
|
||||||
{% endblock %} {% block page %}
|
{% endblock %} {% block page %}
|
||||||
|
|
@ -99,51 +99,12 @@
|
||||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-my-none">NWC Service Provider</h6>
|
<h6 class="text-subtitle1 q-my-none">NWC Service provider</h6>
|
||||||
<p>
|
<p>
|
||||||
Nostr Wallet Connect (NWC) is an open protocol to connect Lightning
|
Nostr Wallet Connect (NWC) is an open protocol to connect lightning
|
||||||
wallets to apps. This extension allows you to use your LNbits wallet
|
wallets to apps
|
||||||
with
|
|
||||||
<a
|
|
||||||
href="https://github.com/getAlby/awesome-nwc#nwc-wallets"
|
|
||||||
title="NWC wallets"
|
|
||||||
target="_blank"
|
|
||||||
>any NWC compatible app</a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
Before you can use this extension, you need to configure it.
|
|
||||||
<a
|
|
||||||
href="https://github.com/lnbits/nwcprovider#configuration"
|
|
||||||
title="NWC Service Provider User Guide"
|
|
||||||
target="_blank"
|
|
||||||
>Read the User Guide</a
|
|
||||||
>
|
|
||||||
to get started.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h6 class="text-subtitle2 q-my-none">Connecting a NWC App</h6>
|
|
||||||
<p>
|
|
||||||
Once you have configured the extension, you can connect a NWC
|
|
||||||
compatible app by following these steps:
|
|
||||||
</p>
|
|
||||||
<ol class="q-pl-md q-mt-sm">
|
|
||||||
<li class="q-mb-sm">
|
|
||||||
In the <strong>NWC Service Provider</strong> extension, select the
|
|
||||||
wallet you want to connect.
|
|
||||||
</li>
|
|
||||||
<li class="q-mb-sm">Click the "+" button to add a new connection.</li>
|
|
||||||
<li class="q-mb-sm">
|
|
||||||
Enter a description, expiry date (optional), permissions, and
|
|
||||||
limits.
|
|
||||||
</li>
|
|
||||||
<li class="q-mb-sm">Click "Connect" to create the connection.</li>
|
|
||||||
<li class="q-mb-sm">
|
|
||||||
Use the generated pairing URL or QR code to connect your chosen app.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-list>
|
<q-list>
|
||||||
|
|
|
||||||
|
|
@ -199,9 +199,7 @@ LNBITS_HIDE_API=false
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
# The tpos extension is no longer shipped with the LNbits dev tree. Keep the
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
# integration fixture focused on the extension under test.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL=""
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
|
@ -251,4 +249,4 @@ LOG_ROTATION="100 MB"
|
||||||
LOG_RETENTION="3 months"
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
# for database cleanup commands
|
# for database cleanup commands
|
||||||
# CLEANUP_WALLETS_DAYS=90
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
|
|
@ -15,18 +15,12 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start nostr Relay. The image defaults to the `strfry` user (UID 1000),
|
# Start nostr Relay
|
||||||
# which is not necessarily the user running the CI job. Create the bind mount
|
|
||||||
# first and run the relay as the current user so LMDB can initialize its files.
|
|
||||||
id=$(id -u)
|
|
||||||
gid=$(id -g)
|
|
||||||
mkdir -p strfry-data
|
|
||||||
docker run --name=lnbits_nwcprovider_ext_nostr_test \
|
docker run --name=lnbits_nwcprovider_ext_nostr_test \
|
||||||
-d \
|
-d \
|
||||||
--rm \
|
--rm \
|
||||||
--user $id:$gid \
|
-v $PWD/strfry.conf:/etc/strfry.conf \
|
||||||
-v $PWD/strfry.conf:/etc/strfry.conf:Z \
|
-v $PWD/strfry-data:/app/strfry-db \
|
||||||
-v $PWD/strfry-data:/app/strfry-db:Z \
|
|
||||||
-p 7777:7777 \
|
-p 7777:7777 \
|
||||||
ghcr.io/hoytech/strfry:latest
|
ghcr.io/hoytech/strfry:latest
|
||||||
|
|
||||||
|
|
@ -34,18 +28,9 @@ ghcr.io/hoytech/strfry:latest
|
||||||
rm -Rf lnbits_itest_data
|
rm -Rf lnbits_itest_data
|
||||||
unzip data.zip
|
unzip data.zip
|
||||||
|
|
||||||
# The fixture was created with the standalone tpos extension installed. tpos
|
id=$(id -u)
|
||||||
# is no longer part of the LNbits dev tree, so leaving its database metadata in
|
gid=$(id -g)
|
||||||
# the fixture makes current LNbits attempt to import a module that is absent.
|
|
||||||
# The integration suite only exercises nwcprovider.
|
|
||||||
python3 - <<'PY'
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
with sqlite3.connect("lnbits_itest_data/database.sqlite3") as conn:
|
|
||||||
conn.execute("DELETE FROM installed_extensions WHERE id = 'tpos'")
|
|
||||||
conn.execute("DELETE FROM dbversions WHERE db = 'tpos'")
|
|
||||||
PY
|
|
||||||
rm -f lnbits_itest_data/ext_tpos.sqlite3 lnbits_itest_data/zips/tpos.zip
|
|
||||||
|
|
||||||
docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
||||||
-d \
|
-d \
|
||||||
|
|
@ -60,50 +45,24 @@ docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
||||||
-v ${PWD}/../../.devcontainer/pre-setup.sh:/pre-setup.sh:ro \
|
-v ${PWD}/../../.devcontainer/pre-setup.sh:/pre-setup.sh:ro \
|
||||||
mcr.microsoft.com/devcontainers/python:1-3.12 bash -c "while true; do sleep 1000; done"
|
mcr.microsoft.com/devcontainers/python:1-3.12 bash -c "while true; do sleep 1000; done"
|
||||||
|
|
||||||
if ! docker network inspect lnbits_nwcprovider_ext_test_network >/dev/null 2>&1; then
|
docker network create lnbits_nwcprovider_ext_test_network || true
|
||||||
docker network create lnbits_nwcprovider_ext_test_network
|
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true
|
||||||
fi
|
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true
|
||||||
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr
|
|
||||||
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits
|
|
||||||
|
|
||||||
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "id -u $id &>/dev/null || useradd -m -u $id tester"
|
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "id -u $id &>/dev/null || useradd -m -u $id tester"
|
||||||
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "bash /pre-setup.sh"
|
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "bash /pre-setup.sh"
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "curl -sSL https://install.python-poetry.org | python3 -"
|
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "curl -sSL https://install.python-poetry.org | python3 -"
|
||||||
|
|
||||||
|
set +e
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && bash /setup.sh /nwcprovider"
|
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && bash /setup.sh /nwcprovider"
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "ln -s /app/.env \$HOME/lnbits/.env"
|
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "ln -s /app/.env \$HOME/lnbits/.env"
|
||||||
|
|
||||||
|
|
||||||
|
ARGS=""
|
||||||
if [ "$HEADLESS" != "" ];
|
if [ "$HEADLESS" != "" ];
|
||||||
then
|
then
|
||||||
# Keep the server log inside the container so a failed health check can
|
ARGS="-d"
|
||||||
# show the actual startup error instead of silently swallowing it.
|
|
||||||
docker exec --user $id:$gid -d lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits > /tmp/lnbits.log 2>&1"
|
|
||||||
|
|
||||||
wait_for_http() {
|
|
||||||
local service="$1"
|
|
||||||
local url="$2"
|
|
||||||
local timeout_seconds="$3"
|
|
||||||
local deadline=$((SECONDS + timeout_seconds))
|
|
||||||
|
|
||||||
until curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1; do
|
|
||||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
|
||||||
echo "Timed out waiting for $service at $url" >&2
|
|
||||||
docker ps -a >&2
|
|
||||||
if [ "$service" = "LNbits" ]; then
|
|
||||||
docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 100 /tmp/lnbits.log >&2 || true
|
|
||||||
else
|
|
||||||
docker logs --tail 100 lnbits_nwcprovider_ext_nostr_test >&2 || true
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# LNbits may need a few minutes for a fresh database migration.
|
|
||||||
wait_for_http "nostr relay" "http://localhost:7777" 180
|
|
||||||
wait_for_http "LNbits" "http://localhost:5002" 180
|
|
||||||
else
|
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
docker exec --user $id:$gid $ARGS lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ relay {
|
||||||
port = 7777
|
port = 7777
|
||||||
|
|
||||||
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
||||||
nofiles = 0
|
nofiles = 1000000
|
||||||
|
|
||||||
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
||||||
realIpHeader = ""
|
realIpHeader = ""
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
import bolt11
|
import bolt11
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
import secp256k1
|
||||||
|
import websockets.client as websockets
|
||||||
|
from Cryptodome import Random
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
from Cryptodome.Util.Padding import pad, unpad
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pynostr.key import PrivateKey
|
|
||||||
from websockets.legacy.client import connect
|
|
||||||
|
|
||||||
SERVICE_STARTUP_TIMEOUT_SECONDS = 180
|
|
||||||
NWC_CONNECTION_TIMEOUT_SECONDS = 60
|
|
||||||
NWC_RESPONSE_TIMEOUT_SECONDS = 60
|
|
||||||
|
|
||||||
wallets = {
|
wallets = {
|
||||||
"wallet1": {
|
"wallet1": {
|
||||||
|
|
@ -45,29 +45,35 @@ wallets = {
|
||||||
|
|
||||||
|
|
||||||
async def check_services():
|
async def check_services():
|
||||||
async def wait_for_service(name: str, url: str):
|
# wait for http server in localhost:7777
|
||||||
deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS
|
while True:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
while True:
|
resp = await client.get("http://localhost:7777")
|
||||||
try:
|
assert resp.status_code == 200
|
||||||
resp = await client.get(url)
|
break
|
||||||
if resp.status_code == 200:
|
except Exception:
|
||||||
return
|
logger.info("Waiting for nostr relay @ http://localhost:7777")
|
||||||
except httpx.HTTPError:
|
logger.info(
|
||||||
pass
|
"""Please start the required services by running\
|
||||||
if time.monotonic() >= deadline:
|
`bash start.sh` if you haven't already"""
|
||||||
raise RuntimeError(
|
)
|
||||||
f"Timed out waiting for {name} at {url}. "
|
await asyncio.sleep(1)
|
||||||
"Start the integration services with `bash start.sh`."
|
|
||||||
)
|
|
||||||
logger.info(f"Waiting for {name} @ {url}")
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise RuntimeError(f"Unable to check {name} at {url}: {exc}") from exc
|
|
||||||
|
|
||||||
await wait_for_service("nostr relay", "http://localhost:7777")
|
# wait lnbits @ localhost:5000
|
||||||
await wait_for_service("LNbits", "http://localhost:5002")
|
while True:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get("http://localhost:5002")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.info("Waiting for lnbits @ http://localhost:5002")
|
||||||
|
logger.info(
|
||||||
|
"""Please start the required services by running\
|
||||||
|
`bash start.sh` if you haven't already"""
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
async def get_wallet_balance(w: str):
|
async def get_wallet_balance(w: str):
|
||||||
|
|
@ -89,20 +95,20 @@ async def refresh_wallet_balances():
|
||||||
|
|
||||||
|
|
||||||
def gen_keypair():
|
def gen_keypair():
|
||||||
private_key = PrivateKey()
|
private_key_hex = bytes.hex(secp256k1._gen_private_key())
|
||||||
private_key_hex = private_key.hex()
|
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
|
||||||
public_key = private_key.public_key
|
public_key = private_key.pubkey
|
||||||
if not public_key:
|
if not public_key:
|
||||||
raise Exception("Error generating pubkey")
|
raise Exception("Error generating pubkey")
|
||||||
public_key_hex = public_key.hex()
|
public_key_hex = public_key.serialize().hex()[2:]
|
||||||
return {"priv": private_key_hex, "pub": public_key_hex}
|
return {"priv": private_key_hex, "pub": public_key_hex}
|
||||||
|
|
||||||
|
|
||||||
async def create_nwc(
|
async def create_nwc(
|
||||||
w: str,
|
w: str,
|
||||||
desc: str,
|
desc: str,
|
||||||
permissions: list[str],
|
permissions: List[str],
|
||||||
budgets: list[dict[str, int]],
|
budgets: List[Dict[str, int]],
|
||||||
expiration: int = 0,
|
expiration: int = 0,
|
||||||
):
|
):
|
||||||
keypair = gen_keypair()
|
keypair = gen_keypair()
|
||||||
|
|
@ -158,12 +164,12 @@ class NWCWallet:
|
||||||
self.event_queue = []
|
self.event_queue = []
|
||||||
self.subscriptions_count = 0
|
self.subscriptions_count = 0
|
||||||
self.sub_id = ""
|
self.sub_id = ""
|
||||||
self.private_key = PrivateKey.from_hex(self.secret)
|
self.private_key = secp256k1.PrivateKey(bytes.fromhex(self.secret))
|
||||||
self.private_key_hex = self.secret
|
self.private_key_hex = self.secret
|
||||||
self.public_key = self.private_key.public_key
|
self.public_key = self.private_key.pubkey
|
||||||
if not self.public_key:
|
if not self.public_key:
|
||||||
raise Exception("Error generating pubkey")
|
raise Exception("Error generating pubkey")
|
||||||
self.public_key_hex = self.public_key.hex()
|
self.public_key_hex = self.public_key.serialize().hex()[2:]
|
||||||
self.task = None
|
self.task = None
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
|
|
@ -181,17 +187,7 @@ class NWCWallet:
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self.task = asyncio.create_task(self._run())
|
self.task = asyncio.create_task(self._run())
|
||||||
try:
|
await self._wait_for_connection()
|
||||||
await asyncio.wait_for(
|
|
||||||
self._wait_for_connection(), timeout=NWC_CONNECTION_TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
self.task.cancel()
|
|
||||||
await asyncio.gather(self.task, return_exceptions=True)
|
|
||||||
self.task = None
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Timed out connecting to NWC relay {self.relay}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _is_shutting_down(self):
|
def _is_shutting_down(self):
|
||||||
return self.shutdown
|
return self.shutdown
|
||||||
|
|
@ -210,7 +206,7 @@ class NWCWallet:
|
||||||
async def _run(self):
|
async def _run(self):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async with connect(self.relay) as ws:
|
async with websockets.connect(self.relay) as ws:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.connected = True
|
self.connected = True
|
||||||
self.sub_id = self._get_new_subid()
|
self.sub_id = self._get_new_subid()
|
||||||
|
|
@ -243,13 +239,43 @@ class NWCWallet:
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
async def _on_message(self, _, message: str):
|
def _encrypt_content(
|
||||||
|
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
|
||||||
|
if not iv_seed:
|
||||||
|
iv = Random.new().read(AES.block_size)
|
||||||
|
else:
|
||||||
|
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder="big")).digest()
|
||||||
|
iv = iv[: AES.block_size]
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
content_bytes = pad(content_bytes, AES.block_size)
|
||||||
|
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
|
||||||
|
iv_b64 = base64.b64encode(iv).decode("ascii")
|
||||||
|
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
|
||||||
|
return encrypted_content
|
||||||
|
|
||||||
|
def _decrypt_content(self, content: str, pubkey_hex: str) -> str:
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
|
||||||
|
(encrypted_content_b64, iv_b64) = content.split("?iv=")
|
||||||
|
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
|
||||||
|
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
decrypted_bytes = aes.decrypt(encrypted_content)
|
||||||
|
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
|
||||||
|
decrypted = decrypted_bytes.decode("utf-8")
|
||||||
|
return decrypted
|
||||||
|
|
||||||
|
async def _on_message(self, ws, message: str):
|
||||||
logger.debug("Received message: " + message)
|
logger.debug("Received message: " + message)
|
||||||
msg = json.loads(message)
|
msg = json.loads(message)
|
||||||
if msg[0] == "EVENT": # Event message
|
if msg[0] == "EVENT": # Event message
|
||||||
event = msg[2]
|
event = msg[2]
|
||||||
nwc_pubkey = event["pubkey"]
|
nwc_pubkey = event["pubkey"]
|
||||||
content = self.private_key.decrypt_message(event["content"], nwc_pubkey)
|
content = self._decrypt_content(event["content"], nwc_pubkey)
|
||||||
content = json.loads(content)
|
content = json.loads(content)
|
||||||
self.event_queue.append(
|
self.event_queue.append(
|
||||||
{
|
{
|
||||||
|
|
@ -262,12 +288,12 @@ class NWCWallet:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _json_dumps(self, data: Union[dict, list]) -> str:
|
def _json_dumps(self, data: Union[Dict, list]) -> str:
|
||||||
if isinstance(data, dict):
|
if isinstance(data, Dict):
|
||||||
data = {k: v for k, v in data.items() if v is not None}
|
data = {k: v for k, v in data.items() if v is not None}
|
||||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
def _sign_event(self, event: dict) -> dict:
|
def _sign_event(self, event: Dict) -> Dict:
|
||||||
signature_data = self._json_dumps(
|
signature_data = self._json_dumps(
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
|
|
@ -282,9 +308,10 @@ class NWCWallet:
|
||||||
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||||
event["id"] = event_id
|
event["id"] = event_id
|
||||||
event["pubkey"] = self.public_key_hex
|
event["pubkey"] = self.public_key_hex
|
||||||
signature = self.private_key.sign(bytes.fromhex(event_id))
|
signature = (
|
||||||
# type error? returns str but is bytes
|
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
|
||||||
event["sig"] = signature.hex() # type: ignore
|
).hex()
|
||||||
|
event["sig"] = signature
|
||||||
return event
|
return event
|
||||||
|
|
||||||
async def send_event(self, method, params):
|
async def send_event(self, method, params):
|
||||||
|
|
@ -300,7 +327,7 @@ class NWCWallet:
|
||||||
"content": json.dumps({"method": method, "params": params}),
|
"content": json.dumps({"method": method, "params": params}),
|
||||||
}
|
}
|
||||||
logger.debug("Sending event: " + str(event))
|
logger.debug("Sending event: " + str(event))
|
||||||
event["content"] = self.private_key.encrypt_message(
|
event["content"] = self._encrypt_content(
|
||||||
event["content"], self.provider_pub_hex
|
event["content"], self.provider_pub_hex
|
||||||
)
|
)
|
||||||
self._sign_event(event)
|
self._sign_event(event)
|
||||||
|
|
@ -308,11 +335,7 @@ class NWCWallet:
|
||||||
await self.ws.send(self._json_dumps(["EVENT", event]))
|
await self.ws.send(self._json_dumps(["EVENT", event]))
|
||||||
|
|
||||||
async def wait_for(
|
async def wait_for(
|
||||||
self,
|
self, result_type, callback=None, on_error_callback=None, timeout=60
|
||||||
result_type,
|
|
||||||
callback=None,
|
|
||||||
on_error_callback=None,
|
|
||||||
timeout=NWC_RESPONSE_TIMEOUT_SECONDS,
|
|
||||||
):
|
):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -359,14 +382,14 @@ async def test_make_invoice():
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000}
|
"make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
logger.info(error)
|
logger.info(error)
|
||||||
assert error, "Expected internal error, because amount is too low"
|
assert error, "Expected internal error, because amount is too low"
|
||||||
|
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["type"] == "incoming"
|
assert result["type"] == "incoming"
|
||||||
assert result["description"] == "test 123"
|
assert result["description"] == "test 123"
|
||||||
|
|
@ -397,7 +420,7 @@ async def test_lookup_invoice():
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["type"] == "incoming"
|
assert result["type"] == "incoming"
|
||||||
assert result["description"] == "test 123"
|
assert result["description"] == "test 123"
|
||||||
|
|
@ -413,7 +436,7 @@ async def test_lookup_invoice():
|
||||||
await wallet2.start()
|
await wallet2.start()
|
||||||
|
|
||||||
await wallet2.send_event("lookup_invoice", {"invoice": result["invoice"]})
|
await wallet2.send_event("lookup_invoice", {"invoice": result["invoice"]})
|
||||||
result, _, error = await wallet2.wait_for("lookup_invoice")
|
result, tags, error = await wallet2.wait_for("lookup_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["type"] == "incoming"
|
assert result["type"] == "incoming"
|
||||||
assert result["description"] == "test 123"
|
assert result["description"] == "test 123"
|
||||||
|
|
@ -438,7 +461,7 @@ async def test_get_info():
|
||||||
await wallet1.start()
|
await wallet1.start()
|
||||||
|
|
||||||
await wallet1.send_event("get_info", {})
|
await wallet1.send_event("get_info", {})
|
||||||
result, _, error = await wallet1.wait_for("get_info")
|
result, tags, error = await wallet1.wait_for("get_info")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["alias"] == "LNBits_NWC_SP"
|
assert result["alias"] == "LNBits_NWC_SP"
|
||||||
assert result["color"] == ""
|
assert result["color"] == ""
|
||||||
|
|
@ -465,33 +488,33 @@ async def test_permisions():
|
||||||
await wallet1.start()
|
await wallet1.start()
|
||||||
|
|
||||||
await wallet1.send_event("get_info", {})
|
await wallet1.send_event("get_info", {})
|
||||||
result, _, error = await wallet1.wait_for("get_info")
|
result, tags, error = await wallet1.wait_for("get_info")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert error
|
assert error
|
||||||
|
|
||||||
await wallet1.close()
|
await wallet1.close()
|
||||||
await wallet2.start()
|
await wallet2.start()
|
||||||
|
|
||||||
await wallet2.send_event("get_info", {})
|
await wallet2.send_event("get_info", {})
|
||||||
result, _, error = await wallet2.wait_for("get_info")
|
result, tags, error = await wallet2.wait_for("get_info")
|
||||||
assert error
|
assert error
|
||||||
|
|
||||||
await wallet2.send_event(
|
await wallet2.send_event(
|
||||||
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet2.wait_for("make_invoice")
|
result, tags, error = await wallet2.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet2.close()
|
await wallet2.close()
|
||||||
await wallet3.start()
|
await wallet3.start()
|
||||||
|
|
||||||
await wallet3.send_event("get_info", {})
|
await wallet3.send_event("get_info", {})
|
||||||
result, _, error = await wallet3.wait_for("get_info")
|
result, tags, error = await wallet3.wait_for("get_info")
|
||||||
assert not error
|
assert not error
|
||||||
assert "make_invoice" in result["methods"]
|
assert "make_invoice" in result["methods"]
|
||||||
assert "pay_invoice" in result["methods"]
|
assert "pay_invoice" in result["methods"]
|
||||||
|
|
@ -521,7 +544,7 @@ async def test_pay_invoice_and_balance():
|
||||||
"make_invoice", {"amount": 123000, "description": "test 123"}
|
"make_invoice", {"amount": 123000, "description": "test 123"}
|
||||||
)
|
)
|
||||||
|
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["invoice"]
|
assert result["invoice"]
|
||||||
|
|
||||||
|
|
@ -530,7 +553,7 @@ async def test_pay_invoice_and_balance():
|
||||||
await wallet2.start()
|
await wallet2.start()
|
||||||
|
|
||||||
await wallet2.send_event("pay_invoice", {"invoice": invoice})
|
await wallet2.send_event("pay_invoice", {"invoice": invoice})
|
||||||
result, _, error = await wallet2.wait_for("pay_invoice")
|
result, tags, error = await wallet2.wait_for("pay_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["preimage"]
|
assert result["preimage"]
|
||||||
|
|
||||||
|
|
@ -542,12 +565,12 @@ async def test_pay_invoice_and_balance():
|
||||||
assert wallet2_balance_new == wallet2_balance - 123000
|
assert wallet2_balance_new == wallet2_balance - 123000
|
||||||
|
|
||||||
await wallet1.send_event("get_balance", {})
|
await wallet1.send_event("get_balance", {})
|
||||||
result, _, error = await wallet1.wait_for("get_balance")
|
result, tags, error = await wallet1.wait_for("get_balance")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["balance"] == wallet1_balance_new
|
assert result["balance"] == wallet1_balance_new
|
||||||
|
|
||||||
await wallet2.send_event("get_balance", {})
|
await wallet2.send_event("get_balance", {})
|
||||||
result, _, error = await wallet2.wait_for("get_balance")
|
result, tags, error = await wallet2.wait_for("get_balance")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["balance"] == wallet2_balance_new
|
assert result["balance"] == wallet2_balance_new
|
||||||
|
|
||||||
|
|
@ -604,7 +627,6 @@ async def test_multi_pay_invoices():
|
||||||
assert not error
|
assert not error
|
||||||
assert result["invoice"]
|
assert result["invoice"]
|
||||||
invoice3 = result["invoice"]
|
invoice3 = result["invoice"]
|
||||||
invoice3_payhash = result["payment_hash"]
|
|
||||||
|
|
||||||
await wallet3.send_event(
|
await wallet3.send_event(
|
||||||
"multi_pay_invoice",
|
"multi_pay_invoice",
|
||||||
|
|
@ -623,7 +645,7 @@ async def test_multi_pay_invoices():
|
||||||
assert result["preimage"]
|
assert result["preimage"]
|
||||||
elif d_tag == "invoice2":
|
elif d_tag == "invoice2":
|
||||||
assert result["preimage"]
|
assert result["preimage"]
|
||||||
elif d_tag == invoice3_payhash:
|
elif d_tag == invoice3:
|
||||||
assert result["preimage"]
|
assert result["preimage"]
|
||||||
else:
|
else:
|
||||||
raise AssertionError("Unexpected d tag")
|
raise AssertionError("Unexpected d tag")
|
||||||
|
|
@ -676,13 +698,13 @@ async def test_insufficient_balance():
|
||||||
await wallet2.send_event(
|
await wallet2.send_event(
|
||||||
"make_invoice", {"amount": amount_to_spend, "description": "test 123"}
|
"make_invoice", {"amount": amount_to_spend, "description": "test 123"}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet2.wait_for("make_invoice")
|
result, tags, error = await wallet2.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
assert result["invoice"]
|
assert result["invoice"]
|
||||||
invoice = result["invoice"]
|
invoice = result["invoice"]
|
||||||
|
|
||||||
await wallet1.send_event("pay_invoice", {"invoice": invoice})
|
await wallet1.send_event("pay_invoice", {"invoice": invoice})
|
||||||
result, _, error = await wallet1.wait_for("pay_invoice")
|
result, tags, error = await wallet1.wait_for("pay_invoice")
|
||||||
logger.info(error)
|
logger.info(error)
|
||||||
logger.info(result)
|
logger.info(result)
|
||||||
logger.info(amount_to_spend)
|
logger.info(amount_to_spend)
|
||||||
|
|
@ -708,7 +730,7 @@ async def test_expiry():
|
||||||
await wallet3.send_event(
|
await wallet3.send_event(
|
||||||
"make_invoice", {"amount": 123000, "description": "test 123"}
|
"make_invoice", {"amount": 123000, "description": "test 123"}
|
||||||
)
|
)
|
||||||
_, _, error = await wallet3.wait_for("make_invoice")
|
result, tags, error = await wallet3.wait_for("make_invoice")
|
||||||
assert error
|
assert error
|
||||||
assert (
|
assert (
|
||||||
error["code"] == "UNAUTHORIZED"
|
error["code"] == "UNAUTHORIZED"
|
||||||
|
|
@ -741,22 +763,22 @@ async def test_budget():
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 101000, "description": "Invalid"}
|
"make_invoice", {"amount": 101000, "description": "Invalid"}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
||||||
result, _, error = await wallet3.wait_for("pay_invoice")
|
result, tags, error = await wallet3.wait_for("pay_invoice")
|
||||||
assert error
|
assert error
|
||||||
assert (
|
assert (
|
||||||
error["code"] == "QUOTA_EXCEEDED"
|
error["code"] == "QUOTA_EXCEEDED"
|
||||||
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
|
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
|
||||||
|
|
||||||
await wallet1.send_event("make_invoice", {"amount": 99000, "description": "Valid"})
|
await wallet1.send_event("make_invoice", {"amount": 99000, "description": "Valid"})
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
||||||
result, _, error = await wallet3.wait_for("pay_invoice")
|
result, tags, error = await wallet3.wait_for("pay_invoice")
|
||||||
assert not error, "Expected successful payment, because the budget was not exceeded"
|
assert not error, "Expected successful payment, because the budget was not exceeded"
|
||||||
assert result["preimage"]
|
assert result["preimage"]
|
||||||
|
|
||||||
|
|
@ -764,11 +786,11 @@ async def test_budget():
|
||||||
"make_invoice", {"amount": 100000 - 99000 + 1000, "description": "Invalid"}
|
"make_invoice", {"amount": 100000 - 99000 + 1000, "description": "Invalid"}
|
||||||
)
|
)
|
||||||
|
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
||||||
result, _, error = await wallet3.wait_for("pay_invoice")
|
result, tags, error = await wallet3.wait_for("pay_invoice")
|
||||||
assert error
|
assert error
|
||||||
assert (
|
assert (
|
||||||
error["code"] == "QUOTA_EXCEEDED"
|
error["code"] == "QUOTA_EXCEEDED"
|
||||||
|
|
@ -797,21 +819,21 @@ async def test_budget_refresh():
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 100000, "description": "Invalid"}
|
"make_invoice", {"amount": 100000, "description": "Invalid"}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet1.send_event(
|
await wallet1.send_event(
|
||||||
"make_invoice", {"amount": 100000, "description": "Invalid"}
|
"make_invoice", {"amount": 100000, "description": "Invalid"}
|
||||||
)
|
)
|
||||||
result2, _, error = await wallet1.wait_for("make_invoice")
|
result2, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
||||||
result, _, error = await wallet3.wait_for("pay_invoice")
|
result, tags, error = await wallet3.wait_for("pay_invoice")
|
||||||
assert not error, "Expected successful payment, because the budget was not exceeded"
|
assert not error, "Expected successful payment, because the budget was not exceeded"
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result2["invoice"]})
|
await wallet3.send_event("pay_invoice", {"invoice": result2["invoice"]})
|
||||||
result, _, error = await wallet3.wait_for("pay_invoice")
|
result, tags, error = await wallet3.wait_for("pay_invoice")
|
||||||
assert error
|
assert error
|
||||||
assert (
|
assert (
|
||||||
error["code"] == "QUOTA_EXCEEDED"
|
error["code"] == "QUOTA_EXCEEDED"
|
||||||
|
|
@ -819,88 +841,17 @@ async def test_budget_refresh():
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
await wallet1.send_event("make_invoice", {"amount": 100000, "description": "Valid"})
|
await wallet1.send_event("make_invoice", {"amount": 100000, "description": "Valid"})
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
result, tags, error = await wallet1.wait_for("make_invoice")
|
||||||
assert not error
|
assert not error
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
||||||
result, _, error = await wallet3.wait_for("pay_invoice")
|
result, tags, error = await wallet3.wait_for("pay_invoice")
|
||||||
assert not error, "Expected successful payment, because the budget was refreshed"
|
assert not error, "Expected successful payment, because the budget was refreshed"
|
||||||
|
|
||||||
await wallet3.close()
|
await wallet3.close()
|
||||||
await wallet1.close()
|
await wallet1.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_never_refresh_budget_counts_previous_spend():
|
|
||||||
await check_services()
|
|
||||||
nwc1 = await create_nwc(
|
|
||||||
"wallet1",
|
|
||||||
"test_never_refresh_budget_counts_previous_spend",
|
|
||||||
["invoice"],
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
nwc3 = await create_nwc(
|
|
||||||
"wallet3",
|
|
||||||
"test_never_refresh_budget_counts_previous_spend",
|
|
||||||
["pay"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"budget_msats": 100000,
|
|
||||||
"refresh_window": 0,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
wallet1 = NWCWallet(nwc1["pairing"])
|
|
||||||
wallet3 = NWCWallet(nwc3["pairing"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
await wallet1.start()
|
|
||||||
await wallet3.start()
|
|
||||||
|
|
||||||
await wallet1.send_event(
|
|
||||||
"make_invoice", {"amount": 60000, "description": "Within lifetime budget"}
|
|
||||||
)
|
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
|
||||||
_, _, error = await wallet3.wait_for("pay_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
"http://localhost:5002/nwcprovider/api/v1/nwc"
|
|
||||||
"?calculate_spent_budget=true",
|
|
||||||
headers={"X-Api-Key": wallets["wallet3"]["admin_key"]},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
payer_nwc = next(
|
|
||||||
item for item in resp.json() if item["data"]["pubkey"] == nwc3["pubkey"]
|
|
||||||
)
|
|
||||||
assert payer_nwc["budgets"][0]["used_budget_msats"] == 60000
|
|
||||||
|
|
||||||
await wallet1.send_event(
|
|
||||||
"make_invoice", {"amount": 50000, "description": "Exceeds lifetime budget"}
|
|
||||||
)
|
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
|
||||||
_, _, error = await wallet3.wait_for("pay_invoice")
|
|
||||||
assert error
|
|
||||||
assert error["code"] == "QUOTA_EXCEEDED"
|
|
||||||
finally:
|
|
||||||
if wallet3.ws:
|
|
||||||
await wallet3.close()
|
|
||||||
if wallet1.ws:
|
|
||||||
await wallet1.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Mostly AI generated pentests
|
# Mostly AI generated pentests
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -932,7 +883,7 @@ async def test_idor_vulnerability():
|
||||||
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
|
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
|
||||||
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
|
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 500
|
||||||
assert "Pubkey has no associated wallet" in resp.text
|
assert "Pubkey has no associated wallet" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -960,7 +911,6 @@ async def test_invalid_invoice_handling():
|
||||||
# Send invalid invoice
|
# Send invalid invoice
|
||||||
await wallet.send_event("pay_invoice", {"invoice": "invalid_lninvoice"})
|
await wallet.send_event("pay_invoice", {"invoice": "invalid_lninvoice"})
|
||||||
_, _, error = await wallet.wait_for("pay_invoice")
|
_, _, error = await wallet.wait_for("pay_invoice")
|
||||||
assert error
|
|
||||||
assert error["code"] == "INTERNAL"
|
assert error["code"] == "INTERNAL"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -980,7 +930,6 @@ async def test_replay_attack():
|
||||||
# Replay same message
|
# Replay same message
|
||||||
await wallet.send_event("pay_invoice", {"invoice": valid_invoice})
|
await wallet.send_event("pay_invoice", {"invoice": valid_invoice})
|
||||||
_, _, error = await wallet.wait_for("pay_invoice")
|
_, _, error = await wallet.wait_for("pay_invoice")
|
||||||
assert error
|
|
||||||
assert error["code"] == "PAYMENT_FAILED"
|
assert error["code"] == "PAYMENT_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1013,7 +962,6 @@ async def test_budget_bypass():
|
||||||
invoice2 = await create_valid_invoice(wallet, 60000)
|
invoice2 = await create_valid_invoice(wallet, 60000)
|
||||||
await wallet.send_event("pay_invoice", {"invoice": invoice2})
|
await wallet.send_event("pay_invoice", {"invoice": invoice2})
|
||||||
_, _, error = await wallet.wait_for("pay_invoice")
|
_, _, error = await wallet.wait_for("pay_invoice")
|
||||||
assert error
|
|
||||||
assert error["code"] == "QUOTA_EXCEEDED"
|
assert error["code"] == "QUOTA_EXCEEDED"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1039,78 +987,7 @@ async def create_valid_invoice(wallet, amount=1000):
|
||||||
await wallet.send_event(
|
await wallet.send_event(
|
||||||
"make_invoice", {"amount": amount, "description": "test invoice"}
|
"make_invoice", {"amount": amount, "description": "test invoice"}
|
||||||
)
|
)
|
||||||
result, _, error = await wallet.wait_for("make_invoice")
|
result, tags, error = await wallet.wait_for("make_invoice")
|
||||||
if error:
|
if error:
|
||||||
raise Exception(f"Failed to create invoice: {error}")
|
raise Exception(f"Failed to create invoice: {error}")
|
||||||
return result["invoice"]
|
return result["invoice"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_transactions():
|
|
||||||
# Create wallets with required permissions
|
|
||||||
nwc1 = await create_nwc(
|
|
||||||
"wallet1",
|
|
||||||
"test_list_transactions",
|
|
||||||
["invoice", "pay", "balance", "history"],
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
nwc2 = await create_nwc(
|
|
||||||
"wallet2",
|
|
||||||
"test_list_transactions",
|
|
||||||
["invoice", "pay", "balance", "history"],
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
wallet1 = NWCWallet(nwc1["pairing"])
|
|
||||||
wallet2 = NWCWallet(nwc2["pairing"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
await wallet1.start()
|
|
||||||
await wallet2.start()
|
|
||||||
|
|
||||||
# First invoice
|
|
||||||
await wallet1.send_event(
|
|
||||||
"make_invoice", {"amount": 1000, "description": "test invoice 1"}
|
|
||||||
)
|
|
||||||
result1, _, error = await wallet1.wait_for("make_invoice")
|
|
||||||
assert not error
|
|
||||||
invoice1 = result1["invoice"]
|
|
||||||
|
|
||||||
# Pay first invoice
|
|
||||||
await wallet2.send_event("pay_invoice", {"invoice": invoice1})
|
|
||||||
_, _, error = await wallet2.wait_for("pay_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
# Second invoice
|
|
||||||
await wallet1.send_event(
|
|
||||||
"make_invoice", {"amount": 2000, "description": "test invoice 2"}
|
|
||||||
)
|
|
||||||
result2, _, error = await wallet1.wait_for("make_invoice")
|
|
||||||
assert not error
|
|
||||||
invoice2 = result2["invoice"]
|
|
||||||
|
|
||||||
# Pay second invoice
|
|
||||||
await wallet2.send_event("pay_invoice", {"invoice": invoice2})
|
|
||||||
_, _, error = await wallet2.wait_for("pay_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
# Test basic transaction listing
|
|
||||||
await wallet1.send_event("list_transactions", {})
|
|
||||||
result, _, error = await wallet1.wait_for("list_transactions")
|
|
||||||
assert not error
|
|
||||||
assert "transactions" in result
|
|
||||||
transactions = result["transactions"]
|
|
||||||
assert len(transactions) >= 2
|
|
||||||
|
|
||||||
# Test limit
|
|
||||||
await wallet1.send_event("list_transactions", {"limit": 1})
|
|
||||||
result, _, error = await wallet1.wait_for("list_transactions")
|
|
||||||
assert not error
|
|
||||||
limited_txs = result["transactions"]
|
|
||||||
assert len(limited_txs) == 1
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await wallet1.close()
|
|
||||||
await wallet2.close()
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import random
|
import os
|
||||||
import string
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ...nwcp import NWCServiceProvider
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||||
|
####
|
||||||
|
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nwcp import NWCServiceProvider
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -35,22 +40,25 @@ def test_supported_methods(nwc_service_provider):
|
||||||
|
|
||||||
def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
|
def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
|
||||||
content = "Hello World"
|
content = "Hello World"
|
||||||
enc_a = nwc_service_provider.private_key.encrypt_message(
|
expected_enc = "qVurNVISSl/9CfREIhk5Lg==?iv=QpCo5dI9gUcoLsSMLA7o7Q=="
|
||||||
content, nwc_service_provider2.public_key_hex
|
enc_a = nwc_service_provider._encrypt_content(
|
||||||
|
content, nwc_service_provider2.public_key_hex, 21
|
||||||
)
|
)
|
||||||
enc_b = nwc_service_provider2.private_key.encrypt_message(
|
enc_b = nwc_service_provider2._encrypt_content(
|
||||||
content, nwc_service_provider.public_key_hex
|
content, nwc_service_provider.public_key_hex, 21
|
||||||
)
|
)
|
||||||
|
|
||||||
dec_a = nwc_service_provider2.private_key.decrypt_message(
|
dec_a = nwc_service_provider2._decrypt_content(
|
||||||
enc_a, nwc_service_provider.public_key_hex
|
enc_a, nwc_service_provider.public_key_hex
|
||||||
)
|
)
|
||||||
dec_b = nwc_service_provider.private_key.decrypt_message(
|
dec_b = nwc_service_provider._decrypt_content(
|
||||||
enc_b, nwc_service_provider2.public_key_hex
|
enc_b, nwc_service_provider2.public_key_hex
|
||||||
)
|
)
|
||||||
|
|
||||||
assert dec_a == content
|
assert dec_a == content
|
||||||
assert dec_b == content
|
assert dec_b == content
|
||||||
|
assert enc_a == expected_enc
|
||||||
|
assert enc_b == expected_enc
|
||||||
|
|
||||||
|
|
||||||
def test_signverify(nwc_service_provider, nwc_service_provider2):
|
def test_signverify(nwc_service_provider, nwc_service_provider2):
|
||||||
|
|
@ -76,31 +84,19 @@ def test_signverify(nwc_service_provider, nwc_service_provider2):
|
||||||
assert nwc_service_provider2._verify_event(signed)
|
assert nwc_service_provider2._verify_event(signed)
|
||||||
|
|
||||||
|
|
||||||
def test_default_event_max_age(nwc_service_provider):
|
|
||||||
assert nwc_service_provider.event_max_age == 5 * 60
|
|
||||||
assert (
|
|
||||||
NWCServiceProvider(
|
|
||||||
"d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
|
|
||||||
"",
|
|
||||||
handle_missed_events=123,
|
|
||||||
).event_max_age
|
|
||||||
== 123
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle(nwc_service_provider, nwc_service_provider2):
|
async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||||
content = nwc_service_provider._json_dumps(
|
content = nwc_service_provider._json_dumps(
|
||||||
{"method": "pay_invoice", "params": {"invoice": "abc"}}
|
{"method": "pay_invoice", "params": {"invoice": "abc"}}
|
||||||
)
|
)
|
||||||
content = nwc_service_provider.private_key.encrypt_message(
|
content = nwc_service_provider._encrypt_content(
|
||||||
content, nwc_service_provider2.public_key_hex
|
content, nwc_service_provider2.public_key_hex, 21
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"kind": 23194,
|
"kind": 23194,
|
||||||
"content": content,
|
"content": content,
|
||||||
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
||||||
"created_at": int(time.time()),
|
"created_at": 1234567890,
|
||||||
}
|
}
|
||||||
signed = nwc_service_provider._sign_event(event)
|
signed = nwc_service_provider._sign_event(event)
|
||||||
|
|
||||||
|
|
@ -114,13 +110,12 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
nwc_service_provider2._send = _send_pass
|
nwc_service_provider2._send = _send_pass
|
||||||
nwc_service_provider2._create_subscription()
|
|
||||||
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
|
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
|
||||||
sent_events = await nwc_service_provider2._handle_request(signed)
|
sent_events = await nwc_service_provider2._handle_request(signed)
|
||||||
assert len(sent_events) == 1
|
assert len(sent_events) == 1
|
||||||
for revent in sent_events:
|
for revent in sent_events:
|
||||||
assert nwc_service_provider2._verify_event(revent)
|
assert nwc_service_provider2._verify_event(revent)
|
||||||
content = nwc_service_provider2.private_key.decrypt_message(
|
content = nwc_service_provider2._decrypt_content(
|
||||||
revent["content"], nwc_service_provider.public_key_hex
|
revent["content"], nwc_service_provider.public_key_hex
|
||||||
)
|
)
|
||||||
logger.debug(event)
|
logger.debug(event)
|
||||||
|
|
@ -140,187 +135,3 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||||
p_tag = [tag for tag in tags if tag[0] == "p"]
|
p_tag = [tag for tag in tags if tag[0] == "p"]
|
||||||
assert len(p_tag) == 1
|
assert len(p_tag) == 1
|
||||||
assert p_tag[0][1] == nwc_service_provider.public_key_hex
|
assert p_tag[0][1] == nwc_service_provider.public_key_hex
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handle_rejects_same_event_replay(
|
|
||||||
nwc_service_provider, nwc_service_provider2
|
|
||||||
):
|
|
||||||
content = nwc_service_provider._json_dumps(
|
|
||||||
{"method": "pay_invoice", "params": {"invoice": "abc"}}
|
|
||||||
)
|
|
||||||
content = nwc_service_provider.private_key.encrypt_message(
|
|
||||||
content, nwc_service_provider2.public_key_hex
|
|
||||||
)
|
|
||||||
event = {
|
|
||||||
"kind": 23194,
|
|
||||||
"content": content,
|
|
||||||
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
signed = nwc_service_provider._sign_event(event)
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def _handle_pay_invoice(provider, pubkey, content):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
return [({"preimage": "00000"}, None, [])]
|
|
||||||
|
|
||||||
async def _send_pass(obj):
|
|
||||||
pass
|
|
||||||
|
|
||||||
nwc_service_provider2._send = _send_pass
|
|
||||||
nwc_service_provider2._create_subscription()
|
|
||||||
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
|
|
||||||
|
|
||||||
await nwc_service_provider2._handle_request(signed)
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="already handled"):
|
|
||||||
await nwc_service_provider2._handle_request(signed)
|
|
||||||
|
|
||||||
assert calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_relay_dispatches_requests_without_waiting_for_previous_request(
|
|
||||||
nwc_service_provider, monkeypatch
|
|
||||||
):
|
|
||||||
sub = nwc_service_provider._create_subscription()
|
|
||||||
sub.requests_sub_id = "requests"
|
|
||||||
sub.requests_eose = True
|
|
||||||
sub.responses_eose = True
|
|
||||||
monkeypatch.setattr(nwc_service_provider, "_verify_event", lambda event: True)
|
|
||||||
|
|
||||||
first_request_finished = asyncio.Event()
|
|
||||||
second_request_finished = asyncio.Event()
|
|
||||||
|
|
||||||
async def _handle_request(event):
|
|
||||||
if event["id"] == "first":
|
|
||||||
await first_request_finished.wait()
|
|
||||||
else:
|
|
||||||
second_request_finished.set()
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request)
|
|
||||||
|
|
||||||
def request(event_id):
|
|
||||||
return json.dumps(
|
|
||||||
[
|
|
||||||
"EVENT",
|
|
||||||
sub.requests_sub_id,
|
|
||||||
{
|
|
||||||
"id": event_id,
|
|
||||||
"kind": 23194,
|
|
||||||
"pubkey": "a" * 64,
|
|
||||||
"content": "",
|
|
||||||
"tags": [["p", nwc_service_provider.public_key_hex]],
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
await nwc_service_provider._on_message(None, request("first"))
|
|
||||||
await nwc_service_provider._on_message(None, request("second"))
|
|
||||||
|
|
||||||
await asyncio.wait_for(second_request_finished.wait(), timeout=1)
|
|
||||||
assert not first_request_finished.is_set()
|
|
||||||
|
|
||||||
first_request_finished.set()
|
|
||||||
await asyncio.gather(*list(nwc_service_provider.request_tasks))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_cancels_pending_request_tasks(nwc_service_provider, monkeypatch):
|
|
||||||
request_started = asyncio.Event()
|
|
||||||
|
|
||||||
async def _handle_request(event):
|
|
||||||
request_started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request)
|
|
||||||
nwc_service_provider._dispatch_request({"id": "pending"})
|
|
||||||
await request_started.wait()
|
|
||||||
|
|
||||||
await nwc_service_provider.cleanup()
|
|
||||||
|
|
||||||
assert not nwc_service_provider.request_tasks
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_info_event(nwc_service_provider):
|
|
||||||
"""_send_info_event should publish a signed kind-13194 event."""
|
|
||||||
nwc_service_provider.add_request_listener(
|
|
||||||
"pay_invoice", lambda *args, **kwargs: None # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
|
|
||||||
sent: list[list] = []
|
|
||||||
|
|
||||||
async def _send_capture(obj):
|
|
||||||
sent.append(obj)
|
|
||||||
|
|
||||||
nwc_service_provider._send = _send_capture
|
|
||||||
nwc_service_provider.connected = True
|
|
||||||
|
|
||||||
await nwc_service_provider._send_info_event()
|
|
||||||
|
|
||||||
assert len(sent) == 1
|
|
||||||
msg = sent[0]
|
|
||||||
assert msg[0] == "EVENT"
|
|
||||||
event = msg[1]
|
|
||||||
assert event["kind"] == 13194
|
|
||||||
assert "pay_invoice" in event["content"]
|
|
||||||
assert nwc_service_provider._verify_event(event)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_info_event_loop_resends(nwc_service_provider):
|
|
||||||
"""_info_event_loop should resend the info event while connected."""
|
|
||||||
sent: list[list] = []
|
|
||||||
|
|
||||||
async def _send_capture(obj):
|
|
||||||
sent.append(obj)
|
|
||||||
|
|
||||||
nwc_service_provider._send = _send_capture
|
|
||||||
nwc_service_provider.connected = True
|
|
||||||
|
|
||||||
loop_task = asyncio.create_task(nwc_service_provider._info_event_loop())
|
|
||||||
# Allow the loop to run through one sleep cycle (patched to near-zero).
|
|
||||||
# We drive it by cancelling right after the first send opportunity.
|
|
||||||
await asyncio.sleep(0) # yield to let the task start
|
|
||||||
# Manually trigger a resend call to verify the helper works correctly.
|
|
||||||
await nwc_service_provider._send_info_event()
|
|
||||||
loop_task.cancel()
|
|
||||||
try:
|
|
||||||
await loop_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# At least the manual call went through.
|
|
||||||
assert len(sent) >= 1
|
|
||||||
for msg in sent:
|
|
||||||
assert msg[0] == "EVENT"
|
|
||||||
assert msg[1]["kind"] == 13194
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_info_event_loop_skips_when_disconnected(nwc_service_provider):
|
|
||||||
"""_info_event_loop should not send the info event while disconnected."""
|
|
||||||
sent: list[list] = []
|
|
||||||
|
|
||||||
async def _send_capture(obj):
|
|
||||||
sent.append(obj)
|
|
||||||
|
|
||||||
nwc_service_provider._send = _send_capture
|
|
||||||
nwc_service_provider.connected = False # not connected
|
|
||||||
|
|
||||||
loop_task = asyncio.create_task(nwc_service_provider._info_event_loop())
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
loop_task.cancel()
|
|
||||||
try:
|
|
||||||
await loop_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Nothing should have been sent because connected=False.
|
|
||||||
assert len(sent) == 0
|
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ... import tasks
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_invoice_returns_payment_failed_on_failed_status(monkeypatch):
|
|
||||||
async def fake_tracked_spend_nwc(*args, **kwargs):
|
|
||||||
return True, "a" * 64
|
|
||||||
|
|
||||||
async def fake_check_transaction_status(wallet_id: str, payment_hash: str):
|
|
||||||
return SimpleNamespace(success=False, failed=True)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks, "tracked_spend_nwc", fake_tracked_spend_nwc)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
tasks, "check_transaction_status", fake_check_transaction_status
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await tasks._process_invoice(
|
|
||||||
wallet_id="wallet123",
|
|
||||||
pubkey="a" * 64,
|
|
||||||
invoice="lnbc1example",
|
|
||||||
amount_msats=1000,
|
|
||||||
description="test",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["error"]["code"] == "PAYMENT_FAILED"
|
|
||||||
assert result["error"]["message"] == "Payment failed."
|
|
||||||
assert result["in_budget"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_invoice_backs_off_pending_payment_polling_to_configured_max(
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
async def fake_tracked_spend_nwc(*args, **kwargs):
|
|
||||||
return True, "a" * 64
|
|
||||||
|
|
||||||
pending = SimpleNamespace(success=False, failed=False)
|
|
||||||
statuses = iter(
|
|
||||||
[pending] * 8
|
|
||||||
+ [
|
|
||||||
SimpleNamespace(
|
|
||||||
success=True,
|
|
||||||
failed=False,
|
|
||||||
preimage="b" * 64,
|
|
||||||
fee_msat=10,
|
|
||||||
paid=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_check_transaction_status(wallet_id: str, payment_hash: str):
|
|
||||||
return next(statuses)
|
|
||||||
|
|
||||||
sleep_calls: list[float] = []
|
|
||||||
|
|
||||||
async def fake_sleep(delay: float):
|
|
||||||
sleep_calls.append(delay)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks, "tracked_spend_nwc", fake_tracked_spend_nwc)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
tasks, "check_transaction_status", fake_check_transaction_status
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(tasks.asyncio, "sleep", fake_sleep)
|
|
||||||
|
|
||||||
result = await tasks._process_invoice(
|
|
||||||
wallet_id="wallet123",
|
|
||||||
pubkey="a" * 64,
|
|
||||||
invoice="lnbc1example",
|
|
||||||
amount_msats=1000,
|
|
||||||
description="test",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert sleep_calls == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 60.0, 60.0]
|
|
||||||
assert result["preimage"] == "b" * 64
|
|
||||||
assert result["fee_msats"] == 10
|
|
||||||
assert result["paid"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def _stub_make_invoice(monkeypatch, captured: dict):
|
|
||||||
async def fake_get_nwc(*args, **kwargs):
|
|
||||||
return SimpleNamespace(wallet="wallet123")
|
|
||||||
|
|
||||||
async def fake_check(nwc, method):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_create_invoice(**kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return SimpleNamespace(payment_hash="b" * 64, bolt11="lnbc1example")
|
|
||||||
|
|
||||||
async def fake_check_transaction_status(wallet_id: str, payment_hash: str):
|
|
||||||
return SimpleNamespace(preimage=None)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks, "get_nwc", fake_get_nwc)
|
|
||||||
monkeypatch.setattr(tasks, "_check", fake_check)
|
|
||||||
monkeypatch.setattr(tasks, "create_invoice", fake_create_invoice)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
tasks, "check_transaction_status", fake_check_transaction_status
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"params",
|
|
||||||
[
|
|
||||||
# rust-nostr, Alby JS SDK: unused optional fields are omitted
|
|
||||||
{"amount": 21000},
|
|
||||||
# Amethyst: unused optional fields are sent as explicit null
|
|
||||||
{
|
|
||||||
"amount": 21000,
|
|
||||||
"description": None,
|
|
||||||
"description_hash": None,
|
|
||||||
"expiry": None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_make_invoice_accepts_absent_or_null_optional_params(monkeypatch, params):
|
|
||||||
captured: dict = {}
|
|
||||||
_stub_make_invoice(monkeypatch, captured)
|
|
||||||
|
|
||||||
[(result, error, _)] = await tasks._on_make_invoice(
|
|
||||||
SimpleNamespace(), "a" * 64, {"params": params}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert error is None
|
|
||||||
assert result["invoice"] == "lnbc1example"
|
|
||||||
assert result["description"] == ""
|
|
||||||
assert "expires_at" not in result
|
|
||||||
assert captured["memo"] == ""
|
|
||||||
assert captured["description_hash"] is None
|
|
||||||
assert captured["unhashed_description"] is None
|
|
||||||
assert captured["expiry"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_make_invoice_passes_description_and_expiry_through(monkeypatch):
|
|
||||||
captured: dict = {}
|
|
||||||
_stub_make_invoice(monkeypatch, captured)
|
|
||||||
|
|
||||||
[(result, error, _)] = await tasks._on_make_invoice(
|
|
||||||
SimpleNamespace(),
|
|
||||||
"a" * 64,
|
|
||||||
{"params": {"amount": 21000, "description": "coffee", "expiry": 600}},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert error is None
|
|
||||||
assert captured["memo"] == "coffee"
|
|
||||||
assert captured["unhashed_description"] == b"coffee"
|
|
||||||
assert captured["expiry"] == 600
|
|
||||||
assert result["description"] == "coffee"
|
|
||||||
assert "expires_at" in result
|
|
||||||
49
views_api.py
49
views_api.py
|
|
@ -1,10 +1,11 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import secp256k1
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.decorators import check_admin, require_admin_key
|
from lnbits.decorators import check_admin, require_admin_key
|
||||||
from pynostr.key import PrivateKey
|
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_nwc,
|
create_nwc,
|
||||||
|
|
@ -37,18 +38,20 @@ nwcprovider_api_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# Get supported permissions
|
# Get supported permissions
|
||||||
@nwcprovider_api_router.get("/api/v1/permissions")
|
@nwcprovider_api_router.get("/api/v1/permissions", status_code=HTTPStatus.OK)
|
||||||
async def api_get_permissions() -> dict:
|
async def api_get_permissions() -> Dict:
|
||||||
return nwc_permissions
|
return nwc_permissions
|
||||||
|
|
||||||
|
|
||||||
## Get nwc keys associated with the wallet
|
## Get nwc keys associated with the wallet
|
||||||
@nwcprovider_api_router.get("/api/v1/nwc")
|
@nwcprovider_api_router.get(
|
||||||
|
"/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]
|
||||||
|
)
|
||||||
async def api_get_nwcs(
|
async def api_get_nwcs(
|
||||||
include_expired: bool = False,
|
include_expired: bool = False,
|
||||||
calculate_spent_budget: bool = False,
|
calculate_spent_budget: bool = False,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[NWCGetResponse]:
|
):
|
||||||
wallet_id = wallet.wallet.id
|
wallet_id = wallet.wallet.id
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -71,7 +74,9 @@ async def api_get_nwcs(
|
||||||
|
|
||||||
|
|
||||||
# Get a nwc key
|
# Get a nwc key
|
||||||
@nwcprovider_api_router.get("/api/v1/nwc/{pubkey}")
|
@nwcprovider_api_router.get(
|
||||||
|
"/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse
|
||||||
|
)
|
||||||
async def api_get_nwc(
|
async def api_get_nwc(
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
include_expired: bool = False,
|
include_expired: bool = False,
|
||||||
|
|
@ -88,31 +93,31 @@ async def api_get_nwc(
|
||||||
nwc = await get_nwc(
|
nwc = await get_nwc(
|
||||||
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
|
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not nwc:
|
if not nwc:
|
||||||
raise ValueError("Pubkey has no associated wallet")
|
raise Exception("Pubkey has no associated wallet")
|
||||||
res = NWCGetResponse(
|
res = NWCGetResponse(
|
||||||
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
|
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
|
||||||
)
|
)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
# Get pairing url for given secret
|
# Get pairing url for given secret
|
||||||
@nwcprovider_api_router.get("/api/v1/pairing/{secret}")
|
@nwcprovider_api_router.get(
|
||||||
|
"/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
|
||||||
|
)
|
||||||
async def api_get_pairing_url(req: Request, secret: str) -> str:
|
async def api_get_pairing_url(req: Request, secret: str) -> str:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_sane_string(secret)
|
assert_sane_string(secret)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
pprivkey: str | None = await get_config_nwc("provider_key")
|
pprivkey: Optional[str] = await get_config_nwc("provider_key")
|
||||||
if not pprivkey:
|
if not pprivkey:
|
||||||
raise Exception("Extension is not configured")
|
raise Exception("Extension is not configured")
|
||||||
relay = await get_config_nwc("relay")
|
relay = await get_config_nwc("relay")
|
||||||
if not relay:
|
if not relay:
|
||||||
raise Exception("Extension is not configured")
|
raise Exception("Extension is not configured")
|
||||||
relay_alias: str | None = await get_config_nwc("relay_alias")
|
relay_alias: Optional[str] = await get_config_nwc("relay_alias")
|
||||||
if relay_alias:
|
if relay_alias:
|
||||||
relay = relay_alias
|
relay = relay_alias
|
||||||
else:
|
else:
|
||||||
|
|
@ -125,11 +130,11 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
|
||||||
scheme = "wss"
|
scheme = "wss"
|
||||||
netloc += "/nostrclient/api/v1/relay"
|
netloc += "/nostrclient/api/v1/relay"
|
||||||
relay = f"{scheme}://{netloc}"
|
relay = f"{scheme}://{netloc}"
|
||||||
psk = PrivateKey.from_hex(pprivkey)
|
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
|
||||||
ppk = psk.public_key
|
ppk = psk.pubkey
|
||||||
if not ppk:
|
if not ppk:
|
||||||
raise Exception("Error generating pubkey")
|
raise Exception("Error generating pubkey")
|
||||||
ppubkey = ppk.hex()
|
ppubkey = ppk.serialize().hex()[2:]
|
||||||
url = "nostr+walletconnect://"
|
url = "nostr+walletconnect://"
|
||||||
url += ppubkey
|
url += ppubkey
|
||||||
url += "?relay=" + relay
|
url += "?relay=" + relay
|
||||||
|
|
@ -142,12 +147,13 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
|
||||||
@nwcprovider_api_router.put(
|
@nwcprovider_api_router.put(
|
||||||
"/api/v1/nwc/{pubkey}",
|
"/api/v1/nwc/{pubkey}",
|
||||||
status_code=HTTPStatus.CREATED,
|
status_code=HTTPStatus.CREATED,
|
||||||
|
response_model=NWCGetResponse,
|
||||||
)
|
)
|
||||||
async def api_register_nwc(
|
async def api_register_nwc(
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
data: NWCRegistrationRequest,
|
data: NWCRegistrationRequest,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> NWCGetResponse:
|
):
|
||||||
wallet_id = wallet.wallet.id
|
wallet_id = wallet.wallet.id
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -171,7 +177,7 @@ async def api_register_nwc(
|
||||||
|
|
||||||
|
|
||||||
# Delete a nwc key
|
# Delete a nwc key
|
||||||
@nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}")
|
@nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
|
||||||
async def api_delete_nwc(
|
async def api_delete_nwc(
|
||||||
pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
|
|
@ -187,7 +193,9 @@ async def api_delete_nwc(
|
||||||
|
|
||||||
|
|
||||||
# Get config
|
# Get config
|
||||||
@nwcprovider_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
|
@nwcprovider_api_router.get(
|
||||||
|
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
|
||||||
|
)
|
||||||
async def api_get_all_config_nwc():
|
async def api_get_all_config_nwc():
|
||||||
config = await get_all_config_nwc()
|
config = await get_all_config_nwc()
|
||||||
return config
|
return config
|
||||||
|
|
@ -196,6 +204,7 @@ async def api_get_all_config_nwc():
|
||||||
# Get config
|
# Get config
|
||||||
@nwcprovider_api_router.get(
|
@nwcprovider_api_router.get(
|
||||||
"/api/v1/config/{key}",
|
"/api/v1/config/{key}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
async def api_get_config_nwc(key: str):
|
async def api_get_config_nwc(key: str):
|
||||||
|
|
@ -206,7 +215,9 @@ async def api_get_config_nwc(key: str):
|
||||||
|
|
||||||
|
|
||||||
# Set config
|
# Set config
|
||||||
@nwcprovider_api_router.post("/api/v1/config", dependencies=[Depends(check_admin)])
|
@nwcprovider_api_router.post(
|
||||||
|
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
|
||||||
|
)
|
||||||
async def api_set_config_nwc(req: Request):
|
async def api_set_config_nwc(req: Request):
|
||||||
data = await req.json()
|
data = await req.json()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue