diff --git a/.env.example b/.env.example index ec980775..97105bc3 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ LNBITS_DATA_FOLDER="./data" LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" +LNBITS_RESERVE_FEE_MIN=2000 # value in millisats +LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent # Change theme LNBITS_SITE_TITLE="LNbits" diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 8e72cf62..90006d2a 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -9,9 +9,9 @@ jobs: postgres: image: postgres:latest env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres + POSTGRES_USER: lnbits + POSTGRES_PASSWORD: lnbits + POSTGRES_DB: migration ports: # maps tcp port 5432 on service container to the host - 5432:5432 @@ -36,11 +36,4 @@ jobs: sudo apt install unzip - name: Run migrations run: | - rm -rf ./data - 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 + make test-migration diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index fcd3c164..250a66c7 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -17,6 +17,7 @@ jobs: - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | + docker build -t lnbitsdocker/lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker chmod +x ./tests @@ -40,7 +41,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: - file: ./coverage.xml + file: ./coverage.xml LndWallet: runs-on: ubuntu-latest strategy: @@ -55,6 +56,7 @@ jobs: - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | + docker build -t lnbitsdocker/lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker chmod +x ./tests @@ -76,11 +78,11 @@ jobs: LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon run: | sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data - make test-real-wallet + make test-real-wallet - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: - file: ./coverage.xml + file: ./coverage.xml CoreLightningWallet: runs-on: ubuntu-latest strategy: @@ -95,6 +97,7 @@ jobs: - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | + docker build -t lnbitsdocker/lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker chmod +x ./tests diff --git a/Makefile b/Makefile index d91d0421..6b2fdeb7 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ all: format check requirements.txt format: prettier isort black -check: mypy checkprettier checkisort checkblack +check: mypy checkprettier checkisort checkblack prettier: $(shell find lnbits -name "*.js" -name ".html") ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html @@ -32,11 +32,17 @@ test: FAKE_WALLET_SECRET="ToTheMoon1" \ LNBITS_DATA_FOLDER="./tests/data" \ PYTHONUNBUFFERED=1 \ + DEBUG=true \ poetry run pytest 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" \ PYTHONUNBUFFERED=1 \ + DEBUG=true \ poetry run pytest test-venv: @@ -44,7 +50,27 @@ test-venv: FAKE_WALLET_SECRET="ToTheMoon1" \ LNBITS_DATA_FOLDER="./tests/data" \ PYTHONUNBUFFERED=1 \ + DEBUG=true \ ./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: # LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres diff --git a/docs/devs/extensions.md b/docs/devs/extensions.md index 0ceb9cb3..cd81a021 100644 --- a/docs/devs/extensions.md +++ b/docs/devs/extensions.md @@ -48,4 +48,25 @@ LNbits currently supports SQLite and PostgreSQL databases. There is a migration ### 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. \ No newline at end of file +`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;'" +``` diff --git a/docs/guide/installation.md b/docs/guide/installation.md index d9b8d5d2..abe3cc59 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -20,7 +20,7 @@ cd lnbits-legend/ sudo apt update sudo apt install software-properties-common 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 - 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 ``` +## 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=`. + +``` +... +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 Problems installing? These commands have helped us install LNbits. @@ -170,8 +244,9 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits" # START LNbits # STOP LNbits -# on the LNBits folder, locate and edit 'tools/conv.py' with the relevant credentials -python3 tools/conv.py +poetry run python tools/conv.py +# or +make migration ``` Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly. @@ -194,15 +269,14 @@ Description=LNbits [Service] # replace with the absolute path of your lnbits installation -WorkingDirectory=/home/bitcoin/lnbits -# same here -ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 +WorkingDirectory=/home/lnbits/lnbits-legend +# same here. run `which poetry` if you can't find the poetry binary +ExecStart=/home/lnbits/.local/bin/poetry run lnbits # replace with the user that you're running lnbits on -User=bitcoin +User=lnbits Restart=always TimeoutSec=120 RestartSec=30 -# this makes sure that you receive logs in real time Environment=PYTHONUNBUFFERED=1 [Install] @@ -216,6 +290,47 @@ sudo systemctl enable 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 < /etc/apache2/sites-enabled/lnbits.conf + + 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/ + + Order deny,allow + Allow from all + + +EOF +``` +restart apache2 +```sh +service restart apache2 +``` + + ## 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. @@ -236,7 +351,7 @@ To create a certificate, first `cd` into your LNbits folder and execute the foll ```sh openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem ``` -This will create two new files (`key.pem` and `cert.pem `). +This will create two new files (`key.pem` and `cert.pem `). Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)): ```sh diff --git a/lnbits/app.py b/lnbits/app.py index e1594a7b..f612c32c 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,6 +1,7 @@ import asyncio import importlib import logging +import signal import sys import traceback import warnings @@ -75,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI: # Only the browser sends "text/html" request # 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( "error.html", {"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: @app.on_event("startup") 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: - error_message, balance = await WALLET.status() - if not error_message: - break - logger.error( - f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", - RuntimeWarning, - ) + try: + error_message, balance = await WALLET.status() + if not error_message: + break + logger.error( + 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...") await asyncio.sleep(5) + signal.signal(signal.SIGINT, original_sigint_handler) logger.info( 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) 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( "error.html", {"request": request, "err": err} ) diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 67039740..32b43feb 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -216,7 +216,7 @@ def lnencode(addr, privkey): expirybits = expirybits[5:] data += tagged("x", expirybits) elif k == "h": - data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest()) + data += tagged_bytes("h", v) elif k == "n": data += tagged_bytes("n", v) else: diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f150270a..cba41f60 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -365,6 +365,11 @@ async def create_payment( webhook: Optional[str] = None, conn: Optional[Connection] = None, ) -> 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( """ 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: await (conn or db).execute( "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index c019d941..4dc15bbc 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -11,6 +11,7 @@ from pydantic import BaseModel from lnbits.helpers import url_for from lnbits.settings import WALLET +from lnbits.wallets.base import PaymentStatus class Wallet(BaseModel): @@ -128,8 +129,16 @@ class Payment(BaseModel): @property def is_uncheckable(self) -> bool: - return self.checking_id.startswith("temp_") or self.checking_id.startswith( - "internal_" + return self.checking_id.startswith("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: @@ -137,9 +146,9 @@ class Payment(BaseModel): await update_payment_status(self.checking_id, pending) - async def check_pending(self) -> None: + async def check_status(self) -> PaymentStatus: if self.is_uncheckable: - return + return PaymentStatus(None) logger.debug( 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}") if self.is_out and status.failed: - logger.info( + logger.warning( f"Deleting outgoing failed payment {self.checking_id}: {status}" ) await self.delete() @@ -161,7 +170,8 @@ class Payment(BaseModel): logger.info( 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: from .crud import delete_payment diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 678c89e3..a6e0b43a 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -21,7 +21,7 @@ from lnbits.decorators import ( ) from lnbits.helpers import url_for, urlsafe_short_hash 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 . import db @@ -31,8 +31,10 @@ from .crud import ( delete_payment, get_wallet, get_wallet_payment, + update_payment_details, update_payment_status, ) +from .models import Payment try: from typing import TypedDict # type: ignore @@ -54,6 +56,7 @@ async def create_invoice( amount: int, # in satoshis memo: str, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, extra: Optional[Dict] = None, webhook: Optional[str] = None, internal: Optional[bool] = False, @@ -65,7 +68,10 @@ async def create_invoice( wallet = FAKE_WALLET if internal else WALLET 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: raise InvoiceFailure(error_message or "unexpected backend error.") @@ -97,11 +103,20 @@ async def pay_invoice( description: str = "", conn: Optional[Connection] = None, ) -> 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) fee_reserve_msat = fee_reserve(invoice.amount_msat) async with (db.reuse_conn(conn) if conn else db.connect()) as conn: - temp_id = f"temp_{urlsafe_short_hash()}" - internal_id = f"internal_{urlsafe_short_hash()}" + temp_id = invoice.payment_hash + internal_id = f"internal_{invoice.payment_hash}" if invoice.amount_msat == 0: raise ValueError("Amountless invoices not supported.") @@ -156,7 +171,7 @@ async def pay_invoice( logger.debug("balance is too low, deleting temporary payment") if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat: 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.") @@ -181,30 +196,41 @@ async def pay_invoice( payment: PaymentResponse = await WALLET.pay_invoice( 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}") - if payment.ok and payment.checking_id: - logger.debug(f"creating final payment {payment.checking_id}") + if payment.checking_id and payment.ok != False: + # payment.ok can be True (paid) or None (pending)! + logger.debug(f"updating payment {temp_id}") async with db.connect() as conn: - await create_payment( - checking_id=payment.checking_id, + await update_payment_details( + checking_id=temp_id, + pending=payment.ok != True, fee=payment.fee_msat, preimage=payment.preimage, - pending=payment.ok == None, + new_checking_id=payment.checking_id, conn=conn, - **payment_kwargs, ) - logger.debug(f"deleting temporary payment {temp_id}") - await delete_payment(temp_id, conn=conn) - else: - logger.debug(f"backend payment failed") + logger.debug(f"payment successful {payment.checking_id}") + elif payment.checking_id is None and payment.ok == False: + # payment failed + logger.warning(f"backend sent payment failure") async with db.connect() as conn: logger.debug(f"deleting temporary payment {temp_id}") await delete_payment(temp_id, conn=conn) raise PaymentFailure( - payment.error_message - or "Payment failed, but backend didn't give us an error message." + f"payment failed: {payment.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 @@ -340,26 +366,19 @@ async def perform_lnurlauth( async def check_transaction_status( wallet_id: str, payment_hash: str, conn: Optional[Connection] = None ) -> 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: 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: - return status - if payment.is_out and status.failed: - logger.info(f"deleting outgoing failed payment {payment.checking_id}: {status}") - await payment.delete() - 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) + # note: before, we still checked the status of the payment again + return PaymentStatus(True) + + status: PaymentStatus = await payment.check_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 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)) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index baa9f605..1c417eaf 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -232,6 +232,9 @@ new Vue({ generateChart(this.$refs.canvas, this.payments) }) }, + focusInput(el) { + this.$nextTick(() => this.$refs[el].focus()) + }, showReceiveDialog: function () { this.receive.show = true this.receive.status = 'pending' @@ -243,6 +246,7 @@ new Vue({ this.receive.paymentChecker = null this.receive.minMax = [0, 2100000000000000] this.receive.lnurl = null + this.focusInput('setAmount') }, showParseDialog: function () { this.parse.show = true @@ -668,7 +672,17 @@ new Vue({ }) }, 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: { diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 78f33f3a..bd2668d1 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -428,6 +428,7 @@ :options="receive.units" > 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() diff --git a/lnbits/extensions/boltz/config.json b/lnbits/extensions/boltz/config.json new file mode 100644 index 00000000..6a11da2d --- /dev/null +++ b/lnbits/extensions/boltz/config.json @@ -0,0 +1,6 @@ +{ + "name": "Boltz", + "short_description": "Perform onchain/offchain swaps via https://boltz.exchange/", + "icon": "swap_horiz", + "contributors": ["dni"] +} diff --git a/lnbits/extensions/boltz/crud.py b/lnbits/extensions/boltz/crud.py new file mode 100644 index 00000000..1bb4286d --- /dev/null +++ b/lnbits/extensions/boltz/crud.py @@ -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 diff --git a/lnbits/extensions/boltz/mempool.py b/lnbits/extensions/boltz/mempool.py new file mode 100644 index 00000000..ee305257 --- /dev/null +++ b/lnbits/extensions/boltz/mempool.py @@ -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, + ) diff --git a/lnbits/extensions/boltz/migrations.py b/lnbits/extensions/boltz/migrations.py new file mode 100644 index 00000000..e4026dd0 --- /dev/null +++ b/lnbits/extensions/boltz/migrations.py @@ -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 + + """ + ); + """ + ) diff --git a/lnbits/extensions/boltz/models.py b/lnbits/extensions/boltz/models.py new file mode 100644 index 00000000..c8ec5646 --- /dev/null +++ b/lnbits/extensions/boltz/models.py @@ -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 diff --git a/lnbits/extensions/boltz/tasks.py b/lnbits/extensions/boltz/tasks.py new file mode 100644 index 00000000..d6f72edf --- /dev/null +++ b/lnbits/extensions/boltz/tasks.py @@ -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") diff --git a/lnbits/extensions/boltz/templates/boltz/_api_docs.html b/lnbits/extensions/boltz/templates/boltz/_api_docs.html new file mode 100644 index 00000000..eea35ab6 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_api_docs.html @@ -0,0 +1,236 @@ + + + + + +
+ Boltz.exchange: Do onchain to offchain and vice-versa swaps +
+

+ Submarine and Reverse Submarine Swaps on LNbits via boltz.exchange + API
+

+

+ Link : + https://boltz.exchange + +

+

+ More details +

+

+ Created by, + dni +

+
+
+
+ + + + + GET + /boltz/api/v1/swap/reverse +
+ Returns 200 OK (application/json) +
+ JSON list of reverse submarine swaps +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + POST + /boltz/api/v1/swap/reverse +
Body (application/json)
+ {"wallet": <string>, "onchain_address": <string>, + "amount": <integer>, "instant_settlement": + <boolean>} +
+ Returns 200 OK (application/json) +
+ JSON create a reverse-submarine swaps +
Curl example
+ curl -X POST {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + GET /boltz/api/v1/swap +
+ Returns 200 OK (application/json) +
+ JSON list of submarine swaps +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + POST /boltz/api/v1/swap +
Body (application/json)
+ {"wallet": <string>, "refund_address": <string>, + "amount": <integer>} +
+ Returns 200 OK (application/json) +
+ JSON create a submarine swaps +
Curl example
+ curl -X POST {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + POST + /boltz/api/v1/swap/refund/{swap_id} +
+ Returns 200 OK (application/json) +
+ JSON submarine swap +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap/refund/{swap_id} -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + POST + /boltz/api/v1/swap/status/{swap_id} +
+ Returns 200 OK (text/plain) +
+ swap status +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap/status/{swap_id} -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /boltz/api/v1/swap/check +
+ Returns 200 OK (application/json) +
+ JSON pending swaps +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap/check -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /boltz/api/v1/swap/boltz +
+ Returns 200 OK (text/plain) +
+ JSON boltz config +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap/boltz -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /boltz/api/v1/swap/mempool +
+ Returns 200 OK (text/plain) +
+ mempool url +
Curl example
+ curl -X GET {{ root_url }}/boltz/api/v1/swap/mempool -H "X-Api-Key: + {{ user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/index.html b/lnbits/extensions/boltz/templates/boltz/index.html new file mode 100644 index 00000000..b7312de7 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/index.html @@ -0,0 +1,1005 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + + + Send onchain funds offchain (BTC -> LN) + + + + + Send offchain funds to onchain address (LN -> BTC) + + + + + Check all pending swaps if they can be refunded. + + + + + + +
+
+
Swaps (In)
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + +
+
+
Reverse Swaps (Out)
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
{{SITE_TITLE}} Boltz extension
+
+ + + {% include "boltz/_api_docs.html" %} + +
+
+ + + + + + + + +
+ + + Cancel +
+
+
+
+ + + + + + + +
+
+ + + Create Onchain TX when transaction is in mempool, but not + confirmed yet. + + +
+
+ +
+ + + Cancel +
+
+
+
+ + + + + +
+ {% raw %} + Bitcoin On-Chain TX
+ Expected amount (sats): {{ qrCodeDialog.data.expected_amount }} +
+ Expected amount (btc): {{ qrCodeDialog.data.expected_amount_btc + }}
+ Onchain Address: {{ qrCodeDialog.data.address }}
+ {% endraw %} +
+
+ Copy On-Chain Address + Close +
+
+
+ + +
+ {% raw %} + Wallet: {{ statusDialog.data.wallet }}
+ Boltz Status: {{ statusDialog.data.boltz }}
+ Mempool Status: {{ statusDialog.data.mempool }}
+ Blockheight timeout: {{ statusDialog.data.timeout_block_height + }}
+ {% endraw %} +
+
+ Refund + + Download refundfile + Close +
+
+
+ + +
pending swaps
+ + {% raw %} + + + {% endraw %} + +
pending reverse swaps
+ + {% raw %} + + + {% endraw %} + +
+ Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/boltz/utils.py b/lnbits/extensions/boltz/utils.py new file mode 100644 index 00000000..4fb2edda --- /dev/null +++ b/lnbits/extensions/boltz/utils.py @@ -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 diff --git a/lnbits/extensions/boltz/views.py b/lnbits/extensions/boltz/views.py new file mode 100644 index 00000000..b6864113 --- /dev/null +++ b/lnbits/extensions/boltz/views.py @@ -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}, + ) diff --git a/lnbits/extensions/boltz/views_api.py b/lnbits/extensions/boltz/views_api.py new file mode 100644 index 00000000..a4b7d318 --- /dev/null +++ b/lnbits/extensions/boltz/views_api.py @@ -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"] diff --git a/lnbits/extensions/copilot/lnurl.py b/lnbits/extensions/copilot/lnurl.py index 2fc3e176..fa199691 100644 --- a/lnbits/extensions/copilot/lnurl.py +++ b/lnbits/extensions/copilot/lnurl.py @@ -73,7 +73,7 @@ async def lnurl_callback( wallet_id=cp.wallet, amount=int(amount_received / 1000), memo=cp.lnurl_title, - description_hash=( + unhashed_description=( LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])) ).encode("utf-8"), extra={"tag": "copilot", "copilotid": cp.id, "comment": comment}, diff --git a/lnbits/extensions/invoices/README.md b/lnbits/extensions/invoices/README.md new file mode 100644 index 00000000..2b5bd538 --- /dev/null +++ b/lnbits/extensions/invoices/README.md @@ -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) \ No newline at end of file diff --git a/lnbits/extensions/invoices/__init__.py b/lnbits/extensions/invoices/__init__.py new file mode 100644 index 00000000..0b60837b --- /dev/null +++ b/lnbits/extensions/invoices/__init__.py @@ -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 diff --git a/lnbits/extensions/invoices/config.json b/lnbits/extensions/invoices/config.json new file mode 100644 index 00000000..0811e0ef --- /dev/null +++ b/lnbits/extensions/invoices/config.json @@ -0,0 +1,6 @@ +{ + "name": "Invoices", + "short_description": "Create invoices for your clients.", + "icon": "request_quote", + "contributors": ["leesalminen"] +} diff --git a/lnbits/extensions/invoices/crud.py b/lnbits/extensions/invoices/crud.py new file mode 100644 index 00000000..4fd055e9 --- /dev/null +++ b/lnbits/extensions/invoices/crud.py @@ -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 diff --git a/lnbits/extensions/invoices/migrations.py b/lnbits/extensions/invoices/migrations.py new file mode 100644 index 00000000..c47a954a --- /dev/null +++ b/lnbits/extensions/invoices/migrations.py @@ -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) + ); + """ + ) diff --git a/lnbits/extensions/invoices/models.py b/lnbits/extensions/invoices/models.py new file mode 100644 index 00000000..adf03e46 --- /dev/null +++ b/lnbits/extensions/invoices/models.py @@ -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 diff --git a/lnbits/extensions/invoices/static/css/pay.css b/lnbits/extensions/invoices/static/css/pay.css new file mode 100644 index 00000000..ad7ce914 --- /dev/null +++ b/lnbits/extensions/invoices/static/css/pay.css @@ -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; + } +} \ No newline at end of file diff --git a/lnbits/extensions/invoices/tasks.py b/lnbits/extensions/invoices/tasks.py new file mode 100644 index 00000000..61bcb7b4 --- /dev/null +++ b/lnbits/extensions/invoices/tasks.py @@ -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 diff --git a/lnbits/extensions/invoices/templates/invoices/_api_docs.html b/lnbits/extensions/invoices/templates/invoices/_api_docs.html new file mode 100644 index 00000000..6e2a6355 --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/_api_docs.html @@ -0,0 +1,153 @@ + + + + + GET /invoices/api/v1/invoices +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<invoice_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST /invoices/api/v1/invoice +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id}/payments +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {payment_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H + "X-Api-Key: <invoice_key>" + +
+
+
+
diff --git a/lnbits/extensions/invoices/templates/invoices/index.html b/lnbits/extensions/invoices/templates/invoices/index.html new file mode 100644 index 00000000..e3093e3c --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/index.html @@ -0,0 +1,571 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Invoice + + + + + +
+
+
Invoices
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Invoices extension +
+
+ + + {% include "invoices/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add Line Item + + + + +
+ Create Invoice + Save Invoice + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/invoices/templates/invoices/pay.html b/lnbits/extensions/invoices/templates/invoices/pay.html new file mode 100644 index 00000000..7b6452dc --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/pay.html @@ -0,0 +1,430 @@ +{% extends "public.html" %} {% block toolbar_title %} Invoice + + +{% endblock %} {% from "macros.jinja" import window_vars with context %} {% +block page %} + +
+
+
+ + +

+ Invoice +

+ + + + ID + {{ invoice_id }} + + + + Created At + {{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d + %H:%M') }} + + + + Status + + + {{ invoice.status }} + + + + + + Total + + {{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency + }} + + + + + Paid + +
+
+ {{ "{:0,.2f}".format(payments_total / 100) }} {{ + invoice.currency }} +
+
+ {% if payments_total < invoice_total %} + + Pay Invoice + + {% endif %} +
+
+
+
+
+
+
+
+ +
+ + +

+ Bill To +

+ + + + Company Name + {{ invoice.company_name }} + + + + Name + {{ invoice.first_name }} {{ invoice.last_name + }} + + + + Address + {{ invoice.address }} + + + + Email + {{ invoice.email }} + + + + Phone + {{ invoice.phone }} + + +
+
+
+
+ +
+ +
+
+ + +

+ Items +

+ + + {% if invoice_items %} + + Item + Amount + + {% endif %} {% for item in invoice_items %} + + {{item.description}} + + {{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency + }} + + + {% endfor %} {% if not invoice_items %} No Invoice Items {% endif %} + +
+
+
+
+ +
+ +
+
+ + +

+ Payments +

+ + + {% if invoice_payments %} + + Date + Amount + + {% endif %} {% for item in invoice_payments %} + + {{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d + %H:%M') }} + + {{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency + }} + + + {% endfor %} {% if not invoice_payments %} No Invoice Payments {% + endif %} + +
+
+
+
+ +
+ +
+
+
+

Scan to View & Pay Online!

+ +
+
+
+ + + + + + + + +
+ Create Payment + Cancel +
+
+
+
+ + + + + + + + +
+ Copy Invoice +
+
+ + + + + + +
+

{{ request.url }}

+
+
+ Copy URL + Close +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/invoices/views.py b/lnbits/extensions/invoices/views.py new file mode 100644 index 00000000..08223df8 --- /dev/null +++ b/lnbits/extensions/invoices/views.py @@ -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, + }, + ) diff --git a/lnbits/extensions/invoices/views_api.py b/lnbits/extensions/invoices/views_api.py new file mode 100644 index 00000000..23a262e3 --- /dev/null +++ b/lnbits/extensions/invoices/views_api.py @@ -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 diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py index 72cc1dbb..349bb280 100644 --- a/lnbits/extensions/livestream/lnurl.py +++ b/lnbits/extensions/livestream/lnurl.py @@ -90,7 +90,7 @@ async def lnurl_callback( wallet_id=ls.wallet, amount=int(amount_received / 1000), 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}, ) diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html b/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html index 49155f27..12173c95 100644 --- a/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html +++ b/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html @@ -13,7 +13,7 @@ Charge people for using your domain name...
More details
diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py index a3160fa9..91371f9a 100644 --- a/lnbits/extensions/lndhub/views_api.py +++ b/lnbits/extensions/lndhub/views_api.py @@ -130,9 +130,8 @@ async def lndhub_gettxs( offset=offset, exclude_uncheckable=True, ): - await payment.set_pending( - (await WALLET.get_payment_status(payment.checking_id)).pending - ) + await payment.check_status() + await asyncio.sleep(0.1) return [ diff --git a/lnbits/extensions/lnurldevice/lnurl.py b/lnbits/extensions/lnurldevice/lnurl.py index d8bd2e01..df0cd4b8 100644 --- a/lnbits/extensions/lnurldevice/lnurl.py +++ b/lnbits/extensions/lnurldevice/lnurl.py @@ -205,7 +205,7 @@ async def lnurl_callback( wallet_id=device.wallet, amount=lnurldevicepayment.sats / 1000, memo=device.title, - description_hash=(await device.lnurlpay_metadata()).encode("utf-8"), + unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"), extra={"tag": "PoS"}, ) lnurldevicepayment = await update_lnurldevicepayment( diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py index 55f6807d..dea8037d 100644 --- a/lnbits/extensions/lnurlp/lnurl.py +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -87,7 +87,7 @@ async def api_lnurl_callback(request: Request, link_id): wallet_id=link.wallet, amount=int(amount_received / 1000), memo=link.description, - description_hash=link.lnurlpay_metadata.encode("utf-8"), + unhashed_description=link.lnurlpay_metadata.encode("utf-8"), extra={ "tag": "lnurlp", "link": link.id, diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index 9677a027..de90f5af 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -296,16 +296,17 @@ Shareable link + >Copy sharable link + + >Write to NFC + >Print Close diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index 97e8fd4c..6a8a724b 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -96,7 +96,7 @@ async def api_link_create_or_update( data.min *= 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( detail="Success URL must be secure https://...", status_code=HTTPStatus.BAD_REQUEST, @@ -121,7 +121,7 @@ async def api_link_create_or_update( 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)): 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) - raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + return {"success": True} @lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 5a2a0bcd..f50df99a 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -73,7 +73,7 @@ async def lnurl_callback(request: Request, item_id: int): wallet_id=shop.wallet, amount=int(amount_received / 1000), 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}, ) except Exception as exc: diff --git a/lnbits/extensions/satsdice/lnurl.py b/lnbits/extensions/satsdice/lnurl.py index 73c5e554..caafc3a4 100644 --- a/lnbits/extensions/satsdice/lnurl.py +++ b/lnbits/extensions/satsdice/lnurl.py @@ -77,7 +77,7 @@ async def api_lnurlp_callback( wallet_id=link.wallet, amount=int(amount_received / 1000), 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"}, ) diff --git a/lnbits/extensions/satsdice/templates/satsdice/index.html b/lnbits/extensions/satsdice/templates/satsdice/index.html index 004e9ccb..654069d8 100644 --- a/lnbits/extensions/satsdice/templates/satsdice/index.html +++ b/lnbits/extensions/satsdice/templates/satsdice/index.html @@ -232,7 +232,7 @@ Copy shareable link diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html index db3b2477..8b3a4c4d 100644 --- a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html +++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html @@ -13,7 +13,7 @@ Charge people for using your subdomain name...
More details
diff --git a/lnbits/extensions/tipjar/crud.py b/lnbits/extensions/tipjar/crud.py index 29e1a469..046b9491 100644 --- a/lnbits/extensions/tipjar/crud.py +++ b/lnbits/extensions/tipjar/crud.py @@ -76,10 +76,10 @@ async def get_tipjars(wallet_id: str) -> Optional[list]: async def delete_tipjar(tipjar_id: int) -> None: """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,)) for row in rows: 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]: diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py index 36bca79b..f6522add 100644 --- a/lnbits/extensions/tpos/models.py +++ b/lnbits/extensions/tpos/models.py @@ -23,3 +23,7 @@ class TPoS(BaseModel): @classmethod def from_row(cls, row: Row) -> "TPoS": return cls(**dict(row)) + + +class PayLnurlWData(BaseModel): + lnurl: str diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index ebc6595e..98334e55 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -14,7 +14,7 @@

{% raw %}{{ famount }}{% endraw %}

-
+
{% raw %}{{ fsat }}{% endraw %} sat
@@ -174,8 +174,21 @@ > {% endraw %} +
+ Copy invoice Close
@@ -281,6 +294,7 @@ exchangeRate: null, stack: [], tipAmount: 0.0, + nfcTagReading: false, invoiceDialog: { show: false, data: null, @@ -356,7 +370,7 @@ this.showInvoice() }, submitForm: function () { - if (this.tip_options) { + if (this.tip_options.length) { this.showTipModal() } else { this.showInvoice() @@ -410,6 +424,98 @@ 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 () { var self = this axios.get('https://api.opennode.co/v1/rates').then(function (response) { diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py index 9609956e..b7f14b98 100644 --- a/lnbits/extensions/tpos/views_api.py +++ b/lnbits/extensions/tpos/views_api.py @@ -1,7 +1,9 @@ from http import HTTPStatus +import httpx from fastapi import Query from fastapi.params import Depends +from lnurl import decode as decode_lnurl from loguru import logger 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 .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) @@ -79,6 +81,66 @@ async def api_tpos_create_invoice( 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( "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK ) diff --git a/lnbits/extensions/watchonly/static/components/address-list/address-list.html b/lnbits/extensions/watchonly/static/components/address-list/address-list.html index f2121063..8db6e4d0 100644 --- a/lnbits/extensions/watchonly/static/components/address-list/address-list.html +++ b/lnbits/extensions/watchonly/static/components/address-list/address-list.html @@ -111,7 +111,7 @@
-
+
+
+ Copy +
wl.id === walletId) return wallet ? wallet.title : 'unknown' diff --git a/lnbits/extensions/watchonly/static/components/history/history.js b/lnbits/extensions/watchonly/static/components/history/history.js index 574a1ef6..81cf44cc 100644 --- a/lnbits/extensions/watchonly/static/components/history/history.js +++ b/lnbits/extensions/watchonly/static/components/history/history.js @@ -39,6 +39,10 @@ async function history(path) { label: 'Date', field: 'date', sortable: true + }, + { + name: 'txId', + field: 'txId' } ], exportColums: [ diff --git a/lnbits/extensions/watchonly/static/components/payment/payment.js b/lnbits/extensions/watchonly/static/components/payment/payment.js index 1459083c..a74e5489 100644 --- a/lnbits/extensions/watchonly/static/components/payment/payment.js +++ b/lnbits/extensions/watchonly/static/components/payment/payment.js @@ -33,7 +33,6 @@ async function payment(path) { signedTxHex: null, sentTxId: null, signedTxId: null, - paymentTab: 'destination', sendToList: [{address: '', amount: undefined}], changeWallet: null, changeAddress: {}, @@ -83,12 +82,27 @@ async function payment(path) { satBtc(val, showUnit = true) { 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 () { this.showChecking = true try { if (!this.serialSignerRef.isConnected()) { - const portOpen = await this.serialSignerRef.openSerialPort() - if (!portOpen) return + this.$q.notify({ + type: 'warning', + message: 'Please connect to a Signing device first!', + timeout: 10000 + }) + return } if (!this.serialSignerRef.isAuthenticated()) { await this.serialSignerRef.hwwShowPasswordDialog() @@ -139,7 +153,6 @@ async function payment(path) { }, createPsbt: async function () { try { - console.log('### this.createPsbt') this.tx = this.createTx() for (const input of this.tx.inputs) { input.tx_hex = await this.fetchTxHex(input.tx_id) @@ -233,8 +246,6 @@ async function payment(path) { this.showChecking = true this.psbtBase64Signed = psbtBase64 - console.log('### payment updateSignedPsbt psbtBase64', psbtBase64) - const data = await this.extractTxFromPsbt(psbtBase64) this.showFinalTx = true if (data) { @@ -249,7 +260,6 @@ async function payment(path) { } }, extractTxFromPsbt: async function (psbtBase64) { - console.log('### extractTxFromPsbt psbtBase64', psbtBase64) try { const {data} = await LNbits.api.request( 'PUT', @@ -260,13 +270,12 @@ async function payment(path) { inputs: this.tx.inputs } ) - console.log('### extractTxFromPsbt data', data) return data } catch (error) { - console.log('### error', error) this.$q.notify({ type: 'warning', message: 'Cannot finalize PSBT!', + caption: `${error}`, timeout: 10000 }) LNbits.utils.notifyApiError(error) @@ -289,8 +298,8 @@ async function payment(path) { timeout: 10000 }) - // todo: event rescan with amount - // todo: display tx id + this.clearState() + this.$emit('broadcast-done', this.sentTxId) } catch (error) { this.sentTxId = null this.$q.notify({ diff --git a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html index 392ace17..18e52058 100644 --- a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html +++ b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html @@ -1,4 +1,15 @@
+
+
+ +
+
+
+ + +
+
+ +
+
+
+
+ +
+
diff --git a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js index 8c155435..87b54e38 100644 --- a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js +++ b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js @@ -2,23 +2,11 @@ async function serialPortConfig(path) { const t = await loadTemplateAsync(path) Vue.component('serial-port-config', { name: 'serial-port-config', + props: ['config'], template: t, data() { - return { - config: { - baudRate: 9600, - bufferSize: 255, - dataBits: 8, - flowControl: 'none', - parity: 'none', - stopBits: 1 - } - } + return {} }, - methods: { - getConfig: function () { - return this.config - } - } + methods: {} }) } diff --git a/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html index eed1c560..68b81980 100644 --- a/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html +++ b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html @@ -46,6 +46,27 @@ > + + + Paired Device ({{device.config.name || 'no-name'}}) + + {{device.id}} + + + Forget + + + Enter Config -
@@ -223,7 +243,7 @@
- Check data on the display of the hardware device. + Confirm then check the Hardware Device.
@@ -329,6 +349,25 @@ + + + + +
+ Close +
+
+
+ Check word at position {{hww.seedWordPosition}} on display @@ -389,6 +428,31 @@
+ +
+ + + + +
Enter new password (8 numbers/letters) -

- - For test purposes only. Do not enter word list with real funds!!! - +
{ - console.log('### navigator.serial event: connected!', event) + this.selectedPort = await navigator.serial.requestPort() + this.selectedPort.addEventListener('connect', event => { + // do nothing }) - navigator.serial.addEventListener('disconnect', () => { - console.log('### navigator.serial event: disconnected!', event) + this.selectedPort.addEventListener('disconnect', () => { + this.selectedPort = null this.hww.authenticated = false this.$q.notify({ type: 'warning', @@ -77,7 +103,7 @@ async function serialSigner(path) { timeout: 10000 }) }) - this.selectedPort = await navigator.serial.requestPort() + // Wait for the serial port to open. await this.selectedPort.open(config) this.startSerialPortReading() @@ -88,6 +114,9 @@ async function serialSigner(path) { ) this.writer = textEncoder.writable.getWriter() + + await this.hwwPing() + return true } catch (error) { this.selectedPort = null @@ -100,7 +129,13 @@ async function serialSigner(path) { 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 }, closeSerialPort: async function () { @@ -113,14 +148,12 @@ async function serialSigner(path) { /* Ignore the error */ }) if (this.selectedPort) await this.selectedPort.close() - this.selectedPort = null this.$q.notify({ type: 'positive', message: 'Serial port disconnected!', timeout: 5000 }) } catch (error) { - this.selectedPort = null this.$q.notify({ type: 'warning', message: 'Cannot close serial port!', @@ -128,6 +161,7 @@ async function serialSigner(path) { timeout: 10000 }) } finally { + this.selectedPort = null this.hww.authenticated = false } }, @@ -199,11 +233,17 @@ async function serialSigner(path) { } } }, - handleSerialPortResponse: function (value) { - const command = value.split(' ')[0] - const commandData = value.substring(command.length).trim() + handleSerialPortResponse: async function (value) { + const {command, commandData} = await this.extractCommand(value) + this.logPublicCommandsResponse(command, commandData) switch (command) { + case COMMAND_PING: + this.handlePingResponse(commandData) + break + case COMMAND_CHECK_PAIRING: + this.handleCheckPairingResponse(commandData) + break case COMMAND_SIGN_PSBT: this.handleSignResponse(commandData) break @@ -222,8 +262,35 @@ async function serialSigner(path) { case COMMAND_XPUB: this.handleXpubResponse(commandData) 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: - 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) { @@ -231,11 +298,48 @@ async function serialSigner(path) { const textArea = document.getElementById('serial-port-console') 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 () { try { this.hww.showPasswordDialog = true - await this.writer.write(COMMAND_PASSWORD + '\n') + await this.sendCommandSecure(COMMAND_PASSWORD) } catch (error) { + console.log(error) this.$q.notify({ type: 'warning', message: 'Failed to connect to Hardware Wallet!', @@ -247,7 +351,7 @@ async function serialSigner(path) { hwwShowWipeDialog: async function () { try { this.hww.showWipeDialog = true - await this.writer.write(COMMAND_WIPE + '\n') + await this.sendCommandSecure(COMMAND_WIPE) } catch (error) { this.$q.notify({ type: 'warning', @@ -260,7 +364,7 @@ async function serialSigner(path) { hwwShowRestoreDialog: async function () { try { this.hww.showRestoreDialog = true - await this.writer.write(COMMAND_WIPE + '\n') + await this.sendCommandSecure(COMMAND_RESTORE) } catch (error) { this.$q.notify({ type: 'warning', @@ -275,11 +379,11 @@ async function serialSigner(path) { if (this.hww.confirm.outputIndex >= this.tx.outputs.length) { this.hww.confirm.showFee = true } - await this.writer.write(COMMAND_CONFIRM_NEXT + '\n') + await this.sendCommandSecure(COMMAND_CONFIRM_NEXT) }, cancelOperation: async function () { try { - await this.writer.write(COMMAND_CANCEL + '\n') + await this.sendCommandSecure(COMMAND_CANCEL) } catch (error) { this.$q.notify({ type: 'warning', @@ -291,15 +395,15 @@ async function serialSigner(path) { }, hwwConfigAndConnect: async function () { this.hww.showConfigDialog = false - const config = this.$refs.serialPortConfig.getConfig() - await this.openSerialPort(config) + if (this.config.deviceId) { + this.updatePairedDeviceConfig(this.config.deviceId, this.config) + } + await this.openSerialPort(this.config) return true }, hwwLogin: async function () { try { - await this.writer.write( - COMMAND_PASSWORD + ' ' + this.hww.password + '\n' - ) + await this.sendCommandSecure(COMMAND_PASSWORD, [this.hww.password]) } catch (error) { this.$q.notify({ type: 'warning', @@ -335,7 +439,7 @@ async function serialSigner(path) { }, hwwLogout: async function () { try { - await this.writer.write(COMMAND_PASSWORD_CLEAR + '\n') + await this.sendCommandSecure(COMMAND_PASSWORD_CLEAR) } catch (error) { this.$q.notify({ type: 'warning', @@ -346,22 +450,24 @@ async function serialSigner(path) { } }, handleLogoutResponse: function (res = '') { - this.hww.authenticated = !(res.trim() === '1') - if (this.hww.authenticated) { + const authenticated = !(res.trim() === '1') + if (this.hww.authenticated && !authenticated) { this.$q.notify({ - type: 'warning', - message: 'Failed to logout from Hardware Wallet', + type: 'positive', + message: 'Logged Out', timeout: 10000 }) } + this.hww.authenticated = authenticated }, hwwSendPsbt: async function (psbtBase64, tx) { try { this.tx = tx this.hww.sendingPsbt = true - await this.writer.write( - COMMAND_SEND_PSBT + ' ' + this.network + ' ' + psbtBase64 + '\n' - ) + await this.sendCommandSecure(COMMAND_SEND_PSBT, [ + this.network, + psbtBase64 + ]) this.$q.notify({ type: 'positive', message: 'Data sent to serial port device!', @@ -411,7 +517,7 @@ async function serialSigner(path) { try { this.hww.showConfirmationDialog = false this.hww.signingPsbt = true - await this.writer.write(COMMAND_SIGN_PSBT + '\n') + await this.sendCommandSecure(COMMAND_SIGN_PSBT) } catch (error) { this.$q.notify({ type: 'warning', @@ -441,9 +547,152 @@ async function serialSigner(path) { 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 () { try { - await this.writer.write(COMMAND_HELP + '\n') + await this.sendCommandSecure(COMMAND_HELP) this.$q.notify({ type: 'positive', message: 'Check display or console for details!', @@ -461,7 +710,7 @@ async function serialSigner(path) { hwwWipe: async function () { try { this.hww.showWipeDialog = false - await this.writer.write(COMMAND_WIPE + ' ' + this.hww.password + '\n') + await this.sendCommandSecure(COMMAND_WIPE, [this.hww.password]) } catch (error) { this.$q.notify({ type: 'warning', @@ -494,13 +743,7 @@ async function serialSigner(path) { }, hwwXpub: async function (path) { try { - console.log( - '### hwwXpub', - COMMAND_XPUB + ' ' + this.network + ' ' + path - ) - await this.writer.write( - COMMAND_XPUB + ' ' + this.network + ' ' + path + '\n' - ) + await this.sendCommandSecure(COMMAND_XPUB, [this.network, path]) } catch (error) { this.$q.notify({ type: 'warning', @@ -526,13 +769,15 @@ async function serialSigner(path) { const fingerprint = args[2].trim() this.xpubResolve({xpub, fingerprint}) }, + hwwShowSeed: async function () { try { this.hww.showSeedDialog = true this.hww.seedWordPosition = 1 - await this.writer.write( - COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' - ) + + await this.sendCommandSecure(COMMAND_SEED, [ + this.hww.seedWordPosition + ]) } catch (error) { this.$q.notify({ type: 'warning', @@ -544,37 +789,29 @@ async function serialSigner(path) { }, showNextSeedWord: async function () { this.hww.seedWordPosition++ - await this.writer.write( - COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' - ) + await this.sendCommandSecure(COMMAND_SEED, [this.hww.seedWordPosition]) }, showPrevSeedWord: async function () { this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1) - console.log('### this.hww.seedWordPosition', this.hww.seedWordPosition) - await this.writer.write( - COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' - ) + await this.sendCommandSecure(COMMAND_SEED, [this.hww.seedWordPosition]) }, handleShowSeedResponse: function (res = '') { 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 () { try { - await this.writer.write( - COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n' - ) - await this.writer.write( - COMMAND_PASSWORD + ' ' + this.hww.password + '\n' - ) + let mnemonicWithPassphrase = this.hww.mnemonic + if ( + this.hww.hasPassphrase && + this.hww.passphrase && + this.hww.passphrase.length + ) { + mnemonicWithPassphrase += '/' + this.hww.passphrase + } + await this.sendCommandSecure(COMMAND_RESTORE, [ + this.hww.password, + mnemonicWithPassphrase + ]) } catch (error) { this.$q.notify({ type: 'warning', @@ -585,6 +822,7 @@ async function serialSigner(path) { } finally { this.hww.showRestoreDialog = false this.hww.mnemonic = null + this.hww.passphrase = null this.hww.showMnemonic = false this.hww.password = null this.hww.confirmedPassword = null @@ -594,6 +832,116 @@ async function serialSigner(path) { updateSignedPsbt: async function (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 () {} diff --git a/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html index 61a35362..748d650d 100644 --- a/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html +++ b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html @@ -1,16 +1,16 @@
-
+
-
+
-
{{satBtc(total)}}
+
{{satBtc(total)}}
-
+
diff --git a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html index ccd6f678..72ff2156 100644 --- a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html +++ b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html @@ -2,7 +2,7 @@
-
+
- -
-
+
+
. 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); \ No newline at end of file diff --git a/lnbits/extensions/watchonly/static/js/crypto/noble-secp256k1.js b/lnbits/extensions/watchonly/static/js/crypto/noble-secp256k1.js new file mode 100644 index 00000000..8be86729 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/crypto/noble-secp256k1.js @@ -0,0 +1,1177 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(['exports'], factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.nobleSecp256k1 = {})); +})(this, (function (exports) { 'use strict'; + + const _nodeResolve_empty = {}; + + const nodeCrypto = /*#__PURE__*/Object.freeze({ + __proto__: null, + 'default': _nodeResolve_empty + }); + + /*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ + const _0n = BigInt(0); + const _1n = BigInt(1); + const _2n = BigInt(2); + const _3n = BigInt(3); + const _8n = BigInt(8); + const POW_2_256 = _2n ** BigInt(256); + const CURVE = { + a: _0n, + b: BigInt(7), + P: POW_2_256 - _2n ** BigInt(32) - BigInt(977), + n: POW_2_256 - BigInt('432420386565659656852420866394968145599'), + h: _1n, + Gx: BigInt('55066263022277343669578718895168534326250603453777594175500187360389116729240'), + Gy: BigInt('32670510020758816978083085130507043184471273380659243275938904335757337482424'), + beta: BigInt('0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee'), + }; + function weistrass(x) { + const { a, b } = CURVE; + const x2 = mod(x * x); + const x3 = mod(x2 * x); + return mod(x3 + a * x + b); + } + const USE_ENDOMORPHISM = CURVE.a === _0n; + class JacobianPoint { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + static fromAffine(p) { + if (!(p instanceof Point)) { + throw new TypeError('JacobianPoint#fromAffine: expected Point'); + } + return new JacobianPoint(p.x, p.y, _1n); + } + static toAffineBatch(points) { + const toInv = invertBatch(points.map((p) => p.z)); + return points.map((p, i) => p.toAffine(toInv[i])); + } + static normalizeZ(points) { + return JacobianPoint.toAffineBatch(points).map(JacobianPoint.fromAffine); + } + equals(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected'); + const { x: X1, y: Y1, z: Z1 } = this; + const { x: X2, y: Y2, z: Z2 } = other; + const Z1Z1 = mod(Z1 ** _2n); + const Z2Z2 = mod(Z2 ** _2n); + const U1 = mod(X1 * Z2Z2); + const U2 = mod(X2 * Z1Z1); + const S1 = mod(mod(Y1 * Z2) * Z2Z2); + const S2 = mod(mod(Y2 * Z1) * Z1Z1); + return U1 === U2 && S1 === S2; + } + negate() { + return new JacobianPoint(this.x, mod(-this.y), this.z); + } + double() { + const { x: X1, y: Y1, z: Z1 } = this; + const A = mod(X1 ** _2n); + const B = mod(Y1 ** _2n); + const C = mod(B ** _2n); + const D = mod(_2n * (mod((X1 + B) ** _2n) - A - C)); + const E = mod(_3n * A); + const F = mod(E ** _2n); + const X3 = mod(F - _2n * D); + const Y3 = mod(E * (D - X3) - _8n * C); + const Z3 = mod(_2n * Y1 * Z1); + return new JacobianPoint(X3, Y3, Z3); + } + add(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected'); + const { x: X1, y: Y1, z: Z1 } = this; + const { x: X2, y: Y2, z: Z2 } = other; + if (X2 === _0n || Y2 === _0n) + return this; + if (X1 === _0n || Y1 === _0n) + return other; + const Z1Z1 = mod(Z1 ** _2n); + const Z2Z2 = mod(Z2 ** _2n); + const U1 = mod(X1 * Z2Z2); + const U2 = mod(X2 * Z1Z1); + const S1 = mod(mod(Y1 * Z2) * Z2Z2); + const S2 = mod(mod(Y2 * Z1) * Z1Z1); + const H = mod(U2 - U1); + const r = mod(S2 - S1); + if (H === _0n) { + if (r === _0n) { + return this.double(); + } + else { + return JacobianPoint.ZERO; + } + } + const HH = mod(H ** _2n); + const HHH = mod(H * HH); + const V = mod(U1 * HH); + const X3 = mod(r ** _2n - HHH - _2n * V); + const Y3 = mod(r * (V - X3) - S1 * HHH); + const Z3 = mod(Z1 * Z2 * H); + return new JacobianPoint(X3, Y3, Z3); + } + subtract(other) { + return this.add(other.negate()); + } + multiplyUnsafe(scalar) { + const P0 = JacobianPoint.ZERO; + if (typeof scalar === 'bigint' && scalar === _0n) + return P0; + let n = normalizeScalar(scalar); + if (n === _1n) + return this; + if (!USE_ENDOMORPHISM) { + let p = P0; + let d = this; + while (n > _0n) { + if (n & _1n) + p = p.add(d); + d = d.double(); + n >>= _1n; + } + return p; + } + let { k1neg, k1, k2neg, k2 } = splitScalarEndo(n); + let k1p = P0; + let k2p = P0; + let d = this; + while (k1 > _0n || k2 > _0n) { + if (k1 & _1n) + k1p = k1p.add(d); + if (k2 & _1n) + k2p = k2p.add(d); + d = d.double(); + k1 >>= _1n; + k2 >>= _1n; + } + if (k1neg) + k1p = k1p.negate(); + if (k2neg) + k2p = k2p.negate(); + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z); + return k1p.add(k2p); + } + precomputeWindow(W) { + const windows = USE_ENDOMORPHISM ? 128 / W + 1 : 256 / W + 1; + const points = []; + let p = this; + let base = p; + for (let window = 0; window < windows; window++) { + base = p; + points.push(base); + for (let i = 1; i < 2 ** (W - 1); i++) { + base = base.add(p); + points.push(base); + } + p = base.double(); + } + return points; + } + wNAF(n, affinePoint) { + if (!affinePoint && this.equals(JacobianPoint.BASE)) + affinePoint = Point.BASE; + const W = (affinePoint && affinePoint._WINDOW_SIZE) || 1; + if (256 % W) { + throw new Error('Point#wNAF: Invalid precomputation window, must be power of 2'); + } + let precomputes = affinePoint && pointPrecomputes.get(affinePoint); + if (!precomputes) { + precomputes = this.precomputeWindow(W); + if (affinePoint && W !== 1) { + precomputes = JacobianPoint.normalizeZ(precomputes); + pointPrecomputes.set(affinePoint, precomputes); + } + } + let p = JacobianPoint.ZERO; + let f = JacobianPoint.ZERO; + const windows = 1 + (USE_ENDOMORPHISM ? 128 / W : 256 / W); + const windowSize = 2 ** (W - 1); + const mask = BigInt(2 ** W - 1); + const maxNumber = 2 ** W; + const shiftBy = BigInt(W); + for (let window = 0; window < windows; window++) { + const offset = window * windowSize; + let wbits = Number(n & mask); + n >>= shiftBy; + if (wbits > windowSize) { + wbits -= maxNumber; + n += _1n; + } + if (wbits === 0) { + let pr = precomputes[offset]; + if (window % 2) + pr = pr.negate(); + f = f.add(pr); + } + else { + let cached = precomputes[offset + Math.abs(wbits) - 1]; + if (wbits < 0) + cached = cached.negate(); + p = p.add(cached); + } + } + return { p, f }; + } + multiply(scalar, affinePoint) { + let n = normalizeScalar(scalar); + let point; + let fake; + if (USE_ENDOMORPHISM) { + const { k1neg, k1, k2neg, k2 } = splitScalarEndo(n); + let { p: k1p, f: f1p } = this.wNAF(k1, affinePoint); + let { p: k2p, f: f2p } = this.wNAF(k2, affinePoint); + if (k1neg) + k1p = k1p.negate(); + if (k2neg) + k2p = k2p.negate(); + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z); + point = k1p.add(k2p); + fake = f1p.add(f2p); + } + else { + const { p, f } = this.wNAF(n, affinePoint); + point = p; + fake = f; + } + return JacobianPoint.normalizeZ([point, fake])[0]; + } + toAffine(invZ = invert(this.z)) { + const { x, y, z } = this; + const iz1 = invZ; + const iz2 = mod(iz1 * iz1); + const iz3 = mod(iz2 * iz1); + const ax = mod(x * iz2); + const ay = mod(y * iz3); + const zz = mod(z * iz1); + if (zz !== _1n) + throw new Error('invZ was invalid'); + return new Point(ax, ay); + } + } + JacobianPoint.BASE = new JacobianPoint(CURVE.Gx, CURVE.Gy, _1n); + JacobianPoint.ZERO = new JacobianPoint(_0n, _1n, _0n); + const pointPrecomputes = new WeakMap(); + class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + _setWindowSize(windowSize) { + this._WINDOW_SIZE = windowSize; + pointPrecomputes.delete(this); + } + static fromCompressedHex(bytes) { + const isShort = bytes.length === 32; + const x = bytesToNumber(isShort ? bytes : bytes.subarray(1)); + if (!isValidFieldElement(x)) + throw new Error('Point is not on curve'); + const y2 = weistrass(x); + let y = sqrtMod(y2); + const isYOdd = (y & _1n) === _1n; + if (isShort) { + if (isYOdd) + y = mod(-y); + } + else { + const isFirstByteOdd = (bytes[0] & 1) === 1; + if (isFirstByteOdd !== isYOdd) + y = mod(-y); + } + const point = new Point(x, y); + point.assertValidity(); + return point; + } + static fromUncompressedHex(bytes) { + const x = bytesToNumber(bytes.subarray(1, 33)); + const y = bytesToNumber(bytes.subarray(33, 65)); + const point = new Point(x, y); + point.assertValidity(); + return point; + } + static fromHex(hex) { + const bytes = ensureBytes(hex); + const len = bytes.length; + const header = bytes[0]; + if (len === 32 || (len === 33 && (header === 0x02 || header === 0x03))) { + return this.fromCompressedHex(bytes); + } + if (len === 65 && header === 0x04) + return this.fromUncompressedHex(bytes); + throw new Error(`Point.fromHex: received invalid point. Expected 32-33 compressed bytes or 65 uncompressed bytes, not ${len}`); + } + static fromPrivateKey(privateKey) { + return Point.BASE.multiply(normalizePrivateKey(privateKey)); + } + static fromSignature(msgHash, signature, recovery) { + msgHash = ensureBytes(msgHash); + const h = truncateHash(msgHash); + const { r, s } = normalizeSignature(signature); + if (recovery !== 0 && recovery !== 1) { + throw new Error('Cannot recover signature: invalid recovery bit'); + } + const prefix = recovery & 1 ? '03' : '02'; + const R = Point.fromHex(prefix + numTo32bStr(r)); + const { n } = CURVE; + const rinv = invert(r, n); + const u1 = mod(-h * rinv, n); + const u2 = mod(s * rinv, n); + const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2); + if (!Q) + throw new Error('Cannot recover signature: point at infinify'); + Q.assertValidity(); + return Q; + } + toRawBytes(isCompressed = false) { + return hexToBytes(this.toHex(isCompressed)); + } + toHex(isCompressed = false) { + const x = numTo32bStr(this.x); + if (isCompressed) { + const prefix = this.y & _1n ? '03' : '02'; + return `${prefix}${x}`; + } + else { + return `04${x}${numTo32bStr(this.y)}`; + } + } + toHexX() { + return this.toHex(true).slice(2); + } + toRawX() { + return this.toRawBytes(true).slice(1); + } + assertValidity() { + const msg = 'Point is not on elliptic curve'; + const { x, y } = this; + if (!isValidFieldElement(x) || !isValidFieldElement(y)) + throw new Error(msg); + const left = mod(y * y); + const right = weistrass(x); + if (mod(left - right) !== _0n) + throw new Error(msg); + } + equals(other) { + return this.x === other.x && this.y === other.y; + } + negate() { + return new Point(this.x, mod(-this.y)); + } + double() { + return JacobianPoint.fromAffine(this).double().toAffine(); + } + add(other) { + return JacobianPoint.fromAffine(this).add(JacobianPoint.fromAffine(other)).toAffine(); + } + subtract(other) { + return this.add(other.negate()); + } + multiply(scalar) { + return JacobianPoint.fromAffine(this).multiply(scalar, this).toAffine(); + } + multiplyAndAddUnsafe(Q, a, b) { + const P = JacobianPoint.fromAffine(this); + const aP = a === _0n || a === _1n || this !== Point.BASE ? P.multiplyUnsafe(a) : P.multiply(a); + const bQ = JacobianPoint.fromAffine(Q).multiplyUnsafe(b); + const sum = aP.add(bQ); + return sum.equals(JacobianPoint.ZERO) ? undefined : sum.toAffine(); + } + } + Point.BASE = new Point(CURVE.Gx, CURVE.Gy); + Point.ZERO = new Point(_0n, _0n); + function sliceDER(s) { + return Number.parseInt(s[0], 16) >= 8 ? '00' + s : s; + } + function parseDERInt(data) { + if (data.length < 2 || data[0] !== 0x02) { + throw new Error(`Invalid signature integer tag: ${bytesToHex(data)}`); + } + const len = data[1]; + const res = data.subarray(2, len + 2); + if (!len || res.length !== len) { + throw new Error(`Invalid signature integer: wrong length`); + } + if (res[0] === 0x00 && res[1] <= 0x7f) { + throw new Error('Invalid signature integer: trailing length'); + } + return { data: bytesToNumber(res), left: data.subarray(len + 2) }; + } + function parseDERSignature(data) { + if (data.length < 2 || data[0] != 0x30) { + throw new Error(`Invalid signature tag: ${bytesToHex(data)}`); + } + if (data[1] !== data.length - 2) { + throw new Error('Invalid signature: incorrect length'); + } + const { data: r, left: sBytes } = parseDERInt(data.subarray(2)); + const { data: s, left: rBytesLeft } = parseDERInt(sBytes); + if (rBytesLeft.length) { + throw new Error(`Invalid signature: left bytes after parsing: ${bytesToHex(rBytesLeft)}`); + } + return { r, s }; + } + class Signature { + constructor(r, s) { + this.r = r; + this.s = s; + this.assertValidity(); + } + static fromCompact(hex) { + const arr = isUint8a(hex); + const name = 'Signature.fromCompact'; + if (typeof hex !== 'string' && !arr) + throw new TypeError(`${name}: Expected string or Uint8Array`); + const str = arr ? bytesToHex(hex) : hex; + if (str.length !== 128) + throw new Error(`${name}: Expected 64-byte hex`); + return new Signature(hexToNumber(str.slice(0, 64)), hexToNumber(str.slice(64, 128))); + } + static fromDER(hex) { + const arr = isUint8a(hex); + if (typeof hex !== 'string' && !arr) + throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`); + const { r, s } = parseDERSignature(arr ? hex : hexToBytes(hex)); + return new Signature(r, s); + } + static fromHex(hex) { + return this.fromDER(hex); + } + assertValidity() { + const { r, s } = this; + if (!isWithinCurveOrder(r)) + throw new Error('Invalid Signature: r must be 0 < r < n'); + if (!isWithinCurveOrder(s)) + throw new Error('Invalid Signature: s must be 0 < s < n'); + } + hasHighS() { + const HALF = CURVE.n >> _1n; + return this.s > HALF; + } + normalizeS() { + return this.hasHighS() ? new Signature(this.r, CURVE.n - this.s) : this; + } + toDERRawBytes(isCompressed = false) { + return hexToBytes(this.toDERHex(isCompressed)); + } + toDERHex(isCompressed = false) { + const sHex = sliceDER(numberToHexUnpadded(this.s)); + if (isCompressed) + return sHex; + const rHex = sliceDER(numberToHexUnpadded(this.r)); + const rLen = numberToHexUnpadded(rHex.length / 2); + const sLen = numberToHexUnpadded(sHex.length / 2); + const length = numberToHexUnpadded(rHex.length / 2 + sHex.length / 2 + 4); + return `30${length}02${rLen}${rHex}02${sLen}${sHex}`; + } + toRawBytes() { + return this.toDERRawBytes(); + } + toHex() { + return this.toDERHex(); + } + toCompactRawBytes() { + return hexToBytes(this.toCompactHex()); + } + toCompactHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s); + } + } + function concatBytes(...arrays) { + if (!arrays.every(isUint8a)) + throw new Error('Uint8Array list expected'); + if (arrays.length === 1) + return arrays[0]; + const length = arrays.reduce((a, arr) => a + arr.length, 0); + const result = new Uint8Array(length); + for (let i = 0, pad = 0; i < arrays.length; i++) { + const arr = arrays[i]; + result.set(arr, pad); + pad += arr.length; + } + return result; + } + function isUint8a(bytes) { + return bytes instanceof Uint8Array; + } + const hexes = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, '0')); + function bytesToHex(uint8a) { + if (!(uint8a instanceof Uint8Array)) + throw new Error('Expected Uint8Array'); + let hex = ''; + for (let i = 0; i < uint8a.length; i++) { + hex += hexes[uint8a[i]]; + } + return hex; + } + function numTo32bStr(num) { + if (num > POW_2_256) + throw new Error('Expected number < 2^256'); + return num.toString(16).padStart(64, '0'); + } + function numTo32b(num) { + return hexToBytes(numTo32bStr(num)); + } + function numberToHexUnpadded(num) { + const hex = num.toString(16); + return hex.length & 1 ? `0${hex}` : hex; + } + function hexToNumber(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToNumber: expected string, got ' + typeof hex); + } + return BigInt(`0x${hex}`); + } + function hexToBytes(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToBytes: expected string, got ' + typeof hex); + } + if (hex.length % 2) + throw new Error('hexToBytes: received invalid unpadded hex' + hex.length); + const array = new Uint8Array(hex.length / 2); + for (let i = 0; i < array.length; i++) { + const j = i * 2; + const hexByte = hex.slice(j, j + 2); + const byte = Number.parseInt(hexByte, 16); + if (Number.isNaN(byte) || byte < 0) + throw new Error('Invalid byte sequence'); + array[i] = byte; + } + return array; + } + function bytesToNumber(bytes) { + return hexToNumber(bytesToHex(bytes)); + } + function ensureBytes(hex) { + return hex instanceof Uint8Array ? Uint8Array.from(hex) : hexToBytes(hex); + } + function normalizeScalar(num) { + if (typeof num === 'number' && Number.isSafeInteger(num) && num > 0) + return BigInt(num); + if (typeof num === 'bigint' && isWithinCurveOrder(num)) + return num; + throw new TypeError('Expected valid private scalar: 0 < scalar < curve.n'); + } + function mod(a, b = CURVE.P) { + const result = a % b; + return result >= _0n ? result : b + result; + } + function pow2(x, power) { + const { P } = CURVE; + let res = x; + while (power-- > _0n) { + res *= res; + res %= P; + } + return res; + } + function sqrtMod(x) { + const { P } = CURVE; + const _6n = BigInt(6); + const _11n = BigInt(11); + const _22n = BigInt(22); + const _23n = BigInt(23); + const _44n = BigInt(44); + const _88n = BigInt(88); + const b2 = (x * x * x) % P; + const b3 = (b2 * b2 * x) % P; + const b6 = (pow2(b3, _3n) * b3) % P; + const b9 = (pow2(b6, _3n) * b3) % P; + const b11 = (pow2(b9, _2n) * b2) % P; + const b22 = (pow2(b11, _11n) * b11) % P; + const b44 = (pow2(b22, _22n) * b22) % P; + const b88 = (pow2(b44, _44n) * b44) % P; + const b176 = (pow2(b88, _88n) * b88) % P; + const b220 = (pow2(b176, _44n) * b44) % P; + const b223 = (pow2(b220, _3n) * b3) % P; + const t1 = (pow2(b223, _23n) * b22) % P; + const t2 = (pow2(t1, _6n) * b2) % P; + return pow2(t2, _2n); + } + function invert(number, modulo = CURVE.P) { + if (number === _0n || modulo <= _0n) { + throw new Error(`invert: expected positive integers, got n=${number} mod=${modulo}`); + } + let a = mod(number, modulo); + let b = modulo; + let x = _0n, u = _1n; + while (a !== _0n) { + const q = b / a; + const r = b % a; + const m = x - u * q; + b = a, a = r, x = u, u = m; + } + const gcd = b; + if (gcd !== _1n) + throw new Error('invert: does not exist'); + return mod(x, modulo); + } + function invertBatch(nums, p = CURVE.P) { + const scratch = new Array(nums.length); + const lastMultiplied = nums.reduce((acc, num, i) => { + if (num === _0n) + return acc; + scratch[i] = acc; + return mod(acc * num, p); + }, _1n); + const inverted = invert(lastMultiplied, p); + nums.reduceRight((acc, num, i) => { + if (num === _0n) + return acc; + scratch[i] = mod(acc * scratch[i], p); + return mod(acc * num, p); + }, inverted); + return scratch; + } + const divNearest = (a, b) => (a + b / _2n) / b; + const POW_2_128 = _2n ** BigInt(128); + function splitScalarEndo(k) { + const { n } = CURVE; + const a1 = BigInt('0x3086d221a7d46bcde86c90e49284eb15'); + const b1 = -_1n * BigInt('0xe4437ed6010e88286f547fa90abfe4c3'); + const a2 = BigInt('0x114ca50f7a8e2f3f657c1108d9d44cfd8'); + const b2 = a1; + const c1 = divNearest(b2 * k, n); + const c2 = divNearest(-b1 * k, n); + let k1 = mod(k - c1 * a1 - c2 * a2, n); + let k2 = mod(-c1 * b1 - c2 * b2, n); + const k1neg = k1 > POW_2_128; + const k2neg = k2 > POW_2_128; + if (k1neg) + k1 = n - k1; + if (k2neg) + k2 = n - k2; + if (k1 > POW_2_128 || k2 > POW_2_128) { + throw new Error('splitScalarEndo: Endomorphism failed, k=' + k); + } + return { k1neg, k1, k2neg, k2 }; + } + function truncateHash(hash) { + const { n } = CURVE; + const byteLength = hash.length; + const delta = byteLength * 8 - 256; + let h = bytesToNumber(hash); + if (delta > 0) + h = h >> BigInt(delta); + if (h >= n) + h -= n; + return h; + } + class HmacDrbg { + constructor() { + this.v = new Uint8Array(32).fill(1); + this.k = new Uint8Array(32).fill(0); + this.counter = 0; + } + hmac(...values) { + return utils.hmacSha256(this.k, ...values); + } + hmacSync(...values) { + if (typeof utils.hmacSha256Sync !== 'function') + throw new Error('utils.hmacSha256Sync is undefined, you need to set it'); + const res = utils.hmacSha256Sync(this.k, ...values); + if (res instanceof Promise) + throw new Error('To use sync sign(), ensure utils.hmacSha256 is sync'); + return res; + } + incr() { + if (this.counter >= 1000) { + throw new Error('Tried 1,000 k values for sign(), all were invalid'); + } + this.counter += 1; + } + async reseed(seed = new Uint8Array()) { + this.k = await this.hmac(this.v, Uint8Array.from([0x00]), seed); + this.v = await this.hmac(this.v); + if (seed.length === 0) + return; + this.k = await this.hmac(this.v, Uint8Array.from([0x01]), seed); + this.v = await this.hmac(this.v); + } + reseedSync(seed = new Uint8Array()) { + this.k = this.hmacSync(this.v, Uint8Array.from([0x00]), seed); + this.v = this.hmacSync(this.v); + if (seed.length === 0) + return; + this.k = this.hmacSync(this.v, Uint8Array.from([0x01]), seed); + this.v = this.hmacSync(this.v); + } + async generate() { + this.incr(); + this.v = await this.hmac(this.v); + return this.v; + } + generateSync() { + this.incr(); + this.v = this.hmacSync(this.v); + return this.v; + } + } + function isWithinCurveOrder(num) { + return _0n < num && num < CURVE.n; + } + function isValidFieldElement(num) { + return _0n < num && num < CURVE.P; + } + function kmdToSig(kBytes, m, d) { + const k = bytesToNumber(kBytes); + if (!isWithinCurveOrder(k)) + return; + const { n } = CURVE; + const q = Point.BASE.multiply(k); + const r = mod(q.x, n); + if (r === _0n) + return; + const s = mod(invert(k, n) * mod(m + d * r, n), n); + if (s === _0n) + return; + const sig = new Signature(r, s); + const recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n); + return { sig, recovery }; + } + function normalizePrivateKey(key) { + let num; + if (typeof key === 'bigint') { + num = key; + } + else if (typeof key === 'number' && Number.isSafeInteger(key) && key > 0) { + num = BigInt(key); + } + else if (typeof key === 'string') { + if (key.length !== 64) + throw new Error('Expected 32 bytes of private key'); + num = hexToNumber(key); + } + else if (isUint8a(key)) { + if (key.length !== 32) + throw new Error('Expected 32 bytes of private key'); + num = bytesToNumber(key); + } + else { + throw new TypeError('Expected valid private key'); + } + if (!isWithinCurveOrder(num)) + throw new Error('Expected private key: 0 < key < n'); + return num; + } + function normalizePublicKey(publicKey) { + if (publicKey instanceof Point) { + publicKey.assertValidity(); + return publicKey; + } + else { + return Point.fromHex(publicKey); + } + } + function normalizeSignature(signature) { + if (signature instanceof Signature) { + signature.assertValidity(); + return signature; + } + try { + return Signature.fromDER(signature); + } + catch (error) { + return Signature.fromCompact(signature); + } + } + function getPublicKey(privateKey, isCompressed = false) { + return Point.fromPrivateKey(privateKey).toRawBytes(isCompressed); + } + function recoverPublicKey(msgHash, signature, recovery, isCompressed = false) { + return Point.fromSignature(msgHash, signature, recovery).toRawBytes(isCompressed); + } + function isPub(item) { + const arr = isUint8a(item); + const str = typeof item === 'string'; + const len = (arr || str) && item.length; + if (arr) + return len === 33 || len === 65; + if (str) + return len === 66 || len === 130; + if (item instanceof Point) + return true; + return false; + } + function getSharedSecret(privateA, publicB, isCompressed = false) { + if (isPub(privateA)) + throw new TypeError('getSharedSecret: first arg must be private key'); + if (!isPub(publicB)) + throw new TypeError('getSharedSecret: second arg must be public key'); + const b = normalizePublicKey(publicB); + b.assertValidity(); + return b.multiply(normalizePrivateKey(privateA)).toRawBytes(isCompressed); + } + function bits2int(bytes) { + const slice = bytes.length > 32 ? bytes.slice(0, 32) : bytes; + return bytesToNumber(slice); + } + function bits2octets(bytes) { + const z1 = bits2int(bytes); + const z2 = mod(z1, CURVE.n); + return int2octets(z2 < _0n ? z1 : z2); + } + function int2octets(num) { + if (typeof num !== 'bigint') + throw new Error('Expected bigint'); + const hex = numTo32bStr(num); + return hexToBytes(hex); + } + function initSigArgs(msgHash, privateKey, extraEntropy) { + if (msgHash == null) + throw new Error(`sign: expected valid message hash, not "${msgHash}"`); + const h1 = ensureBytes(msgHash); + const d = normalizePrivateKey(privateKey); + const seedArgs = [int2octets(d), bits2octets(h1)]; + if (extraEntropy != null) { + if (extraEntropy === true) + extraEntropy = utils.randomBytes(32); + const e = ensureBytes(extraEntropy); + if (e.length !== 32) + throw new Error('sign: Expected 32 bytes of extra data'); + seedArgs.push(e); + } + const seed = concatBytes(...seedArgs); + const m = bits2int(h1); + return { seed, m, d }; + } + function finalizeSig(recSig, opts) { + let { sig, recovery } = recSig; + const { canonical, der, recovered } = Object.assign({ canonical: true, der: true }, opts); + if (canonical && sig.hasHighS()) { + sig = sig.normalizeS(); + recovery ^= 1; + } + const hashed = der ? sig.toDERRawBytes() : sig.toCompactRawBytes(); + return recovered ? [hashed, recovery] : hashed; + } + async function sign(msgHash, privKey, opts = {}) { + const { seed, m, d } = initSigArgs(msgHash, privKey, opts.extraEntropy); + let sig; + const drbg = new HmacDrbg(); + await drbg.reseed(seed); + while (!(sig = kmdToSig(await drbg.generate(), m, d))) + await drbg.reseed(); + return finalizeSig(sig, opts); + } + function signSync(msgHash, privKey, opts = {}) { + const { seed, m, d } = initSigArgs(msgHash, privKey, opts.extraEntropy); + let sig; + const drbg = new HmacDrbg(); + drbg.reseedSync(seed); + while (!(sig = kmdToSig(drbg.generateSync(), m, d))) + drbg.reseedSync(); + return finalizeSig(sig, opts); + } + const vopts = { strict: true }; + function verify(signature, msgHash, publicKey, opts = vopts) { + let sig; + try { + sig = normalizeSignature(signature); + msgHash = ensureBytes(msgHash); + } + catch (error) { + return false; + } + const { r, s } = sig; + if (opts.strict && sig.hasHighS()) + return false; + const h = truncateHash(msgHash); + let P; + try { + P = normalizePublicKey(publicKey); + } + catch (error) { + return false; + } + const { n } = CURVE; + const sinv = invert(s, n); + const u1 = mod(h * sinv, n); + const u2 = mod(r * sinv, n); + const R = Point.BASE.multiplyAndAddUnsafe(P, u1, u2); + if (!R) + return false; + const v = mod(R.x, n); + return v === r; + } + function finalizeSchnorrChallenge(ch) { + return mod(bytesToNumber(ch), CURVE.n); + } + function hasEvenY(point) { + return (point.y & _1n) === _0n; + } + class SchnorrSignature { + constructor(r, s) { + this.r = r; + this.s = s; + this.assertValidity(); + } + static fromHex(hex) { + const bytes = ensureBytes(hex); + if (bytes.length !== 64) + throw new TypeError(`SchnorrSignature.fromHex: expected 64 bytes, not ${bytes.length}`); + const r = bytesToNumber(bytes.subarray(0, 32)); + const s = bytesToNumber(bytes.subarray(32, 64)); + return new SchnorrSignature(r, s); + } + assertValidity() { + const { r, s } = this; + if (!isValidFieldElement(r) || !isWithinCurveOrder(s)) + throw new Error('Invalid signature'); + } + toHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s); + } + toRawBytes() { + return hexToBytes(this.toHex()); + } + } + function schnorrGetPublicKey(privateKey) { + return Point.fromPrivateKey(privateKey).toRawX(); + } + function initSchnorrSigArgs(message, privateKey, auxRand) { + if (message == null) + throw new TypeError(`sign: Expected valid message, not "${message}"`); + const m = ensureBytes(message); + const d0 = normalizePrivateKey(privateKey); + const rand = ensureBytes(auxRand); + if (rand.length !== 32) + throw new TypeError('sign: Expected 32 bytes of aux randomness'); + const P = Point.fromPrivateKey(d0); + const px = P.toRawX(); + const d = hasEvenY(P) ? d0 : CURVE.n - d0; + return { m, P, px, d, rand }; + } + function initSchnorrNonce(d, t0h) { + return numTo32b(d ^ bytesToNumber(t0h)); + } + function finalizeSchnorrNonce(k0h) { + const k0 = mod(bytesToNumber(k0h), CURVE.n); + if (k0 === _0n) + throw new Error('sign: Creation of signature failed. k is zero'); + const R = Point.fromPrivateKey(k0); + const rx = R.toRawX(); + const k = hasEvenY(R) ? k0 : CURVE.n - k0; + return { R, rx, k }; + } + function finalizeSchnorrSig(R, k, e, d) { + return new SchnorrSignature(R.x, mod(k + e * d, CURVE.n)).toRawBytes(); + } + async function schnorrSign(message, privateKey, auxRand = utils.randomBytes()) { + const { m, px, d, rand } = initSchnorrSigArgs(message, privateKey, auxRand); + const t = initSchnorrNonce(d, await utils.taggedHash(TAGS.aux, rand)); + const { R, rx, k } = finalizeSchnorrNonce(await utils.taggedHash(TAGS.nonce, t, px, m)); + const e = finalizeSchnorrChallenge(await utils.taggedHash(TAGS.challenge, rx, px, m)); + const sig = finalizeSchnorrSig(R, k, e, d); + const isValid = await schnorrVerify(sig, m, px); + if (!isValid) + throw new Error('sign: Invalid signature produced'); + return sig; + } + function schnorrSignSync(message, privateKey, auxRand = utils.randomBytes()) { + const { m, px, d, rand } = initSchnorrSigArgs(message, privateKey, auxRand); + const t = initSchnorrNonce(d, utils.taggedHashSync(TAGS.aux, rand)); + const { R, rx, k } = finalizeSchnorrNonce(utils.taggedHashSync(TAGS.nonce, t, px, m)); + const e = finalizeSchnorrChallenge(utils.taggedHashSync(TAGS.challenge, rx, px, m)); + const sig = finalizeSchnorrSig(R, k, e, d); + const isValid = schnorrVerifySync(sig, m, px); + if (!isValid) + throw new Error('sign: Invalid signature produced'); + return sig; + } + function initSchnorrVerify(signature, message, publicKey) { + const raw = signature instanceof SchnorrSignature; + const sig = raw ? signature : SchnorrSignature.fromHex(signature); + if (raw) + sig.assertValidity(); + return { + ...sig, + m: ensureBytes(message), + P: normalizePublicKey(publicKey), + }; + } + function finalizeSchnorrVerify(r, P, s, e) { + const R = Point.BASE.multiplyAndAddUnsafe(P, normalizePrivateKey(s), mod(-e, CURVE.n)); + if (!R || !hasEvenY(R) || R.x !== r) + return false; + return true; + } + async function schnorrVerify(signature, message, publicKey) { + try { + const { r, s, m, P } = initSchnorrVerify(signature, message, publicKey); + const e = finalizeSchnorrChallenge(await utils.taggedHash(TAGS.challenge, numTo32b(r), P.toRawX(), m)); + return finalizeSchnorrVerify(r, P, s, e); + } + catch (error) { + return false; + } + } + function schnorrVerifySync(signature, message, publicKey) { + try { + const { r, s, m, P } = initSchnorrVerify(signature, message, publicKey); + const e = finalizeSchnorrChallenge(utils.taggedHashSync(TAGS.challenge, numTo32b(r), P.toRawX(), m)); + return finalizeSchnorrVerify(r, P, s, e); + } + catch (error) { + return false; + } + } + const schnorr = { + Signature: SchnorrSignature, + getPublicKey: schnorrGetPublicKey, + sign: schnorrSign, + verify: schnorrVerify, + signSync: schnorrSignSync, + verifySync: schnorrVerifySync, + }; + Point.BASE._setWindowSize(8); + const crypto = { + node: nodeCrypto, + web: typeof self === 'object' && 'crypto' in self ? self.crypto : undefined, + }; + const TAGS = { + challenge: 'BIP0340/challenge', + aux: 'BIP0340/aux', + nonce: 'BIP0340/nonce', + }; + const TAGGED_HASH_PREFIXES = {}; + const utils = { + isValidPrivateKey(privateKey) { + try { + normalizePrivateKey(privateKey); + return true; + } + catch (error) { + return false; + } + }, + privateAdd: (privateKey, tweak) => { + const p = normalizePrivateKey(privateKey); + const t = normalizePrivateKey(tweak); + return numTo32b(mod(p + t, CURVE.n)); + }, + privateNegate: (privateKey) => { + const p = normalizePrivateKey(privateKey); + return numTo32b(CURVE.n - p); + }, + pointAddScalar: (p, tweak, isCompressed) => { + const P = Point.fromHex(p); + const t = normalizePrivateKey(tweak); + const Q = Point.BASE.multiplyAndAddUnsafe(P, t, _1n); + if (!Q) + throw new Error('Tweaked point at infinity'); + return Q.toRawBytes(isCompressed); + }, + pointMultiply: (p, tweak, isCompressed) => { + const P = Point.fromHex(p); + const t = bytesToNumber(ensureBytes(tweak)); + return P.multiply(t).toRawBytes(isCompressed); + }, + hashToPrivateKey: (hash) => { + hash = ensureBytes(hash); + if (hash.length < 40 || hash.length > 1024) + throw new Error('Expected 40-1024 bytes of private key as per FIPS 186'); + const num = mod(bytesToNumber(hash), CURVE.n - _1n) + _1n; + return numTo32b(num); + }, + randomBytes: (bytesLength = 32) => { + if (crypto.web) { + return crypto.web.getRandomValues(new Uint8Array(bytesLength)); + } + else if (crypto.node) { + const { randomBytes } = crypto.node; + return Uint8Array.from(randomBytes(bytesLength)); + } + else { + throw new Error("The environment doesn't have randomBytes function"); + } + }, + randomPrivateKey: () => { + return utils.hashToPrivateKey(utils.randomBytes(40)); + }, + bytesToHex, + hexToBytes, + concatBytes, + mod, + invert, + sha256: async (...messages) => { + if (crypto.web) { + const buffer = await crypto.web.subtle.digest('SHA-256', concatBytes(...messages)); + return new Uint8Array(buffer); + } + else if (crypto.node) { + const { createHash } = crypto.node; + const hash = createHash('sha256'); + messages.forEach((m) => hash.update(m)); + return Uint8Array.from(hash.digest()); + } + else { + throw new Error("The environment doesn't have sha256 function"); + } + }, + hmacSha256: async (key, ...messages) => { + if (crypto.web) { + const ckey = await crypto.web.subtle.importKey('raw', key, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']); + const message = concatBytes(...messages); + const buffer = await crypto.web.subtle.sign('HMAC', ckey, message); + return new Uint8Array(buffer); + } + else if (crypto.node) { + const { createHmac } = crypto.node; + const hash = createHmac('sha256', key); + messages.forEach((m) => hash.update(m)); + return Uint8Array.from(hash.digest()); + } + else { + throw new Error("The environment doesn't have hmac-sha256 function"); + } + }, + sha256Sync: undefined, + hmacSha256Sync: undefined, + taggedHash: async (tag, ...messages) => { + let tagP = TAGGED_HASH_PREFIXES[tag]; + if (tagP === undefined) { + const tagH = await utils.sha256(Uint8Array.from(tag, (c) => c.charCodeAt(0))); + tagP = concatBytes(tagH, tagH); + TAGGED_HASH_PREFIXES[tag] = tagP; + } + return utils.sha256(tagP, ...messages); + }, + taggedHashSync: (tag, ...messages) => { + if (typeof utils.sha256Sync !== 'function') + throw new Error('utils.sha256Sync is undefined, you need to set it'); + let tagP = TAGGED_HASH_PREFIXES[tag]; + if (tagP === undefined) { + const tagH = utils.sha256Sync(Uint8Array.from(tag, (c) => c.charCodeAt(0))); + tagP = concatBytes(tagH, tagH); + TAGGED_HASH_PREFIXES[tag] = tagP; + } + return utils.sha256Sync(tagP, ...messages); + }, + precompute(windowSize = 8, point = Point.BASE) { + const cached = point === Point.BASE ? point : new Point(point.x, point.y); + cached._setWindowSize(windowSize); + cached.multiply(_3n); + return cached; + }, + }; + + exports.CURVE = CURVE; + exports.Point = Point; + exports.Signature = Signature; + exports.getPublicKey = getPublicKey; + exports.getSharedSecret = getSharedSecret; + exports.recoverPublicKey = recoverPublicKey; + exports.schnorr = schnorr; + exports.sign = sign; + exports.signSync = signSync; + exports.utils = utils; + exports.verify = verify; + + Object.defineProperty(exports, '__esModule', { value: true }); + +})); diff --git a/lnbits/extensions/watchonly/static/js/index.js b/lnbits/extensions/watchonly/static/js/index.js index 68204aca..ad1cdc14 100644 --- a/lnbits/extensions/watchonly/static/js/index.js +++ b/lnbits/extensions/watchonly/static/js/index.js @@ -386,6 +386,13 @@ const watchOnly = async () => { this.addresses = addresses 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 () { diff --git a/lnbits/extensions/watchonly/static/js/utils.js b/lnbits/extensions/watchonly/static/js/utils.js index 6065d74c..5e39a37f 100644 --- a/lnbits/extensions/watchonly/static/js/utils.js +++ b/lnbits/extensions/watchonly/static/js/utils.js @@ -1,4 +1,6 @@ const PSBT_BASE64_PREFIX = 'cHNidP8' + +const COMMAND_PING = '/ping' const COMMAND_PASSWORD = '/password' const COMMAND_PASSWORD_CLEAR = '/password-clear' const COMMAND_SEND_PSBT = '/psbt' @@ -10,8 +12,24 @@ const COMMAND_RESTORE = '/restore' const COMMAND_CONFIRM_NEXT = '/confirm-next' const COMMAND_CANCEL = '/cancel' 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 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 => 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` } } + +function asciiToUint8Array(str) { + var chars = [] + for (var i = 0; i < str.length; ++i) { + chars.push(str.charCodeAt(i)) + } + return new Uint8Array(chars) +} diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html index 982f9041..b9ce361c 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -13,6 +13,7 @@ :network="config.network" :sats-denominated="config.sats_denominated" @signed:psbt="updateSignedPsbt" + class="q-pr-lg float-right" > @@ -33,7 +34,7 @@ {% raw %}
-
+
Scan Blockchain
-
+
-
+
@@ -170,14 +172,24 @@ >

+ + {{ currentAddress.address }} +

@@ -237,5 +249,8 @@ + + + {% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index b1d927af..27684f6b 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -418,16 +418,18 @@ Shareable link + >Copy sharable link + + >Write to NFC + >Print Close
diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index 800fecce..e0d3e56f 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -113,7 +113,7 @@ async def api_link_create_or_update( 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)): 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) - raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + return {"success": True} @withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK) diff --git a/lnbits/settings.py b/lnbits/settings.py index 5778b9e2..25e43eec 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -55,6 +55,8 @@ FAKE_WALLET = getattr(wallets_module, "FakeWallet")() DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet") 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) try: @@ -67,3 +69,13 @@ try: ) except: 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" +) diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js index b8c9f4d0..ab3f7f08 100644 --- a/lnbits/static/js/components.js +++ b/lnbits/static/js/components.js @@ -179,6 +179,11 @@ Vue.component('lnbits-extension-list', { Vue.component('lnbits-payment-details', { props: ['payment'], + data: function () { + return { + LNBITS_DENOMINATION: LNBITS_DENOMINATION + } + }, template: `
diff --git a/lnbits/tasks.py b/lnbits/tasks.py index f4d0a928..45e59c4c 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -86,6 +86,9 @@ async def check_pending_payments(): incoming = 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( since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago complete=False, @@ -94,11 +97,14 @@ async def check_pending_payments(): incoming=incoming, 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 if incoming: + logger.debug("Task: deleting all expired invoices") await delete_expired_invoices() + logger.debug("Task: expired invoice deletion finished") + # after the first check we will only check outgoing, not incoming # that will be handled by the global invoice listeners, hopefully incoming = False diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index f35eb370..e38b6d8f 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -18,13 +18,15 @@ class PaymentResponse(NamedTuple): # when ok is None it means we don't know if this succeeded ok: Optional[bool] = None checking_id: Optional[str] = None # payment_hash, rcp_id - fee_msat: int = 0 + fee_msat: Optional[int] = None preimage: Optional[str] = None error_message: Optional[str] = None class PaymentStatus(NamedTuple): paid: Optional[bool] = None + fee_msat: Optional[int] = None + preimage: Optional[str] = None @property def pending(self) -> bool: diff --git a/lnbits/wallets/cliche.py b/lnbits/wallets/cliche.py index 7c034717..9b862794 100644 --- a/lnbits/wallets/cliche.py +++ b/lnbits/wallets/cliche.py @@ -46,12 +46,19 @@ class ClicheWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: - if description_hash: - description_hash_hashed = hashlib.sha256(description_hash).hexdigest() + if unhashed_description or description_hash: + 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.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() else: @@ -74,31 +81,41 @@ class ClicheWallet(Wallet): data["result"]["invoice"], ) else: - return InvoiceResponse( - False, checking_id, payment_request, "Could not get payment hash" - ) + return InvoiceResponse(False, None, None, "Could not get payment hash") return InvoiceResponse(True, checking_id, payment_request, error_message) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: ws = create_connection(self.endpoint) ws.send(f"pay-invoice --invoice {bolt11}") - r = ws.recv() - data = json.loads(r) - checking_id = None - error_message = None + for _ in range(2): + r = ws.recv() + data = json.loads(r) + 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"): - logger.error(data["error"]["message"]) - error_message = data["error"]["message"] - return PaymentResponse(False, None, 0, error_message) + if data.get("error") is not None: + error_message = data["error"].get("message") + return PaymentResponse(False, None, None, None, error_message) - if data.get("result") is not None and data["result"].get("payment_hash"): - checking_id = data["result"]["payment_hash"] - else: - return PaymentResponse(False, checking_id, 0, "Could not get payment hash") + if data.get("method") == "payment_succeeded": + payment_ok = True + checking_id = data["params"]["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: ws = create_connection(self.endpoint) @@ -122,22 +139,30 @@ class ClicheWallet(Wallet): if data.get("error") is not None and data["error"].get("message"): logger.error(data["error"]["message"]) return PaymentStatus(None) - + payment = data["result"] 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]: - try: - ws = await create_connection(self.endpoint) - while True: - r = await ws.recv() - data = json.loads(r) - try: - if data["result"]["status"]: - yield data["result"]["payment_hash"] - except: - continue - except: - pass - logger.error("lost connection to cliche's websocket, retrying in 5 seconds") - await asyncio.sleep(5) + while True: + try: + ws = await create_connection(self.endpoint) + while True: + r = await ws.recv() + data = json.loads(r) + print(data) + try: + if data["result"]["status"]: + yield data["result"]["payment_hash"] + except: + continue + except Exception as exc: + logger.error( + f"lost connection to cliche's invoices stream: '{exc}', retrying in 5 seconds" + ) + await asyncio.sleep(5) + continue diff --git a/lnbits/wallets/cln.py b/lnbits/wallets/cln.py index a92501f2..48b96128 100644 --- a/lnbits/wallets/cln.py +++ b/lnbits/wallets/cln.py @@ -82,21 +82,26 @@ class CoreLightningWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: label = "lbl{}".format(random.random()) msat: int = int(amount * 1000) try: - if description_hash and not self.supports_description_hash: - raise Unsupported("description_hash") + if description_hash and not unhashed_description: + 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( msatoshi=msat, label=label, - description=description_hash.decode("utf-8") - if description_hash + description=unhashed_description.decode("utf-8") + if unhashed_description else memo, exposeprivatechannels=True, deschashonly=True - if description_hash + if unhashed_description else False, # we can't pass None here ) @@ -105,29 +110,38 @@ class CoreLightningWallet(Wallet): return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "") except RpcError as exc: - error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." - logger.error("RPC error:", error_message) + error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'." return InvoiceResponse(False, None, None, error_message) except Exception as e: - logger.error("error:", e) return InvoiceResponse(False, None, None, str(e)) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: invoice = lnbits_bolt11.decode(bolt11) + + 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 payload = { "bolt11": bolt11, "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: wrapped = async_wrap(_pay_invoice) 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: - 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( True, r["payment_hash"], fee_msat, r["payment_preimage"], None ) @@ -139,9 +153,16 @@ class CoreLightningWallet(Wallet): return PaymentStatus(None) if not r["invoices"]: return PaymentStatus(None) - if r["invoices"][0]["payment_hash"] == checking_id: - return PaymentStatus(r["invoices"][0]["status"] == "paid") - raise KeyError("supplied an invalid checking_id") + + invoice_resp = r["invoices"][-1] + + 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: try: @@ -150,14 +171,21 @@ class CoreLightningWallet(Wallet): return PaymentStatus(None) if not r["pays"]: return PaymentStatus(None) - if r["pays"][0]["payment_hash"] == checking_id: - status = r["pays"][0]["status"] + payment_resp = r["pays"][-1] + + if payment_resp["payment_hash"] == checking_id: + status = payment_resp["status"] 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": return PaymentStatus(False) 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]: while True: diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 0a4f1f3e..c03e3f53 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -50,7 +50,7 @@ class EclairWallet(Wallet): async def status(self) -> StatusResponse: async with httpx.AsyncClient() as client: r = await client.post( - f"{self.url}/usablebalances", headers=self.auth, timeout=40 + f"{self.url}/globalbalance", headers=self.auth, timeout=5 ) try: data = r.json() @@ -60,20 +60,25 @@ class EclairWallet(Wallet): ) 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( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: data: Dict = {"amountMsat": amount * 1000} 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: data["description"] = memo or "" @@ -111,13 +116,18 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(False, None, None, None, error_message) data = r.json() + if data["type"] == "payment-failed": + return PaymentResponse(False, None, None, None, "payment failed") + checking_id = data["paymentHash"] preimage = data["paymentPreimage"] + # We do all this again to get the fee: + async with httpx.AsyncClient() as client: r = await client.post( f"{self.url}/getsentinfo", @@ -133,15 +143,22 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse( - True, checking_id, 0, preimage, error_message - ) ## ?? is this ok ?? + return PaymentResponse(None, checking_id, None, preimage, error_message) - data = r.json() - fees = [i["status"] for i in data] - fee_msat = sum([i["feesPaid"] for i in fees]) + statuses = { + "sent": True, + "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 with httpx.AsyncClient() as client: @@ -152,54 +169,61 @@ class EclairWallet(Wallet): ) 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) - if data["status"]["type"] != "received": - return PaymentStatus(False) - - return PaymentStatus(True) + statuses = { + "received": True, + "expired": False, + "pending": None, + } + return PaymentStatus(statuses.get(data["status"]["type"])) async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( - url=f"{self.url}/getsentinfo", + f"{self.url}/getsentinfo", headers=self.auth, data={"paymentHash": checking_id}, + timeout=40, ) - data = r.json()[0] - if r.is_error: return PaymentStatus(None) - if data["status"]["type"] != "sent": - return PaymentStatus(False) + data = r.json()[-1] - 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]: + 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: - async with connect( - 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": + yield message["paymentHash"] - if message and message["type"] == "payment-received": - yield message["paymentHash"] - - except ( - OSError, - 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) + except Exception as exc: + logger.error( + f"lost connection to eclair invoices stream: '{exc}', retrying in 5 seconds" + ) + await asyncio.sleep(5) diff --git a/lnbits/wallets/fake.py b/lnbits/wallets/fake.py index 80a3d8c6..8424001b 100644 --- a/lnbits/wallets/fake.py +++ b/lnbits/wallets/fake.py @@ -35,6 +35,7 @@ class FakeWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: # we set a default secret since FakeWallet is used for internal=True invoices # and the user might not have configured a secret yet @@ -61,7 +62,10 @@ class FakeWallet(Wallet): data["timestamp"] = datetime.now().timestamp() if description_hash: 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: data["tags_set"] = ["d"] data["memo"] = memo diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index 677b518a..ddd80e77 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -57,12 +57,15 @@ class LNbitsWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: data: Dict = {"out": False, "amount": amount} if description_hash: - data["description_hash"] = hashlib.sha256(description_hash).hexdigest() - else: - data["memo"] = memo or "" + data["description_hash"] = description_hash.hex() + if unhashed_description: + data["unhashed_description"] = unhashed_description.hex() + + data["memo"] = memo or "" async with httpx.AsyncClient() as client: r = await client.post( @@ -91,15 +94,25 @@ class LNbitsWallet(Wallet): json={"out": True, "bolt11": bolt11}, 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: error_message = r.json()["detail"] + return PaymentResponse(None, None, None, None, error_message) else: 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: try: @@ -122,8 +135,11 @@ class LNbitsWallet(Wallet): if r.is_error: 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]: url = f"{self.endpoint}/api/v1/payments/sse" diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index fee8b955..a613ac9f 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -65,14 +65,32 @@ def get_ssl_context(cert_path: str): return context -def parse_checking_id(checking_id: str) -> bytes: +def b64_to_bytes(checking_id: str) -> bytes: 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("/", "_") +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 # we need to use that cipher suite otherwise there will be a handhsake # error when we communicate with the lnd rpc server. @@ -134,14 +152,15 @@ class LndWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: params: Dict = {"value": amount, "expiry": 600, "private": True} - if description_hash: + params["description_hash"] = description_hash + elif unhashed_description: params["description_hash"] = hashlib.sha256( - description_hash + unhashed_description ).digest() # as bytes directly - else: params["memo"] = memo or "" @@ -152,7 +171,7 @@ class LndWallet(Wallet): error_message = str(exc) 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) return InvoiceResponse(True, checking_id, payment_request, None) @@ -167,9 +186,9 @@ class LndWallet(Wallet): try: resp = await self.routerpc.SendPaymentV2(req).read() except RpcError as exc: - return PaymentResponse(False, "", 0, None, exc._details) + return PaymentResponse(False, None, None, None, exc._details) 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 statuses = { @@ -179,29 +198,31 @@ class LndWallet(Wallet): 3: False, # FAILED } - if resp.status in [0, 1, 3]: - fee_msat = 0 - preimage = "" - checking_id = "" - elif resp.status == 2: # SUCCEEDED - fee_msat = resp.htlcs[-1].route.total_fees_msat - preimage = resp.payment_preimage - checking_id = resp.payment_hash + fee_msat = None + preimage = None + checking_id = resp.payment_hash + + if resp.status: # SUCCEEDED + fee_msat = -resp.htlcs[-1].route.total_fees_msat + preimage = bytes_to_hex(resp.payment_preimage) + return PaymentResponse( statuses[resp.status], checking_id, fee_msat, preimage, None ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: - r_hash = parse_checking_id(checking_id) + r_hash = hex_to_bytes(checking_id) if len(r_hash) != 32: raise binascii.Error except binascii.Error: # this may happen if we switch between backend wallets # that use different checking_id formats return PaymentStatus(None) - - resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) + try: + resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) + except RpcError as exc: + return PaymentStatus(None) if resp.settled: return PaymentStatus(True) @@ -212,7 +233,7 @@ class LndWallet(Wallet): This routine checks the payment status using routerpc.TrackPaymentV2. """ try: - r_hash = parse_checking_id(checking_id) + r_hash = hex_to_bytes(checking_id) if len(r_hash) != 32: raise binascii.Error except binascii.Error: @@ -220,11 +241,6 @@ class LndWallet(Wallet): # that use different checking_id formats 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( router.TrackPaymentRequest(payment_hash=r_hash) ) @@ -239,6 +255,12 @@ class LndWallet(Wallet): try: 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]) except: # most likely the payment wasn't found return PaymentStatus(None) @@ -247,13 +269,13 @@ class LndWallet(Wallet): async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: - request = ln.InvoiceSubscription() try: + request = ln.InvoiceSubscription() async for i in self.rpc.SubscribeInvoices(request): if not i.settled: continue - checking_id = stringify_checking_id(i.r_hash) + checking_id = bytes_to_hex(i.r_hash) yield checking_id except Exception as exc: logger.error( diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 9f7f9558..1083e48a 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -73,11 +73,17 @@ class LndRestWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, ) -> InvoiceResponse: data: Dict = {"value": amount, "private": True} if description_hash: + data["description_hash"] = base64.b64encode(description_hash).decode( + "ascii" + ) + elif unhashed_description: data["description_hash"] = base64.b64encode( - hashlib.sha256(description_hash).digest() + hashlib.sha256(unhashed_description).digest() ).decode("ascii") else: data["memo"] = memo or "" @@ -117,18 +123,15 @@ class LndRestWallet(Wallet): if r.is_error or r.json().get("payment_error"): 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() - payment_hash = data["payment_hash"] - checking_id = payment_hash + checking_id = base64.b64decode(data["payment_hash"]).hex() fee_msat = int(data["payment_route"]["total_fees_msat"]) preimage = base64.b64decode(data["payment_preimage"]).hex() return PaymentResponse(True, checking_id, fee_msat, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - checking_id = checking_id.replace("_", "/") - async with httpx.AsyncClient(verify=self.cert) as client: r = await client.get( 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. """ + # 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}" # check payment.status: - # https://api.lightning.community/rest/index.html?python#peersynctype + # https://api.lightning.community/?python=#paymentpaymentstatus statuses = { "UNKNOWN": None, "IN_FLIGHT": None, @@ -172,7 +183,11 @@ class LndRestWallet(Wallet): return PaymentStatus(None) payment = line.get("result") 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: return PaymentStatus(None) except: @@ -181,10 +196,9 @@ class LndRestWallet(Wallet): return PaymentStatus(None) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - url = self.endpoint + "/v1/invoices/subscribe" - while True: try: + url = self.endpoint + "/v1/invoices/subscribe" async with httpx.AsyncClient( timeout=None, headers=self.auth, verify=self.cert ) as client: diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 7ba45a22..5db68e1f 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -52,10 +52,14 @@ class LNPayWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, ) -> InvoiceResponse: data: Dict = {"num_satoshis": f"{amount}"} 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: data["memo"] = memo or "" @@ -96,7 +100,7 @@ class LNPayWallet(Wallet): ) 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"] fee_msat = 0 @@ -109,15 +113,18 @@ class LNPayWallet(Wallet): async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.get( - url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", + url=f"{self.endpoint}/lntx/{checking_id}", headers=self.auth, ) if r.is_error: return PaymentStatus(None) + data = r.json() + preimage = data["payment_preimage"] + fee_msat = data["fee_msat"] 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]: self.queue: asyncio.Queue = asyncio.Queue(0) diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index 9b0954e9..13046d26 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -52,10 +52,14 @@ class LntxbotWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, ) -> InvoiceResponse: data: Dict = {"amt": str(amount)} 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: data["memo"] = memo or "" @@ -93,10 +97,11 @@ class LntxbotWallet(Wallet): except: error_message = r.text pass - - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(False, None, None, None, error_message) data = r.json() + if data.get("type") != "paid_invoice": + return PaymentResponse(None) checking_id = data["payment_hash"] fee_msat = -data["fee_msat"] preimage = data["payment_preimage"] diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 0760d186..f7dcba40 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -47,15 +47,17 @@ class OpenNodeWallet(Wallet): if r.is_error: 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( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, ) -> InvoiceResponse: - if description_hash: + if description_hash or unhashed_description: raise Unsupported("description_hash") async with httpx.AsyncClient() as client: @@ -90,11 +92,15 @@ class OpenNodeWallet(Wallet): if r.is_error: 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"] 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) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: @@ -104,9 +110,9 @@ class OpenNodeWallet(Wallet): ) if r.is_error: return PaymentStatus(None) - - statuses = {"processing": None, "paid": True, "unpaid": False} - return PaymentStatus(statuses[r.json()["data"]["status"]]) + data = r.json()["data"] + statuses = {"processing": None, "paid": True, "unpaid": None} + return PaymentStatus(statuses[data.get("status")]) async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: @@ -117,14 +123,16 @@ class OpenNodeWallet(Wallet): if r.is_error: return PaymentStatus(None) + data = r.json()["data"] statuses = { "initial": None, "pending": None, "confirmed": True, - "error": False, + "error": None, "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]: self.queue: asyncio.Queue = asyncio.Queue(0) diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 55758aab..414d4e47 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -93,6 +93,8 @@ class SparkWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, ) -> InvoiceResponse: label = "lbs{}".format(random.random()) checking_id = label @@ -102,7 +104,13 @@ class SparkWallet(Wallet): r = await self.invoicewithdescriptionhash( msatoshi=amount * 1000, 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: r = await self.invoice( @@ -129,7 +137,7 @@ class SparkWallet(Wallet): pays = listpays["pays"] if len(pays) == 0: - return PaymentResponse(False, None, 0, None, str(exc)) + return PaymentResponse(False, None, None, None, str(exc)) pay = pays[0] payment_hash = pay["payment_hash"] @@ -140,11 +148,9 @@ class SparkWallet(Wallet): ) if pay["status"] == "failed": - return PaymentResponse(False, None, 0, None, str(exc)) + return PaymentResponse(False, None, None, None, str(exc)) elif pay["status"] == "pending": - return PaymentResponse( - None, payment_hash, fee_limit_msat, None, None - ) + return PaymentResponse(None, payment_hash, None, None, None) elif pay["status"] == "complete": r = pay r["payment_preimage"] = pay["preimage"] @@ -155,7 +161,7 @@ class SparkWallet(Wallet): # this is good pass - fee_msat = r["msatoshi_sent"] - r["msatoshi"] + fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"]) preimage = r["payment_preimage"] 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: status = r["pays"][0]["status"] 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": return PaymentStatus(False) return PaymentStatus(None) diff --git a/lnbits/wallets/void.py b/lnbits/wallets/void.py index d6a01d3a..0de387aa 100644 --- a/lnbits/wallets/void.py +++ b/lnbits/wallets/void.py @@ -18,6 +18,7 @@ class VoidWallet(Wallet): amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, + **kwargs, ) -> InvoiceResponse: raise Unsupported("") diff --git a/tests/core/views/test_api.py b/tests/core/views/test_api.py index 9dd13004..e0f6b576 100644 --- a/tests/core/views/test_api.py +++ b/tests/core/views/test_api.py @@ -11,8 +11,9 @@ from lnbits.core.views.api import ( api_payment, 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 @@ -44,6 +45,20 @@ async def test_get_wallet_adminkey(client, adminkey_headers_to): 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 @pytest.mark.asyncio 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] @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): data = {"out": True, "bolt11": invoice["payment_request"]} # 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 +@pytest.mark.skipif( + wallet_class.__name__ in ["CoreLightningWallet"], + reason="wallet does not support description_hash", +) @pytest.mark.asyncio async def test_create_invoice_with_description_hash(client, inkey_headers_to): data = await get_random_invoice_data() 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( "/api/v1/payments", json=data, headers=inkey_headers_to diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip index 6f7165b3..d184f94a 100644 Binary files a/tests/data/mock_data.zip and b/tests/data/mock_data.zip differ diff --git a/tests/extensions/bleskomat/test_lnurl_api.py b/tests/extensions/bleskomat/test_lnurl_api.py index 0100a62d..3f723266 100644 --- a/tests/extensions/bleskomat/test_lnurl_api.py +++ b/tests/extensions/bleskomat/test_lnurl_api.py @@ -12,7 +12,7 @@ from lnbits.extensions.bleskomat.helpers import ( from lnbits.settings import HOST, PORT from tests.conftest import client 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 @@ -97,6 +97,7 @@ async def test_bleskomat_lnurl_api_valid_signature(client, bleskomat): @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): bleskomat = lnurl["bleskomat"] secret = lnurl["secret"] @@ -116,6 +117,7 @@ async def test_bleskomat_lnurl_api_action_insufficient_balance(client, lnurl): @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): bleskomat = lnurl["bleskomat"] secret = lnurl["secret"] diff --git a/tests/extensions/boltz/__init__.py b/tests/extensions/boltz/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/extensions/boltz/conftest.py b/tests/extensions/boltz/conftest.py new file mode 100644 index 00000000..b9ef7887 --- /dev/null +++ b/tests/extensions/boltz/conftest.py @@ -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) diff --git a/tests/extensions/boltz/test_api.py b/tests/extensions/boltz/test_api.py new file mode 100644 index 00000000..20b6e5a4 --- /dev/null +++ b/tests/extensions/boltz/test_api.py @@ -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 diff --git a/tests/extensions/boltz/test_swap.py b/tests/extensions/boltz/test_swap.py new file mode 100644 index 00000000..ab5954ac --- /dev/null +++ b/tests/extensions/boltz/test_swap.py @@ -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" diff --git a/tests/extensions/invoices/__init__.py b/tests/extensions/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/extensions/invoices/conftest.py b/tests/extensions/invoices/conftest.py new file mode 100644 index 00000000..09ac42ec --- /dev/null +++ b/tests/extensions/invoices/conftest.py @@ -0,0 +1,37 @@ +import pytest +import pytest_asyncio + +from lnbits.core.crud import create_account, create_wallet +from lnbits.extensions.invoices.crud import ( + create_invoice_internal, + create_invoice_items, +) +from lnbits.extensions.invoices.models import CreateInvoiceData + + +@pytest_asyncio.fixture +async def invoices_wallet(): + user = await create_account() + wallet = await create_wallet(user_id=user.id, wallet_name="invoices_test") + + return wallet + + +@pytest_asyncio.fixture +async def accounting_invoice(invoices_wallet): + invoice_data = CreateInvoiceData( + status="open", + currency="USD", + company_name="LNBits, Inc", + first_name="Ben", + last_name="Arc", + items=[{"amount": 10.20, "description": "Item costs 10.20"}], + ) + invoice = await create_invoice_internal( + wallet_id=invoices_wallet.id, data=invoice_data + ) + items = await create_invoice_items(invoice_id=invoice.id, data=invoice_data.items) + + invoice_dict = invoice.dict() + invoice_dict["items"] = items + return invoice_dict diff --git a/tests/extensions/invoices/test_invoices_api.py b/tests/extensions/invoices/test_invoices_api.py new file mode 100644 index 00000000..eaadd07b --- /dev/null +++ b/tests/extensions/invoices/test_invoices_api.py @@ -0,0 +1,135 @@ +import pytest +import pytest_asyncio +from loguru import logger + +from lnbits.core.crud import get_wallet +from tests.conftest import adminkey_headers_from, client, invoice +from tests.extensions.invoices.conftest import accounting_invoice, invoices_wallet +from tests.helpers import credit_wallet +from tests.mocks import WALLET + + +@pytest.mark.asyncio +async def test_invoices_unknown_invoice(client): + response = await client.get("/invoices/pay/u") + assert response.json() == {"detail": "Invoice does not exist."} + + +@pytest.mark.asyncio +async def test_invoices_api_create_invoice_valid(client, invoices_wallet): + query = { + "status": "open", + "currency": "EUR", + "company_name": "LNBits, Inc.", + "first_name": "Ben", + "last_name": "Arc", + "email": "ben@legend.arc", + "items": [ + {"amount": 2.34, "description": "Item 1"}, + {"amount": 0.98, "description": "Item 2"}, + ], + } + + status = query["status"] + currency = query["currency"] + fname = query["first_name"] + total = sum(d["amount"] for d in query["items"]) + + response = await client.post( + "/invoices/api/v1/invoice", + json=query, + headers={"X-Api-Key": invoices_wallet.inkey}, + ) + + assert response.status_code == 201 + data = response.json() + + assert data["status"] == status + assert data["wallet"] == invoices_wallet.id + assert data["currency"] == currency + assert data["first_name"] == fname + assert sum(d["amount"] / 100 for d in data["items"]) == total + + +@pytest.mark.asyncio +async def test_invoices_api_partial_pay_invoice( + client, accounting_invoice, adminkey_headers_from +): + invoice_id = accounting_invoice["id"] + amount_to_pay = int(5.05 * 100) # mock invoice total amount is 10 USD + + # ask for an invoice + response = await client.post( + f"/invoices/api/v1/invoice/{invoice_id}/payments?famount={amount_to_pay}" + ) + assert response.status_code < 300 + data = response.json() + payment_hash = data["payment_hash"] + + # pay the invoice + data = {"out": True, "bolt11": data["payment_request"]} + response = await client.post( + "/api/v1/payments", json=data, headers=adminkey_headers_from + ) + assert response.status_code < 300 + assert len(response.json()["payment_hash"]) == 64 + assert len(response.json()["checking_id"]) > 0 + + # check invoice is paid + response = await client.get( + f"/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}" + ) + assert response.status_code == 200 + assert response.json()["paid"] == True + + # check invoice status + response = await client.get(f"/invoices/api/v1/invoice/{invoice_id}") + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "open" + + +#### +# +# TEST FAILS FOR NOW, AS LISTENERS ARE NOT WORKING ON TESTING +# +### + +# @pytest.mark.asyncio +# async def test_invoices_api_full_pay_invoice(client, accounting_invoice, adminkey_headers_to): +# invoice_id = accounting_invoice["id"] +# print(accounting_invoice["id"]) +# amount_to_pay = int(10.20 * 100) + +# # ask for an invoice +# response = await client.post( +# f"/invoices/api/v1/invoice/{invoice_id}/payments?famount={amount_to_pay}" +# ) +# assert response.status_code == 201 +# data = response.json() +# payment_hash = data["payment_hash"] + +# # pay the invoice +# data = {"out": True, "bolt11": data["payment_request"]} +# response = await client.post( +# "/api/v1/payments", json=data, headers=adminkey_headers_to +# ) +# assert response.status_code < 300 +# assert len(response.json()["payment_hash"]) == 64 +# assert len(response.json()["checking_id"]) > 0 + +# # check invoice is paid +# response = await client.get( +# f"/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}" +# ) +# assert response.status_code == 200 +# assert response.json()["paid"] == True + +# # check invoice status +# response = await client.get(f"/invoices/api/v1/invoice/{invoice_id}") +# assert response.status_code == 200 +# data = response.json() + +# print(data) +# assert data["status"] == "paid" diff --git a/tests/helpers.py b/tests/helpers.py index 7be26ce3..fc5931bc 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -4,6 +4,7 @@ import secrets import string from lnbits.core.crud import create_payment +from lnbits.settings import wallet_class async def credit_wallet(wallet_id: str, amount: int): @@ -32,3 +33,7 @@ def get_random_string(N=10): async def get_random_invoice_data(): return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"} + + +is_fake: bool = wallet_class.__name__ == "FakeWallet" +is_regtest: bool = not is_fake diff --git a/tests/mocks.py b/tests/mocks.py index 65e8440a..3fc0efae 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -5,7 +5,7 @@ from lnbits.settings import WALLET from lnbits.wallets.base import PaymentResponse, PaymentStatus, StatusResponse from lnbits.wallets.fake import FakeWallet -from .helpers import get_random_string +from .helpers import get_random_string, is_fake # generates an invoice with FakeWallet @@ -16,12 +16,13 @@ async def generate_mock_invoice(**x): return invoice -WALLET.status = AsyncMock( - return_value=StatusResponse( - "", # no error - 1000000, # msats +if is_fake: + WALLET.status = AsyncMock( + return_value=StatusResponse( + "", # no error + 1000000, # msats + ) ) -) # Note: if this line is uncommented, invoices will always be generated by FakeWallet # WALLET.create_invoice = generate_mock_invoice @@ -51,26 +52,27 @@ WALLET.status = AsyncMock( # ) -def pay_invoice_side_effect( - payment_request: str, fee_limit_msat: int -) -> PaymentResponse: - invoice = bolt11.decode(payment_request) - return PaymentResponse( - True, # ok - invoice.payment_hash, # checking_id (i.e. payment_hash) - 0, # fee_msat - "", # no error - ) +if is_fake: + def pay_invoice_side_effect( + payment_request: str, fee_limit_msat: int + ) -> PaymentResponse: + invoice = bolt11.decode(payment_request) + return PaymentResponse( + True, # ok + invoice.payment_hash, # checking_id (i.e. payment_hash) + 0, # fee_msat + "", # no error + ) -WALLET.pay_invoice = AsyncMock(side_effect=pay_invoice_side_effect) -WALLET.get_invoice_status = AsyncMock( - return_value=PaymentStatus( - True, # paid + WALLET.pay_invoice = AsyncMock(side_effect=pay_invoice_side_effect) + WALLET.get_invoice_status = AsyncMock( + return_value=PaymentStatus( + True, # paid + ) ) -) -WALLET.get_payment_status = AsyncMock( - return_value=PaymentStatus( - True, # paid + WALLET.get_payment_status = AsyncMock( + return_value=PaymentStatus( + True, # paid + ) ) -) diff --git a/tools/conv.py b/tools/conv.py index 10e8c9cd..5084660f 100644 --- a/tools/conv.py +++ b/tools/conv.py @@ -19,16 +19,12 @@ env.read_env() # Change these values as needed -sqfolder = "data/" +sqfolder = env.str("LNBITS_DATA_FOLDER", default=None) LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None) if LNBITS_DATABASE_URL is None: - pgdb = "lnbits" - pguser = "lnbits" - pgpswd = "postgres" - pghost = "localhost" - pgport = "5432" - pgschema = "" + print("missing LNBITS_DATABASE_URL") + sys.exit(1) else: # parse postgres://lnbits:postgres@localhost:5432/lnbits pgdb = LNBITS_DATABASE_URL.split("/")[-1] @@ -129,7 +125,7 @@ def migrate_db(file: str, schema: str, exclude_tables: List[str] = []): sq = get_sqlite_cursor(file) tables = sq.execute( """ - SELECT name FROM sqlite_master + SELECT name FROM sqlite_master WHERE type='table' AND name not like 'sqlite?_%' escape '?' """ ).fetchall()