Merge branch 'lnbits:main' into main

This commit is contained in:
blackcoffeexbt 2022-08-30 19:37:48 +01:00 committed by GitHub
commit f41565cdf8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 8641 additions and 439 deletions

View file

@ -25,6 +25,8 @@ LNBITS_DATA_FOLDER="./data"
LNBITS_FORCE_HTTPS=true LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0" LNBITS_SERVICE_FEE="0.0"
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent
# Change theme # Change theme
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"

View file

@ -9,9 +9,9 @@ jobs:
postgres: postgres:
image: postgres:latest image: postgres:latest
env: env:
POSTGRES_USER: postgres POSTGRES_USER: lnbits
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: lnbits
POSTGRES_DB: postgres POSTGRES_DB: migration
ports: ports:
# maps tcp port 5432 on service container to the host # maps tcp port 5432 on service container to the host
- 5432:5432 - 5432:5432
@ -36,11 +36,4 @@ jobs:
sudo apt install unzip sudo apt install unzip
- name: Run migrations - name: Run migrations
run: | run: |
rm -rf ./data make test-migration
mkdir -p ./data
export LNBITS_DATA_FOLDER="./data"
unzip tests/data/mock_data.zip -d ./data
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
export LNBITS_DATABASE_URL="postgres://postgres:postgres@0.0.0.0:5432/postgres"
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
poetry run python tools/conv.py

View file

@ -17,6 +17,7 @@ jobs:
- uses: abatilo/actions-poetry@v2.1.3 - uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest - name: Setup Regtest
run: | run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker cd docker
chmod +x ./tests chmod +x ./tests
@ -55,6 +56,7 @@ jobs:
- uses: abatilo/actions-poetry@v2.1.3 - uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest - name: Setup Regtest
run: | run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker cd docker
chmod +x ./tests chmod +x ./tests
@ -95,6 +97,7 @@ jobs:
- uses: abatilo/actions-poetry@v2.1.3 - uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest - name: Setup Regtest
run: | run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker cd docker
chmod +x ./tests chmod +x ./tests

View file

@ -32,11 +32,17 @@ test:
FAKE_WALLET_SECRET="ToTheMoon1" \ FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest poetry run pytest
test-real-wallet: test-real-wallet:
BOLTZ_NETWORK="regtest" \
BOLTZ_URL="http://127.0.0.1:9001" \
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest poetry run pytest
test-venv: test-venv:
@ -44,7 +50,27 @@ test-venv:
FAKE_WALLET_SECRET="ToTheMoon1" \ FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests ./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
test-migration:
rm -rf ./migration-data
mkdir -p ./migration-data
unzip tests/data/mock_data.zip -d ./migration-data
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./migration-data" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./migration-data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
poetry run python tools/conv.py
migration:
poetry run python tools/conv.py
bak: bak:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres # LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres

View file

@ -48,4 +48,25 @@ LNbits currently supports SQLite and PostgreSQL databases. There is a migration
### Adding mock data to `mock_data.zip` ### Adding mock data to `mock_data.zip`
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR. `mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
### running migration locally
you will need a running postgres database
#### create lnbits user for migration database
```console
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
```
#### create migration database
```console
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
```
#### run the migration
```console
make test-migration
```
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
#### clean migration database afterwards, fails if you try again
```console
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
```

View file

@ -20,7 +20,7 @@ cd lnbits-legend/
sudo apt update sudo apt update
sudo apt install software-properties-common sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 sudo apt install python3.9 python3.9-distutils
curl -sSL https://install.python-poetry.org | python3 - curl -sSL https://install.python-poetry.org | python3 -
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
@ -95,6 +95,80 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
``` ```
## 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.
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
```
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
Manually add the directory to your $HOME/.bash_profile (or similar)
export FLYCTL_INSTALL="/home/ubuntu/.fly"
export PATH="$FLYCTL_INSTALL/bin:$PATH"
```
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
Once installed, run the following commands.
```
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
fly auth login
[complete login process]
fly launch
```
You'll be prompted to enter an app name, region, postgres (choose no), deploy now (choose no).
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
```
...
kill_timeout = 30
...
...
[mounts]
source="lnbits_data"
destination="/data"
...
...
[env]
HOST="127.0.0.1"
PORT=5000
LNBITS_FORCE_HTTPS=true
LNBITS_DATA_FOLDER="/data"
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
...
...
[[services]]
internal_port = 5000
...
```
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
```
fly volumes create lnbits_data --size 1
```
You're ready to deploy! Run `fly deploy` and follow the steps to finish deployment. You'll select a `region` (up to you, choose the same as you did for the storage volume previously created), `postgres` (choose no), `deploy` (choose yes).
You can use `fly logs` to view the application logs, or `fly ssh console` to get a ssh shell in the running container.
### Troubleshooting ### Troubleshooting
Problems installing? These commands have helped us install LNbits. Problems installing? These commands have helped us install LNbits.
@ -170,8 +244,9 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# START LNbits # START LNbits
# STOP LNbits # STOP LNbits
# on the LNBits folder, locate and edit 'tools/conv.py' with the relevant credentials poetry run python tools/conv.py
python3 tools/conv.py # or
make migration
``` ```
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly. Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
@ -194,15 +269,14 @@ Description=LNbits
[Service] [Service]
# replace with the absolute path of your lnbits installation # replace with the absolute path of your lnbits installation
WorkingDirectory=/home/bitcoin/lnbits WorkingDirectory=/home/lnbits/lnbits-legend
# same here # same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# replace with the user that you're running lnbits on # replace with the user that you're running lnbits on
User=bitcoin User=lnbits
Restart=always Restart=always
TimeoutSec=120 TimeoutSec=120
RestartSec=30 RestartSec=30
# this makes sure that you receive logs in real time
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
[Install] [Install]
@ -216,6 +290,47 @@ sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service sudo systemctl start lnbits.service
``` ```
## Running behind an apache2 reverse proxy over https
Install apache2 and enable apache2 mods
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
```
create a ssl certificate with letsencrypt
```sh
certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org
```
create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf
```sh
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
<VirtualHost *:443>
ServerName lnbits.org
SSLEngine On
SSLProxyEngine On
SSLCertificateFile /etc/letsencrypt/live/lnbits.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/lnbits.org/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
LogLevel info
ErrorLog /var/log/apache2/lnbits.log
CustomLog /var/log/apache2/lnbits-access.log combined
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
ProxyPreserveHost On
ProxyPass / http://localhost:5000/
ProxyPassReverse / http://localhost:5000/
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
</VirtualHost>
EOF
```
restart apache2
```sh
service restart apache2
```
## Using https without reverse proxy ## Using https without reverse proxy
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network. The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import importlib import importlib
import logging import logging
import signal
import sys import sys
import traceback import traceback
import warnings import warnings
@ -75,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
# Only the browser sends "text/html" request # Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response # not fail proof, but everything else get's a JSON response
if "text/html" in request.headers["accept"]: if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"error.html", "error.html",
{"request": request, "err": f"{exc.errors()} is not a valid UUID."}, {"request": request, "err": f"{exc.errors()} is not a valid UUID."},
@ -101,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
def check_funding_source(app: FastAPI) -> None: def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup") @app.on_event("startup")
async def check_wallet_status(): async def check_wallet_status():
original_sigint_handler = signal.getsignal(signal.SIGINT)
def signal_handler(signal, frame):
logger.debug(f"SIGINT received, terminating LNbits.")
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
while True: while True:
error_message, balance = await WALLET.status() try:
if not error_message: error_message, balance = await WALLET.status()
break if not error_message:
logger.error( break
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", logger.error(
RuntimeWarning, f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
) RuntimeWarning,
)
except:
pass
logger.info("Retrying connection to backend in 5 seconds...") logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler)
logger.info( logger.info(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
) )
@ -185,7 +201,11 @@ def register_exception_handlers(app: FastAPI):
traceback.print_exception(etype, err, tb) traceback.print_exception(etype, err, tb)
exc = traceback.format_exc() exc = traceback.format_exc()
if "text/html" in request.headers["accept"]: if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err} "error.html", {"request": request, "err": err}
) )

View file

@ -216,7 +216,7 @@ def lnencode(addr, privkey):
expirybits = expirybits[5:] expirybits = expirybits[5:]
data += tagged("x", expirybits) data += tagged("x", expirybits)
elif k == "h": elif k == "h":
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest()) data += tagged_bytes("h", v)
elif k == "n": elif k == "n":
data += tagged_bytes("n", v) data += tagged_bytes("n", v)
else: else:

View file

@ -365,6 +365,11 @@ async def create_payment(
webhook: Optional[str] = None, webhook: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
# todo: add this when tests are fixed
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists"
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT INTO apipayments INSERT INTO apipayments
@ -404,6 +409,40 @@ async def update_payment_status(
) )
async def update_payment_details(
checking_id: str,
pending: Optional[bool] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
set_clause: List[str] = []
set_variables: List[Any] = []
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 fee is not None:
set_clause.append("fee = ?")
set_variables.append(fee)
if preimage is not None:
set_clause.append("preimage = ?")
set_variables.append(preimage)
set_variables.append(checking_id)
await (conn or db).execute(
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
tuple(set_variables),
)
return
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None: async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)

View file

@ -11,6 +11,7 @@ from pydantic import BaseModel
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.settings import WALLET from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus
class Wallet(BaseModel): class Wallet(BaseModel):
@ -128,8 +129,16 @@ class Payment(BaseModel):
@property @property
def is_uncheckable(self) -> bool: def is_uncheckable(self) -> bool:
return self.checking_id.startswith("temp_") or self.checking_id.startswith( return self.checking_id.startswith("internal_")
"internal_"
async def update_status(self, status: PaymentStatus) -> 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,
) )
async def set_pending(self, pending: bool) -> None: async def set_pending(self, pending: bool) -> None:
@ -137,9 +146,9 @@ class Payment(BaseModel):
await update_payment_status(self.checking_id, pending) await update_payment_status(self.checking_id, pending)
async def check_pending(self) -> None: async def check_status(self) -> PaymentStatus:
if self.is_uncheckable: if self.is_uncheckable:
return return PaymentStatus(None)
logger.debug( logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}" f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
@ -153,7 +162,7 @@ class Payment(BaseModel):
logger.debug(f"Status: {status}") logger.debug(f"Status: {status}")
if self.is_out and status.failed: if self.is_out and status.failed:
logger.info( logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}" f"Deleting outgoing failed payment {self.checking_id}: {status}"
) )
await self.delete() await self.delete()
@ -161,7 +170,8 @@ class Payment(BaseModel):
logger.info( logger.info(
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}" f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
) )
await self.set_pending(status.pending) await self.update_status(status)
return status
async def delete(self) -> None: async def delete(self) -> None:
from .crud import delete_payment from .crud import delete_payment

View file

@ -21,7 +21,7 @@ from lnbits.decorators import (
) )
from lnbits.helpers import url_for, urlsafe_short_hash from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g from lnbits.requestvars import g
from lnbits.settings import FAKE_WALLET, WALLET from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
from lnbits.wallets.base import PaymentResponse, PaymentStatus from lnbits.wallets.base import PaymentResponse, PaymentStatus
from . import db from . import db
@ -31,8 +31,10 @@ from .crud import (
delete_payment, delete_payment,
get_wallet, get_wallet,
get_wallet_payment, get_wallet_payment,
update_payment_details,
update_payment_status, update_payment_status,
) )
from .models import Payment
try: try:
from typing import TypedDict # type: ignore from typing import TypedDict # type: ignore
@ -54,6 +56,7 @@ async def create_invoice(
amount: int, # in satoshis amount: int, # in satoshis
memo: str, memo: str,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
extra: Optional[Dict] = None, extra: Optional[Dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
@ -65,7 +68,10 @@ async def create_invoice(
wallet = FAKE_WALLET if internal else WALLET wallet = FAKE_WALLET if internal else WALLET
ok, checking_id, payment_request, error_message = await wallet.create_invoice( ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount, memo=invoice_memo, description_hash=description_hash amount=amount,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
) )
if not ok: if not ok:
raise InvoiceFailure(error_message or "unexpected backend error.") raise InvoiceFailure(error_message or "unexpected backend error.")
@ -97,11 +103,20 @@ async def pay_invoice(
description: str = "", description: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve fee.
We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend.
If the payment is successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process will regularly check for the payment.
"""
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
fee_reserve_msat = fee_reserve(invoice.amount_msat) fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn: async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = f"temp_{urlsafe_short_hash()}" temp_id = invoice.payment_hash
internal_id = f"internal_{urlsafe_short_hash()}" internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0: if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.") raise ValueError("Amountless invoices not supported.")
@ -156,7 +171,7 @@ async def pay_invoice(
logger.debug("balance is too low, deleting temporary payment") logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat: if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure( raise PaymentFailure(
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees." f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
) )
raise PermissionError("Insufficient balance.") raise PermissionError("Insufficient balance.")
@ -181,30 +196,41 @@ async def pay_invoice(
payment: PaymentResponse = await WALLET.pay_invoice( payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat payment_request, fee_reserve_msat
) )
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}") logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.ok and payment.checking_id: if payment.checking_id and payment.ok != False:
logger.debug(f"creating final payment {payment.checking_id}") # payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn: async with db.connect() as conn:
await create_payment( await update_payment_details(
checking_id=payment.checking_id, checking_id=temp_id,
pending=payment.ok != True,
fee=payment.fee_msat, fee=payment.fee_msat,
preimage=payment.preimage, preimage=payment.preimage,
pending=payment.ok == None, new_checking_id=payment.checking_id,
conn=conn, conn=conn,
**payment_kwargs,
) )
logger.debug(f"deleting temporary payment {temp_id}") logger.debug(f"payment successful {payment.checking_id}")
await delete_payment(temp_id, conn=conn) elif payment.checking_id is None and payment.ok == False:
else: # payment failed
logger.debug(f"backend payment failed") logger.warning(f"backend sent payment failure")
async with db.connect() as conn: async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}") logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn) await delete_payment(temp_id, conn=conn)
raise PaymentFailure( raise PaymentFailure(
payment.error_message f"payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message." or "payment failed, but backend didn't give us an error message"
) )
logger.debug(f"payment successful {payment.checking_id}") else:
logger.warning(
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
)
return invoice.payment_hash return invoice.payment_hash
@ -340,26 +366,19 @@ async def perform_lnurlauth(
async def check_transaction_status( async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus: ) -> PaymentStatus:
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment: if not payment:
return PaymentStatus(None) return PaymentStatus(None)
if payment.is_out:
status = await WALLET.get_payment_status(payment.checking_id)
else:
status = await WALLET.get_invoice_status(payment.checking_id)
if not payment.pending: if not payment.pending:
return status # note: before, we still checked the status of the payment again
if payment.is_out and status.failed: return PaymentStatus(True)
logger.info(f"deleting outgoing failed payment {payment.checking_id}: {status}")
await payment.delete() status: PaymentStatus = await payment.check_status()
elif not status.pending:
logger.info(
f"marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}"
)
await payment.set_pending(status.pending)
return status return status
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ # WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int) -> int: def fee_reserve(amount_msat: int) -> int:
return max(2000, int(amount_msat * 0.01)) return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))

View file

@ -232,6 +232,9 @@ new Vue({
generateChart(this.$refs.canvas, this.payments) generateChart(this.$refs.canvas, this.payments)
}) })
}, },
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog: function () { showReceiveDialog: function () {
this.receive.show = true this.receive.show = true
this.receive.status = 'pending' this.receive.status = 'pending'
@ -243,6 +246,7 @@ new Vue({
this.receive.paymentChecker = null this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000] this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null this.receive.lnurl = null
this.focusInput('setAmount')
}, },
showParseDialog: function () { showParseDialog: function () {
this.parse.show = true this.parse.show = true
@ -668,7 +672,17 @@ new Vue({
}) })
}, },
exportCSV: function () { exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments) // 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
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
} }
}, },
watch: { watch: {

View file

@ -428,6 +428,7 @@
:options="receive.units" :options="receive.units"
></q-select> ></q-select>
<q-input <q-input
ref="setAmount"
filled filled
dense dense
v-model.number="receive.data.amount" v-model.number="receive.data.amount"

View file

@ -1,7 +1,7 @@
import asyncio import asyncio
import binascii
import hashlib import hashlib
import json import json
from binascii import unhexlify
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
@ -141,6 +141,7 @@ class CreateInvoiceData(BaseModel):
memo: Optional[str] = None memo: Optional[str] = None
unit: Optional[str] = "sat" unit: Optional[str] = "sat"
description_hash: Optional[str] = None description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
lnurl_callback: Optional[str] = None lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None extra: Optional[dict] = None
@ -151,10 +152,28 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash: if data.description_hash:
description_hash = unhexlify(data.description_hash) try:
description_hash = binascii.unhexlify(data.description_hash)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
)
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = "" memo = ""
else: else:
description_hash = b"" description_hash = b""
unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat": if data.unit == "sat":
amount = int(data.amount) amount = int(data.amount)
@ -170,6 +189,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
amount=amount, amount=amount,
memo=memo, memo=memo,
description_hash=description_hash, description_hash=description_hash,
unhashed_description=unhashed_description,
extra=data.extra, extra=data.extra,
webhook=data.webhook, webhook=data.webhook,
internal=data.internal, internal=data.internal,
@ -262,7 +282,7 @@ async def api_payments_create(
return await api_payments_create_invoice(invoiceData, wallet.wallet) return await api_payments_create_invoice(invoiceData, wallet.wallet)
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.", detail="Invoice (or Admin) key required.",
) )
@ -358,7 +378,7 @@ async def subscribe(request: Request, wallet: Wallet):
while True: while True:
payment: Payment = await payment_queue.get() payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id: if payment.wallet_id == this_wallet_id:
logger.debug("payment receieved", payment) logger.debug("payment received", payment)
await send_queue.put(("payment-received", payment)) await send_queue.put(("payment-received", payment))
asyncio.create_task(payment_received()) asyncio.create_task(payment_received())
@ -382,6 +402,10 @@ async def subscribe(request: Request, wallet: Wallet):
async def api_payments_sse( async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
): ):
if wallet is None or wallet.wallet is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
return EventSourceResponse( return EventSourceResponse(
subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream" subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream"
) )
@ -416,7 +440,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
try: try:
await payment.check_pending() await payment.check_status()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment} return {"paid": False, "details": payment}

View file

@ -130,10 +130,13 @@ async def get_key_type(
# 2: invalid # 2: invalid
pathname = r["path"].split("/")[1] pathname = r["path"].split("/")[1]
if not api_key_header and not api_key_query: token = api_key_header or api_key_query
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
token = api_key_header if api_key_header else api_key_query if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
try: try:
admin_checker = WalletAdminKeyChecker(api_key=token) admin_checker = WalletAdminKeyChecker(api_key=token)
@ -180,7 +183,14 @@ async def require_admin_key(
api_key_header: str = Security(api_key_header), # type: ignore api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore api_key_query: str = Security(api_key_query), # type: ignore
): ):
token = api_key_header if api_key_header else api_key_query
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Admin key required.",
)
wallet = await get_key_type(r, token) wallet = await get_key_type(r, token)
@ -199,7 +209,14 @@ async def require_invoice_key(
api_key_header: str = Security(api_key_header), # type: ignore api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore api_key_query: str = Security(api_key_query), # type: ignore
): ):
token = api_key_header if api_key_header else api_key_query
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
wallet = await get_key_type(r, token) wallet = await get_key_type(r, token)

View file

@ -0,0 +1,40 @@
# Swap on [Boltz](https://boltz.exchange)
providing **trustless** and **account-free** swap services since **2018.**
move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times.
* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2)
* [Documentation](https://docs.boltz.exchange/en/latest/)
* [Discord](https://discord.gg/d6EK85KK)
* [Twitter](https://twitter.com/Boltzhq)
# usage
This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds.
## create normal swap
1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds.
---
![create swap](https://imgur.com/OyOh3Nm.png)
---
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
---
![pay onchain tx](https://imgur.com/r2UhwCY.png)
---
3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet.
if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either).
## create reverse swap
1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails.
---
![reverse swap](https://imgur.com/UEAPpbs.png)
---
if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds.
# refund locked onchain funds from a normal swap
if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc.
this can happen if boltz is not able to pay your lightning invoice after you locked up your funds.
in case that happens, there is a info icon in the Swap (In) List which opens following dialog.
---
![refund](https://imgur.com/pN81ltf.png)
----
if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website.
if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK)

View file

@ -0,0 +1,26 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_boltz")
boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"])
def boltz_renderer():
return template_renderer(["lnbits/extensions/boltz/templates"])
from .tasks import check_for_pending_swaps, wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def boltz_start():
loop = asyncio.get_event_loop()
loop.create_task(check_for_pending_swaps())
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -0,0 +1,424 @@
import asyncio
import os
from binascii import hexlify, unhexlify
from hashlib import sha256
from typing import Awaitable, Union
import httpx
from embit import ec, script
from embit.networks import NETWORKS
from embit.transaction import SIGHASH, Transaction, TransactionInput, TransactionOutput
from loguru import logger
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL
from .crud import update_swap_status
from .mempool import (
get_fee_estimation,
get_mempool_blockheight,
get_mempool_fees,
get_mempool_tx,
get_mempool_tx_from_txs,
send_onchain_tx,
wait_for_websocket_message,
)
from .models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
SwapStatus,
)
from .utils import check_balance, get_timestamp, req_wrap
net = NETWORKS[BOLTZ_NETWORK]
logger.debug(f"BOLTZ_URL: {BOLTZ_URL}")
logger.debug(f"Bitcoin Network: {net['name']}")
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
if not check_boltz_limits(data.amount):
msg = f"Boltz - swap not in boltz limits"
logger.warning(msg)
raise Exception(msg)
swap_id = urlsafe_short_hash()
try:
payment_hash, payment_request = await create_invoice(
wallet_id=data.wallet,
amount=data.amount,
memo=f"swap of {data.amount} sats on boltz.exchange",
extra={"tag": "boltz", "swap_id": swap_id},
)
except Exception as exc:
msg = f"Boltz - create_invoice failed {str(exc)}"
logger.error(msg)
raise
refund_privkey = ec.PrivateKey(os.urandom(32), True, net)
refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8")
res = req_wrap(
"post",
f"{BOLTZ_URL}/createswap",
json={
"type": "submarine",
"pairId": "BTC/BTC",
"orderSide": "sell",
"refundPublicKey": refund_pubkey_hex,
"invoice": payment_request,
"referralId": "lnbits",
},
headers={"Content-Type": "application/json"},
)
res = res.json()
logger.info(
f"Boltz - created normal swap, boltz_id: {res['id']}. wallet: {data.wallet}"
)
return SubmarineSwap(
id=swap_id,
time=get_timestamp(),
wallet=data.wallet,
amount=data.amount,
payment_hash=payment_hash,
refund_privkey=refund_privkey.wif(net),
refund_address=data.refund_address,
boltz_id=res["id"],
status="pending",
address=res["address"],
expected_amount=res["expectedAmount"],
timeout_block_height=res["timeoutBlockHeight"],
bip21=res["bip21"],
redeem_script=res["redeemScript"],
)
"""
explanation taken from electrum
send on Lightning, receive on-chain
- User generates preimage, RHASH. Sends RHASH to server.
- Server creates an LN invoice for RHASH.
- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
- Server creates on-chain output locked to RHASH.
- User spends on-chain output, revealing preimage.
- Server fulfills HTLC using preimage.
Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
"""
async def create_reverse_swap(
data: CreateReverseSubmarineSwap,
) -> [ReverseSubmarineSwap, asyncio.Task]:
if not check_boltz_limits(data.amount):
msg = f"Boltz - reverse swap not in boltz limits"
logger.warning(msg)
raise Exception(msg)
swap_id = urlsafe_short_hash()
if not await check_balance(data):
logger.error(f"Boltz - reverse swap, insufficient balance.")
return False
claim_privkey = ec.PrivateKey(os.urandom(32), True, net)
claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8")
preimage = os.urandom(32)
preimage_hash = sha256(preimage).hexdigest()
res = req_wrap(
"post",
f"{BOLTZ_URL}/createswap",
json={
"type": "reversesubmarine",
"pairId": "BTC/BTC",
"orderSide": "buy",
"invoiceAmount": data.amount,
"preimageHash": preimage_hash,
"claimPublicKey": claim_pubkey_hex,
"referralId": "lnbits",
},
headers={"Content-Type": "application/json"},
)
res = res.json()
logger.info(
f"Boltz - created reverse swap, boltz_id: {res['id']}. wallet: {data.wallet}"
)
swap = ReverseSubmarineSwap(
id=swap_id,
amount=data.amount,
wallet=data.wallet,
onchain_address=data.onchain_address,
instant_settlement=data.instant_settlement,
claim_privkey=claim_privkey.wif(net),
preimage=preimage.hex(),
status="pending",
boltz_id=res["id"],
timeout_block_height=res["timeoutBlockHeight"],
lockup_address=res["lockupAddress"],
onchain_amount=res["onchainAmount"],
redeem_script=res["redeemScript"],
invoice=res["invoice"],
time=get_timestamp(),
)
logger.debug(f"Boltz - waiting for onchain tx, reverse swap_id: {swap.id}")
task = create_task_log_exception(
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_initial)
)
return swap, task
def start_onchain_listener(swap: ReverseSubmarineSwap) -> asyncio.Task:
return create_task_log_exception(
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_restart)
)
async def start_confirmation_listener(
swap: ReverseSubmarineSwap, mempool_lockup_tx
) -> asyncio.Task:
logger.debug(f"Boltz - reverse swap, waiting for confirmation...")
tx, txid, *_ = mempool_lockup_tx
confirmed = await wait_for_websocket_message({"track-tx": txid}, "txConfirmed")
if confirmed:
logger.debug(f"Boltz - reverse swap lockup transaction confirmed! claiming...")
await create_claim_tx(swap, mempool_lockup_tx)
else:
logger.debug(f"Boltz - reverse swap lockup transaction still not confirmed.")
def create_task_log_exception(swap_id: str, awaitable: Awaitable) -> asyncio.Task:
async def _log_exception(awaitable):
try:
return await awaitable
except Exception as e:
logger.error(f"Boltz - reverse swap failed!: {swap_id} - {e}")
await update_swap_status(swap_id, "failed")
return asyncio.create_task(_log_exception(awaitable))
async def swap_websocket_callback_initial(swap):
wstask = asyncio.create_task(
wait_for_websocket_message(
{"track-address": swap.lockup_address}, "address-transactions"
)
)
logger.debug(
f"Boltz - created task, waiting on mempool websocket for address: {swap.lockup_address}"
)
# create_task is used because pay_invoice is stuck as long as boltz does not
# see the onchain claim tx and it ends up in deadlock
task: asyncio.Task = create_task_log_exception(
swap.id,
pay_invoice(
wallet_id=swap.wallet,
payment_request=swap.invoice,
description=f"reverse swap for {swap.amount} sats on boltz.exchange",
extra={"tag": "boltz", "swap_id": swap.id, "reverse": True},
),
)
logger.debug(f"Boltz - task pay_invoice created, reverse swap_id: {swap.id}")
done, pending = await asyncio.wait(
[task, wstask], return_when=asyncio.FIRST_COMPLETED
)
message = done.pop().result()
# pay_invoice already failed, do not wait for onchain tx anymore
if message is None:
logger.debug(f"Boltz - pay_invoice already failed cancel websocket task.")
wstask.cancel()
raise
return task, message
async def swap_websocket_callback_restart(swap):
logger.debug(f"Boltz - swap_websocket_callback_restart called...")
message = await wait_for_websocket_message(
{"track-address": swap.lockup_address}, "address-transactions"
)
return None, message
async def wait_for_onchain_tx(swap: ReverseSubmarineSwap, callback):
task, txs = await callback(swap)
mempool_lockup_tx = get_mempool_tx_from_txs(txs, swap.lockup_address)
if mempool_lockup_tx:
tx, txid, *_ = mempool_lockup_tx
if swap.instant_settlement or tx["status"]["confirmed"]:
logger.debug(
f"Boltz - reverse swap instant settlement, claiming immediatly..."
)
await create_claim_tx(swap, mempool_lockup_tx)
else:
await start_confirmation_listener(swap, mempool_lockup_tx)
try:
if task:
await task
except:
logger.error(
f"Boltz - could not await pay_invoice task, but sent onchain. should never happen!"
)
else:
logger.error(f"Boltz - mempool lockup tx not found.")
async def create_claim_tx(swap: ReverseSubmarineSwap, mempool_lockup_tx):
tx = await create_onchain_tx(swap, mempool_lockup_tx)
await send_onchain_tx(tx)
logger.debug(f"Boltz - onchain tx sent, reverse swap completed")
await update_swap_status(swap.id, "complete")
async def create_refund_tx(swap: SubmarineSwap):
mempool_lockup_tx = get_mempool_tx(swap.address)
tx = await create_onchain_tx(swap, mempool_lockup_tx)
await send_onchain_tx(tx)
def check_block_height(block_height: int):
current_block_height = get_mempool_blockheight()
if current_block_height <= block_height:
msg = f"refund not possible, timeout_block_height ({block_height}) is not yet exceeded ({current_block_height})"
logger.debug(msg)
raise Exception(msg)
"""
a submarine swap consists of 2 onchain tx's a lockup and a redeem tx.
we create a tx to redeem the funds locked by the onchain lockup tx.
claim tx for reverse swaps, refund tx for normal swaps they are the same
onchain redeem tx, the difference between them is the private key, onchain_address,
input sequence and input script_sig
"""
async def create_onchain_tx(
swap: Union[ReverseSubmarineSwap, SubmarineSwap], mempool_lockup_tx
) -> Transaction:
is_refund_tx = type(swap) == SubmarineSwap
if is_refund_tx:
check_block_height(swap.timeout_block_height)
privkey = ec.PrivateKey.from_wif(swap.refund_privkey)
onchain_address = swap.refund_address
preimage = b""
sequence = 0xFFFFFFFE
else:
privkey = ec.PrivateKey.from_wif(swap.claim_privkey)
preimage = unhexlify(swap.preimage)
onchain_address = swap.onchain_address
sequence = 0xFFFFFFFF
locktime = swap.timeout_block_height
redeem_script = unhexlify(swap.redeem_script)
fees = get_fee_estimation()
tx, txid, vout_cnt, vout_amount = mempool_lockup_tx
script_pubkey = script.address_to_scriptpubkey(onchain_address)
vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)]
vout = [TransactionOutput(vout_amount - fees, script_pubkey)]
tx = Transaction(vin=vin, vout=vout)
if is_refund_tx:
tx.locktime = locktime
# TODO: 2 rounds for fee calculation, look at vbytes after signing and do another TX
s = script.Script(data=redeem_script)
for i, inp in enumerate(vin):
if is_refund_tx:
rs = bytes([34]) + bytes([0]) + bytes([32]) + sha256(redeem_script).digest()
tx.vin[i].script_sig = script.Script(data=rs)
h = tx.sighash_segwit(i, s, vout_amount)
sig = privkey.sign(h).serialize() + bytes([SIGHASH.ALL])
witness_items = [sig, preimage, redeem_script]
tx.vin[i].witness = script.Witness(items=witness_items)
return tx
def get_swap_status(swap: Union[SubmarineSwap, ReverseSubmarineSwap]) -> SwapStatus:
swap_status = SwapStatus(
wallet=swap.wallet,
swap_id=swap.id,
)
try:
boltz_request = get_boltz_status(swap.boltz_id)
swap_status.boltz = boltz_request["status"]
except httpx.HTTPStatusError as exc:
json = exc.response.json()
swap_status.boltz = json["error"]
if "could not find" in swap_status.boltz:
swap_status.exists = False
if type(swap) == SubmarineSwap:
swap_status.reverse = False
swap_status.address = swap.address
else:
swap_status.reverse = True
swap_status.address = swap.lockup_address
swap_status.block_height = get_mempool_blockheight()
swap_status.timeout_block_height = (
f"{str(swap.timeout_block_height)} -> current: {str(swap_status.block_height)}"
)
if swap_status.block_height >= swap.timeout_block_height:
swap_status.hit_timeout = True
mempool_tx = get_mempool_tx(swap_status.address)
swap_status.lockup = mempool_tx
if mempool_tx == None:
swap_status.has_lockup = False
swap_status.confirmed = False
swap_status.mempool = "transaction.unknown"
swap_status.message = "lockup tx not in mempool"
else:
swap_status.has_lockup = True
tx, *_ = mempool_tx
if tx["status"]["confirmed"] == True:
swap_status.mempool = "transaction.confirmed"
swap_status.confirmed = True
else:
swap_status.confirmed = False
swap_status.mempool = "transaction.unconfirmed"
return swap_status
def check_boltz_limits(amount):
try:
pairs = get_boltz_pairs()
limits = pairs["pairs"]["BTC/BTC"]["limits"]
return amount >= limits["minimal"] and amount <= limits["maximal"]
except:
return False
def get_boltz_pairs():
res = req_wrap(
"get",
f"{BOLTZ_URL}/getpairs",
headers={"Content-Type": "application/json"},
)
return res.json()
def get_boltz_status(boltzid):
res = req_wrap(
"post",
f"{BOLTZ_URL}/swapstatus",
json={"id": boltzid},
)
return res.json()

View file

@ -0,0 +1,6 @@
{
"name": "Boltz",
"short_description": "Perform onchain/offchain swaps via https://boltz.exchange/",
"icon": "swap_horiz",
"contributors": ["dni"]
}

View file

@ -0,0 +1,225 @@
from http import HTTPStatus
from typing import List, Optional, Union
from loguru import logger
from starlette.exceptions import HTTPException
from . import db
from .models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
)
"""
Submarine Swaps
"""
async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [SubmarineSwap(**row) for row in rows]
async def get_pending_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[SubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
(*wallet_ids,),
)
return [SubmarineSwap(**row) for row in rows]
async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]:
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE status='pending' order by time DESC",
)
return [SubmarineSwap(**row) for row in rows]
async def get_submarine_swap(swap_id) -> SubmarineSwap:
row = await db.fetchone(
"SELECT * FROM boltz.submarineswap WHERE id = ?", (swap_id,)
)
return SubmarineSwap(**row) if row else None
async def create_submarine_swap(swap: SubmarineSwap) -> Optional[SubmarineSwap]:
await db.execute(
"""
INSERT INTO boltz.submarineswap (
id,
wallet,
payment_hash,
status,
boltz_id,
refund_privkey,
refund_address,
expected_amount,
timeout_block_height,
address,
bip21,
redeem_script,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
swap.id,
swap.wallet,
swap.payment_hash,
swap.status,
swap.boltz_id,
swap.refund_privkey,
swap.refund_address,
swap.expected_amount,
swap.timeout_block_height,
swap.address,
swap.bip21,
swap.redeem_script,
swap.amount,
),
)
return await get_submarine_swap(swap.id)
async def delete_submarine_swap(swap_id):
await db.execute("DELETE FROM boltz.submarineswap WHERE id = ?", (swap_id,))
async def get_reverse_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[ReverseSubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_pending_reverse_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[ReverseSubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
(*wallet_ids,),
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap]:
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE status='pending' order by time DESC"
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_reverse_submarine_swap(swap_id) -> SubmarineSwap:
row = await db.fetchone(
"SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,)
)
return ReverseSubmarineSwap(**row) if row else None
async def create_reverse_submarine_swap(
swap: ReverseSubmarineSwap,
) -> Optional[ReverseSubmarineSwap]:
await db.execute(
"""
INSERT INTO boltz.reverse_submarineswap (
id,
wallet,
status,
boltz_id,
instant_settlement,
preimage,
claim_privkey,
lockup_address,
invoice,
onchain_amount,
onchain_address,
timeout_block_height,
redeem_script,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
swap.id,
swap.wallet,
swap.status,
swap.boltz_id,
swap.instant_settlement,
swap.preimage,
swap.claim_privkey,
swap.lockup_address,
swap.invoice,
swap.onchain_amount,
swap.onchain_address,
swap.timeout_block_height,
swap.redeem_script,
swap.amount,
),
)
return await get_reverse_submarine_swap(swap.id)
async def update_swap_status(swap_id: str, status: str):
reverse = ""
swap = await get_submarine_swap(swap_id)
if swap is None:
swap = await get_reverse_submarine_swap(swap_id)
if swap is None:
return None
if type(swap) == SubmarineSwap:
await db.execute(
"UPDATE boltz.submarineswap SET status='"
+ status
+ "' WHERE id='"
+ swap.id
+ "'"
)
if type(swap) == ReverseSubmarineSwap:
reverse = "reverse"
await db.execute(
"UPDATE boltz.reverse_submarineswap SET status='"
+ status
+ "' WHERE id='"
+ swap.id
+ "'"
)
message = f"Boltz - {reverse} swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}"
logger.info(message)
return swap

View file

@ -0,0 +1,97 @@
import asyncio
import json
from binascii import hexlify
import httpx
import websockets
from embit.transaction import Transaction
from loguru import logger
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
from .utils import req_wrap
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
async def wait_for_websocket_message(send, message_string):
async for websocket in websockets.connect(websocket_url):
try:
await websocket.send(json.dumps({"action": "want", "data": ["blocks"]}))
await websocket.send(json.dumps(send))
async for raw in websocket:
message = json.loads(raw)
if message_string in message:
return message.get(message_string)
except websockets.ConnectionClosed:
continue
def get_mempool_tx(address):
res = req_wrap(
"get",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/address/{address}/txs",
headers={"Content-Type": "text/plain"},
)
txs = res.json()
return get_mempool_tx_from_txs(txs, address)
def get_mempool_tx_from_txs(txs, address):
if len(txs) == 0:
return None
tx = txid = vout_cnt = vout_amount = None
for a_tx in txs:
for i, vout in enumerate(a_tx["vout"]):
if vout["scriptpubkey_address"] == address:
tx = a_tx
txid = a_tx["txid"]
vout_cnt = i
vout_amount = vout["value"]
# should never happen
if tx == None:
raise Exception("mempool tx not found")
if txid == None:
raise Exception("mempool txid not found")
return tx, txid, vout_cnt, vout_amount
def get_fee_estimation() -> int:
# TODO: hardcoded maximum tx size, in the future we try to get the size of the tx via embit
# we need a function like Transaction.vsize()
tx_size_vbyte = 200
mempool_fees = get_mempool_fees()
return mempool_fees * tx_size_vbyte
def get_mempool_fees() -> int:
res = req_wrap(
"get",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/v1/fees/recommended",
headers={"Content-Type": "text/plain"},
)
fees = res.json()
return int(fees["economyFee"])
def get_mempool_blockheight() -> int:
res = req_wrap(
"get",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/blocks/tip/height",
headers={"Content-Type": "text/plain"},
)
return int(res.text)
async def send_onchain_tx(tx: Transaction):
raw = hexlify(tx.serialize())
logger.debug(f"Boltz - mempool sending onchain tx...")
req_wrap(
"post",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
headers={"Content-Type": "text/plain"},
content=raw,
)

View file

@ -0,0 +1,46 @@
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE boltz.submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
payment_hash TEXT NOT NULL,
amount INT NOT NULL,
status TEXT NOT NULL,
boltz_id TEXT NOT NULL,
refund_address TEXT NOT NULL,
refund_privkey TEXT NOT NULL,
expected_amount INT NOT NULL,
timeout_block_height INT NOT NULL,
address TEXT NOT NULL,
bip21 TEXT NOT NULL,
redeem_script TEXT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltz.reverse_submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
onchain_address TEXT NOT NULL,
amount INT NOT NULL,
instant_settlement BOOLEAN NOT NULL,
status TEXT NOT NULL,
boltz_id TEXT NOT NULL,
timeout_block_height INT NOT NULL,
redeem_script TEXT NOT NULL,
preimage TEXT NOT NULL,
claim_privkey TEXT NOT NULL,
lockup_address TEXT NOT NULL,
invoice TEXT NOT NULL,
onchain_amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)

View file

@ -0,0 +1,75 @@
import json
from typing import Dict, List, Optional
from fastapi.params import Query
from pydantic.main import BaseModel
from sqlalchemy.engine import base # type: ignore
class SubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
payment_hash: str
time: int
status: str
refund_privkey: str
refund_address: str
boltz_id: str
expected_amount: int
timeout_block_height: int
address: str
bip21: str
redeem_script: str
class CreateSubmarineSwap(BaseModel):
wallet: str = Query(...) # type: ignore
refund_address: str = Query(...) # type: ignore
amount: int = Query(...) # type: ignore
class ReverseSubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
onchain_address: str
instant_settlement: bool
time: int
status: str
boltz_id: str
preimage: str
claim_privkey: str
lockup_address: str
invoice: str
onchain_amount: int
timeout_block_height: int
redeem_script: str
class CreateReverseSubmarineSwap(BaseModel):
wallet: str = Query(...) # type: ignore
amount: int = Query(...) # type: ignore
instant_settlement: bool = Query(...) # type: ignore
# validate on-address, bcrt1 for regtest addresses
onchain_address: str = Query(
..., regex="^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$"
) # type: ignore
class SwapStatus(BaseModel):
swap_id: str
wallet: str
status: str = ""
message: str = ""
boltz: str = ""
mempool: str = ""
address: str = ""
block_height: int = 0
timeout_block_height: str = ""
lockup: Optional[dict] = {}
has_lockup: bool = False
hit_timeout: bool = False
confirmed: bool = True
exists: bool = True
reverse: bool = False

View file

@ -0,0 +1,153 @@
import asyncio
import httpx
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services import check_transaction_status
from lnbits.tasks import register_invoice_listener
from .boltz import (
create_claim_tx,
create_refund_tx,
get_swap_status,
start_confirmation_listener,
start_onchain_listener,
)
from .crud import (
get_all_pending_reverse_submarine_swaps,
get_all_pending_submarine_swaps,
get_reverse_submarine_swap,
get_submarine_swap,
update_swap_status,
)
"""
testcases for boltz startup
A. normal swaps
1. test: create -> kill -> start -> startup invoice listeners -> pay onchain funds -> should complete
2. test: create -> kill -> pay onchain funds -> start -> startup check -> should complete
3. test: create -> kill -> mine blocks and hit timeout -> start -> should go timeout/failed
4. test: create -> kill -> pay to less onchain funds -> mine blocks hit timeout -> start lnbits -> should be refunded
B. reverse swaps
1. test: create instant -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should claim/complete
2. test: create instant -> kill -> no lockup -> start lnbits -> should start onchain listener -> boltz does lockup -> should claim/complete (difficult to test)
3. test: create -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should start tx listener -> after confirmation -> should claim/complete
4. test: create -> kill -> boltz does lockup -> confirmed -> start lnbits -> should claim/complete
5. test: create -> kill -> boltz does lockup -> hit timeout -> boltz refunds -> start -> should timeout
"""
async def check_for_pending_swaps():
try:
swaps = await get_all_pending_submarine_swaps()
reverse_swaps = await get_all_pending_reverse_submarine_swaps()
if len(swaps) > 0 or len(reverse_swaps) > 0:
logger.debug(f"Boltz - startup swap check")
except:
# database is not created yet, do nothing
return
if len(swaps) > 0:
logger.debug(f"Boltz - {len(swaps)} pending swaps")
for swap in swaps:
try:
swap_status = get_swap_status(swap)
# should only happen while development when regtest is reset
if swap_status.exists is False:
logger.warning(f"Boltz - swap: {swap.boltz_id} does not exist.")
await update_swap_status(swap.id, "failed")
continue
payment_status = await check_transaction_status(
swap.wallet, swap.payment_hash
)
if payment_status.paid:
logger.debug(
f"Boltz - swap: {swap.boltz_id} got paid while offline."
)
await update_swap_status(swap.id, "complete")
else:
if swap_status.hit_timeout:
if not swap_status.has_lockup:
logger.warning(
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
)
await update_swap_status(swap.id, "timeout")
else:
logger.debug(f"Boltz - refunding swap: {swap.id}...")
await create_refund_tx(swap)
await update_swap_status(swap.id, "refunded")
except Exception as exc:
logger.error(f"Boltz - swap: {swap.id} - {str(exc)}")
if len(reverse_swaps) > 0:
logger.debug(f"Boltz - {len(reverse_swaps)} pending reverse swaps")
for reverse_swap in reverse_swaps:
try:
swap_status = get_swap_status(reverse_swap)
if swap_status.exists is False:
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} does not exist."
)
await update_swap_status(reverse_swap.id, "failed")
continue
# if timeout hit, boltz would have already refunded
if swap_status.hit_timeout:
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} timeout."
)
await update_swap_status(reverse_swap.id, "timeout")
continue
if not swap_status.has_lockup:
# start listener for onchain address
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted onchain address listener."
)
await start_onchain_listener(reverse_swap)
continue
if reverse_swap.instant_settlement or swap_status.confirmed:
await create_claim_tx(reverse_swap, swap_status.lockup)
else:
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted confirmation listener."
)
await start_confirmation_listener(reverse_swap, swap_status.lockup)
except Exception as exc:
logger.error(f"Boltz - reverse swap: {reverse_swap.id} - {str(exc)}")
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "boltz" != payment.extra.get("tag"):
# not a boltz invoice
return
await payment.set_pending(False)
swap_id = payment.extra.get("swap_id")
swap = await get_submarine_swap(swap_id)
if not swap:
logger.error(f"swap_id: {swap_id} not found.")
return
logger.info(
f"Boltz - lightning invoice is paid, normal swap completed. swap_id: {swap_id}"
)
await update_swap_status(swap_id, "complete")

View file

@ -0,0 +1,236 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Boltz"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<img
src="https://boltz.exchange/static/media/Shape.6c1a92b3.svg"
alt=""
/>
<img
src="https://boltz.exchange/static/media/Boltz.02fb7acb.svg"
style="padding: 5px 9px"
alt=""
/>
<h5 class="text-subtitle1 q-my-none">
Boltz.exchange: Do onchain to offchain and vice-versa swaps
</h5>
<p>
Submarine and Reverse Submarine Swaps on LNbits via boltz.exchange
API<br />
</p>
<p>
Link :
<a target="_blank" href="https://boltz.exchange"
>https://boltz.exchange
</a>
</p>
<p>
<a
target="_blank"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
>More details</a
>
</p>
<p>
<small
>Created by,
<a target="_blank" href="https://github.com/dni">dni</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="GET swap/reverse">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/reverse</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON list of reverse submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
{{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="POST swap/reverse"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span>
/boltz/api/v1/swap/reverse</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"wallet": &lt;string&gt;, "onchain_address": &lt;string&gt;,
"amount": &lt;integer&gt;, "instant_settlement":
&lt;boolean&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON create a reverse-submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
{{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap">
<q-card>
<q-card-section>
<code><span class="text-light-blue">GET</span> /boltz/api/v1/swap</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON list of submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="POST swap">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span> /boltz/api/v1/swap</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"wallet": &lt;string&gt;, "refund_address": &lt;string&gt;,
"amount": &lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON create a submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap/refund">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span>
/boltz/api/v1/swap/refund/{swap_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON submarine swap</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/refund/{swap_id} -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap/status">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span>
/boltz/api/v1/swap/status/{swap_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (text/plain)
</h5>
<code>swap status</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/status/{swap_id} -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap/check">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/check</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON pending swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/check -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET boltz-config">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/boltz</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (text/plain)
</h5>
<code>JSON boltz config</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/boltz -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET mempool-url">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/mempool</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (text/plain)
</h5>
<code>mempool url</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/mempool -H "X-Api-Key:
{{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
import calendar
import datetime
import httpx
from loguru import logger
from lnbits.core.services import fee_reserve, get_wallet
async def check_balance(data) -> bool:
# check if we can pay the invoice before we create the actual swap on boltz
amount_msat = data.amount * 1000
fee_reserve_msat = fee_reserve(amount_msat)
wallet = await get_wallet(data.wallet)
assert wallet
if wallet.balance_msat - fee_reserve_msat < amount_msat:
return False
return True
def get_timestamp():
date = datetime.datetime.utcnow()
return calendar.timegm(date.utctimetuple())
def req_wrap(funcname, *args, **kwargs):
try:
try:
func = getattr(httpx, funcname)
except AttributeError:
logger.error('httpx function not found "%s"' % funcname)
else:
res = func(*args, timeout=30, **kwargs)
res.raise_for_status()
return res
except httpx.RequestError as exc:
msg = f"Unreachable: {exc.request.url!r}."
logger.error(msg)
raise
except httpx.HTTPStatusError as exc:
msg = f"HTTP Status Error: {exc.response.status_code} while requesting {exc.request.url!r}."
logger.error(msg)
logger.error(exc.response.json()["error"])
raise

View file

@ -0,0 +1,23 @@
from urllib.parse import urlparse
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import Payment, User
from lnbits.decorators import check_user_exists
from . import boltz_ext, boltz_renderer
templates = Jinja2Templates(directory="templates")
@boltz_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
root_url = urlparse(str(request.url)).netloc
wallet_ids = [wallet.id for wallet in user.wallets]
return boltz_renderer().TemplateResponse(
"boltz/index.html",
{"request": request, "user": user.dict(), "root_url": root_url},
)

View file

@ -0,0 +1,338 @@
from datetime import datetime
from http import HTTPStatus
from typing import List
import httpx
from fastapi import status
from fastapi.encoders import jsonable_encoder
from fastapi.param_functions import Body
from fastapi.params import Depends, Query
from loguru import logger
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from starlette.requests import Request
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL
from . import boltz_ext
from .boltz import (
create_refund_tx,
create_reverse_swap,
create_swap,
get_boltz_pairs,
get_swap_status,
)
from .crud import (
create_reverse_submarine_swap,
create_submarine_swap,
get_pending_reverse_submarine_swaps,
get_pending_submarine_swaps,
get_reverse_submarine_swap,
get_reverse_submarine_swaps,
get_submarine_swap,
get_submarine_swaps,
update_swap_status,
)
from .models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
)
from .utils import check_balance
@boltz_ext.get(
"/api/v1/swap/mempool",
name=f"boltz.get /swap/mempool",
summary="get a the mempool url",
description="""
This endpoint gets the URL from mempool.space
""",
response_description="mempool.space url",
response_model=str,
)
async def api_mempool_url():
return BOLTZ_MEMPOOL_SPACE_URL
# NORMAL SWAP
@boltz_ext.get(
"/api/v1/swap",
name=f"boltz.get /swap",
summary="get a list of swaps a swap",
description="""
This endpoint gets a list of normal swaps.
""",
response_description="list of normal swaps",
dependencies=[Depends(get_key_type)],
response_model=List[SubmarineSwap],
)
async def api_submarineswap(
g: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
for swap in await get_pending_submarine_swaps(wallet_ids):
swap_status = get_swap_status(swap)
if swap_status.hit_timeout:
if not swap_status.has_lockup:
logger.warning(
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
)
await update_swap_status(swap.id, "timeout")
return [swap.dict() for swap in await get_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/refund",
name=f"boltz.swap_refund",
summary="refund of a swap",
description="""
This endpoint attempts to refund a normal swaps, creates onchain tx and sets swap status ro refunded.
""",
response_description="refunded swap with status set to refunded",
dependencies=[Depends(require_admin_key)],
response_model=SubmarineSwap,
responses={
400: {"description": "when swap_id is missing"},
404: {"description": "when swap is not found"},
405: {"description": "when swap is not pending"},
500: {
"description": "when something goes wrong creating the refund onchain tx"
},
},
)
async def api_submarineswap_refund(
swap_id: str,
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
if swap_id == None:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="swap_id missing"
)
swap = await get_submarine_swap(swap_id)
if swap == None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
)
if swap.status != "pending":
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="swap is not pending."
)
try:
await create_refund_tx(swap)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
await update_swap_status(swap.id, "refunded")
return swap
@boltz_ext.post(
"/api/v1/swap",
status_code=status.HTTP_201_CREATED,
name=f"boltz.post /swap",
summary="create a submarine swap",
description="""
This endpoint creates a submarine swap
""",
response_description="create swap",
response_model=SubmarineSwap,
responses={
405: {"description": "not allowed method, insufficient balance"},
500: {"description": "boltz error"},
},
)
async def api_submarineswap_create(
data: CreateSubmarineSwap,
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
try:
swap_data = await create_swap(data)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code, detail=exc.response.json()["error"]
)
swap = await create_submarine_swap(swap_data)
return swap.dict()
# REVERSE SWAP
@boltz_ext.get(
"/api/v1/swap/reverse",
name=f"boltz.get /swap/reverse",
summary="get a list of reverse swaps a swap",
description="""
This endpoint gets a list of reverse swaps.
""",
response_description="list of reverse swaps",
dependencies=[Depends(get_key_type)],
response_model=List[ReverseSubmarineSwap],
)
async def api_reverse_submarineswap(
g: WalletTypeInfo = Depends(get_key_type), # type:ignore
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [swap.dict() for swap in await get_reverse_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/reverse",
status_code=status.HTTP_201_CREATED,
name=f"boltz.post /swap/reverse",
summary="create a reverse submarine swap",
description="""
This endpoint creates a reverse submarine swap
""",
response_description="create reverse swap",
response_model=ReverseSubmarineSwap,
responses={
405: {"description": "not allowed method, insufficient balance"},
500: {"description": "boltz error"},
},
)
async def api_reverse_submarineswap_create(
data: CreateReverseSubmarineSwap,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
if not await check_balance(data):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="Insufficient balance."
)
try:
swap_data, task = await create_reverse_swap(data)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code, detail=exc.response.json()["error"]
)
except Exception as exc:
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
swap = await create_reverse_submarine_swap(swap_data)
return swap.dict()
@boltz_ext.post(
"/api/v1/swap/status",
name=f"boltz.swap_status",
summary="shows the status of a swap",
description="""
This endpoint attempts to get the status of the swap.
""",
response_description="status of swap json",
responses={
404: {"description": "when swap_id is not found"},
},
)
async def api_swap_status(
swap_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
):
swap = await get_submarine_swap(swap_id) or await get_reverse_submarine_swap(
swap_id
)
if swap == None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
)
try:
status = get_swap_status(swap)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
)
return status
@boltz_ext.post(
"/api/v1/swap/check",
name=f"boltz.swap_check",
summary="list all pending swaps",
description="""
This endpoint gives you 2 lists of pending swaps and reverse swaps.
""",
response_description="list of pending swaps",
)
async def api_check_swaps(
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
status = []
try:
for swap in await get_pending_submarine_swaps(wallet_ids):
status.append(get_swap_status(swap))
for reverseswap in await get_pending_reverse_submarine_swaps(wallet_ids):
status.append(get_swap_status(reverseswap))
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
)
return status
@boltz_ext.get(
"/api/v1/swap/boltz",
name=f"boltz.get /swap/boltz",
summary="get a boltz configuration",
description="""
This endpoint gets configuration for boltz. (limits, fees...)
""",
response_description="dict of boltz config",
response_model=dict,
)
async def api_boltz_config():
try:
res = get_boltz_pairs()
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return res["pairs"]["BTC/BTC"]

View file

@ -73,7 +73,7 @@ async def lnurl_callback(
wallet_id=cp.wallet, wallet_id=cp.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo=cp.lnurl_title, memo=cp.lnurl_title,
description_hash=( unhashed_description=(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])) LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8"), ).encode("utf-8"),
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment}, extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},

View file

@ -0,0 +1,19 @@
# Invoices
## Create invoices that you can send to your client to pay online over Lightning.
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
## Usage
1. Create an invoice by clicking "NEW INVOICE"\
![create new invoice](https://imgur.com/a/Dce3wrr.png)
2. Fill the options for your INVOICE
- select the wallet
- select the fiat currency the invoice will be denominated in
- select a status for the invoice (default is draft)
- enter a company name, first name, last name, email, phone & address (optional)
- add one or more line items
- enter a name & price for each line item
3. You can then use share your invoice link with your customer to receive payment\
![invoice link](https://imgur.com/a/L0JOj4T.png)

View file

@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_invoices")
invoices_static_files = [
{
"path": "/invoices/static",
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
"name": "invoices_static",
}
]
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
def invoices_renderer():
return template_renderer(["lnbits/extensions/invoices/templates"])
from .tasks import wait_for_paid_invoices
def invoices_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Invoices",
"short_description": "Create invoices for your clients.",
"icon": "request_quote",
"contributors": ["leesalminen"]
}

View file

@ -0,0 +1,206 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
CreateInvoiceData,
CreateInvoiceItemData,
CreatePaymentData,
Invoice,
InvoiceItem,
Payment,
UpdateInvoiceData,
UpdateInvoiceItemData,
)
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
row = await db.fetchone(
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
)
return Invoice.from_row(row) if row else None
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
rows = await db.fetchall(
f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
)
return [InvoiceItem.from_row(row) for row in rows]
async def get_invoice_item(item_id: str) -> InvoiceItem:
row = await db.fetchone(
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
)
return InvoiceItem.from_row(row) if row else None
async def get_invoice_total(items: List[InvoiceItem]) -> int:
return sum(item.amount for item in items)
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Invoice.from_row(row) for row in rows]
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
rows = await db.fetchall(
f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
)
return [Payment.from_row(row) for row in rows]
async def get_invoice_payment(payment_id: str) -> Payment:
row = await db.fetchone(
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
)
return Payment.from_row(row) if row else None
async def get_payments_total(payments: List[Payment]) -> int:
return sum(item.amount for item in payments)
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
invoice_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
invoice_id,
wallet_id,
data.status,
data.currency,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
),
)
invoice = await get_invoice(invoice_id)
assert invoice, "Newly created invoice couldn't be retrieved"
return invoice
async def create_invoice_items(
invoice_id: str, data: List[CreateInvoiceItemData]
) -> List[InvoiceItem]:
for item in data:
item_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
VALUES (?, ?, ?, ?)
""",
(
item_id,
invoice_id,
item.description,
int(item.amount * 100),
),
)
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
await db.execute(
"""
UPDATE invoices.invoices
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
WHERE id = ?
""",
(
wallet_id,
data.currency,
data.status,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
data.id,
),
)
invoice = await get_invoice(data.id)
assert invoice, "Newly updated invoice couldn't be retrieved"
return invoice
async def update_invoice_items(
invoice_id: str, data: List[UpdateInvoiceItemData]
) -> List[InvoiceItem]:
updated_items = []
for item in data:
if item.id:
updated_items.append(item.id)
await db.execute(
"""
UPDATE invoices.invoice_items
SET description = ?, amount = ?
WHERE id = ?
""",
(item.description, int(item.amount * 100), item.id),
)
placeholders = ",".join("?" for i in range(len(updated_items)))
if not placeholders:
placeholders = "?"
updated_items = ("skip",)
await db.execute(
f"""
DELETE FROM invoices.invoice_items
WHERE invoice_id = ?
AND id NOT IN ({placeholders})
""",
(
invoice_id,
*tuple(updated_items),
),
)
for item in data:
if not item.id:
await create_invoice_items(invoice_id=invoice_id, data=[item])
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
payment_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.payments (id, invoice_id, amount)
VALUES (?, ?, ?)
""",
(
payment_id,
invoice_id,
amount,
),
)
payment = await get_invoice_payment(payment_id)
assert payment, "Newly created payment couldn't be retrieved"
return payment

View file

@ -0,0 +1,55 @@
async def m001_initial_invoices(db):
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
await db.execute(
f"""
CREATE TABLE invoices.invoices (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT NOT NULL,
company_name TEXT DEFAULT NULL,
first_name TEXT DEFAULT NULL,
last_name TEXT DEFAULT NULL,
email TEXT DEFAULT NULL,
phone TEXT DEFAULT NULL,
address TEXT DEFAULT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.invoice_items (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.payments (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)

View file

@ -0,0 +1,104 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
class InvoiceStatusEnum(str, Enum):
draft = "draft"
open = "open"
paid = "paid"
canceled = "canceled"
class CreateInvoiceItemData(BaseModel):
description: str
amount: float = Query(..., ge=0.01)
class CreateInvoiceData(BaseModel):
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[CreateInvoiceItemData]
class Config:
use_enum_values = True
class UpdateInvoiceItemData(BaseModel):
id: Optional[str]
description: str
amount: float = Query(..., ge=0.01)
class UpdateInvoiceData(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[UpdateInvoiceItemData]
class Invoice(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
time: int
class Config:
use_enum_values = True
@classmethod
def from_row(cls, row: Row) -> "Invoice":
return cls(**dict(row))
class InvoiceItem(BaseModel):
id: str
invoice_id: str
description: str
amount: int
class Config:
orm_mode = True
@classmethod
def from_row(cls, row: Row) -> "InvoiceItem":
return cls(**dict(row))
class Payment(BaseModel):
id: str
invoice_id: str
amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Payment":
return cls(**dict(row))
class CreatePaymentData(BaseModel):
invoice_id: str
amount: int

View file

@ -0,0 +1,65 @@
#invoicePage>.row:first-child>.col-md-6 {
display: flex;
}
#invoicePage>.row:first-child>.col-md-6>.q-card {
flex: 1;
}
#invoicePage .clear {
margin-bottom: 25px;
}
#printQrCode {
display: none;
}
@media (min-width: 1024px) {
#invoicePage>.row:first-child>.col-md-6:first-child>div {
margin-right: 5px;
}
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
margin-left: 5px;
}
}
@media print {
* {
color: black !important;
}
header, button, #payButtonContainer {
display: none !important;
}
main, .q-page-container {
padding-top: 0px !important;
}
.q-card {
box-shadow: none !important;
border: 1px solid black;
}
.q-item {
padding: 5px;
}
.q-card__section {
padding: 5px;
}
#printQrCode {
display: block;
}
p {
margin-bottom: 0px !important;
}
#invoicePage .clear {
margin-bottom: 10px !important;
}
}

View file

@ -0,0 +1,51 @@
import asyncio
import json
from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from .crud import (
create_invoice_payment,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
update_invoice_internal,
)
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "invoices":
# not relevant
return
invoice_id = payment.extra.get("invoice_id")
payment = await create_invoice_payment(
invoice_id=invoice_id, amount=payment.extra.get("famount")
)
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total >= invoice_total:
invoice.status = "paid"
await update_invoice_internal(invoice.wallet, invoice)
return

View file

@ -0,0 +1,153 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List Invoices">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /invoices/api/v1/invoices</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;invoice_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /invoices/api/v1/invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create Invoice Payment"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}/payments</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{payment_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check Invoice Payment Status"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,571 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Invoice</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Invoices</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="invoices"
row-key="id"
:columns="invoicesTable.columns"
:pagination.sync="invoicesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="edit"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="showEditModal(props.row)"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'pay/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Invoices extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveInvoice" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
:options="currencyOptions"
label="Currency *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.status"
:options="['draft', 'open', 'paid', 'canceled']"
label="Status *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.company_name"
label="Company Name"
placeholder="LNBits Labs"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.first_name"
label="First Name"
placeholder="Satoshi"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.last_name"
label="Last Name"
placeholder="Nakamoto"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
label="Email"
placeholder="satoshi@gmail.com"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.phone"
label="Phone"
placeholder="+81 (012)-345-6789"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.address"
label="Address"
placeholder="1600 Pennsylvania Ave."
type="textarea"
></q-input>
<q-list bordered separator>
<q-item
clickable
v-ripple
v-for="(item, index) in formDialog.invoiceItems"
:key="index"
>
<q-item-section>
<q-input
filled
dense
label="Item"
placeholder="Jelly Beans"
v-model="formDialog.invoiceItems[index].description"
></q-input>
</q-item-section>
<q-item-section>
<q-input
filled
dense
label="Amount"
placeholder="4.20"
v-model="formDialog.invoiceItems[index].amount"
></q-input>
</q-item-section>
<q-item-section side>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="formDialog.invoiceItems.splice(index, 1)"
></q-btn>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
Add Line Item
</q-btn>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id == 'undefined'"
>Create Invoice</q-btn
>
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id !== 'undefined'"
>Save Invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoices: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
invoicesTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'status', align: 'left', label: 'Status', field: 'status'},
{name: 'time', align: 'left', label: 'Created', field: 'time'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{
name: 'company_name',
align: 'left',
label: 'Company Name',
field: 'company_name'
},
{
name: 'first_name',
align: 'left',
label: 'First Name',
field: 'first_name'
},
{
name: 'last_name',
align: 'left',
label: 'Last Name',
field: 'last_name'
},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
{name: 'address', align: 'left', label: 'Address', field: 'address'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {},
invoiceItems: []
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
this.formDialog.invoiceItems = []
},
showEditModal: function (obj) {
this.formDialog.data = obj
this.formDialog.show = true
this.getInvoice(obj.id)
},
getInvoice: function (invoice_id) {
var self = this
LNbits.api
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
.then(function (response) {
self.formDialog.invoiceItems = response.data.items.map(function (
obj
) {
return mapInvoiceItems(obj)
})
})
},
getInvoices: function () {
var self = this
LNbits.api
.request(
'GET',
'/invoices/api/v1/invoices?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.invoices = response.data.map(function (obj) {
return mapInvoice(obj)
})
})
},
saveInvoice: function () {
var data = this.formDialog.data
data.items = this.formDialog.invoiceItems
var self = this
LNbits.api
.request(
'POST',
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
if (!data.id) {
self.invoices.push(mapInvoice(response.data))
} else {
self.getInvoices()
}
self.formDialog.invoiceItems = []
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteTPoS: function (tposId) {
var self = this
var tpos = _.findWhere(this.tposs, {id: tposId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this TPoS?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/tpos/api/v1/tposs/' + tposId,
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
)
.then(function (response) {
self.tposs = _.reject(self.tposs, function (obj) {
return obj.id == tposId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getInvoices()
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,430 @@
{% extends "public.html" %} {% block toolbar_title %} Invoice
<q-btn
flat
dense
size="md"
@click.prevent="urlDialog.show = true"
icon="share"
color="white"
></q-btn>
<q-btn
flat
dense
size="md"
@click.prevent="printInvoice()"
icon="print"
color="white"
></q-btn>
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
block page %}
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
<div id="invoicePage">
<div class="row q-gutter-y-md">
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Invoice</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>ID</b></q-item-section>
<q-item-section style="word-break: break-all"
>{{ invoice_id }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Created At</b></q-item-section>
<q-item-section
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
%H:%M') }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Status</b></q-item-section>
<q-item-section>
<span>
<q-badge color=""> {{ invoice.status }} </q-badge>
</span>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Total</b></q-item-section>
<q-item-section>
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Paid</b></q-item-section>
<q-item-section>
<div class="row" style="align-items: center">
<div class="col-sm-6">
{{ "{:0,.2f}".format(payments_total / 100) }} {{
invoice.currency }}
</div>
<div class="col-sm-6" id="payButtonContainer">
{% if payments_total < invoice_total %}
<q-btn
unelevated
color="primary"
@click="formDialog.show = true"
v-if="status == 'open'"
>
Pay Invoice
</q-btn>
{% endif %}
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Bill To</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>Company Name</b></q-item-section>
<q-item-section>{{ invoice.company_name }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Name</b></q-item-section>
<q-item-section
>{{ invoice.first_name }} {{ invoice.last_name
}}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Address</b></q-item-section>
<q-item-section>{{ invoice.address }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Email</b></q-item-section>
<q-item-section>{{ invoice.email }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Phone</b></q-item-section>
<q-item-section>{{ invoice.phone }}</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Items</b>
</p>
<q-list bordered separator>
{% if invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>Item</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>{{item.description}}</b></q-item-section>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Payments</b>
</p>
<q-list bordered separator>
{% if invoice_payments %}
<q-item clickable v-ripple>
<q-item-section><b>Date</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_payments %}
<q-item clickable v-ripple>
<q-item-section
><b
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
%H:%M') }}</b
></q-item-section
>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
<div class="col-12 col-md">
<div class="text-center">
<p><b>Scan to View & Pay Online!</b></p>
<qrcode
value="{{ request.url }}"
:options="{width: 200}"
class="rounded-borders"
></qrcode>
</div>
</div>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPayment" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.payment_amount"
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
min="0.01"
label="Payment Amount"
placeholder="4.20"
>
<template v-slot:append>
<span style="font-size: 12px"> {{ invoice.currency }} </span>
</template>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.payment_amount == null"
type="submit"
>Create Payment</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<br />
<q-btn
outline
color="grey"
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
>Copy Invoice</q-btn
>
</q-card>
</q-dialog>
<q-dialog v-model="urlDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
value="{{ request.url }}"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="text-center q-mb-xl">
<p style="word-break: break-all">{{ request.url }}</p>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
>Copy URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoice_id: '{{ invoice.id }}',
wallet: '{{ invoice.wallet }}',
currency: '{{ invoice.currency }}',
status: '{{ invoice.status }}',
qrCodeDialog: {
data: {
payment_request: null,
},
show: false,
},
formDialog: {
data: {
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
},
show: false,
},
urlDialog: {
show: false,
},
}
},
methods: {
printInvoice: function() {
window.print()
},
closeFormDialog: function() {
this.formDialog.show = false
},
closeQrCodeDialog: function() {
this.qrCodeDialog.show = false
},
createPayment: function () {
var self = this
var qrCodeDialog = this.qrCodeDialog
var formDialog = this.formDialog
var famount = parseInt(formDialog.data.payment_amount * 100)
axios
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
params: {
famount: famount,
}
})
.then(function (response) {
formDialog.show = false
formDialog.data = {}
qrCodeDialog.data = response.data
qrCodeDialog.show = true
console.log(qrCodeDialog.data)
qrCodeDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
qrCodeDialog.paymentChecker = setInterval(function () {
axios
.get(
'/invoices/api/v1/invoice/' +
self.invoice_id +
'/payments/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(qrCodeDialog.paymentChecker)
qrCodeDialog.dismissMsg()
qrCodeDialog.show = false
setTimeout(function () {
window.location.reload()
}, 500)
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
},
computed: {
statusBadgeColor: function() {
switch(this.status) {
case 'draft':
return 'gray'
break
case 'open':
return 'blue'
break
case 'paid':
return 'green'
break
case 'canceled':
return 'red'
break
}
},
},
created: function () {
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,59 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import invoices_ext, invoices_renderer
from .crud import (
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
)
templates = Jinja2Templates(directory="templates")
@invoices_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return invoices_renderer().TemplateResponse(
"invoices/index.html", {"request": request, "user": user.dict()}
)
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
async def index(request: Request, invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
return invoices_renderer().TemplateResponse(
"invoices/pay.html",
{
"request": request,
"invoice_id": invoice_id,
"invoice": invoice.dict(),
"invoice_items": invoice_items,
"invoice_total": invoice_total,
"invoice_payments": invoice_payments,
"payments_total": payments_total,
"datetime": datetime,
},
)

View file

@ -0,0 +1,136 @@
from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import invoices_ext
from .crud import (
create_invoice_internal,
create_invoice_items,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_invoices,
get_payments_total,
update_invoice_internal,
update_invoice_items,
)
from .models import CreateInvoiceData, UpdateInvoiceData
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
async def api_invoices(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice(invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
invoice_dict = invoice.dict()
invoice_dict["items"] = invoice_items
invoice_dict["payments"] = payments_total
return invoice_dict
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
async def api_invoice_create(
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
):
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice_update(
data: UpdateInvoiceData,
invoice_id: str,
wallet: WalletTypeInfo = Depends(get_key_type),
):
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post(
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
)
async def api_invoices_create_payment(
famount: int = Query(..., ge=1), invoice_id: str = None
):
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total + famount > invoice_total:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=invoice.wallet,
amount=price_in_sats,
memo=f"Payment for invoice {invoice_id}",
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@invoices_ext.get(
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status

View file

@ -90,7 +90,7 @@ async def lnurl_callback(
wallet_id=ls.wallet, wallet_id=ls.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo=await track.fullname(), memo=await track.fullname(),
description_hash=(await track.lnurlpay_metadata()).encode("utf-8"), unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "livestream", "track": track.id, "comment": comment}, extra={"tag": "livestream", "track": track.id, "comment": comment},
) )

View file

@ -13,7 +13,7 @@
Charge people for using your domain name...<br /> Charge people for using your domain name...<br />
<a <a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/lnaddress" href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
>More details</a >More details</a
> >
<br /> <br />

View file

@ -130,9 +130,8 @@ async def lndhub_gettxs(
offset=offset, offset=offset,
exclude_uncheckable=True, exclude_uncheckable=True,
): ):
await payment.set_pending( await payment.check_status()
(await WALLET.get_payment_status(payment.checking_id)).pending
)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
return [ return [

View file

@ -205,7 +205,7 @@ async def lnurl_callback(
wallet_id=device.wallet, wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000, amount=lnurldevicepayment.sats / 1000,
memo=device.title, memo=device.title,
description_hash=(await device.lnurlpay_metadata()).encode("utf-8"), unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "PoS"}, extra={"tag": "PoS"},
) )
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(

View file

@ -87,7 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
wallet_id=link.wallet, wallet_id=link.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo=link.description, memo=link.description,
description_hash=link.lnurlpay_metadata.encode("utf-8"), unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
extra={ extra={
"tag": "lnurlp", "tag": "lnurlp",
"link": link.id, "link": link.id,

View file

@ -296,16 +296,17 @@
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')" @click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn ><q-tooltip>Copy sharable link</q-tooltip>
> </q-btn>
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="nfc" icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)" @click="writeNfcTag(qrCodeDialog.data.lnurl)"
:disable="nfcTagWriting" :disable="nfcTagWriting"
> ><q-tooltip>Write to NFC</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
outline outline
@ -314,7 +315,8 @@
type="a" type="a"
:href="qrCodeDialog.data.print_url" :href="qrCodeDialog.data.print_url"
target="_blank" target="_blank"
></q-btn> ><q-tooltip>Print</q-tooltip></q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>

View file

@ -96,7 +96,7 @@ async def api_link_create_or_update(
data.min *= data.fiat_base_multiplier data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier data.max *= data.fiat_base_multiplier
if data.success_url is not None and data.success_url.startswith("https://"): if data.success_url is not None and not data.success_url.startswith("https://"):
raise HTTPException( raise HTTPException(
detail="Success URL must be secure https://...", detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@ -121,7 +121,7 @@ async def api_link_create_or_update(
return {**link.dict(), "lnurl": link.lnurl(request)} return {**link.dict(), "lnurl": link.lnurl(request)}
@lnurlp_ext.delete("/api/v1/links/{link_id}") @lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)): async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
link = await get_pay_link(link_id) link = await get_pay_link(link_id)
@ -136,7 +136,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
) )
await delete_pay_link(link_id) await delete_pay_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return {"success": True}
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK) @lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)

View file

@ -73,7 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
wallet_id=shop.wallet, wallet_id=shop.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo=item.name, memo=item.name,
description_hash=(await item.lnurlpay_metadata()).encode("utf-8"), unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "offlineshop", "item": item.id}, extra={"tag": "offlineshop", "item": item.id},
) )
except Exception as exc: except Exception as exc:

View file

@ -77,7 +77,7 @@ async def api_lnurlp_callback(
wallet_id=link.wallet, wallet_id=link.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo="Satsdice bet", memo="Satsdice bet",
description_hash=link.lnurlpay_metadata.encode("utf-8"), unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "satsdice", "link": link.id, "comment": "comment"}, extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
) )

View file

@ -232,7 +232,7 @@
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="share" icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')" @click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
><q-tooltip>Copy shareable link</q-tooltip></q-btn ><q-tooltip>Copy shareable link</q-tooltip></q-btn
> >

View file

@ -13,7 +13,7 @@
Charge people for using your subdomain name...<br /> Charge people for using your subdomain name...<br />
<a <a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/subdomains" href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/subdomains"
>More details</a >More details</a
> >
<br /> <br />

View file

@ -76,10 +76,10 @@ async def get_tipjars(wallet_id: str) -> Optional[list]:
async def delete_tipjar(tipjar_id: int) -> None: async def delete_tipjar(tipjar_id: int) -> None:
"""Delete a TipJar and all corresponding Tips""" """Delete a TipJar and all corresponding Tips"""
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,)) rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
for row in rows: for row in rows:
await delete_tip(row["id"]) await delete_tip(row["id"])
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
async def get_tip(tip_id: str) -> Optional[Tip]: async def get_tip(tip_id: str) -> Optional[Tip]:

View file

@ -23,3 +23,7 @@ class TPoS(BaseModel):
@classmethod @classmethod
def from_row(cls, row: Row) -> "TPoS": def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row)) return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str

View file

@ -14,7 +14,7 @@
<div class="row justify-center full-width"> <div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center"> <div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
<h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3> <h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3>
<h5 class="q-mt-none"> <h5 class="q-mt-none q-mb-sm">
{% raw %}{{ fsat }}{% endraw %} <small>sat</small> {% raw %}{{ fsat }}{% endraw %} <small>sat</small>
</h5> </h5>
</div> </div>
@ -174,8 +174,21 @@
> >
{% endraw %} {% endraw %}
</h5> </h5>
<q-btn
outline
color="grey"
icon="nfc"
@click="readNfcTag()"
:disable="nfcTagReading"
></q-btn>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(invoiceDialog.data.payment_request)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
@ -281,6 +294,7 @@
exchangeRate: null, exchangeRate: null,
stack: [], stack: [],
tipAmount: 0.0, tipAmount: 0.0,
nfcTagReading: false,
invoiceDialog: { invoiceDialog: {
show: false, show: false,
data: null, data: null,
@ -356,7 +370,7 @@
this.showInvoice() this.showInvoice()
}, },
submitForm: function () { submitForm: function () {
if (this.tip_options) { if (this.tip_options.length) {
this.showTipModal() this.showTipModal()
} else { } else {
this.showInvoice() this.showInvoice()
@ -410,6 +424,98 @@
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message}) => {
//Decode NDEF data from tag
const textDecoder = new TextDecoder('utf-8')
const record = message.records.find(el => {
const payload = textDecoder.decode(el.data)
return payload.toUpperCase().indexOf('LNURL') !== -1
})
const lnurl = textDecoder.decode(record.data)
//User feedback, show loader icon
self.nfcTagReading = false
self.payInvoice(lnurl, readerAbortController)
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
payInvoice: function (lnurl, readerAbortController) {
const self = this
return axios
.post(
'/tpos/api/v1/tposs/' +
self.tposId +
'/invoices/' +
self.invoiceDialog.data.payment_request +
'/pay',
{
lnurl: lnurl
}
)
.then(response => {
if (!response.data.success) {
this.$q.notify({
type: 'negative',
message: response.data.detail
})
}
readerAbortController.abort()
})
},
getRates: function () { getRates: function () {
var self = this var self = this
axios.get('https://api.opennode.co/v1/rates').then(function (response) { axios.get('https://api.opennode.co/v1/rates').then(function (response) {

View file

@ -1,7 +1,9 @@
from http import HTTPStatus from http import HTTPStatus
import httpx
from fastapi import Query from fastapi import Query
from fastapi.params import Depends from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
@ -12,7 +14,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import tpos_ext from . import tpos_ext
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
from .models import CreateTposData from .models import CreateTposData, PayLnurlWData
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK) @tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
@ -79,6 +81,66 @@ async def api_tpos_create_invoice(
return {"payment_hash": payment_hash, "payment_request": payment_request} return {"payment_hash": payment_hash, "payment_request": payment_request}
@tpos_ext.post(
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
)
async def api_tpos_pay_invoice(
lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None
):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
lnurl = (
lnurl_data.lnurl.replace("lnurlw://", "")
.replace("lightning://", "")
.replace("LIGHTNING://", "")
.replace("lightning:", "")
.replace("LIGHTNING:", "")
)
if lnurl.lower().startswith("lnurl"):
lnurl = decode_lnurl(lnurl)
else:
lnurl = "https://" + lnurl
async with httpx.AsyncClient() as client:
try:
r = await client.get(lnurl, follow_redirects=True)
if r.is_error:
lnurl_response = {"success": False, "detail": "Error loading"}
else:
resp = r.json()
if resp["tag"] != "withdrawRequest":
lnurl_response = {"success": False, "detail": "Wrong tag type"}
else:
r2 = await client.get(
resp["callback"],
follow_redirects=True,
params={
"k1": resp["k1"],
"pr": payment_request,
},
)
resp2 = r2.json()
if r2.is_error:
lnurl_response = {
"success": False,
"detail": "Error loading callback",
}
elif resp2["status"] == "ERROR":
lnurl_response = {"success": False, "detail": resp2["reason"]}
else:
lnurl_response = {"success": True, "detail": resp2}
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
return lnurl_response
@tpos_ext.get( @tpos_ext.get(
"/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
) )

View file

@ -111,7 +111,7 @@
<q-td colspan="100%"> <q-td colspan="100%">
<div class="row items-center q-mt-md q-mb-lg"> <div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div> <div class="col-2 q-pr-lg"></div>
<div class="col-4 q-pr-lg"> <div class="col-2 q-pr-lg">
<q-btn <q-btn
unelevated unelevated
dense dense
@ -123,6 +123,16 @@
QR Code</q-btn QR Code</q-btn
> >
</div> </div>
<div class="col-2 q-pr-lg">
<q-btn
outline
color="grey"
icon="content_copy"
@click="copyText(props.row.address)"
class="q-ml-sm"
>Copy</q-btn
>
</div>
<div class="col-2 q-pr-lg"> <div class="col-2 q-pr-lg">
<q-btn <q-btn
outline outline

View file

@ -74,6 +74,16 @@ async function addressList(path) {
satBtc(val, showUnit = true) { satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated) return satOrBtc(val, showUnit, this.satsDenominated)
}, },
// todo: bad. base.js not present in custom components
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
notify({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
},
getWalletName: function (walletId) { getWalletName: function (walletId) {
const wallet = (this.accounts || []).find(wl => wl.id === walletId) const wallet = (this.accounts || []).find(wl => wl.id === walletId)
return wallet ? wallet.title : 'unknown' return wallet ? wallet.title : 'unknown'

View file

@ -39,6 +39,10 @@ async function history(path) {
label: 'Date', label: 'Date',
field: 'date', field: 'date',
sortable: true sortable: true
},
{
name: 'txId',
field: 'txId'
} }
], ],
exportColums: [ exportColums: [

View file

@ -33,7 +33,6 @@ async function payment(path) {
signedTxHex: null, signedTxHex: null,
sentTxId: null, sentTxId: null,
signedTxId: null, signedTxId: null,
paymentTab: 'destination',
sendToList: [{address: '', amount: undefined}], sendToList: [{address: '', amount: undefined}],
changeWallet: null, changeWallet: null,
changeAddress: {}, changeAddress: {},
@ -83,12 +82,27 @@ async function payment(path) {
satBtc(val, showUnit = true) { satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated) return satOrBtc(val, showUnit, this.satsDenominated)
}, },
clearState: function () {
this.psbtBase64 = null
this.psbtBase64Signed = null
this.signedTx = null
this.signedTxHex = null
this.signedTxId = null
this.sendToList = [{address: '', amount: undefined}]
this.showChecking = false
this.showPsbt = false
this.showFinalTx = false
},
checkAndSend: async function () { checkAndSend: async function () {
this.showChecking = true this.showChecking = true
try { try {
if (!this.serialSignerRef.isConnected()) { if (!this.serialSignerRef.isConnected()) {
const portOpen = await this.serialSignerRef.openSerialPort() this.$q.notify({
if (!portOpen) return type: 'warning',
message: 'Please connect to a Signing device first!',
timeout: 10000
})
return
} }
if (!this.serialSignerRef.isAuthenticated()) { if (!this.serialSignerRef.isAuthenticated()) {
await this.serialSignerRef.hwwShowPasswordDialog() await this.serialSignerRef.hwwShowPasswordDialog()
@ -139,7 +153,6 @@ async function payment(path) {
}, },
createPsbt: async function () { createPsbt: async function () {
try { try {
console.log('### this.createPsbt')
this.tx = this.createTx() this.tx = this.createTx()
for (const input of this.tx.inputs) { for (const input of this.tx.inputs) {
input.tx_hex = await this.fetchTxHex(input.tx_id) input.tx_hex = await this.fetchTxHex(input.tx_id)
@ -233,8 +246,6 @@ async function payment(path) {
this.showChecking = true this.showChecking = true
this.psbtBase64Signed = psbtBase64 this.psbtBase64Signed = psbtBase64
console.log('### payment updateSignedPsbt psbtBase64', psbtBase64)
const data = await this.extractTxFromPsbt(psbtBase64) const data = await this.extractTxFromPsbt(psbtBase64)
this.showFinalTx = true this.showFinalTx = true
if (data) { if (data) {
@ -249,7 +260,6 @@ async function payment(path) {
} }
}, },
extractTxFromPsbt: async function (psbtBase64) { extractTxFromPsbt: async function (psbtBase64) {
console.log('### extractTxFromPsbt psbtBase64', psbtBase64)
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
@ -260,13 +270,12 @@ async function payment(path) {
inputs: this.tx.inputs inputs: this.tx.inputs
} }
) )
console.log('### extractTxFromPsbt data', data)
return data return data
} catch (error) { } catch (error) {
console.log('### error', error)
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
message: 'Cannot finalize PSBT!', message: 'Cannot finalize PSBT!',
caption: `${error}`,
timeout: 10000 timeout: 10000
}) })
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
@ -289,8 +298,8 @@ async function payment(path) {
timeout: 10000 timeout: 10000
}) })
// todo: event rescan with amount this.clearState()
// todo: display tx id this.$emit('broadcast-done', this.sentTxId)
} catch (error) { } catch (error) {
this.sentTxId = null this.sentTxId = null
this.$q.notify({ this.$q.notify({

View file

@ -1,4 +1,15 @@
<div> <div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.name"
label="Name (optional)"
></q-input>
</div>
</div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-mt-md"> <div class="row q-mt-md">
<div class="col-12"> <div class="col-12">
<q-input <q-input
@ -64,4 +75,26 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.buttonOnePin"
label="Pin Number (Button 1)"
></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.buttonTwoPin"
label="Pin Number (Button 2)"
></q-input>
</div>
</div>
</div> </div>

View file

@ -2,23 +2,11 @@ async function serialPortConfig(path) {
const t = await loadTemplateAsync(path) const t = await loadTemplateAsync(path)
Vue.component('serial-port-config', { Vue.component('serial-port-config', {
name: 'serial-port-config', name: 'serial-port-config',
props: ['config'],
template: t, template: t,
data() { data() {
return { return {}
config: {
baudRate: 9600,
bufferSize: 255,
dataBits: 8,
flowControl: 'none',
parity: 'none',
stopBits: 1
}
}
}, },
methods: { methods: {}
getConfig: function () {
return this.config
}
}
}) })
} }

View file

@ -46,6 +46,27 @@
> >
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item
v-for="device in pairedDevices"
:key="device.id"
v-if="!selectedPort"
clickable
v-close-popup
>
<q-item-section>
<q-item-label @click="openSerialPortConfig(device.id)"
>Paired Device ({{device.config.name || 'no-name'}})
</q-item-label>
<q-item-label caption @click="openSerialPortConfig(device.id)"
>{{device.id}}
</q-item-label>
<q-item-label caption @click="removePairedDevice(device.id)">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Forget</q-btn
>
</q-item-label>
</q-item-section>
</q-item>
<q-item <q-item
v-if="selectedPort" v-if="selectedPort"
clickable clickable
@ -123,10 +144,9 @@
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwConfigAndConnect" class="q-gutter-md"> <q-form @submit="hwwConfigAndConnect" class="q-gutter-md">
<span>Enter Config</span> <span>Enter Config</span>
<serial-port-config <serial-port-config
ref="serialPortConfig" ref="serialPortConfig"
:config="hww.config" :config="config"
></serial-port-config> ></serial-port-config>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
@ -223,7 +243,7 @@
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<div class="col-12"> <div class="col-12">
<q-badge class="text-subtitle2" color="yellow" text-color="black"> <q-badge class="text-subtitle2" color="yellow" text-color="black">
<span>Check data on the display of the hardware device.</span> <span>Confirm then check the Hardware Device.</span>
</q-badge> </q-badge>
</div> </div>
</div> </div>
@ -329,6 +349,25 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="showConsole" position="top">
<q-card class="q-pa-lg q-pt-xl">
<q-input
filled
dense
for="serial-port-console"
v-model.trim="receivedData"
type="textarea"
rows="25"
cols="200"
label="Console"
></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="hww.showSeedDialog" position="top"> <q-dialog v-model="hww.showSeedDialog" position="top">
<q-card class="q-pa-lg q-pt-xl"> <q-card class="q-pa-lg q-pt-xl">
<span>Check word at position {{hww.seedWordPosition}} on display</span> <span>Check word at position {{hww.seedWordPosition}} on display</span>
@ -389,6 +428,31 @@
</template> </template>
</q-input> </q-input>
<br /> <br />
<q-toggle
label="Passphrase (optional)"
color="secodary"
v-model="hww.hasPassphrase"
></q-toggle>
<br />
<q-input
v-if="hww.hasPassphrase"
v-model.trim="hww.passphrase"
filled
:type="hww.showPassphrase ? 'text' : 'password'"
filled
dense
label="Passphrase"
>
<template v-slot:append>
<q-icon
:name="hww.showPassphrase ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="hww.showPassphrase = !hww.showPassphrase"
/>
</template>
</q-input>
<q-separator></q-separator>
<br />
<span>Enter new password (8 numbers/letters)</span> <span>Enter new password (8 numbers/letters)</span>
<q-input <q-input
v-model.trim="hww.password" v-model.trim="hww.password"
@ -414,15 +478,7 @@
type="password" type="password"
label="Confirm Password" label="Confirm Password"
></q-input> ></q-input>
<br /><br /> <br />
<q-badge
color="pink"
text-color="black"
class="text-subtitle2"
multi-line
>
For test purposes only. Do not enter word list with real funds!!!
</q-badge>
<q-separator></q-separator> <q-separator></q-separator>
<q-badge <q-badge
color="pink" color="pink"

View file

@ -14,12 +14,17 @@ async function serialSigner(path) {
reader: null, reader: null,
receivedData: '', receivedData: '',
config: {}, config: {},
decryptionKey: null,
sharedSecret: null, // todo: store in secure local storage
hww: { hww: {
password: null, password: null,
showPassword: false, showPassword: false,
mnemonic: null, mnemonic: null,
showMnemonic: false, showMnemonic: false,
passphrase: null,
showPassphrase: false,
hasPassphrase: false,
authenticated: false, authenticated: false,
showPasswordDialog: false, showPasswordDialog: false,
showConfigDialog: false, showConfigDialog: false,
@ -34,6 +39,8 @@ async function serialSigner(path) {
xpubResolve: null, xpubResolve: null,
seedWordPosition: 1, seedWordPosition: 1,
showSeedDialog: false, showSeedDialog: false,
// config: null,
confirm: { confirm: {
outputIndex: 0, outputIndex: 0,
showFee: false showFee: false
@ -45,12 +52,30 @@ async function serialSigner(path) {
} }
}, },
computed: {
pairedDevices: {
get: function () {
return (
JSON.parse(window.localStorage.getItem('lnbits-paired-devices')) ||
[]
)
},
set: function (devices) {
window.localStorage.setItem(
'lnbits-paired-devices',
JSON.stringify(devices)
)
}
}
},
methods: { methods: {
satBtc(val, showUnit = true) { satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated) return satOrBtc(val, showUnit, this.satsDenominated)
}, },
openSerialPortDialog: async function () { openSerialPortDialog: async function () {
await this.openSerialPort() this.config = {...HWW_DEFAULT_CONFIG}
await this.openSerialPort(this.config)
}, },
openSerialPort: async function (config = {baudRate: 9600}) { openSerialPort: async function (config = {baudRate: 9600}) {
if (!this.checkSerialPortSupported()) return false if (!this.checkSerialPortSupported()) return false
@ -64,12 +89,13 @@ async function serialSigner(path) {
} }
try { try {
navigator.serial.addEventListener('connect', event => { this.selectedPort = await navigator.serial.requestPort()
console.log('### navigator.serial event: connected!', event) this.selectedPort.addEventListener('connect', event => {
// do nothing
}) })
navigator.serial.addEventListener('disconnect', () => { this.selectedPort.addEventListener('disconnect', () => {
console.log('### navigator.serial event: disconnected!', event) this.selectedPort = null
this.hww.authenticated = false this.hww.authenticated = false
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -77,7 +103,7 @@ async function serialSigner(path) {
timeout: 10000 timeout: 10000
}) })
}) })
this.selectedPort = await navigator.serial.requestPort()
// Wait for the serial port to open. // Wait for the serial port to open.
await this.selectedPort.open(config) await this.selectedPort.open(config)
this.startSerialPortReading() this.startSerialPortReading()
@ -88,6 +114,9 @@ async function serialSigner(path) {
) )
this.writer = textEncoder.writable.getWriter() this.writer = textEncoder.writable.getWriter()
await this.hwwPing()
return true return true
} catch (error) { } catch (error) {
this.selectedPort = null this.selectedPort = null
@ -100,7 +129,13 @@ async function serialSigner(path) {
return false return false
} }
}, },
openSerialPortConfig: async function () { openSerialPortConfig: async function (deviceId) {
const device = this.getPairedDevice(deviceId)
if (device) {
this.config = device.config
} else {
this.config = {...HWW_DEFAULT_CONFIG}
}
this.hww.showConfigDialog = true this.hww.showConfigDialog = true
}, },
closeSerialPort: async function () { closeSerialPort: async function () {
@ -113,14 +148,12 @@ async function serialSigner(path) {
/* Ignore the error */ /* Ignore the error */
}) })
if (this.selectedPort) await this.selectedPort.close() if (this.selectedPort) await this.selectedPort.close()
this.selectedPort = null
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
message: 'Serial port disconnected!', message: 'Serial port disconnected!',
timeout: 5000 timeout: 5000
}) })
} catch (error) { } catch (error) {
this.selectedPort = null
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
message: 'Cannot close serial port!', message: 'Cannot close serial port!',
@ -128,6 +161,7 @@ async function serialSigner(path) {
timeout: 10000 timeout: 10000
}) })
} finally { } finally {
this.selectedPort = null
this.hww.authenticated = false this.hww.authenticated = false
} }
}, },
@ -199,11 +233,17 @@ async function serialSigner(path) {
} }
} }
}, },
handleSerialPortResponse: function (value) { handleSerialPortResponse: async function (value) {
const command = value.split(' ')[0] const {command, commandData} = await this.extractCommand(value)
const commandData = value.substring(command.length).trim() this.logPublicCommandsResponse(command, commandData)
switch (command) { switch (command) {
case COMMAND_PING:
this.handlePingResponse(commandData)
break
case COMMAND_CHECK_PAIRING:
this.handleCheckPairingResponse(commandData)
break
case COMMAND_SIGN_PSBT: case COMMAND_SIGN_PSBT:
this.handleSignResponse(commandData) this.handleSignResponse(commandData)
break break
@ -222,8 +262,35 @@ async function serialSigner(path) {
case COMMAND_XPUB: case COMMAND_XPUB:
this.handleXpubResponse(commandData) this.handleXpubResponse(commandData)
break break
case COMMAND_SEED:
this.handleShowSeedResponse(commandData)
break
case COMMAND_PAIR:
this.handlePairResponse(commandData)
break
case COMMAND_LOG:
console.log(
` %c${commandData}`,
'background: #222; color: #bada55'
)
break
default: default:
console.log('### console', value) console.log(` %c${value}`, 'background: #222; color: red')
}
},
logPublicCommandsResponse: function (command, commandData) {
switch (command) {
case COMMAND_SIGN_PSBT:
case COMMAND_PASSWORD:
case COMMAND_PASSWORD_CLEAR:
case COMMAND_SEND_PSBT:
case COMMAND_WIPE:
case COMMAND_XPUB:
case COMMAND_PAIR:
console.log(
` %c${command} ${commandData}`,
'background: #222; color: yellow'
)
} }
}, },
updateSerialPortConsole: function (value) { updateSerialPortConsole: function (value) {
@ -231,11 +298,48 @@ async function serialSigner(path) {
const textArea = document.getElementById('serial-port-console') const textArea = document.getElementById('serial-port-console')
if (textArea) textArea.scrollTop = textArea.scrollHeight if (textArea) textArea.scrollTop = textArea.scrollHeight
}, },
hwwPing: async function () {
try {
await this.sendCommandClearText(COMMAND_PING, [window.location.host])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to ping Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
handlePingResponse: function (res = '') {
const [status, deviceId] = res.split(' ')
this.deviceId = deviceId
if (!this.deviceId) {
this.$q.notify({
type: 'warning',
message: 'Missing device ID for Hardware Wallet',
timeout: 10000
})
return
}
const device = this.getPairedDevice(deviceId)
if (device) {
this.sharedSecret = nobleSecp256k1.utils.hexToBytes(
device.sharedSecretHex
)
this.hwwCheckPairing()
} else {
this.hwwPair()
}
},
hwwShowPasswordDialog: async function () { hwwShowPasswordDialog: async function () {
try { try {
this.hww.showPasswordDialog = true this.hww.showPasswordDialog = true
await this.writer.write(COMMAND_PASSWORD + '\n') await this.sendCommandSecure(COMMAND_PASSWORD)
} catch (error) { } catch (error) {
console.log(error)
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
message: 'Failed to connect to Hardware Wallet!', message: 'Failed to connect to Hardware Wallet!',
@ -247,7 +351,7 @@ async function serialSigner(path) {
hwwShowWipeDialog: async function () { hwwShowWipeDialog: async function () {
try { try {
this.hww.showWipeDialog = true this.hww.showWipeDialog = true
await this.writer.write(COMMAND_WIPE + '\n') await this.sendCommandSecure(COMMAND_WIPE)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -260,7 +364,7 @@ async function serialSigner(path) {
hwwShowRestoreDialog: async function () { hwwShowRestoreDialog: async function () {
try { try {
this.hww.showRestoreDialog = true this.hww.showRestoreDialog = true
await this.writer.write(COMMAND_WIPE + '\n') await this.sendCommandSecure(COMMAND_RESTORE)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -275,11 +379,11 @@ async function serialSigner(path) {
if (this.hww.confirm.outputIndex >= this.tx.outputs.length) { if (this.hww.confirm.outputIndex >= this.tx.outputs.length) {
this.hww.confirm.showFee = true this.hww.confirm.showFee = true
} }
await this.writer.write(COMMAND_CONFIRM_NEXT + '\n') await this.sendCommandSecure(COMMAND_CONFIRM_NEXT)
}, },
cancelOperation: async function () { cancelOperation: async function () {
try { try {
await this.writer.write(COMMAND_CANCEL + '\n') await this.sendCommandSecure(COMMAND_CANCEL)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -291,15 +395,15 @@ async function serialSigner(path) {
}, },
hwwConfigAndConnect: async function () { hwwConfigAndConnect: async function () {
this.hww.showConfigDialog = false this.hww.showConfigDialog = false
const config = this.$refs.serialPortConfig.getConfig() if (this.config.deviceId) {
await this.openSerialPort(config) this.updatePairedDeviceConfig(this.config.deviceId, this.config)
}
await this.openSerialPort(this.config)
return true return true
}, },
hwwLogin: async function () { hwwLogin: async function () {
try { try {
await this.writer.write( await this.sendCommandSecure(COMMAND_PASSWORD, [this.hww.password])
COMMAND_PASSWORD + ' ' + this.hww.password + '\n'
)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -335,7 +439,7 @@ async function serialSigner(path) {
}, },
hwwLogout: async function () { hwwLogout: async function () {
try { try {
await this.writer.write(COMMAND_PASSWORD_CLEAR + '\n') await this.sendCommandSecure(COMMAND_PASSWORD_CLEAR)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -346,22 +450,24 @@ async function serialSigner(path) {
} }
}, },
handleLogoutResponse: function (res = '') { handleLogoutResponse: function (res = '') {
this.hww.authenticated = !(res.trim() === '1') const authenticated = !(res.trim() === '1')
if (this.hww.authenticated) { if (this.hww.authenticated && !authenticated) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'positive',
message: 'Failed to logout from Hardware Wallet', message: 'Logged Out',
timeout: 10000 timeout: 10000
}) })
} }
this.hww.authenticated = authenticated
}, },
hwwSendPsbt: async function (psbtBase64, tx) { hwwSendPsbt: async function (psbtBase64, tx) {
try { try {
this.tx = tx this.tx = tx
this.hww.sendingPsbt = true this.hww.sendingPsbt = true
await this.writer.write( await this.sendCommandSecure(COMMAND_SEND_PSBT, [
COMMAND_SEND_PSBT + ' ' + this.network + ' ' + psbtBase64 + '\n' this.network,
) psbtBase64
])
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
message: 'Data sent to serial port device!', message: 'Data sent to serial port device!',
@ -411,7 +517,7 @@ async function serialSigner(path) {
try { try {
this.hww.showConfirmationDialog = false this.hww.showConfirmationDialog = false
this.hww.signingPsbt = true this.hww.signingPsbt = true
await this.writer.write(COMMAND_SIGN_PSBT + '\n') await this.sendCommandSecure(COMMAND_SIGN_PSBT)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -441,9 +547,152 @@ async function serialSigner(path) {
timeout: 10000 timeout: 10000
}) })
}, },
hwwCheckPairing: async function () {
const iv = window.crypto.getRandomValues(new Uint8Array(16))
const encrypted = await this.encryptMessage(
this.sharedSecret,
iv,
PAIRING_CONTROL_TEXT.length + ' ' + PAIRING_CONTROL_TEXT
)
const encryptedHex = nobleSecp256k1.utils.bytesToHex(encrypted)
const encryptedIvHex = nobleSecp256k1.utils.bytesToHex(iv)
try {
await this.sendCommandClearText(COMMAND_CHECK_PAIRING, [
encryptedHex + encryptedIvHex
])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to check secure connection!',
caption: `${error}`,
timeout: 10000
})
}
},
handleCheckPairingResponse: async function (res = '') {
const [statusCode, encryptedMessage] = res.split(' ')
switch (statusCode) {
case '0':
const controlText = await this.decryptData(encryptedMessage)
if (controlText == PAIRING_CONTROL_TEXT) {
this.$q.notify({
type: 'positive',
message: 'Re-paired with success!',
timeout: 10000
})
} else {
this.$q.notify({
type: 'warning',
message: 'Re-pairing failed!',
caption: 'Remove (forget) device and try again!',
timeout: 10000
})
}
break
default:
// noting to do here yet
break
}
},
hwwPair: async function () {
try {
this.decryptionKey = nobleSecp256k1.utils.randomPrivateKey()
const publicKey = nobleSecp256k1.Point.fromPrivateKey(
this.decryptionKey
)
const publicKeyHex = publicKey.toHex().slice(2)
const args = [publicKeyHex]
if (Number.isInteger(+this.config.buttonOnePin)) {
args.push(this.config.buttonOnePin)
}
if (Number.isInteger(+this.config.buttonTwoPin)) {
args.push(this.config.buttonTwoPin)
}
await this.sendCommandClearText(COMMAND_PAIR, args)
this.$q.notify({
type: 'positive',
message: 'Pairing started!',
timeout: 5000
})
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to pair with device!',
caption: `${error}`,
timeout: 10000
})
}
},
handlePairResponse: async function (res = '') {
const [statusCode, data] = res.trim().split(' ')
let pubKeyHex, errorMessage, captionMessage
switch (statusCode) {
case '0':
pubKeyHex = data
if (!data) errorMessage = 'Failed to exchange DH secret!'
break
case '1':
errorMessage =
'Device pairing only possible in the first 10 seconds after start-up!'
captionMessage = 'Restart and try again'
break
default:
errorMessage = 'Unexpected error code'
break
}
if (errorMessage) {
this.$q.notify({
type: 'warning',
message: errorMessage,
caption: captionMessage || '',
timeout: 10000
})
this.closeSerialPort()
return
}
const hwwPublicKey = nobleSecp256k1.Point.fromHex('04' + pubKeyHex)
this.sharedSecret = nobleSecp256k1
.getSharedSecret(this.decryptionKey, hwwPublicKey)
.slice(1, 33)
const sharedSecretHex = nobleSecp256k1.utils.bytesToHex(
this.sharedSecret
)
const sharedSecredHash = await nobleSecp256k1.utils.sha256(
asciiToUint8Array(sharedSecretHex)
)
const fingerprint = nobleSecp256k1.utils
.bytesToHex(sharedSecredHash)
.substring(0, 5)
.toUpperCase()
LNbits.utils
.confirmDialog('Confirm code from display: ' + fingerprint)
.onOk(() => {
this.addPairedDevice(
this.deviceId,
nobleSecp256k1.utils.bytesToHex(this.sharedSecret),
this.config
)
this.$q.notify({
type: 'positive',
message: 'Paired with device!',
timeout: 5000
})
})
.onCancel(() => {
this.closeSerialPort()
})
},
hwwHelp: async function () { hwwHelp: async function () {
try { try {
await this.writer.write(COMMAND_HELP + '\n') await this.sendCommandSecure(COMMAND_HELP)
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
message: 'Check display or console for details!', message: 'Check display or console for details!',
@ -461,7 +710,7 @@ async function serialSigner(path) {
hwwWipe: async function () { hwwWipe: async function () {
try { try {
this.hww.showWipeDialog = false this.hww.showWipeDialog = false
await this.writer.write(COMMAND_WIPE + ' ' + this.hww.password + '\n') await this.sendCommandSecure(COMMAND_WIPE, [this.hww.password])
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -494,13 +743,7 @@ async function serialSigner(path) {
}, },
hwwXpub: async function (path) { hwwXpub: async function (path) {
try { try {
console.log( await this.sendCommandSecure(COMMAND_XPUB, [this.network, path])
'### hwwXpub',
COMMAND_XPUB + ' ' + this.network + ' ' + path
)
await this.writer.write(
COMMAND_XPUB + ' ' + this.network + ' ' + path + '\n'
)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -526,13 +769,15 @@ async function serialSigner(path) {
const fingerprint = args[2].trim() const fingerprint = args[2].trim()
this.xpubResolve({xpub, fingerprint}) this.xpubResolve({xpub, fingerprint})
}, },
hwwShowSeed: async function () { hwwShowSeed: async function () {
try { try {
this.hww.showSeedDialog = true this.hww.showSeedDialog = true
this.hww.seedWordPosition = 1 this.hww.seedWordPosition = 1
await this.writer.write(
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' await this.sendCommandSecure(COMMAND_SEED, [
) this.hww.seedWordPosition
])
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -544,37 +789,29 @@ async function serialSigner(path) {
}, },
showNextSeedWord: async function () { showNextSeedWord: async function () {
this.hww.seedWordPosition++ this.hww.seedWordPosition++
await this.writer.write( await this.sendCommandSecure(COMMAND_SEED, [this.hww.seedWordPosition])
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
)
}, },
showPrevSeedWord: async function () { showPrevSeedWord: async function () {
this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1) this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1)
console.log('### this.hww.seedWordPosition', this.hww.seedWordPosition) await this.sendCommandSecure(COMMAND_SEED, [this.hww.seedWordPosition])
await this.writer.write(
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
)
}, },
handleShowSeedResponse: function (res = '') { handleShowSeedResponse: function (res = '') {
const args = res.trim().split(' ') const args = res.trim().split(' ')
if (args.length < 2 || args[0].trim() !== '1') {
this.$q.notify({
type: 'warning',
message: 'Failed to show seed!',
caption: `${res}`,
timeout: 10000
})
return
}
}, },
hwwRestore: async function () { hwwRestore: async function () {
try { try {
await this.writer.write( let mnemonicWithPassphrase = this.hww.mnemonic
COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n' if (
) this.hww.hasPassphrase &&
await this.writer.write( this.hww.passphrase &&
COMMAND_PASSWORD + ' ' + this.hww.password + '\n' this.hww.passphrase.length
) ) {
mnemonicWithPassphrase += '/' + this.hww.passphrase
}
await this.sendCommandSecure(COMMAND_RESTORE, [
this.hww.password,
mnemonicWithPassphrase
])
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
@ -585,6 +822,7 @@ async function serialSigner(path) {
} finally { } finally {
this.hww.showRestoreDialog = false this.hww.showRestoreDialog = false
this.hww.mnemonic = null this.hww.mnemonic = null
this.hww.passphrase = null
this.hww.showMnemonic = false this.hww.showMnemonic = false
this.hww.password = null this.hww.password = null
this.hww.confirmedPassword = null this.hww.confirmedPassword = null
@ -594,6 +832,116 @@ async function serialSigner(path) {
updateSignedPsbt: async function (value) { updateSignedPsbt: async function (value) {
this.$emit('signed:psbt', value) this.$emit('signed:psbt', value)
},
sendCommandSecure: async function (command, attrs = []) {
const message = [command].concat(attrs).join(' ')
const iv = window.crypto.getRandomValues(new Uint8Array(16))
const encrypted = await this.encryptMessage(
this.sharedSecret,
iv,
message.length + ' ' + message
)
const encryptedHex = nobleSecp256k1.utils.bytesToHex(encrypted)
const encryptedIvHex = nobleSecp256k1.utils.bytesToHex(iv)
await this.writer.write(encryptedHex + encryptedIvHex + '\n')
},
sendCommandClearText: async function (command, attrs = []) {
const message = [command].concat(attrs).join(' ')
await this.writer.write(message + '\n')
},
extractCommand: async function (value) {
const command = value.split(' ')[0]
const commandData = value.substring(command.length).trim()
if (
command === COMMAND_PAIR ||
command === COMMAND_LOG ||
command === COMMAND_PASSWORD_CLEAR ||
command === COMMAND_PING ||
command === COMMAND_CHECK_PAIRING
)
return {command, commandData}
const decryptedValue = await this.decryptData(value)
const decryptedCommand = decryptedValue.split(' ')[0]
const decryptedCommandData = decryptedValue
.substring(decryptedCommand.length)
.trim()
return {
command: decryptedCommand,
commandData: decryptedCommandData
}
},
decryptData: async function (value) {
if (!this.sharedSecret) {
return '/error Secure session not established!'
}
try {
const ivSize = 32
const messageHex = value.substring(0, value.length - ivSize)
const ivHex = value.substring(value.length - ivSize)
const messageBytes = nobleSecp256k1.utils.hexToBytes(messageHex)
const iv = nobleSecp256k1.utils.hexToBytes(ivHex)
const decrypted1 = await this.decryptMessage(
this.sharedSecret,
iv,
messageBytes
)
const data = new TextDecoder().decode(decrypted1)
const [len] = data.split(' ')
const command = data
.substring(len.length + 1, +len + len.length + 1)
.trim()
return command
} catch (error) {
return '/error Failed to decrypt message from device!'
}
},
encryptMessage: async function (key, iv, message) {
while (message.length % 16 !== 0) message += ' '
const encodedMessage = asciiToUint8Array(message)
const aesCbc = new aesjs.ModeOfOperation.cbc(key, iv)
const encryptedBytes = aesCbc.encrypt(encodedMessage)
return encryptedBytes
},
decryptMessage: async function (key, iv, encryptedBytes) {
const aesCbc = new aesjs.ModeOfOperation.cbc(key, iv)
const decryptedBytes = aesCbc.decrypt(encryptedBytes)
return decryptedBytes
},
getPairedDevice: function (deviceId) {
return this.pairedDevices.find(d => d.id === deviceId)
},
removePairedDevice: function (deviceId) {
const devices = this.pairedDevices
const deviceIndex = devices.findIndex(d => d.id === deviceId)
if (deviceIndex !== -1) {
devices.splice(deviceIndex, 1)
}
this.pairedDevices = devices
},
addPairedDevice: function (deviceId, sharedSecretHex, config) {
const devices = this.pairedDevices
config.deviceId = deviceId
devices.unshift({
id: deviceId,
sharedSecretHex: sharedSecretHex,
pairingDate: new Date().toISOString(),
config
})
this.pairedDevices = devices
},
updatePairedDeviceConfig(deviceId, config) {
const device = this.getPairedDevice(deviceId)
if (device) {
this.removePairedDevice(deviceId)
this.addPairedDevice(deviceId, device.sharedSecretHex, config)
}
} }
}, },
created: async function () {} created: async function () {}

View file

@ -1,16 +1,16 @@
<div> <div>
<q-card> <q-card>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-ml-lg"> <div class="col-md-2 col-xs-4 q-ml-lg">
<q-btn unelevated @click="show = true" color="primary" icon="settings"> <q-btn unelevated @click="show = true" color="primary" icon="settings">
</q-btn> </q-btn>
</div> </div>
<div class="col-8"> <div class="col-md-8 col-xs-4">
<div class="row justify-center q-gutter-x-md items-center"> <div class="row justify-center q-gutter-x-md items-center">
<div class="text-h3">{{satBtc(total)}}</div> <div :class="{'text-h4': $q.screen.gt.md}">{{satBtc(total)}}</div>
</div> </div>
</div> </div>
<div class="col-2 float-right"> <div class="col-md-2 col-xs-4 q-pr-lg">
<slot name="serial"></slot> <slot name="serial"></slot>
</div> </div>
</div> </div>

View file

@ -2,7 +2,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col"> <div class="col-4">
<q-btn-dropdown <q-btn-dropdown
split split
unelevated unelevated
@ -30,9 +30,8 @@
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
</div> </div>
<div class="col-4 q-pl-lg"></div>
<div class="col-auto q-pr-lg"></div> <div class="col-4 q-pl-lg">
<div class="col-auto q-pl-lg">
<q-input <q-input
borderless borderless
dense dense

View file

@ -99,6 +99,7 @@ async function walletList(path) {
async network(newNet, oldNet) { async network(newNet, oldNet) {
if (newNet !== oldNet) { if (newNet !== oldNet) {
await this.refreshWalletAccounts() await this.refreshWalletAccounts()
this.handleAddressTypeChanged(this.addressTypeOptions[1])
} }
} }
}, },
@ -255,8 +256,12 @@ async function walletList(path) {
getXpubFromDevice: async function () { getXpubFromDevice: async function () {
try { try {
if (!this.serialSignerRef.isConnected()) { if (!this.serialSignerRef.isConnected()) {
const portOpen = await this.serialSignerRef.openSerialPort() this.$q.notify({
if (!portOpen) return type: 'warning',
message: 'Please connect to a hardware Device first!',
timeout: 10000
})
return
} }
if (!this.serialSignerRef.isAuthenticated()) { if (!this.serialSignerRef.isAuthenticated()) {
await this.serialSignerRef.hwwShowPasswordDialog() await this.serialSignerRef.hwwShowPasswordDialog()

View file

@ -0,0 +1,802 @@
/*! MIT License. Copyright 2015-2018 Richard Moore <me@ricmoo.com>. See LICENSE.txt. */
(function(root) {
"use strict";
function checkInt(value) {
return (parseInt(value) === value);
}
function checkInts(arrayish) {
if (!checkInt(arrayish.length)) { return false; }
for (var i = 0; i < arrayish.length; i++) {
if (!checkInt(arrayish[i]) || arrayish[i] < 0 || arrayish[i] > 255) {
return false;
}
}
return true;
}
function coerceArray(arg, copy) {
// ArrayBuffer view
if (arg.buffer && arg.name === 'Uint8Array') {
if (copy) {
if (arg.slice) {
arg = arg.slice();
} else {
arg = Array.prototype.slice.call(arg);
}
}
return arg;
}
// It's an array; check it is a valid representation of a byte
if (Array.isArray(arg)) {
if (!checkInts(arg)) {
throw new Error('Array contains invalid value: ' + arg);
}
return new Uint8Array(arg);
}
// Something else, but behaves like an array (maybe a Buffer? Arguments?)
if (checkInt(arg.length) && checkInts(arg)) {
return new Uint8Array(arg);
}
throw new Error('unsupported array-like object');
}
function createArray(length) {
return new Uint8Array(length);
}
function copyArray(sourceArray, targetArray, targetStart, sourceStart, sourceEnd) {
if (sourceStart != null || sourceEnd != null) {
if (sourceArray.slice) {
sourceArray = sourceArray.slice(sourceStart, sourceEnd);
} else {
sourceArray = Array.prototype.slice.call(sourceArray, sourceStart, sourceEnd);
}
}
targetArray.set(sourceArray, targetStart);
}
var convertUtf8 = (function() {
function toBytes(text) {
var result = [], i = 0;
text = encodeURI(text);
while (i < text.length) {
var c = text.charCodeAt(i++);
// if it is a % sign, encode the following 2 bytes as a hex value
if (c === 37) {
result.push(parseInt(text.substr(i, 2), 16))
i += 2;
// otherwise, just the actual byte
} else {
result.push(c)
}
}
return coerceArray(result);
}
function fromBytes(bytes) {
var result = [], i = 0;
while (i < bytes.length) {
var c = bytes[i];
if (c < 128) {
result.push(String.fromCharCode(c));
i++;
} else if (c > 191 && c < 224) {
result.push(String.fromCharCode(((c & 0x1f) << 6) | (bytes[i + 1] & 0x3f)));
i += 2;
} else {
result.push(String.fromCharCode(((c & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f)));
i += 3;
}
}
return result.join('');
}
return {
toBytes: toBytes,
fromBytes: fromBytes,
}
})();
var convertHex = (function() {
function toBytes(text) {
var result = [];
for (var i = 0; i < text.length; i += 2) {
result.push(parseInt(text.substr(i, 2), 16));
}
return result;
}
// http://ixti.net/development/javascript/2011/11/11/base64-encodedecode-of-utf8-in-browser-with-js.html
var Hex = '0123456789abcdef';
function fromBytes(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
result.push(Hex[(v & 0xf0) >> 4] + Hex[v & 0x0f]);
}
return result.join('');
}
return {
toBytes: toBytes,
fromBytes: fromBytes,
}
})();
// Number of rounds by keysize
var numberOfRounds = {16: 10, 24: 12, 32: 14}
// Round constant words
var rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91];
// S-box and Inverse S-box (S is for Substitution)
var S = [0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16];
var Si =[0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d];
// Transformations for encryption
var T1 = [0xc66363a5, 0xf87c7c84, 0xee777799, 0xf67b7b8d, 0xfff2f20d, 0xd66b6bbd, 0xde6f6fb1, 0x91c5c554, 0x60303050, 0x02010103, 0xce6767a9, 0x562b2b7d, 0xe7fefe19, 0xb5d7d762, 0x4dababe6, 0xec76769a, 0x8fcaca45, 0x1f82829d, 0x89c9c940, 0xfa7d7d87, 0xeffafa15, 0xb25959eb, 0x8e4747c9, 0xfbf0f00b, 0x41adadec, 0xb3d4d467, 0x5fa2a2fd, 0x45afafea, 0x239c9cbf, 0x53a4a4f7, 0xe4727296, 0x9bc0c05b, 0x75b7b7c2, 0xe1fdfd1c, 0x3d9393ae, 0x4c26266a, 0x6c36365a, 0x7e3f3f41, 0xf5f7f702, 0x83cccc4f, 0x6834345c, 0x51a5a5f4, 0xd1e5e534, 0xf9f1f108, 0xe2717193, 0xabd8d873, 0x62313153, 0x2a15153f, 0x0804040c, 0x95c7c752, 0x46232365, 0x9dc3c35e, 0x30181828, 0x379696a1, 0x0a05050f, 0x2f9a9ab5, 0x0e070709, 0x24121236, 0x1b80809b, 0xdfe2e23d, 0xcdebeb26, 0x4e272769, 0x7fb2b2cd, 0xea75759f, 0x1209091b, 0x1d83839e, 0x582c2c74, 0x341a1a2e, 0x361b1b2d, 0xdc6e6eb2, 0xb45a5aee, 0x5ba0a0fb, 0xa45252f6, 0x763b3b4d, 0xb7d6d661, 0x7db3b3ce, 0x5229297b, 0xdde3e33e, 0x5e2f2f71, 0x13848497, 0xa65353f5, 0xb9d1d168, 0x00000000, 0xc1eded2c, 0x40202060, 0xe3fcfc1f, 0x79b1b1c8, 0xb65b5bed, 0xd46a6abe, 0x8dcbcb46, 0x67bebed9, 0x7239394b, 0x944a4ade, 0x984c4cd4, 0xb05858e8, 0x85cfcf4a, 0xbbd0d06b, 0xc5efef2a, 0x4faaaae5, 0xedfbfb16, 0x864343c5, 0x9a4d4dd7, 0x66333355, 0x11858594, 0x8a4545cf, 0xe9f9f910, 0x04020206, 0xfe7f7f81, 0xa05050f0, 0x783c3c44, 0x259f9fba, 0x4ba8a8e3, 0xa25151f3, 0x5da3a3fe, 0x804040c0, 0x058f8f8a, 0x3f9292ad, 0x219d9dbc, 0x70383848, 0xf1f5f504, 0x63bcbcdf, 0x77b6b6c1, 0xafdada75, 0x42212163, 0x20101030, 0xe5ffff1a, 0xfdf3f30e, 0xbfd2d26d, 0x81cdcd4c, 0x180c0c14, 0x26131335, 0xc3ecec2f, 0xbe5f5fe1, 0x359797a2, 0x884444cc, 0x2e171739, 0x93c4c457, 0x55a7a7f2, 0xfc7e7e82, 0x7a3d3d47, 0xc86464ac, 0xba5d5de7, 0x3219192b, 0xe6737395, 0xc06060a0, 0x19818198, 0x9e4f4fd1, 0xa3dcdc7f, 0x44222266, 0x542a2a7e, 0x3b9090ab, 0x0b888883, 0x8c4646ca, 0xc7eeee29, 0x6bb8b8d3, 0x2814143c, 0xa7dede79, 0xbc5e5ee2, 0x160b0b1d, 0xaddbdb76, 0xdbe0e03b, 0x64323256, 0x743a3a4e, 0x140a0a1e, 0x924949db, 0x0c06060a, 0x4824246c, 0xb85c5ce4, 0x9fc2c25d, 0xbdd3d36e, 0x43acacef, 0xc46262a6, 0x399191a8, 0x319595a4, 0xd3e4e437, 0xf279798b, 0xd5e7e732, 0x8bc8c843, 0x6e373759, 0xda6d6db7, 0x018d8d8c, 0xb1d5d564, 0x9c4e4ed2, 0x49a9a9e0, 0xd86c6cb4, 0xac5656fa, 0xf3f4f407, 0xcfeaea25, 0xca6565af, 0xf47a7a8e, 0x47aeaee9, 0x10080818, 0x6fbabad5, 0xf0787888, 0x4a25256f, 0x5c2e2e72, 0x381c1c24, 0x57a6a6f1, 0x73b4b4c7, 0x97c6c651, 0xcbe8e823, 0xa1dddd7c, 0xe874749c, 0x3e1f1f21, 0x964b4bdd, 0x61bdbddc, 0x0d8b8b86, 0x0f8a8a85, 0xe0707090, 0x7c3e3e42, 0x71b5b5c4, 0xcc6666aa, 0x904848d8, 0x06030305, 0xf7f6f601, 0x1c0e0e12, 0xc26161a3, 0x6a35355f, 0xae5757f9, 0x69b9b9d0, 0x17868691, 0x99c1c158, 0x3a1d1d27, 0x279e9eb9, 0xd9e1e138, 0xebf8f813, 0x2b9898b3, 0x22111133, 0xd26969bb, 0xa9d9d970, 0x078e8e89, 0x339494a7, 0x2d9b9bb6, 0x3c1e1e22, 0x15878792, 0xc9e9e920, 0x87cece49, 0xaa5555ff, 0x50282878, 0xa5dfdf7a, 0x038c8c8f, 0x59a1a1f8, 0x09898980, 0x1a0d0d17, 0x65bfbfda, 0xd7e6e631, 0x844242c6, 0xd06868b8, 0x824141c3, 0x299999b0, 0x5a2d2d77, 0x1e0f0f11, 0x7bb0b0cb, 0xa85454fc, 0x6dbbbbd6, 0x2c16163a];
var T2 = [0xa5c66363, 0x84f87c7c, 0x99ee7777, 0x8df67b7b, 0x0dfff2f2, 0xbdd66b6b, 0xb1de6f6f, 0x5491c5c5, 0x50603030, 0x03020101, 0xa9ce6767, 0x7d562b2b, 0x19e7fefe, 0x62b5d7d7, 0xe64dabab, 0x9aec7676, 0x458fcaca, 0x9d1f8282, 0x4089c9c9, 0x87fa7d7d, 0x15effafa, 0xebb25959, 0xc98e4747, 0x0bfbf0f0, 0xec41adad, 0x67b3d4d4, 0xfd5fa2a2, 0xea45afaf, 0xbf239c9c, 0xf753a4a4, 0x96e47272, 0x5b9bc0c0, 0xc275b7b7, 0x1ce1fdfd, 0xae3d9393, 0x6a4c2626, 0x5a6c3636, 0x417e3f3f, 0x02f5f7f7, 0x4f83cccc, 0x5c683434, 0xf451a5a5, 0x34d1e5e5, 0x08f9f1f1, 0x93e27171, 0x73abd8d8, 0x53623131, 0x3f2a1515, 0x0c080404, 0x5295c7c7, 0x65462323, 0x5e9dc3c3, 0x28301818, 0xa1379696, 0x0f0a0505, 0xb52f9a9a, 0x090e0707, 0x36241212, 0x9b1b8080, 0x3ddfe2e2, 0x26cdebeb, 0x694e2727, 0xcd7fb2b2, 0x9fea7575, 0x1b120909, 0x9e1d8383, 0x74582c2c, 0x2e341a1a, 0x2d361b1b, 0xb2dc6e6e, 0xeeb45a5a, 0xfb5ba0a0, 0xf6a45252, 0x4d763b3b, 0x61b7d6d6, 0xce7db3b3, 0x7b522929, 0x3edde3e3, 0x715e2f2f, 0x97138484, 0xf5a65353, 0x68b9d1d1, 0x00000000, 0x2cc1eded, 0x60402020, 0x1fe3fcfc, 0xc879b1b1, 0xedb65b5b, 0xbed46a6a, 0x468dcbcb, 0xd967bebe, 0x4b723939, 0xde944a4a, 0xd4984c4c, 0xe8b05858, 0x4a85cfcf, 0x6bbbd0d0, 0x2ac5efef, 0xe54faaaa, 0x16edfbfb, 0xc5864343, 0xd79a4d4d, 0x55663333, 0x94118585, 0xcf8a4545, 0x10e9f9f9, 0x06040202, 0x81fe7f7f, 0xf0a05050, 0x44783c3c, 0xba259f9f, 0xe34ba8a8, 0xf3a25151, 0xfe5da3a3, 0xc0804040, 0x8a058f8f, 0xad3f9292, 0xbc219d9d, 0x48703838, 0x04f1f5f5, 0xdf63bcbc, 0xc177b6b6, 0x75afdada, 0x63422121, 0x30201010, 0x1ae5ffff, 0x0efdf3f3, 0x6dbfd2d2, 0x4c81cdcd, 0x14180c0c, 0x35261313, 0x2fc3ecec, 0xe1be5f5f, 0xa2359797, 0xcc884444, 0x392e1717, 0x5793c4c4, 0xf255a7a7, 0x82fc7e7e, 0x477a3d3d, 0xacc86464, 0xe7ba5d5d, 0x2b321919, 0x95e67373, 0xa0c06060, 0x98198181, 0xd19e4f4f, 0x7fa3dcdc, 0x66442222, 0x7e542a2a, 0xab3b9090, 0x830b8888, 0xca8c4646, 0x29c7eeee, 0xd36bb8b8, 0x3c281414, 0x79a7dede, 0xe2bc5e5e, 0x1d160b0b, 0x76addbdb, 0x3bdbe0e0, 0x56643232, 0x4e743a3a, 0x1e140a0a, 0xdb924949, 0x0a0c0606, 0x6c482424, 0xe4b85c5c, 0x5d9fc2c2, 0x6ebdd3d3, 0xef43acac, 0xa6c46262, 0xa8399191, 0xa4319595, 0x37d3e4e4, 0x8bf27979, 0x32d5e7e7, 0x438bc8c8, 0x596e3737, 0xb7da6d6d, 0x8c018d8d, 0x64b1d5d5, 0xd29c4e4e, 0xe049a9a9, 0xb4d86c6c, 0xfaac5656, 0x07f3f4f4, 0x25cfeaea, 0xafca6565, 0x8ef47a7a, 0xe947aeae, 0x18100808, 0xd56fbaba, 0x88f07878, 0x6f4a2525, 0x725c2e2e, 0x24381c1c, 0xf157a6a6, 0xc773b4b4, 0x5197c6c6, 0x23cbe8e8, 0x7ca1dddd, 0x9ce87474, 0x213e1f1f, 0xdd964b4b, 0xdc61bdbd, 0x860d8b8b, 0x850f8a8a, 0x90e07070, 0x427c3e3e, 0xc471b5b5, 0xaacc6666, 0xd8904848, 0x05060303, 0x01f7f6f6, 0x121c0e0e, 0xa3c26161, 0x5f6a3535, 0xf9ae5757, 0xd069b9b9, 0x91178686, 0x5899c1c1, 0x273a1d1d, 0xb9279e9e, 0x38d9e1e1, 0x13ebf8f8, 0xb32b9898, 0x33221111, 0xbbd26969, 0x70a9d9d9, 0x89078e8e, 0xa7339494, 0xb62d9b9b, 0x223c1e1e, 0x92158787, 0x20c9e9e9, 0x4987cece, 0xffaa5555, 0x78502828, 0x7aa5dfdf, 0x8f038c8c, 0xf859a1a1, 0x80098989, 0x171a0d0d, 0xda65bfbf, 0x31d7e6e6, 0xc6844242, 0xb8d06868, 0xc3824141, 0xb0299999, 0x775a2d2d, 0x111e0f0f, 0xcb7bb0b0, 0xfca85454, 0xd66dbbbb, 0x3a2c1616];
var T3 = [0x63a5c663, 0x7c84f87c, 0x7799ee77, 0x7b8df67b, 0xf20dfff2, 0x6bbdd66b, 0x6fb1de6f, 0xc55491c5, 0x30506030, 0x01030201, 0x67a9ce67, 0x2b7d562b, 0xfe19e7fe, 0xd762b5d7, 0xabe64dab, 0x769aec76, 0xca458fca, 0x829d1f82, 0xc94089c9, 0x7d87fa7d, 0xfa15effa, 0x59ebb259, 0x47c98e47, 0xf00bfbf0, 0xadec41ad, 0xd467b3d4, 0xa2fd5fa2, 0xafea45af, 0x9cbf239c, 0xa4f753a4, 0x7296e472, 0xc05b9bc0, 0xb7c275b7, 0xfd1ce1fd, 0x93ae3d93, 0x266a4c26, 0x365a6c36, 0x3f417e3f, 0xf702f5f7, 0xcc4f83cc, 0x345c6834, 0xa5f451a5, 0xe534d1e5, 0xf108f9f1, 0x7193e271, 0xd873abd8, 0x31536231, 0x153f2a15, 0x040c0804, 0xc75295c7, 0x23654623, 0xc35e9dc3, 0x18283018, 0x96a13796, 0x050f0a05, 0x9ab52f9a, 0x07090e07, 0x12362412, 0x809b1b80, 0xe23ddfe2, 0xeb26cdeb, 0x27694e27, 0xb2cd7fb2, 0x759fea75, 0x091b1209, 0x839e1d83, 0x2c74582c, 0x1a2e341a, 0x1b2d361b, 0x6eb2dc6e, 0x5aeeb45a, 0xa0fb5ba0, 0x52f6a452, 0x3b4d763b, 0xd661b7d6, 0xb3ce7db3, 0x297b5229, 0xe33edde3, 0x2f715e2f, 0x84971384, 0x53f5a653, 0xd168b9d1, 0x00000000, 0xed2cc1ed, 0x20604020, 0xfc1fe3fc, 0xb1c879b1, 0x5bedb65b, 0x6abed46a, 0xcb468dcb, 0xbed967be, 0x394b7239, 0x4ade944a, 0x4cd4984c, 0x58e8b058, 0xcf4a85cf, 0xd06bbbd0, 0xef2ac5ef, 0xaae54faa, 0xfb16edfb, 0x43c58643, 0x4dd79a4d, 0x33556633, 0x85941185, 0x45cf8a45, 0xf910e9f9, 0x02060402, 0x7f81fe7f, 0x50f0a050, 0x3c44783c, 0x9fba259f, 0xa8e34ba8, 0x51f3a251, 0xa3fe5da3, 0x40c08040, 0x8f8a058f, 0x92ad3f92, 0x9dbc219d, 0x38487038, 0xf504f1f5, 0xbcdf63bc, 0xb6c177b6, 0xda75afda, 0x21634221, 0x10302010, 0xff1ae5ff, 0xf30efdf3, 0xd26dbfd2, 0xcd4c81cd, 0x0c14180c, 0x13352613, 0xec2fc3ec, 0x5fe1be5f, 0x97a23597, 0x44cc8844, 0x17392e17, 0xc45793c4, 0xa7f255a7, 0x7e82fc7e, 0x3d477a3d, 0x64acc864, 0x5de7ba5d, 0x192b3219, 0x7395e673, 0x60a0c060, 0x81981981, 0x4fd19e4f, 0xdc7fa3dc, 0x22664422, 0x2a7e542a, 0x90ab3b90, 0x88830b88, 0x46ca8c46, 0xee29c7ee, 0xb8d36bb8, 0x143c2814, 0xde79a7de, 0x5ee2bc5e, 0x0b1d160b, 0xdb76addb, 0xe03bdbe0, 0x32566432, 0x3a4e743a, 0x0a1e140a, 0x49db9249, 0x060a0c06, 0x246c4824, 0x5ce4b85c, 0xc25d9fc2, 0xd36ebdd3, 0xacef43ac, 0x62a6c462, 0x91a83991, 0x95a43195, 0xe437d3e4, 0x798bf279, 0xe732d5e7, 0xc8438bc8, 0x37596e37, 0x6db7da6d, 0x8d8c018d, 0xd564b1d5, 0x4ed29c4e, 0xa9e049a9, 0x6cb4d86c, 0x56faac56, 0xf407f3f4, 0xea25cfea, 0x65afca65, 0x7a8ef47a, 0xaee947ae, 0x08181008, 0xbad56fba, 0x7888f078, 0x256f4a25, 0x2e725c2e, 0x1c24381c, 0xa6f157a6, 0xb4c773b4, 0xc65197c6, 0xe823cbe8, 0xdd7ca1dd, 0x749ce874, 0x1f213e1f, 0x4bdd964b, 0xbddc61bd, 0x8b860d8b, 0x8a850f8a, 0x7090e070, 0x3e427c3e, 0xb5c471b5, 0x66aacc66, 0x48d89048, 0x03050603, 0xf601f7f6, 0x0e121c0e, 0x61a3c261, 0x355f6a35, 0x57f9ae57, 0xb9d069b9, 0x86911786, 0xc15899c1, 0x1d273a1d, 0x9eb9279e, 0xe138d9e1, 0xf813ebf8, 0x98b32b98, 0x11332211, 0x69bbd269, 0xd970a9d9, 0x8e89078e, 0x94a73394, 0x9bb62d9b, 0x1e223c1e, 0x87921587, 0xe920c9e9, 0xce4987ce, 0x55ffaa55, 0x28785028, 0xdf7aa5df, 0x8c8f038c, 0xa1f859a1, 0x89800989, 0x0d171a0d, 0xbfda65bf, 0xe631d7e6, 0x42c68442, 0x68b8d068, 0x41c38241, 0x99b02999, 0x2d775a2d, 0x0f111e0f, 0xb0cb7bb0, 0x54fca854, 0xbbd66dbb, 0x163a2c16];
var T4 = [0x6363a5c6, 0x7c7c84f8, 0x777799ee, 0x7b7b8df6, 0xf2f20dff, 0x6b6bbdd6, 0x6f6fb1de, 0xc5c55491, 0x30305060, 0x01010302, 0x6767a9ce, 0x2b2b7d56, 0xfefe19e7, 0xd7d762b5, 0xababe64d, 0x76769aec, 0xcaca458f, 0x82829d1f, 0xc9c94089, 0x7d7d87fa, 0xfafa15ef, 0x5959ebb2, 0x4747c98e, 0xf0f00bfb, 0xadadec41, 0xd4d467b3, 0xa2a2fd5f, 0xafafea45, 0x9c9cbf23, 0xa4a4f753, 0x727296e4, 0xc0c05b9b, 0xb7b7c275, 0xfdfd1ce1, 0x9393ae3d, 0x26266a4c, 0x36365a6c, 0x3f3f417e, 0xf7f702f5, 0xcccc4f83, 0x34345c68, 0xa5a5f451, 0xe5e534d1, 0xf1f108f9, 0x717193e2, 0xd8d873ab, 0x31315362, 0x15153f2a, 0x04040c08, 0xc7c75295, 0x23236546, 0xc3c35e9d, 0x18182830, 0x9696a137, 0x05050f0a, 0x9a9ab52f, 0x0707090e, 0x12123624, 0x80809b1b, 0xe2e23ddf, 0xebeb26cd, 0x2727694e, 0xb2b2cd7f, 0x75759fea, 0x09091b12, 0x83839e1d, 0x2c2c7458, 0x1a1a2e34, 0x1b1b2d36, 0x6e6eb2dc, 0x5a5aeeb4, 0xa0a0fb5b, 0x5252f6a4, 0x3b3b4d76, 0xd6d661b7, 0xb3b3ce7d, 0x29297b52, 0xe3e33edd, 0x2f2f715e, 0x84849713, 0x5353f5a6, 0xd1d168b9, 0x00000000, 0xeded2cc1, 0x20206040, 0xfcfc1fe3, 0xb1b1c879, 0x5b5bedb6, 0x6a6abed4, 0xcbcb468d, 0xbebed967, 0x39394b72, 0x4a4ade94, 0x4c4cd498, 0x5858e8b0, 0xcfcf4a85, 0xd0d06bbb, 0xefef2ac5, 0xaaaae54f, 0xfbfb16ed, 0x4343c586, 0x4d4dd79a, 0x33335566, 0x85859411, 0x4545cf8a, 0xf9f910e9, 0x02020604, 0x7f7f81fe, 0x5050f0a0, 0x3c3c4478, 0x9f9fba25, 0xa8a8e34b, 0x5151f3a2, 0xa3a3fe5d, 0x4040c080, 0x8f8f8a05, 0x9292ad3f, 0x9d9dbc21, 0x38384870, 0xf5f504f1, 0xbcbcdf63, 0xb6b6c177, 0xdada75af, 0x21216342, 0x10103020, 0xffff1ae5, 0xf3f30efd, 0xd2d26dbf, 0xcdcd4c81, 0x0c0c1418, 0x13133526, 0xecec2fc3, 0x5f5fe1be, 0x9797a235, 0x4444cc88, 0x1717392e, 0xc4c45793, 0xa7a7f255, 0x7e7e82fc, 0x3d3d477a, 0x6464acc8, 0x5d5de7ba, 0x19192b32, 0x737395e6, 0x6060a0c0, 0x81819819, 0x4f4fd19e, 0xdcdc7fa3, 0x22226644, 0x2a2a7e54, 0x9090ab3b, 0x8888830b, 0x4646ca8c, 0xeeee29c7, 0xb8b8d36b, 0x14143c28, 0xdede79a7, 0x5e5ee2bc, 0x0b0b1d16, 0xdbdb76ad, 0xe0e03bdb, 0x32325664, 0x3a3a4e74, 0x0a0a1e14, 0x4949db92, 0x06060a0c, 0x24246c48, 0x5c5ce4b8, 0xc2c25d9f, 0xd3d36ebd, 0xacacef43, 0x6262a6c4, 0x9191a839, 0x9595a431, 0xe4e437d3, 0x79798bf2, 0xe7e732d5, 0xc8c8438b, 0x3737596e, 0x6d6db7da, 0x8d8d8c01, 0xd5d564b1, 0x4e4ed29c, 0xa9a9e049, 0x6c6cb4d8, 0x5656faac, 0xf4f407f3, 0xeaea25cf, 0x6565afca, 0x7a7a8ef4, 0xaeaee947, 0x08081810, 0xbabad56f, 0x787888f0, 0x25256f4a, 0x2e2e725c, 0x1c1c2438, 0xa6a6f157, 0xb4b4c773, 0xc6c65197, 0xe8e823cb, 0xdddd7ca1, 0x74749ce8, 0x1f1f213e, 0x4b4bdd96, 0xbdbddc61, 0x8b8b860d, 0x8a8a850f, 0x707090e0, 0x3e3e427c, 0xb5b5c471, 0x6666aacc, 0x4848d890, 0x03030506, 0xf6f601f7, 0x0e0e121c, 0x6161a3c2, 0x35355f6a, 0x5757f9ae, 0xb9b9d069, 0x86869117, 0xc1c15899, 0x1d1d273a, 0x9e9eb927, 0xe1e138d9, 0xf8f813eb, 0x9898b32b, 0x11113322, 0x6969bbd2, 0xd9d970a9, 0x8e8e8907, 0x9494a733, 0x9b9bb62d, 0x1e1e223c, 0x87879215, 0xe9e920c9, 0xcece4987, 0x5555ffaa, 0x28287850, 0xdfdf7aa5, 0x8c8c8f03, 0xa1a1f859, 0x89898009, 0x0d0d171a, 0xbfbfda65, 0xe6e631d7, 0x4242c684, 0x6868b8d0, 0x4141c382, 0x9999b029, 0x2d2d775a, 0x0f0f111e, 0xb0b0cb7b, 0x5454fca8, 0xbbbbd66d, 0x16163a2c];
// Transformations for decryption
var T5 = [0x51f4a750, 0x7e416553, 0x1a17a4c3, 0x3a275e96, 0x3bab6bcb, 0x1f9d45f1, 0xacfa58ab, 0x4be30393, 0x2030fa55, 0xad766df6, 0x88cc7691, 0xf5024c25, 0x4fe5d7fc, 0xc52acbd7, 0x26354480, 0xb562a38f, 0xdeb15a49, 0x25ba1b67, 0x45ea0e98, 0x5dfec0e1, 0xc32f7502, 0x814cf012, 0x8d4697a3, 0x6bd3f9c6, 0x038f5fe7, 0x15929c95, 0xbf6d7aeb, 0x955259da, 0xd4be832d, 0x587421d3, 0x49e06929, 0x8ec9c844, 0x75c2896a, 0xf48e7978, 0x99583e6b, 0x27b971dd, 0xbee14fb6, 0xf088ad17, 0xc920ac66, 0x7dce3ab4, 0x63df4a18, 0xe51a3182, 0x97513360, 0x62537f45, 0xb16477e0, 0xbb6bae84, 0xfe81a01c, 0xf9082b94, 0x70486858, 0x8f45fd19, 0x94de6c87, 0x527bf8b7, 0xab73d323, 0x724b02e2, 0xe31f8f57, 0x6655ab2a, 0xb2eb2807, 0x2fb5c203, 0x86c57b9a, 0xd33708a5, 0x302887f2, 0x23bfa5b2, 0x02036aba, 0xed16825c, 0x8acf1c2b, 0xa779b492, 0xf307f2f0, 0x4e69e2a1, 0x65daf4cd, 0x0605bed5, 0xd134621f, 0xc4a6fe8a, 0x342e539d, 0xa2f355a0, 0x058ae132, 0xa4f6eb75, 0x0b83ec39, 0x4060efaa, 0x5e719f06, 0xbd6e1051, 0x3e218af9, 0x96dd063d, 0xdd3e05ae, 0x4de6bd46, 0x91548db5, 0x71c45d05, 0x0406d46f, 0x605015ff, 0x1998fb24, 0xd6bde997, 0x894043cc, 0x67d99e77, 0xb0e842bd, 0x07898b88, 0xe7195b38, 0x79c8eedb, 0xa17c0a47, 0x7c420fe9, 0xf8841ec9, 0x00000000, 0x09808683, 0x322bed48, 0x1e1170ac, 0x6c5a724e, 0xfd0efffb, 0x0f853856, 0x3daed51e, 0x362d3927, 0x0a0fd964, 0x685ca621, 0x9b5b54d1, 0x24362e3a, 0x0c0a67b1, 0x9357e70f, 0xb4ee96d2, 0x1b9b919e, 0x80c0c54f, 0x61dc20a2, 0x5a774b69, 0x1c121a16, 0xe293ba0a, 0xc0a02ae5, 0x3c22e043, 0x121b171d, 0x0e090d0b, 0xf28bc7ad, 0x2db6a8b9, 0x141ea9c8, 0x57f11985, 0xaf75074c, 0xee99ddbb, 0xa37f60fd, 0xf701269f, 0x5c72f5bc, 0x44663bc5, 0x5bfb7e34, 0x8b432976, 0xcb23c6dc, 0xb6edfc68, 0xb8e4f163, 0xd731dcca, 0x42638510, 0x13972240, 0x84c61120, 0x854a247d, 0xd2bb3df8, 0xaef93211, 0xc729a16d, 0x1d9e2f4b, 0xdcb230f3, 0x0d8652ec, 0x77c1e3d0, 0x2bb3166c, 0xa970b999, 0x119448fa, 0x47e96422, 0xa8fc8cc4, 0xa0f03f1a, 0x567d2cd8, 0x223390ef, 0x87494ec7, 0xd938d1c1, 0x8ccaa2fe, 0x98d40b36, 0xa6f581cf, 0xa57ade28, 0xdab78e26, 0x3fadbfa4, 0x2c3a9de4, 0x5078920d, 0x6a5fcc9b, 0x547e4662, 0xf68d13c2, 0x90d8b8e8, 0x2e39f75e, 0x82c3aff5, 0x9f5d80be, 0x69d0937c, 0x6fd52da9, 0xcf2512b3, 0xc8ac993b, 0x10187da7, 0xe89c636e, 0xdb3bbb7b, 0xcd267809, 0x6e5918f4, 0xec9ab701, 0x834f9aa8, 0xe6956e65, 0xaaffe67e, 0x21bccf08, 0xef15e8e6, 0xbae79bd9, 0x4a6f36ce, 0xea9f09d4, 0x29b07cd6, 0x31a4b2af, 0x2a3f2331, 0xc6a59430, 0x35a266c0, 0x744ebc37, 0xfc82caa6, 0xe090d0b0, 0x33a7d815, 0xf104984a, 0x41ecdaf7, 0x7fcd500e, 0x1791f62f, 0x764dd68d, 0x43efb04d, 0xccaa4d54, 0xe49604df, 0x9ed1b5e3, 0x4c6a881b, 0xc12c1fb8, 0x4665517f, 0x9d5eea04, 0x018c355d, 0xfa877473, 0xfb0b412e, 0xb3671d5a, 0x92dbd252, 0xe9105633, 0x6dd64713, 0x9ad7618c, 0x37a10c7a, 0x59f8148e, 0xeb133c89, 0xcea927ee, 0xb761c935, 0xe11ce5ed, 0x7a47b13c, 0x9cd2df59, 0x55f2733f, 0x1814ce79, 0x73c737bf, 0x53f7cdea, 0x5ffdaa5b, 0xdf3d6f14, 0x7844db86, 0xcaaff381, 0xb968c43e, 0x3824342c, 0xc2a3405f, 0x161dc372, 0xbce2250c, 0x283c498b, 0xff0d9541, 0x39a80171, 0x080cb3de, 0xd8b4e49c, 0x6456c190, 0x7bcb8461, 0xd532b670, 0x486c5c74, 0xd0b85742];
var T6 = [0x5051f4a7, 0x537e4165, 0xc31a17a4, 0x963a275e, 0xcb3bab6b, 0xf11f9d45, 0xabacfa58, 0x934be303, 0x552030fa, 0xf6ad766d, 0x9188cc76, 0x25f5024c, 0xfc4fe5d7, 0xd7c52acb, 0x80263544, 0x8fb562a3, 0x49deb15a, 0x6725ba1b, 0x9845ea0e, 0xe15dfec0, 0x02c32f75, 0x12814cf0, 0xa38d4697, 0xc66bd3f9, 0xe7038f5f, 0x9515929c, 0xebbf6d7a, 0xda955259, 0x2dd4be83, 0xd3587421, 0x2949e069, 0x448ec9c8, 0x6a75c289, 0x78f48e79, 0x6b99583e, 0xdd27b971, 0xb6bee14f, 0x17f088ad, 0x66c920ac, 0xb47dce3a, 0x1863df4a, 0x82e51a31, 0x60975133, 0x4562537f, 0xe0b16477, 0x84bb6bae, 0x1cfe81a0, 0x94f9082b, 0x58704868, 0x198f45fd, 0x8794de6c, 0xb7527bf8, 0x23ab73d3, 0xe2724b02, 0x57e31f8f, 0x2a6655ab, 0x07b2eb28, 0x032fb5c2, 0x9a86c57b, 0xa5d33708, 0xf2302887, 0xb223bfa5, 0xba02036a, 0x5ced1682, 0x2b8acf1c, 0x92a779b4, 0xf0f307f2, 0xa14e69e2, 0xcd65daf4, 0xd50605be, 0x1fd13462, 0x8ac4a6fe, 0x9d342e53, 0xa0a2f355, 0x32058ae1, 0x75a4f6eb, 0x390b83ec, 0xaa4060ef, 0x065e719f, 0x51bd6e10, 0xf93e218a, 0x3d96dd06, 0xaedd3e05, 0x464de6bd, 0xb591548d, 0x0571c45d, 0x6f0406d4, 0xff605015, 0x241998fb, 0x97d6bde9, 0xcc894043, 0x7767d99e, 0xbdb0e842, 0x8807898b, 0x38e7195b, 0xdb79c8ee, 0x47a17c0a, 0xe97c420f, 0xc9f8841e, 0x00000000, 0x83098086, 0x48322bed, 0xac1e1170, 0x4e6c5a72, 0xfbfd0eff, 0x560f8538, 0x1e3daed5, 0x27362d39, 0x640a0fd9, 0x21685ca6, 0xd19b5b54, 0x3a24362e, 0xb10c0a67, 0x0f9357e7, 0xd2b4ee96, 0x9e1b9b91, 0x4f80c0c5, 0xa261dc20, 0x695a774b, 0x161c121a, 0x0ae293ba, 0xe5c0a02a, 0x433c22e0, 0x1d121b17, 0x0b0e090d, 0xadf28bc7, 0xb92db6a8, 0xc8141ea9, 0x8557f119, 0x4caf7507, 0xbbee99dd, 0xfda37f60, 0x9ff70126, 0xbc5c72f5, 0xc544663b, 0x345bfb7e, 0x768b4329, 0xdccb23c6, 0x68b6edfc, 0x63b8e4f1, 0xcad731dc, 0x10426385, 0x40139722, 0x2084c611, 0x7d854a24, 0xf8d2bb3d, 0x11aef932, 0x6dc729a1, 0x4b1d9e2f, 0xf3dcb230, 0xec0d8652, 0xd077c1e3, 0x6c2bb316, 0x99a970b9, 0xfa119448, 0x2247e964, 0xc4a8fc8c, 0x1aa0f03f, 0xd8567d2c, 0xef223390, 0xc787494e, 0xc1d938d1, 0xfe8ccaa2, 0x3698d40b, 0xcfa6f581, 0x28a57ade, 0x26dab78e, 0xa43fadbf, 0xe42c3a9d, 0x0d507892, 0x9b6a5fcc, 0x62547e46, 0xc2f68d13, 0xe890d8b8, 0x5e2e39f7, 0xf582c3af, 0xbe9f5d80, 0x7c69d093, 0xa96fd52d, 0xb3cf2512, 0x3bc8ac99, 0xa710187d, 0x6ee89c63, 0x7bdb3bbb, 0x09cd2678, 0xf46e5918, 0x01ec9ab7, 0xa8834f9a, 0x65e6956e, 0x7eaaffe6, 0x0821bccf, 0xe6ef15e8, 0xd9bae79b, 0xce4a6f36, 0xd4ea9f09, 0xd629b07c, 0xaf31a4b2, 0x312a3f23, 0x30c6a594, 0xc035a266, 0x37744ebc, 0xa6fc82ca, 0xb0e090d0, 0x1533a7d8, 0x4af10498, 0xf741ecda, 0x0e7fcd50, 0x2f1791f6, 0x8d764dd6, 0x4d43efb0, 0x54ccaa4d, 0xdfe49604, 0xe39ed1b5, 0x1b4c6a88, 0xb8c12c1f, 0x7f466551, 0x049d5eea, 0x5d018c35, 0x73fa8774, 0x2efb0b41, 0x5ab3671d, 0x5292dbd2, 0x33e91056, 0x136dd647, 0x8c9ad761, 0x7a37a10c, 0x8e59f814, 0x89eb133c, 0xeecea927, 0x35b761c9, 0xede11ce5, 0x3c7a47b1, 0x599cd2df, 0x3f55f273, 0x791814ce, 0xbf73c737, 0xea53f7cd, 0x5b5ffdaa, 0x14df3d6f, 0x867844db, 0x81caaff3, 0x3eb968c4, 0x2c382434, 0x5fc2a340, 0x72161dc3, 0x0cbce225, 0x8b283c49, 0x41ff0d95, 0x7139a801, 0xde080cb3, 0x9cd8b4e4, 0x906456c1, 0x617bcb84, 0x70d532b6, 0x74486c5c, 0x42d0b857];
var T7 = [0xa75051f4, 0x65537e41, 0xa4c31a17, 0x5e963a27, 0x6bcb3bab, 0x45f11f9d, 0x58abacfa, 0x03934be3, 0xfa552030, 0x6df6ad76, 0x769188cc, 0x4c25f502, 0xd7fc4fe5, 0xcbd7c52a, 0x44802635, 0xa38fb562, 0x5a49deb1, 0x1b6725ba, 0x0e9845ea, 0xc0e15dfe, 0x7502c32f, 0xf012814c, 0x97a38d46, 0xf9c66bd3, 0x5fe7038f, 0x9c951592, 0x7aebbf6d, 0x59da9552, 0x832dd4be, 0x21d35874, 0x692949e0, 0xc8448ec9, 0x896a75c2, 0x7978f48e, 0x3e6b9958, 0x71dd27b9, 0x4fb6bee1, 0xad17f088, 0xac66c920, 0x3ab47dce, 0x4a1863df, 0x3182e51a, 0x33609751, 0x7f456253, 0x77e0b164, 0xae84bb6b, 0xa01cfe81, 0x2b94f908, 0x68587048, 0xfd198f45, 0x6c8794de, 0xf8b7527b, 0xd323ab73, 0x02e2724b, 0x8f57e31f, 0xab2a6655, 0x2807b2eb, 0xc2032fb5, 0x7b9a86c5, 0x08a5d337, 0x87f23028, 0xa5b223bf, 0x6aba0203, 0x825ced16, 0x1c2b8acf, 0xb492a779, 0xf2f0f307, 0xe2a14e69, 0xf4cd65da, 0xbed50605, 0x621fd134, 0xfe8ac4a6, 0x539d342e, 0x55a0a2f3, 0xe132058a, 0xeb75a4f6, 0xec390b83, 0xefaa4060, 0x9f065e71, 0x1051bd6e, 0x8af93e21, 0x063d96dd, 0x05aedd3e, 0xbd464de6, 0x8db59154, 0x5d0571c4, 0xd46f0406, 0x15ff6050, 0xfb241998, 0xe997d6bd, 0x43cc8940, 0x9e7767d9, 0x42bdb0e8, 0x8b880789, 0x5b38e719, 0xeedb79c8, 0x0a47a17c, 0x0fe97c42, 0x1ec9f884, 0x00000000, 0x86830980, 0xed48322b, 0x70ac1e11, 0x724e6c5a, 0xfffbfd0e, 0x38560f85, 0xd51e3dae, 0x3927362d, 0xd9640a0f, 0xa621685c, 0x54d19b5b, 0x2e3a2436, 0x67b10c0a, 0xe70f9357, 0x96d2b4ee, 0x919e1b9b, 0xc54f80c0, 0x20a261dc, 0x4b695a77, 0x1a161c12, 0xba0ae293, 0x2ae5c0a0, 0xe0433c22, 0x171d121b, 0x0d0b0e09, 0xc7adf28b, 0xa8b92db6, 0xa9c8141e, 0x198557f1, 0x074caf75, 0xddbbee99, 0x60fda37f, 0x269ff701, 0xf5bc5c72, 0x3bc54466, 0x7e345bfb, 0x29768b43, 0xc6dccb23, 0xfc68b6ed, 0xf163b8e4, 0xdccad731, 0x85104263, 0x22401397, 0x112084c6, 0x247d854a, 0x3df8d2bb, 0x3211aef9, 0xa16dc729, 0x2f4b1d9e, 0x30f3dcb2, 0x52ec0d86, 0xe3d077c1, 0x166c2bb3, 0xb999a970, 0x48fa1194, 0x642247e9, 0x8cc4a8fc, 0x3f1aa0f0, 0x2cd8567d, 0x90ef2233, 0x4ec78749, 0xd1c1d938, 0xa2fe8cca, 0x0b3698d4, 0x81cfa6f5, 0xde28a57a, 0x8e26dab7, 0xbfa43fad, 0x9de42c3a, 0x920d5078, 0xcc9b6a5f, 0x4662547e, 0x13c2f68d, 0xb8e890d8, 0xf75e2e39, 0xaff582c3, 0x80be9f5d, 0x937c69d0, 0x2da96fd5, 0x12b3cf25, 0x993bc8ac, 0x7da71018, 0x636ee89c, 0xbb7bdb3b, 0x7809cd26, 0x18f46e59, 0xb701ec9a, 0x9aa8834f, 0x6e65e695, 0xe67eaaff, 0xcf0821bc, 0xe8e6ef15, 0x9bd9bae7, 0x36ce4a6f, 0x09d4ea9f, 0x7cd629b0, 0xb2af31a4, 0x23312a3f, 0x9430c6a5, 0x66c035a2, 0xbc37744e, 0xcaa6fc82, 0xd0b0e090, 0xd81533a7, 0x984af104, 0xdaf741ec, 0x500e7fcd, 0xf62f1791, 0xd68d764d, 0xb04d43ef, 0x4d54ccaa, 0x04dfe496, 0xb5e39ed1, 0x881b4c6a, 0x1fb8c12c, 0x517f4665, 0xea049d5e, 0x355d018c, 0x7473fa87, 0x412efb0b, 0x1d5ab367, 0xd25292db, 0x5633e910, 0x47136dd6, 0x618c9ad7, 0x0c7a37a1, 0x148e59f8, 0x3c89eb13, 0x27eecea9, 0xc935b761, 0xe5ede11c, 0xb13c7a47, 0xdf599cd2, 0x733f55f2, 0xce791814, 0x37bf73c7, 0xcdea53f7, 0xaa5b5ffd, 0x6f14df3d, 0xdb867844, 0xf381caaf, 0xc43eb968, 0x342c3824, 0x405fc2a3, 0xc372161d, 0x250cbce2, 0x498b283c, 0x9541ff0d, 0x017139a8, 0xb3de080c, 0xe49cd8b4, 0xc1906456, 0x84617bcb, 0xb670d532, 0x5c74486c, 0x5742d0b8];
var T8 = [0xf4a75051, 0x4165537e, 0x17a4c31a, 0x275e963a, 0xab6bcb3b, 0x9d45f11f, 0xfa58abac, 0xe303934b, 0x30fa5520, 0x766df6ad, 0xcc769188, 0x024c25f5, 0xe5d7fc4f, 0x2acbd7c5, 0x35448026, 0x62a38fb5, 0xb15a49de, 0xba1b6725, 0xea0e9845, 0xfec0e15d, 0x2f7502c3, 0x4cf01281, 0x4697a38d, 0xd3f9c66b, 0x8f5fe703, 0x929c9515, 0x6d7aebbf, 0x5259da95, 0xbe832dd4, 0x7421d358, 0xe0692949, 0xc9c8448e, 0xc2896a75, 0x8e7978f4, 0x583e6b99, 0xb971dd27, 0xe14fb6be, 0x88ad17f0, 0x20ac66c9, 0xce3ab47d, 0xdf4a1863, 0x1a3182e5, 0x51336097, 0x537f4562, 0x6477e0b1, 0x6bae84bb, 0x81a01cfe, 0x082b94f9, 0x48685870, 0x45fd198f, 0xde6c8794, 0x7bf8b752, 0x73d323ab, 0x4b02e272, 0x1f8f57e3, 0x55ab2a66, 0xeb2807b2, 0xb5c2032f, 0xc57b9a86, 0x3708a5d3, 0x2887f230, 0xbfa5b223, 0x036aba02, 0x16825ced, 0xcf1c2b8a, 0x79b492a7, 0x07f2f0f3, 0x69e2a14e, 0xdaf4cd65, 0x05bed506, 0x34621fd1, 0xa6fe8ac4, 0x2e539d34, 0xf355a0a2, 0x8ae13205, 0xf6eb75a4, 0x83ec390b, 0x60efaa40, 0x719f065e, 0x6e1051bd, 0x218af93e, 0xdd063d96, 0x3e05aedd, 0xe6bd464d, 0x548db591, 0xc45d0571, 0x06d46f04, 0x5015ff60, 0x98fb2419, 0xbde997d6, 0x4043cc89, 0xd99e7767, 0xe842bdb0, 0x898b8807, 0x195b38e7, 0xc8eedb79, 0x7c0a47a1, 0x420fe97c, 0x841ec9f8, 0x00000000, 0x80868309, 0x2bed4832, 0x1170ac1e, 0x5a724e6c, 0x0efffbfd, 0x8538560f, 0xaed51e3d, 0x2d392736, 0x0fd9640a, 0x5ca62168, 0x5b54d19b, 0x362e3a24, 0x0a67b10c, 0x57e70f93, 0xee96d2b4, 0x9b919e1b, 0xc0c54f80, 0xdc20a261, 0x774b695a, 0x121a161c, 0x93ba0ae2, 0xa02ae5c0, 0x22e0433c, 0x1b171d12, 0x090d0b0e, 0x8bc7adf2, 0xb6a8b92d, 0x1ea9c814, 0xf1198557, 0x75074caf, 0x99ddbbee, 0x7f60fda3, 0x01269ff7, 0x72f5bc5c, 0x663bc544, 0xfb7e345b, 0x4329768b, 0x23c6dccb, 0xedfc68b6, 0xe4f163b8, 0x31dccad7, 0x63851042, 0x97224013, 0xc6112084, 0x4a247d85, 0xbb3df8d2, 0xf93211ae, 0x29a16dc7, 0x9e2f4b1d, 0xb230f3dc, 0x8652ec0d, 0xc1e3d077, 0xb3166c2b, 0x70b999a9, 0x9448fa11, 0xe9642247, 0xfc8cc4a8, 0xf03f1aa0, 0x7d2cd856, 0x3390ef22, 0x494ec787, 0x38d1c1d9, 0xcaa2fe8c, 0xd40b3698, 0xf581cfa6, 0x7ade28a5, 0xb78e26da, 0xadbfa43f, 0x3a9de42c, 0x78920d50, 0x5fcc9b6a, 0x7e466254, 0x8d13c2f6, 0xd8b8e890, 0x39f75e2e, 0xc3aff582, 0x5d80be9f, 0xd0937c69, 0xd52da96f, 0x2512b3cf, 0xac993bc8, 0x187da710, 0x9c636ee8, 0x3bbb7bdb, 0x267809cd, 0x5918f46e, 0x9ab701ec, 0x4f9aa883, 0x956e65e6, 0xffe67eaa, 0xbccf0821, 0x15e8e6ef, 0xe79bd9ba, 0x6f36ce4a, 0x9f09d4ea, 0xb07cd629, 0xa4b2af31, 0x3f23312a, 0xa59430c6, 0xa266c035, 0x4ebc3774, 0x82caa6fc, 0x90d0b0e0, 0xa7d81533, 0x04984af1, 0xecdaf741, 0xcd500e7f, 0x91f62f17, 0x4dd68d76, 0xefb04d43, 0xaa4d54cc, 0x9604dfe4, 0xd1b5e39e, 0x6a881b4c, 0x2c1fb8c1, 0x65517f46, 0x5eea049d, 0x8c355d01, 0x877473fa, 0x0b412efb, 0x671d5ab3, 0xdbd25292, 0x105633e9, 0xd647136d, 0xd7618c9a, 0xa10c7a37, 0xf8148e59, 0x133c89eb, 0xa927eece, 0x61c935b7, 0x1ce5ede1, 0x47b13c7a, 0xd2df599c, 0xf2733f55, 0x14ce7918, 0xc737bf73, 0xf7cdea53, 0xfdaa5b5f, 0x3d6f14df, 0x44db8678, 0xaff381ca, 0x68c43eb9, 0x24342c38, 0xa3405fc2, 0x1dc37216, 0xe2250cbc, 0x3c498b28, 0x0d9541ff, 0xa8017139, 0x0cb3de08, 0xb4e49cd8, 0x56c19064, 0xcb84617b, 0x32b670d5, 0x6c5c7448, 0xb85742d0];
// Transformations for decryption key expansion
var U1 = [0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927, 0x24362e3a, 0x2a3f2331, 0x70486858, 0x7e416553, 0x6c5a724e, 0x62537f45, 0x486c5c74, 0x4665517f, 0x547e4662, 0x5a774b69, 0xe090d0b0, 0xee99ddbb, 0xfc82caa6, 0xf28bc7ad, 0xd8b4e49c, 0xd6bde997, 0xc4a6fe8a, 0xcaaff381, 0x90d8b8e8, 0x9ed1b5e3, 0x8ccaa2fe, 0x82c3aff5, 0xa8fc8cc4, 0xa6f581cf, 0xb4ee96d2, 0xbae79bd9, 0xdb3bbb7b, 0xd532b670, 0xc729a16d, 0xc920ac66, 0xe31f8f57, 0xed16825c, 0xff0d9541, 0xf104984a, 0xab73d323, 0xa57ade28, 0xb761c935, 0xb968c43e, 0x9357e70f, 0x9d5eea04, 0x8f45fd19, 0x814cf012, 0x3bab6bcb, 0x35a266c0, 0x27b971dd, 0x29b07cd6, 0x038f5fe7, 0x0d8652ec, 0x1f9d45f1, 0x119448fa, 0x4be30393, 0x45ea0e98, 0x57f11985, 0x59f8148e, 0x73c737bf, 0x7dce3ab4, 0x6fd52da9, 0x61dc20a2, 0xad766df6, 0xa37f60fd, 0xb16477e0, 0xbf6d7aeb, 0x955259da, 0x9b5b54d1, 0x894043cc, 0x87494ec7, 0xdd3e05ae, 0xd33708a5, 0xc12c1fb8, 0xcf2512b3, 0xe51a3182, 0xeb133c89, 0xf9082b94, 0xf701269f, 0x4de6bd46, 0x43efb04d, 0x51f4a750, 0x5ffdaa5b, 0x75c2896a, 0x7bcb8461, 0x69d0937c, 0x67d99e77, 0x3daed51e, 0x33a7d815, 0x21bccf08, 0x2fb5c203, 0x058ae132, 0x0b83ec39, 0x1998fb24, 0x1791f62f, 0x764dd68d, 0x7844db86, 0x6a5fcc9b, 0x6456c190, 0x4e69e2a1, 0x4060efaa, 0x527bf8b7, 0x5c72f5bc, 0x0605bed5, 0x080cb3de, 0x1a17a4c3, 0x141ea9c8, 0x3e218af9, 0x302887f2, 0x223390ef, 0x2c3a9de4, 0x96dd063d, 0x98d40b36, 0x8acf1c2b, 0x84c61120, 0xaef93211, 0xa0f03f1a, 0xb2eb2807, 0xbce2250c, 0xe6956e65, 0xe89c636e, 0xfa877473, 0xf48e7978, 0xdeb15a49, 0xd0b85742, 0xc2a3405f, 0xccaa4d54, 0x41ecdaf7, 0x4fe5d7fc, 0x5dfec0e1, 0x53f7cdea, 0x79c8eedb, 0x77c1e3d0, 0x65daf4cd, 0x6bd3f9c6, 0x31a4b2af, 0x3fadbfa4, 0x2db6a8b9, 0x23bfa5b2, 0x09808683, 0x07898b88, 0x15929c95, 0x1b9b919e, 0xa17c0a47, 0xaf75074c, 0xbd6e1051, 0xb3671d5a, 0x99583e6b, 0x97513360, 0x854a247d, 0x8b432976, 0xd134621f, 0xdf3d6f14, 0xcd267809, 0xc32f7502, 0xe9105633, 0xe7195b38, 0xf5024c25, 0xfb0b412e, 0x9ad7618c, 0x94de6c87, 0x86c57b9a, 0x88cc7691, 0xa2f355a0, 0xacfa58ab, 0xbee14fb6, 0xb0e842bd, 0xea9f09d4, 0xe49604df, 0xf68d13c2, 0xf8841ec9, 0xd2bb3df8, 0xdcb230f3, 0xcea927ee, 0xc0a02ae5, 0x7a47b13c, 0x744ebc37, 0x6655ab2a, 0x685ca621, 0x42638510, 0x4c6a881b, 0x5e719f06, 0x5078920d, 0x0a0fd964, 0x0406d46f, 0x161dc372, 0x1814ce79, 0x322bed48, 0x3c22e043, 0x2e39f75e, 0x2030fa55, 0xec9ab701, 0xe293ba0a, 0xf088ad17, 0xfe81a01c, 0xd4be832d, 0xdab78e26, 0xc8ac993b, 0xc6a59430, 0x9cd2df59, 0x92dbd252, 0x80c0c54f, 0x8ec9c844, 0xa4f6eb75, 0xaaffe67e, 0xb8e4f163, 0xb6edfc68, 0x0c0a67b1, 0x02036aba, 0x10187da7, 0x1e1170ac, 0x342e539d, 0x3a275e96, 0x283c498b, 0x26354480, 0x7c420fe9, 0x724b02e2, 0x605015ff, 0x6e5918f4, 0x44663bc5, 0x4a6f36ce, 0x587421d3, 0x567d2cd8, 0x37a10c7a, 0x39a80171, 0x2bb3166c, 0x25ba1b67, 0x0f853856, 0x018c355d, 0x13972240, 0x1d9e2f4b, 0x47e96422, 0x49e06929, 0x5bfb7e34, 0x55f2733f, 0x7fcd500e, 0x71c45d05, 0x63df4a18, 0x6dd64713, 0xd731dcca, 0xd938d1c1, 0xcb23c6dc, 0xc52acbd7, 0xef15e8e6, 0xe11ce5ed, 0xf307f2f0, 0xfd0efffb, 0xa779b492, 0xa970b999, 0xbb6bae84, 0xb562a38f, 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3];
var U2 = [0x00000000, 0x0b0e090d, 0x161c121a, 0x1d121b17, 0x2c382434, 0x27362d39, 0x3a24362e, 0x312a3f23, 0x58704868, 0x537e4165, 0x4e6c5a72, 0x4562537f, 0x74486c5c, 0x7f466551, 0x62547e46, 0x695a774b, 0xb0e090d0, 0xbbee99dd, 0xa6fc82ca, 0xadf28bc7, 0x9cd8b4e4, 0x97d6bde9, 0x8ac4a6fe, 0x81caaff3, 0xe890d8b8, 0xe39ed1b5, 0xfe8ccaa2, 0xf582c3af, 0xc4a8fc8c, 0xcfa6f581, 0xd2b4ee96, 0xd9bae79b, 0x7bdb3bbb, 0x70d532b6, 0x6dc729a1, 0x66c920ac, 0x57e31f8f, 0x5ced1682, 0x41ff0d95, 0x4af10498, 0x23ab73d3, 0x28a57ade, 0x35b761c9, 0x3eb968c4, 0x0f9357e7, 0x049d5eea, 0x198f45fd, 0x12814cf0, 0xcb3bab6b, 0xc035a266, 0xdd27b971, 0xd629b07c, 0xe7038f5f, 0xec0d8652, 0xf11f9d45, 0xfa119448, 0x934be303, 0x9845ea0e, 0x8557f119, 0x8e59f814, 0xbf73c737, 0xb47dce3a, 0xa96fd52d, 0xa261dc20, 0xf6ad766d, 0xfda37f60, 0xe0b16477, 0xebbf6d7a, 0xda955259, 0xd19b5b54, 0xcc894043, 0xc787494e, 0xaedd3e05, 0xa5d33708, 0xb8c12c1f, 0xb3cf2512, 0x82e51a31, 0x89eb133c, 0x94f9082b, 0x9ff70126, 0x464de6bd, 0x4d43efb0, 0x5051f4a7, 0x5b5ffdaa, 0x6a75c289, 0x617bcb84, 0x7c69d093, 0x7767d99e, 0x1e3daed5, 0x1533a7d8, 0x0821bccf, 0x032fb5c2, 0x32058ae1, 0x390b83ec, 0x241998fb, 0x2f1791f6, 0x8d764dd6, 0x867844db, 0x9b6a5fcc, 0x906456c1, 0xa14e69e2, 0xaa4060ef, 0xb7527bf8, 0xbc5c72f5, 0xd50605be, 0xde080cb3, 0xc31a17a4, 0xc8141ea9, 0xf93e218a, 0xf2302887, 0xef223390, 0xe42c3a9d, 0x3d96dd06, 0x3698d40b, 0x2b8acf1c, 0x2084c611, 0x11aef932, 0x1aa0f03f, 0x07b2eb28, 0x0cbce225, 0x65e6956e, 0x6ee89c63, 0x73fa8774, 0x78f48e79, 0x49deb15a, 0x42d0b857, 0x5fc2a340, 0x54ccaa4d, 0xf741ecda, 0xfc4fe5d7, 0xe15dfec0, 0xea53f7cd, 0xdb79c8ee, 0xd077c1e3, 0xcd65daf4, 0xc66bd3f9, 0xaf31a4b2, 0xa43fadbf, 0xb92db6a8, 0xb223bfa5, 0x83098086, 0x8807898b, 0x9515929c, 0x9e1b9b91, 0x47a17c0a, 0x4caf7507, 0x51bd6e10, 0x5ab3671d, 0x6b99583e, 0x60975133, 0x7d854a24, 0x768b4329, 0x1fd13462, 0x14df3d6f, 0x09cd2678, 0x02c32f75, 0x33e91056, 0x38e7195b, 0x25f5024c, 0x2efb0b41, 0x8c9ad761, 0x8794de6c, 0x9a86c57b, 0x9188cc76, 0xa0a2f355, 0xabacfa58, 0xb6bee14f, 0xbdb0e842, 0xd4ea9f09, 0xdfe49604, 0xc2f68d13, 0xc9f8841e, 0xf8d2bb3d, 0xf3dcb230, 0xeecea927, 0xe5c0a02a, 0x3c7a47b1, 0x37744ebc, 0x2a6655ab, 0x21685ca6, 0x10426385, 0x1b4c6a88, 0x065e719f, 0x0d507892, 0x640a0fd9, 0x6f0406d4, 0x72161dc3, 0x791814ce, 0x48322bed, 0x433c22e0, 0x5e2e39f7, 0x552030fa, 0x01ec9ab7, 0x0ae293ba, 0x17f088ad, 0x1cfe81a0, 0x2dd4be83, 0x26dab78e, 0x3bc8ac99, 0x30c6a594, 0x599cd2df, 0x5292dbd2, 0x4f80c0c5, 0x448ec9c8, 0x75a4f6eb, 0x7eaaffe6, 0x63b8e4f1, 0x68b6edfc, 0xb10c0a67, 0xba02036a, 0xa710187d, 0xac1e1170, 0x9d342e53, 0x963a275e, 0x8b283c49, 0x80263544, 0xe97c420f, 0xe2724b02, 0xff605015, 0xf46e5918, 0xc544663b, 0xce4a6f36, 0xd3587421, 0xd8567d2c, 0x7a37a10c, 0x7139a801, 0x6c2bb316, 0x6725ba1b, 0x560f8538, 0x5d018c35, 0x40139722, 0x4b1d9e2f, 0x2247e964, 0x2949e069, 0x345bfb7e, 0x3f55f273, 0x0e7fcd50, 0x0571c45d, 0x1863df4a, 0x136dd647, 0xcad731dc, 0xc1d938d1, 0xdccb23c6, 0xd7c52acb, 0xe6ef15e8, 0xede11ce5, 0xf0f307f2, 0xfbfd0eff, 0x92a779b4, 0x99a970b9, 0x84bb6bae, 0x8fb562a3, 0xbe9f5d80, 0xb591548d, 0xa8834f9a, 0xa38d4697];
var U3 = [0x00000000, 0x0d0b0e09, 0x1a161c12, 0x171d121b, 0x342c3824, 0x3927362d, 0x2e3a2436, 0x23312a3f, 0x68587048, 0x65537e41, 0x724e6c5a, 0x7f456253, 0x5c74486c, 0x517f4665, 0x4662547e, 0x4b695a77, 0xd0b0e090, 0xddbbee99, 0xcaa6fc82, 0xc7adf28b, 0xe49cd8b4, 0xe997d6bd, 0xfe8ac4a6, 0xf381caaf, 0xb8e890d8, 0xb5e39ed1, 0xa2fe8cca, 0xaff582c3, 0x8cc4a8fc, 0x81cfa6f5, 0x96d2b4ee, 0x9bd9bae7, 0xbb7bdb3b, 0xb670d532, 0xa16dc729, 0xac66c920, 0x8f57e31f, 0x825ced16, 0x9541ff0d, 0x984af104, 0xd323ab73, 0xde28a57a, 0xc935b761, 0xc43eb968, 0xe70f9357, 0xea049d5e, 0xfd198f45, 0xf012814c, 0x6bcb3bab, 0x66c035a2, 0x71dd27b9, 0x7cd629b0, 0x5fe7038f, 0x52ec0d86, 0x45f11f9d, 0x48fa1194, 0x03934be3, 0x0e9845ea, 0x198557f1, 0x148e59f8, 0x37bf73c7, 0x3ab47dce, 0x2da96fd5, 0x20a261dc, 0x6df6ad76, 0x60fda37f, 0x77e0b164, 0x7aebbf6d, 0x59da9552, 0x54d19b5b, 0x43cc8940, 0x4ec78749, 0x05aedd3e, 0x08a5d337, 0x1fb8c12c, 0x12b3cf25, 0x3182e51a, 0x3c89eb13, 0x2b94f908, 0x269ff701, 0xbd464de6, 0xb04d43ef, 0xa75051f4, 0xaa5b5ffd, 0x896a75c2, 0x84617bcb, 0x937c69d0, 0x9e7767d9, 0xd51e3dae, 0xd81533a7, 0xcf0821bc, 0xc2032fb5, 0xe132058a, 0xec390b83, 0xfb241998, 0xf62f1791, 0xd68d764d, 0xdb867844, 0xcc9b6a5f, 0xc1906456, 0xe2a14e69, 0xefaa4060, 0xf8b7527b, 0xf5bc5c72, 0xbed50605, 0xb3de080c, 0xa4c31a17, 0xa9c8141e, 0x8af93e21, 0x87f23028, 0x90ef2233, 0x9de42c3a, 0x063d96dd, 0x0b3698d4, 0x1c2b8acf, 0x112084c6, 0x3211aef9, 0x3f1aa0f0, 0x2807b2eb, 0x250cbce2, 0x6e65e695, 0x636ee89c, 0x7473fa87, 0x7978f48e, 0x5a49deb1, 0x5742d0b8, 0x405fc2a3, 0x4d54ccaa, 0xdaf741ec, 0xd7fc4fe5, 0xc0e15dfe, 0xcdea53f7, 0xeedb79c8, 0xe3d077c1, 0xf4cd65da, 0xf9c66bd3, 0xb2af31a4, 0xbfa43fad, 0xa8b92db6, 0xa5b223bf, 0x86830980, 0x8b880789, 0x9c951592, 0x919e1b9b, 0x0a47a17c, 0x074caf75, 0x1051bd6e, 0x1d5ab367, 0x3e6b9958, 0x33609751, 0x247d854a, 0x29768b43, 0x621fd134, 0x6f14df3d, 0x7809cd26, 0x7502c32f, 0x5633e910, 0x5b38e719, 0x4c25f502, 0x412efb0b, 0x618c9ad7, 0x6c8794de, 0x7b9a86c5, 0x769188cc, 0x55a0a2f3, 0x58abacfa, 0x4fb6bee1, 0x42bdb0e8, 0x09d4ea9f, 0x04dfe496, 0x13c2f68d, 0x1ec9f884, 0x3df8d2bb, 0x30f3dcb2, 0x27eecea9, 0x2ae5c0a0, 0xb13c7a47, 0xbc37744e, 0xab2a6655, 0xa621685c, 0x85104263, 0x881b4c6a, 0x9f065e71, 0x920d5078, 0xd9640a0f, 0xd46f0406, 0xc372161d, 0xce791814, 0xed48322b, 0xe0433c22, 0xf75e2e39, 0xfa552030, 0xb701ec9a, 0xba0ae293, 0xad17f088, 0xa01cfe81, 0x832dd4be, 0x8e26dab7, 0x993bc8ac, 0x9430c6a5, 0xdf599cd2, 0xd25292db, 0xc54f80c0, 0xc8448ec9, 0xeb75a4f6, 0xe67eaaff, 0xf163b8e4, 0xfc68b6ed, 0x67b10c0a, 0x6aba0203, 0x7da71018, 0x70ac1e11, 0x539d342e, 0x5e963a27, 0x498b283c, 0x44802635, 0x0fe97c42, 0x02e2724b, 0x15ff6050, 0x18f46e59, 0x3bc54466, 0x36ce4a6f, 0x21d35874, 0x2cd8567d, 0x0c7a37a1, 0x017139a8, 0x166c2bb3, 0x1b6725ba, 0x38560f85, 0x355d018c, 0x22401397, 0x2f4b1d9e, 0x642247e9, 0x692949e0, 0x7e345bfb, 0x733f55f2, 0x500e7fcd, 0x5d0571c4, 0x4a1863df, 0x47136dd6, 0xdccad731, 0xd1c1d938, 0xc6dccb23, 0xcbd7c52a, 0xe8e6ef15, 0xe5ede11c, 0xf2f0f307, 0xfffbfd0e, 0xb492a779, 0xb999a970, 0xae84bb6b, 0xa38fb562, 0x80be9f5d, 0x8db59154, 0x9aa8834f, 0x97a38d46];
var U4 = [0x00000000, 0x090d0b0e, 0x121a161c, 0x1b171d12, 0x24342c38, 0x2d392736, 0x362e3a24, 0x3f23312a, 0x48685870, 0x4165537e, 0x5a724e6c, 0x537f4562, 0x6c5c7448, 0x65517f46, 0x7e466254, 0x774b695a, 0x90d0b0e0, 0x99ddbbee, 0x82caa6fc, 0x8bc7adf2, 0xb4e49cd8, 0xbde997d6, 0xa6fe8ac4, 0xaff381ca, 0xd8b8e890, 0xd1b5e39e, 0xcaa2fe8c, 0xc3aff582, 0xfc8cc4a8, 0xf581cfa6, 0xee96d2b4, 0xe79bd9ba, 0x3bbb7bdb, 0x32b670d5, 0x29a16dc7, 0x20ac66c9, 0x1f8f57e3, 0x16825ced, 0x0d9541ff, 0x04984af1, 0x73d323ab, 0x7ade28a5, 0x61c935b7, 0x68c43eb9, 0x57e70f93, 0x5eea049d, 0x45fd198f, 0x4cf01281, 0xab6bcb3b, 0xa266c035, 0xb971dd27, 0xb07cd629, 0x8f5fe703, 0x8652ec0d, 0x9d45f11f, 0x9448fa11, 0xe303934b, 0xea0e9845, 0xf1198557, 0xf8148e59, 0xc737bf73, 0xce3ab47d, 0xd52da96f, 0xdc20a261, 0x766df6ad, 0x7f60fda3, 0x6477e0b1, 0x6d7aebbf, 0x5259da95, 0x5b54d19b, 0x4043cc89, 0x494ec787, 0x3e05aedd, 0x3708a5d3, 0x2c1fb8c1, 0x2512b3cf, 0x1a3182e5, 0x133c89eb, 0x082b94f9, 0x01269ff7, 0xe6bd464d, 0xefb04d43, 0xf4a75051, 0xfdaa5b5f, 0xc2896a75, 0xcb84617b, 0xd0937c69, 0xd99e7767, 0xaed51e3d, 0xa7d81533, 0xbccf0821, 0xb5c2032f, 0x8ae13205, 0x83ec390b, 0x98fb2419, 0x91f62f17, 0x4dd68d76, 0x44db8678, 0x5fcc9b6a, 0x56c19064, 0x69e2a14e, 0x60efaa40, 0x7bf8b752, 0x72f5bc5c, 0x05bed506, 0x0cb3de08, 0x17a4c31a, 0x1ea9c814, 0x218af93e, 0x2887f230, 0x3390ef22, 0x3a9de42c, 0xdd063d96, 0xd40b3698, 0xcf1c2b8a, 0xc6112084, 0xf93211ae, 0xf03f1aa0, 0xeb2807b2, 0xe2250cbc, 0x956e65e6, 0x9c636ee8, 0x877473fa, 0x8e7978f4, 0xb15a49de, 0xb85742d0, 0xa3405fc2, 0xaa4d54cc, 0xecdaf741, 0xe5d7fc4f, 0xfec0e15d, 0xf7cdea53, 0xc8eedb79, 0xc1e3d077, 0xdaf4cd65, 0xd3f9c66b, 0xa4b2af31, 0xadbfa43f, 0xb6a8b92d, 0xbfa5b223, 0x80868309, 0x898b8807, 0x929c9515, 0x9b919e1b, 0x7c0a47a1, 0x75074caf, 0x6e1051bd, 0x671d5ab3, 0x583e6b99, 0x51336097, 0x4a247d85, 0x4329768b, 0x34621fd1, 0x3d6f14df, 0x267809cd, 0x2f7502c3, 0x105633e9, 0x195b38e7, 0x024c25f5, 0x0b412efb, 0xd7618c9a, 0xde6c8794, 0xc57b9a86, 0xcc769188, 0xf355a0a2, 0xfa58abac, 0xe14fb6be, 0xe842bdb0, 0x9f09d4ea, 0x9604dfe4, 0x8d13c2f6, 0x841ec9f8, 0xbb3df8d2, 0xb230f3dc, 0xa927eece, 0xa02ae5c0, 0x47b13c7a, 0x4ebc3774, 0x55ab2a66, 0x5ca62168, 0x63851042, 0x6a881b4c, 0x719f065e, 0x78920d50, 0x0fd9640a, 0x06d46f04, 0x1dc37216, 0x14ce7918, 0x2bed4832, 0x22e0433c, 0x39f75e2e, 0x30fa5520, 0x9ab701ec, 0x93ba0ae2, 0x88ad17f0, 0x81a01cfe, 0xbe832dd4, 0xb78e26da, 0xac993bc8, 0xa59430c6, 0xd2df599c, 0xdbd25292, 0xc0c54f80, 0xc9c8448e, 0xf6eb75a4, 0xffe67eaa, 0xe4f163b8, 0xedfc68b6, 0x0a67b10c, 0x036aba02, 0x187da710, 0x1170ac1e, 0x2e539d34, 0x275e963a, 0x3c498b28, 0x35448026, 0x420fe97c, 0x4b02e272, 0x5015ff60, 0x5918f46e, 0x663bc544, 0x6f36ce4a, 0x7421d358, 0x7d2cd856, 0xa10c7a37, 0xa8017139, 0xb3166c2b, 0xba1b6725, 0x8538560f, 0x8c355d01, 0x97224013, 0x9e2f4b1d, 0xe9642247, 0xe0692949, 0xfb7e345b, 0xf2733f55, 0xcd500e7f, 0xc45d0571, 0xdf4a1863, 0xd647136d, 0x31dccad7, 0x38d1c1d9, 0x23c6dccb, 0x2acbd7c5, 0x15e8e6ef, 0x1ce5ede1, 0x07f2f0f3, 0x0efffbfd, 0x79b492a7, 0x70b999a9, 0x6bae84bb, 0x62a38fb5, 0x5d80be9f, 0x548db591, 0x4f9aa883, 0x4697a38d];
function convertToInt32(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i += 4) {
result.push(
(bytes[i ] << 24) |
(bytes[i + 1] << 16) |
(bytes[i + 2] << 8) |
bytes[i + 3]
);
}
return result;
}
var AES = function(key) {
if (!(this instanceof AES)) {
throw Error('AES must be instanitated with `new`');
}
Object.defineProperty(this, 'key', {
value: coerceArray(key, true)
});
this._prepare();
}
AES.prototype._prepare = function() {
var rounds = numberOfRounds[this.key.length];
if (rounds == null) {
throw new Error('invalid key size (must be 16, 24 or 32 bytes)');
}
// encryption round keys
this._Ke = [];
// decryption round keys
this._Kd = [];
for (var i = 0; i <= rounds; i++) {
this._Ke.push([0, 0, 0, 0]);
this._Kd.push([0, 0, 0, 0]);
}
var roundKeyCount = (rounds + 1) * 4;
var KC = this.key.length / 4;
// convert the key into ints
var tk = convertToInt32(this.key);
// copy values into round key arrays
var index;
for (var i = 0; i < KC; i++) {
index = i >> 2;
this._Ke[index][i % 4] = tk[i];
this._Kd[rounds - index][i % 4] = tk[i];
}
// key expansion (fips-197 section 5.2)
var rconpointer = 0;
var t = KC, tt;
while (t < roundKeyCount) {
tt = tk[KC - 1];
tk[0] ^= ((S[(tt >> 16) & 0xFF] << 24) ^
(S[(tt >> 8) & 0xFF] << 16) ^
(S[ tt & 0xFF] << 8) ^
S[(tt >> 24) & 0xFF] ^
(rcon[rconpointer] << 24));
rconpointer += 1;
// key expansion (for non-256 bit)
if (KC != 8) {
for (var i = 1; i < KC; i++) {
tk[i] ^= tk[i - 1];
}
// key expansion for 256-bit keys is "slightly different" (fips-197)
} else {
for (var i = 1; i < (KC / 2); i++) {
tk[i] ^= tk[i - 1];
}
tt = tk[(KC / 2) - 1];
tk[KC / 2] ^= (S[ tt & 0xFF] ^
(S[(tt >> 8) & 0xFF] << 8) ^
(S[(tt >> 16) & 0xFF] << 16) ^
(S[(tt >> 24) & 0xFF] << 24));
for (var i = (KC / 2) + 1; i < KC; i++) {
tk[i] ^= tk[i - 1];
}
}
// copy values into round key arrays
var i = 0, r, c;
while (i < KC && t < roundKeyCount) {
r = t >> 2;
c = t % 4;
this._Ke[r][c] = tk[i];
this._Kd[rounds - r][c] = tk[i++];
t++;
}
}
// inverse-cipher-ify the decryption round key (fips-197 section 5.3)
for (var r = 1; r < rounds; r++) {
for (var c = 0; c < 4; c++) {
tt = this._Kd[r][c];
this._Kd[r][c] = (U1[(tt >> 24) & 0xFF] ^
U2[(tt >> 16) & 0xFF] ^
U3[(tt >> 8) & 0xFF] ^
U4[ tt & 0xFF]);
}
}
}
AES.prototype.encrypt = function(plaintext) {
if (plaintext.length != 16) {
throw new Error('invalid plaintext size (must be 16 bytes)');
}
var rounds = this._Ke.length - 1;
var a = [0, 0, 0, 0];
// convert plaintext to (ints ^ key)
var t = convertToInt32(plaintext);
for (var i = 0; i < 4; i++) {
t[i] ^= this._Ke[0][i];
}
// apply round transforms
for (var r = 1; r < rounds; r++) {
for (var i = 0; i < 4; i++) {
a[i] = (T1[(t[ i ] >> 24) & 0xff] ^
T2[(t[(i + 1) % 4] >> 16) & 0xff] ^
T3[(t[(i + 2) % 4] >> 8) & 0xff] ^
T4[ t[(i + 3) % 4] & 0xff] ^
this._Ke[r][i]);
}
t = a.slice();
}
// the last round is special
var result = createArray(16), tt;
for (var i = 0; i < 4; i++) {
tt = this._Ke[rounds][i];
result[4 * i ] = (S[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
result[4 * i + 1] = (S[(t[(i + 1) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
result[4 * i + 2] = (S[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
result[4 * i + 3] = (S[ t[(i + 3) % 4] & 0xff] ^ tt ) & 0xff;
}
return result;
}
AES.prototype.decrypt = function(ciphertext) {
if (ciphertext.length != 16) {
throw new Error('invalid ciphertext size (must be 16 bytes)');
}
var rounds = this._Kd.length - 1;
var a = [0, 0, 0, 0];
// convert plaintext to (ints ^ key)
var t = convertToInt32(ciphertext);
for (var i = 0; i < 4; i++) {
t[i] ^= this._Kd[0][i];
}
// apply round transforms
for (var r = 1; r < rounds; r++) {
for (var i = 0; i < 4; i++) {
a[i] = (T5[(t[ i ] >> 24) & 0xff] ^
T6[(t[(i + 3) % 4] >> 16) & 0xff] ^
T7[(t[(i + 2) % 4] >> 8) & 0xff] ^
T8[ t[(i + 1) % 4] & 0xff] ^
this._Kd[r][i]);
}
t = a.slice();
}
// the last round is special
var result = createArray(16), tt;
for (var i = 0; i < 4; i++) {
tt = this._Kd[rounds][i];
result[4 * i ] = (Si[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
result[4 * i + 1] = (Si[(t[(i + 3) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
result[4 * i + 2] = (Si[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
result[4 * i + 3] = (Si[ t[(i + 1) % 4] & 0xff] ^ tt ) & 0xff;
}
return result;
}
/**
* Mode Of Operation - Electonic Codebook (ECB)
*/
var ModeOfOperationECB = function(key) {
if (!(this instanceof ModeOfOperationECB)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Electronic Code Block";
this.name = "ecb";
this._aes = new AES(key);
}
ModeOfOperationECB.prototype.encrypt = function(plaintext) {
plaintext = coerceArray(plaintext);
if ((plaintext.length % 16) !== 0) {
throw new Error('invalid plaintext size (must be multiple of 16 bytes)');
}
var ciphertext = createArray(plaintext.length);
var block = createArray(16);
for (var i = 0; i < plaintext.length; i += 16) {
copyArray(plaintext, block, 0, i, i + 16);
block = this._aes.encrypt(block);
copyArray(block, ciphertext, i);
}
return ciphertext;
}
ModeOfOperationECB.prototype.decrypt = function(ciphertext) {
ciphertext = coerceArray(ciphertext);
if ((ciphertext.length % 16) !== 0) {
throw new Error('invalid ciphertext size (must be multiple of 16 bytes)');
}
var plaintext = createArray(ciphertext.length);
var block = createArray(16);
for (var i = 0; i < ciphertext.length; i += 16) {
copyArray(ciphertext, block, 0, i, i + 16);
block = this._aes.decrypt(block);
copyArray(block, plaintext, i);
}
return plaintext;
}
/**
* Mode Of Operation - Cipher Block Chaining (CBC)
*/
var ModeOfOperationCBC = function(key, iv) {
if (!(this instanceof ModeOfOperationCBC)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Cipher Block Chaining";
this.name = "cbc";
if (!iv) {
iv = createArray(16);
} else if (iv.length != 16) {
throw new Error('invalid initialation vector size (must be 16 bytes)');
}
this._lastCipherblock = coerceArray(iv, true);
this._aes = new AES(key);
}
ModeOfOperationCBC.prototype.encrypt = function(plaintext) {
plaintext = coerceArray(plaintext);
if ((plaintext.length % 16) !== 0) {
throw new Error('invalid plaintext size (must be multiple of 16 bytes)');
}
var ciphertext = createArray(plaintext.length);
var block = createArray(16);
for (var i = 0; i < plaintext.length; i += 16) {
copyArray(plaintext, block, 0, i, i + 16);
for (var j = 0; j < 16; j++) {
block[j] ^= this._lastCipherblock[j];
}
this._lastCipherblock = this._aes.encrypt(block);
copyArray(this._lastCipherblock, ciphertext, i);
}
return ciphertext;
}
ModeOfOperationCBC.prototype.decrypt = function(ciphertext) {
ciphertext = coerceArray(ciphertext);
if ((ciphertext.length % 16) !== 0) {
throw new Error('invalid ciphertext size (must be multiple of 16 bytes)');
}
var plaintext = createArray(ciphertext.length);
var block = createArray(16);
for (var i = 0; i < ciphertext.length; i += 16) {
copyArray(ciphertext, block, 0, i, i + 16);
block = this._aes.decrypt(block);
for (var j = 0; j < 16; j++) {
plaintext[i + j] = block[j] ^ this._lastCipherblock[j];
}
copyArray(ciphertext, this._lastCipherblock, 0, i, i + 16);
}
return plaintext;
}
/**
* Mode Of Operation - Cipher Feedback (CFB)
*/
var ModeOfOperationCFB = function(key, iv, segmentSize) {
if (!(this instanceof ModeOfOperationCFB)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Cipher Feedback";
this.name = "cfb";
if (!iv) {
iv = createArray(16);
} else if (iv.length != 16) {
throw new Error('invalid initialation vector size (must be 16 size)');
}
if (!segmentSize) { segmentSize = 1; }
this.segmentSize = segmentSize;
this._shiftRegister = coerceArray(iv, true);
this._aes = new AES(key);
}
ModeOfOperationCFB.prototype.encrypt = function(plaintext) {
if ((plaintext.length % this.segmentSize) != 0) {
throw new Error('invalid plaintext size (must be segmentSize bytes)');
}
var encrypted = coerceArray(plaintext, true);
var xorSegment;
for (var i = 0; i < encrypted.length; i += this.segmentSize) {
xorSegment = this._aes.encrypt(this._shiftRegister);
for (var j = 0; j < this.segmentSize; j++) {
encrypted[i + j] ^= xorSegment[j];
}
// Shift the register
copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize);
copyArray(encrypted, this._shiftRegister, 16 - this.segmentSize, i, i + this.segmentSize);
}
return encrypted;
}
ModeOfOperationCFB.prototype.decrypt = function(ciphertext) {
if ((ciphertext.length % this.segmentSize) != 0) {
throw new Error('invalid ciphertext size (must be segmentSize bytes)');
}
var plaintext = coerceArray(ciphertext, true);
var xorSegment;
for (var i = 0; i < plaintext.length; i += this.segmentSize) {
xorSegment = this._aes.encrypt(this._shiftRegister);
for (var j = 0; j < this.segmentSize; j++) {
plaintext[i + j] ^= xorSegment[j];
}
// Shift the register
copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize);
copyArray(ciphertext, this._shiftRegister, 16 - this.segmentSize, i, i + this.segmentSize);
}
return plaintext;
}
/**
* Mode Of Operation - Output Feedback (OFB)
*/
var ModeOfOperationOFB = function(key, iv) {
if (!(this instanceof ModeOfOperationOFB)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Output Feedback";
this.name = "ofb";
if (!iv) {
iv = createArray(16);
} else if (iv.length != 16) {
throw new Error('invalid initialation vector size (must be 16 bytes)');
}
this._lastPrecipher = coerceArray(iv, true);
this._lastPrecipherIndex = 16;
this._aes = new AES(key);
}
ModeOfOperationOFB.prototype.encrypt = function(plaintext) {
var encrypted = coerceArray(plaintext, true);
for (var i = 0; i < encrypted.length; i++) {
if (this._lastPrecipherIndex === 16) {
this._lastPrecipher = this._aes.encrypt(this._lastPrecipher);
this._lastPrecipherIndex = 0;
}
encrypted[i] ^= this._lastPrecipher[this._lastPrecipherIndex++];
}
return encrypted;
}
// Decryption is symetric
ModeOfOperationOFB.prototype.decrypt = ModeOfOperationOFB.prototype.encrypt;
/**
* Counter object for CTR common mode of operation
*/
var Counter = function(initialValue) {
if (!(this instanceof Counter)) {
throw Error('Counter must be instanitated with `new`');
}
// We allow 0, but anything false-ish uses the default 1
if (initialValue !== 0 && !initialValue) { initialValue = 1; }
if (typeof(initialValue) === 'number') {
this._counter = createArray(16);
this.setValue(initialValue);
} else {
this.setBytes(initialValue);
}
}
Counter.prototype.setValue = function(value) {
if (typeof(value) !== 'number' || parseInt(value) != value) {
throw new Error('invalid counter value (must be an integer)');
}
// We cannot safely handle numbers beyond the safe range for integers
if (value > Number.MAX_SAFE_INTEGER) {
throw new Error('integer value out of safe range');
}
for (var index = 15; index >= 0; --index) {
this._counter[index] = value % 256;
value = parseInt(value / 256);
}
}
Counter.prototype.setBytes = function(bytes) {
bytes = coerceArray(bytes, true);
if (bytes.length != 16) {
throw new Error('invalid counter bytes size (must be 16 bytes)');
}
this._counter = bytes;
};
Counter.prototype.increment = function() {
for (var i = 15; i >= 0; i--) {
if (this._counter[i] === 255) {
this._counter[i] = 0;
} else {
this._counter[i]++;
break;
}
}
}
/**
* Mode Of Operation - Counter (CTR)
*/
var ModeOfOperationCTR = function(key, counter) {
if (!(this instanceof ModeOfOperationCTR)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Counter";
this.name = "ctr";
if (!(counter instanceof Counter)) {
counter = new Counter(counter)
}
this._counter = counter;
this._remainingCounter = null;
this._remainingCounterIndex = 16;
this._aes = new AES(key);
}
ModeOfOperationCTR.prototype.encrypt = function(plaintext) {
var encrypted = coerceArray(plaintext, true);
for (var i = 0; i < encrypted.length; i++) {
if (this._remainingCounterIndex === 16) {
this._remainingCounter = this._aes.encrypt(this._counter._counter);
this._remainingCounterIndex = 0;
this._counter.increment();
}
encrypted[i] ^= this._remainingCounter[this._remainingCounterIndex++];
}
return encrypted;
}
// Decryption is symetric
ModeOfOperationCTR.prototype.decrypt = ModeOfOperationCTR.prototype.encrypt;
///////////////////////
// Padding
// See:https://tools.ietf.org/html/rfc2315
function pkcs7pad(data) {
data = coerceArray(data, true);
var padder = 16 - (data.length % 16);
var result = createArray(data.length + padder);
copyArray(data, result);
for (var i = data.length; i < result.length; i++) {
result[i] = padder;
}
return result;
}
function pkcs7strip(data) {
data = coerceArray(data, true);
if (data.length < 16) { throw new Error('PKCS#7 invalid length'); }
var padder = data[data.length - 1];
if (padder > 16) { throw new Error('PKCS#7 padding byte out of range'); }
var length = data.length - padder;
for (var i = 0; i < padder; i++) {
if (data[length + i] !== padder) {
throw new Error('PKCS#7 invalid padding byte');
}
}
var result = createArray(length);
copyArray(data, result, 0, 0, length);
return result;
}
///////////////////////
// Exporting
// The block cipher
var aesjs = {
AES: AES,
Counter: Counter,
ModeOfOperation: {
ecb: ModeOfOperationECB,
cbc: ModeOfOperationCBC,
cfb: ModeOfOperationCFB,
ofb: ModeOfOperationOFB,
ctr: ModeOfOperationCTR
},
utils: {
hex: convertHex,
utf8: convertUtf8
},
padding: {
pkcs7: {
pad: pkcs7pad,
strip: pkcs7strip
}
},
_arrayTest: {
coerceArray: coerceArray,
createArray: createArray,
copyArray: copyArray,
}
};
// node.js
if (typeof exports !== 'undefined') {
module.exports = aesjs
// RequireJS/AMD
// http://www.requirejs.org/docs/api.html
// https://github.com/amdjs/amdjs-api/wiki/AMD
} else if (typeof(define) === 'function' && define.amd) {
define([], function() { return aesjs; });
// Web Browsers
} else {
// If there was an existing library at "aesjs" make sure it's still available
if (root.aesjs) {
aesjs._aesjs = root.aesjs;
}
root.aesjs = aesjs;
}
})(this);

File diff suppressed because it is too large Load diff

View file

@ -386,6 +386,13 @@ const watchOnly = async () => {
this.addresses = addresses this.addresses = addresses
this.scanAddressWithAmount() this.scanAddressWithAmount()
} }
},
handleBroadcastSuccess: async function (txId) {
this.tab = 'history'
this.searchInTab({tab: 'history', value: txId})
this.showPayment = false
await this.refreshAddresses()
await this.scanAddressWithAmount()
} }
}, },
created: async function () { created: async function () {

View file

@ -1,4 +1,6 @@
const PSBT_BASE64_PREFIX = 'cHNidP8' const PSBT_BASE64_PREFIX = 'cHNidP8'
const COMMAND_PING = '/ping'
const COMMAND_PASSWORD = '/password' const COMMAND_PASSWORD = '/password'
const COMMAND_PASSWORD_CLEAR = '/password-clear' const COMMAND_PASSWORD_CLEAR = '/password-clear'
const COMMAND_SEND_PSBT = '/psbt' const COMMAND_SEND_PSBT = '/psbt'
@ -10,8 +12,24 @@ const COMMAND_RESTORE = '/restore'
const COMMAND_CONFIRM_NEXT = '/confirm-next' const COMMAND_CONFIRM_NEXT = '/confirm-next'
const COMMAND_CANCEL = '/cancel' const COMMAND_CANCEL = '/cancel'
const COMMAND_XPUB = '/xpub' const COMMAND_XPUB = '/xpub'
const COMMAND_PAIR = '/pair'
const COMMAND_LOG = '/log'
const COMMAND_CHECK_PAIRING = '/check-pairing'
const DEFAULT_RECEIVE_GAP_LIMIT = 20 const DEFAULT_RECEIVE_GAP_LIMIT = 20
const PAIRING_CONTROL_TEXT = 'lnbits'
const HWW_DEFAULT_CONFIG = Object.freeze({
name: '',
buttonOnePin: '',
buttonTwoPin: '',
baudRate: 9600,
bufferSize: 255,
dataBits: 8,
flowControl: 'none',
parity: 'none',
stopBits: 1
})
const blockTimeToDate = blockTime => const blockTimeToDate = blockTime =>
blockTime ? moment(blockTime * 1000).format('LLL') : '' blockTime ? moment(blockTime * 1000).format('LLL') : ''
@ -181,3 +199,11 @@ function findAccountPathIssues(path = '') {
if (isNaN(p[i])) return `${p[i]} is not a valid value` if (isNaN(p[i])) return `${p[i]} is not a valid value`
} }
} }
function asciiToUint8Array(str) {
var chars = []
for (var i = 0; i < str.length; ++i) {
chars.push(str.charCodeAt(i))
}
return new Uint8Array(chars)
}

View file

@ -13,6 +13,7 @@
:network="config.network" :network="config.network"
:sats-denominated="config.sats_denominated" :sats-denominated="config.sats_denominated"
@signed:psbt="updateSignedPsbt" @signed:psbt="updateSignedPsbt"
class="q-pr-lg float-right"
></serial-signer> ></serial-signer>
</template> </template>
</wallet-config> </wallet-config>
@ -33,7 +34,7 @@
{% raw %} {% raw %}
<q-card> <q-card>
<div class="row q-pt-sm q-pb-sm items-center no-wrap q-mb-md"> <div class="row q-pt-sm q-pb-sm items-center no-wrap q-mb-md">
<div class="col-3 q-pl-md"> <div class="col-md-3 col-sm-5 q-pl-md">
<q-btn <q-btn
unelevated unelevated
class="btn-full" class="btn-full"
@ -43,14 +44,14 @@
>Scan Blockchain</q-btn >Scan Blockchain</q-btn
> >
</div> </div>
<div class="col-6"> <div class="col-md-6 col-sm-2 q-pl-md">
<q-spinner <q-spinner
v-if="scan.scanning == true" v-if="scan.scanning == true"
color="primary" color="primary"
size="2.55em" size="2.55em"
></q-spinner> ></q-spinner>
</div> </div>
<div class="col-3 q-pr-md"> <div class="col-md-3 col-sm-5 q-pr-md">
<q-btn <q-btn
v-if="!showPayment" v-if="!showPayment"
unelevated unelevated
@ -135,6 +136,7 @@
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
:serial-signer-ref="$refs.serialSigner" :serial-signer-ref="$refs.serialSigner"
:sats-denominated="config.sats_denominated" :sats-denominated="config.sats_denominated"
@broadcast-done="handleBroadcastSuccess"
></payment> ></payment>
<!-- todo: no more utxos.data --> <!-- todo: no more utxos.data -->
</div> </div>
@ -170,14 +172,24 @@
></qrcode> ></qrcode>
</q-responsive> </q-responsive>
<p v-if="currentAddress"> <p v-if="currentAddress">
<q-btn
flat
dense
size="ms"
icon="content_copy"
@click="copyText(currentAddress.address)"
class="q-ml-sm"
></q-btn>
{{ currentAddress.address }} {{ currentAddress.address }}
<q-btn <q-btn
flat flat
dense dense
size="ms" size="ms"
icon="launch" icon="launch"
type="a" type="a"
:href="mempoolHostname + '/address/' + currentAddress.address" :href="'https://' + mempoolHostname + '/address/' + currentAddress.address"
target="_blank" target="_blank"
></q-btn> ></q-btn>
</p> </p>
@ -237,5 +249,8 @@
<script src="{{ url_for('watchonly_static', path='components/payment/payment.js') }}"></script> <script src="{{ url_for('watchonly_static', path='components/payment/payment.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='components/serial-signer/serial-signer.js') }}"></script> <script src="{{ url_for('watchonly_static', path='components/serial-signer/serial-signer.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='components/serial-port-config/serial-port-config.js') }}"></script> <script src="{{ url_for('watchonly_static', path='components/serial-port-config/serial-port-config.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='js/crypto/noble-secp256k1.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='js/crypto/aes.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='js/index.js') }}"></script> <script src="{{ url_for('watchonly_static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -418,16 +418,18 @@
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')" @click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
>Shareable link</q-btn ><q-tooltip>Copy sharable link</q-tooltip>
> </q-btn>
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="nfc" icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)" @click="writeNfcTag(qrCodeDialog.data.lnurl)"
:disable="nfcTagWriting" :disable="nfcTagWriting"
></q-btn> ><q-tooltip>Write to NFC</q-tooltip></q-btn
>
<q-btn <q-btn
outline outline
color="grey" color="grey"
@ -435,7 +437,8 @@
type="a" type="a"
:href="qrCodeDialog.data.print_url" :href="qrCodeDialog.data.print_url"
target="_blank" target="_blank"
></q-btn> ><q-tooltip>Print</q-tooltip></q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>

View file

@ -113,7 +113,7 @@ async def api_link_create_or_update(
return {**link.dict(), **{"lnurl": link.lnurl(req)}} return {**link.dict(), **{"lnurl": link.lnurl(req)}}
@withdraw_ext.delete("/api/v1/links/{link_id}") @withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
@ -128,7 +128,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
) )
await delete_withdraw_link(link_id) await delete_withdraw_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return {"success": True}
@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK) @withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK)

View file

@ -55,6 +55,8 @@ FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet") DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True) PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
RESERVE_FEE_MIN = env.int("LNBITS_RESERVE_FEE_MIN", default=2000)
RESERVE_FEE_PERCENT = env.float("LNBITS_RESERVE_FEE_PERCENT", default=1.0)
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0) SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
try: try:
@ -67,3 +69,13 @@ try:
) )
except: except:
LNBITS_COMMIT = "unknown" LNBITS_COMMIT = "unknown"
BOLTZ_NETWORK = env.str("BOLTZ_NETWORK", default="main")
BOLTZ_URL = env.str("BOLTZ_URL", default="https://boltz.exchange/api")
BOLTZ_MEMPOOL_SPACE_URL = env.str(
"BOLTZ_MEMPOOL_SPACE_URL", default="https://mempool.space"
)
BOLTZ_MEMPOOL_SPACE_URL_WS = env.str(
"BOLTZ_MEMPOOL_SPACE_URL_WS", default="wss://mempool.space"
)

View file

@ -179,6 +179,11 @@ Vue.component('lnbits-extension-list', {
Vue.component('lnbits-payment-details', { Vue.component('lnbits-payment-details', {
props: ['payment'], props: ['payment'],
data: function () {
return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
template: ` template: `
<div class="q-py-md" style="text-align: left"> <div class="q-py-md" style="text-align: left">
<div class="row justify-center q-mb-md"> <div class="row justify-center q-mb-md">

View file

@ -86,6 +86,9 @@ async def check_pending_payments():
incoming = True incoming = True
while True: while True:
logger.debug(
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
)
for payment in await get_payments( for payment in await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False, complete=False,
@ -94,11 +97,14 @@ async def check_pending_payments():
incoming=incoming, incoming=incoming,
exclude_uncheckable=True, exclude_uncheckable=True,
): ):
await payment.check_pending() await payment.check_status()
logger.debug("Task: pending payments check finished")
# we delete expired invoices once upon the first pending check # we delete expired invoices once upon the first pending check
if incoming: if incoming:
logger.debug("Task: deleting all expired invoices")
await delete_expired_invoices() await delete_expired_invoices()
logger.debug("Task: expired invoice deletion finished")
# after the first check we will only check outgoing, not incoming # after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully # that will be handled by the global invoice listeners, hopefully
incoming = False incoming = False

View file

@ -18,13 +18,15 @@ class PaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded # when ok is None it means we don't know if this succeeded
ok: Optional[bool] = None ok: Optional[bool] = None
checking_id: Optional[str] = None # payment_hash, rcp_id checking_id: Optional[str] = None # payment_hash, rcp_id
fee_msat: int = 0 fee_msat: Optional[int] = None
preimage: Optional[str] = None preimage: Optional[str] = None
error_message: Optional[str] = None error_message: Optional[str] = None
class PaymentStatus(NamedTuple): class PaymentStatus(NamedTuple):
paid: Optional[bool] = None paid: Optional[bool] = None
fee_msat: Optional[int] = None
preimage: Optional[str] = None
@property @property
def pending(self) -> bool: def pending(self) -> bool:

View file

@ -46,12 +46,19 @@ class ClicheWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
if description_hash: if unhashed_description or description_hash:
description_hash_hashed = hashlib.sha256(description_hash).hexdigest() description_hash_str = (
description_hash.hex()
if description_hash
else hashlib.sha256(unhashed_description).hexdigest()
if unhashed_description
else None
)
ws = create_connection(self.endpoint) ws = create_connection(self.endpoint)
ws.send( ws.send(
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_hashed}" f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_str}"
) )
r = ws.recv() r = ws.recv()
else: else:
@ -74,31 +81,41 @@ class ClicheWallet(Wallet):
data["result"]["invoice"], data["result"]["invoice"],
) )
else: else:
return InvoiceResponse( return InvoiceResponse(False, None, None, "Could not get payment hash")
False, checking_id, payment_request, "Could not get payment hash"
)
return InvoiceResponse(True, checking_id, payment_request, error_message) return InvoiceResponse(True, checking_id, payment_request, error_message)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
ws = create_connection(self.endpoint) ws = create_connection(self.endpoint)
ws.send(f"pay-invoice --invoice {bolt11}") ws.send(f"pay-invoice --invoice {bolt11}")
r = ws.recv() for _ in range(2):
data = json.loads(r) r = ws.recv()
checking_id = None data = json.loads(r)
error_message = None checking_id, fee_msat, preimage, error_message, payment_ok = (
None,
None,
None,
None,
None,
)
if data.get("error") is not None and data["error"].get("message"): if data.get("error") is not None:
logger.error(data["error"]["message"]) error_message = data["error"].get("message")
error_message = data["error"]["message"] return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(False, None, 0, error_message)
if data.get("result") is not None and data["result"].get("payment_hash"): if data.get("method") == "payment_succeeded":
checking_id = data["result"]["payment_hash"] payment_ok = True
else: checking_id = data["params"]["payment_hash"]
return PaymentResponse(False, checking_id, 0, "Could not get payment hash") fee_msat = data["params"]["fee_msatoshi"]
preimage = data["params"]["preimage"]
continue
return PaymentResponse(True, checking_id, 0, error_message) if data.get("result") is None:
return PaymentResponse(None)
return PaymentResponse(
payment_ok, checking_id, fee_msat, preimage, error_message
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
ws = create_connection(self.endpoint) ws = create_connection(self.endpoint)
@ -122,22 +139,30 @@ class ClicheWallet(Wallet):
if data.get("error") is not None and data["error"].get("message"): if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"]) logger.error(data["error"]["message"])
return PaymentStatus(None) return PaymentStatus(None)
payment = data["result"]
statuses = {"pending": None, "complete": True, "failed": False} statuses = {"pending": None, "complete": True, "failed": False}
return PaymentStatus(statuses[data["result"]["status"]]) return PaymentStatus(
statuses[payment["status"]],
payment.get("fee_msatoshi"),
payment.get("preimage"),
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
try: while True:
ws = await create_connection(self.endpoint) try:
while True: ws = await create_connection(self.endpoint)
r = await ws.recv() while True:
data = json.loads(r) r = await ws.recv()
try: data = json.loads(r)
if data["result"]["status"]: print(data)
yield data["result"]["payment_hash"] try:
except: if data["result"]["status"]:
continue yield data["result"]["payment_hash"]
except: except:
pass continue
logger.error("lost connection to cliche's websocket, retrying in 5 seconds") except Exception as exc:
await asyncio.sleep(5) logger.error(
f"lost connection to cliche's invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)
continue

View file

@ -82,21 +82,26 @@ class CoreLightningWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
label = "lbl{}".format(random.random()) label = "lbl{}".format(random.random())
msat: int = int(amount * 1000) msat: int = int(amount * 1000)
try: try:
if description_hash and not self.supports_description_hash: if description_hash and not unhashed_description:
raise Unsupported("description_hash") raise Unsupported(
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
)
if unhashed_description and not self.supports_description_hash:
raise Unsupported("unhashed_description")
r = self.ln.invoice( r = self.ln.invoice(
msatoshi=msat, msatoshi=msat,
label=label, label=label,
description=description_hash.decode("utf-8") description=unhashed_description.decode("utf-8")
if description_hash if unhashed_description
else memo, else memo,
exposeprivatechannels=True, exposeprivatechannels=True,
deschashonly=True deschashonly=True
if description_hash if unhashed_description
else False, # we can't pass None here else False, # we can't pass None here
) )
@ -105,29 +110,38 @@ class CoreLightningWallet(Wallet):
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "") return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
except RpcError as exc: except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
logger.error("RPC error:", error_message)
return InvoiceResponse(False, None, None, error_message) return InvoiceResponse(False, None, None, error_message)
except Exception as e: except Exception as e:
logger.error("error:", e)
return InvoiceResponse(False, None, None, str(e)) return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11) invoice = lnbits_bolt11.decode(bolt11)
previous_payment = await self.get_payment_status(invoice.payment_hash)
if previous_payment.paid:
return PaymentResponse(False, None, None, None, "invoice already paid")
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
payload = { payload = {
"bolt11": bolt11, "bolt11": bolt11,
"maxfeepercent": "{:.11}".format(fee_limit_percent), "maxfeepercent": "{:.11}".format(fee_limit_percent),
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee under 5000 millisatoshi (which is default value of exemptfee) "exemptfee": 0, # so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi (which is default value of exemptfee)
} }
try: try:
wrapped = async_wrap(_pay_invoice) wrapped = async_wrap(_pay_invoice)
r = await wrapped(self.ln, payload) r = await wrapped(self.ln, payload)
except RpcError as exc:
try:
error_message = exc.error["attempts"][-1]["fail_reason"]
except:
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
return PaymentResponse(False, None, None, None, error_message)
except Exception as exc: except Exception as exc:
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
fee_msat = r["msatoshi_sent"] - r["msatoshi"] fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
return PaymentResponse( return PaymentResponse(
True, r["payment_hash"], fee_msat, r["payment_preimage"], None True, r["payment_hash"], fee_msat, r["payment_preimage"], None
) )
@ -139,9 +153,16 @@ class CoreLightningWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
if not r["invoices"]: if not r["invoices"]:
return PaymentStatus(None) return PaymentStatus(None)
if r["invoices"][0]["payment_hash"] == checking_id:
return PaymentStatus(r["invoices"][0]["status"] == "paid") invoice_resp = r["invoices"][-1]
raise KeyError("supplied an invalid checking_id")
if invoice_resp["payment_hash"] == checking_id:
if invoice_resp["status"] == "paid":
return PaymentStatus(True)
elif invoice_resp["status"] == "unpaid":
return PaymentStatus(None)
logger.warning(f"supplied an invalid checking_id: {checking_id}")
return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try: try:
@ -150,14 +171,21 @@ class CoreLightningWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
if not r["pays"]: if not r["pays"]:
return PaymentStatus(None) return PaymentStatus(None)
if r["pays"][0]["payment_hash"] == checking_id: payment_resp = r["pays"][-1]
status = r["pays"][0]["status"]
if payment_resp["payment_hash"] == checking_id:
status = payment_resp["status"]
if status == "complete": if status == "complete":
return PaymentStatus(True) fee_msat = -int(
payment_resp["amount_sent_msat"] - payment_resp["amount_msat"]
)
return PaymentStatus(True, fee_msat, payment_resp["preimage"])
elif status == "failed": elif status == "failed":
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id") logger.warning(f"supplied an invalid checking_id: {checking_id}")
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True: while True:

View file

@ -50,7 +50,7 @@ class EclairWallet(Wallet):
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.url}/usablebalances", headers=self.auth, timeout=40 f"{self.url}/globalbalance", headers=self.auth, timeout=5
) )
try: try:
data = r.json() data = r.json()
@ -60,20 +60,25 @@ class EclairWallet(Wallet):
) )
if r.is_error: if r.is_error:
return StatusResponse(data["error"], 0) return StatusResponse(data.get("error") or "undefined error", 0)
if len(data) == 0:
return StatusResponse("no data", 0)
return StatusResponse(None, data[0]["canSend"] * 1000) return StatusResponse(None, int(data.get("total") * 100_000_000_000))
async def create_invoice( async def create_invoice(
self, self,
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"amountMsat": amount * 1000} data: Dict = {"amountMsat": amount * 1000}
if description_hash: if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest() data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else: else:
data["description"] = memo or "" data["description"] = memo or ""
@ -111,13 +116,18 @@ class EclairWallet(Wallet):
except: except:
error_message = r.text error_message = r.text
pass pass
return PaymentResponse(False, None, 0, None, error_message) return PaymentResponse(False, None, None, None, error_message)
data = r.json() data = r.json()
if data["type"] == "payment-failed":
return PaymentResponse(False, None, None, None, "payment failed")
checking_id = data["paymentHash"] checking_id = data["paymentHash"]
preimage = data["paymentPreimage"] preimage = data["paymentPreimage"]
# We do all this again to get the fee:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.url}/getsentinfo", f"{self.url}/getsentinfo",
@ -133,15 +143,22 @@ class EclairWallet(Wallet):
except: except:
error_message = r.text error_message = r.text
pass pass
return PaymentResponse( return PaymentResponse(None, checking_id, None, preimage, error_message)
True, checking_id, 0, preimage, error_message
) ## ?? is this ok ??
data = r.json() statuses = {
fees = [i["status"] for i in data] "sent": True,
fee_msat = sum([i["feesPaid"] for i in fees]) "failed": False,
"pending": None,
}
return PaymentResponse(True, checking_id, fee_msat, preimage, None) data = r.json()[-1]
if data["status"]["type"] == "sent":
fee_msat = -data["status"]["feesPaid"]
preimage = data["status"]["paymentPreimage"]
return PaymentResponse(
statuses[data["status"]["type"]], checking_id, fee_msat, preimage, None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -152,54 +169,61 @@ class EclairWallet(Wallet):
) )
data = r.json() data = r.json()
if r.is_error or "error" in data: if r.is_error or "error" in data or data.get("status") is None:
return PaymentStatus(None) return PaymentStatus(None)
if data["status"]["type"] != "received": statuses = {
return PaymentStatus(False) "received": True,
"expired": False,
return PaymentStatus(True) "pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]))
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
url=f"{self.url}/getsentinfo", f"{self.url}/getsentinfo",
headers=self.auth, headers=self.auth,
data={"paymentHash": checking_id}, data={"paymentHash": checking_id},
timeout=40,
) )
data = r.json()[0]
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
if data["status"]["type"] != "sent": data = r.json()[-1]
return PaymentStatus(False)
return PaymentStatus(True) if r.is_error or "error" in data or data.get("status") is None:
return PaymentStatus(None)
fee_msat, preimage = None, None
if data["status"]["type"] == "sent":
fee_msat = -data["status"]["feesPaid"]
preimage = data["status"]["paymentPreimage"]
statuses = {
"sent": True,
"failed": False,
"pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]), fee_msat, preimage)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
async with connect(
self.ws_url,
extra_headers=[("Authorization", self.auth["Authorization"])],
) as ws:
while True:
message = await ws.recv()
message = json.loads(message)
try: if message and message["type"] == "payment-received":
async with connect( yield message["paymentHash"]
self.ws_url,
extra_headers=[("Authorization", self.auth["Authorization"])],
) as ws:
while True:
message = await ws.recv()
message = json.loads(message)
if message and message["type"] == "payment-received": except Exception as exc:
yield message["paymentHash"] logger.error(
f"lost connection to eclair invoices stream: '{exc}', retrying in 5 seconds"
except ( )
OSError, await asyncio.sleep(5)
ConnectionClosedOK,
ConnectionClosedError,
ConnectionClosed,
) as ose:
logger.error("OSE", ose)
pass
logger.error("lost connection to eclair's websocket, retrying in 5 seconds")
await asyncio.sleep(5)

View file

@ -35,6 +35,7 @@ class FakeWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
# we set a default secret since FakeWallet is used for internal=True invoices # we set a default secret since FakeWallet is used for internal=True invoices
# and the user might not have configured a secret yet # and the user might not have configured a secret yet
@ -61,7 +62,10 @@ class FakeWallet(Wallet):
data["timestamp"] = datetime.now().timestamp() data["timestamp"] = datetime.now().timestamp()
if description_hash: if description_hash:
data["tags_set"] = ["h"] data["tags_set"] = ["h"]
data["description_hash"] = description_hash.decode("utf-8") data["description_hash"] = description_hash
elif unhashed_description:
data["tags_set"] = ["d"]
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
else: else:
data["tags_set"] = ["d"] data["tags_set"] = ["d"]
data["memo"] = memo data["memo"] = memo

View file

@ -57,12 +57,15 @@ class LNbitsWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"out": False, "amount": amount} data: Dict = {"out": False, "amount": amount}
if description_hash: if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest() data["description_hash"] = description_hash.hex()
else: if unhashed_description:
data["memo"] = memo or "" data["unhashed_description"] = unhashed_description.hex()
data["memo"] = memo or ""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
@ -91,15 +94,25 @@ class LNbitsWallet(Wallet):
json={"out": True, "bolt11": bolt11}, json={"out": True, "bolt11": bolt11},
timeout=None, timeout=None,
) )
ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None ok, checking_id, fee_msat, preimage, error_message = (
not r.is_error,
None,
None,
None,
None,
)
if r.is_error: if r.is_error:
error_message = r.json()["detail"] error_message = r.json()["detail"]
return PaymentResponse(None, None, None, None, error_message)
else: else:
data = r.json() data = r.json()
checking_id = data["checking_id"] checking_id = data["payment_hash"]
return PaymentResponse(ok, checking_id, fee_msat, error_message) # we do this to get the fee and preimage
payment: PaymentStatus = await self.get_payment_status(checking_id)
return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:
@ -122,8 +135,11 @@ class LNbitsWallet(Wallet):
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()
if "paid" not in data and "details" not in data:
return PaymentStatus(None)
return PaymentStatus(r.json()["paid"]) return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = f"{self.endpoint}/api/v1/payments/sse" url = f"{self.endpoint}/api/v1/payments/sse"

View file

@ -65,14 +65,32 @@ def get_ssl_context(cert_path: str):
return context return context
def parse_checking_id(checking_id: str) -> bytes: def b64_to_bytes(checking_id: str) -> bytes:
return base64.b64decode(checking_id.replace("_", "/")) return base64.b64decode(checking_id.replace("_", "/"))
def stringify_checking_id(r_hash: bytes) -> str: def bytes_to_b64(r_hash: bytes) -> str:
return base64.b64encode(r_hash).decode("utf-8").replace("/", "_") return base64.b64encode(r_hash).decode("utf-8").replace("/", "_")
def hex_to_b64(hex_str: str) -> str:
try:
return base64.b64encode(bytes.fromhex(hex_str)).decode()
except ValueError:
return ""
def hex_to_bytes(hex_str: str) -> bytes:
try:
return bytes.fromhex(hex_str)
except:
return b""
def bytes_to_hex(b: bytes) -> str:
return b.hex()
# Due to updated ECDSA generated tls.cert we need to let gprc know that # Due to updated ECDSA generated tls.cert we need to let gprc know that
# we need to use that cipher suite otherwise there will be a handhsake # we need to use that cipher suite otherwise there will be a handhsake
# error when we communicate with the lnd rpc server. # error when we communicate with the lnd rpc server.
@ -134,14 +152,15 @@ class LndWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
params: Dict = {"value": amount, "expiry": 600, "private": True} params: Dict = {"value": amount, "expiry": 600, "private": True}
if description_hash: if description_hash:
params["description_hash"] = description_hash
elif unhashed_description:
params["description_hash"] = hashlib.sha256( params["description_hash"] = hashlib.sha256(
description_hash unhashed_description
).digest() # as bytes directly ).digest() # as bytes directly
else: else:
params["memo"] = memo or "" params["memo"] = memo or ""
@ -152,7 +171,7 @@ class LndWallet(Wallet):
error_message = str(exc) error_message = str(exc)
return InvoiceResponse(False, None, None, error_message) return InvoiceResponse(False, None, None, error_message)
checking_id = stringify_checking_id(resp.r_hash) checking_id = bytes_to_hex(resp.r_hash)
payment_request = str(resp.payment_request) payment_request = str(resp.payment_request)
return InvoiceResponse(True, checking_id, payment_request, None) return InvoiceResponse(True, checking_id, payment_request, None)
@ -167,9 +186,9 @@ class LndWallet(Wallet):
try: try:
resp = await self.routerpc.SendPaymentV2(req).read() resp = await self.routerpc.SendPaymentV2(req).read()
except RpcError as exc: except RpcError as exc:
return PaymentResponse(False, "", 0, None, exc._details) return PaymentResponse(False, None, None, None, exc._details)
except Exception as exc: except Exception as exc:
return PaymentResponse(False, "", 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178 # PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
statuses = { statuses = {
@ -179,29 +198,31 @@ class LndWallet(Wallet):
3: False, # FAILED 3: False, # FAILED
} }
if resp.status in [0, 1, 3]: fee_msat = None
fee_msat = 0 preimage = None
preimage = "" checking_id = resp.payment_hash
checking_id = ""
elif resp.status == 2: # SUCCEEDED if resp.status: # SUCCEEDED
fee_msat = resp.htlcs[-1].route.total_fees_msat fee_msat = -resp.htlcs[-1].route.total_fees_msat
preimage = resp.payment_preimage preimage = bytes_to_hex(resp.payment_preimage)
checking_id = resp.payment_hash
return PaymentResponse( return PaymentResponse(
statuses[resp.status], checking_id, fee_msat, preimage, None statuses[resp.status], checking_id, fee_msat, preimage, None
) )
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:
r_hash = parse_checking_id(checking_id) r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32: if len(r_hash) != 32:
raise binascii.Error raise binascii.Error
except binascii.Error: except binascii.Error:
# this may happen if we switch between backend wallets # this may happen if we switch between backend wallets
# that use different checking_id formats # that use different checking_id formats
return PaymentStatus(None) return PaymentStatus(None)
try:
resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash))
except RpcError as exc:
return PaymentStatus(None)
if resp.settled: if resp.settled:
return PaymentStatus(True) return PaymentStatus(True)
@ -212,7 +233,7 @@ class LndWallet(Wallet):
This routine checks the payment status using routerpc.TrackPaymentV2. This routine checks the payment status using routerpc.TrackPaymentV2.
""" """
try: try:
r_hash = parse_checking_id(checking_id) r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32: if len(r_hash) != 32:
raise binascii.Error raise binascii.Error
except binascii.Error: except binascii.Error:
@ -220,11 +241,6 @@ class LndWallet(Wallet):
# that use different checking_id formats # that use different checking_id formats
return PaymentStatus(None) return PaymentStatus(None)
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
resp = self.routerpc.TrackPaymentV2( resp = self.routerpc.TrackPaymentV2(
router.TrackPaymentRequest(payment_hash=r_hash) router.TrackPaymentRequest(payment_hash=r_hash)
) )
@ -239,6 +255,12 @@ class LndWallet(Wallet):
try: try:
async for payment in resp: async for payment in resp:
if statuses[payment.htlcs[-1].status]:
return PaymentStatus(
True,
-payment.htlcs[-1].route.total_fees_msat,
bytes_to_hex(payment.htlcs[-1].preimage),
)
return PaymentStatus(statuses[payment.htlcs[-1].status]) return PaymentStatus(statuses[payment.htlcs[-1].status])
except: # most likely the payment wasn't found except: # most likely the payment wasn't found
return PaymentStatus(None) return PaymentStatus(None)
@ -247,13 +269,13 @@ class LndWallet(Wallet):
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True: while True:
request = ln.InvoiceSubscription()
try: try:
request = ln.InvoiceSubscription()
async for i in self.rpc.SubscribeInvoices(request): async for i in self.rpc.SubscribeInvoices(request):
if not i.settled: if not i.settled:
continue continue
checking_id = stringify_checking_id(i.r_hash) checking_id = bytes_to_hex(i.r_hash)
yield checking_id yield checking_id
except Exception as exc: except Exception as exc:
logger.error( logger.error(

View file

@ -73,11 +73,17 @@ class LndRestWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"value": amount, "private": True} data: Dict = {"value": amount, "private": True}
if description_hash: if description_hash:
data["description_hash"] = base64.b64encode(description_hash).decode(
"ascii"
)
elif unhashed_description:
data["description_hash"] = base64.b64encode( data["description_hash"] = base64.b64encode(
hashlib.sha256(description_hash).digest() hashlib.sha256(unhashed_description).digest()
).decode("ascii") ).decode("ascii")
else: else:
data["memo"] = memo or "" data["memo"] = memo or ""
@ -117,18 +123,15 @@ class LndRestWallet(Wallet):
if r.is_error or r.json().get("payment_error"): if r.is_error or r.json().get("payment_error"):
error_message = r.json().get("payment_error") or r.text error_message = r.json().get("payment_error") or r.text
return PaymentResponse(False, None, 0, None, error_message) return PaymentResponse(False, None, None, None, error_message)
data = r.json() data = r.json()
payment_hash = data["payment_hash"] checking_id = base64.b64decode(data["payment_hash"]).hex()
checking_id = payment_hash
fee_msat = int(data["payment_route"]["total_fees_msat"]) fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex() preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None) return PaymentResponse(True, checking_id, fee_msat, preimage, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_", "/")
async with httpx.AsyncClient(verify=self.cert) as client: async with httpx.AsyncClient(verify=self.cert) as client:
r = await client.get( r = await client.get(
url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth
@ -145,10 +148,18 @@ class LndRestWallet(Wallet):
""" """
This routine checks the payment status using routerpc.TrackPaymentV2. This routine checks the payment status using routerpc.TrackPaymentV2.
""" """
# convert checking_id from hex to base64 and some LND magic
try:
checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode(
"ascii"
)
except ValueError:
return PaymentStatus(None)
url = f"{self.endpoint}/v2/router/track/{checking_id}" url = f"{self.endpoint}/v2/router/track/{checking_id}"
# check payment.status: # check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype # https://api.lightning.community/?python=#paymentpaymentstatus
statuses = { statuses = {
"UNKNOWN": None, "UNKNOWN": None,
"IN_FLIGHT": None, "IN_FLIGHT": None,
@ -172,7 +183,11 @@ class LndRestWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
payment = line.get("result") payment = line.get("result")
if payment is not None and payment.get("status"): if payment is not None and payment.get("status"):
return PaymentStatus(statuses[payment["status"]]) return PaymentStatus(
paid=statuses[payment["status"]],
fee_msat=payment.get("fee_msat"),
preimage=payment.get("payment_preimage"),
)
else: else:
return PaymentStatus(None) return PaymentStatus(None)
except: except:
@ -181,10 +196,9 @@ class LndRestWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.endpoint + "/v1/invoices/subscribe"
while True: while True:
try: try:
url = self.endpoint + "/v1/invoices/subscribe"
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=None, headers=self.auth, verify=self.cert timeout=None, headers=self.auth, verify=self.cert
) as client: ) as client:

View file

@ -52,10 +52,14 @@ class LNPayWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"num_satoshis": f"{amount}"} data: Dict = {"num_satoshis": f"{amount}"}
if description_hash: if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest() data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else: else:
data["memo"] = memo or "" data["memo"] = memo or ""
@ -96,7 +100,7 @@ class LNPayWallet(Wallet):
) )
if r.is_error: if r.is_error:
return PaymentResponse(False, None, 0, None, data["message"]) return PaymentResponse(False, None, None, None, data["message"])
checking_id = data["lnTx"]["id"] checking_id = data["lnTx"]["id"]
fee_msat = 0 fee_msat = 0
@ -109,15 +113,18 @@ class LNPayWallet(Wallet):
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get( r = await client.get(
url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", url=f"{self.endpoint}/lntx/{checking_id}",
headers=self.auth, headers=self.auth,
) )
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()
preimage = data["payment_preimage"]
fee_msat = data["fee_msat"]
statuses = {0: None, 1: True, -1: False} statuses = {0: None, 1: True, -1: False}
return PaymentStatus(statuses[r.json()["settled"]]) return PaymentStatus(statuses[data["settled"]], fee_msat, preimage)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0) self.queue: asyncio.Queue = asyncio.Queue(0)

View file

@ -52,10 +52,14 @@ class LntxbotWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"amt": str(amount)} data: Dict = {"amt": str(amount)}
if description_hash: if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest() data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else: else:
data["memo"] = memo or "" data["memo"] = memo or ""
@ -93,10 +97,11 @@ class LntxbotWallet(Wallet):
except: except:
error_message = r.text error_message = r.text
pass pass
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(False, None, 0, None, error_message)
data = r.json() data = r.json()
if data.get("type") != "paid_invoice":
return PaymentResponse(None)
checking_id = data["payment_hash"] checking_id = data["payment_hash"]
fee_msat = -data["fee_msat"] fee_msat = -data["fee_msat"]
preimage = data["payment_preimage"] preimage = data["payment_preimage"]

View file

@ -47,15 +47,17 @@ class OpenNodeWallet(Wallet):
if r.is_error: if r.is_error:
return StatusResponse(data["message"], 0) return StatusResponse(data["message"], 0)
return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000) return StatusResponse(None, data["balance"]["BTC"] * 1000)
async def create_invoice( async def create_invoice(
self, self,
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
if description_hash: if description_hash or unhashed_description:
raise Unsupported("description_hash") raise Unsupported("description_hash")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -90,11 +92,15 @@ class OpenNodeWallet(Wallet):
if r.is_error: if r.is_error:
error_message = r.json()["message"] error_message = r.json()["message"]
return PaymentResponse(False, None, 0, None, error_message) return PaymentResponse(False, None, None, None, error_message)
data = r.json()["data"] data = r.json()["data"]
checking_id = data["id"] checking_id = data["id"]
fee_msat = data["fee"] * 1000 fee_msat = -data["fee"] * 1000
if data["status"] != "paid":
return PaymentResponse(None, checking_id, fee_msat, None, "payment failed")
return PaymentResponse(True, checking_id, fee_msat, None, None) return PaymentResponse(True, checking_id, fee_msat, None, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -104,9 +110,9 @@ class OpenNodeWallet(Wallet):
) )
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()["data"]
statuses = {"processing": None, "paid": True, "unpaid": False} statuses = {"processing": None, "paid": True, "unpaid": None}
return PaymentStatus(statuses[r.json()["data"]["status"]]) return PaymentStatus(statuses[data.get("status")])
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -117,14 +123,16 @@ class OpenNodeWallet(Wallet):
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()["data"]
statuses = { statuses = {
"initial": None, "initial": None,
"pending": None, "pending": None,
"confirmed": True, "confirmed": True,
"error": False, "error": None,
"failed": False, "failed": False,
} }
return PaymentStatus(statuses[r.json()["data"]["status"]]) fee_msat = -data.get("fee") * 1000
return PaymentStatus(statuses[data.get("status")], fee_msat)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0) self.queue: asyncio.Queue = asyncio.Queue(0)

View file

@ -93,6 +93,8 @@ class SparkWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
label = "lbs{}".format(random.random()) label = "lbs{}".format(random.random())
checking_id = label checking_id = label
@ -102,7 +104,13 @@ class SparkWallet(Wallet):
r = await self.invoicewithdescriptionhash( r = await self.invoicewithdescriptionhash(
msatoshi=amount * 1000, msatoshi=amount * 1000,
label=label, label=label,
description_hash=hashlib.sha256(description_hash).hexdigest(), description_hash=description_hash.hex(),
)
elif unhashed_description:
r = await self.invoicewithdescriptionhash(
msatoshi=amount * 1000,
label=label,
description_hash=hashlib.sha256(unhashed_description).hexdigest(),
) )
else: else:
r = await self.invoice( r = await self.invoice(
@ -129,7 +137,7 @@ class SparkWallet(Wallet):
pays = listpays["pays"] pays = listpays["pays"]
if len(pays) == 0: if len(pays) == 0:
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
pay = pays[0] pay = pays[0]
payment_hash = pay["payment_hash"] payment_hash = pay["payment_hash"]
@ -140,11 +148,9 @@ class SparkWallet(Wallet):
) )
if pay["status"] == "failed": if pay["status"] == "failed":
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
elif pay["status"] == "pending": elif pay["status"] == "pending":
return PaymentResponse( return PaymentResponse(None, payment_hash, None, None, None)
None, payment_hash, fee_limit_msat, None, None
)
elif pay["status"] == "complete": elif pay["status"] == "complete":
r = pay r = pay
r["payment_preimage"] = pay["preimage"] r["payment_preimage"] = pay["preimage"]
@ -155,7 +161,7 @@ class SparkWallet(Wallet):
# this is good # this is good
pass pass
fee_msat = r["msatoshi_sent"] - r["msatoshi"] fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
preimage = r["payment_preimage"] preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None) return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
@ -193,7 +199,10 @@ class SparkWallet(Wallet):
if r["pays"][0]["payment_hash"] == checking_id: if r["pays"][0]["payment_hash"] == checking_id:
status = r["pays"][0]["status"] status = r["pays"][0]["status"]
if status == "complete": if status == "complete":
return PaymentStatus(True) fee_msat = -int(
r["pays"][0]["amount_sent_msat"] - r["pays"][0]["amount_msat"]
)
return PaymentStatus(True, fee_msat, r["pays"][0]["preimage"])
elif status == "failed": elif status == "failed":
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)

View file

@ -18,6 +18,7 @@ class VoidWallet(Wallet):
amount: int, amount: int,
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
raise Unsupported("") raise Unsupported("")

View file

@ -11,8 +11,9 @@ from lnbits.core.views.api import (
api_payment, api_payment,
api_payments_create_invoice, api_payments_create_invoice,
) )
from lnbits.settings import wallet_class
from ...helpers import get_random_invoice_data from ...helpers import get_random_invoice_data, is_regtest
# check if the client is working # check if the client is working
@ -44,6 +45,20 @@ async def test_get_wallet_adminkey(client, adminkey_headers_to):
assert "id" in result assert "id" in result
# check PUT /api/v1/wallet/newwallet: empty request where admin key is needed
@pytest.mark.asyncio
async def test_put_empty_request_expected_admin_keys(client):
response = await client.put("/api/v1/wallet/newwallet")
assert response.status_code == 401
# check POST /api/v1/payments: empty request where invoice key is needed
@pytest.mark.asyncio
async def test_post_empty_request_expected_invoice_keys(client):
response = await client.post("/api/v1/payments")
assert response.status_code == 401
# check POST /api/v1/payments: invoice creation # check POST /api/v1/payments: invoice creation
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_invoice(client, inkey_headers_to): async def test_create_invoice(client, inkey_headers_to):
@ -147,6 +162,7 @@ async def test_pay_invoice_invoicekey(client, invoice, inkey_headers_from):
# check POST /api/v1/payments: payment with admin key [should pass] # check POST /api/v1/payments: payment with admin key [should pass]
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="this only works in fakewallet")
async def test_pay_invoice_adminkey(client, invoice, adminkey_headers_from): async def test_pay_invoice_adminkey(client, invoice, adminkey_headers_from):
data = {"out": True, "bolt11": invoice["payment_request"]} data = {"out": True, "bolt11": invoice["payment_request"]}
# try payment with admin key # try payment with admin key
@ -192,11 +208,32 @@ async def test_api_payment_with_key(invoice, inkey_headers_from):
# check POST /api/v1/payments: invoice creation with a description hash # check POST /api/v1/payments: invoice creation with a description hash
@pytest.mark.skipif(
wallet_class.__name__ in ["CoreLightningWallet"],
reason="wallet does not support description_hash",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_invoice_with_description_hash(client, inkey_headers_to): async def test_create_invoice_with_description_hash(client, inkey_headers_to):
data = await get_random_invoice_data() data = await get_random_invoice_data()
descr_hash = hashlib.sha256("asdasdasd".encode("utf-8")).hexdigest() descr_hash = hashlib.sha256("asdasdasd".encode("utf-8")).hexdigest()
data["description_hash"] = "asdasdasd".encode("utf-8").hex() data["description_hash"] = descr_hash
response = await client.post(
"/api/v1/payments", json=data, headers=inkey_headers_to
)
invoice = response.json()
invoice_bolt11 = bolt11.decode(invoice["payment_request"])
assert invoice_bolt11.description_hash == descr_hash
assert invoice_bolt11.description is None
return invoice
@pytest.mark.asyncio
async def test_create_invoice_with_unhashed_description(client, inkey_headers_to):
data = await get_random_invoice_data()
descr_hash = hashlib.sha256("asdasdasd".encode("utf-8")).hexdigest()
data["unhashed_description"] = "asdasdasd".encode("utf-8").hex()
response = await client.post( response = await client.post(
"/api/v1/payments", json=data, headers=inkey_headers_to "/api/v1/payments", json=data, headers=inkey_headers_to

Binary file not shown.

View file

@ -12,7 +12,7 @@ from lnbits.extensions.bleskomat.helpers import (
from lnbits.settings import HOST, PORT from lnbits.settings import HOST, PORT
from tests.conftest import client from tests.conftest import client
from tests.extensions.bleskomat.conftest import bleskomat, lnurl from tests.extensions.bleskomat.conftest import bleskomat, lnurl
from tests.helpers import credit_wallet from tests.helpers import credit_wallet, is_regtest
from tests.mocks import WALLET from tests.mocks import WALLET
@ -97,6 +97,7 @@ async def test_bleskomat_lnurl_api_valid_signature(client, bleskomat):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="this test is only passes in fakewallet")
async def test_bleskomat_lnurl_api_action_insufficient_balance(client, lnurl): async def test_bleskomat_lnurl_api_action_insufficient_balance(client, lnurl):
bleskomat = lnurl["bleskomat"] bleskomat = lnurl["bleskomat"]
secret = lnurl["secret"] secret = lnurl["secret"]
@ -116,6 +117,7 @@ async def test_bleskomat_lnurl_api_action_insufficient_balance(client, lnurl):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="this test is only passes in fakewallet")
async def test_bleskomat_lnurl_api_action_success(client, lnurl): async def test_bleskomat_lnurl_api_action_success(client, lnurl):
bleskomat = lnurl["bleskomat"] bleskomat = lnurl["bleskomat"]
secret = lnurl["secret"] secret = lnurl["secret"]

View file

View file

@ -0,0 +1,25 @@
import asyncio
import json
import secrets
import pytest
import pytest_asyncio
from lnbits.core.crud import create_account, create_wallet, get_wallet
from lnbits.extensions.boltz.boltz import create_reverse_swap, create_swap
from lnbits.extensions.boltz.models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
)
from tests.mocks import WALLET
@pytest_asyncio.fixture(scope="session")
async def reverse_swap(from_wallet):
data = CreateReverseSubmarineSwap(
wallet=from_wallet.id,
instant_settlement=True,
onchain_address="bcrt1q4vfyszl4p8cuvqh07fyhtxve5fxq8e2ux5gx43",
amount=20_000,
)
return await create_reverse_swap(data)

View file

@ -0,0 +1,146 @@
import pytest
import pytest_asyncio
from tests.helpers import is_fake, is_regtest
@pytest.mark.asyncio
async def test_mempool_url(client):
response = await client.get("/boltz/api/v1/swap/mempool")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_boltz_config(client):
response = await client.get("/boltz/api/v1/swap/boltz")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_endpoints_unauthenticated(client):
response = await client.get("/boltz/api/v1/swap?all_wallets=true")
assert response.status_code == 401
response = await client.get("/boltz/api/v1/swap/reverse?all_wallets=true")
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap")
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/reverse")
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/status")
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/check")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_endpoints_inkey(client, inkey_headers_to):
response = await client.get(
"/boltz/api/v1/swap?all_wallets=true", headers=inkey_headers_to
)
assert response.status_code == 200
response = await client.get(
"/boltz/api/v1/swap/reverse?all_wallets=true", headers=inkey_headers_to
)
assert response.status_code == 200
response = await client.post("/boltz/api/v1/swap", headers=inkey_headers_to)
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/reverse", headers=inkey_headers_to)
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/refund", headers=inkey_headers_to)
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/status", headers=inkey_headers_to)
assert response.status_code == 401
response = await client.post("/boltz/api/v1/swap/check", headers=inkey_headers_to)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_endpoints_adminkey_nocontent(client, adminkey_headers_to):
response = await client.post("/boltz/api/v1/swap", headers=adminkey_headers_to)
assert response.status_code == 204
response = await client.post(
"/boltz/api/v1/swap/reverse", headers=adminkey_headers_to
)
assert response.status_code == 204
response = await client.post(
"/boltz/api/v1/swap/refund", headers=adminkey_headers_to
)
assert response.status_code == 204
response = await client.post(
"/boltz/api/v1/swap/status", headers=adminkey_headers_to
)
assert response.status_code == 204
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="this test is only passes with fakewallet")
async def test_endpoints_adminkey_fakewallet(client, from_wallet, adminkey_headers_to):
response = await client.post(
"/boltz/api/v1/swap/check", headers=adminkey_headers_to
)
assert response.status_code == 200
swap = {
"wallet": from_wallet.id,
"refund_address": "bcrt1q3cwq33y435h52gq3qqsdtczh38ltlnf69zvypm",
"amount": 50_000,
}
response = await client.post(
"/boltz/api/v1/swap", json=swap, headers=adminkey_headers_to
)
assert response.status_code == 405
reverse_swap = {
"wallet": from_wallet.id,
"instant_settlement": True,
"onchain_address": "bcrt1q4vfyszl4p8cuvqh07fyhtxve5fxq8e2ux5gx43",
"amount": 50_000,
}
response = await client.post(
"/boltz/api/v1/swap/reverse", json=reverse_swap, headers=adminkey_headers_to
)
assert response.status_code == 201
reverse_swap = response.json()
assert reverse_swap["id"] is not None
response = await client.post(
"/boltz/api/v1/swap/status",
params={"swap_id": reverse_swap["id"]},
headers=adminkey_headers_to,
)
assert response.status_code == 200
response = await client.post(
"/boltz/api/v1/swap/status",
params={"swap_id": "wrong"},
headers=adminkey_headers_to,
)
assert response.status_code == 404
response = await client.post(
"/boltz/api/v1/swap/refund",
params={"swap_id": "wrong"},
headers=adminkey_headers_to,
)
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="this test is only passes with regtest")
async def test_endpoints_adminkey_regtest(client, from_wallet, adminkey_headers_to):
swap = {
"wallet": from_wallet.id,
"refund_address": "bcrt1q3cwq33y435h52gq3qqsdtczh38ltlnf69zvypm",
"amount": 50_000,
}
response = await client.post(
"/boltz/api/v1/swap", json=swap, headers=adminkey_headers_to
)
assert response.status_code == 201
reverse_swap = {
"wallet": from_wallet.id,
"instant_settlement": True,
"onchain_address": "bcrt1q4vfyszl4p8cuvqh07fyhtxve5fxq8e2ux5gx43",
"amount": 50_000,
}
response = await client.post(
"/boltz/api/v1/swap/reverse", json=reverse_swap, headers=adminkey_headers_to
)
assert response.status_code == 201

View file

@ -0,0 +1,31 @@
import asyncio
import pytest
import pytest_asyncio
from lnbits.extensions.boltz.boltz import create_reverse_swap, create_swap
from lnbits.extensions.boltz.crud import (
create_reverse_submarine_swap,
create_submarine_swap,
get_reverse_submarine_swap,
get_submarine_swap,
)
from tests.extensions.boltz.conftest import reverse_swap
from tests.helpers import is_fake, is_regtest
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="this test is only passes in regtest")
async def test_create_reverse_swap(client, reverse_swap):
swap, wait_for_onchain = reverse_swap
assert swap.status == "pending"
assert swap.id is not None
assert swap.boltz_id is not None
assert swap.claim_privkey is not None
assert swap.onchain_address is not None
assert swap.lockup_address is not None
newswap = await create_reverse_submarine_swap(swap)
await wait_for_onchain
newswap = await get_reverse_submarine_swap(swap.id)
assert newswap is not None
assert newswap.status == "complete"

View file

Some files were not shown because too many files have changed in this diff Show more