Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e342a7ab2 | ||
|
|
d7e180d855 | ||
|
|
af863b8c8f | ||
|
|
d8d898b20b | ||
|
|
8aa1716e32 | ||
|
|
fbd22c1a22 | ||
|
|
e8a6870d7a | ||
|
|
937936ff33 | ||
|
|
ab3eb967b4 | ||
|
|
6341e1edaf | ||
|
|
69d518bac0 | ||
|
|
9db2429a45 | ||
|
|
dd90dec768 | ||
|
|
eb37a064ad | ||
|
|
28df100d9a | ||
|
|
6e6b387b7d | ||
|
|
209cf7fbe0 | ||
|
|
596167f443 | ||
|
|
4732c4b296 | ||
|
|
405a2f0776 | ||
|
|
304ad3035b | ||
|
|
8cffda5a55 | ||
|
|
cbe858b385 | ||
|
|
65ecca2507 | ||
|
|
1900cf9aa4 | ||
|
|
296b1dfa9a | ||
|
|
b14e0e4cc6 | ||
|
|
fb585402dd | ||
|
|
63f246908e | ||
|
|
8ac827f5a4 | ||
|
|
74d4ddd312 | ||
|
|
40ffa7dea0 | ||
|
|
ddb8fcb986 | ||
|
|
27b9e8254c | ||
|
|
daa4b92331 | ||
|
|
0015314e11 | ||
|
|
235f8a6c19 | ||
|
|
bab399f825 | ||
|
|
646a604221 | ||
|
|
7d8fad267a | ||
|
|
ce0aff206d | ||
|
|
80e7b9639d | ||
|
|
94caa2e1ba | ||
|
|
b0a66e8cf5 | ||
|
|
ffba71c0ce | ||
|
|
b41705167f | ||
|
|
0387db3b55 | ||
|
|
38ef1b0061 | ||
|
|
19c231a2f4 | ||
|
|
026c9b5155 | ||
|
|
c834929f8b | ||
|
|
97de4eda18 | ||
|
|
a844711530 | ||
|
|
e2522b358a | ||
|
|
8f761dfd0f | ||
|
|
b14d36a0aa | ||
|
|
dbb689c5c5 | ||
|
|
2167aa398f | ||
|
|
eb8d2f312f | ||
|
|
f9133760fc |
+18
-1
@@ -28,7 +28,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -87,6 +87,12 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# BoltzWallet
|
||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
@@ -112,11 +118,22 @@ LNBITS_DENOMINATION=sats
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
|
||||
# NWCWalllet
|
||||
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
|
||||
|
||||
# LnTipsWallet
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# BreezSdkWallet
|
||||
BREEZ_API_KEY=KEY
|
||||
BREEZ_GREENLIGHT_SEED=SEED
|
||||
# A Greenlight invite code or Greenlight partner certificate/key can be used
|
||||
BREEZ_GREENLIGHT_INVITE_CODE=CODE
|
||||
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
|
||||
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
||||
|
||||
######################################
|
||||
####### Auth Configurations ##########
|
||||
######################################
|
||||
|
||||
@@ -28,11 +28,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v24
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-23.11
|
||||
- uses: cachix/cachix-action@v13
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||
nix_path: nixpkgs=channel:nixos-24.05
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: lnbits
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: release-rc
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*-rc[0-9]"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --prerelease
|
||||
|
||||
docker:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
@@ -39,24 +39,28 @@ dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
test-wallets:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/wallets
|
||||
|
||||
test-unit:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/unit
|
||||
|
||||
test-api:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/api
|
||||
|
||||
test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/regtest
|
||||
|
||||
@@ -11,13 +11,16 @@ Thanks for contributing :)
|
||||
|
||||
# Run
|
||||
|
||||
This starts the lnbits uvicorn server
|
||||
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
|
||||
guide to install poetry and other dependencies.
|
||||
|
||||
Then you can start LNbits uvicorn server with:
|
||||
|
||||
```bash
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
This starts the lnbits uvicorn with hot reloading.
|
||||
Or you can use the following to start uvicorn with hot reloading enabled:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
@@ -25,6 +28,15 @@ make dev
|
||||
poetry run lnbits --reload
|
||||
```
|
||||
|
||||
You might need the following extra dependencies on clean installation of Debian:
|
||||
|
||||
```
|
||||
sudo apt install nodejs
|
||||
sudo apt install npm
|
||||
npm install
|
||||
sudo apt-get install autoconf libtool libpg-dev
|
||||
```
|
||||
|
||||
# Precommit hooks
|
||||
|
||||
This ensures that all commits adhere to the formatting and linting rules.
|
||||
|
||||
+20
-18
@@ -10,22 +10,17 @@ The following sections explain how to install LNbits using varions package manag
|
||||
|
||||
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
|
||||
|
||||
## Option 1 (recommended): poetry
|
||||
## Option 1 (recommended): Poetry
|
||||
|
||||
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
|
||||
Make sure you have Python 3.9 or 3.10 installed.
|
||||
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
|
||||
|
||||
### install python on ubuntu
|
||||
### Verify Python version
|
||||
|
||||
```sh
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### install poetry
|
||||
### Install Poetry
|
||||
|
||||
```sh
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
@@ -38,13 +33,8 @@ git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
git checkout main
|
||||
|
||||
# Next command, you can exchange with python3.10 or newer versions.
|
||||
# Identify your version with python3 --version and specify in the next line
|
||||
# command is only needed when your default python is not ^3.9 or ^3.10
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
# set funding source amongst other options
|
||||
nano .env
|
||||
@@ -70,7 +60,19 @@ poetry install --only main
|
||||
# Start LNbits with `poetry run lnbits`
|
||||
```
|
||||
|
||||
## Option 2: Nix
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
|
||||
```sh
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
||||
chmod +x lnbits.sh &&
|
||||
./lnbits.sh
|
||||
```
|
||||
|
||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||
|
||||
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
||||
|
||||
## Option 3: Nix
|
||||
|
||||
```sh
|
||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||
@@ -108,7 +110,7 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
```
|
||||
|
||||
## Option 3: Docker
|
||||
## Option 4: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
|
||||
@@ -130,7 +132,7 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
## Option 4: Fly.io
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
|
||||
|
||||
@@ -93,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### Boltz
|
||||
|
||||
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
|
||||
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
|
||||
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
|
||||
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
|
||||
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
|
||||
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
|
||||
- `BOLTZ_CLIENT_WALLET`: lnbits
|
||||
|
||||
### ZBD
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||
@@ -110,6 +122,26 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
|
||||
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
|
||||
|
||||
### Breez SDK
|
||||
|
||||
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
|
||||
- `BREEZ_API_KEY`: ...
|
||||
- `BREEZ_GREENLIGHT_SEED`: ...
|
||||
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
### Nostr Wallet Connect (NWC)
|
||||
|
||||
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
|
||||
|
||||
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
|
||||
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
|
||||
|
||||
Generated
+16
-16
@@ -5,11 +5,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698974481,
|
||||
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
|
||||
"lastModified": 1703863825,
|
||||
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
|
||||
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,16 +41,16 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1702233072,
|
||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||
"lastModified": 1723938990,
|
||||
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-23.11",
|
||||
"ref": "nixos-24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -66,11 +66,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1702334837,
|
||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||
"lastModified": 1724134185,
|
||||
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -122,11 +122,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1699786194,
|
||||
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
|
||||
"lastModified": 1719749022,
|
||||
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
|
||||
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -33,22 +33,6 @@
|
||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
|
||||
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
dnspython = prev.dnspython.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.hatchling ]; }
|
||||
);
|
||||
jinja2 = prev.jinja2.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.flit-core ]; }
|
||||
);
|
||||
pytest-md = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
types-mock = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check install has not already run
|
||||
if [ ! -d lnbits/data ]; then
|
||||
|
||||
# Update package list and install prerequisites non-interactively
|
||||
sudo apt update -y
|
||||
sudo apt install -y software-properties-common
|
||||
|
||||
# Add the deadsnakes PPA repository non-interactively
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
|
||||
# Install Python 3.9 and distutils non-interactively
|
||||
sudo apt install -y python3.9 python3.9-distutils
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3.9 -
|
||||
|
||||
# Add Poetry to PATH for the current session
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
if [ ! -d lnbits/wallets ]; then
|
||||
# Clone the LNbits repository
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone the repository ... FAIL"
|
||||
exit 1
|
||||
fi
|
||||
# Ensure we are in the lnbits directory
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
git checkout main
|
||||
# Make data folder
|
||||
mkdir data
|
||||
|
||||
# Copy the .env.example to .env
|
||||
cp .env.example .env
|
||||
|
||||
elif [ ! -d lnbits/wallets ]; then
|
||||
# cd into lnbits
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
# Install the dependencies using Poetry
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
# Set environment variables for LNbits
|
||||
export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
|
||||
# Run LNbits
|
||||
poetry run lnbits
|
||||
+14
-12
@@ -63,6 +63,7 @@ from .middleware import (
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
create_task,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
)
|
||||
@@ -93,13 +94,8 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
await check_installed_extensions(app)
|
||||
register_all_ext_routes(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
register_async_tasks(app)
|
||||
|
||||
|
||||
async def shutdown():
|
||||
@@ -210,7 +206,7 @@ async def check_funding_source() -> None:
|
||||
break
|
||||
|
||||
retry_counter += 1
|
||||
sleep_time = 0.25 * (2**retry_counter)
|
||||
sleep_time = min(0.25 * (2**retry_counter), 60)
|
||||
logger.warning(
|
||||
f"Retrying connection to backend in {sleep_time} seconds... "
|
||||
f"({retry_counter}/{max_retries})"
|
||||
@@ -399,22 +395,28 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
def register_all_ext_routes(app: FastAPI):
|
||||
async def check_and_register_extensions(app: FastAPI):
|
||||
await check_installed_extensions(app)
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
|
||||
|
||||
def register_async_tasks():
|
||||
def register_async_tasks(app: FastAPI):
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
create_task(check_and_register_extensions(app))
|
||||
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue = asyncio.Queue(5)
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue(5)
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
|
||||
+18
-68
@@ -12,18 +12,8 @@ from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import (
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
delete_wallet_by_id,
|
||||
@@ -35,14 +25,20 @@ from .core.crud import (
|
||||
remove_deleted_wallets,
|
||||
update_payment_status,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .extension_manager import (
|
||||
from lnbits.core.helpers import migrate_databases
|
||||
from lnbits.core.models import Payment, PaymentState, User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
|
||||
def coro(f):
|
||||
@@ -122,48 +118,6 @@ def database_migrate():
|
||||
loop.run_until_complete(migrate_databases())
|
||||
|
||||
|
||||
async def db_migrate():
|
||||
task = asyncio.create_task(migrate_databases())
|
||||
await task
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
# todo: revisit, use installed extensions
|
||||
for ext in get_valid_extensions(False):
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
@db.command("versions")
|
||||
@coro
|
||||
async def db_versions():
|
||||
@@ -216,10 +170,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
||||
@db.command("mark-payment-pending")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_revert_payment(checking_id: str, pending: bool = True):
|
||||
"""Mark wallet as deleted"""
|
||||
async def database_revert_payment(checking_id: str):
|
||||
"""Mark payment as pending"""
|
||||
async with core_db.connect() as conn:
|
||||
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
|
||||
await update_payment_status(
|
||||
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
|
||||
)
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@@ -313,12 +269,6 @@ async def check_invalid_payments(
|
||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_installed_extensions(active=False)
|
||||
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
|
||||
|
||||
|
||||
@extensions.command("list")
|
||||
@coro
|
||||
async def extensions_list():
|
||||
@@ -694,7 +644,7 @@ async def _can_run_operation(url) -> bool:
|
||||
elif url:
|
||||
click.echo(
|
||||
"The option '--url' has been provided,"
|
||||
+ f" but no server found runnint at '{url}'"
|
||||
f" but no server found running at '{url}'"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -38,3 +38,6 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
+25
-33
@@ -8,6 +8,7 @@ import shortuuid
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentState
|
||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
|
||||
from lnbits.extension_manager import (
|
||||
InstallableExtension,
|
||||
@@ -738,7 +739,7 @@ async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: in
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = false
|
||||
WHERE status = '{PaymentState.SUCCESS}'
|
||||
AND extra LIKE ?
|
||||
AND extra LIKE ?
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
@@ -782,9 +783,11 @@ async def get_payments_paginated(
|
||||
if complete and pending:
|
||||
pass
|
||||
elif complete:
|
||||
clause.append("((amount > 0 AND pending = false) OR amount < 0)")
|
||||
clause.append(
|
||||
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
|
||||
)
|
||||
elif pending:
|
||||
clause.append("pending = true")
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
else:
|
||||
pass
|
||||
|
||||
@@ -857,7 +860,7 @@ async def delete_expired_invoices(
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = true AND amount > 0
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
|
||||
"""
|
||||
)
|
||||
@@ -865,7 +868,7 @@ async def delete_expired_invoices(
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = true AND amount > 0
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
AND expiry < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
@@ -884,9 +887,9 @@ async def create_payment(
|
||||
amount: int,
|
||||
memo: str,
|
||||
fee: int = 0,
|
||||
status: PaymentState = PaymentState.PENDING,
|
||||
preimage: Optional[str] = None,
|
||||
expiry: Optional[datetime.datetime] = None,
|
||||
pending: bool = True,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
@@ -900,8 +903,8 @@ async def create_payment(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra, webhook, expiry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
amount, status, memo, fee, extra, webhook, expiry, pending)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
@@ -910,7 +913,7 @@ async def create_payment(
|
||||
payment_hash,
|
||||
preimage,
|
||||
amount,
|
||||
pending,
|
||||
status.value,
|
||||
memo,
|
||||
fee,
|
||||
(
|
||||
@@ -920,6 +923,7 @@ async def create_payment(
|
||||
),
|
||||
webhook,
|
||||
db.datetime_to_timestamp(expiry) if expiry else None,
|
||||
False, # TODO: remove this in next release
|
||||
),
|
||||
)
|
||||
|
||||
@@ -930,17 +934,17 @@ async def create_payment(
|
||||
|
||||
|
||||
async def update_payment_status(
|
||||
checking_id: str, pending: bool, conn: Optional[Connection] = None
|
||||
checking_id: str, status: PaymentState, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
|
||||
(pending, checking_id),
|
||||
"UPDATE apipayments SET status = ? WHERE checking_id = ?",
|
||||
(status.value, checking_id),
|
||||
)
|
||||
|
||||
|
||||
async def update_payment_details(
|
||||
checking_id: str,
|
||||
pending: Optional[bool] = None,
|
||||
status: Optional[PaymentState] = None,
|
||||
fee: Optional[int] = None,
|
||||
preimage: Optional[str] = None,
|
||||
new_checking_id: Optional[str] = None,
|
||||
@@ -952,9 +956,9 @@ async def update_payment_details(
|
||||
if new_checking_id is not None:
|
||||
set_clause.append("checking_id = ?")
|
||||
set_variables.append(new_checking_id)
|
||||
if pending is not None:
|
||||
set_clause.append("pending = ?")
|
||||
set_variables.append(pending)
|
||||
if status is not None:
|
||||
set_clause.append("status = ?")
|
||||
set_variables.append(status.value)
|
||||
if fee is not None:
|
||||
set_clause.append("fee = ?")
|
||||
set_variables.append(fee)
|
||||
@@ -968,7 +972,6 @@ async def update_payment_details(
|
||||
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
|
||||
tuple(set_variables),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def update_payment_extra(
|
||||
@@ -1000,16 +1003,6 @@ async def update_payment_extra(
|
||||
)
|
||||
|
||||
|
||||
async def update_pending_payments(wallet_id: str):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
await payment.check_status()
|
||||
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
@@ -1025,7 +1018,7 @@ async def get_payments_history(
|
||||
) -> List[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
where = ["(pending = False OR amount < 0)"]
|
||||
where = [f"(status = '{PaymentState.SUCCESS}' OR amount < 0)"]
|
||||
values = []
|
||||
if wallet_id:
|
||||
where.append("wallet = ?")
|
||||
@@ -1090,9 +1083,9 @@ async def check_internal(
|
||||
otherwise None
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
f"""
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
WHERE hash = ? AND status = '{PaymentState.PENDING}' AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
@@ -1111,15 +1104,14 @@ async def check_internal_pending(
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT pending FROM apipayments
|
||||
SELECT status FROM apipayments
|
||||
WHERE hash = ? AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return True
|
||||
else:
|
||||
return row["pending"]
|
||||
return row["status"] == PaymentState.PENDING.value
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
|
||||
+54
-4
@@ -6,13 +6,20 @@ from uuid import UUID
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import migrations as core_migrations
|
||||
from lnbits.core.crud import (
|
||||
get_dbversions,
|
||||
get_installed_extensions,
|
||||
update_migration_version,
|
||||
)
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.extension_manager import Extension
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.extension_manager import (
|
||||
Extension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .crud import update_migration_version
|
||||
|
||||
|
||||
async def migrate_extension_database(ext: Extension, current_version):
|
||||
try:
|
||||
@@ -119,3 +126,46 @@ def to_valid_user_id(user_id: str) -> UUID:
|
||||
raise ValueError("Invalid hex string for User ID.") from exc
|
||||
|
||||
return UUID(hex=user_id[:32], version=4)
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_installed_extensions(active=False)
|
||||
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
# todo: revisit, use installed extensions
|
||||
for ext in get_valid_extensions(False):
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
@@ -520,3 +520,31 @@ async def m020_add_column_column_to_user_extensions(db):
|
||||
Adds extra column to user extensions.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
|
||||
|
||||
|
||||
async def m021_add_success_failed_to_apipayments(db):
|
||||
"""
|
||||
Adds success and failed columns to apipayments.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
|
||||
# set all not pending to success true, failed payments were deleted until now
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
FROM wallets
|
||||
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
|
||||
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
|
||||
AND (
|
||||
(apipayments.status = 'success' AND apipayments.amount > 0)
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
# TODO: drop column in next release
|
||||
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
|
||||
|
||||
+50
-79
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -6,19 +8,22 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Callable, Dict, List, Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from fastapi import Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||
from lnbits.db import FilterModel, FromRowModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
|
||||
from lnbits.wallets.base import (
|
||||
PaymentPendingStatus,
|
||||
PaymentStatus,
|
||||
)
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
@@ -62,7 +67,7 @@ class Wallet(BaseWallet):
|
||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||
)
|
||||
|
||||
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
|
||||
from .crud import get_standalone_payment
|
||||
|
||||
return await get_standalone_payment(payment_hash)
|
||||
@@ -132,8 +137,8 @@ class User(BaseModel):
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
extensions: List[str] = []
|
||||
wallets: List[Wallet] = []
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
has_password: bool = False
|
||||
@@ -142,10 +147,10 @@ class User(BaseModel):
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
def wallet_ids(self) -> list[str]:
|
||||
return [wallet.id for wallet in self.wallets]
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
|
||||
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
|
||||
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
|
||||
return w[0] if w else None
|
||||
|
||||
@@ -197,9 +202,20 @@ class LoginUsernamePassword(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PaymentState(str, Enum):
|
||||
PENDING = "pending"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class Payment(FromRowModel):
|
||||
checking_id: str
|
||||
status: str
|
||||
# TODO should be removed in the future, backward compatibility
|
||||
pending: bool
|
||||
checking_id: str
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
@@ -208,11 +224,19 @@ class Payment(FromRowModel):
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[float]
|
||||
extra: Dict = {}
|
||||
extra: dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.status == PaymentState.SUCCESS.value
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.status == PaymentState.FAILED.value
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(
|
||||
@@ -221,7 +245,9 @@ class Payment(FromRowModel):
|
||||
bolt11=row["bolt11"] or "",
|
||||
preimage=row["preimage"] or "0" * 64,
|
||||
extra=json.loads(row["extra"] or "{}"),
|
||||
pending=row["pending"],
|
||||
status=row["status"],
|
||||
# TODO should be removed in the future, backward compatibility
|
||||
pending=row["status"] == PaymentState.PENDING.value,
|
||||
amount=row["amount"],
|
||||
fee=row["fee"],
|
||||
memo=row["memo"],
|
||||
@@ -262,80 +288,16 @@ class Payment(FromRowModel):
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
status: PaymentStatus,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
from .crud import update_payment_details
|
||||
|
||||
await update_payment_details(
|
||||
checking_id=self.checking_id,
|
||||
pending=status.pending,
|
||||
fee=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
self.pending = pending
|
||||
|
||||
await update_payment_status(self.checking_id, pending)
|
||||
|
||||
async def check_status(
|
||||
self,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||
f"pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
funding_source = get_funding_source()
|
||||
if self.is_out:
|
||||
status = await funding_source.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
if self.is_in and status.pending and self.is_expired and self.expiry:
|
||||
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
||||
logger.debug(
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: "
|
||||
f"expired {expiration_date}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
# wait at least 15 minutes before deleting failed outgoing payments
|
||||
elif self.is_out and status.failed:
|
||||
if self.time + 900 < int(time.time()):
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Tried to delete outgoing payment {self.checking_id}: "
|
||||
"skipping because it's not old enough"
|
||||
)
|
||||
elif not status.pending:
|
||||
logger.info(
|
||||
f"Marking '{'in' if self.is_in else 'out'}' "
|
||||
f"{self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.update_status(status, conn=conn)
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_wallet_payment
|
||||
|
||||
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount"]
|
||||
@@ -349,7 +311,7 @@ class PaymentFilters(FilterModel):
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Dict = {}
|
||||
extra: dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
@@ -395,6 +357,7 @@ class Callback(BaseModel):
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
filter_fields: Optional[list[str]] = []
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
@@ -420,6 +383,14 @@ class CreateInvoice(BaseModel):
|
||||
bolt11: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
def unit_is_from_allowed_currencies(cls, v):
|
||||
if v != "sat" and v not in allowed_currencies():
|
||||
raise ValueError("The provided unit is not supported")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
id: str
|
||||
|
||||
+101
-45
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from bolt11 import MilliSatoshi
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
@@ -24,6 +25,7 @@ from lnbits.decorators import (
|
||||
check_user_extension_access,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
@@ -50,7 +52,6 @@ from .crud import (
|
||||
create_admin_settings,
|
||||
create_payment,
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username,
|
||||
@@ -67,19 +68,7 @@ from .crud import (
|
||||
update_user_extension,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import BalanceDelta, Payment, User, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class InvoiceError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
@@ -283,32 +272,19 @@ async def pay_invoice(
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0 + abs(fee_reserve_total_msat),
|
||||
pending=False,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=False
|
||||
new_payment = await _create_external_payment(
|
||||
temp_id, invoice.amount_msat, conn=conn, **payment_kwargs
|
||||
)
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"could not create temporary payment: {exc}")
|
||||
# happens if the same wallet tries to pay an invoice twice
|
||||
raise PaymentError("Could not make payment.", status="failed") from exc
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
|
||||
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
|
||||
|
||||
if extra and "tag" in extra:
|
||||
@@ -325,7 +301,9 @@ async def pay_invoice(
|
||||
# the payer has enough to deduct from
|
||||
async with db.connect() as conn:
|
||||
await update_payment_status(
|
||||
checking_id=internal_checking_id, pending=False, conn=conn
|
||||
checking_id=internal_checking_id,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
await send_payment_notification(wallet, new_payment)
|
||||
|
||||
@@ -350,15 +328,18 @@ async def pay_invoice(
|
||||
f" {payment.checking_id})"
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
logger.debug(f"backend: pay_invoice response {payment}")
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
|
||||
if payment.checking_id and payment.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {temp_id}")
|
||||
async with db.connect() as conn:
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok is not True,
|
||||
status=(
|
||||
PaymentState.SUCCESS
|
||||
if payment.ok is True
|
||||
else PaymentState.PENDING
|
||||
),
|
||||
fee=-(
|
||||
abs(payment.fee_msat if payment.fee_msat else 0)
|
||||
+ abs(service_fee_msat)
|
||||
@@ -373,13 +354,16 @@ async def pay_invoice(
|
||||
)
|
||||
if wallet and updated:
|
||||
await send_payment_notification(wallet, updated)
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
logger.success(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok is False:
|
||||
# payment failed
|
||||
logger.warning("backend sent payment failure")
|
||||
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
|
||||
async with db.connect() as conn:
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
|
||||
await update_payment_status(
|
||||
checking_id=temp_id,
|
||||
status=PaymentState.FAILED,
|
||||
conn=conn,
|
||||
)
|
||||
raise PaymentError(
|
||||
f"Payment failed: {payment.error_message}"
|
||||
or "Payment failed, but backend didn't give us an error message.",
|
||||
@@ -401,11 +385,62 @@ async def pay_invoice(
|
||||
checking_id="service_fee" + temp_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
pending=False,
|
||||
status=PaymentState.SUCCESS,
|
||||
)
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def _create_external_payment(
|
||||
temp_id: str,
|
||||
amount_msat: MilliSatoshi,
|
||||
conn: Optional[Connection],
|
||||
**payment_kwargs,
|
||||
) -> Payment:
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||
|
||||
# check if there is already a payment with the same checking_id
|
||||
old_payment = await get_standalone_payment(temp_id, conn=conn)
|
||||
if old_payment:
|
||||
# fail on pending payments
|
||||
if old_payment.pending:
|
||||
raise PaymentError("Payment is still pending.", status="pending")
|
||||
if old_payment.success:
|
||||
raise PaymentError("Payment already paid.", status="success")
|
||||
if old_payment.failed:
|
||||
status = await old_payment.check_status()
|
||||
if status.success:
|
||||
# payment was successful on the fundingsource
|
||||
await update_payment_status(
|
||||
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
|
||||
)
|
||||
raise PaymentError(
|
||||
"Failed payment was already paid on the fundingsource.",
|
||||
status="success",
|
||||
)
|
||||
if status.failed:
|
||||
raise PaymentError(
|
||||
"Payment is failed node, retrying is not possible.", status="failed"
|
||||
)
|
||||
# status.pending fall through and try again
|
||||
return old_payment
|
||||
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
return new_payment
|
||||
except Exception as exc:
|
||||
logger.error(f"could not create temporary payment: {exc}")
|
||||
# happens if the same wallet tries to pay an invoice twice
|
||||
raise PaymentError("Could not make payment", status="failed") from exc
|
||||
|
||||
|
||||
def _check_wallet_balance(
|
||||
wallet: Wallet,
|
||||
fee_reserve_total_msat: int,
|
||||
@@ -617,12 +652,11 @@ async def check_transaction_status(
|
||||
)
|
||||
if not payment:
|
||||
return PaymentPendingStatus()
|
||||
if not payment.pending:
|
||||
# note: before, we still checked the status of the payment again
|
||||
|
||||
if payment.status == PaymentState.SUCCESS.value:
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
status: PaymentStatus = await payment.check_status()
|
||||
return status
|
||||
return await payment.check_status()
|
||||
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to
|
||||
@@ -680,7 +714,9 @@ async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
async with db.connect() as conn:
|
||||
checking_id = await check_internal(payment_hash, conn=conn)
|
||||
assert checking_id, "newly created checking_id cannot be retrieved"
|
||||
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
|
||||
await update_payment_status(
|
||||
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
|
||||
)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
@@ -856,3 +892,23 @@ async def get_balance_delta() -> BalanceDelta:
|
||||
lnbits_balance_msats=lnbits_balance,
|
||||
node_balance_msats=status.balance_msat,
|
||||
)
|
||||
|
||||
|
||||
async def update_pending_payments(wallet_id: str):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
await update_payment_status(
|
||||
checking_id=payment.checking_id,
|
||||
status=PaymentState.FAILED,
|
||||
)
|
||||
elif status.success:
|
||||
await update_payment_status(
|
||||
checking_id=payment.checking_id,
|
||||
status=PaymentState.SUCCESS,
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="blocked_ip"
|
||||
:label="blocked_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -202,7 +202,7 @@
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="allowed_ip"
|
||||
:label="allowed_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
<q-tab-panel name="server">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
<li
|
||||
v-if="settings.lnbits_data_folder"
|
||||
v-text="'SQlite: ' + settings.lnbits_data_folder"
|
||||
></li>
|
||||
<li
|
||||
v-if="settings.lnbits_database_url"
|
||||
v-text="'Postgres: ' + settings.lnbits_database_url"
|
||||
></li>
|
||||
</ul>
|
||||
<div class="col-md-6">
|
||||
<p>Base URL</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lnbits_baseurl"
|
||||
label="Static/Base url for the server"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-my-none">Currency Settings</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
>
|
||||
<span v-text="user"></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -49,8 +49,8 @@
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
>
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -5,10 +5,77 @@
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
<q-list>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer"
|
||||
@click="copyText('{{ wallet.id }}')"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Admin key: </strong
|
||||
><em
|
||||
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText('{{ wallet.adminkey }}')"
|
||||
></q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Invoice/read key: </strong
|
||||
><em
|
||||
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="inkeyHidden = !inkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText('{{ wallet.inkey }}')"
|
||||
></q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
@@ -109,18 +176,16 @@
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/payments/decode</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"invoice": <string>}</code>
|
||||
<code>{"data": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
|
||||
'{"data": <bolt11/lnurl, string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||
'{"data": <bolt11/lnurl, string>}' -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -373,6 +373,27 @@
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('gradient_background')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleGradient"
|
||||
icon="gradient"
|
||||
size="sm"
|
||||
v-model="gradientChoice"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_gradient')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('toggle_darkmode')"></span>
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="release.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
:label="$t('wallet_required')"
|
||||
@@ -607,6 +608,7 @@
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="selectedExtension.payToEnable.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
@@ -721,6 +723,7 @@
|
||||
dense
|
||||
v-model="selectedExtension.payToEnable.paymentWallet"
|
||||
emit-value
|
||||
map-options
|
||||
:options="g.user.walletOptions"
|
||||
:label="$t('wallet_required')"
|
||||
class="q-mt-sm"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
@@ -484,6 +484,32 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
@@ -519,7 +545,18 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"></div>
|
||||
<div class="col">
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,9 +156,17 @@
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
:options="{ width: 256 }"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
<q-card-actions class="flex-center q-pb-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
:label="$t('copy_wallet_url')"
|
||||
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
@@ -311,13 +319,11 @@
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
|
||||
inputmode="numeric"
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="$t('amount') + ' (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
|
||||
@@ -33,6 +33,7 @@ from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
get_fiat_rate_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
@@ -200,6 +201,12 @@ async def api_perform_lnurlauth(
|
||||
return ""
|
||||
|
||||
|
||||
@api_router.get("/api/v1/rate/{currency}")
|
||||
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
|
||||
rate = await get_fiat_rate_satoshis(currency)
|
||||
return {"rate": rate}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available() -> List[str]:
|
||||
return allowed_currencies()
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Cookie, Depends, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from lnurl import decode as lnurl_decode
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
@@ -20,6 +23,7 @@ from lnbits.wallets import get_funding_source
|
||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
get_dbversions,
|
||||
get_installed_extensions,
|
||||
@@ -71,7 +75,7 @@ async def robots():
|
||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
try:
|
||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
||||
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||
@@ -389,3 +393,53 @@ async def hex_to_uuid4(hex_value: str):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
|
||||
async def lnurlwallet(request: Request):
|
||||
"""
|
||||
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
|
||||
their smartphone camera, or a QR scanner app, they can follow the link provided to
|
||||
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
|
||||
"""
|
||||
|
||||
lightning_param = request.query_params.get("lightning")
|
||||
if not lightning_param:
|
||||
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||
if not settings.lnbits_allow_new_accounts:
|
||||
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
||||
|
||||
lnurl = lnurl_decode(lightning_param)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
||||
res1 = await client.get(lnurl, timeout=2)
|
||||
res1.raise_for_status()
|
||||
data1 = res1.json()
|
||||
if data1.get("tag") != "withdrawRequest":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid lnurl. Expected tag=withdrawRequest",
|
||||
)
|
||||
if not data1.get("maxWithdrawable"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid lnurl. Expected maxWithdrawable",
|
||||
)
|
||||
account = await create_account()
|
||||
wallet = await create_wallet(user_id=account.id)
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data1.get("maxWithdrawable") / 1000,
|
||||
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
|
||||
)
|
||||
url = data1.get("callback")
|
||||
params = {"k1": data1.get("k1"), "pr": payment_request}
|
||||
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
|
||||
|
||||
res2 = await client.get(callback, timeout=2)
|
||||
res2.raise_for_status()
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={account.id}&wal={wallet.id}",
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ from lnbits.decorators import (
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
@@ -52,13 +52,12 @@ from ..crud import (
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..services import (
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
@@ -402,15 +401,8 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
|
||||
if payment.success:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
@@ -424,12 +416,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"paid": payment.success,
|
||||
"status": f"{status!s}",
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
return {"paid": payment.success, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@payment_router.post("/decode", status_code=HTTPStatus.OK)
|
||||
@@ -441,7 +433,8 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return JSONResponse(invoice.data)
|
||||
filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
|
||||
return JSONResponse(filtered_data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {exc!s}"},
|
||||
|
||||
@@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
# TODO: refactor to use PaymentState
|
||||
if payment.success:
|
||||
return {"status": "paid"}
|
||||
|
||||
try:
|
||||
|
||||
@@ -84,10 +84,10 @@ async def api_users_toggle_admin(user_id: str) -> None:
|
||||
settings.lnbits_admin_users.remove(user_id)
|
||||
else:
|
||||
settings.lnbits_admin_users.append(user_id)
|
||||
update_settings = EditableSettings(
|
||||
lnbits_admin_users=settings.lnbits_admin_users
|
||||
)
|
||||
await update_admin_settings(update_settings)
|
||||
update_settings = EditableSettings(
|
||||
lnbits_admin_users=settings.lnbits_admin_users
|
||||
)
|
||||
await update_admin_settings(update_settings)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
|
||||
+10
-4
@@ -65,6 +65,14 @@ def compat_timestamp_placeholder():
|
||||
return "?"
|
||||
|
||||
|
||||
def get_placeholder(model: Any, field: str) -> str:
|
||||
type_ = model.__fields__[field].type_
|
||||
if type_ == datetime.datetime:
|
||||
return compat_timestamp_placeholder()
|
||||
else:
|
||||
return "?"
|
||||
|
||||
|
||||
class Compat:
|
||||
type: Optional[str] = "<inherited>"
|
||||
schema: Optional[str] = "<inherited>"
|
||||
@@ -422,10 +430,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
@property
|
||||
def statement(self):
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
|
||||
placeholder = compat_timestamp_placeholder()
|
||||
else:
|
||||
placeholder = "?"
|
||||
assert self.model, "Model is required for statement generation"
|
||||
placeholder = get_placeholder(self.model, self.field)
|
||||
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
||||
placeholders = ", ".join([placeholder] * len(self.values))
|
||||
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Optional, Type, Union
|
||||
|
||||
import jwt
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||
from fastapi.security.base import SecurityBase
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
@@ -256,7 +256,7 @@ async def _check_user_extension_access(user_id: str, current_path: str):
|
||||
|
||||
async def _get_account_from_token(access_token):
|
||||
try:
|
||||
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
|
||||
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||
if "sub" in payload and payload.get("sub"):
|
||||
return await get_account_by_username(str(payload.get("sub")))
|
||||
if "usr" in payload and payload.get("usr"):
|
||||
@@ -265,10 +265,10 @@ async def _get_account_from_token(access_token):
|
||||
return await get_account_by_email(str(payload.get("email")))
|
||||
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
||||
except ExpiredSignatureError as exc:
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||
) from exc
|
||||
except JWTError as exc:
|
||||
except jwt.PyJWTError as exc:
|
||||
logger.debug(exc)
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
||||
|
||||
+14
-4
@@ -8,11 +8,21 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import InvoiceError, PaymentError
|
||||
|
||||
from .helpers import template_renderer
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class InvoiceError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
register_exception_handler(app)
|
||||
register_request_validation_exception_handler(app)
|
||||
@@ -90,7 +100,7 @@ def register_http_exception_handler(app: FastAPI):
|
||||
def register_payment_error_handler(app: FastAPI):
|
||||
@app.exception_handler(PaymentError)
|
||||
async def payment_error_handler(request: Request, exc: PaymentError):
|
||||
logger.error(f"PaymentError: {exc.message}, {exc.status}")
|
||||
logger.error(f"{exc.message}, {exc.status}")
|
||||
return JSONResponse(
|
||||
status_code=520,
|
||||
content={"detail": exc.message, "status": exc.status},
|
||||
@@ -100,7 +110,7 @@ def register_payment_error_handler(app: FastAPI):
|
||||
def register_invoice_error_handler(app: FastAPI):
|
||||
@app.exception_handler(InvoiceError)
|
||||
async def invoice_error_handler(request: Request, exc: InvoiceError):
|
||||
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}")
|
||||
logger.error(f"{exc.message}, Status: {exc.status}")
|
||||
return JSONResponse(
|
||||
status_code=520,
|
||||
content={"detail": exc.message, "status": exc.status},
|
||||
|
||||
+19
-4
@@ -5,11 +5,12 @@ from pathlib import Path
|
||||
from typing import Any, List, Optional, Type
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from jose import jwt
|
||||
from pydantic import BaseModel
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.db import get_placeholder
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
@@ -178,9 +179,12 @@ def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
"""
|
||||
placeholders = ", ".join(["?"] * len(model.dict().keys()))
|
||||
placeholders = []
|
||||
for field in model.dict().keys():
|
||||
placeholders.append(get_placeholder(model, field))
|
||||
fields = ", ".join(model.dict().keys())
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
|
||||
values = ", ".join(placeholders)
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
|
||||
|
||||
|
||||
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
|
||||
@@ -190,7 +194,11 @@ def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?")
|
||||
:param model: Pydantic model
|
||||
:param where: Where string, default to `WHERE id = ?`
|
||||
"""
|
||||
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
|
||||
fields = []
|
||||
for field in model.dict().keys():
|
||||
placeholder = get_placeholder(model, field)
|
||||
fields.append(f"{field} = {placeholder}")
|
||||
query = ", ".join(fields)
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
|
||||
|
||||
@@ -223,3 +231,10 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||
|
||||
|
||||
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
|
||||
if not filter_keys:
|
||||
# return shallow clone of the dict even if there are no filters
|
||||
return {**data}
|
||||
return {key: data[key] for key in filter_keys if key in data}
|
||||
|
||||
+65
-1
@@ -1 +1,65 @@
|
||||
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from lnurl import LnurlErrorResponse, decode, encode, handle
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
|
||||
|
||||
class LnurlErrorResponseHandler(APIRoute):
|
||||
"""
|
||||
Custom APIRoute class to handle LNURL errors.
|
||||
LNURL errors always return with status 200 and
|
||||
a JSON response with `status="ERROR"` and a `reason` key.
|
||||
Helps to catch HTTPException and return a valid lnurl error response
|
||||
|
||||
Example:
|
||||
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
|
||||
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
|
||||
"""
|
||||
|
||||
def get_route_handler(self) -> Callable:
|
||||
original_route_handler = super().get_route_handler()
|
||||
|
||||
async def lnurl_route_handler(request: Request) -> Response:
|
||||
try:
|
||||
response = await original_route_handler(request)
|
||||
return response
|
||||
except (InvoiceError, PaymentError) as exc:
|
||||
logger.debug(f"Wallet Error: {exc}")
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "ERROR", "reason": f"{exc.message}"},
|
||||
)
|
||||
return response
|
||||
except HTTPException as exc:
|
||||
logger.debug(f"HTTPException: {exc}")
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "ERROR", "reason": f"{exc.detail}"},
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error("Unknown Error:", exc)
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": "ERROR",
|
||||
"reason": f"UNKNOWN ERROR: {exc!s}",
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
return lnurl_route_handler
|
||||
|
||||
|
||||
__all__ = [
|
||||
"decode",
|
||||
"encode",
|
||||
"handle",
|
||||
"LnurlErrorResponse",
|
||||
"LnurlErrorResponseHandler",
|
||||
]
|
||||
|
||||
@@ -214,8 +214,6 @@ def add_ip_block_middleware(app: FastAPI):
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(block_allow_ip_middleware)
|
||||
|
||||
|
||||
def add_first_install_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
@@ -228,5 +226,3 @@ def add_first_install_middleware(app: FastAPI):
|
||||
):
|
||||
return RedirectResponse("/first_install")
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(first_install_middleware)
|
||||
|
||||
@@ -258,6 +258,25 @@ class LnTipsFundingSource(LNbitsSettings):
|
||||
lntips_invoice_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class NWCFundingSource(LNbitsSettings):
|
||||
nwc_pairing_url: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class BreezSdkFundingSource(LNbitsSettings):
|
||||
breez_api_key: Optional[str] = Field(default=None)
|
||||
breez_greenlight_seed: Optional[str] = Field(default=None)
|
||||
breez_greenlight_invite_code: Optional[str] = Field(default=None)
|
||||
breez_greenlight_device_key: Optional[str] = Field(default=None)
|
||||
breez_greenlight_device_cert: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: Optional[str] = Field(default=None)
|
||||
boltz_client_wallet: Optional[str] = Field(default="lnbits")
|
||||
boltz_client_cert: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600)
|
||||
|
||||
@@ -274,11 +293,14 @@ class FundingSourcesSettings(
|
||||
LnPayFundingSource,
|
||||
BlinkFundingSource,
|
||||
AlbyFundingSource,
|
||||
BoltzFundingSource,
|
||||
ZBDFundingSource,
|
||||
PhoenixdFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
|
||||
@@ -422,7 +444,9 @@ class SuperUserSettings(LNbitsSettings):
|
||||
lnbits_allowed_funding_sources: list[str] = Field(
|
||||
default=[
|
||||
"AlbyWallet",
|
||||
"BoltzWallet",
|
||||
"BlinkWallet",
|
||||
"BreezSdkWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
@@ -436,6 +460,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"PhoenixdWallet",
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
"NWCWallet",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
Vendored
+11
-11
File diff suppressed because one or more lines are too long
@@ -241,6 +241,8 @@ window.localisation.en = {
|
||||
back: 'Back',
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
toggle_gradient: 'Toggle Gradient',
|
||||
gradient_background: 'Gradient Background',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme',
|
||||
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
|
||||
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)" style="fill:none">
|
||||
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#fff"/>
|
||||
</g>
|
||||
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)" style="fill:none">
|
||||
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
|
||||
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)">
|
||||
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#000"/>
|
||||
</g>
|
||||
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)">
|
||||
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#000"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 469 B |
Binary file not shown.
|
After Width: | Height: | Size: 492 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -32,6 +32,42 @@ new Vue({
|
||||
toggleDarkMode: function () {
|
||||
this.$q.dark.toggle()
|
||||
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
|
||||
if (!this.$q.dark.isActive && this.gradientChoice) {
|
||||
this.toggleGradient()
|
||||
}
|
||||
},
|
||||
applyGradient: function () {
|
||||
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
|
||||
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
|
||||
if (this.gradientChoice) {
|
||||
if (!this.$q.dark.isActive) {
|
||||
this.toggleDarkMode()
|
||||
}
|
||||
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
|
||||
document.body.style.setProperty(
|
||||
'background-image',
|
||||
gradientStyle,
|
||||
'important'
|
||||
)
|
||||
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML =
|
||||
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
|
||||
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
|
||||
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
|
||||
document.head.appendChild(style)
|
||||
this.$q.localStorage.set('lnbits.gradientBg', true)
|
||||
} else {
|
||||
this.$q.localStorage.set('lnbits.gradientBg', false)
|
||||
}
|
||||
},
|
||||
toggleGradient: function () {
|
||||
this.gradientChoice = !this.gradientChoice
|
||||
this.applyGradient()
|
||||
if (!this.gradientChoice) {
|
||||
window.location.reload()
|
||||
}
|
||||
this.gradientChoice = this.$q.localStorage.getItem('lnbits.gradientBg')
|
||||
},
|
||||
reactionChoiceFunc: function () {
|
||||
this.$q.localStorage.set('lnbits.reactions', this.reactionChoice)
|
||||
@@ -39,6 +75,24 @@ new Vue({
|
||||
changeColor: function (newValue) {
|
||||
document.body.setAttribute('data-theme', newValue)
|
||||
this.$q.localStorage.set('lnbits.theme', newValue)
|
||||
this.setColors()
|
||||
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
|
||||
this.applyGradient()
|
||||
}
|
||||
},
|
||||
setColors: function () {
|
||||
this.$q.localStorage.set(
|
||||
'lnbits.primaryColor',
|
||||
LNbits.utils.getPaletteColor('primary')
|
||||
)
|
||||
this.$q.localStorage.set(
|
||||
'lnbits.secondaryColor',
|
||||
LNbits.utils.getPaletteColor('secondary')
|
||||
)
|
||||
this.$q.localStorage.set(
|
||||
'lnbits.darkBgColor',
|
||||
LNbits.utils.getPaletteColor('dark')
|
||||
)
|
||||
},
|
||||
updateAccount: async function () {
|
||||
try {
|
||||
@@ -118,5 +172,8 @@ new Vue({
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
|
||||
this.applyGradient()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -247,7 +247,7 @@ window.LNbits = {
|
||||
payment: function (data) {
|
||||
obj = {
|
||||
checking_id: data.checking_id,
|
||||
pending: data.pending,
|
||||
status: data.status,
|
||||
amount: data.amount,
|
||||
fee: data.fee,
|
||||
memo: data.memo,
|
||||
@@ -280,8 +280,15 @@ window.LNbits = {
|
||||
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
|
||||
obj.isIn = obj.amount > 0
|
||||
obj.isOut = obj.amount < 0
|
||||
obj.isPaid = !obj.pending
|
||||
obj.isPending = obj.status === 'pending'
|
||||
obj.isPaid = obj.status === 'success'
|
||||
obj.isFailed = obj.status === 'failed'
|
||||
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
|
||||
try {
|
||||
obj.details = JSON.parse(data.extra?.details || '{}')
|
||||
} catch {
|
||||
obj.details = {extraDetails: data.extra?.details}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
},
|
||||
@@ -321,6 +328,7 @@ window.LNbits = {
|
||||
return this.formatSat(value / 1000)
|
||||
},
|
||||
notifyApiError: function (error) {
|
||||
console.error(error)
|
||||
var types = {
|
||||
400: 'warning',
|
||||
401: 'warning',
|
||||
@@ -422,6 +430,18 @@ window.LNbits = {
|
||||
converter.setFlavor('github')
|
||||
converter.setOption('simpleLineBreaks', true)
|
||||
return converter.makeHtml(text)
|
||||
},
|
||||
hexToRgb: function (hex) {
|
||||
return Quasar.utils.colors.hexToRgb(hex)
|
||||
},
|
||||
hexDarken: function (hex, percent) {
|
||||
return Quasar.utils.colors.lighten(hex, percent)
|
||||
},
|
||||
hexAlpha: function (hex, alpha) {
|
||||
return Quasar.utils.colors.changeAlpha(hex, alpha)
|
||||
},
|
||||
getPaletteColor: function (color) {
|
||||
return Quasar.utils.colors.getPaletteColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,6 +452,8 @@ window.windowMixin = {
|
||||
return {
|
||||
toggleSubs: true,
|
||||
reactionChoice: 'confettiBothSides',
|
||||
gradientChoice:
|
||||
this.$q.localStorage.getItem('lnbits.gradientBg') || false,
|
||||
isUserAuthorized: false,
|
||||
g: {
|
||||
offline: !navigator.onLine,
|
||||
@@ -451,6 +473,25 @@ window.windowMixin = {
|
||||
document.body.setAttribute('data-theme', newValue)
|
||||
this.$q.localStorage.set('lnbits.theme', newValue)
|
||||
},
|
||||
applyGradient: function () {
|
||||
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
|
||||
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
|
||||
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
|
||||
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
|
||||
document.body.style.setProperty(
|
||||
'background-image',
|
||||
gradientStyle,
|
||||
'important'
|
||||
)
|
||||
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML =
|
||||
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
|
||||
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
|
||||
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
},
|
||||
copyText: function (text, message, position) {
|
||||
var notify = this.$q.notify
|
||||
Quasar.utils.copyToClipboard(text).then(function () {
|
||||
@@ -514,6 +555,8 @@ window.windowMixin = {
|
||||
this.reactionChoice =
|
||||
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
|
||||
|
||||
this.applyGradient()
|
||||
|
||||
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
|
||||
|
||||
let locale = this.$q.localStorage.getItem('lnbits.lang')
|
||||
|
||||
@@ -610,7 +610,8 @@ Vue.component('lnbits-dynamic-fields', {
|
||||
props: ['options', 'value'],
|
||||
data() {
|
||||
return {
|
||||
formData: null
|
||||
formData: null,
|
||||
rules: [val => !!val || 'Field is required']
|
||||
}
|
||||
},
|
||||
|
||||
@@ -619,49 +620,109 @@ Vue.component('lnbits-dynamic-fields', {
|
||||
<div class="row q-mb-lg" v-for="o in options">
|
||||
<div class="col auto-width">
|
||||
<p v-if=o.options?.length class="q-ml-xl">
|
||||
<span v-text="o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
|
||||
<span v-text="o.label || o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
|
||||
</p>
|
||||
<lnbits-dynamic-fields v-if="o.options?.length" :options="o.options" v-model="formData[o.name]"
|
||||
@input="handleValueChanged" class="q-ml-xl">
|
||||
</lnbits-dynamic-fields>
|
||||
<div v-else>
|
||||
<q-input v-if="o.type === 'number'" v-model="formData[o.name]" @input="handleValueChanged" type="number"
|
||||
:label="o.name" :hint="o.description" filled dense>
|
||||
</q-input>
|
||||
<q-input v-else-if="o.type === 'text'" v-model="formData[o.name]" @input="handleValueChanged" type="textarea"
|
||||
rows="5" :label="o.name" :hint="o.description" filled dense>
|
||||
</q-input>
|
||||
<q-input v-else-if="o.type === 'password'" v-model="formData[o.name]" @input="handleValueChanged" type="password"
|
||||
:label="o.name" :hint="o.description" filled dense>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="o.type === 'number'"
|
||||
type="number"
|
||||
v-model="formData[o.name]"
|
||||
@input="handleValueChanged"
|
||||
:label="o.label || o.name"
|
||||
:hint="o.description"
|
||||
:rules="applyRules(o.required)"
|
||||
filled
|
||||
dense
|
||||
></q-input>
|
||||
<q-input
|
||||
v-else-if="o.type === 'text'"
|
||||
type="textarea"
|
||||
rows="5"
|
||||
v-model="formData[o.name]"
|
||||
@input="handleValueChanged"
|
||||
:label="o.label || o.name"
|
||||
:hint="o.description"
|
||||
:rules="applyRules(o.required)"
|
||||
filled
|
||||
dense
|
||||
></q-input>
|
||||
<q-input
|
||||
v-else-if="o.type === 'password'"
|
||||
v-model="formData[o.name]"
|
||||
@input="handleValueChanged"
|
||||
type="password"
|
||||
:label="o.label || o.name"
|
||||
:hint="o.description"
|
||||
:rules="applyRules(o.required)"
|
||||
filled
|
||||
dense
|
||||
></q-input>
|
||||
<q-select
|
||||
v-else-if="o.type === 'select'"
|
||||
v-model="formData[o.name]"
|
||||
@input="handleValueChanged"
|
||||
:label="o.label || o.name"
|
||||
:hint="o.description"
|
||||
:options="o.values"
|
||||
:rules="applyRules(o.required)"
|
||||
></q-select>
|
||||
<q-select
|
||||
v-else-if="o.isList"
|
||||
v-model.trim="formData[o.name]"
|
||||
@input="handleValueChanged"
|
||||
input-debounce="0"
|
||||
new-value-mode="add-unique"
|
||||
:label="o.label || o.name"
|
||||
:hint="o.description"
|
||||
:rules="applyRules(o.required)"
|
||||
filled
|
||||
multiple
|
||||
dense
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
hide-dropdown-icon
|
||||
></q-select>
|
||||
<div v-else-if="o.type === 'bool'">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar top>
|
||||
<q-checkbox v-model="formData[o.name]" @input="handleValueChanged" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label><span v-text="o.name"></span></q-item-label>
|
||||
<q-item-label><span v-text="o.label || o.name"></span></q-item-label>
|
||||
<q-item-label caption> <span v-text="o.description"></span> </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-select v-else-if="o.type === 'select'" v-model="formData[o.name]" @input="handleValueChanged" :label="o.name"
|
||||
:hint="o.description" :options="o.values"></q-select>
|
||||
|
||||
<q-select v-else-if="o.isList" filled multiple dense v-model.trim="formData[o.name]" use-input use-chips
|
||||
@input="handleValueChanged" multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
|
||||
:label="o.name" :hint="o.description">
|
||||
</q-select>
|
||||
<q-input v-else v-model="formData[o.name]" @input="handleValueChanged" :label="o.name" :hint="o.description"
|
||||
filled dense>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-else-if="o.type === 'hidden'"
|
||||
v-model="formData[o.name]"
|
||||
type="text"
|
||||
style="display: none"
|
||||
:rules="applyRules(o.required)"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-else
|
||||
v-model="formData[o.name]"
|
||||
@input="handleValueChanged"
|
||||
:hint="o.description"
|
||||
:label="o.label || o.name"
|
||||
:rules="applyRules(o.required)"
|
||||
filled
|
||||
dense
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
applyRules(required) {
|
||||
return required ? this.rules : []
|
||||
},
|
||||
buildData(options, data = {}) {
|
||||
return options.reduce((d, option) => {
|
||||
if (option.options?.length) {
|
||||
|
||||
@@ -127,6 +127,16 @@ Vue.component('lnbits-funding-sources', {
|
||||
alby_access_token: 'Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
'BoltzWallet',
|
||||
'Boltz',
|
||||
{
|
||||
boltz_client_endpoint: 'Endpoint',
|
||||
boltz_client_macaroon: 'Admin Macaroon path or hex',
|
||||
boltz_client_cert: 'Certificate path or hex',
|
||||
boltz_client_wallet: 'Wallet Name'
|
||||
}
|
||||
],
|
||||
[
|
||||
'ZBDWallet',
|
||||
'ZBD',
|
||||
@@ -165,6 +175,24 @@ Vue.component('lnbits-funding-sources', {
|
||||
spark_url: 'Endpoint',
|
||||
spark_token: 'Token'
|
||||
}
|
||||
],
|
||||
[
|
||||
'NWCWallet',
|
||||
'Nostr Wallet Connect',
|
||||
{
|
||||
nwc_pairing_url: 'Pairing URL'
|
||||
}
|
||||
],
|
||||
[
|
||||
'BreezSdkWallet',
|
||||
'Breez SDK',
|
||||
{
|
||||
breez_api_key: 'Breez API Key',
|
||||
breez_greenlight_seed: 'Greenlight Seed',
|
||||
breez_greenlight_device_key: 'Greenlight Device Key',
|
||||
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
||||
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ Vue.component('payment-list', {
|
||||
search: null,
|
||||
loading: false
|
||||
},
|
||||
exportTagName: '',
|
||||
exportPaymentTagList: [],
|
||||
paymentsCSV: {
|
||||
columns: [
|
||||
{
|
||||
@@ -146,7 +148,7 @@ Vue.component('payment-list', {
|
||||
paymentTableRowKey: function (row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
exportCSV: function () {
|
||||
exportCSV(detailed = false) {
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
@@ -157,14 +159,51 @@ Vue.component('payment-list', {
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
LNbits.api.getPayments(this.wallet, params).then(response => {
|
||||
const payments = response.data.data.map(LNbits.map.payment)
|
||||
let payments = response.data.data.map(LNbits.map.payment)
|
||||
let columns = this.paymentsCSV.columns
|
||||
|
||||
if (detailed) {
|
||||
if (this.exportPaymentTagList.length) {
|
||||
payments = payments.filter(p =>
|
||||
this.exportPaymentTagList.includes(p.tag)
|
||||
)
|
||||
}
|
||||
const extraColumns = Object.keys(
|
||||
payments.reduce((e, p) => ({...e, ...p.details}), {})
|
||||
).map(col => ({
|
||||
name: col,
|
||||
align: 'right',
|
||||
label:
|
||||
col.charAt(0).toUpperCase() +
|
||||
col.slice(1).replace(/([A-Z])/g, ' $1'),
|
||||
field: row => row.details[col],
|
||||
format: data =>
|
||||
typeof data === 'object' ? JSON.stringify(data) : data
|
||||
}))
|
||||
columns = this.paymentsCSV.columns.concat(extraColumns)
|
||||
}
|
||||
|
||||
LNbits.utils.exportCSV(
|
||||
this.paymentsCSV.columns,
|
||||
columns,
|
||||
payments,
|
||||
this.wallet.name + '-payments'
|
||||
)
|
||||
})
|
||||
},
|
||||
addFilterTag: function () {
|
||||
if (!this.exportTagName) return
|
||||
const value = this.exportTagName.trim()
|
||||
this.exportPaymentTagList = this.exportPaymentTagList.filter(
|
||||
v => v !== value
|
||||
)
|
||||
this.exportPaymentTagList.push(value)
|
||||
this.exportTagName = ''
|
||||
},
|
||||
removeExportTag: function (value) {
|
||||
this.exportPaymentTagList = this.exportPaymentTagList.filter(
|
||||
v => v !== value
|
||||
)
|
||||
},
|
||||
formatCurrency: function (amount, currency) {
|
||||
try {
|
||||
return LNbits.utils.formatCurrency(amount, currency)
|
||||
@@ -202,7 +241,59 @@ Vue.component('payment-list', {
|
||||
></h5>
|
||||
</div>
|
||||
<div class="gt-sm col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV" :label="$t('export_csv')" ></q-btn>
|
||||
<q-btn-dropdown
|
||||
outline
|
||||
persistent
|
||||
class="q-mr-sm"
|
||||
color="grey"
|
||||
:label="$t('export_csv')"
|
||||
split
|
||||
@click="exportCSV(false)"
|
||||
>
|
||||
<q-list>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
@keydown.enter="addFilterTag"
|
||||
filled
|
||||
dense
|
||||
v-model="exportTagName"
|
||||
type="text"
|
||||
label="Payment Tags"
|
||||
class="q-pa-sm"
|
||||
>
|
||||
<q-btn
|
||||
@click="addFilterTag"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="exportPaymentTagList.length">
|
||||
<q-item-section>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="tag in exportPaymentTagList"
|
||||
:key="tag"
|
||||
removable
|
||||
@remove="removeExportTag(tag)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="tag"
|
||||
></q-chip>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-btn v-close-popup outline color="grey" @click="exportCSV(true)" label="Export to CSV with details" ></q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<payment-chart :wallet="wallet" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,6 +345,16 @@ Vue.component('payment-list', {
|
||||
:color="props.row.isOut ? 'pink' : 'green'"
|
||||
@click="props.expand = !props.expand"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else-if="props.row.isFailed"
|
||||
name="warning"
|
||||
color="yellow"
|
||||
@click="props.expand = !props.expand"
|
||||
>
|
||||
<q-tooltip
|
||||
><span>failed</span
|
||||
></q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
name="settings_ethernet"
|
||||
@@ -319,7 +420,7 @@ Vue.component('payment-list', {
|
||||
<q-dialog v-model="props.expand" :props="props" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
<div v-if="props.row.isIn && props.row.isPending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
<span v-text="$t('invoice_waiting')"></span>
|
||||
<lnbits-payment-details
|
||||
@@ -353,6 +454,13 @@ Vue.component('payment-list', {
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.isPending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
@@ -375,9 +483,9 @@ Vue.component('payment-list', {
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.pending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<div v-else-if="props.row.isFailed">
|
||||
<q-icon name="warning" color="yellow"></q-icon>
|
||||
<span>Payment failed</span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
|
||||
+62
-61
@@ -56,7 +56,9 @@ new Vue({
|
||||
update: {
|
||||
name: null,
|
||||
currency: null
|
||||
}
|
||||
},
|
||||
inkeyHidden: true,
|
||||
adminkeyHidden: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -209,6 +211,54 @@ new Vue({
|
||||
})
|
||||
}
|
||||
},
|
||||
lnurlScan() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
let data = response.data
|
||||
|
||||
if (data.status === 'ERROR') {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `${data.domain} lnurl call failed.`,
|
||||
caption: data.reason
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.kind === 'pay') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
} else if (data.kind === 'auth') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.kind === 'withdraw') {
|
||||
this.parse.show = false
|
||||
this.receive.show = true
|
||||
this.receive.status = 'pending'
|
||||
this.receive.paymentReq = null
|
||||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = data.maxWithdrawable / 1000
|
||||
this.receive.data.memo = data.defaultDescription
|
||||
this.receive.minMax = [
|
||||
data.minWithdrawable / 1000,
|
||||
data.maxWithdrawable / 1000
|
||||
]
|
||||
this.receive.lnurl = {
|
||||
domain: data.domain,
|
||||
callback: data.callback,
|
||||
fixed: data.fixed
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
this.parse.data.request = res
|
||||
this.decodeRequest()
|
||||
@@ -216,67 +266,18 @@ new Vue({
|
||||
},
|
||||
decodeRequest: function () {
|
||||
this.parse.show = true
|
||||
let req = this.parse.data.request.toLowerCase()
|
||||
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(10)
|
||||
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(6)
|
||||
} else if (req.indexOf('lightning=lnurl1') !== -1) {
|
||||
this.parse.data.request = this.parse.data.request
|
||||
.split('lightning=')[1]
|
||||
.split('&')[0]
|
||||
this.parse.data.request = this.parse.data.request.trim().toLowerCase()
|
||||
let req = this.parse.data.request
|
||||
if (req.startsWith('lightning:')) {
|
||||
this.parse.data.request = req.slice(10)
|
||||
} else if (req.startsWith('lnurl:')) {
|
||||
this.parse.data.request = req.slice(6)
|
||||
} else if (req.includes('lightning=lnurl1')) {
|
||||
this.parse.data.request = req.split('lightning=')[1].split('&')[0]
|
||||
}
|
||||
|
||||
if (
|
||||
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
|
||||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
|
||||
) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
let data = response.data
|
||||
|
||||
if (data.status === 'ERROR') {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `${data.domain} lnurl call failed.`,
|
||||
caption: data.reason
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.kind === 'pay') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
} else if (data.kind === 'auth') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.kind === 'withdraw') {
|
||||
this.parse.show = false
|
||||
this.receive.show = true
|
||||
this.receive.status = 'pending'
|
||||
this.receive.paymentReq = null
|
||||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = data.maxWithdrawable / 1000
|
||||
this.receive.data.memo = data.defaultDescription
|
||||
this.receive.minMax = [
|
||||
data.minWithdrawable / 1000,
|
||||
data.maxWithdrawable / 1000
|
||||
]
|
||||
this.receive.lnurl = {
|
||||
domain: data.domain,
|
||||
callback: data.callback,
|
||||
fixed: data.fixed
|
||||
}
|
||||
}
|
||||
})
|
||||
req = this.parse.data.request
|
||||
if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
|
||||
this.lnurlScan()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1711
-453
File diff suppressed because it is too large
Load Diff
Vendored
+368
-365
@@ -1,5 +1,5 @@
|
||||
//! moment.js
|
||||
//! version : 2.29.4
|
||||
//! version : 2.30.1
|
||||
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
|
||||
//! license : MIT
|
||||
//! momentjs.com
|
||||
@@ -155,24 +155,25 @@
|
||||
}
|
||||
|
||||
function isValid(m) {
|
||||
if (m._isValid == null) {
|
||||
var flags = getParsingFlags(m),
|
||||
parsedParts = some.call(flags.parsedDateParts, function (i) {
|
||||
return i != null;
|
||||
}),
|
||||
isNowValid =
|
||||
!isNaN(m._d.getTime()) &&
|
||||
flags.overflow < 0 &&
|
||||
!flags.empty &&
|
||||
!flags.invalidEra &&
|
||||
!flags.invalidMonth &&
|
||||
!flags.invalidWeekday &&
|
||||
!flags.weekdayMismatch &&
|
||||
!flags.nullInput &&
|
||||
!flags.invalidFormat &&
|
||||
!flags.userInvalidated &&
|
||||
(!flags.meridiem || (flags.meridiem && parsedParts));
|
||||
|
||||
var flags = null,
|
||||
parsedParts = false,
|
||||
isNowValid = m._d && !isNaN(m._d.getTime());
|
||||
if (isNowValid) {
|
||||
flags = getParsingFlags(m);
|
||||
parsedParts = some.call(flags.parsedDateParts, function (i) {
|
||||
return i != null;
|
||||
});
|
||||
isNowValid =
|
||||
flags.overflow < 0 &&
|
||||
!flags.empty &&
|
||||
!flags.invalidEra &&
|
||||
!flags.invalidMonth &&
|
||||
!flags.invalidWeekday &&
|
||||
!flags.weekdayMismatch &&
|
||||
!flags.nullInput &&
|
||||
!flags.invalidFormat &&
|
||||
!flags.userInvalidated &&
|
||||
(!flags.meridiem || (flags.meridiem && parsedParts));
|
||||
if (m._strict) {
|
||||
isNowValid =
|
||||
isNowValid &&
|
||||
@@ -180,12 +181,11 @@
|
||||
flags.unusedTokens.length === 0 &&
|
||||
flags.bigHour === undefined;
|
||||
}
|
||||
|
||||
if (Object.isFrozen == null || !Object.isFrozen(m)) {
|
||||
m._isValid = isNowValid;
|
||||
} else {
|
||||
return isNowValid;
|
||||
}
|
||||
}
|
||||
if (Object.isFrozen == null || !Object.isFrozen(m)) {
|
||||
m._isValid = isNowValid;
|
||||
} else {
|
||||
return isNowValid;
|
||||
}
|
||||
return m._isValid;
|
||||
}
|
||||
@@ -630,12 +630,56 @@
|
||||
return isFunction(format) ? format(output) : format.replace(/%s/i, output);
|
||||
}
|
||||
|
||||
var aliases = {};
|
||||
|
||||
function addUnitAlias(unit, shorthand) {
|
||||
var lowerCase = unit.toLowerCase();
|
||||
aliases[lowerCase] = aliases[lowerCase + 's'] = aliases[shorthand] = unit;
|
||||
}
|
||||
var aliases = {
|
||||
D: 'date',
|
||||
dates: 'date',
|
||||
date: 'date',
|
||||
d: 'day',
|
||||
days: 'day',
|
||||
day: 'day',
|
||||
e: 'weekday',
|
||||
weekdays: 'weekday',
|
||||
weekday: 'weekday',
|
||||
E: 'isoWeekday',
|
||||
isoweekdays: 'isoWeekday',
|
||||
isoweekday: 'isoWeekday',
|
||||
DDD: 'dayOfYear',
|
||||
dayofyears: 'dayOfYear',
|
||||
dayofyear: 'dayOfYear',
|
||||
h: 'hour',
|
||||
hours: 'hour',
|
||||
hour: 'hour',
|
||||
ms: 'millisecond',
|
||||
milliseconds: 'millisecond',
|
||||
millisecond: 'millisecond',
|
||||
m: 'minute',
|
||||
minutes: 'minute',
|
||||
minute: 'minute',
|
||||
M: 'month',
|
||||
months: 'month',
|
||||
month: 'month',
|
||||
Q: 'quarter',
|
||||
quarters: 'quarter',
|
||||
quarter: 'quarter',
|
||||
s: 'second',
|
||||
seconds: 'second',
|
||||
second: 'second',
|
||||
gg: 'weekYear',
|
||||
weekyears: 'weekYear',
|
||||
weekyear: 'weekYear',
|
||||
GG: 'isoWeekYear',
|
||||
isoweekyears: 'isoWeekYear',
|
||||
isoweekyear: 'isoWeekYear',
|
||||
w: 'week',
|
||||
weeks: 'week',
|
||||
week: 'week',
|
||||
W: 'isoWeek',
|
||||
isoweeks: 'isoWeek',
|
||||
isoweek: 'isoWeek',
|
||||
y: 'year',
|
||||
years: 'year',
|
||||
year: 'year',
|
||||
};
|
||||
|
||||
function normalizeUnits(units) {
|
||||
return typeof units === 'string'
|
||||
@@ -660,11 +704,24 @@
|
||||
return normalizedInput;
|
||||
}
|
||||
|
||||
var priorities = {};
|
||||
|
||||
function addUnitPriority(unit, priority) {
|
||||
priorities[unit] = priority;
|
||||
}
|
||||
var priorities = {
|
||||
date: 9,
|
||||
day: 11,
|
||||
weekday: 11,
|
||||
isoWeekday: 11,
|
||||
dayOfYear: 4,
|
||||
hour: 13,
|
||||
millisecond: 16,
|
||||
minute: 14,
|
||||
month: 8,
|
||||
quarter: 7,
|
||||
second: 15,
|
||||
weekYear: 1,
|
||||
isoWeekYear: 1,
|
||||
week: 5,
|
||||
isoWeek: 5,
|
||||
year: 1,
|
||||
};
|
||||
|
||||
function getPrioritizedUnits(unitsObj) {
|
||||
var units = [],
|
||||
@@ -680,96 +737,6 @@
|
||||
return units;
|
||||
}
|
||||
|
||||
function isLeapYear(year) {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
function absFloor(number) {
|
||||
if (number < 0) {
|
||||
// -0 -> 0
|
||||
return Math.ceil(number) || 0;
|
||||
} else {
|
||||
return Math.floor(number);
|
||||
}
|
||||
}
|
||||
|
||||
function toInt(argumentForCoercion) {
|
||||
var coercedNumber = +argumentForCoercion,
|
||||
value = 0;
|
||||
|
||||
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
|
||||
value = absFloor(coercedNumber);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function makeGetSet(unit, keepTime) {
|
||||
return function (value) {
|
||||
if (value != null) {
|
||||
set$1(this, unit, value);
|
||||
hooks.updateOffset(this, keepTime);
|
||||
return this;
|
||||
} else {
|
||||
return get(this, unit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function get(mom, unit) {
|
||||
return mom.isValid()
|
||||
? mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]()
|
||||
: NaN;
|
||||
}
|
||||
|
||||
function set$1(mom, unit, value) {
|
||||
if (mom.isValid() && !isNaN(value)) {
|
||||
if (
|
||||
unit === 'FullYear' &&
|
||||
isLeapYear(mom.year()) &&
|
||||
mom.month() === 1 &&
|
||||
mom.date() === 29
|
||||
) {
|
||||
value = toInt(value);
|
||||
mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](
|
||||
value,
|
||||
mom.month(),
|
||||
daysInMonth(value, mom.month())
|
||||
);
|
||||
} else {
|
||||
mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MOMENTS
|
||||
|
||||
function stringGet(units) {
|
||||
units = normalizeUnits(units);
|
||||
if (isFunction(this[units])) {
|
||||
return this[units]();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
function stringSet(units, value) {
|
||||
if (typeof units === 'object') {
|
||||
units = normalizeObjectUnits(units);
|
||||
var prioritized = getPrioritizedUnits(units),
|
||||
i,
|
||||
prioritizedLen = prioritized.length;
|
||||
for (i = 0; i < prioritizedLen; i++) {
|
||||
this[prioritized[i].unit](units[prioritized[i].unit]);
|
||||
}
|
||||
} else {
|
||||
units = normalizeUnits(units);
|
||||
if (isFunction(this[units])) {
|
||||
return this[units](value);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
var match1 = /\d/, // 0 - 9
|
||||
match2 = /\d\d/, // 00 - 99
|
||||
match3 = /\d{3}/, // 000 - 999
|
||||
@@ -790,6 +757,8 @@
|
||||
// includes scottish gaelic two word and hyphenated months
|
||||
matchWord =
|
||||
/[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i,
|
||||
match1to2NoLeadingZero = /^[1-9]\d?/, // 1-99
|
||||
match1to2HasZero = /^([1-9]\d|\d)/, // 0-99
|
||||
regexes;
|
||||
|
||||
regexes = {};
|
||||
@@ -828,6 +797,26 @@
|
||||
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
function absFloor(number) {
|
||||
if (number < 0) {
|
||||
// -0 -> 0
|
||||
return Math.ceil(number) || 0;
|
||||
} else {
|
||||
return Math.floor(number);
|
||||
}
|
||||
}
|
||||
|
||||
function toInt(argumentForCoercion) {
|
||||
var coercedNumber = +argumentForCoercion,
|
||||
value = 0;
|
||||
|
||||
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
|
||||
value = absFloor(coercedNumber);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
var tokens = {};
|
||||
|
||||
function addParseToken(token, callback) {
|
||||
@@ -861,6 +850,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isLeapYear(year) {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
var YEAR = 0,
|
||||
MONTH = 1,
|
||||
DATE = 2,
|
||||
@@ -871,6 +864,173 @@
|
||||
WEEK = 7,
|
||||
WEEKDAY = 8;
|
||||
|
||||
// FORMATTING
|
||||
|
||||
addFormatToken('Y', 0, 0, function () {
|
||||
var y = this.year();
|
||||
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
|
||||
});
|
||||
|
||||
addFormatToken(0, ['YY', 2], 0, function () {
|
||||
return this.year() % 100;
|
||||
});
|
||||
|
||||
addFormatToken(0, ['YYYY', 4], 0, 'year');
|
||||
addFormatToken(0, ['YYYYY', 5], 0, 'year');
|
||||
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('Y', matchSigned);
|
||||
addRegexToken('YY', match1to2, match2);
|
||||
addRegexToken('YYYY', match1to4, match4);
|
||||
addRegexToken('YYYYY', match1to6, match6);
|
||||
addRegexToken('YYYYYY', match1to6, match6);
|
||||
|
||||
addParseToken(['YYYYY', 'YYYYYY'], YEAR);
|
||||
addParseToken('YYYY', function (input, array) {
|
||||
array[YEAR] =
|
||||
input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input);
|
||||
});
|
||||
addParseToken('YY', function (input, array) {
|
||||
array[YEAR] = hooks.parseTwoDigitYear(input);
|
||||
});
|
||||
addParseToken('Y', function (input, array) {
|
||||
array[YEAR] = parseInt(input, 10);
|
||||
});
|
||||
|
||||
// HELPERS
|
||||
|
||||
function daysInYear(year) {
|
||||
return isLeapYear(year) ? 366 : 365;
|
||||
}
|
||||
|
||||
// HOOKS
|
||||
|
||||
hooks.parseTwoDigitYear = function (input) {
|
||||
return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
|
||||
};
|
||||
|
||||
// MOMENTS
|
||||
|
||||
var getSetYear = makeGetSet('FullYear', true);
|
||||
|
||||
function getIsLeapYear() {
|
||||
return isLeapYear(this.year());
|
||||
}
|
||||
|
||||
function makeGetSet(unit, keepTime) {
|
||||
return function (value) {
|
||||
if (value != null) {
|
||||
set$1(this, unit, value);
|
||||
hooks.updateOffset(this, keepTime);
|
||||
return this;
|
||||
} else {
|
||||
return get(this, unit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function get(mom, unit) {
|
||||
if (!mom.isValid()) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
var d = mom._d,
|
||||
isUTC = mom._isUTC;
|
||||
|
||||
switch (unit) {
|
||||
case 'Milliseconds':
|
||||
return isUTC ? d.getUTCMilliseconds() : d.getMilliseconds();
|
||||
case 'Seconds':
|
||||
return isUTC ? d.getUTCSeconds() : d.getSeconds();
|
||||
case 'Minutes':
|
||||
return isUTC ? d.getUTCMinutes() : d.getMinutes();
|
||||
case 'Hours':
|
||||
return isUTC ? d.getUTCHours() : d.getHours();
|
||||
case 'Date':
|
||||
return isUTC ? d.getUTCDate() : d.getDate();
|
||||
case 'Day':
|
||||
return isUTC ? d.getUTCDay() : d.getDay();
|
||||
case 'Month':
|
||||
return isUTC ? d.getUTCMonth() : d.getMonth();
|
||||
case 'FullYear':
|
||||
return isUTC ? d.getUTCFullYear() : d.getFullYear();
|
||||
default:
|
||||
return NaN; // Just in case
|
||||
}
|
||||
}
|
||||
|
||||
function set$1(mom, unit, value) {
|
||||
var d, isUTC, year, month, date;
|
||||
|
||||
if (!mom.isValid() || isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
d = mom._d;
|
||||
isUTC = mom._isUTC;
|
||||
|
||||
switch (unit) {
|
||||
case 'Milliseconds':
|
||||
return void (isUTC
|
||||
? d.setUTCMilliseconds(value)
|
||||
: d.setMilliseconds(value));
|
||||
case 'Seconds':
|
||||
return void (isUTC ? d.setUTCSeconds(value) : d.setSeconds(value));
|
||||
case 'Minutes':
|
||||
return void (isUTC ? d.setUTCMinutes(value) : d.setMinutes(value));
|
||||
case 'Hours':
|
||||
return void (isUTC ? d.setUTCHours(value) : d.setHours(value));
|
||||
case 'Date':
|
||||
return void (isUTC ? d.setUTCDate(value) : d.setDate(value));
|
||||
// case 'Day': // Not real
|
||||
// return void (isUTC ? d.setUTCDay(value) : d.setDay(value));
|
||||
// case 'Month': // Not used because we need to pass two variables
|
||||
// return void (isUTC ? d.setUTCMonth(value) : d.setMonth(value));
|
||||
case 'FullYear':
|
||||
break; // See below ...
|
||||
default:
|
||||
return; // Just in case
|
||||
}
|
||||
|
||||
year = value;
|
||||
month = mom.month();
|
||||
date = mom.date();
|
||||
date = date === 29 && month === 1 && !isLeapYear(year) ? 28 : date;
|
||||
void (isUTC
|
||||
? d.setUTCFullYear(year, month, date)
|
||||
: d.setFullYear(year, month, date));
|
||||
}
|
||||
|
||||
// MOMENTS
|
||||
|
||||
function stringGet(units) {
|
||||
units = normalizeUnits(units);
|
||||
if (isFunction(this[units])) {
|
||||
return this[units]();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
function stringSet(units, value) {
|
||||
if (typeof units === 'object') {
|
||||
units = normalizeObjectUnits(units);
|
||||
var prioritized = getPrioritizedUnits(units),
|
||||
i,
|
||||
prioritizedLen = prioritized.length;
|
||||
for (i = 0; i < prioritizedLen; i++) {
|
||||
this[prioritized[i].unit](units[prioritized[i].unit]);
|
||||
}
|
||||
} else {
|
||||
units = normalizeUnits(units);
|
||||
if (isFunction(this[units])) {
|
||||
return this[units](value);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
function mod(n, x) {
|
||||
return ((n % x) + x) % x;
|
||||
}
|
||||
@@ -919,17 +1079,9 @@
|
||||
return this.localeData().months(this, format);
|
||||
});
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('month', 'M');
|
||||
|
||||
// PRIORITY
|
||||
|
||||
addUnitPriority('month', 8);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('M', match1to2);
|
||||
addRegexToken('M', match1to2, match1to2NoLeadingZero);
|
||||
addRegexToken('MM', match1to2, match2);
|
||||
addRegexToken('MMM', function (isStrict, locale) {
|
||||
return locale.monthsShortRegex(isStrict);
|
||||
@@ -1095,8 +1247,6 @@
|
||||
// MOMENTS
|
||||
|
||||
function setMonth(mom, value) {
|
||||
var dayOfMonth;
|
||||
|
||||
if (!mom.isValid()) {
|
||||
// No op
|
||||
return mom;
|
||||
@@ -1114,8 +1264,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
dayOfMonth = Math.min(mom.date(), daysInMonth(mom.year(), value));
|
||||
mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth);
|
||||
var month = value,
|
||||
date = mom.date();
|
||||
|
||||
date = date < 29 ? date : Math.min(date, daysInMonth(mom.year(), month));
|
||||
void (mom._isUTC
|
||||
? mom._d.setUTCMonth(month, date)
|
||||
: mom._d.setMonth(month, date));
|
||||
return mom;
|
||||
}
|
||||
|
||||
@@ -1182,27 +1337,24 @@
|
||||
longPieces = [],
|
||||
mixedPieces = [],
|
||||
i,
|
||||
mom;
|
||||
mom,
|
||||
shortP,
|
||||
longP;
|
||||
for (i = 0; i < 12; i++) {
|
||||
// make the regex if we don't have it already
|
||||
mom = createUTC([2000, i]);
|
||||
shortPieces.push(this.monthsShort(mom, ''));
|
||||
longPieces.push(this.months(mom, ''));
|
||||
mixedPieces.push(this.months(mom, ''));
|
||||
mixedPieces.push(this.monthsShort(mom, ''));
|
||||
shortP = regexEscape(this.monthsShort(mom, ''));
|
||||
longP = regexEscape(this.months(mom, ''));
|
||||
shortPieces.push(shortP);
|
||||
longPieces.push(longP);
|
||||
mixedPieces.push(longP);
|
||||
mixedPieces.push(shortP);
|
||||
}
|
||||
// Sorting makes sure if one month (or abbr) is a prefix of another it
|
||||
// will match the longer piece.
|
||||
shortPieces.sort(cmpLenRev);
|
||||
longPieces.sort(cmpLenRev);
|
||||
mixedPieces.sort(cmpLenRev);
|
||||
for (i = 0; i < 12; i++) {
|
||||
shortPieces[i] = regexEscape(shortPieces[i]);
|
||||
longPieces[i] = regexEscape(longPieces[i]);
|
||||
}
|
||||
for (i = 0; i < 24; i++) {
|
||||
mixedPieces[i] = regexEscape(mixedPieces[i]);
|
||||
}
|
||||
|
||||
this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i');
|
||||
this._monthsShortRegex = this._monthsRegex;
|
||||
@@ -1216,69 +1368,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// FORMATTING
|
||||
|
||||
addFormatToken('Y', 0, 0, function () {
|
||||
var y = this.year();
|
||||
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
|
||||
});
|
||||
|
||||
addFormatToken(0, ['YY', 2], 0, function () {
|
||||
return this.year() % 100;
|
||||
});
|
||||
|
||||
addFormatToken(0, ['YYYY', 4], 0, 'year');
|
||||
addFormatToken(0, ['YYYYY', 5], 0, 'year');
|
||||
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('year', 'y');
|
||||
|
||||
// PRIORITIES
|
||||
|
||||
addUnitPriority('year', 1);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('Y', matchSigned);
|
||||
addRegexToken('YY', match1to2, match2);
|
||||
addRegexToken('YYYY', match1to4, match4);
|
||||
addRegexToken('YYYYY', match1to6, match6);
|
||||
addRegexToken('YYYYYY', match1to6, match6);
|
||||
|
||||
addParseToken(['YYYYY', 'YYYYYY'], YEAR);
|
||||
addParseToken('YYYY', function (input, array) {
|
||||
array[YEAR] =
|
||||
input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input);
|
||||
});
|
||||
addParseToken('YY', function (input, array) {
|
||||
array[YEAR] = hooks.parseTwoDigitYear(input);
|
||||
});
|
||||
addParseToken('Y', function (input, array) {
|
||||
array[YEAR] = parseInt(input, 10);
|
||||
});
|
||||
|
||||
// HELPERS
|
||||
|
||||
function daysInYear(year) {
|
||||
return isLeapYear(year) ? 366 : 365;
|
||||
}
|
||||
|
||||
// HOOKS
|
||||
|
||||
hooks.parseTwoDigitYear = function (input) {
|
||||
return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
|
||||
};
|
||||
|
||||
// MOMENTS
|
||||
|
||||
var getSetYear = makeGetSet('FullYear', true);
|
||||
|
||||
function getIsLeapYear() {
|
||||
return isLeapYear(this.year());
|
||||
}
|
||||
|
||||
function createDate(y, m, d, h, M, s, ms) {
|
||||
// can't just apply() to create a date:
|
||||
// https://stackoverflow.com/q/181348
|
||||
@@ -1384,21 +1473,11 @@
|
||||
addFormatToken('w', ['ww', 2], 'wo', 'week');
|
||||
addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('week', 'w');
|
||||
addUnitAlias('isoWeek', 'W');
|
||||
|
||||
// PRIORITIES
|
||||
|
||||
addUnitPriority('week', 5);
|
||||
addUnitPriority('isoWeek', 5);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('w', match1to2);
|
||||
addRegexToken('w', match1to2, match1to2NoLeadingZero);
|
||||
addRegexToken('ww', match1to2, match2);
|
||||
addRegexToken('W', match1to2);
|
||||
addRegexToken('W', match1to2, match1to2NoLeadingZero);
|
||||
addRegexToken('WW', match1to2, match2);
|
||||
|
||||
addWeekParseToken(
|
||||
@@ -1460,17 +1539,6 @@
|
||||
addFormatToken('e', 0, 0, 'weekday');
|
||||
addFormatToken('E', 0, 0, 'isoWeekday');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('day', 'd');
|
||||
addUnitAlias('weekday', 'e');
|
||||
addUnitAlias('isoWeekday', 'E');
|
||||
|
||||
// PRIORITY
|
||||
addUnitPriority('day', 11);
|
||||
addUnitPriority('weekday', 11);
|
||||
addUnitPriority('isoWeekday', 11);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('d', match1to2);
|
||||
@@ -1550,24 +1618,24 @@
|
||||
return m === true
|
||||
? shiftWeekdays(weekdays, this._week.dow)
|
||||
: m
|
||||
? weekdays[m.day()]
|
||||
: weekdays;
|
||||
? weekdays[m.day()]
|
||||
: weekdays;
|
||||
}
|
||||
|
||||
function localeWeekdaysShort(m) {
|
||||
return m === true
|
||||
? shiftWeekdays(this._weekdaysShort, this._week.dow)
|
||||
: m
|
||||
? this._weekdaysShort[m.day()]
|
||||
: this._weekdaysShort;
|
||||
? this._weekdaysShort[m.day()]
|
||||
: this._weekdaysShort;
|
||||
}
|
||||
|
||||
function localeWeekdaysMin(m) {
|
||||
return m === true
|
||||
? shiftWeekdays(this._weekdaysMin, this._week.dow)
|
||||
: m
|
||||
? this._weekdaysMin[m.day()]
|
||||
: this._weekdaysMin;
|
||||
? this._weekdaysMin[m.day()]
|
||||
: this._weekdaysMin;
|
||||
}
|
||||
|
||||
function handleStrictParse$1(weekdayName, format, strict) {
|
||||
@@ -1716,7 +1784,8 @@
|
||||
if (!this.isValid()) {
|
||||
return input != null ? this : NaN;
|
||||
}
|
||||
var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay();
|
||||
|
||||
var day = get(this, 'Day');
|
||||
if (input != null) {
|
||||
input = parseWeekday(input, this.localeData());
|
||||
return this.add(input - day, 'd');
|
||||
@@ -1915,13 +1984,6 @@
|
||||
meridiem('a', true);
|
||||
meridiem('A', false);
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('hour', 'h');
|
||||
|
||||
// PRIORITY
|
||||
addUnitPriority('hour', 13);
|
||||
|
||||
// PARSING
|
||||
|
||||
function matchMeridiem(isStrict, locale) {
|
||||
@@ -1930,9 +1992,9 @@
|
||||
|
||||
addRegexToken('a', matchMeridiem);
|
||||
addRegexToken('A', matchMeridiem);
|
||||
addRegexToken('H', match1to2);
|
||||
addRegexToken('h', match1to2);
|
||||
addRegexToken('k', match1to2);
|
||||
addRegexToken('H', match1to2, match1to2HasZero);
|
||||
addRegexToken('h', match1to2, match1to2NoLeadingZero);
|
||||
addRegexToken('k', match1to2, match1to2NoLeadingZero);
|
||||
addRegexToken('HH', match1to2, match2);
|
||||
addRegexToken('hh', match1to2, match2);
|
||||
addRegexToken('kk', match1to2, match2);
|
||||
@@ -2082,7 +2144,8 @@
|
||||
|
||||
function isLocaleNameSane(name) {
|
||||
// Prevent names that look like filesystem paths, i.e contain '/' or '\'
|
||||
return name.match('^[^/\\\\]*$') != null;
|
||||
// Ensure name is available and function returns boolean
|
||||
return !!(name && name.match('^[^/\\\\]*$'));
|
||||
}
|
||||
|
||||
function loadLocale(name) {
|
||||
@@ -2274,21 +2337,21 @@
|
||||
a[MONTH] < 0 || a[MONTH] > 11
|
||||
? MONTH
|
||||
: a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH])
|
||||
? DATE
|
||||
: a[HOUR] < 0 ||
|
||||
a[HOUR] > 24 ||
|
||||
(a[HOUR] === 24 &&
|
||||
(a[MINUTE] !== 0 ||
|
||||
a[SECOND] !== 0 ||
|
||||
a[MILLISECOND] !== 0))
|
||||
? HOUR
|
||||
: a[MINUTE] < 0 || a[MINUTE] > 59
|
||||
? MINUTE
|
||||
: a[SECOND] < 0 || a[SECOND] > 59
|
||||
? SECOND
|
||||
: a[MILLISECOND] < 0 || a[MILLISECOND] > 999
|
||||
? MILLISECOND
|
||||
: -1;
|
||||
? DATE
|
||||
: a[HOUR] < 0 ||
|
||||
a[HOUR] > 24 ||
|
||||
(a[HOUR] === 24 &&
|
||||
(a[MINUTE] !== 0 ||
|
||||
a[SECOND] !== 0 ||
|
||||
a[MILLISECOND] !== 0))
|
||||
? HOUR
|
||||
: a[MINUTE] < 0 || a[MINUTE] > 59
|
||||
? MINUTE
|
||||
: a[SECOND] < 0 || a[SECOND] > 59
|
||||
? SECOND
|
||||
: a[MILLISECOND] < 0 || a[MILLISECOND] > 999
|
||||
? MILLISECOND
|
||||
: -1;
|
||||
|
||||
if (
|
||||
getParsingFlags(m)._overflowDayOfYear &&
|
||||
@@ -3729,16 +3792,16 @@
|
||||
return diff < -6
|
||||
? 'sameElse'
|
||||
: diff < -1
|
||||
? 'lastWeek'
|
||||
: diff < 0
|
||||
? 'lastDay'
|
||||
: diff < 1
|
||||
? 'sameDay'
|
||||
: diff < 2
|
||||
? 'nextDay'
|
||||
: diff < 7
|
||||
? 'nextWeek'
|
||||
: 'sameElse';
|
||||
? 'lastWeek'
|
||||
: diff < 0
|
||||
? 'lastDay'
|
||||
: diff < 1
|
||||
? 'sameDay'
|
||||
: diff < 2
|
||||
? 'nextDay'
|
||||
: diff < 7
|
||||
? 'nextWeek'
|
||||
: 'sameElse';
|
||||
}
|
||||
|
||||
function calendar$1(time, formats) {
|
||||
@@ -4546,16 +4609,22 @@
|
||||
mixedPieces = [],
|
||||
i,
|
||||
l,
|
||||
erasName,
|
||||
erasAbbr,
|
||||
erasNarrow,
|
||||
eras = this.eras();
|
||||
|
||||
for (i = 0, l = eras.length; i < l; ++i) {
|
||||
namePieces.push(regexEscape(eras[i].name));
|
||||
abbrPieces.push(regexEscape(eras[i].abbr));
|
||||
narrowPieces.push(regexEscape(eras[i].narrow));
|
||||
erasName = regexEscape(eras[i].name);
|
||||
erasAbbr = regexEscape(eras[i].abbr);
|
||||
erasNarrow = regexEscape(eras[i].narrow);
|
||||
|
||||
mixedPieces.push(regexEscape(eras[i].name));
|
||||
mixedPieces.push(regexEscape(eras[i].abbr));
|
||||
mixedPieces.push(regexEscape(eras[i].narrow));
|
||||
namePieces.push(erasName);
|
||||
abbrPieces.push(erasAbbr);
|
||||
narrowPieces.push(erasNarrow);
|
||||
mixedPieces.push(erasName);
|
||||
mixedPieces.push(erasAbbr);
|
||||
mixedPieces.push(erasNarrow);
|
||||
}
|
||||
|
||||
this._erasRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i');
|
||||
@@ -4588,14 +4657,6 @@
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('weekYear', 'gg');
|
||||
addUnitAlias('isoWeekYear', 'GG');
|
||||
|
||||
// PRIORITY
|
||||
|
||||
addUnitPriority('weekYear', 1);
|
||||
addUnitPriority('isoWeekYear', 1);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('G', matchSigned);
|
||||
@@ -4625,7 +4686,7 @@
|
||||
this,
|
||||
input,
|
||||
this.week(),
|
||||
this.weekday(),
|
||||
this.weekday() + this.localeData()._week.dow,
|
||||
this.localeData()._week.dow,
|
||||
this.localeData()._week.doy
|
||||
);
|
||||
@@ -4687,14 +4748,6 @@
|
||||
|
||||
addFormatToken('Q', 0, 'Qo', 'quarter');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('quarter', 'Q');
|
||||
|
||||
// PRIORITY
|
||||
|
||||
addUnitPriority('quarter', 7);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('Q', match1);
|
||||
@@ -4714,16 +4767,9 @@
|
||||
|
||||
addFormatToken('D', ['DD', 2], 'Do', 'date');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('date', 'D');
|
||||
|
||||
// PRIORITY
|
||||
addUnitPriority('date', 9);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('D', match1to2);
|
||||
addRegexToken('D', match1to2, match1to2NoLeadingZero);
|
||||
addRegexToken('DD', match1to2, match2);
|
||||
addRegexToken('Do', function (isStrict, locale) {
|
||||
// TODO: Remove "ordinalParse" fallback in next major release.
|
||||
@@ -4745,13 +4791,6 @@
|
||||
|
||||
addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('dayOfYear', 'DDD');
|
||||
|
||||
// PRIORITY
|
||||
addUnitPriority('dayOfYear', 4);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('DDD', match1to3);
|
||||
@@ -4776,17 +4815,9 @@
|
||||
|
||||
addFormatToken('m', ['mm', 2], 0, 'minute');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('minute', 'm');
|
||||
|
||||
// PRIORITY
|
||||
|
||||
addUnitPriority('minute', 14);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('m', match1to2);
|
||||
addRegexToken('m', match1to2, match1to2HasZero);
|
||||
addRegexToken('mm', match1to2, match2);
|
||||
addParseToken(['m', 'mm'], MINUTE);
|
||||
|
||||
@@ -4798,17 +4829,9 @@
|
||||
|
||||
addFormatToken('s', ['ss', 2], 0, 'second');
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('second', 's');
|
||||
|
||||
// PRIORITY
|
||||
|
||||
addUnitPriority('second', 15);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('s', match1to2);
|
||||
addRegexToken('s', match1to2, match1to2HasZero);
|
||||
addRegexToken('ss', match1to2, match2);
|
||||
addParseToken(['s', 'ss'], SECOND);
|
||||
|
||||
@@ -4846,14 +4869,6 @@
|
||||
return this.millisecond() * 1000000;
|
||||
});
|
||||
|
||||
// ALIASES
|
||||
|
||||
addUnitAlias('millisecond', 'ms');
|
||||
|
||||
// PRIORITY
|
||||
|
||||
addUnitPriority('millisecond', 16);
|
||||
|
||||
// PARSING
|
||||
|
||||
addRegexToken('S', match1to3, match1);
|
||||
@@ -5161,12 +5176,12 @@
|
||||
toInt((number % 100) / 10) === 1
|
||||
? 'th'
|
||||
: b === 1
|
||||
? 'st'
|
||||
: b === 2
|
||||
? 'nd'
|
||||
: b === 3
|
||||
? 'rd'
|
||||
: 'th';
|
||||
? 'st'
|
||||
: b === 2
|
||||
? 'nd'
|
||||
: b === 3
|
||||
? 'rd'
|
||||
: 'th';
|
||||
return number + output;
|
||||
},
|
||||
});
|
||||
@@ -5339,19 +5354,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Use this.as('ms')?
|
||||
function valueOf$1() {
|
||||
if (!this.isValid()) {
|
||||
return NaN;
|
||||
}
|
||||
return (
|
||||
this._milliseconds +
|
||||
this._days * 864e5 +
|
||||
(this._months % 12) * 2592e6 +
|
||||
toInt(this._months / 12) * 31536e6
|
||||
);
|
||||
}
|
||||
|
||||
function makeAs(alias) {
|
||||
return function () {
|
||||
return this.as(alias);
|
||||
@@ -5366,7 +5368,8 @@
|
||||
asWeeks = makeAs('w'),
|
||||
asMonths = makeAs('M'),
|
||||
asQuarters = makeAs('Q'),
|
||||
asYears = makeAs('y');
|
||||
asYears = makeAs('y'),
|
||||
valueOf$1 = asMilliseconds;
|
||||
|
||||
function clone$1() {
|
||||
return createDuration(this);
|
||||
@@ -5635,7 +5638,7 @@
|
||||
|
||||
//! moment.js
|
||||
|
||||
hooks.version = '2.29.4';
|
||||
hooks.version = '2.30.1';
|
||||
|
||||
setHookCallback(createLocal);
|
||||
|
||||
|
||||
+95
-55
@@ -4,18 +4,26 @@ import time
|
||||
import traceback
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, List, Optional
|
||||
from typing import (
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from lnbits.core.crud import (
|
||||
delete_expired_invoices,
|
||||
delete_webpush_subscriptions,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentState
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
@@ -23,17 +31,13 @@ tasks: List[asyncio.Task] = []
|
||||
unique_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def create_task(coro):
|
||||
def create_task(coro: Coroutine) -> asyncio.Task:
|
||||
task = asyncio.create_task(coro)
|
||||
tasks.append(task)
|
||||
return task
|
||||
|
||||
|
||||
def create_permanent_task(func):
|
||||
return create_task(catch_everything_and_restart(func))
|
||||
|
||||
|
||||
def create_unique_task(name: str, coro):
|
||||
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
|
||||
if unique_tasks.get(name):
|
||||
logger.warning(f"task `{name}` already exists, cancelling it")
|
||||
try:
|
||||
@@ -45,11 +49,17 @@ def create_unique_task(name: str, coro):
|
||||
return task
|
||||
|
||||
|
||||
def create_permanent_unique_task(name: str, coro):
|
||||
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
|
||||
return create_task(catch_everything_and_restart(func))
|
||||
|
||||
|
||||
def create_permanent_unique_task(
|
||||
name: str, coro: Callable[[], Coroutine]
|
||||
) -> asyncio.Task:
|
||||
return create_unique_task(name, catch_everything_and_restart(coro, name))
|
||||
|
||||
|
||||
def cancel_all_tasks():
|
||||
def cancel_all_tasks() -> None:
|
||||
for task in tasks:
|
||||
try:
|
||||
task.cancel()
|
||||
@@ -62,9 +72,12 @@ def cancel_all_tasks():
|
||||
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
|
||||
|
||||
|
||||
async def catch_everything_and_restart(func, name: str = "unnamed"):
|
||||
async def catch_everything_and_restart(
|
||||
func: Callable[[], Coroutine],
|
||||
name: str = "unnamed",
|
||||
) -> Coroutine:
|
||||
try:
|
||||
await func()
|
||||
return await func()
|
||||
except asyncio.CancelledError:
|
||||
raise # because we must pass this up
|
||||
except Exception as exc:
|
||||
@@ -72,7 +85,7 @@ async def catch_everything_and_restart(func, name: str = "unnamed"):
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("will restart the task in 5 seconds.")
|
||||
await asyncio.sleep(5)
|
||||
await catch_everything_and_restart(func, name)
|
||||
return await catch_everything_and_restart(func, name)
|
||||
|
||||
|
||||
invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
@@ -99,7 +112,7 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = No
|
||||
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
||||
|
||||
async def internal_invoice_listener():
|
||||
async def internal_invoice_listener() -> None:
|
||||
"""
|
||||
internal_invoice_queue will be filled directly in core/services.py
|
||||
after the payment was deemed to be settled internally.
|
||||
@@ -108,11 +121,11 @@ async def internal_invoice_listener():
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
checking_id = await internal_invoice_queue.get()
|
||||
logger.info("> got internal payment notification", checking_id)
|
||||
create_task(invoice_callback_dispatcher(checking_id))
|
||||
logger.info(f"got an internal payment notification {checking_id}")
|
||||
await invoice_callback_dispatcher(checking_id, is_internal=True)
|
||||
|
||||
|
||||
async def invoice_listener():
|
||||
async def invoice_listener() -> None:
|
||||
"""
|
||||
invoice_listener will collect all invoices that come directly
|
||||
from the backend wallet.
|
||||
@@ -121,8 +134,23 @@ async def invoice_listener():
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
async for checking_id in funding_source.paid_invoices_stream():
|
||||
logger.info("> got a payment notification", checking_id)
|
||||
create_task(invoice_callback_dispatcher(checking_id))
|
||||
logger.info(f"got a payment notification {checking_id}")
|
||||
await invoice_callback_dispatcher(checking_id)
|
||||
|
||||
|
||||
def wait_for_paid_invoices(
|
||||
invoice_listener_name: str,
|
||||
func: Callable[[Payment], Coroutine],
|
||||
) -> Callable[[], Coroutine]:
|
||||
|
||||
async def wrapper() -> None:
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, invoice_listener_name)
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_queue.get()
|
||||
await func(payment)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def check_pending_payments():
|
||||
@@ -131,59 +159,68 @@ async def check_pending_payments():
|
||||
the backend and also to delete expired invoices. Incoming payments will be
|
||||
checked only once, outgoing pending payments will be checked regularly.
|
||||
"""
|
||||
outgoing = True
|
||||
incoming = True
|
||||
sleep_time = 60 * 30 # 30 minutes
|
||||
|
||||
while settings.lnbits_running:
|
||||
logger.info(
|
||||
f"Task: checking all pending payments (incoming={incoming},"
|
||||
f" outgoing={outgoing}) of last 15 days"
|
||||
)
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
logger.warning("Task: skipping pending check for VoidWallet")
|
||||
await asyncio.sleep(sleep_time)
|
||||
continue
|
||||
start_time = time.time()
|
||||
pending_payments = await get_payments(
|
||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||
complete=False,
|
||||
pending=True,
|
||||
outgoing=outgoing,
|
||||
incoming=incoming,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
await payment.check_status()
|
||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||
|
||||
logger.info(
|
||||
f"Task: pending check finished for {len(pending_payments)} payments"
|
||||
f" (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
# we delete expired invoices once upon the first pending check
|
||||
if incoming:
|
||||
logger.debug("Task: deleting all expired invoices")
|
||||
start_time = time.time()
|
||||
await delete_expired_invoices()
|
||||
count = len(pending_payments)
|
||||
if count > 0:
|
||||
logger.info(f"Task: checking {count} pending payments of last 15 days...")
|
||||
for i, payment in enumerate(pending_payments):
|
||||
status = await payment.check_status()
|
||||
prefix = f"payment ({i+1} / {count})"
|
||||
if status.failed:
|
||||
await update_payment_status(
|
||||
payment.checking_id, status=PaymentState.FAILED
|
||||
)
|
||||
logger.debug(f"{prefix} failed {payment.checking_id}")
|
||||
elif status.success:
|
||||
await update_payment_details(
|
||||
checking_id=payment.checking_id,
|
||||
fee=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
status=PaymentState.SUCCESS,
|
||||
)
|
||||
logger.debug(f"{prefix} success {payment.checking_id}")
|
||||
else:
|
||||
logger.debug(f"{prefix} pending {payment.checking_id}")
|
||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||
logger.info(
|
||||
"Task: expired invoice deletion finished (took"
|
||||
f" {time.time() - start_time:0.3f} s)"
|
||||
f"Task: pending check finished for {count} payments"
|
||||
f" (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
|
||||
# after the first check we will only check outgoing, not incoming
|
||||
# that will be handled by the global invoice listeners, hopefully
|
||||
incoming = False
|
||||
|
||||
await asyncio.sleep(60 * 30) # every 30 minutes
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
|
||||
async def invoice_callback_dispatcher(checking_id: str):
|
||||
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
|
||||
"""
|
||||
Takes incoming payments, sets pending=False, and dispatches them to
|
||||
Takes an incoming payment, checks its status, and dispatches it to
|
||||
invoice_listeners from core and extensions.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if payment and payment.is_in:
|
||||
logger.trace(
|
||||
f"invoice listeners: sending invoice callback for payment {checking_id}"
|
||||
status = await payment.check_status()
|
||||
await update_payment_details(
|
||||
checking_id=payment.checking_id,
|
||||
fee=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
status=PaymentState.SUCCESS,
|
||||
)
|
||||
await payment.set_pending(False)
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
assert payment, "updated payment not found"
|
||||
internal = "internal" if is_internal else ""
|
||||
logger.success(f"{internal} invoice {checking_id} settled")
|
||||
for name, send_chan in invoice_listeners.items():
|
||||
logger.trace(f"invoice listeners: sending to `{name}`")
|
||||
await send_chan.put(payment)
|
||||
@@ -204,8 +241,11 @@ async def send_push_notification(subscription, title, body, url=""):
|
||||
{"aud": "", "sub": "mailto:alan@lnbits.com"},
|
||||
)
|
||||
except WebPushException as e:
|
||||
if e.response.status_code == HTTPStatus.GONE:
|
||||
if e.response and e.response.status_code == HTTPStatus.GONE:
|
||||
# cleanup unsubscribed or expired push subscriptions
|
||||
await delete_webpush_subscriptions(subscription.endpoint)
|
||||
else:
|
||||
logger.error(f"failed sending push notification: {e.response.text}")
|
||||
logger.error(
|
||||
f"failed sending push notification: "
|
||||
f"{e.response.text if e.response else e}"
|
||||
)
|
||||
|
||||
@@ -191,6 +191,7 @@ class Provider(NamedTuple):
|
||||
domain: str
|
||||
api_url: str
|
||||
getter: Callable
|
||||
exclude_to: list = []
|
||||
|
||||
|
||||
exchange_rate_providers = {
|
||||
@@ -200,30 +201,34 @@ exchange_rate_providers = {
|
||||
"binance.com",
|
||||
"https://api.binance.com/api/v3/ticker/price?symbol={FROM}{TO}",
|
||||
lambda data, replacements: data["price"],
|
||||
["czk"],
|
||||
),
|
||||
"blockchain": Provider(
|
||||
"Blockchain",
|
||||
"blockchain.com",
|
||||
"https://blockchain.info/tobtc?currency={TO}&value=1",
|
||||
lambda data, replacements: 1 / data,
|
||||
"https://blockchain.info/tobtc?currency={TO}&value=1000000",
|
||||
lambda data, replacements: 1000000 / data,
|
||||
),
|
||||
"exir": Provider(
|
||||
"Exir",
|
||||
"exir.io",
|
||||
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
|
||||
lambda data, replacements: data["last"],
|
||||
["czk", "eur"],
|
||||
),
|
||||
"bitfinex": Provider(
|
||||
"Bitfinex",
|
||||
"bitfinex.com",
|
||||
"https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||
lambda data, replacements: data["last_price"],
|
||||
["czk"],
|
||||
),
|
||||
"bitstamp": Provider(
|
||||
"Bitstamp",
|
||||
"bitstamp.net",
|
||||
"https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||
lambda data, replacements: data["last"],
|
||||
["czk"],
|
||||
),
|
||||
"coinbase": Provider(
|
||||
"Coinbase",
|
||||
@@ -242,6 +247,21 @@ exchange_rate_providers = {
|
||||
"kraken.com",
|
||||
"https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||
lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0],
|
||||
["czk"],
|
||||
),
|
||||
"bitpay": Provider(
|
||||
"BitPay",
|
||||
"bitpay.com",
|
||||
"https://bitpay.com/rates",
|
||||
lambda data, replacements: next(
|
||||
i["rate"] for i in data["data"] if i["code"] == replacements["TO"]
|
||||
),
|
||||
),
|
||||
"yadio": Provider(
|
||||
"yadio",
|
||||
"yadio.io",
|
||||
"https://api.yadio.io/exrates/{FROM}",
|
||||
lambda data, replacements: data[replacements["FROM"]][replacements["TO"]],
|
||||
),
|
||||
}
|
||||
|
||||
@@ -255,6 +275,9 @@ async def btc_price(currency: str) -> float:
|
||||
}
|
||||
|
||||
async def fetch_price(provider: Provider):
|
||||
if currency.lower() in provider.exclude_to:
|
||||
raise Exception(f"Provider {provider.name} does not support {currency}.")
|
||||
|
||||
url = provider.api_url.format(**replacements)
|
||||
try:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
|
||||
@@ -9,6 +9,8 @@ from lnbits.wallets.base import Wallet
|
||||
|
||||
from .alby import AlbyWallet
|
||||
from .blink import BlinkWallet
|
||||
from .boltz import BoltzWallet
|
||||
from .breez import BreezSdkWallet
|
||||
from .cliche import ClicheWallet
|
||||
from .corelightning import CoreLightningWallet
|
||||
|
||||
@@ -23,6 +25,7 @@ from .lndgrpc import LndWallet
|
||||
from .lndrest import LndRestWallet
|
||||
from .lnpay import LNPayWallet
|
||||
from .lntips import LnTipsWallet
|
||||
from .nwc import NWCWallet
|
||||
from .opennode import OpenNodeWallet
|
||||
from .phoenixd import PhoenixdWallet
|
||||
from .spark import SparkWallet
|
||||
@@ -48,3 +51,28 @@ fake_wallet = FakeWallet()
|
||||
|
||||
# initialize as fake wallet
|
||||
funding_source: Wallet = fake_wallet
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AlbyWallet",
|
||||
"BlinkWallet",
|
||||
"BoltzWallet",
|
||||
"BreezSdkWallet",
|
||||
"ClicheWallet",
|
||||
"CoreLightningWallet",
|
||||
"CLightningWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"EclairWallet",
|
||||
"FakeWallet",
|
||||
"LNbitsWallet",
|
||||
"LndWallet",
|
||||
"LndRestWallet",
|
||||
"LNPayWallet",
|
||||
"LnTipsWallet",
|
||||
"NWCWallet",
|
||||
"OpenNodeWallet",
|
||||
"PhoenixdWallet",
|
||||
"SparkWallet",
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from bolt11.decode import decode
|
||||
from grpc.aio import AioRpcError
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
|
||||
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
|
||||
from lnbits.wallets.macaroon.macaroon import load_macaroon
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class BoltzWallet(Wallet):
|
||||
"""
|
||||
Utilizing Boltz Client gRPC interface
|
||||
|
||||
gRPC Bindings can be updated by running lnbits/wallets/boltz_grpc_files/update.sh
|
||||
"""
|
||||
|
||||
async def cleanup(self):
|
||||
logger.warning("Cleaning up BoltzWallet...")
|
||||
|
||||
def __init__(self):
|
||||
if not settings.boltz_client_endpoint:
|
||||
raise ValueError(
|
||||
"cannot initialize BoltzWallet: missing boltz_client_endpoint"
|
||||
)
|
||||
if not settings.boltz_client_wallet:
|
||||
raise ValueError(
|
||||
"cannot initialize BoltzWallet: missing boltz_client_wallet"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(
|
||||
settings.boltz_client_endpoint, add_proto=True
|
||||
)
|
||||
|
||||
if settings.boltz_client_macaroon:
|
||||
self.metadata = [
|
||||
("macaroon", load_macaroon(settings.boltz_client_macaroon))
|
||||
]
|
||||
else:
|
||||
self.metadata = None
|
||||
|
||||
if settings.boltz_client_cert:
|
||||
cert = open(settings.boltz_client_cert, "rb").read()
|
||||
creds = grpc.ssl_channel_credentials(cert)
|
||||
channel = grpc.aio.secure_channel(settings.boltz_client_endpoint, creds)
|
||||
else:
|
||||
channel = grpc.aio.insecure_channel(settings.boltz_client_endpoint)
|
||||
|
||||
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
|
||||
self.wallet_id: int = 0
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
|
||||
response: boltzrpc_pb2.Wallet = await self.rpc.GetWallet(
|
||||
request, metadata=self.metadata
|
||||
)
|
||||
except AioRpcError as exc:
|
||||
return StatusResponse(
|
||||
exc.details()
|
||||
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501
|
||||
0,
|
||||
)
|
||||
|
||||
self.wallet_id = response.id
|
||||
|
||||
return StatusResponse(None, response.balance.total * 1000)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
pair = boltzrpc_pb2.Pair(to=boltzrpc_pb2.LBTC)
|
||||
request = boltzrpc_pb2.CreateReverseSwapRequest(
|
||||
amount=amount,
|
||||
pair=pair,
|
||||
wallet_id=self.wallet_id,
|
||||
accept_zero_conf=True,
|
||||
external_pay=True,
|
||||
description=memo,
|
||||
)
|
||||
response: boltzrpc_pb2.CreateReverseSwapResponse
|
||||
try:
|
||||
response = await self.rpc.CreateReverseSwap(request, metadata=self.metadata)
|
||||
except AioRpcError as exc:
|
||||
return InvoiceResponse(ok=False, error_message=exc.details())
|
||||
return InvoiceResponse(
|
||||
ok=True, checking_id=response.id, payment_request=response.invoice
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
|
||||
try:
|
||||
pair_info: boltzrpc_pb2.PairInfo
|
||||
pair_request = boltzrpc_pb2.GetPairInfoRequest(
|
||||
type=boltzrpc_pb2.SUBMARINE, pair=pair
|
||||
)
|
||||
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
|
||||
invoice = decode(bolt11)
|
||||
|
||||
assert invoice.amount_msat, "amountless invoice"
|
||||
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
|
||||
estimate = service_fee + pair_info.fees.miner_fees * 1000
|
||||
if estimate > fee_limit_msat:
|
||||
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
|
||||
|
||||
return PaymentResponse(ok=False, error_message=error)
|
||||
|
||||
request = boltzrpc_pb2.CreateSwapRequest(
|
||||
invoice=bolt11,
|
||||
pair=pair,
|
||||
wallet_id=self.wallet_id,
|
||||
zero_conf=True,
|
||||
send_from_internal=True,
|
||||
)
|
||||
response: boltzrpc_pb2.CreateSwapResponse
|
||||
response = await self.rpc.CreateSwap(request, metadata=self.metadata)
|
||||
except AioRpcError as exc:
|
||||
return PaymentResponse(ok=False, error_message=exc.details())
|
||||
|
||||
try:
|
||||
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
|
||||
info: boltzrpc_pb2.GetSwapInfoResponse
|
||||
async for info in self.rpc.GetSwapInfoStream(
|
||||
info_request, metadata=self.metadata
|
||||
):
|
||||
if info.swap.state == boltzrpc_pb2.SUCCESSFUL:
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=response.id,
|
||||
fee_msat=(info.swap.onchain_fee + info.swap.service_fee) * 1000,
|
||||
preimage=info.swap.preimage,
|
||||
)
|
||||
elif info.swap.error != "":
|
||||
return PaymentResponse(ok=False, error_message=info.swap.error)
|
||||
return PaymentResponse(
|
||||
ok=False, error_message="stream stopped unexpectedly"
|
||||
)
|
||||
except AioRpcError as exc:
|
||||
return PaymentResponse(ok=False, error_message=exc.details())
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
|
||||
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
|
||||
)
|
||||
swap = response.reverse_swap
|
||||
except AioRpcError:
|
||||
return PaymentPendingStatus()
|
||||
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=(
|
||||
(swap.service_fee + swap.onchain_fee) * 1000 + swap.routing_fee_msat
|
||||
),
|
||||
preimage=swap.preimage,
|
||||
)
|
||||
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
return PaymentFailedStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
|
||||
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
|
||||
)
|
||||
swap = response.swap
|
||||
except AioRpcError:
|
||||
return PaymentPendingStatus()
|
||||
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=(swap.service_fee + swap.onchain_fee) * 1000,
|
||||
preimage=swap.preimage,
|
||||
)
|
||||
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
return PaymentFailedStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
try:
|
||||
request = boltzrpc_pb2.GetSwapInfoRequest()
|
||||
info: boltzrpc_pb2.GetSwapInfoResponse
|
||||
async for info in self.rpc.GetSwapInfoStream(
|
||||
request, metadata=self.metadata
|
||||
):
|
||||
reverse = info.reverse_swap
|
||||
if reverse and reverse.state == boltzrpc_pb2.SUCCESSFUL:
|
||||
yield reverse.id
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to boltz client swap stream: '{exc}', retrying in"
|
||||
" 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
@@ -0,0 +1,742 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package boltzrpc;
|
||||
option go_package = "github.com/BoltzExchange/boltz-client/boltzrpc";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
service Boltz {
|
||||
/*
|
||||
Gets general information about the daemon like the chain of the lightning node it is connected to
|
||||
and the IDs of pending swaps.
|
||||
*/
|
||||
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
|
||||
|
||||
/*
|
||||
Fetches the latest limits and fees from the Boltz backend API it is connected to.
|
||||
*/
|
||||
rpc GetServiceInfo (GetServiceInfoRequest) returns (GetServiceInfoResponse) {
|
||||
option deprecated = true;
|
||||
};
|
||||
|
||||
/*
|
||||
Fetches information about a specific pair for a chain swap.
|
||||
*/
|
||||
rpc GetPairInfo (GetPairInfoRequest) returns (PairInfo);
|
||||
|
||||
/*
|
||||
Fetches all available pairs for submarine and reverse swaps.
|
||||
*/
|
||||
rpc GetPairs (google.protobuf.Empty) returns (GetPairsResponse);
|
||||
|
||||
/*
|
||||
Returns a list of all swaps, reverse swaps, and chain swaps in the database.
|
||||
*/
|
||||
rpc ListSwaps (ListSwapsRequest) returns (ListSwapsResponse);
|
||||
|
||||
/*
|
||||
Returns stats of all swaps, reverse swaps, and chain swaps in the database.
|
||||
*/
|
||||
rpc GetStats (GetStatsRequest) returns (GetStatsResponse);
|
||||
|
||||
/*
|
||||
Refund a failed swap manually.
|
||||
This is only required when no refund address has been set and the swap does not have an associated wallet.
|
||||
*/
|
||||
rpc RefundSwap (RefundSwapRequest) returns (GetSwapInfoResponse);
|
||||
|
||||
/*
|
||||
Claim swaps manually.
|
||||
This is only required when no claim address has been set and the swap does not have an associated wallet.
|
||||
*/
|
||||
rpc ClaimSwaps (ClaimSwapsRequest) returns (ClaimSwapsResponse);
|
||||
|
||||
/*
|
||||
Gets all available information about a swap from the database.
|
||||
*/
|
||||
rpc GetSwapInfo (GetSwapInfoRequest) returns (GetSwapInfoResponse);
|
||||
|
||||
/*
|
||||
Returns the entire history of the swap if is still pending and streams updates in real time.
|
||||
If the swap id is empty or "*" updates for all swaps will be streamed.
|
||||
*/
|
||||
rpc GetSwapInfoStream (GetSwapInfoRequest) returns (stream GetSwapInfoResponse);
|
||||
|
||||
/*
|
||||
This is a wrapper for channel creation swaps. The daemon only returns the ID, timeout block height and lockup address.
|
||||
The Boltz backend takes care of the rest. When an amount of onchain coins that is in the limits is sent to the address
|
||||
before the timeout block height, the daemon creates a new lightning invoice, sends it to the Boltz backend which
|
||||
will try to pay it and if that is not possible, create a new channel to make the swap succeed.
|
||||
*/
|
||||
rpc Deposit (DepositRequest) returns (DepositResponse) { option deprecated = true; }
|
||||
|
||||
/*
|
||||
Creates a new swap from onchain to lightning.
|
||||
*/
|
||||
rpc CreateSwap (CreateSwapRequest) returns (CreateSwapResponse);
|
||||
|
||||
/*
|
||||
Create a new swap from onchain to a new lightning channel. The daemon will only accept the invoice payment if the HTLCs
|
||||
is coming trough a new channel channel opened by Boltz.
|
||||
*/
|
||||
rpc CreateChannel (CreateChannelRequest) returns (CreateSwapResponse) { option deprecated = true; };
|
||||
|
||||
/*
|
||||
Creates a new reverse swap from lightning to onchain. If `accept_zero_conf` is set to true in the request, the daemon
|
||||
will not wait until the lockup transaction from Boltz is confirmed in a block, but will claim it instantly.
|
||||
*/
|
||||
rpc CreateReverseSwap (CreateReverseSwapRequest) returns (CreateReverseSwapResponse);
|
||||
|
||||
/*
|
||||
Creates a new chain swap from one chain to another. If `accept_zero_conf` is set to true in the request, the daemon
|
||||
will not wait until the lockup transaction from Boltz is confirmed in a block, but will claim it instantly.
|
||||
*/
|
||||
rpc CreateChainSwap (CreateChainSwapRequest) returns (ChainSwapInfo);
|
||||
|
||||
/*
|
||||
Creates a new liquid wallet and returns the mnemonic.
|
||||
*/
|
||||
rpc CreateWallet (CreateWalletRequest) returns (CreateWalletResponse);
|
||||
|
||||
/*
|
||||
Imports an existing wallet.
|
||||
*/
|
||||
rpc ImportWallet (ImportWalletRequest) returns (Wallet);
|
||||
|
||||
/*
|
||||
Sets the subaccount of a wallet. Not supported for readonly wallets.
|
||||
*/
|
||||
rpc SetSubaccount (SetSubaccountRequest) returns (Subaccount);
|
||||
|
||||
/*
|
||||
Returns all subaccounts for a given wallet. Not supported for readonly wallets.
|
||||
*/
|
||||
rpc GetSubaccounts (GetSubaccountsRequest) returns (GetSubaccountsResponse);
|
||||
|
||||
/*
|
||||
Returns all available wallets.
|
||||
*/
|
||||
rpc GetWallets (GetWalletsRequest) returns (Wallets);
|
||||
|
||||
/*
|
||||
Returns the current balance and subaccount of a wallet.
|
||||
*/
|
||||
rpc GetWallet (GetWalletRequest) returns (Wallet);
|
||||
|
||||
/*
|
||||
Returns the credentials of a wallet. The password will be required if the wallet is encrypted.
|
||||
*/
|
||||
rpc GetWalletCredentials (GetWalletCredentialsRequest) returns (WalletCredentials);
|
||||
|
||||
/*
|
||||
Removes a wallet.
|
||||
*/
|
||||
rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse);
|
||||
|
||||
/*
|
||||
Gracefully stops the daemon.
|
||||
*/
|
||||
rpc Stop(google.protobuf.Empty) returns (google.protobuf.Empty);
|
||||
|
||||
/*
|
||||
Unlocks the server. This will be required on startup if there are any encrypted wallets.
|
||||
*/
|
||||
rpc Unlock(UnlockRequest) returns (google.protobuf.Empty);
|
||||
|
||||
/*
|
||||
Check if the password is correct.
|
||||
*/
|
||||
rpc VerifyWalletPassword(VerifyWalletPasswordRequest) returns (VerifyWalletPasswordResponse);
|
||||
|
||||
/*
|
||||
Changes the password for wallet encryption.
|
||||
*/
|
||||
rpc ChangeWalletPassword(ChangeWalletPasswordRequest) returns (google.protobuf.Empty);
|
||||
|
||||
/*
|
||||
Creates a new tenant which can be used to bake restricted macaroons.
|
||||
*/
|
||||
rpc CreateTenant(CreateTenantRequest) returns (Tenant);
|
||||
|
||||
/*
|
||||
Returns all tenants.
|
||||
*/
|
||||
rpc ListTenants(ListTenantsRequest) returns (ListTenantsResponse);
|
||||
|
||||
/*
|
||||
Get a specifiy tenant.
|
||||
*/
|
||||
rpc GetTenant(GetTenantRequest) returns (Tenant);
|
||||
|
||||
/*
|
||||
Bakes a new macaroon with the specified permissions.
|
||||
The macaroon can also be restricted to a specific tenant. In this case,
|
||||
- any swap or wallet created with the returned macaroon will belong to this tenant and can not be accessed by other tenants.
|
||||
- the lightning node connected to the daemon can not be used to pay or create invoices for swaps.
|
||||
*/
|
||||
rpc BakeMacaroon(BakeMacaroonRequest) returns (BakeMacaroonResponse);
|
||||
}
|
||||
|
||||
message CreateTenantRequest {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message ListTenantsRequest {}
|
||||
message ListTenantsResponse {
|
||||
repeated Tenant tenants = 1;
|
||||
}
|
||||
|
||||
message GetTenantRequest {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message Tenant {
|
||||
uint64 id = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
enum MacaroonAction {
|
||||
READ = 0;
|
||||
WRITE = 1;
|
||||
}
|
||||
|
||||
message MacaroonPermissions {
|
||||
MacaroonAction action = 2;
|
||||
}
|
||||
|
||||
message BakeMacaroonRequest {
|
||||
optional uint64 tenant_id = 1;
|
||||
repeated MacaroonPermissions permissions = 2;
|
||||
}
|
||||
message BakeMacaroonResponse {
|
||||
string macaroon = 1;
|
||||
}
|
||||
|
||||
enum SwapState {
|
||||
PENDING = 0;
|
||||
SUCCESSFUL= 1;
|
||||
|
||||
// Unknown client error. Check the error field of the message for more information
|
||||
ERROR = 2;
|
||||
|
||||
// Unknown server error. Check the status field of the message for more information
|
||||
SERVER_ERROR = 3;
|
||||
|
||||
// Client refunded locked coins after the HTLC timed out
|
||||
REFUNDED = 4;
|
||||
|
||||
// Client noticed that the HTLC timed out but didn't find any outputs to refund
|
||||
ABANDONED = 5;
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
BTC = 0;
|
||||
LBTC = 1;
|
||||
}
|
||||
|
||||
message Pair {
|
||||
Currency from = 1;
|
||||
Currency to = 2;
|
||||
}
|
||||
|
||||
message SwapInfo {
|
||||
string id = 1;
|
||||
Pair pair = 22;
|
||||
|
||||
SwapState state = 2;
|
||||
string error = 3;
|
||||
|
||||
// Latest status message of the Boltz backend
|
||||
string status = 4;
|
||||
string private_key = 5;
|
||||
string preimage = 6;
|
||||
string redeem_script = 7;
|
||||
string invoice = 8;
|
||||
string lockup_address = 9;
|
||||
uint64 expected_amount = 10;
|
||||
uint32 timeout_block_height = 11;
|
||||
string lockup_transaction_id = 12;
|
||||
/*
|
||||
If the swap times out or fails for some other reason, the damon will automatically refund the coins sent to the
|
||||
`lockup_address` back to the configured wallet or the address specified in the `refund_address` field.
|
||||
*/
|
||||
string refund_transaction_id = 13;
|
||||
optional string refund_address = 19;
|
||||
|
||||
repeated ChannelId chan_ids = 14;
|
||||
optional string blinding_key = 15;
|
||||
int64 created_at = 16;
|
||||
optional uint64 service_fee = 17;
|
||||
optional uint64 onchain_fee = 18;
|
||||
// internal wallet which was used to pay the swap
|
||||
optional uint64 wallet_id = 20;
|
||||
uint64 tenant_id = 21;
|
||||
}
|
||||
|
||||
enum SwapType {
|
||||
SUBMARINE = 0;
|
||||
REVERSE = 1;
|
||||
CHAIN = 2;
|
||||
}
|
||||
|
||||
message GetPairInfoRequest {
|
||||
SwapType type = 1;
|
||||
Pair pair = 2;
|
||||
}
|
||||
|
||||
message PairInfo {
|
||||
Pair pair = 1;
|
||||
SwapFees fees = 2;
|
||||
Limits limits = 3;
|
||||
string hash = 4;
|
||||
}
|
||||
|
||||
/*
|
||||
Channel creations are an optional extension to a submarine swap in the data types of boltz-client.
|
||||
*/
|
||||
message ChannelCreationInfo {
|
||||
option deprecated = true;
|
||||
// ID of the swap to which this channel channel belongs
|
||||
string swap_id = 1;
|
||||
string status = 2;
|
||||
uint32 inbound_liquidity = 3;
|
||||
bool private = 4;
|
||||
string funding_transaction_id = 5;
|
||||
uint32 funding_transaction_vout = 6;
|
||||
}
|
||||
message CombinedChannelSwapInfo {
|
||||
option deprecated = true;
|
||||
SwapInfo swap = 1;
|
||||
ChannelCreationInfo channel_creation = 2;
|
||||
}
|
||||
|
||||
message ReverseSwapInfo {
|
||||
string id = 1;
|
||||
|
||||
SwapState state = 2;
|
||||
string error = 3;
|
||||
|
||||
// Latest status message of the Boltz backend
|
||||
string status = 4;
|
||||
string private_key = 5;
|
||||
string preimage = 6;
|
||||
string redeem_script = 7;
|
||||
string invoice = 8;
|
||||
string claim_address = 9;
|
||||
int64 onchain_amount = 10;
|
||||
uint32 timeout_block_height = 11;
|
||||
string lockup_transaction_id = 12;
|
||||
string claim_transaction_id = 13;
|
||||
Pair pair = 14;
|
||||
repeated ChannelId chan_ids = 15;
|
||||
optional string blinding_key = 16;
|
||||
int64 created_at = 17;
|
||||
optional int64 paid_at = 23; // the time when the invoice was paid
|
||||
optional uint64 service_fee = 18;
|
||||
optional uint64 onchain_fee = 19;
|
||||
optional uint64 routing_fee_msat = 20;
|
||||
bool external_pay = 21;
|
||||
uint64 tenant_id = 22;
|
||||
}
|
||||
|
||||
message BlockHeights {
|
||||
uint32 btc = 1;
|
||||
optional uint32 liquid = 2;
|
||||
}
|
||||
|
||||
message GetInfoRequest {}
|
||||
message GetInfoResponse {
|
||||
string version = 9;
|
||||
string node = 10;
|
||||
string network = 2;
|
||||
string node_pubkey = 7;
|
||||
// one of: running, disabled, error
|
||||
string auto_swap_status = 11;
|
||||
// mapping of the currency to the latest block height.
|
||||
BlockHeights block_heights = 8;
|
||||
// swaps that need a manual interaction to refund
|
||||
repeated string refundable_swaps = 12;
|
||||
// the currently authenticated tenant
|
||||
optional Tenant tenant = 13;
|
||||
// swaps that need a manual interaction to claim
|
||||
repeated string claimable_swaps = 14;
|
||||
|
||||
string symbol = 1 [deprecated = true];
|
||||
string lnd_pubkey = 3 [deprecated = true];
|
||||
uint32 block_height = 4 [deprecated = true];
|
||||
repeated string pending_swaps = 5 [deprecated = true];
|
||||
repeated string pending_reverse_swaps = 6 [deprecated = true];
|
||||
}
|
||||
|
||||
message Limits {
|
||||
uint64 minimal = 1;
|
||||
uint64 maximal = 2;
|
||||
uint64 maximal_zero_conf_amount = 3;
|
||||
}
|
||||
|
||||
message SwapFees {
|
||||
double percentage = 1;
|
||||
uint64 miner_fees = 2;
|
||||
}
|
||||
|
||||
message GetPairsResponse {
|
||||
repeated PairInfo submarine = 1;
|
||||
repeated PairInfo reverse = 2;
|
||||
repeated PairInfo chain = 3;
|
||||
}
|
||||
|
||||
message MinerFees {
|
||||
uint32 normal = 1;
|
||||
uint32 reverse = 2;
|
||||
}
|
||||
message Fees {
|
||||
float percentage = 1;
|
||||
MinerFees miner = 2;
|
||||
}
|
||||
|
||||
message GetServiceInfoRequest {}
|
||||
|
||||
message GetServiceInfoResponse {
|
||||
Fees fees = 1;
|
||||
Limits limits = 2;
|
||||
}
|
||||
|
||||
enum IncludeSwaps {
|
||||
ALL = 0;
|
||||
MANUAL = 1;
|
||||
AUTO = 2;
|
||||
}
|
||||
|
||||
message ListSwapsRequest {
|
||||
optional Currency from = 1;
|
||||
optional Currency to = 2;
|
||||
optional SwapState state = 4;
|
||||
IncludeSwaps include = 5;
|
||||
}
|
||||
message ListSwapsResponse {
|
||||
repeated SwapInfo swaps = 1;
|
||||
repeated CombinedChannelSwapInfo channel_creations = 2;
|
||||
repeated ReverseSwapInfo reverse_swaps = 3;
|
||||
repeated ChainSwapInfo chain_swaps = 4;
|
||||
}
|
||||
|
||||
message GetStatsRequest {
|
||||
IncludeSwaps include = 1;
|
||||
}
|
||||
|
||||
message GetStatsResponse {
|
||||
SwapStats stats = 1;
|
||||
}
|
||||
|
||||
message RefundSwapRequest {
|
||||
string id = 1;
|
||||
oneof destination {
|
||||
string address = 2;
|
||||
uint64 wallet_id = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ClaimSwapsRequest {
|
||||
repeated string swap_ids = 1;
|
||||
oneof destination {
|
||||
string address = 2;
|
||||
uint64 wallet_id = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ClaimSwapsResponse {
|
||||
string transaction_id = 1;
|
||||
}
|
||||
|
||||
message GetSwapInfoRequest {
|
||||
string id = 1;
|
||||
}
|
||||
message GetSwapInfoResponse {
|
||||
SwapInfo swap = 1;
|
||||
ChannelCreationInfo channel_creation = 2;
|
||||
ReverseSwapInfo reverse_swap = 3;
|
||||
ChainSwapInfo chain_swap = 4;
|
||||
}
|
||||
|
||||
message DepositRequest {
|
||||
/*
|
||||
Percentage of inbound liquidity the channel that is opened in case the invoice cannot be paid should have.
|
||||
25 by default.
|
||||
*/
|
||||
uint32 inbound_liquidity = 1;
|
||||
}
|
||||
message DepositResponse {
|
||||
string id = 1;
|
||||
string address = 2;
|
||||
uint32 timeout_block_height = 3;
|
||||
}
|
||||
|
||||
message CreateSwapRequest {
|
||||
uint64 amount = 1;
|
||||
Pair pair = 2;
|
||||
// not yet supported
|
||||
// repeated string chan_ids = 3;
|
||||
|
||||
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field or any wallet otherwise.
|
||||
bool send_from_internal = 4;
|
||||
// address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise.
|
||||
optional string refund_address = 5;
|
||||
// wallet to pay swap from. only used if `send_from_internal` is set to true
|
||||
optional uint64 wallet_id = 6;
|
||||
// invoice to use for the swap. if not set, the daemon will get a new invoice from the lightning node
|
||||
optional string invoice = 7;
|
||||
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
|
||||
// when `zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
|
||||
optional bool zero_conf = 8;
|
||||
}
|
||||
message CreateSwapResponse {
|
||||
string id = 1;
|
||||
string address = 2;
|
||||
uint64 expected_amount = 3;
|
||||
string bip21 = 4;
|
||||
// lockup transaction id. Only populated when `send_from_internal` was specified in the request
|
||||
string tx_id = 5;
|
||||
uint32 timeout_block_height = 6;
|
||||
float timeout_hours = 7;
|
||||
}
|
||||
|
||||
message CreateChannelRequest {
|
||||
int64 amount = 1;
|
||||
|
||||
/*
|
||||
Percentage of inbound liquidity the channel that is opened should have.
|
||||
25 by default.
|
||||
*/
|
||||
uint32 inbound_liquidity = 2;
|
||||
bool private = 3;
|
||||
};
|
||||
|
||||
message CreateReverseSwapRequest {
|
||||
// amount of satoshis to swap
|
||||
uint64 amount = 1;
|
||||
// If no value is set, the daemon will query a new address from the lightning node
|
||||
string address = 2;
|
||||
// Whether the daemon should broadcast the claim transaction immediately after the lockup transaction is in the mempool.
|
||||
// Should only be used for smaller amounts as it involves trust in boltz.
|
||||
bool accept_zero_conf = 3;
|
||||
Pair pair = 4;
|
||||
// a list of channel ids which are allowed for paying the invoice. can be in either cln or lnd style.
|
||||
repeated string chan_ids = 5;
|
||||
// wallet from which the onchain address should be generated - only considered if `address` is not set
|
||||
optional uint64 wallet_id = 6;
|
||||
// Whether the daemon should return immediately after creating the swap or wait until the swap is successful or failed.
|
||||
// It will always return immediately if `accept_zero_conf` is not set.
|
||||
optional bool return_immediately = 7;
|
||||
// If set, the daemon will not pay the invoice of the swap and return the invoice to be paid. This implicitly sets `return_immediately` to true.
|
||||
optional bool external_pay = 8;
|
||||
// Description of the invoice which will be created for the swap
|
||||
optional string description = 9;
|
||||
}
|
||||
message CreateReverseSwapResponse {
|
||||
string id = 1;
|
||||
string lockup_address = 2;
|
||||
|
||||
// Only populated when zero-conf is accepted and return_immediately is set to false
|
||||
optional uint64 routing_fee_milli_sat = 3;
|
||||
// Only populated when zero-conf is accepted and return_immediately is set to false
|
||||
optional string claim_transaction_id = 4;
|
||||
// Invoice to be paid. Only populated when `external_pay` is set to true
|
||||
optional string invoice = 5;
|
||||
}
|
||||
|
||||
message CreateChainSwapRequest {
|
||||
// Amount of satoshis to swap. It is the amount expected to be sent to the lockup address.
|
||||
uint64 amount = 1;
|
||||
Pair pair = 2;
|
||||
// Address where funds will be swept to if the swap succeeds
|
||||
optional string to_address = 3;
|
||||
// Address where the coins should be refunded to if the swap fails.
|
||||
optional string refund_address = 4;
|
||||
// Wallet from which the swap should be paid from. Ignored if `external_pay` is set to true.
|
||||
// If the swap fails, funds will be refunded to this wallet as well.
|
||||
optional uint64 from_wallet_id = 5;
|
||||
// Wallet where the the funds will go if the swap succeeds.
|
||||
optional uint64 to_wallet_id = 6;
|
||||
// Whether the daemon should broadcast the claim transaction immediately after the lockup transaction is in the mempool.
|
||||
// Should only be used for smaller amounts as it involves trust in Boltz.
|
||||
optional bool accept_zero_conf = 7;
|
||||
// If set, the daemon will not pay the swap from an internal wallet.
|
||||
optional bool external_pay = 8;
|
||||
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
|
||||
// when `lockup_zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
|
||||
optional bool lockup_zero_conf = 9;
|
||||
}
|
||||
|
||||
message ChainSwapInfo {
|
||||
string id = 1;
|
||||
Pair pair = 2;
|
||||
SwapState state = 3;
|
||||
string error = 4;
|
||||
string status = 5;
|
||||
string preimage = 6;
|
||||
bool is_auto = 8;
|
||||
optional uint64 service_fee = 9;
|
||||
double service_fee_percent = 10;
|
||||
optional uint64 onchain_fee = 11;
|
||||
int64 created_at = 12;
|
||||
uint64 tenant_id = 13;
|
||||
ChainSwapData from_data = 14;
|
||||
ChainSwapData to_data = 15;
|
||||
}
|
||||
|
||||
message ChainSwapData {
|
||||
string id = 1;
|
||||
Currency currency = 2;
|
||||
string private_key = 3;
|
||||
string their_public_key = 4;
|
||||
uint64 amount = 6;
|
||||
uint32 timeout_block_height = 7;
|
||||
optional string lockup_transaction_id = 8;
|
||||
optional string transaction_id = 9;
|
||||
optional uint64 wallet_id = 20;
|
||||
optional string address = 12;
|
||||
optional string blinding_key = 13;
|
||||
string lockup_address = 14;
|
||||
}
|
||||
|
||||
message ChannelId {
|
||||
// cln style: 832347x2473x1
|
||||
string cln = 1;
|
||||
// lnd style: 915175205006540801
|
||||
uint64 lnd = 2;
|
||||
}
|
||||
|
||||
message LightningChannel {
|
||||
ChannelId id = 1;
|
||||
uint64 capacity = 2;
|
||||
uint64 outbound_sat = 3;
|
||||
uint64 inbound_sat = 4;
|
||||
string peer_id = 5;
|
||||
}
|
||||
|
||||
message SwapStats {
|
||||
uint64 total_fees = 1;
|
||||
uint64 total_amount = 2;
|
||||
uint64 avg_fees = 3;
|
||||
uint64 avg_amount = 4;
|
||||
uint64 count = 5;
|
||||
uint64 success_count = 6;
|
||||
}
|
||||
|
||||
message Budget {
|
||||
uint64 total = 1;
|
||||
int64 remaining = 2;
|
||||
int64 start_date = 3;
|
||||
int64 end_date = 4;
|
||||
}
|
||||
|
||||
message WalletCredentials {
|
||||
// only one of these is allowed to be present
|
||||
optional string mnemonic = 1;
|
||||
optional string xpub = 2;
|
||||
optional string core_descriptor = 3;
|
||||
|
||||
// only used in combination with mnemonic
|
||||
optional uint64 subaccount = 4;
|
||||
}
|
||||
|
||||
message WalletParams {
|
||||
string name = 1;
|
||||
Currency currency = 2;
|
||||
// the password to encrypt the wallet with. If there are existing encrypted wallets, the same password has to be used.
|
||||
optional string password = 3;
|
||||
}
|
||||
|
||||
message ImportWalletRequest {
|
||||
WalletCredentials credentials = 1;
|
||||
WalletParams params = 2;
|
||||
}
|
||||
|
||||
message CreateWalletRequest {
|
||||
WalletParams params = 2;
|
||||
}
|
||||
|
||||
message CreateWalletResponse {
|
||||
string mnemonic = 1;
|
||||
Wallet wallet = 2;
|
||||
}
|
||||
|
||||
message SetSubaccountRequest {
|
||||
uint64 wallet_id = 1;
|
||||
// The subaccount to use. If not set, a new one will be created.
|
||||
optional uint64 subaccount = 2;
|
||||
}
|
||||
|
||||
message GetSubaccountsRequest {
|
||||
uint64 wallet_id = 1;
|
||||
}
|
||||
|
||||
message GetSubaccountsResponse {
|
||||
optional uint64 current = 1;
|
||||
repeated Subaccount subaccounts = 2;
|
||||
}
|
||||
|
||||
message ImportWalletResponse {}
|
||||
|
||||
message GetWalletsRequest {
|
||||
optional Currency currency = 1;
|
||||
optional bool include_readonly = 2;
|
||||
}
|
||||
|
||||
message GetWalletRequest {
|
||||
optional string name = 1;
|
||||
optional uint64 id = 2;
|
||||
}
|
||||
|
||||
message GetWalletCredentialsRequest {
|
||||
uint64 id = 1;
|
||||
optional string password = 2;
|
||||
}
|
||||
|
||||
message RemoveWalletRequest {
|
||||
uint64 id = 1;
|
||||
}
|
||||
|
||||
|
||||
message Wallet {
|
||||
uint64 id = 1;
|
||||
string name = 2;
|
||||
Currency currency = 3;
|
||||
bool readonly = 4;
|
||||
Balance balance = 5;
|
||||
uint64 tenant_id = 6;
|
||||
}
|
||||
|
||||
message Wallets {
|
||||
repeated Wallet wallets = 1;
|
||||
}
|
||||
|
||||
message Balance {
|
||||
uint64 total = 1;
|
||||
uint64 confirmed = 2;
|
||||
uint64 unconfirmed = 3;
|
||||
}
|
||||
|
||||
message Subaccount {
|
||||
Balance balance = 1;
|
||||
uint64 pointer = 2;
|
||||
string type = 3;
|
||||
}
|
||||
|
||||
message RemoveWalletResponse {}
|
||||
|
||||
message UnlockRequest {
|
||||
string password = 1;
|
||||
}
|
||||
|
||||
message VerifyWalletPasswordRequest {
|
||||
string password = 1;
|
||||
}
|
||||
|
||||
message VerifyWalletPasswordResponse {
|
||||
bool correct = 1;
|
||||
}
|
||||
|
||||
message ChangeWalletPasswordRequest {
|
||||
string old = 1;
|
||||
string new = 2;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto
|
||||
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto
|
||||
@@ -0,0 +1,281 @@
|
||||
import base64
|
||||
|
||||
try:
|
||||
import breez_sdk # type: ignore
|
||||
|
||||
BREEZ_SDK_INSTALLED = True
|
||||
except ImportError:
|
||||
BREEZ_SDK_INSTALLED = False
|
||||
|
||||
if not BREEZ_SDK_INSTALLED:
|
||||
|
||||
class BreezSdkWallet: # pyright: ignore
|
||||
def __init__(self):
|
||||
raise RuntimeError(
|
||||
"Breez SDK is not installed. "
|
||||
"Ask admin to run `poetry install -E breez` to install it."
|
||||
)
|
||||
|
||||
else:
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11 as lnbits_bolt11
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
UnsupportedError,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
breez_event_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def load_bytes(source: str, extension: str) -> Optional[bytes]:
|
||||
# first check if it can be read from a file
|
||||
if source.split(".")[-1] == extension:
|
||||
with open(source, "rb") as f:
|
||||
source_bytes = f.read()
|
||||
return source_bytes
|
||||
else:
|
||||
# else check the source string can be converted from hex
|
||||
try:
|
||||
return bytes.fromhex(source)
|
||||
except ValueError:
|
||||
pass
|
||||
# else convert from base64
|
||||
try:
|
||||
return base64.b64decode(source)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def load_greenlight_credentials() -> (
|
||||
Optional[
|
||||
breez_sdk.GreenlightCredentials # pyright: ignore[reportUnboundVariable]
|
||||
]
|
||||
):
|
||||
if (
|
||||
settings.breez_greenlight_device_key
|
||||
and settings.breez_greenlight_device_cert
|
||||
):
|
||||
device_key_bytes = load_bytes(settings.breez_greenlight_device_key, "pem")
|
||||
device_cert_bytes = load_bytes(settings.breez_greenlight_device_cert, "crt")
|
||||
if not device_key_bytes or not device_cert_bytes:
|
||||
raise ValueError(
|
||||
"cannot initialize BreezSdkWallet: "
|
||||
"cannot decode breez_greenlight_device_key "
|
||||
"or breez_greenlight_device_cert"
|
||||
)
|
||||
return breez_sdk.GreenlightCredentials( # pyright: ignore[reportUnboundVariable]
|
||||
developer_key=list(device_key_bytes),
|
||||
developer_cert=list(device_cert_bytes),
|
||||
)
|
||||
return None
|
||||
|
||||
class SDKListener(
|
||||
breez_sdk.EventListener # pyright: ignore[reportUnboundVariable]
|
||||
):
|
||||
def on_event(self, event):
|
||||
logger.debug(event)
|
||||
breez_event_queue.put_nowait(event)
|
||||
|
||||
class BreezSdkWallet(Wallet): # type: ignore[no-redef]
|
||||
def __init__(self):
|
||||
if not settings.breez_greenlight_seed:
|
||||
raise ValueError(
|
||||
"cannot initialize BreezSdkWallet: missing breez_greenlight_seed"
|
||||
)
|
||||
if not settings.breez_api_key:
|
||||
raise ValueError(
|
||||
"cannot initialize BreezSdkWallet: missing breez_api_key"
|
||||
)
|
||||
if (
|
||||
settings.breez_greenlight_device_key
|
||||
and not settings.breez_greenlight_device_cert
|
||||
):
|
||||
raise ValueError(
|
||||
"cannot initialize BreezSdkWallet: "
|
||||
"missing breez_greenlight_device_cert"
|
||||
)
|
||||
if (
|
||||
settings.breez_greenlight_device_cert
|
||||
and not settings.breez_greenlight_device_key
|
||||
):
|
||||
raise ValueError(
|
||||
"cannot initialize BreezSdkWallet: "
|
||||
"missing breez_greenlight_device_key"
|
||||
)
|
||||
|
||||
self.config = breez_sdk.default_config(
|
||||
breez_sdk.EnvironmentType.PRODUCTION,
|
||||
settings.breez_api_key,
|
||||
breez_sdk.NodeConfig.GREENLIGHT(
|
||||
config=breez_sdk.GreenlightNodeConfig(
|
||||
partner_credentials=load_greenlight_credentials(),
|
||||
invite_code=settings.breez_greenlight_invite_code,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
breez_sdk_working_dir = Path(settings.lnbits_data_folder, "breez-sdk")
|
||||
breez_sdk_working_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
|
||||
|
||||
try:
|
||||
seed = breez_sdk.mnemonic_to_seed(settings.breez_greenlight_seed)
|
||||
connect_request = breez_sdk.ConnectRequest(self.config, seed)
|
||||
self.sdk_services = breez_sdk.connect(connect_request, SDKListener())
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise ValueError(f"cannot initialize BreezSdkWallet: {exc!s}") from exc
|
||||
|
||||
async def cleanup(self):
|
||||
self.sdk_services.disconnect()
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
node_info: breez_sdk.NodeState = self.sdk_services.node_info()
|
||||
except Exception as exc:
|
||||
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
|
||||
|
||||
return StatusResponse(None, int(node_info.channels_balance_msat))
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
# if description_hash or unhashed_description:
|
||||
# raise UnsupportedError("description_hash and unhashed_description")
|
||||
try:
|
||||
if description_hash and not unhashed_description:
|
||||
raise UnsupportedError(
|
||||
"'description_hash' unsupported by Greenlight, provide"
|
||||
" 'unhashed_description'"
|
||||
)
|
||||
breez_invoice: breez_sdk.ReceivePaymentResponse = (
|
||||
self.sdk_services.receive_payment(
|
||||
breez_sdk.ReceivePaymentRequest(
|
||||
amount * 1000, # breez uses msat
|
||||
(
|
||||
unhashed_description.decode()
|
||||
if unhashed_description
|
||||
else memo
|
||||
),
|
||||
preimage=kwargs.get("preimage"),
|
||||
opening_fee_params=None,
|
||||
use_description_hash=True if unhashed_description else None,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return InvoiceResponse(
|
||||
True,
|
||||
breez_invoice.ln_invoice.payment_hash,
|
||||
breez_invoice.ln_invoice.bolt11,
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return InvoiceResponse(False, None, None, str(e))
|
||||
|
||||
async def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
) -> PaymentResponse:
|
||||
invoice = lnbits_bolt11.decode(bolt11)
|
||||
|
||||
try:
|
||||
send_payment_request = breez_sdk.SendPaymentRequest(bolt11=bolt11)
|
||||
send_payment_response: breez_sdk.SendPaymentResponse = (
|
||||
self.sdk_services.send_payment(send_payment_request)
|
||||
)
|
||||
payment: breez_sdk.Payment = send_payment_response.payment
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
try:
|
||||
# try to report issue to Breez to improve LSP routing
|
||||
self.sdk_services.report_issue(
|
||||
breez_sdk.ReportIssueRequest.PAYMENT_FAILURE(
|
||||
breez_sdk.ReportPaymentFailureDetails(invoice.payment_hash)
|
||||
)
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.info(ex)
|
||||
# assume that payment failed?
|
||||
return PaymentResponse(
|
||||
False, None, None, None, f"payment failed: {exc}"
|
||||
)
|
||||
|
||||
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
|
||||
return PaymentResponse(False, None, None, None, "payment is pending")
|
||||
|
||||
# let's use the payment_hash as the checking_id
|
||||
checking_id = invoice.payment_hash
|
||||
|
||||
return PaymentResponse(
|
||||
True,
|
||||
checking_id,
|
||||
payment.fee_msat,
|
||||
payment.details.data.payment_preimage,
|
||||
None,
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
|
||||
checking_id
|
||||
)
|
||||
if payment is None:
|
||||
return PaymentPendingStatus()
|
||||
if payment.payment_type != breez_sdk.PaymentType.RECEIVED:
|
||||
logger.warning(f"unexpected payment type: {payment.status}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == breez_sdk.PaymentStatus.FAILED:
|
||||
return PaymentFailedStatus()
|
||||
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
|
||||
return PaymentSuccessStatus()
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
|
||||
checking_id
|
||||
)
|
||||
if payment is None:
|
||||
return PaymentPendingStatus()
|
||||
if payment.payment_type != breez_sdk.PaymentType.SENT:
|
||||
logger.warning(f"unexpected payment type: {payment.status}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=payment.fee_msat,
|
||||
preimage=payment.details.data.payment_preimage,
|
||||
)
|
||||
if payment.status == breez_sdk.PaymentStatus.FAILED:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
event = await breez_event_queue.get()
|
||||
if event.is_invoice_paid():
|
||||
yield event.details.payment_hash
|
||||
@@ -101,7 +101,7 @@ class CoreLightningWallet(Wallet):
|
||||
if unhashed_description and not self.supports_description_hash:
|
||||
raise UnsupportedError("unhashed_description")
|
||||
r: dict = self.ln.invoice( # type: ignore
|
||||
msatoshi=msat,
|
||||
amount_msat=msat,
|
||||
label=label,
|
||||
description=(
|
||||
unhashed_description.decode() if unhashed_description else memo
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from .macaroon import load_macaroon
|
||||
|
||||
__all__ = ["load_macaroon"]
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from typing import AsyncGenerator, Dict, List, Optional, Union, cast
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import secp256k1
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Util.Padding import pad, unpad
|
||||
from loguru import logger
|
||||
from websockets.client import connect as ws_connect
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class NWCError(Exception):
|
||||
"""
|
||||
An exception from NWC
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(self.__str__())
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} {self.message}"
|
||||
|
||||
|
||||
class NWCWallet(Wallet):
|
||||
"""
|
||||
A funding source that connects to a Nostr Wallet Connect (NWC) service provider.
|
||||
https://nwc.dev/
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.shutdown = False
|
||||
nwc_data = parse_nwc(settings.nwc_pairing_url)
|
||||
self.conn = NWCConnection(
|
||||
nwc_data["pubkey"], nwc_data["secret"], nwc_data["relay"]
|
||||
)
|
||||
# pending payments for paid_invoices_stream.
|
||||
# They are tracked until they expire or are settled
|
||||
self.pending_payments = []
|
||||
# interval in seconds between checks for pending payments
|
||||
self.pending_payments_lookup_interval = 10
|
||||
# track paid invoices for paid_invoices_stream
|
||||
self.paid_invoices_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
# This task periodically checks if pending payments have been settled
|
||||
self.pending_payments_lookup_task = asyncio.create_task(
|
||||
self._handle_pending_payments()
|
||||
)
|
||||
|
||||
def _is_shutting_down(self) -> bool:
|
||||
"""
|
||||
Returns True if the wallet is shutting down.
|
||||
"""
|
||||
return self.shutdown or not settings.lnbits_running
|
||||
|
||||
async def _handle_pending_payments(self):
|
||||
"""
|
||||
Periodically checks if any pending payments have been settled.
|
||||
"""
|
||||
while not self._is_shutting_down():
|
||||
await asyncio.sleep(self.pending_payments_lookup_interval)
|
||||
# Check if any pending payments have been settled or timed out
|
||||
now = time.time()
|
||||
for payment in self.pending_payments:
|
||||
try:
|
||||
if not payment["settled"]:
|
||||
payment_data = await self.conn.call(
|
||||
"lookup_invoice", {"payment_hash": payment["checking_id"]}
|
||||
)
|
||||
settled = (
|
||||
"settled_at" in payment_data
|
||||
and payment_data["settled_at"]
|
||||
and int(payment_data["settled_at"]) > 0
|
||||
and "preimage" in payment_data
|
||||
and payment_data["preimage"]
|
||||
)
|
||||
if settled:
|
||||
logger.debug(
|
||||
"Pending payment " + payment["checking_id"] + " settled"
|
||||
)
|
||||
payment["settled"] = True
|
||||
self.paid_invoices_queue.put_nowait(payment["checking_id"])
|
||||
except Exception as e:
|
||||
logger.error("Error handling pending payment: " + str(e))
|
||||
try:
|
||||
if now > payment["expires_at"]:
|
||||
logger.warning(
|
||||
"Pending payment " + payment["checking_id"] + " timed out"
|
||||
)
|
||||
payment["expired"] = True
|
||||
except Exception as e:
|
||||
logger.error("Error handling pending payment: " + str(e))
|
||||
|
||||
# Remove all settled or expired payments
|
||||
self.pending_payments = [
|
||||
payment
|
||||
for payment in self.pending_payments
|
||||
if not payment["settled"] and not payment["expired"]
|
||||
]
|
||||
|
||||
async def cleanup(self):
|
||||
self.shutdown = True
|
||||
try:
|
||||
self.pending_payments_lookup_task.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("Error cancelling pending payments lookup task: " + str(e))
|
||||
await self.conn.close()
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
desc = ""
|
||||
desc_hash = None
|
||||
if description_hash:
|
||||
desc_hash = description_hash.hex()
|
||||
desc = (unhashed_description or b"").decode()
|
||||
elif unhashed_description:
|
||||
desc = unhashed_description.decode()
|
||||
desc_hash = hashlib.sha256(desc.encode()).hexdigest()
|
||||
else:
|
||||
desc = memo or ""
|
||||
try:
|
||||
info = await self.conn.get_info()
|
||||
if "make_invoice" not in info["supported_methods"]:
|
||||
return InvoiceResponse(
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
"make_invoice is not supported by this NWC service.",
|
||||
)
|
||||
resp = await self.conn.call(
|
||||
"make_invoice",
|
||||
{
|
||||
"amount": int(amount * 1000), # nwc uses msats denominations
|
||||
"description_hash": desc_hash,
|
||||
"description": desc,
|
||||
},
|
||||
)
|
||||
checking_id = str(resp["payment_hash"])
|
||||
payment_request = resp.get("invoice", None)
|
||||
# if lookup_invoice is not supported, we can't track the payment
|
||||
if "lookup_invoice" in info["supported_methods"]:
|
||||
created_at = int(resp.get("created_at", time.time()))
|
||||
expires_at = int(resp.get("expires_at", created_at + 3600))
|
||||
self.pending_payments.append(
|
||||
{ # Start tracking
|
||||
"checking_id": checking_id,
|
||||
"expires_at": expires_at,
|
||||
"settled": False,
|
||||
"expired": False,
|
||||
}
|
||||
)
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
except Exception as e:
|
||||
return InvoiceResponse(ok=False, error_message=str(e))
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
info = await self.conn.get_info()
|
||||
if "get_balance" not in info["supported_methods"]:
|
||||
logger.debug("get_balance is not supported by this NWC service.")
|
||||
return StatusResponse(None, 0)
|
||||
resp = await self.conn.call("get_balance", {})
|
||||
balance = int(resp["balance"])
|
||||
return StatusResponse(None, balance)
|
||||
except Exception as e:
|
||||
return StatusResponse(str(e), 0)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
resp = await self.conn.call("pay_invoice", {"invoice": bolt11})
|
||||
preimage = resp.get("preimage", None)
|
||||
invoice_data = bolt11_decode(bolt11)
|
||||
payment_hash = invoice_data.payment_hash
|
||||
# pay_invoice doesn't return payment data, so we need
|
||||
# to call lookup_invoice too (if supported)
|
||||
info = await self.conn.get_info()
|
||||
|
||||
if "lookup_invoice" not in info["supported_methods"]:
|
||||
# if not supported, we assume it succeeded
|
||||
return PaymentResponse(True, payment_hash, None, preimage, None)
|
||||
|
||||
try:
|
||||
payment_data = await self.conn.call(
|
||||
"lookup_invoice", {"invoice": bolt11}
|
||||
)
|
||||
settled = payment_data.get("settled_at", None) and payment_data.get(
|
||||
"preimage", None
|
||||
)
|
||||
if not settled:
|
||||
return PaymentResponse(None, payment_hash, None, None, None)
|
||||
else:
|
||||
fee_msat = payment_data.get("fees_paid", None)
|
||||
return PaymentResponse(True, payment_hash, fee_msat, preimage, None)
|
||||
except Exception:
|
||||
# Workaround: some nwc service providers might not store the invoice
|
||||
# right away, so this call may raise an exception.
|
||||
# We will assume the payment is pending anyway
|
||||
return PaymentResponse(None, payment_hash, None, None, None)
|
||||
except NWCError as e:
|
||||
logger.error("Error paying invoice: " + str(e))
|
||||
failure_codes = [
|
||||
"RATE_LIMITED",
|
||||
"NOT_IMPLEMENTED",
|
||||
"INSUFFICIENT_BALANCE",
|
||||
"QUOTA_EXCEEDED",
|
||||
"RESTRICTED",
|
||||
"UNAUTHORIZED",
|
||||
"INTERNAL",
|
||||
"OTHER",
|
||||
"PAYMENT_FAILED",
|
||||
]
|
||||
failed = e.code in failure_codes
|
||||
return PaymentResponse(
|
||||
None if not failed else False,
|
||||
error_message=e.message if failed else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error paying invoice: " + str(e))
|
||||
# assume pending
|
||||
return PaymentResponse(None)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return await self.get_payment_status(checking_id)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
info = await self.conn.get_info()
|
||||
if "lookup_invoice" in info["supported_methods"]:
|
||||
payment_data = await self.conn.call(
|
||||
"lookup_invoice", {"payment_hash": checking_id}
|
||||
)
|
||||
settled = payment_data.get("settled_at", None) and payment_data.get(
|
||||
"preimage", None
|
||||
)
|
||||
fee_msat = payment_data.get("fees_paid", None)
|
||||
preimage = payment_data.get("preimage", None)
|
||||
created_at = int(payment_data.get("created_at", time.time()))
|
||||
expires_at = int(payment_data.get("expires_at", created_at + 3600))
|
||||
expired = expires_at and time.time() > expires_at
|
||||
if expired and not settled:
|
||||
return PaymentStatus(False, fee_msat=fee_msat, preimage=preimage)
|
||||
else:
|
||||
return PaymentStatus(
|
||||
True if settled else None, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
else:
|
||||
return PaymentStatus(None, fee_msat=None, preimage=None)
|
||||
except NWCError as e:
|
||||
logger.error("Error getting payment status: " + str(e))
|
||||
failed = e.code == "NOT_FOUND"
|
||||
return PaymentStatus(
|
||||
None if not failed else False, fee_msat=None, preimage=None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error getting payment status: " + str(e))
|
||||
# assume pending (eg. exception due to network error)
|
||||
return PaymentStatus(None, fee_msat=None, preimage=None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while not self._is_shutting_down():
|
||||
value = await self.paid_invoices_queue.get()
|
||||
yield value
|
||||
|
||||
|
||||
class NWCConnection:
|
||||
"""
|
||||
A connection to a Nostr Wallet Connect (NWC) service provider.
|
||||
"""
|
||||
|
||||
def __init__(self, pubkey, secret, relay):
|
||||
# Parse pairing url (if invalid an exception is raised)
|
||||
|
||||
# Extract keys (used to sign nwc events+identify NWC user)
|
||||
self.account_private_key = secp256k1.PrivateKey(bytes.fromhex(secret))
|
||||
self.account_private_key_hex = secret
|
||||
self.account_public_key = self.account_private_key.pubkey
|
||||
self.account_public_key_hex = self.account_public_key.serialize().hex()[2:]
|
||||
|
||||
# Extract service key (used for encryption to identify the nwc service provider)
|
||||
self.service_pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True)
|
||||
self.service_pubkey_hex = pubkey
|
||||
|
||||
# Extract relay url
|
||||
self.relay = relay
|
||||
|
||||
# Create temporary subscriptions, stored until the response is received/expires
|
||||
self.subscriptions = {}
|
||||
# Timeout in seconds after which a subscription is closed
|
||||
# if no response is received
|
||||
self.subscription_timeout = 10
|
||||
# Incremental counter to generate unique subscription ids for the connection
|
||||
self.subscriptions_count = 0
|
||||
|
||||
# websocket connection
|
||||
self.ws = None
|
||||
# if True the websocket is connected
|
||||
self.connected = False
|
||||
# if True the connection is shutting down
|
||||
self.shutdown = False
|
||||
|
||||
# cached info about the service provider
|
||||
self.info = None
|
||||
|
||||
# This task handles connection and reconnection to the relay
|
||||
self.connection_task = asyncio.create_task(self._connect_to_relay())
|
||||
|
||||
# This task periodically checks and removes subscriptions
|
||||
# and pending payments that have timed out
|
||||
self.timeout_task = asyncio.create_task(self._handle_timeouts())
|
||||
|
||||
logger.info(
|
||||
"NWCConnection is ready. relay: "
|
||||
+ self.relay
|
||||
+ " account: "
|
||||
+ self.account_public_key_hex
|
||||
+ " service: "
|
||||
+ self.service_pubkey_hex
|
||||
)
|
||||
|
||||
def _is_shutting_down(self) -> bool:
|
||||
"""
|
||||
Returns True if the connection is shutting down.
|
||||
"""
|
||||
return self.shutdown or not settings.lnbits_running
|
||||
|
||||
async def _send(self, data: List[Union[str, Dict]]):
|
||||
"""
|
||||
Sends data to the NWC relay.
|
||||
|
||||
Args:
|
||||
data (Dict): The data to be sent.
|
||||
"""
|
||||
if self._is_shutting_down():
|
||||
logger.warning("Trying to send data while shutting down")
|
||||
return
|
||||
if not self.ws:
|
||||
logger.warning("Trying to send data without a connection")
|
||||
return
|
||||
await self._wait_for_connection() # ensure the connection is established
|
||||
tx = json_dumps(data)
|
||||
await self.ws.send(tx)
|
||||
|
||||
def _get_new_subid(self) -> str:
|
||||
"""
|
||||
Generates a unique subscription id.
|
||||
|
||||
Returns:
|
||||
str: The generated 64 characters long subscription id (eg. lnbits0abc...)
|
||||
"""
|
||||
subid = "lnbits" + str(self.subscriptions_count)
|
||||
self.subscriptions_count += 1
|
||||
max_length = 64
|
||||
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
n = max_length - len(subid)
|
||||
if n > 0:
|
||||
for _ in range(n):
|
||||
subid += chars[random.randint(0, len(chars) - 1)]
|
||||
return subid
|
||||
|
||||
async def _close_subscription_by_subid(
|
||||
self, sub_id: str, send_event: bool = True
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Closes a subscription by its sub_id.
|
||||
|
||||
Args:
|
||||
sub_id (str): The subscription id.
|
||||
sendEvent (bool): If True, sends a CLOSE event to the relay.
|
||||
|
||||
Returns:
|
||||
Dict: The subscription that was closed.
|
||||
"""
|
||||
logger.debug("Closing subscription " + sub_id)
|
||||
sub_to_close = None
|
||||
for subscription in self.subscriptions.values():
|
||||
if subscription["sub_id"] == sub_id:
|
||||
sub_to_close = subscription
|
||||
# send CLOSE event to the relay if the subscription
|
||||
# is not already closed and sendEvent is True
|
||||
if not subscription["closed"] and send_event:
|
||||
await self._send(["CLOSE", sub_id])
|
||||
# mark as closed
|
||||
subscription["closed"] = True
|
||||
break
|
||||
# remove the subscription from the list
|
||||
if sub_to_close:
|
||||
self.subscriptions.pop(sub_to_close["event_id"], None)
|
||||
return sub_to_close
|
||||
|
||||
async def _close_subscription_by_eventid(
|
||||
self, event_id, send_event=True
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Closes a subscription associated to an event_id.
|
||||
|
||||
Args:
|
||||
event_id (str): The event id associated to the subscription.
|
||||
sendEvent (bool): If True, sends a CLOSE event to the relay.
|
||||
|
||||
Returns:
|
||||
Dict: The subscription that was closed.
|
||||
"""
|
||||
logger.debug("Closing subscription for event " + event_id)
|
||||
# find and remove the subscription
|
||||
subscription = self.subscriptions.pop(event_id, None)
|
||||
if subscription:
|
||||
# send CLOSE event to the relay if the subscription
|
||||
# is not already closed and sendEvent is True
|
||||
if not subscription["closed"] and send_event:
|
||||
await self._send(["CLOSE", subscription["sub_id"]])
|
||||
# mark as closed
|
||||
subscription["closed"] = True
|
||||
return subscription
|
||||
|
||||
async def _wait_for_connection(self):
|
||||
"""
|
||||
Waits until the connection is ready
|
||||
"""
|
||||
while not self.connected:
|
||||
if self._is_shutting_down():
|
||||
raise Exception("Connection is closing")
|
||||
logger.debug("Waiting for connection...")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _handle_timeouts(self):
|
||||
"""
|
||||
Periodically checks if any subscriptions and pending
|
||||
payments have timed out, and removes them.
|
||||
"""
|
||||
try:
|
||||
while not self._is_shutting_down():
|
||||
try:
|
||||
await asyncio.sleep(int(self.subscription_timeout * 0.5))
|
||||
# skip if connection is not established
|
||||
if not self.connected:
|
||||
continue
|
||||
# Find all subscriptions that have timed out
|
||||
now = time.time()
|
||||
subscriptions_to_close = []
|
||||
for subscription in self.subscriptions.values():
|
||||
t = now - subscription["timestamp"]
|
||||
if t > self.subscription_timeout:
|
||||
logger.warning(
|
||||
"Subscription " + subscription["sub_id"] + " timed out"
|
||||
)
|
||||
subscriptions_to_close.append(subscription["sub_id"])
|
||||
# if not already closed, pass the "time out"
|
||||
# exception to the future
|
||||
if not subscription["closed"]:
|
||||
subscription["future"].set_exception(
|
||||
Exception("timed out")
|
||||
)
|
||||
# Close all timed out subscriptions
|
||||
for sub_id in subscriptions_to_close:
|
||||
await self._close_subscription_by_subid(sub_id)
|
||||
except Exception as e:
|
||||
logger.error("Error handling subscription timeout: " + str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error handling subscription timeout: " + str(e))
|
||||
|
||||
async def _on_ok_message(self, msg: List[str]):
|
||||
"""
|
||||
Handles OK messages from the relay.
|
||||
"""
|
||||
event_id = msg[1]
|
||||
status = msg[2]
|
||||
info = (msg[3] or "") if len(msg) > 3 else ""
|
||||
if not status:
|
||||
# close subscription and pass an exception
|
||||
# if the event was rejected by the relay
|
||||
subscription = await self._close_subscription_by_eventid(event_id)
|
||||
if subscription: # Check if the subscription exists first
|
||||
subscription["future"].set_exception(Exception(info))
|
||||
|
||||
async def _on_event_message(self, msg: List[Union[str, Dict]]):
|
||||
"""
|
||||
Handles EVENT messages from the relay.
|
||||
"""
|
||||
sub_id = cast(str, msg[1])
|
||||
event = cast(Dict, msg[2])
|
||||
if not verify_event(event): # Ensure the event is valid (do not trust relays)
|
||||
raise Exception("Invalid event signature")
|
||||
tags = event["tags"]
|
||||
if event["kind"] == 13194: # An info event
|
||||
# info events are handled specially,
|
||||
# they are stored in the subscriptions list
|
||||
# using the subscription id for both sub_id and event_id
|
||||
subscription = await self._close_subscription_by_eventid(
|
||||
sub_id
|
||||
) # sub_id is the event_id for info events
|
||||
if subscription: # Check if the subscription exists first
|
||||
if (
|
||||
subscription["method"] != "info_sub"
|
||||
): # Ensure the subscription is for an info event
|
||||
raise Exception("Unexpected info event")
|
||||
# create an info dictionary with the supported
|
||||
# methods that is passed to the future
|
||||
content = event["content"]
|
||||
subscription["future"].set_result(
|
||||
{"supported_methods": content.split(" ")}
|
||||
)
|
||||
else: # A response event
|
||||
subscription = None
|
||||
# find the first "e" tag that is handled by
|
||||
# a registered subscription
|
||||
# Note: usually we expect only one "e" tag, but we are
|
||||
# handling multiple "e" tags just in case
|
||||
for tag in tags:
|
||||
if tag[0] == "e":
|
||||
subscription = await self._close_subscription_by_eventid(tag[1])
|
||||
if subscription:
|
||||
break
|
||||
# if a subscription was found, pass the result to the future
|
||||
if subscription:
|
||||
content = decrypt_content(
|
||||
event["content"], self.service_pubkey, self.account_private_key_hex
|
||||
)
|
||||
content = json.loads(content)
|
||||
result_type = content.get("result_type", "")
|
||||
error = content.get("error", None)
|
||||
result = content.get("result", None)
|
||||
if error: # if an error occurred, pass the error to the future
|
||||
nwc_exception = NWCError(error["code"], error["message"])
|
||||
subscription["future"].set_exception(nwc_exception)
|
||||
else:
|
||||
# ensure the result is for the expected method
|
||||
if result_type != subscription["method"]:
|
||||
raise Exception("Unexpected result type")
|
||||
if not result:
|
||||
raise Exception("Malformed response")
|
||||
else:
|
||||
subscription["future"].set_result(result)
|
||||
|
||||
async def _on_closed_message(self, msg: List[str]):
|
||||
"""
|
||||
Handles CLOSED messages from the relay.
|
||||
"""
|
||||
# The change is reflected in the subscriptions list.
|
||||
sub_id = msg[1]
|
||||
info = msg[2] or ""
|
||||
if info:
|
||||
logger.warning("Subscription " + sub_id + " closed remotely: " + info)
|
||||
# Note: sendEvent=false because the action was initiated by the relay
|
||||
await self._close_subscription_by_subid(sub_id, send_event=False)
|
||||
|
||||
async def _on_message(self, ws, message: str):
|
||||
"""
|
||||
Handle incoming messages from the relay.
|
||||
"""
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
if msg[0] == "OK": # Event status message
|
||||
await self._on_ok_message(msg)
|
||||
elif msg[0] == "EVENT": # Event message
|
||||
await self._on_event_message(msg)
|
||||
elif msg[0] == "EOSE":
|
||||
# Do nothing. No need to handle this message type for NWC
|
||||
pass
|
||||
elif msg[0] == "CLOSED":
|
||||
# Subscription was closed remotely.
|
||||
await self._on_closed_message(msg)
|
||||
elif msg[0] == "NOTICE":
|
||||
# A message from the relay, mostly useless, but we log it anyway
|
||||
logger.info("Notice from relay " + self.relay + ": " + str(msg[1]))
|
||||
else:
|
||||
raise Exception("Unknown message type")
|
||||
except Exception as e:
|
||||
logger.error("Error parsing event: " + str(e))
|
||||
|
||||
async def _connect_to_relay(self):
|
||||
"""
|
||||
Initiate websocket connection to the relay.
|
||||
"""
|
||||
logger.debug("Connecting to NWC relay " + self.relay)
|
||||
while (
|
||||
not self._is_shutting_down()
|
||||
): # Reconnect until the connection is shutting down
|
||||
logger.debug("Creating new connection...")
|
||||
try:
|
||||
async with ws_connect(self.relay) as ws:
|
||||
self.ws = ws
|
||||
self.connected = True
|
||||
while (
|
||||
not self._is_shutting_down()
|
||||
): # receive messages until the connection is shutting down
|
||||
try:
|
||||
reply = await ws.recv()
|
||||
reply_str = ""
|
||||
if isinstance(reply, bytes):
|
||||
reply_str = reply.decode("utf-8")
|
||||
else:
|
||||
reply_str = reply
|
||||
await self._on_message(ws, reply_str)
|
||||
except Exception as e:
|
||||
logger.debug("Error receiving message: " + str(e))
|
||||
break
|
||||
logger.debug("Connection to NWC relay closed")
|
||||
except Exception as e:
|
||||
logger.error("Error connecting to NWC relay: " + str(e))
|
||||
# the connection was closed, so we set the connected flag to False
|
||||
# this will make the methods calling _wait_for_connection()
|
||||
# to wait until the connection is re-established
|
||||
self.connected = False
|
||||
if not self._is_shutting_down():
|
||||
# Wait some time before reconnecting
|
||||
logger.debug("Reconnecting to NWC relay in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def call(self, method: str, params: Dict) -> Dict:
|
||||
"""
|
||||
Call a NWC method.
|
||||
|
||||
Args:
|
||||
method (str): The method name.
|
||||
params (Dict): The method parameters.
|
||||
|
||||
Returns:
|
||||
Dict: The result of the method call.
|
||||
"""
|
||||
await self._wait_for_connection()
|
||||
logger.debug("Calling " + method + " with params: " + str(params))
|
||||
# Prepare the content
|
||||
content = json_dumps(
|
||||
{
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
# Encrypt
|
||||
content = encrypt_content(
|
||||
content, self.service_pubkey, self.account_private_key_hex
|
||||
)
|
||||
# Prepare the NWC event
|
||||
event = {
|
||||
"kind": 23194,
|
||||
"content": content,
|
||||
"created_at": int(time.time()),
|
||||
"tags": [["p", self.service_pubkey_hex]],
|
||||
}
|
||||
# Sign
|
||||
sign_event(event, self.account_public_key_hex, self.account_private_key)
|
||||
# Subscribe for a response to this event
|
||||
sub_filter = {
|
||||
"kinds": [23195],
|
||||
"#p": [self.account_public_key_hex],
|
||||
"#e": [event["id"]],
|
||||
"since": event["created_at"],
|
||||
}
|
||||
sub_id = self._get_new_subid()
|
||||
# register a future to receive the response asynchronously
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
# Check if the subscription already exists
|
||||
# (this means there is a bug somewhere, should not happen)
|
||||
if event["id"] in self.subscriptions:
|
||||
raise Exception("Subscription for this event id already exists?")
|
||||
# Store the subscription in the list
|
||||
self.subscriptions[event["id"]] = {
|
||||
"method": method,
|
||||
"future": future,
|
||||
"sub_id": sub_id,
|
||||
"event_id": event["id"],
|
||||
"timestamp": time.time(),
|
||||
"closed": False,
|
||||
}
|
||||
# Send the events
|
||||
await self._send(["REQ", sub_id, sub_filter])
|
||||
await self._send(["EVENT", event])
|
||||
# Wait for the response
|
||||
return await future
|
||||
|
||||
async def get_info(self) -> Dict:
|
||||
"""
|
||||
Get the info about the service provider and cache it.
|
||||
|
||||
Returns:
|
||||
Dict: The info about the service provider.
|
||||
"""
|
||||
if not self.info: # if not cached
|
||||
try:
|
||||
await self._wait_for_connection()
|
||||
# Prepare filter to request the info note
|
||||
sub_filter = {"kinds": [13194], "authors": [self.service_pubkey_hex]}
|
||||
# We register a special subscription using the sub_id as the event_id
|
||||
sub_id = self._get_new_subid()
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.subscriptions[sub_id] = {
|
||||
"method": "info_sub",
|
||||
"future": future,
|
||||
"sub_id": sub_id,
|
||||
"event_id": sub_id,
|
||||
"timestamp": time.time(),
|
||||
"closed": False,
|
||||
}
|
||||
# Send the request
|
||||
await self._send(["REQ", sub_id, sub_filter])
|
||||
# Wait for the response
|
||||
service_info = await future
|
||||
# Get account info when possible
|
||||
if "get_info" in service_info["supported_methods"]:
|
||||
try:
|
||||
account_info = await self.call("get_info", {})
|
||||
# cache
|
||||
self.info = service_info
|
||||
self.info["alias"] = account_info.get("alias", "")
|
||||
self.info["color"] = account_info.get("color", "")
|
||||
self.info["pubkey"] = account_info.get("pubkey", "")
|
||||
self.info["network"] = account_info.get("network", "")
|
||||
self.info["block_height"] = account_info.get("block_height", 0)
|
||||
self.info["block_hash"] = account_info.get("block_hash", "")
|
||||
self.info["supported_methods"] = account_info.get(
|
||||
"methods",
|
||||
service_info.get("supported_methods", ["pay_invoice"]),
|
||||
)
|
||||
except Exception as e:
|
||||
# If there is an error, fallback to using service info
|
||||
logger.error(
|
||||
"Error getting account info: "
|
||||
+ str(e)
|
||||
+ " Using service info only"
|
||||
)
|
||||
self.info = service_info
|
||||
else:
|
||||
# get_info is not supported,
|
||||
# so we will make do with the service info
|
||||
self.info = service_info # cache
|
||||
except Exception as e:
|
||||
logger.error("Error getting info: " + str(e))
|
||||
# The error could mean that the service provider does
|
||||
# not provide an info note
|
||||
# So we just assume it supports the bare minimum to be Nip47 compliant
|
||||
self.info = {
|
||||
"supported_methods": ["pay_invoice"],
|
||||
}
|
||||
return self.info
|
||||
|
||||
async def close(self):
|
||||
logger.debug("Closing NWCConnection")
|
||||
self.shutdown = True # Mark for shutdown
|
||||
# cancel all tasks
|
||||
try:
|
||||
self.timeout_task.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("Error cancelling subscription timeout task: " + str(e))
|
||||
try:
|
||||
self.connection_task.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("Error cancelling connection task: " + str(e))
|
||||
# close the websocket
|
||||
try:
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
except Exception as e:
|
||||
logger.warning("Error closing connection: " + str(e))
|
||||
|
||||
|
||||
def parse_nwc(nwc) -> Dict:
|
||||
"""
|
||||
Parses a NWC URL (nostr+walletconnect://...) and extracts relevant information.
|
||||
|
||||
Args:
|
||||
nwc (str): The Nostr Wallet Connect URL to be parsed.
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: A dict containing:'pubkey', 'relay', and 'secret'.
|
||||
If the URL is invalid, an exception is raised.
|
||||
|
||||
Example:
|
||||
>>> parse_nwc("nostr+walletconnect://000000...000000?relay=example.com&secret=123")
|
||||
{'pubkey': '000000...000000', 'relay': 'example.com', 'secret': '123'}
|
||||
"""
|
||||
data = {}
|
||||
prefix = "nostr+walletconnect://"
|
||||
if nwc and nwc.startswith(prefix):
|
||||
nwc = nwc[len(prefix) :]
|
||||
parsed_url = urlparse(nwc)
|
||||
data["pubkey"] = parsed_url.path
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
for key, value in query_params.items():
|
||||
if key in ["relay", "secret"] and value:
|
||||
data[key] = unquote(value[0])
|
||||
if "pubkey" not in data or "relay" not in data or "secret" not in data:
|
||||
raise ValueError("Invalid NWC pairing url")
|
||||
else:
|
||||
raise ValueError("Invalid NWC pairing url")
|
||||
return data
|
||||
|
||||
|
||||
def json_dumps(data: Union[Dict, list]) -> str:
|
||||
"""
|
||||
Converts a Python dictionary to a JSON string with compact encoding.
|
||||
|
||||
Args:
|
||||
data (Dict): The dictionary to be converted.
|
||||
|
||||
Returns:
|
||||
str: The compact JSON string.
|
||||
"""
|
||||
if isinstance(data, Dict):
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def encrypt_content(
|
||||
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
|
||||
) -> str:
|
||||
"""
|
||||
Encrypts the content to be sent to the service.
|
||||
|
||||
Args:
|
||||
content (str): The content to be encrypted.
|
||||
service_pubkey (secp256k1.PublicKey): The service provider's public key.
|
||||
account_private_key_hex (str): The account private key in hex format.
|
||||
|
||||
Returns:
|
||||
str: The encrypted content.
|
||||
"""
|
||||
shared = service_pubkey.tweak_mul(
|
||||
bytes.fromhex(account_private_key_hex)
|
||||
).serialize()[1:]
|
||||
# random iv (16B)
|
||||
iv = Random.new().read(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)
|
||||
|
||||
# Encrypt
|
||||
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(
|
||||
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
|
||||
) -> str:
|
||||
"""
|
||||
Decrypts the content coming from the service.
|
||||
|
||||
Args:
|
||||
content (str): The encrypted content.
|
||||
service_pubkey (secp256k1.PublicKey): The service provider's public key.
|
||||
account_private_key_hex (str): The account private key in hex format.
|
||||
|
||||
Returns:
|
||||
str: The decrypted content.
|
||||
"""
|
||||
shared = service_pubkey.tweak_mul(
|
||||
bytes.fromhex(account_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(event: Dict) -> bool:
|
||||
"""
|
||||
Verify the event signature
|
||||
|
||||
Args:
|
||||
event (Dict): The event to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if the event signature is valid, False otherwise.
|
||||
"""
|
||||
signature_data = json_dumps(
|
||||
[
|
||||
0,
|
||||
event["pubkey"],
|
||||
event["created_at"],
|
||||
event["kind"],
|
||||
event["tags"],
|
||||
event["content"],
|
||||
]
|
||||
)
|
||||
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||
if event_id != event["id"]: # Invalid event id
|
||||
return False
|
||||
pubkey_hex = event["pubkey"]
|
||||
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||
if not pubkey.schnorr_verify(
|
||||
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sign_event(
|
||||
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
|
||||
) -> Dict:
|
||||
"""
|
||||
Signs the event (in place) with the service secret
|
||||
|
||||
Args:
|
||||
event (Dict): The event to be signed.
|
||||
account_public_key_hex (str): The account public key in hex format.
|
||||
account_private_key (secp256k1.PrivateKey): The account private key.
|
||||
|
||||
Returns:
|
||||
Dict: The input event with the signature added.
|
||||
"""
|
||||
signature_data = json_dumps(
|
||||
[
|
||||
0,
|
||||
account_public_key_hex,
|
||||
event["created_at"],
|
||||
event["kind"],
|
||||
event["tags"],
|
||||
event["content"],
|
||||
]
|
||||
)
|
||||
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||
event["id"] = event_id
|
||||
event["pubkey"] = account_public_key_hex
|
||||
|
||||
signature = (
|
||||
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
|
||||
).hex()
|
||||
event["sig"] = signature
|
||||
return event
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
@@ -13,7 +14,6 @@ from .base import (
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
UnsupportedError,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
@@ -64,18 +64,23 @@ class ZBDWallet(Wallet):
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
# https://api.zebedee.io/v0/charges
|
||||
if description_hash or unhashed_description:
|
||||
raise UnsupportedError("description_hash")
|
||||
|
||||
msats_amount = amount * 1000
|
||||
data: Dict = {
|
||||
"amount": f"{msats_amount}",
|
||||
"description": memo,
|
||||
"expiresIn": 3600,
|
||||
"callbackUrl": "",
|
||||
"internalId": "",
|
||||
}
|
||||
|
||||
## handle description_hash and unhashed for ZBD
|
||||
if description_hash:
|
||||
data["description"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["description"] = memo or ""
|
||||
|
||||
r = await self.client.post(
|
||||
"charges",
|
||||
json=data,
|
||||
|
||||
Generated
+18
-14
@@ -7,9 +7,9 @@
|
||||
"name": "lnbits",
|
||||
"dependencies": {
|
||||
"@chenfengyuan/vue-qrcode": "1.0.2",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "2.9",
|
||||
"moment": "^2.29.4",
|
||||
"axios": "^1.7.5",
|
||||
"chart.js": "^2.9.4",
|
||||
"moment": "^2.30.1",
|
||||
"quasar": "1.13.2",
|
||||
"showdown": "^2.1.0",
|
||||
"underscore": "^1.13.6",
|
||||
@@ -22,7 +22,7 @@
|
||||
"devDependencies": {
|
||||
"concat": "^1.0.3",
|
||||
"minify": "^9.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"pyright": "1.1.289",
|
||||
"sass": "^1.60.0"
|
||||
}
|
||||
@@ -149,11 +149,12 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz",
|
||||
"integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
|
||||
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
@@ -749,9 +750,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
@@ -863,10 +865,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -887,6 +890,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.289.tgz",
|
||||
"integrity": "sha512-fG3STxnwAt3i7bxbXUPJdYNFrcOWHLwCSEOySH2foUqtYdzWLcxDez0Kgl1X8LMQx0arMJ6HRkKghxfRD1/z6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"pyright": "index.js",
|
||||
"pyright-langserver": "langserver.index.js"
|
||||
|
||||
+4
-4
@@ -12,15 +12,15 @@
|
||||
"devDependencies": {
|
||||
"concat": "^1.0.3",
|
||||
"minify": "^9.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"pyright": "1.1.289",
|
||||
"sass": "^1.60.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenfengyuan/vue-qrcode": "1.0.2",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "2.9",
|
||||
"moment": "^2.29.4",
|
||||
"axios": "^1.7.5",
|
||||
"chart.js": "^2.9.4",
|
||||
"moment": "^2.30.1",
|
||||
"quasar": "1.13.2",
|
||||
"showdown": "^2.1.0",
|
||||
"underscore": "^1.13.6",
|
||||
|
||||
Generated
+658
-584
File diff suppressed because it is too large
Load Diff
+50
-49
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "lnbits"
|
||||
version = "0.12.9"
|
||||
version = "0.12.12-rc1"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
readme = "README.md"
|
||||
@@ -12,78 +12,79 @@ packages = [
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10 | ^3.9"
|
||||
python = "^3.12 | ^3.11 | ^3.10 | ^3.9"
|
||||
bech32 = "1.2.0"
|
||||
click = "8.1.7"
|
||||
ecdsa = "0.18.0"
|
||||
fastapi = "0.109.2"
|
||||
httpx = "0.25.0"
|
||||
ecdsa = "0.19.0"
|
||||
fastapi = "0.112.0"
|
||||
httpx = "0.27.0"
|
||||
jinja2 = "3.1.4"
|
||||
lnurl = "0.4.2"
|
||||
psycopg2-binary = "2.9.7"
|
||||
lnurl = "0.5.3"
|
||||
psycopg2-binary = "2.9.9"
|
||||
pydantic = "1.10.17"
|
||||
pyqrcode = "1.2.1"
|
||||
shortuuid = "1.0.11"
|
||||
shortuuid = "1.0.13"
|
||||
sqlalchemy = "1.3.24"
|
||||
sqlalchemy-aio = "0.17.0"
|
||||
sse-starlette = "1.6.5"
|
||||
typing-extensions = "4.8.0"
|
||||
uvicorn = "0.23.2"
|
||||
sse-starlette = "1.8.2"
|
||||
typing-extensions = "4.12.2"
|
||||
uvicorn = "0.30.5"
|
||||
uvloop = "0.19.0"
|
||||
websockets = "11.0.3"
|
||||
loguru = "0.7.2"
|
||||
grpcio = "1.62.2"
|
||||
protobuf = "4.24.3"
|
||||
pyln-client = "23.8"
|
||||
pywebpush = "1.14.0"
|
||||
grpcio = "1.65.5"
|
||||
protobuf = "5.27.3"
|
||||
pyln-client = "24.5"
|
||||
pywebpush = "1.14.1"
|
||||
slowapi = "0.1.9"
|
||||
websocket-client = "1.6.3"
|
||||
pycryptodomex = "3.19.1"
|
||||
packaging = "23.1"
|
||||
bolt11 = "2.0.6"
|
||||
websocket-client = "1.8.0"
|
||||
pycryptodomex = "3.20.0"
|
||||
packaging = "24.0"
|
||||
bolt11 = "2.1.0"
|
||||
# needed for new login methods: username-password, google-auth, github-auth
|
||||
bcrypt = "^4.1.1"
|
||||
python-jose = "^3.3.0"
|
||||
passlib = "^1.7.4"
|
||||
itsdangerous = "^2.1.2"
|
||||
fastapi-sso = "^0.9.1"
|
||||
bcrypt = "4.2.0"
|
||||
pyjwt = "2.9.0"
|
||||
passlib = "1.7.4"
|
||||
itsdangerous = "2.2.0"
|
||||
fastapi-sso = "0.15.0"
|
||||
# needed for boltz, lnurldevice, watchonly extensions
|
||||
embit = "0.7.0"
|
||||
embit = "0.8.0"
|
||||
# needed for cashu, lnurlp, nostrclient, nostrmarket, nostrrelay extensions
|
||||
secp256k1 = "0.14.0"
|
||||
# keep for backwards compatibility with lnurlp and cashu
|
||||
environs = "9.5.0"
|
||||
# needed for scheduler extension
|
||||
python-crontab = "3.0.0"
|
||||
python-crontab = "3.2.0"
|
||||
# needed for liquid support boltz
|
||||
wallycore = {version = "^1.0.0", optional = true}
|
||||
wallycore = {version = "1.3.0", optional = true}
|
||||
# needed for breez funding source
|
||||
breez-sdk = {version = "0.5.2", optional = true}
|
||||
|
||||
[tool.poetry.extras]
|
||||
breez = ["breez-sdk"]
|
||||
liquid = ["wallycore"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^24.3.0"
|
||||
pytest-asyncio = "^0.21.0"
|
||||
pytest = "^7.3.2"
|
||||
black = "^24.8.0"
|
||||
# we are stuck with pytest-asyncio 0.21.x because of https://github.com/pytest-dev/pytest-asyncio/issues/706
|
||||
pytest-asyncio = "^0.21.2"
|
||||
pytest = "^8.3.2"
|
||||
pytest-cov = "^4.1.0"
|
||||
mypy = "^1.5.1"
|
||||
types-protobuf = "^4.24.0.2"
|
||||
pre-commit = "^3.2.2"
|
||||
openapi-spec-validator = "^0.6.0"
|
||||
ruff = "^0.3.2"
|
||||
# not our dependency but needed indirectly by openapi-spec-validator
|
||||
# we want to use 0.10.3 because newer versions are broken on nix
|
||||
rpds-py = "0.10.3"
|
||||
types-passlib = "^1.7.7.13"
|
||||
types-python-jose = "^3.3.4.8"
|
||||
openai = "^1.12.0"
|
||||
json5 = "^0.9.17"
|
||||
mypy = "^1.11.1"
|
||||
types-protobuf = "^5.27.0.20240626"
|
||||
pre-commit = "^3.8.0"
|
||||
openapi-spec-validator = "^0.7.1"
|
||||
ruff = "^0.5.7"
|
||||
types-passlib = "^1.7.7.20240327"
|
||||
openai = "^1.39.0"
|
||||
json5 = "^0.9.25"
|
||||
asgi-lifespan = "^2.1.0"
|
||||
pytest-md = "^0.2.0"
|
||||
pytest-httpserver = "^1.0.10"
|
||||
pytest-httpserver = "^1.1.0"
|
||||
pytest-mock = "^3.14.0"
|
||||
types-mock = "^5.1.0.20240311"
|
||||
types-mock = "^5.1.0.20240425"
|
||||
mock = "^5.1.0"
|
||||
grpcio-tools = "^1.65.5"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
@@ -100,6 +101,7 @@ include = [
|
||||
"tools",
|
||||
]
|
||||
exclude = [
|
||||
"lnbits/wallets/boltz_grpc_files",
|
||||
"lnbits/wallets/lnd_grpc_files",
|
||||
"lnbits/extensions",
|
||||
"lnbits/upgrades",
|
||||
@@ -112,6 +114,7 @@ files = [
|
||||
"tools",
|
||||
]
|
||||
exclude = [
|
||||
"^lnbits/wallets/boltz_grpc_files",
|
||||
"^lnbits/wallets/lnd_grpc_files",
|
||||
"^lnbits/extensions",
|
||||
"^lnbits/upgrades",
|
||||
@@ -159,6 +162,7 @@ extend-exclude = """(
|
||||
| lnbits/extensions
|
||||
| lnbits/upgrades
|
||||
| lnbits/wallets/lnd_grpc_files
|
||||
| lnbits/wallets/boltz_grpc_files
|
||||
)"""
|
||||
|
||||
[tool.ruff]
|
||||
@@ -167,7 +171,8 @@ line-length = 88
|
||||
|
||||
# Exclude generated files.
|
||||
extend-exclude = [
|
||||
"lnbits/wallets/lnd_grpc_files"
|
||||
"lnbits/wallets/lnd_grpc_files",
|
||||
"lnbits/wallets/boltz_grpc_files"
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
@@ -200,10 +205,6 @@ classmethod-decorators = [
|
||||
"root_validator",
|
||||
]
|
||||
|
||||
# Ignore unused imports in __init__.py files.
|
||||
[tool.ruff.lint.extend-per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403"]
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
# TODO: Decrease this to 10.
|
||||
max-complexity = 16
|
||||
|
||||
@@ -150,6 +150,21 @@ async def test_create_invoice_fiat_amount(client, inkey_headers_to):
|
||||
assert extra["fiat_rate"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("currency", ("msat", "RRR"))
|
||||
async def test_create_invoice_validates_used_currency(
|
||||
currency, client, inkey_headers_to
|
||||
):
|
||||
data = await get_random_invoice_data()
|
||||
data["unit"] = currency
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||
)
|
||||
assert response.status_code == 400
|
||||
res_data = response.json()
|
||||
assert "The provided unit is not supported" in res_data["detail"]
|
||||
|
||||
|
||||
# check POST /api/v1/payments: invoice creation for internal payments only
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_internal_invoice(client, inkey_headers_to):
|
||||
|
||||
+4
-2
@@ -19,7 +19,7 @@ from lnbits.core.crud import (
|
||||
get_user,
|
||||
update_payment_status,
|
||||
)
|
||||
from lnbits.core.models import CreateInvoice
|
||||
from lnbits.core.models import CreateInvoice, PaymentState
|
||||
from lnbits.core.services import update_wallet_balance
|
||||
from lnbits.core.views.payment_api import api_payments_create_invoice
|
||||
from lnbits.db import DB_TYPE, SQLITE, Database
|
||||
@@ -199,7 +199,9 @@ async def fake_payments(client, adminkey_headers_from):
|
||||
"/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict()
|
||||
)
|
||||
assert response.is_success
|
||||
await update_payment_status(response.json()["checking_id"], pending=False)
|
||||
await update_payment_status(
|
||||
response.json()["checking_id"], status=PaymentState.SUCCESS
|
||||
)
|
||||
|
||||
params = {"time[ge]": ts, "time[le]": time()}
|
||||
return fake_data, params
|
||||
|
||||
@@ -10,6 +10,10 @@ from lnbits.db import DB_TYPE, POSTGRES, FromRowModel
|
||||
from lnbits.wallets import get_funding_source, set_funding_source
|
||||
|
||||
|
||||
class FakeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DbTestModel(FromRowModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -5,12 +5,12 @@ import pytest
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import get_standalone_payment, update_payment_details
|
||||
from lnbits.core.models import CreateInvoice, Payment
|
||||
from lnbits.core.models import CreateInvoice, Payment, PaymentState
|
||||
from lnbits.core.services import fee_reserve_total, get_balance_delta
|
||||
from lnbits.core.views.payment_api import api_payment
|
||||
from lnbits.tasks import create_task, wait_for_paid_invoices
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
from ..helpers import is_fake, is_regtest
|
||||
from ..helpers import FakeError, is_fake, is_regtest
|
||||
from .helpers import (
|
||||
cancel_invoice,
|
||||
get_real_invoice,
|
||||
@@ -80,29 +80,30 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
|
||||
payment_status = response.json()
|
||||
assert not payment_status["paid"]
|
||||
|
||||
async def listen():
|
||||
found_checking_id = False
|
||||
async for checking_id in get_funding_source().paid_invoices_stream():
|
||||
if checking_id == invoice["checking_id"]:
|
||||
found_checking_id = True
|
||||
return
|
||||
assert found_checking_id
|
||||
async def on_paid(payment: Payment):
|
||||
|
||||
task = asyncio.create_task(listen())
|
||||
await asyncio.sleep(1)
|
||||
assert payment.checking_id == invoice["payment_hash"]
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
await asyncio.sleep(1)
|
||||
balance = await get_node_balance_sats()
|
||||
assert balance - prev_balance == create_invoice.amount
|
||||
|
||||
# exit out of infinite loop
|
||||
raise FakeError()
|
||||
|
||||
task = create_task(wait_for_paid_invoices("test_create_invoice", on_paid)())
|
||||
pay_real_invoice(invoice["payment_request"])
|
||||
await asyncio.wait_for(task, timeout=10)
|
||||
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
await asyncio.sleep(1)
|
||||
balance = await get_node_balance_sats()
|
||||
assert balance - prev_balance == create_invoice.amount
|
||||
# wait for the task to exit
|
||||
with pytest.raises(FakeError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -127,10 +128,11 @@ async def test_pay_real_invoice_set_pending_and_check_state(
|
||||
assert len(invoice["checking_id"]) > 0
|
||||
|
||||
# check the payment status
|
||||
response = await api_payment(
|
||||
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response["paid"]
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
# make sure that the backend also thinks it's paid
|
||||
funding_source = get_funding_source()
|
||||
@@ -140,22 +142,9 @@ async def test_pay_real_invoice_set_pending_and_check_state(
|
||||
# get the outgoing payment from the db
|
||||
payment = await get_standalone_payment(invoice["payment_hash"])
|
||||
assert payment
|
||||
assert payment.success
|
||||
assert payment.pending is False
|
||||
|
||||
# set the outgoing invoice to pending
|
||||
await update_payment_details(payment.checking_id, pending=True)
|
||||
|
||||
payment_pending = await get_standalone_payment(invoice["payment_hash"])
|
||||
assert payment_pending
|
||||
assert payment_pending.pending is True
|
||||
|
||||
# check the outgoing payment status
|
||||
await payment.check_status()
|
||||
|
||||
payment_not_pending = await get_standalone_payment(invoice["payment_hash"])
|
||||
assert payment_not_pending
|
||||
assert payment_not_pending.pending is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
@@ -229,9 +218,11 @@ async def test_pay_hold_invoice_check_pending_and_fail(
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# payment should not be in database anymore
|
||||
# payment should be in database as failed
|
||||
payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash)
|
||||
assert payment_db_after_settlement is None
|
||||
assert payment_db_after_settlement
|
||||
assert payment_db_after_settlement.pending is False
|
||||
assert payment_db_after_settlement.failed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -272,15 +263,10 @@ async def test_pay_hold_invoice_check_pending_and_fail_cancel_payment_task_in_me
|
||||
payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash)
|
||||
assert payment_db_after_settlement is not None
|
||||
|
||||
# status should still be available and be False
|
||||
# payment is failed
|
||||
status = await payment_db.check_status()
|
||||
assert not status.paid
|
||||
|
||||
# now the payment should be gone after the status check
|
||||
# payment_db_after_status_check = await get_standalone_payment(
|
||||
# invoice_obj.payment_hash
|
||||
# )
|
||||
# assert payment_db_after_status_check is None
|
||||
assert status.failed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -304,60 +290,44 @@ async def test_receive_real_invoice_set_pending_and_check_state(
|
||||
)
|
||||
assert response.status_code < 300
|
||||
invoice = response.json()
|
||||
response = await api_payment(
|
||||
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert not response["paid"]
|
||||
payment_status = response.json()
|
||||
assert not payment_status["paid"]
|
||||
|
||||
async def listen():
|
||||
found_checking_id = False
|
||||
async for checking_id in get_funding_source().paid_invoices_stream():
|
||||
if checking_id == invoice["checking_id"]:
|
||||
found_checking_id = True
|
||||
return
|
||||
assert found_checking_id
|
||||
async def on_paid(payment: Payment):
|
||||
assert payment.checking_id == invoice["payment_hash"]
|
||||
|
||||
task = asyncio.create_task(listen())
|
||||
await asyncio.sleep(1)
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
assert payment
|
||||
assert payment.pending is False
|
||||
|
||||
# set the incoming invoice to pending
|
||||
await update_payment_details(payment.checking_id, status=PaymentState.PENDING)
|
||||
|
||||
payment_pending = await get_standalone_payment(
|
||||
invoice["payment_hash"], incoming=True
|
||||
)
|
||||
assert payment_pending
|
||||
assert payment_pending.pending is True
|
||||
assert payment_pending.success is False
|
||||
assert payment_pending.failed is False
|
||||
|
||||
# exit out of infinite loop
|
||||
raise FakeError()
|
||||
|
||||
task = create_task(wait_for_paid_invoices("test_create_invoice", on_paid)())
|
||||
pay_real_invoice(invoice["payment_request"])
|
||||
await asyncio.wait_for(task, timeout=10)
|
||||
response = await api_payment(
|
||||
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
|
||||
)
|
||||
assert response["paid"]
|
||||
|
||||
# get the incoming payment from the db
|
||||
payment = await get_standalone_payment(invoice["payment_hash"], incoming=True)
|
||||
assert payment
|
||||
assert payment.pending is False
|
||||
|
||||
# set the incoming invoice to pending
|
||||
await update_payment_details(payment.checking_id, pending=True)
|
||||
|
||||
payment_pending = await get_standalone_payment(
|
||||
invoice["payment_hash"], incoming=True
|
||||
)
|
||||
assert payment_pending
|
||||
assert payment_pending.pending is True
|
||||
|
||||
# check the incoming payment status
|
||||
await payment.check_status()
|
||||
|
||||
payment_not_pending = await get_standalone_payment(
|
||||
invoice["payment_hash"], incoming=True
|
||||
)
|
||||
assert payment_not_pending
|
||||
assert payment_not_pending.pending is False
|
||||
|
||||
# verify we get the same result if we use the checking_id to look up the payment
|
||||
payment_by_checking_id = await get_standalone_payment(
|
||||
payment_not_pending.checking_id, incoming=True
|
||||
)
|
||||
|
||||
assert payment_by_checking_id
|
||||
assert payment_by_checking_id.pending is False
|
||||
assert payment_by_checking_id.bolt11 == payment_not_pending.bolt11
|
||||
assert payment_by_checking_id.payment_hash == payment_not_pending.payment_hash
|
||||
with pytest.raises(FakeError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -160,7 +160,8 @@ async def test_peer_management(node_client):
|
||||
|
||||
response = await node_client.delete(f"/node/api/v1/peers/{peer_id}")
|
||||
assert response.status_code == 200
|
||||
await asyncio.sleep(0.1)
|
||||
# lndrest is slow to remove the peer
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
response = await node_client.get("/node/api/v1/peers")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
{
|
||||
"funding_sources": {
|
||||
"nwc": {
|
||||
"wallet_class": "NWCWallet",
|
||||
"settings": {
|
||||
"nwc_pairing_url": "nostr+walletconnect://be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3?relay=ws://127.0.0.1:8555&secret=d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3"
|
||||
},
|
||||
"mock_settings": {
|
||||
"service_public_key": "be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3",
|
||||
"service_private_key": "ad6a224c9c2f2a7ac7181092348d99671a94f28974e331e0f0afe3bcdab72bed",
|
||||
"user_public_key": "bef38893a567e717110478cc5729f4bf881727f5a7d7859edad2b2b21ee81f7e",
|
||||
"user_private_key": "d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3",
|
||||
"port": 8555,
|
||||
"supported_methods": [
|
||||
"get_balance",
|
||||
"make_invoice",
|
||||
"pay_invoice",
|
||||
"lookup_invoice"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"functions": {
|
||||
"status": {
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"get_balance": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {},
|
||||
"expect": {
|
||||
"error_message": null,
|
||||
"balance_msat": 55000
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"get_balance": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "get_balance",
|
||||
"params": {}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "get_balance",
|
||||
"result": {
|
||||
"balance": 55000
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "error",
|
||||
"call_params": {},
|
||||
"expect": {
|
||||
"error_message": "TEST ERROR test-error",
|
||||
"balance_msat": 0
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"get_balance": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "get_balance",
|
||||
"params": {}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "get_balance",
|
||||
"error": {
|
||||
"code": "TEST ERROR",
|
||||
"message": "test-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"create_invoice": {
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"make_invoice": {}
|
||||
},
|
||||
"nwc-bad": {
|
||||
"make_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
},
|
||||
"expect": {
|
||||
"error_message": null,
|
||||
"success": true,
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n"
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"make_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "make_invoice",
|
||||
"params": {
|
||||
"amount": 555000,
|
||||
"description": "Test Invoice"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "make_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n",
|
||||
"description": "Test Invoice",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 555000,
|
||||
"fees_paid": 0,
|
||||
"created_at": 0,
|
||||
"expires_at": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "error",
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
},
|
||||
"expect": {
|
||||
"error_message": "TEST ERROR test-error",
|
||||
"success": false,
|
||||
"checking_id": null,
|
||||
"payment_request": null
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"make_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "make_invoice",
|
||||
"params": {
|
||||
"amount": 555000,
|
||||
"description": "Test Invoice"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "make_invoice",
|
||||
"error": {
|
||||
"code": "TEST ERROR",
|
||||
"message": "test-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"pay_invoice": {
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"pay_invoice": {},
|
||||
"lookup_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {
|
||||
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"fee_limit_msat": 25000
|
||||
},
|
||||
"expect": {
|
||||
"error_message": null,
|
||||
"success": true,
|
||||
"pending": false,
|
||||
"failed": false,
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"fee_msat": 50,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"pay_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "pay_invoice",
|
||||
"result": {
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "outgoing",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test invoice",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 1000,
|
||||
"fees_paid": 50,
|
||||
"created_at": 0,
|
||||
"settled_at": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "failed",
|
||||
"call_params": {
|
||||
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"fee_limit_msat": 25000
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"pending": false,
|
||||
"failed": true,
|
||||
"checking_id": null,
|
||||
"fee_msat": null,
|
||||
"preimage": null
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"pay_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "pay_invoice",
|
||||
"error": {
|
||||
"code": "TEST ERROR",
|
||||
"message": "test-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"description": "error",
|
||||
"call_params": {
|
||||
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"fee_limit_msat": 25000
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"pending": true,
|
||||
"failed": false,
|
||||
"checking_id": null,
|
||||
"fee_msat": null,
|
||||
"preimage": null,
|
||||
"error_message": "TEST ERROR test-error"
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"pay_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "pay_invoice",
|
||||
"error": {
|
||||
"code": "TEST ERROR",
|
||||
"message": "test-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"get_invoice_status": {
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"lookup_invoice": {}
|
||||
},
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "paid",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"success": true,
|
||||
"failed": false,
|
||||
"pending": false
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 123,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"settled_at": 1,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "failed",
|
||||
"description1": "pending should be false in the 'expect', this is a bug",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"failed": true,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": [
|
||||
{
|
||||
"description": "nwc.py doesn't handle the 'failed' status for `get_invoice_status`"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "pending",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 0,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"get_payment_status": {
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"lookup_invoice": {}
|
||||
},
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "paid",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"fee_msat": 1000,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"success": true,
|
||||
"failed": false,
|
||||
"pending": false
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 1000,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"settled_at": 1,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "failed",
|
||||
"description1": "pending should be false in the 'expect', this is a bug",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"preimage": null,
|
||||
"success": false,
|
||||
"failed": true,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": [
|
||||
{
|
||||
"description": "nwc.py doesn't handle the 'failed' status for `get_payment_status`"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "pending",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"preimage": null,
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 0,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"funding_sources": {
|
||||
"nwc-bad": {
|
||||
"description": "NWC service that supports only pay_invoice",
|
||||
"wallet_class": "NWCWallet",
|
||||
"settings": {
|
||||
"nwc_pairing_url": "nostr+walletconnect://be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3?relay=ws://127.0.0.1:8555&secret=d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3"
|
||||
},
|
||||
"mock_settings": {
|
||||
"service_public_key": "be927be01ce2b3ab0fc33ffec6c6ab590381d7fc883a392d163d3966fc5840b3",
|
||||
"service_private_key": "ad6a224c9c2f2a7ac7181092348d99671a94f28974e331e0f0afe3bcdab72bed",
|
||||
"user_public_key": "bef38893a567e717110478cc5729f4bf881727f5a7d7859edad2b2b21ee81f7e",
|
||||
"user_private_key": "d1b1d3b0f4a1fcba4c15094d34ff0569cae3c8c7af939b3473ccd564cce3bfa3",
|
||||
"port": 8555,
|
||||
"supported_methods": ["pay_invoice"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"functions": {
|
||||
"status": {
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"get_balance": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {},
|
||||
"expect": {
|
||||
"error_message": null,
|
||||
"balance_msat": 0
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"create_invoice": {
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"make_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
},
|
||||
"expect": {
|
||||
"error_message": "make_invoice is not supported by this NWC service.",
|
||||
"success": false
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"pay_invoice": {
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"pay_invoice": {},
|
||||
"lookup_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {
|
||||
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"fee_limit_msat": 25000
|
||||
},
|
||||
"expect": {
|
||||
"error_message": null,
|
||||
"success": true,
|
||||
"pending": false,
|
||||
"failed": false,
|
||||
"checking_id": "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925",
|
||||
"fee_msat": 50,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"pay_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "pay_invoice",
|
||||
"result": {
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "failed",
|
||||
"call_params": {
|
||||
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"fee_limit_msat": 25000
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"pending": false,
|
||||
"failed": true,
|
||||
"checking_id": null,
|
||||
"fee_msat": null,
|
||||
"preimage": null
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"pay_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "pay_invoice",
|
||||
"error": {
|
||||
"code": "TEST ERROR",
|
||||
"message": "test-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "error",
|
||||
"call_params": {
|
||||
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"fee_limit_msat": 25000
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"pending": true,
|
||||
"failed": false,
|
||||
"checking_id": null,
|
||||
"fee_msat": null,
|
||||
"preimage": null,
|
||||
"error_message": "TEST ERROR test-error"
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"pay_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "pay_invoice",
|
||||
"error": {
|
||||
"code": "TEST ERROR",
|
||||
"message": "test-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"get_invoice_status": {
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "paid",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 123,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"settled_at": 1,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "failed",
|
||||
"description1": "pending should be false in the 'expect', this is a bug",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": [
|
||||
{
|
||||
"description": "nwc.py doesn't handle the 'failed' status for `get_invoice_status`"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "pending",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 0,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"get_payment_status": {
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": {}
|
||||
}
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"description": "paid",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"fee_msat": null,
|
||||
"preimage": null,
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 1000,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"settled_at": 1,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "failed",
|
||||
"description1": "pending should be false in the 'expect', this is a bug",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"preimage": null,
|
||||
"success": false,
|
||||
"failed": true,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": [
|
||||
{
|
||||
"description": "nwc.py doesn't handle the 'failed' status for `get_payment_status`"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "pending",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
},
|
||||
"expect": {
|
||||
"preimage": null,
|
||||
"success": false,
|
||||
"failed": false,
|
||||
"pending": true
|
||||
},
|
||||
"mocks": {
|
||||
"nwc-bad": {
|
||||
"lookup_invoice": [
|
||||
{
|
||||
"request_type": "json",
|
||||
"request_body": {
|
||||
"method": "lookup_invoice",
|
||||
"params": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"result_type": "lookup_invoice",
|
||||
"result": {
|
||||
"type": "incoming",
|
||||
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
|
||||
"description": "Test Invoice",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"amount": 123,
|
||||
"fees_paid": 0,
|
||||
"created_at": 0,
|
||||
"expires_at": 0,
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"settings": {
|
||||
"corelightning_rest_url": "http://127.0.0.1:8555",
|
||||
"corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn",
|
||||
"corelightning_rest_cert": false,
|
||||
"user_agent": "LNbits/Tests"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"msatoshi": 555000
|
||||
"amount_msat": 555000
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -470,7 +470,7 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"msatoshi": 555000
|
||||
"amount_msat": 555000
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -491,7 +491,7 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"msatoshi": 555000
|
||||
"amount_msat": 555000
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -559,7 +559,7 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"msatoshi": 555000
|
||||
"amount_msat": 555000
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -601,7 +601,7 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"msatoshi": 555000
|
||||
"amount_msat": 555000
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
|
||||
@@ -8,6 +8,7 @@ class FundingSourceConfig(BaseModel):
|
||||
skip: Optional[bool]
|
||||
wallet_class: str
|
||||
settings: dict
|
||||
mock_settings: Optional[dict]
|
||||
|
||||
|
||||
class FunctionMock(BaseModel):
|
||||
|
||||
@@ -26,7 +26,7 @@ def wallet_fixtures_from_json(path) -> List["WalletTest"]:
|
||||
fn_tests = _tests_for_function(funding_sources, fn_name, fn)
|
||||
_merge_dict_of_lists(tests, fn_tests)
|
||||
|
||||
all_tests: list["WalletTest"] = functools.reduce(
|
||||
all_tests: list[WalletTest] = functools.reduce(
|
||||
operator.iadd, [tests[fs_name] for fs_name in tests], []
|
||||
)
|
||||
return all_tests
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, cast
|
||||
|
||||
import pytest
|
||||
import secp256k1
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Util.Padding import pad, unpad
|
||||
from websockets.server import serve as ws_serve
|
||||
|
||||
from lnbits.wallets.nwc import NWCWallet
|
||||
from tests.wallets.helpers import (
|
||||
WalletTest,
|
||||
build_test_id,
|
||||
check_assertions,
|
||||
load_funding_source,
|
||||
wallet_fixtures_from_json,
|
||||
)
|
||||
|
||||
|
||||
def encrypt_content(priv_key, dest_pub_key, content):
|
||||
p = secp256k1.PublicKey(bytes.fromhex("02" + dest_pub_key), True)
|
||||
shared = p.tweak_mul(bytes.fromhex(priv_key)).serialize()[1:]
|
||||
iv = Random.new().read(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(priv_key, source_pub_key, content):
|
||||
p = secp256k1.PublicKey(bytes.fromhex("02" + source_pub_key), True)
|
||||
shared = p.tweak_mul(bytes.fromhex(priv_key)).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)
|
||||
return decrypted_bytes.decode("utf-8")
|
||||
|
||||
|
||||
def json_dumps(data):
|
||||
if isinstance(data, Dict):
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def sign_event(pub_key, priv_key, event):
|
||||
signature_data = json_dumps(
|
||||
[
|
||||
0,
|
||||
pub_key,
|
||||
event["created_at"],
|
||||
event["kind"],
|
||||
event["tags"],
|
||||
event["content"],
|
||||
]
|
||||
)
|
||||
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||
event["id"] = event_id
|
||||
event["pubkey"] = pub_key
|
||||
s = secp256k1.PrivateKey(bytes.fromhex(priv_key))
|
||||
signature = (s.schnorr_sign(bytes.fromhex(event_id), None, raw=True)).hex()
|
||||
event["sig"] = signature
|
||||
return event
|
||||
|
||||
|
||||
async def handle(wallet, mock_settings, data, websocket, path):
|
||||
async for message in websocket:
|
||||
if not wallet:
|
||||
continue
|
||||
msg = json.loads(message)
|
||||
if msg[0] == "REQ":
|
||||
sub_id = msg[1]
|
||||
sub_filter = msg[2]
|
||||
kinds = sub_filter["kinds"]
|
||||
if 13194 in kinds: # Send info event
|
||||
event = {
|
||||
"kind": 13194,
|
||||
"content": " ".join(mock_settings["supported_methods"]),
|
||||
"created_at": int(time.time()),
|
||||
"tags": [],
|
||||
}
|
||||
sign_event(
|
||||
mock_settings["service_public_key"],
|
||||
mock_settings["service_private_key"],
|
||||
event,
|
||||
)
|
||||
await websocket.send(json.dumps(["EVENT", sub_id, event]))
|
||||
elif msg[0] == "EVENT":
|
||||
event = msg[1]
|
||||
decrypted_content = decrypt_content(
|
||||
mock_settings["service_private_key"],
|
||||
mock_settings["user_public_key"],
|
||||
event["content"],
|
||||
)
|
||||
content = json.loads(decrypted_content)
|
||||
mock = None
|
||||
for m in data.mocks:
|
||||
rb = m.request_body
|
||||
if rb and rb["method"] == content["method"]:
|
||||
p1 = rb["params"]
|
||||
p2 = content["params"]
|
||||
p1 = json_dumps({k: v for k, v in p1.items() if v is not None})
|
||||
p2 = json_dumps({k: v for k, v in p2.items() if v is not None})
|
||||
if p1 == p2:
|
||||
mock = m
|
||||
break
|
||||
if mock:
|
||||
sub_id = None
|
||||
nwcwallet = cast(NWCWallet, wallet)
|
||||
for subscription in nwcwallet.conn.subscriptions.values():
|
||||
if subscription["event_id"] == event["id"]:
|
||||
sub_id = subscription["sub_id"]
|
||||
break
|
||||
if sub_id:
|
||||
response = mock.response
|
||||
encrypted_content = encrypt_content(
|
||||
mock_settings["service_private_key"],
|
||||
mock_settings["user_public_key"],
|
||||
json_dumps(response),
|
||||
)
|
||||
response_event = {
|
||||
"kind": 23195,
|
||||
"content": encrypted_content,
|
||||
"created_at": int(time.time()),
|
||||
"tags": [
|
||||
["e", event["id"]],
|
||||
["p", mock_settings["user_public_key"]],
|
||||
],
|
||||
}
|
||||
sign_event(
|
||||
mock_settings["service_public_key"],
|
||||
mock_settings["service_private_key"],
|
||||
response_event,
|
||||
)
|
||||
await websocket.send(json.dumps(["EVENT", sub_id, response_event]))
|
||||
else:
|
||||
raise Exception(
|
||||
"No mock found for "
|
||||
+ content["method"]
|
||||
+ " "
|
||||
+ json_dumps(content["params"])
|
||||
)
|
||||
|
||||
|
||||
async def run(data: WalletTest):
|
||||
if data.skip:
|
||||
pytest.skip()
|
||||
|
||||
wallet = None
|
||||
mock_settings = data.funding_source.mock_settings
|
||||
if mock_settings is None:
|
||||
return
|
||||
|
||||
async def handler(websocket, path):
|
||||
return await handle(wallet, mock_settings, data, websocket, path)
|
||||
|
||||
if mock_settings is not None:
|
||||
async with ws_serve(handler, "localhost", mock_settings["port"]) as server:
|
||||
await server.start_serving()
|
||||
wallet = load_funding_source(data.funding_source)
|
||||
await check_assertions(wallet, data)
|
||||
nwcwallet = cast(NWCWallet, wallet)
|
||||
await nwcwallet.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"test_data",
|
||||
wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_nwc.json"),
|
||||
ids=build_test_id,
|
||||
)
|
||||
async def test_nwc_wallet(test_data: WalletTest):
|
||||
await run(test_data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"test_data",
|
||||
wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_nwc_bad.json"),
|
||||
ids=build_test_id,
|
||||
)
|
||||
async def test_nwc_wallet_bad(test_data: WalletTest):
|
||||
await run(test_data)
|
||||
@@ -3,6 +3,7 @@ from typing import Dict, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
from pytest_httpserver import HTTPServer
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
@@ -34,14 +35,27 @@ def httpserver_listen_address():
|
||||
ids=build_test_id,
|
||||
)
|
||||
async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest):
|
||||
if test_data.skip:
|
||||
pytest.skip()
|
||||
test_id = build_test_id(test_data)
|
||||
logger.info(f"[{test_id}]: test start")
|
||||
try:
|
||||
if test_data.skip:
|
||||
logger.info(f"[{test_id}]: test skip")
|
||||
pytest.skip()
|
||||
|
||||
for mock in test_data.mocks:
|
||||
_apply_mock(httpserver, mock)
|
||||
logger.info(f"[{test_id}]: apply {len(test_data.mocks)} mocks")
|
||||
for mock in test_data.mocks:
|
||||
_apply_mock(httpserver, mock)
|
||||
|
||||
wallet = load_funding_source(test_data.funding_source)
|
||||
await check_assertions(wallet, test_data)
|
||||
logger.info(f"[{test_id}]: load funding source")
|
||||
wallet = load_funding_source(test_data.funding_source)
|
||||
|
||||
logger.info(f"[{test_id}]: check assertions")
|
||||
await check_assertions(wallet, test_data)
|
||||
except Exception as exc:
|
||||
logger.info(f"[{test_id}]: test failed: {exc}")
|
||||
raise exc
|
||||
finally:
|
||||
logger.info(f"[{test_id}]: test end")
|
||||
|
||||
|
||||
def _apply_mock(httpserver: HTTPServer, mock: Mock):
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Dict, List, Optional
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models import BaseWallet
|
||||
@@ -24,19 +25,35 @@ from tests.wallets.helpers import (
|
||||
ids=build_test_id,
|
||||
)
|
||||
async def test_wallets(mocker: MockerFixture, test_data: WalletTest):
|
||||
if test_data.skip:
|
||||
pytest.skip()
|
||||
test_id = build_test_id(test_data)
|
||||
logger.info(f"[{test_id}]: test start")
|
||||
|
||||
for mock in test_data.mocks:
|
||||
_apply_rpc_mock(mocker, mock)
|
||||
try:
|
||||
if test_data.skip:
|
||||
logger.info(f"[{test_id}]: test skip")
|
||||
pytest.skip()
|
||||
|
||||
wallet = load_funding_source(test_data.funding_source)
|
||||
logger.info(f"[{test_id}]: apply {len(test_data.mocks)} mocks")
|
||||
for mock in test_data.mocks:
|
||||
_apply_rpc_mock(mocker, mock)
|
||||
|
||||
expected_calls = _spy_mocks(mocker, test_data, wallet)
|
||||
logger.info(f"[{test_id}]: load funding source")
|
||||
wallet = load_funding_source(test_data.funding_source)
|
||||
|
||||
await check_assertions(wallet, test_data)
|
||||
logger.info(f"[{test_id}]: spy mocks")
|
||||
expected_calls = _spy_mocks(mocker, test_data, wallet)
|
||||
|
||||
_check_calls(expected_calls)
|
||||
logger.info(f"[{test_id}]: check assertions")
|
||||
await check_assertions(wallet, test_data)
|
||||
|
||||
logger.info(f"[{test_id}]: check calls")
|
||||
_check_calls(expected_calls)
|
||||
|
||||
except Exception as exc:
|
||||
logger.info(f"[{test_id}]: test failed: {exc}")
|
||||
raise exc
|
||||
finally:
|
||||
logger.info(f"[{test_id}]: test end")
|
||||
|
||||
|
||||
def _apply_rpc_mock(mocker: MockerFixture, mock: RpcMock):
|
||||
|
||||
@@ -55,8 +55,8 @@ cursor.execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra, webhook, expiry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
amount, status, memo, fee, extra, webhook, expiry, pending)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
@@ -65,12 +65,13 @@ cursor.execute(
|
||||
"test_admin_internal",
|
||||
None,
|
||||
amount * 1000,
|
||||
False,
|
||||
"success",
|
||||
"test_admin_internal",
|
||||
0,
|
||||
None,
|
||||
"",
|
||||
expiration_date,
|
||||
False, # TODO: remove this in next release
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY env var not set"
|
||||
|
||||
def load_language(lang: str) -> dict:
|
||||
s = open(f"lnbits/static/i18n/{lang}.js").read()
|
||||
prefix = "window.localisation.%s = {\n" % lang
|
||||
prefix = f"window.localisation.{lang} = {{\n"
|
||||
assert s.startswith(prefix)
|
||||
s = s[len(prefix) - 2 :]
|
||||
json = json5.loads(s)
|
||||
@@ -28,15 +28,15 @@ def load_language(lang: str) -> dict:
|
||||
|
||||
def save_language(lang: str, data) -> None:
|
||||
with open(f"lnbits/static/i18n/{lang}.js", "w") as f:
|
||||
f.write("window.localisation.%s = {\n" % lang)
|
||||
f.write(f"window.localisation.{lang} = {{\n")
|
||||
row = 0
|
||||
for k, v in data.items():
|
||||
row += 1
|
||||
f.write(" %s:\n" % k)
|
||||
f.write(f" {k}:\n")
|
||||
if "'" in v:
|
||||
f.write(' "%s"' % v)
|
||||
f.write(f' "{v}"')
|
||||
else:
|
||||
f.write(" '%s'" % v)
|
||||
f.write(f" '{v}'")
|
||||
if row == len(data):
|
||||
f.write("\n")
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user