diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index e35c9f93..250a66c7 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -17,7 +17,7 @@ jobs: - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | - docker build -t lnbits-legend . + docker build -t lnbitsdocker/lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker chmod +x ./tests @@ -56,7 +56,7 @@ jobs: - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | - docker build -t lnbits-legend . + docker build -t lnbitsdocker/lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker chmod +x ./tests @@ -97,7 +97,7 @@ jobs: - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | - docker build -t lnbits-legend . + 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 80ae00c2..6b2fdeb7 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,10 @@ test: 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 \ diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 6de893ad..6f9d0d4f 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -10,7 +10,7 @@ You can choose between four package managers, `poetry`, `nix` and `venv`. By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below). -## Option 1: poetry +## Option 1 (recommended): poetry ```sh git clone https://github.com/lnbits/lnbits-legend.git @@ -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. diff --git a/lnbits/app.py b/lnbits/app.py index fb750eb3..f612c32c 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -122,10 +122,10 @@ def check_funding_source(app: FastAPI) -> None: f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", RuntimeWarning, ) - logger.info("Retrying connection to backend in 5 seconds...") - await asyncio.sleep(5) 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." diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f150270a..4122f902 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -327,6 +327,7 @@ async def delete_expired_invoices( AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)} """ ) + logger.debug(f"Checking expiry of {len(rows)} invoices") for (payment_request,) in rows: try: invoice = bolt11.decode(payment_request) @@ -336,7 +337,9 @@ async def delete_expired_invoices( expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry) if expiration_date > datetime.datetime.utcnow(): continue - logger.debug(f"Deleting expired invoice: {invoice.payment_hash}") + logger.debug( + f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})" + ) await (conn or db).execute( """ DELETE FROM apipayments @@ -365,6 +368,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 +412,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 90f62186..a6e0b43a 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -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 @@ -101,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.") @@ -185,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 @@ -344,23 +366,16 @@ 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 diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 15cab4b6..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 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" > Tutorial + +**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!*** + +***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp). + +## About the keys + +Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set: + +One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1. + +One for calculating CMAC (c parameter), let's called it file key, key #02 or K2. + +The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app. + +***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!*** + +## Setting the card - bolt-nfc-android-app (easy way) +So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer. + +- Read the card with the app. Note UID so you can fill it in the extension later. +- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}` + - `{external_id}` should be replaced with the External ID found in the LNBits dialog. + +- Add new card in the extension. + - Set a max sats per transaction. Any transaction greater than this amount will be rejected. + - Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected. + - Set a card name. This is just for your reference inside LNBits. + - Set the card UID. This is the unique identifier on your NFC card and is 7 bytes. + - If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field. + - Advanced Options + - Card Keys (k0, k1, k2) will be automatically generated if not explicitly set. + - Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode. + - GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead. + - Click CREATE CARD button +- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys. +- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys. +- Tap the NFC card to write the keys to the card. + +## Setting the card - computer (hard way) + +Follow the guide. + +The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000` + +Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0. + +## Setting the card - android NXP app (hard way) +- If you don't know the card ID, use NXP TagInfo app to find it out. +- In the TagWriter app tap Write tags +- New Data Set > Link +- Set URI type to Custom URL +- URL should look like lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000 +- click Configure mirroring options +- Select Card Type NTAG 424 DNA +- Check Enable SDM Mirroring +- Select SDM Meta Read Access Right to 01 +- Check Enable UID Mirroring +- Check Enable Counter Mirroring +- Set SDM Counter Retrieval Key to 0E +- Set PICC Data Offset to immediately after e= +- Set Derivation Key for CMAC Calculation to 00 +- Set SDM MAC Input Offset to immediately after c= +- Set SDM MAC Offset to immediately after c= +- Save & Write +- Scan with compatible Wallet + +This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0. diff --git a/lnbits/extensions/boltcards/__init__.py b/lnbits/extensions/boltcards/__init__.py new file mode 100644 index 00000000..bfdc7492 --- /dev/null +++ b/lnbits/extensions/boltcards/__init__.py @@ -0,0 +1,37 @@ +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_boltcards") + +boltcards_static_files = [ + { + "path": "/boltcards/static", + "app": StaticFiles(packages=[("lnbits", "extensions/boltcards/static")]), + "name": "boltcards_static", + } +] + +boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"]) + + +def boltcards_renderer(): + return template_renderer(["lnbits/extensions/boltcards/templates"]) + + +from .lnurl import * # noqa +from .tasks import * # noqa + + +def boltcards_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/boltcards/config.json b/lnbits/extensions/boltcards/config.json new file mode 100644 index 00000000..e46070d3 --- /dev/null +++ b/lnbits/extensions/boltcards/config.json @@ -0,0 +1,6 @@ +{ + "name": "Bolt Cards", + "short_description": "Self custody Bolt Cards with one time LNURLw", + "icon": "payment", + "contributors": ["iwarpbtc", "arcbtc", "leesalminen"] +} diff --git a/lnbits/extensions/boltcards/crud.py b/lnbits/extensions/boltcards/crud.py new file mode 100644 index 00000000..c541346e --- /dev/null +++ b/lnbits/extensions/boltcards/crud.py @@ -0,0 +1,273 @@ +import secrets +from datetime import date, datetime +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Card, CreateCardData, Hit, Refund + + +async def create_card(data: CreateCardData, wallet_id: str) -> Card: + card_id = urlsafe_short_hash().upper() + extenal_id = urlsafe_short_hash().lower() + + await db.execute( + """ + INSERT INTO boltcards.cards ( + id, + uid, + external_id, + wallet, + card_name, + counter, + tx_limit, + daily_limit, + enable, + k0, + k1, + k2, + otp + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + card_id, + data.uid.upper(), + extenal_id, + wallet_id, + data.card_name, + data.counter, + data.tx_limit, + data.daily_limit, + True, + data.k0, + data.k1, + data.k2, + secrets.token_hex(16), + ), + ) + card = await get_card(card_id) + assert card, "Newly created card couldn't be retrieved" + return card + + +async def update_card(card_id: str, **kwargs) -> Optional[Card]: + if "is_unique" in kwargs: + kwargs["is_unique"] = int(kwargs["is_unique"]) + if "uid" in kwargs: + kwargs["uid"] = kwargs["uid"].upper() + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE boltcards.cards SET {q} WHERE id = ?", + (*kwargs.values(), card_id), + ) + row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) + return Card(**row) if row else None + + +async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Card(**row) for row in rows] + + +async def get_card(card_id: str) -> Optional[Card]: + row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def get_card_by_uid(card_uid: str) -> Optional[Card]: + row = await db.fetchone( + "SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),) + ) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def get_card_by_external_id(external_id: str) -> Optional[Card]: + row = await db.fetchone( + "SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),) + ) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def get_card_by_otp(otp: str) -> Optional[Card]: + row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,)) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def delete_card(card_id: str) -> None: + # Delete cards + card = await get_card(card_id) + await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,)) + # Delete hits + hits = await get_hits([card_id]) + for hit in hits: + await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,)) + # Delete refunds + refunds = await get_refunds([hit]) + for refund in refunds: + await db.execute( + "DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,) + ) + + +async def update_card_counter(counter: int, id: str): + await db.execute( + "UPDATE boltcards.cards SET counter = ? WHERE id = ?", + (counter, id), + ) + + +async def enable_disable_card(enable: bool, id: str) -> Optional[Card]: + row = await db.execute( + "UPDATE boltcards.cards SET enable = ? WHERE id = ?", + (enable, id), + ) + return await get_card(id) + + +async def update_card_otp(otp: str, id: str): + await db.execute( + "UPDATE boltcards.cards SET otp = ? WHERE id = ?", + (otp, id), + ) + + +async def get_hit(hit_id: str) -> Optional[Hit]: + row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id)) + if not row: + return None + + hit = dict(**row) + + return Hit.parse_obj(hit) + + +async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]: + q = ",".join(["?"] * len(cards_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,) + ) + + return [Hit(**row) for row in rows] + + +async def get_hits_today(card_id: str) -> Optional[Hit]: + rows = await db.fetchall( + f"SELECT * FROM boltcards.hits WHERE card_id = ?", + (card_id,), + ) + updatedrow = [] + for row in rows: + if datetime.now().date() == datetime.fromtimestamp(row.time).date(): + updatedrow.append(row) + + return [Hit(**row) for row in updatedrow] + + +async def spend_hit(id: str, amount: int): + await db.execute( + "UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?", + (True, amount, id), + ) + return await get_hit(id) + + +async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: + hit_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO boltcards.hits ( + id, + card_id, + ip, + spent, + useragent, + old_ctr, + new_ctr, + amount + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + hit_id, + card_id, + ip, + False, + useragent, + old_ctr, + new_ctr, + 0, + ), + ) + hit = await get_hit(hit_id) + assert hit, "Newly recorded hit couldn't be retrieved" + return hit + + +async def create_refund(hit_id, refund_amount) -> Refund: + refund_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO boltcards.refunds ( + id, + hit_id, + refund_amount + ) + VALUES (?, ?, ?) + """, + ( + refund_id, + hit_id, + refund_amount, + ), + ) + refund = await get_refund(refund_id) + assert refund, "Newly recorded hit couldn't be retrieved" + return refund + + +async def get_refund(refund_id: str) -> Optional[Refund]: + row = await db.fetchone( + f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id) + ) + if not row: + return None + refund = dict(**row) + return Refund.parse_obj(refund) + + +async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]: + q = ",".join(["?"] * len(hits_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,) + ) + + return [Refund(**row) for row in rows] diff --git a/lnbits/extensions/boltcards/lnurl.py b/lnbits/extensions/boltcards/lnurl.py new file mode 100644 index 00000000..6fb9ad8d --- /dev/null +++ b/lnbits/extensions/boltcards/lnurl.py @@ -0,0 +1,219 @@ +import base64 +import hashlib +import hmac +import json +import secrets +from http import HTTPStatus +from io import BytesIO +from typing import Optional +from urllib.parse import urlparse + +from embit import bech32, compact +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends, Query +from lnurl import Lnurl, LnurlWithdrawResponse +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from loguru import logger +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from lnbits import bolt11 +from lnbits.core.services import create_invoice +from lnbits.core.views.api import pay_invoice + +from . import boltcards_ext +from .crud import ( + create_hit, + get_card, + get_card_by_external_id, + get_card_by_otp, + get_hit, + get_hits_today, + spend_hit, + update_card, + update_card_counter, + update_card_otp, +) +from .models import CreateCardData +from .nxp424 import decryptSUN, getSunMAC + +###############LNURLWITHDRAW################# + +# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 +@boltcards_ext.get("/api/v1/scan/{external_id}") +async def api_scan(p, c, request: Request, external_id: str = None): + # some wallets send everything as lower case, no bueno + p = p.upper() + c = c.upper() + card = None + counter = b"" + card = await get_card_by_external_id(external_id) + if not card: + return {"status": "ERROR", "reason": "No card."} + if not card.enable: + return {"status": "ERROR", "reason": "Card is disabled."} + try: + card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1)) + if card.uid.upper() != card_uid.hex().upper(): + return {"status": "ERROR", "reason": "Card UID mis-match."} + if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): + return {"status": "ERROR", "reason": "CMAC does not check."} + except: + return {"status": "ERROR", "reason": "Error decrypting card."} + + ctr_int = int.from_bytes(counter, "little") + + if ctr_int <= card.counter: + return {"status": "ERROR", "reason": "This link is already used."} + + await update_card_counter(ctr_int, card.id) + + # gathering some info for hit record + ip = request.client.host + if "x-real-ip" in request.headers: + ip = request.headers["x-real-ip"] + elif "x-forwarded-for" in request.headers: + ip = request.headers["x-forwarded-for"] + + agent = request.headers["user-agent"] if "user-agent" in request.headers else "" + todays_hits = await get_hits_today(card.id) + + hits_amount = 0 + for hit in todays_hits: + hits_amount = hits_amount + hit.amount + if hits_amount > card.daily_limit: + return {"status": "ERROR", "reason": "Max daily limit spent."} + hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) + lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) + return { + "tag": "withdrawRequest", + "callback": request.url_for("boltcards.lnurl_callback", hitid=hit.id), + "k1": hit.id, + "minWithdrawable": 1 * 1000, + "maxWithdrawable": card.tx_limit * 1000, + "defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay})", + } + + +@boltcards_ext.get( + "/api/v1/lnurl/cb/{hitid}", + status_code=HTTPStatus.OK, + name="boltcards.lnurl_callback", +) +async def lnurl_callback( + request: Request, + pr: str = Query(None), + k1: str = Query(None), +): + hit = await get_hit(k1) + card = await get_card(hit.card_id) + if not hit: + return {"status": "ERROR", "reason": f"LNURL-pay record not found."} + if hit.id != k1: + return {"status": "ERROR", "reason": "Bad K1"} + if hit.spent: + return {"status": "ERROR", "reason": f"Payment already claimed"} + invoice = bolt11.decode(pr) + hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000)) + try: + await pay_invoice( + wallet_id=card.wallet, + payment_request=pr, + max_sat=card.tx_limit, + extra={"tag": "boltcard", "tag": hit.id}, + ) + return {"status": "OK"} + except: + return {"status": "ERROR", "reason": f"Payment failed"} + + +# /boltcards/api/v1/auth?a=00000000000000000000000000000000 +@boltcards_ext.get("/api/v1/auth") +async def api_auth(a, request: Request): + if a == "00000000000000000000000000000000": + response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32} + return response + + card = await get_card_by_otp(a) + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + new_otp = secrets.token_hex(16) + await update_card_otp(new_otp, card.id) + + lnurlw_base = ( + f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}" + ) + + response = { + "card_name": card.card_name, + "id": 1, + "k0": card.k0, + "k1": card.k1, + "k2": card.k2, + "k3": card.k1, + "k4": card.k2, + "lnurlw_base": "lnurlw://" + lnurlw_base, + "protocol_name": "new_bolt_card_response", + "protocol_version": 1, + } + + return response + + +###############LNURLPAY REFUNDS################# + + +@boltcards_ext.get( + "/api/v1/lnurlp/{hit_id}", + response_class=HTMLResponse, + name="boltcards.lnurlp_response", +) +async def lnurlp_response(req: Request, hit_id: str = Query(None)): + hit = await get_hit(hit_id) + card = await get_card(hit.card_id) + if not hit: + return {"status": "ERROR", "reason": f"LNURL-pay record not found."} + if not card.enable: + return {"status": "ERROR", "reason": "Card is disabled."} + payResponse = { + "tag": "payRequest", + "callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id), + "metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), + "minSendable": 1 * 1000, + "maxSendable": card.tx_limit * 1000, + } + return json.dumps(payResponse) + + +@boltcards_ext.get( + "/api/v1/lnurlp/cb/{hit_id}", + response_class=HTMLResponse, + name="boltcards.lnurlp_callback", +) +async def lnurlp_callback( + req: Request, hit_id: str = Query(None), amount: str = Query(None) +): + hit = await get_hit(hit_id) + card = await get_card(hit.card_id) + if not hit: + return {"status": "ERROR", "reason": f"LNURL-pay record not found."} + + payment_hash, payment_request = await create_invoice( + wallet_id=card.wallet, + amount=int(amount) / 1000, + memo=f"Refund {hit_id}", + unhashed_description=LnurlPayMetadata( + json.dumps([["text/plain", "Refund"]]) + ).encode("utf-8"), + extra={"refund": hit_id}, + ) + + payResponse = {"pr": payment_request, "routes": []} + + return json.dumps(payResponse) diff --git a/lnbits/extensions/boltcards/migrations.py b/lnbits/extensions/boltcards/migrations.py new file mode 100644 index 00000000..08126013 --- /dev/null +++ b/lnbits/extensions/boltcards/migrations.py @@ -0,0 +1,60 @@ +from lnbits.helpers import urlsafe_short_hash + + +async def m001_initial(db): + await db.execute( + """ + CREATE TABLE boltcards.cards ( + id TEXT PRIMARY KEY UNIQUE, + wallet TEXT NOT NULL, + card_name TEXT NOT NULL, + uid TEXT NOT NULL UNIQUE, + external_id TEXT NOT NULL UNIQUE, + counter INT NOT NULL DEFAULT 0, + tx_limit TEXT NOT NULL, + daily_limit TEXT NOT NULL, + enable BOOL NOT NULL, + k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + otp TEXT NOT NULL DEFAULT '', + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE boltcards.hits ( + id TEXT PRIMARY KEY UNIQUE, + card_id TEXT NOT NULL, + ip TEXT NOT NULL, + spent BOOL NOT NULL DEFAULT True, + useragent TEXT, + old_ctr INT NOT NULL DEFAULT 0, + new_ctr INT NOT NULL DEFAULT 0, + amount INT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE boltcards.refunds ( + id TEXT PRIMARY KEY UNIQUE, + hit_id TEXT NOT NULL, + refund_amount INT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/boltcards/models.py b/lnbits/extensions/boltcards/models.py new file mode 100644 index 00000000..47ca1df0 --- /dev/null +++ b/lnbits/extensions/boltcards/models.py @@ -0,0 +1,83 @@ +from sqlite3 import Row +from typing import Optional + +from fastapi import Request +from fastapi.params import Query +from lnurl import Lnurl +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from pydantic import BaseModel +from pydantic.main import BaseModel + +ZERO_KEY = "00000000000000000000000000000000" + + +class Card(BaseModel): + id: str + wallet: str + card_name: str + uid: str + external_id: str + counter: int + tx_limit: int + daily_limit: int + enable: bool + k0: str + k1: str + k2: str + prev_k0: str + prev_k1: str + prev_k2: str + otp: str + time: int + + def from_row(cls, row: Row) -> "Card": + return cls(**dict(row)) + + def lnurl(self, req: Request) -> Lnurl: + url = req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True) + return lnurl_encode(url) + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) + + +class CreateCardData(BaseModel): + card_name: str = Query(...) + uid: str = Query(...) + counter: int = Query(0) + tx_limit: int = Query(0) + daily_limit: int = Query(0) + enable: bool = Query(True) + k0: str = Query(ZERO_KEY) + k1: str = Query(ZERO_KEY) + k2: str = Query(ZERO_KEY) + prev_k0: str = Query(ZERO_KEY) + prev_k1: str = Query(ZERO_KEY) + prev_k2: str = Query(ZERO_KEY) + + +class Hit(BaseModel): + id: str + card_id: str + ip: str + spent: bool + useragent: str + old_ctr: int + new_ctr: int + amount: int + time: int + + def from_row(cls, row: Row) -> "Hit": + return cls(**dict(row)) + + +class Refund(BaseModel): + id: str + hit_id: str + refund_amount: int + time: int + + def from_row(cls, row: Row) -> "Refund": + return cls(**dict(row)) diff --git a/lnbits/extensions/boltcards/nxp424.py b/lnbits/extensions/boltcards/nxp424.py new file mode 100644 index 00000000..83f4e50d --- /dev/null +++ b/lnbits/extensions/boltcards/nxp424.py @@ -0,0 +1,36 @@ +# https://www.nxp.com/docs/en/application-note/AN12196.pdf +from typing import Tuple + +from Cryptodome.Cipher import AES +from Cryptodome.Hash import CMAC + +SV2 = "3CC300010080" + + +def myCMAC(key: bytes, msg: bytes = b"") -> bytes: + cobj = CMAC.new(key, ciphermod=AES) + if msg != b"": + cobj.update(msg) + return cobj.digest() + + +def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]: + IVbytes = b"\x00" * 16 + + cipher = AES.new(key, AES.MODE_CBC, IVbytes) + sun_plain = cipher.decrypt(sun) + + UID = sun_plain[1:8] + counter = sun_plain[8:11] + + return UID, counter + + +def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes: + sv2prefix = bytes.fromhex(SV2) + sv2bytes = sv2prefix + UID + counter + + mac1 = myCMAC(key, sv2bytes) + mac2 = myCMAC(mac1) + + return mac2[1::2] diff --git a/lnbits/extensions/boltcards/static/js/index.js b/lnbits/extensions/boltcards/static/js/index.js new file mode 100644 index 00000000..11df222a --- /dev/null +++ b/lnbits/extensions/boltcards/static/js/index.js @@ -0,0 +1,435 @@ +Vue.component(VueQrcode.name, VueQrcode) + +const mapCards = obj => { + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + toggleAdvanced: false, + nfcTagReading: false, + lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`, + cards: [], + hits: [], + refunds: [], + cardDialog: { + show: false, + data: { + counter: 1, + k0: '', + k1: '', + k2: '', + uid: '', + card_name: '' + }, + temp: {} + }, + cardsTable: { + columns: [ + { + name: 'card_name', + align: 'left', + label: 'Card name', + field: 'card_name' + }, + { + name: 'counter', + align: 'left', + label: 'Counter', + field: 'counter' + }, + { + name: 'wallet', + align: 'left', + label: 'Wallet', + field: 'wallet' + }, + { + name: 'tx_limit', + align: 'left', + label: 'Max tx', + field: 'tx_limit' + }, + { + name: 'daily_limit', + align: 'left', + label: 'Daily tx limit', + field: 'daily_limit' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + refundsTable: { + columns: [ + { + name: 'hit_id', + align: 'left', + label: 'Hit ID', + field: 'hit_id' + }, + { + name: 'refund_amount', + align: 'left', + label: 'Refund Amount', + field: 'refund_amount' + }, + { + name: 'date', + align: 'left', + label: 'Time', + field: 'date' + } + ], + pagination: { + rowsPerPage: 10, + sortBy: 'date', + descending: true + } + }, + hitsTable: { + columns: [ + { + name: 'card_name', + align: 'left', + label: 'Card name', + field: 'card_name' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount' + }, + { + name: 'old_ctr', + align: 'left', + label: 'Old counter', + field: 'old_ctr' + }, + { + name: 'new_ctr', + align: 'left', + label: 'New counter', + field: 'new_ctr' + }, + { + name: 'date', + align: 'left', + label: 'Time', + field: 'date' + }, + { + name: 'ip', + align: 'left', + label: 'IP', + field: 'ip' + }, + { + name: 'useragent', + align: 'left', + label: 'User agent', + field: 'useragent' + } + ], + pagination: { + rowsPerPage: 10, + sortBy: 'date', + descending: true + } + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + methods: { + 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 copy its UID here.' + }) + + 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, serialNumber}) => { + //Decode NDEF data from tag + var self = this + self.cardDialog.data.uid = serialNumber + .toUpperCase() + .replaceAll(':', '') + 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.' + }) + } + }, + getCards: function () { + var self = this + + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/cards?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.cards = response.data.map(function (obj) { + return mapCards(obj) + }) + }) + .then(function () { + self.getHits() + }) + }, + getHits: function () { + var self = this + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/hits?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.hits = response.data.map(function (obj) { + obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name + return mapCards(obj) + }) + }) + }, + getRefunds: function () { + var self = this + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/refunds?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.refunds = response.data.map(function (obj) { + return mapCards(obj) + }) + }) + }, + openQrCodeDialog(cardId) { + var card = _.findWhere(this.cards, {id: cardId}) + this.qrCodeDialog.data = { + link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, + name: card.card_name, + uid: card.uid, + external_id: card.external_id, + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2 + } + this.qrCodeDialog.show = true + }, + addCardOpen: function () { + this.cardDialog.show = true + this.generateKeys() + }, + generateKeys: function () { + var self = this + const genRanHex = size => + [...Array(size)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join('') + + debugcard = + typeof this.cardDialog.data.card_name === 'string' && + this.cardDialog.data.card_name.search('debug') > -1 + + self.cardDialog.data.k0 = debugcard + ? '11111111111111111111111111111111' + : genRanHex(32) + + self.cardDialog.data.k1 = debugcard + ? '22222222222222222222222222222222' + : genRanHex(32) + + self.cardDialog.data.k2 = debugcard + ? '33333333333333333333333333333333' + : genRanHex(32) + }, + closeFormDialog: function () { + this.cardDialog.data = {} + }, + sendFormData: function () { + let wallet = _.findWhere(this.g.user.wallets, { + id: this.cardDialog.data.wallet + }) + let data = this.cardDialog.data + if (data.id) { + this.updateCard(wallet, data) + } else { + this.createCard(wallet, data) + } + }, + createCard: function (wallet, data) { + var self = this + + LNbits.api + .request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data) + .then(function (response) { + self.cards.push(mapCards(response.data)) + self.cardDialog.show = false + self.cardDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + updateCardDialog: function (formId) { + var card = _.findWhere(this.cards, {id: formId}) + this.cardDialog.data = _.clone(card) + + this.cardDialog.temp.k0 = this.cardDialog.data.k0 + this.cardDialog.temp.k1 = this.cardDialog.data.k1 + this.cardDialog.temp.k2 = this.cardDialog.data.k2 + + this.cardDialog.show = true + }, + updateCard: function (wallet, data) { + var self = this + + if ( + this.cardDialog.temp.k0 != data.k0 || + this.cardDialog.temp.k1 != data.k1 || + this.cardDialog.temp.k2 != data.k2 + ) { + data.prev_k0 = this.cardDialog.temp.k0 + data.prev_k1 = this.cardDialog.temp.k1 + data.prev_k2 = this.cardDialog.temp.k2 + } + + LNbits.api + .request( + 'PUT', + '/boltcards/api/v1/cards/' + data.id, + wallet.adminkey, + data + ) + .then(function (response) { + self.cards = _.reject(self.cards, function (obj) { + return obj.id == data.id + }) + self.cards.push(mapCards(response.data)) + self.cardDialog.show = false + self.cardDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + enableCard: function (wallet, card_id, enable) { + var self = this + let fullWallet = _.findWhere(self.g.user.wallets, { + id: wallet + }) + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/cards/enable/' + card_id + '/' + enable, + fullWallet.adminkey + ) + .then(function (response) { + console.log(response.data) + self.cards = _.reject(self.cards, function (obj) { + return obj.id == response.data.id + }) + self.cards.push(mapCards(response.data)) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteCard: function (cardId) { + let self = this + let cards = _.findWhere(this.cards, {id: cardId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this card') + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/boltcards/api/v1/cards/' + cardId, + _.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey + ) + .then(function (response) { + self.cards = _.reject(self.cards, function (obj) { + return obj.id == cardId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + }, + exportCardsCSV: function () { + LNbits.utils.exportCSV(this.cardsTable.columns, this.cards) + }, + exportHitsCSV: function () { + LNbits.utils.exportCSV(this.hitsTable.columns, this.hits) + }, + exportRefundsCSV: function () { + LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds) + } + }, + created: function () { + if (this.g.user.wallets.length) { + this.getCards() + this.getRefunds() + } + } +}) diff --git a/lnbits/extensions/boltcards/tasks.py b/lnbits/extensions/boltcards/tasks.py new file mode 100644 index 00000000..1b51c98b --- /dev/null +++ b/lnbits/extensions/boltcards/tasks.py @@ -0,0 +1,47 @@ +import asyncio +import json + +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import create_refund, get_hit + + +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 not payment.extra.get("refund"): + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + hit = await get_hit(payment.extra.get("refund")) + + if hit: + refund = await create_refund( + hit_id=hit.id, refund_amount=(payment.amount / 1000) + ) + await mark_webhook_sent(payment, 1) + + +async def mark_webhook_sent(payment: Payment, status: int) -> None: + payment.extra["wh_status"] = status + + await core_db.execute( + """ + UPDATE apipayments SET extra = ? + WHERE hash = ? + """, + (json.dumps(payment.extra), payment.payment_hash), + ) diff --git a/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html b/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html new file mode 100644 index 00000000..be4e2ae8 --- /dev/null +++ b/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html @@ -0,0 +1,21 @@ + + + +
Be your own card association
+

+ Manage your Bolt Cards self custodian way
+ + More details +
+

+
+
+
diff --git a/lnbits/extensions/boltcards/templates/boltcards/index.html b/lnbits/extensions/boltcards/templates/boltcards/index.html new file mode 100644 index 00000000..55cc1e5e --- /dev/null +++ b/lnbits/extensions/boltcards/templates/boltcards/index.html @@ -0,0 +1,405 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + +
+
+
+
+
Cards
+
+
+ + Add card + +
+
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + +
+
+
Hits
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + +
+
+
Refunds
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Bolt Cards extension +
+
+ + + {% include "boltcards/_api_docs.html" %} + +
+
+ + + + + +
+
+ +
+
+ +
+
+ + +
+
+ + Get from the card you'll use, using an NFC app + +
+
+ + Tap card to scan UID + +
+
+ + +
+ + + + + + + Zero if you don't know. + + Generate keys +
+
+ Update Card + Create Card + + Cancel +
+
+
+
+ + + + {% raw %} + + + +

+ (Keys for + bolt-nfc-android-app) +

+

+ Name: {{ qrCodeDialog.data.name }}
+ UID: {{ qrCodeDialog.data.uid }}
+ External ID: {{ qrCodeDialog.data.external_id }}
+ Lock key: {{ qrCodeDialog.data.k0 }}
+ Meta key: {{ qrCodeDialog.data.k1 }}
+ File key: {{ qrCodeDialog.data.k2 }}
+

+
+ + + Click to copy, then add to NFC card + + {% endraw %} +
+ Close +
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/boltcards/views.py b/lnbits/extensions/boltcards/views.py new file mode 100644 index 00000000..8fcbb7de --- /dev/null +++ b/lnbits/extensions/boltcards/views.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import boltcards_ext, boltcards_renderer + +templates = Jinja2Templates(directory="templates") + + +@boltcards_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return boltcards_renderer().TemplateResponse( + "boltcards/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/boltcards/views_api.py b/lnbits/extensions/boltcards/views_api.py new file mode 100644 index 00000000..7b8357cf --- /dev/null +++ b/lnbits/extensions/boltcards/views_api.py @@ -0,0 +1,170 @@ +import secrets +from http import HTTPStatus + +from fastapi.params import Depends, Query +from loguru import logger +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 . import boltcards_ext +from .crud import ( + create_card, + create_hit, + delete_card, + enable_disable_card, + get_card, + get_card_by_otp, + get_card_by_uid, + get_cards, + get_hits, + get_refunds, + update_card, + update_card_counter, + update_card_otp, +) +from .models import CreateCardData +from .nxp424 import decryptSUN, getSunMAC + + +@boltcards_ext.get("/api/v1/cards") +async def api_cards( + 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 + + return [card.dict() for card in await get_cards(wallet_ids)] + + +@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED) +@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK) +async def api_card_create_or_update( + # req: Request, + data: CreateCardData, + card_id: str = None, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + try: + if len(bytes.fromhex(data.uid)) != 7: + raise HTTPException( + detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k0)) != 16: + raise HTTPException( + detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k1)) != 16: + raise HTTPException( + detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k2)) != 16: + raise HTTPException( + detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST + ) + except: + raise HTTPException( + detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST + ) + if card_id: + card = await get_card(card_id) + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + if card.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your card.", status_code=HTTPStatus.FORBIDDEN + ) + checkUid = await get_card_by_uid(data.uid) + if checkUid and checkUid.id != card_id: + raise HTTPException( + detail="UID already registered. Delete registered card and try again.", + status_code=HTTPStatus.BAD_REQUEST, + ) + card = await update_card(card_id, **data.dict()) + else: + checkUid = await get_card_by_uid(data.uid) + if checkUid: + raise HTTPException( + detail="UID already registered. Delete registered card and try again.", + status_code=HTTPStatus.BAD_REQUEST, + ) + card = await create_card(wallet_id=wallet.wallet.id, data=data) + return card.dict() + + +@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK) +async def enable_card( + card_id, + enable, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + card = await get_card(card_id) + if not card: + raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) + if card.wallet != wallet.wallet.id: + raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) + card = await enable_disable_card(enable=enable, id=card_id) + return card.dict() + + +@boltcards_ext.delete("/api/v1/cards/{card_id}") +async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)): + card = await get_card(card_id) + + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if card.wallet != wallet.wallet.id: + raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) + + await delete_card(card_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@boltcards_ext.get("/api/v1/hits") +async def api_hits( + 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 + + cards = await get_cards(wallet_ids) + cards_ids = [] + for card in cards: + cards_ids.append(card.id) + + return [hit.dict() for hit in await get_hits(cards_ids)] + + +@boltcards_ext.get("/api/v1/refunds") +async def api_hits( + 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 + + cards = await get_cards(wallet_ids) + cards_ids = [] + for card in cards: + cards_ids.append(card.id) + hits = await get_hits(cards_ids) + hits_ids = [] + for hit in hits: + hits_ids.append(hit.id) + + return [refund.dict() for refund in await get_refunds(hits_ids)] diff --git a/lnbits/extensions/boltz/README.md b/lnbits/extensions/boltz/README.md new file mode 100644 index 00000000..28289b73 --- /dev/null +++ b/lnbits/extensions/boltz/README.md @@ -0,0 +1,40 @@ +# Swap on [Boltz](https://boltz.exchange) +providing **trustless** and **account-free** swap services since **2018.** +move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times. +* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2) +* [Documentation](https://docs.boltz.exchange/en/latest/) +* [Discord](https://discord.gg/d6EK85KK) +* [Twitter](https://twitter.com/Boltzhq) + +# usage +This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds. + +## create normal swap +1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds. +--- +![create swap](https://imgur.com/OyOh3Nm.png) +--- +2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up. +--- +![pay onchain tx](https://imgur.com/r2UhwCY.png) +--- +3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet. + +if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either). + +## create reverse swap +1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails. +--- +![reverse swap](https://imgur.com/UEAPpbs.png) +--- +if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds. + +# refund locked onchain funds from a normal swap +if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc. +this can happen if boltz is not able to pay your lightning invoice after you locked up your funds. +in case that happens, there is a info icon in the Swap (In) List which opens following dialog. +--- +![refund](https://imgur.com/pN81ltf.png) +---- +if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website. +if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK) diff --git a/lnbits/extensions/boltz/__init__.py b/lnbits/extensions/boltz/__init__.py new file mode 100644 index 00000000..7eb2e5a7 --- /dev/null +++ b/lnbits/extensions/boltz/__init__.py @@ -0,0 +1,26 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_boltz") + +boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"]) + + +def boltz_renderer(): + return template_renderer(["lnbits/extensions/boltz/templates"]) + + +from .tasks import check_for_pending_swaps, wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def boltz_start(): + loop = asyncio.get_event_loop() + loop.create_task(check_for_pending_swaps()) + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/boltz/boltz.py b/lnbits/extensions/boltz/boltz.py new file mode 100644 index 00000000..4e5fecd0 --- /dev/null +++ b/lnbits/extensions/boltz/boltz.py @@ -0,0 +1,424 @@ +import asyncio +import os +from binascii import hexlify, unhexlify +from hashlib import sha256 +from typing import Awaitable, Union + +import httpx +from embit import ec, script +from embit.networks import NETWORKS +from embit.transaction import SIGHASH, Transaction, TransactionInput, TransactionOutput +from loguru import logger + +from lnbits.core.services import create_invoice, pay_invoice +from lnbits.helpers import urlsafe_short_hash +from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL + +from .crud import update_swap_status +from .mempool import ( + get_fee_estimation, + get_mempool_blockheight, + get_mempool_fees, + get_mempool_tx, + get_mempool_tx_from_txs, + send_onchain_tx, + wait_for_websocket_message, +) +from .models import ( + CreateReverseSubmarineSwap, + CreateSubmarineSwap, + ReverseSubmarineSwap, + SubmarineSwap, + SwapStatus, +) +from .utils import check_balance, get_timestamp, req_wrap + +net = NETWORKS[BOLTZ_NETWORK] +logger.debug(f"BOLTZ_URL: {BOLTZ_URL}") +logger.debug(f"Bitcoin Network: {net['name']}") + + +async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap: + if not check_boltz_limits(data.amount): + msg = f"Boltz - swap not in boltz limits" + logger.warning(msg) + raise Exception(msg) + + swap_id = urlsafe_short_hash() + try: + payment_hash, payment_request = await create_invoice( + wallet_id=data.wallet, + amount=data.amount, + memo=f"swap of {data.amount} sats on boltz.exchange", + extra={"tag": "boltz", "swap_id": swap_id}, + ) + except Exception as exc: + msg = f"Boltz - create_invoice failed {str(exc)}" + logger.error(msg) + raise + + refund_privkey = ec.PrivateKey(os.urandom(32), True, net) + refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8") + + res = req_wrap( + "post", + f"{BOLTZ_URL}/createswap", + json={ + "type": "submarine", + "pairId": "BTC/BTC", + "orderSide": "sell", + "refundPublicKey": refund_pubkey_hex, + "invoice": payment_request, + "referralId": "lnbits", + }, + headers={"Content-Type": "application/json"}, + ) + res = res.json() + logger.info( + f"Boltz - created normal swap, boltz_id: {res['id']}. wallet: {data.wallet}" + ) + return SubmarineSwap( + id=swap_id, + time=get_timestamp(), + wallet=data.wallet, + amount=data.amount, + payment_hash=payment_hash, + refund_privkey=refund_privkey.wif(net), + refund_address=data.refund_address, + boltz_id=res["id"], + status="pending", + address=res["address"], + expected_amount=res["expectedAmount"], + timeout_block_height=res["timeoutBlockHeight"], + bip21=res["bip21"], + redeem_script=res["redeemScript"], + ) + + +""" +explanation taken from electrum +send on Lightning, receive on-chain +- User generates preimage, RHASH. Sends RHASH to server. +- Server creates an LN invoice for RHASH. +- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown. +- Server creates on-chain output locked to RHASH. +- User spends on-chain output, revealing preimage. +- Server fulfills HTLC using preimage. +Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee. +""" + + +async def create_reverse_swap( + data: CreateReverseSubmarineSwap, +) -> [ReverseSubmarineSwap, asyncio.Task]: + if not check_boltz_limits(data.amount): + msg = f"Boltz - reverse swap not in boltz limits" + logger.warning(msg) + raise Exception(msg) + + swap_id = urlsafe_short_hash() + + if not await check_balance(data): + logger.error(f"Boltz - reverse swap, insufficient balance.") + return False + + claim_privkey = ec.PrivateKey(os.urandom(32), True, net) + claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8") + preimage = os.urandom(32) + preimage_hash = sha256(preimage).hexdigest() + + res = req_wrap( + "post", + f"{BOLTZ_URL}/createswap", + json={ + "type": "reversesubmarine", + "pairId": "BTC/BTC", + "orderSide": "buy", + "invoiceAmount": data.amount, + "preimageHash": preimage_hash, + "claimPublicKey": claim_pubkey_hex, + "referralId": "lnbits", + }, + headers={"Content-Type": "application/json"}, + ) + res = res.json() + + logger.info( + f"Boltz - created reverse swap, boltz_id: {res['id']}. wallet: {data.wallet}" + ) + + swap = ReverseSubmarineSwap( + id=swap_id, + amount=data.amount, + wallet=data.wallet, + onchain_address=data.onchain_address, + instant_settlement=data.instant_settlement, + claim_privkey=claim_privkey.wif(net), + preimage=preimage.hex(), + status="pending", + boltz_id=res["id"], + timeout_block_height=res["timeoutBlockHeight"], + lockup_address=res["lockupAddress"], + onchain_amount=res["onchainAmount"], + redeem_script=res["redeemScript"], + invoice=res["invoice"], + time=get_timestamp(), + ) + logger.debug(f"Boltz - waiting for onchain tx, reverse swap_id: {swap.id}") + task = create_task_log_exception( + swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_initial) + ) + return swap, task + + +def start_onchain_listener(swap: ReverseSubmarineSwap) -> asyncio.Task: + return create_task_log_exception( + swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_restart) + ) + + +async def start_confirmation_listener( + swap: ReverseSubmarineSwap, mempool_lockup_tx +) -> asyncio.Task: + logger.debug(f"Boltz - reverse swap, waiting for confirmation...") + + tx, txid, *_ = mempool_lockup_tx + + confirmed = await wait_for_websocket_message({"track-tx": txid}, "txConfirmed") + if confirmed: + logger.debug(f"Boltz - reverse swap lockup transaction confirmed! claiming...") + await create_claim_tx(swap, mempool_lockup_tx) + else: + logger.debug(f"Boltz - reverse swap lockup transaction still not confirmed.") + + +def create_task_log_exception(swap_id: str, awaitable: Awaitable) -> asyncio.Task: + async def _log_exception(awaitable): + try: + return await awaitable + except Exception as e: + logger.error(f"Boltz - reverse swap failed!: {swap_id} - {e}") + await update_swap_status(swap_id, "failed") + + return asyncio.create_task(_log_exception(awaitable)) + + +async def swap_websocket_callback_initial(swap): + wstask = asyncio.create_task( + wait_for_websocket_message( + {"track-address": swap.lockup_address}, "address-transactions" + ) + ) + logger.debug( + f"Boltz - created task, waiting on mempool websocket for address: {swap.lockup_address}" + ) + + # create_task is used because pay_invoice is stuck as long as boltz does not + # see the onchain claim tx and it ends up in deadlock + task: asyncio.Task = create_task_log_exception( + swap.id, + pay_invoice( + wallet_id=swap.wallet, + payment_request=swap.invoice, + description=f"reverse swap for {swap.amount} sats on boltz.exchange", + extra={"tag": "boltz", "swap_id": swap.id, "reverse": True}, + ), + ) + logger.debug(f"Boltz - task pay_invoice created, reverse swap_id: {swap.id}") + + done, pending = await asyncio.wait( + [task, wstask], return_when=asyncio.FIRST_COMPLETED + ) + message = done.pop().result() + + # pay_invoice already failed, do not wait for onchain tx anymore + if message is None: + logger.debug(f"Boltz - pay_invoice already failed cancel websocket task.") + wstask.cancel() + raise + + return task, message + + +async def swap_websocket_callback_restart(swap): + logger.debug(f"Boltz - swap_websocket_callback_restart called...") + message = await wait_for_websocket_message( + {"track-address": swap.lockup_address}, "address-transactions" + ) + return None, message + + +async def wait_for_onchain_tx(swap: ReverseSubmarineSwap, callback): + task, txs = await callback(swap) + mempool_lockup_tx = get_mempool_tx_from_txs(txs, swap.lockup_address) + if mempool_lockup_tx: + tx, txid, *_ = mempool_lockup_tx + if swap.instant_settlement or tx["status"]["confirmed"]: + logger.debug( + f"Boltz - reverse swap instant settlement, claiming immediatly..." + ) + await create_claim_tx(swap, mempool_lockup_tx) + else: + await start_confirmation_listener(swap, mempool_lockup_tx) + try: + if task: + await task + except: + logger.error( + f"Boltz - could not await pay_invoice task, but sent onchain. should never happen!" + ) + else: + logger.error(f"Boltz - mempool lockup tx not found.") + + +async def create_claim_tx(swap: ReverseSubmarineSwap, mempool_lockup_tx): + tx = await create_onchain_tx(swap, mempool_lockup_tx) + await send_onchain_tx(tx) + logger.debug(f"Boltz - onchain tx sent, reverse swap completed") + await update_swap_status(swap.id, "complete") + + +async def create_refund_tx(swap: SubmarineSwap): + mempool_lockup_tx = get_mempool_tx(swap.address) + tx = await create_onchain_tx(swap, mempool_lockup_tx) + await send_onchain_tx(tx) + + +def check_block_height(block_height: int): + current_block_height = get_mempool_blockheight() + if current_block_height <= block_height: + msg = f"refund not possible, timeout_block_height ({block_height}) is not yet exceeded ({current_block_height})" + logger.debug(msg) + raise Exception(msg) + + +""" +a submarine swap consists of 2 onchain tx's a lockup and a redeem tx. +we create a tx to redeem the funds locked by the onchain lockup tx. +claim tx for reverse swaps, refund tx for normal swaps they are the same +onchain redeem tx, the difference between them is the private key, onchain_address, +input sequence and input script_sig +""" + + +async def create_onchain_tx( + swap: Union[ReverseSubmarineSwap, SubmarineSwap], mempool_lockup_tx +) -> Transaction: + is_refund_tx = type(swap) == SubmarineSwap + if is_refund_tx: + check_block_height(swap.timeout_block_height) + privkey = ec.PrivateKey.from_wif(swap.refund_privkey) + onchain_address = swap.refund_address + preimage = b"" + sequence = 0xFFFFFFFE + else: + privkey = ec.PrivateKey.from_wif(swap.claim_privkey) + preimage = unhexlify(swap.preimage) + onchain_address = swap.onchain_address + sequence = 0xFFFFFFFF + + locktime = swap.timeout_block_height + redeem_script = unhexlify(swap.redeem_script) + + fees = get_fee_estimation() + + tx, txid, vout_cnt, vout_amount = mempool_lockup_tx + + script_pubkey = script.address_to_scriptpubkey(onchain_address) + + vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)] + vout = [TransactionOutput(vout_amount - fees, script_pubkey)] + tx = Transaction(vin=vin, vout=vout) + + if is_refund_tx: + tx.locktime = locktime + + # TODO: 2 rounds for fee calculation, look at vbytes after signing and do another TX + s = script.Script(data=redeem_script) + for i, inp in enumerate(vin): + if is_refund_tx: + rs = bytes([34]) + bytes([0]) + bytes([32]) + sha256(redeem_script).digest() + tx.vin[i].script_sig = script.Script(data=rs) + h = tx.sighash_segwit(i, s, vout_amount) + sig = privkey.sign(h).serialize() + bytes([SIGHASH.ALL]) + witness_items = [sig, preimage, redeem_script] + tx.vin[i].witness = script.Witness(items=witness_items) + + return tx + + +def get_swap_status(swap: Union[SubmarineSwap, ReverseSubmarineSwap]) -> SwapStatus: + swap_status = SwapStatus( + wallet=swap.wallet, + swap_id=swap.id, + ) + + try: + boltz_request = get_boltz_status(swap.boltz_id) + swap_status.boltz = boltz_request["status"] + except httpx.HTTPStatusError as exc: + json = exc.response.json() + swap_status.boltz = json["error"] + if "could not find" in swap_status.boltz: + swap_status.exists = False + + if type(swap) == SubmarineSwap: + swap_status.reverse = False + swap_status.address = swap.address + else: + swap_status.reverse = True + swap_status.address = swap.lockup_address + + swap_status.block_height = get_mempool_blockheight() + swap_status.timeout_block_height = ( + f"{str(swap.timeout_block_height)} -> current: {str(swap_status.block_height)}" + ) + + if swap_status.block_height >= swap.timeout_block_height: + swap_status.hit_timeout = True + + mempool_tx = get_mempool_tx(swap_status.address) + swap_status.lockup = mempool_tx + if mempool_tx == None: + swap_status.has_lockup = False + swap_status.confirmed = False + swap_status.mempool = "transaction.unknown" + swap_status.message = "lockup tx not in mempool" + else: + swap_status.has_lockup = True + tx, *_ = mempool_tx + if tx["status"]["confirmed"] == True: + swap_status.mempool = "transaction.confirmed" + swap_status.confirmed = True + else: + swap_status.confirmed = False + swap_status.mempool = "transaction.unconfirmed" + + return swap_status + + +def check_boltz_limits(amount): + try: + pairs = get_boltz_pairs() + limits = pairs["pairs"]["BTC/BTC"]["limits"] + return amount >= limits["minimal"] and amount <= limits["maximal"] + except: + return False + + +def get_boltz_pairs(): + res = req_wrap( + "get", + f"{BOLTZ_URL}/getpairs", + headers={"Content-Type": "application/json"}, + ) + return res.json() + + +def get_boltz_status(boltzid): + res = req_wrap( + "post", + f"{BOLTZ_URL}/swapstatus", + json={"id": boltzid}, + ) + return res.json() diff --git a/lnbits/extensions/boltz/config.json b/lnbits/extensions/boltz/config.json new file mode 100644 index 00000000..0f69d2a5 --- /dev/null +++ b/lnbits/extensions/boltz/config.json @@ -0,0 +1,6 @@ +{ + "name": "Boltz", + "short_description": "Perform onchain/offchain swaps", + "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/views.py b/lnbits/extensions/copilot/views.py index ff0a5a93..7ee7f590 100644 --- a/lnbits/extensions/copilot/views.py +++ b/lnbits/extensions/copilot/views.py @@ -63,7 +63,7 @@ class ConnectionManager: manager = ConnectionManager() -@copilot_ext.websocket("/copilot/ws/{copilot_id}", name="copilot.websocket_by_id") +@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id") async def websocket_endpoint(websocket: WebSocket, copilot_id: str): await manager.connect(websocket, copilot_id) try: diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py index a3160fa9..8cbe5a6b 100644 --- a/lnbits/extensions/lndhub/views_api.py +++ b/lnbits/extensions/lndhub/views_api.py @@ -130,10 +130,7 @@ async def lndhub_gettxs( offset=offset, exclude_uncheckable=True, ): - await payment.set_pending( - (await WALLET.get_payment_status(payment.checking_id)).pending - ) - await asyncio.sleep(0.1) + await payment.check_status() return [ { @@ -181,7 +178,6 @@ async def lndhub_getuserinvoices( await invoice.set_pending( (await WALLET.get_invoice_status(invoice.checking_id)).pending ) - await asyncio.sleep(0.1) return [ { diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index c9aabe4c..98334e55 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -183,6 +183,12 @@ >
+ Copy invoice Close
diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md index c07f75b1..45abdb93 100644 --- a/lnbits/extensions/watchonly/README.md +++ b/lnbits/extensions/watchonly/README.md @@ -6,6 +6,8 @@ Monitor an extended public key and generate deterministic fresh public keys with You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension +Video demo + ### Wallet Account - a user can add one or more `xPubs` or `descriptors` - the `xPub` must be unique per user diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py index 4bf0dfca..0c08780d 100644 --- a/lnbits/extensions/watchonly/models.py +++ b/lnbits/extensions/watchonly/models.py @@ -19,7 +19,7 @@ class WalletAccount(BaseModel): title: str address_no: int balance: int - type: str = "" + type: Optional[str] = "" network: str = "Mainnet" @classmethod 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!') + this.selectedPort.addEventListener('disconnect', () => { this.selectedPort = null this.hww.authenticated = false this.$q.notify({ @@ -78,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() @@ -89,6 +114,9 @@ async function serialSigner(path) { ) this.writer = textEncoder.writable.getWriter() + + await this.hwwPing() + return true } catch (error) { this.selectedPort = null @@ -101,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 () { @@ -114,7 +148,6 @@ async function serialSigner(path) { /* Ignore the error */ }) if (this.selectedPort) await this.selectedPort.close() - this.$q.notify({ type: 'positive', message: 'Serial port disconnected!', @@ -200,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 @@ -223,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) { @@ -232,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!', @@ -248,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', @@ -261,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', @@ -276,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', @@ -292,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', @@ -336,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', @@ -347,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!', @@ -412,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', @@ -442,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!', @@ -462,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', @@ -495,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', @@ -527,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', @@ -545,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', @@ -586,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 @@ -595,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-list/wallet-list.js b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js index adc82b3e..f36e0d59 100644 --- a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js +++ b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js @@ -99,6 +99,7 @@ async function walletList(path) { async network(newNet, oldNet) { if (newNet !== oldNet) { await this.refreshWalletAccounts() + this.handleAddressTypeChanged(this.addressTypeOptions[1]) } } }, @@ -255,8 +256,12 @@ async function walletList(path) { getXpubFromDevice: async function () { try { if (!this.serialSignerRef.isConnected()) { - const portOpen = await this.serialSignerRef.openSerialPort() - if (!portOpen) return + this.$q.notify({ + type: 'warning', + message: 'Please connect to a hardware Device first!', + timeout: 10000 + }) + return } if (!this.serialSignerRef.isAuthenticated()) { await this.serialSignerRef.hwwShowPasswordDialog() diff --git a/lnbits/extensions/watchonly/static/js/crypto/aes.js b/lnbits/extensions/watchonly/static/js/crypto/aes.js new file mode 100644 index 00000000..92a17ca2 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/crypto/aes.js @@ -0,0 +1,802 @@ +/*! MIT License. Copyright 2015-2018 Richard Moore . 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 f6c853e3..b9ce361c 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -136,6 +136,7 @@ :adminkey="g.user.wallets[0].adminkey" :serial-signer-ref="$refs.serialSigner" :sats-denominated="config.sats_denominated" + @broadcast-done="handleBroadcastSuccess" >
@@ -176,7 +177,7 @@ dense size="ms" icon="content_copy" - @click="copyText(props.row.address)" + @click="copyText(currentAddress.address)" class="q-ml-sm" > @@ -248,5 +249,8 @@ + + + {% endblock %} diff --git a/lnbits/settings.py b/lnbits/settings.py index 79c74fb4..25e43eec 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -69,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/tasks.py b/lnbits/tasks.py index f4d0a928..078edca1 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -16,6 +16,8 @@ from lnbits.core.crud import ( from lnbits.core.services import redeem_lnurl_withdraw from lnbits.settings import WALLET +from .core import db + deferred_async: List[Callable] = [] @@ -86,19 +88,35 @@ async def check_pending_payments(): incoming = True while True: - for payment in await get_payments( - since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago - complete=False, - pending=True, - outgoing=outgoing, - incoming=incoming, - exclude_uncheckable=True, - ): - await payment.check_pending() + async with db.connect() as conn: + logger.debug( + f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days" + ) + start_time: float = time.time() + pending_payments = await get_payments( + since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago + complete=False, + pending=True, + outgoing=outgoing, + incoming=incoming, + exclude_uncheckable=True, + conn=conn, + ) + for payment in pending_payments: + await payment.check_status() + + logger.debug( + f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)" + ) + # we delete expired invoices once upon the first pending check + if incoming: + logger.debug("Task: deleting all expired invoices") + start_time: float = time.time() + await delete_expired_invoices(conn=conn) + logger.debug( + f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)" + ) - # we delete expired invoices once upon the first pending check - if incoming: - await delete_expired_invoices() # 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 9dbe5a22..9b862794 100644 --- a/lnbits/wallets/cliche.py +++ b/lnbits/wallets/cliche.py @@ -81,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) @@ -129,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 4761a59b..48b96128 100644 --- a/lnbits/wallets/cln.py +++ b/lnbits/wallets/cln.py @@ -110,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 ) @@ -144,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: @@ -155,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 247b96e1..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,9 +60,11 @@ 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, @@ -114,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", @@ -136,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: @@ -155,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/lnbits.py b/lnbits/wallets/lnbits.py index 96b7dbb6..ddd80e77 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -62,10 +62,10 @@ class LNbitsWallet(Wallet): data: Dict = {"out": False, "amount": amount} if description_hash: data["description_hash"] = description_hash.hex() - elif unhashed_description: - data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest() - else: - data["memo"] = memo or "" + if unhashed_description: + data["unhashed_description"] = unhashed_description.hex() + + data["memo"] = memo or "" async with httpx.AsyncClient() as client: r = await client.post( @@ -94,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: @@ -125,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 10bd27e7..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. @@ -153,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) @@ -168,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 = { @@ -180,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) @@ -213,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: @@ -221,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) ) @@ -240,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) @@ -248,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 6bdbe5e0..1083e48a 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -123,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 @@ -151,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, @@ -178,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: @@ -187,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 de0a60a8..5db68e1f 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -100,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 @@ -113,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 ba310823..13046d26 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -97,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 9fcf374a..f7dcba40 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -47,7 +47,7 @@ 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, @@ -92,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: @@ -106,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: @@ -119,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 a26177db..414d4e47 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -137,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"] @@ -148,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"] @@ -163,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) @@ -201,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/tests/core/views/test_api.py b/tests/core/views/test_api.py index 86c17fa9..e0f6b576 100644 --- a/tests/core/views/test_api.py +++ b/tests/core/views/test_api.py @@ -13,7 +13,7 @@ from lnbits.core.views.api import ( ) 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 @@ -162,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 diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip index 8704eb6d..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/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 + ) ) -)