diff --git a/.env.example b/.env.example index 6a3710c2..a367bec6 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ +#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/ + HOST=127.0.0.1 PORT=5000 -# uvicorn variable, allow https behind a proxy +# uvicorn variable, uncomment to allow https behind a proxy # FORWARDED_ALLOW_IPS="*" DEBUG=false @@ -54,8 +56,9 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" -# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet, LnTipsWallet -# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet +# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet, +# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet, +# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet, LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. @@ -76,6 +79,14 @@ CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=LNBITS_ADMIN_KEY +# LndWallet +LND_GRPC_ENDPOINT=127.0.0.1 +LND_GRPC_PORT=10009 +LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" +LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING" +# To use an AES-encrypted macaroon, set +# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn" + # LndRestWallet LND_REST_ENDPOINT=https://127.0.0.1:8080/ LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml index b6966bfa..18445899 100644 --- a/.github/workflows/formatting.yml +++ b/.github/workflows/formatting.yml @@ -24,7 +24,9 @@ jobs: with: poetry-version: ${{ matrix.poetry-version }} - name: Install packages - run: poetry install + run: | + poetry config virtualenvs.create false + poetry install - name: Check black run: make checkblack - name: Check isort diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 11429665..6725d845 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -36,6 +36,7 @@ jobs: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install sudo apt install unzip - name: Run migrations diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 6868455e..4c47fafc 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -21,6 +21,7 @@ jobs: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install - name: Run tests run: poetry run mypy diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 99687032..ecba4090 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -29,6 +29,7 @@ jobs: sudo chmod -R a+rwx . - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install - name: Run tests env: @@ -72,6 +73,7 @@ jobs: sudo chmod -R a+rwx . - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install - name: Run tests env: @@ -116,6 +118,7 @@ jobs: sudo chmod -R a+rwx . - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install - name: Run tests env: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7409b03e..dbbddf98 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,6 +44,7 @@ jobs: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install - name: Run tests run: make test @@ -80,6 +81,7 @@ jobs: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: | + poetry config virtualenvs.create false poetry install - name: Run tests env: diff --git a/.prettierrc b/.prettierrc index 224c6ee0..725c3980 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,6 +7,6 @@ "singleQuote": true, "trailingComma": "none", "useTabs": false, - "jsxBracketSameLine": false, + "bracketSameLine": false, "bracketSpacing": false } diff --git a/Makefile b/Makefile index ebf2a872..22d79f11 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ check: mypy checkprettier checkisort checkblack prettier: $(shell find lnbits -name "*.js" -o -name ".html") ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html +pyright: + ./node_modules/.bin/pyright + black: poetry run black . diff --git a/docs/guide/faq.md b/docs/guide/faq.md new file mode 100644 index 00000000..1ccf3e28 --- /dev/null +++ b/docs/guide/faq.md @@ -0,0 +1,268 @@ +--- +layout: default +title: FAQ +nav_order: 5 +--- + + +# FAQ - Frequently Asked Questions + +## Install options + + +## Troubleshooting + + + +## Building hardware tools + + +## Use cases of LNbits + + +## Developing for LNbits + + diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 1b2e07e9..52ce6420 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -15,8 +15,8 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL If you have problems installing LNbits using these instructions, please have a look at the [Troubleshooting](#troubleshooting) section. ```sh -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend/ +git clone https://github.com/lnbits/lnbits.git +cd lnbits # for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version sudo apt update @@ -50,7 +50,7 @@ poetry run lnbits #### Updating the server ``` -cd lnbits-legend/ +cd lnbits # Stop LNbits with `ctrl + x` git pull # Keep your poetry install up to date, this can be done with `poetry self update` @@ -63,8 +63,8 @@ poetry install --only main > note: currently not supported while we make some architectural changes on the path to leave beta ```sh -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend/ +git clone https://github.com/lnbits/lnbits.git +cd lnbits # Modern debian distros usually include Nix, however you can install with: # 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation @@ -83,8 +83,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT ## Option 3: venv ```sh -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend/ +git clone https://github.com/lnbits/lnbits.git +cd lnbits # ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv' python3.9 -m venv venv # If you have problems here, try `sudo apt install -y pkg-config libpq-dev` @@ -106,9 +106,9 @@ If you want to host LNbits on the internet, run with the option `--host 0.0.0.0` ## Option 4: Docker ```sh -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend -docker build -t lnbits-legend . +git clone https://github.com/lnbits/lnbits.git +cd lnbits +docker build -t lnbits . cp .env.example .env mkdir data docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend @@ -136,8 +136,8 @@ You can either run those commands, then `source ~/.bash_profile` or, if you don' Once installed, run the following commands. ``` -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend +git clone https://github.com/lnbits/lnbits.git +cd lnbits fly auth login [complete login process] fly launch @@ -438,8 +438,8 @@ If you want to run LNbits on your Umbrel but want it to be reached through clear To install using docker you first need to build the docker image as: ``` -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend +git clone https://github.com/lnbits/lnbits.git +cd lnbits docker build -t lnbits-legend . ``` diff --git a/lnbits/core/templates/core/index.html b/lnbits/core/templates/core/index.html index a28030c0..104dccd9 100644 --- a/lnbits/core/templates/core/index.html +++ b/lnbits/core/templates/core/index.html @@ -66,7 +66,7 @@ outline color="grey" type="a" - href="https://github.com/lnbits/lnbits-legend" + href="https://github.com/lnbits/lnbits" target="_blank" rel="noopener" >View project in GitHub Lightning) 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) @@ -22,14 +23,14 @@ This extension lets you create swaps, reverse swaps and in the case of failure r 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 +## create reverse swap (Lightning -> Onchain) 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 +# refund locked onchain funds from a normal swap (Onchain -> Lightning) 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. @@ -37,4 +38,5 @@ in case that happens, there is a info icon in the Swap (In) List which opens fol ![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) +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). +In a recent update we made *automated check*, every 15 minutes, to check if LNbits can refund your failed swap. diff --git a/lnbits/extensions/boltz/boltz.py b/lnbits/extensions/boltz/boltz.py deleted file mode 100644 index 31d927ea..00000000 --- a/lnbits/extensions/boltz/boltz.py +++ /dev/null @@ -1,421 +0,0 @@ -import asyncio -import os -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 settings - -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[settings.boltz_network] - - -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 = bytes.hex(refund_privkey.sec()).decode() - - res = req_wrap( - "post", - f"{settings.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 = bytes.hex(claim_privkey.sec()).decode() - preimage = os.urandom(32) - preimage_hash = sha256(preimage).hexdigest() - - res = req_wrap( - "post", - f"{settings.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 = bytes.fromhex(swap.preimage) - onchain_address = swap.onchain_address - sequence = 0xFFFFFFFF - - locktime = swap.timeout_block_height - redeem_script = bytes.fromhex(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(bytes.fromhex(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"{settings.boltz_url}/getpairs", - headers={"Content-Type": "application/json"}, - ) - return res.json() - - -def get_boltz_status(boltzid): - res = req_wrap( - "post", - f"{settings.boltz_url}/swapstatus", - json={"id": boltzid}, - ) - return res.json() diff --git a/lnbits/extensions/boltz/crud.py b/lnbits/extensions/boltz/crud.py index 1bb4286d..1c9eb700 100644 --- a/lnbits/extensions/boltz/crud.py +++ b/lnbits/extensions/boltz/crud.py @@ -1,21 +1,21 @@ -from http import HTTPStatus +import time from typing import List, Optional, Union +from boltz_client.boltz import BoltzReverseSwapResponse, BoltzSwapResponse from loguru import logger -from starlette.exceptions import HTTPException + +from lnbits.helpers import urlsafe_short_hash from . import db from .models import ( + AutoReverseSubmarineSwap, + CreateAutoReverseSubmarineSwap, CreateReverseSubmarineSwap, CreateSubmarineSwap, ReverseSubmarineSwap, SubmarineSwap, ) -""" -Submarine Swaps -""" - async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]: if isinstance(wallet_ids, str): @@ -30,20 +30,6 @@ async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[Submari 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", @@ -51,14 +37,20 @@ async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]: return [SubmarineSwap(**row) for row in rows] -async def get_submarine_swap(swap_id) -> SubmarineSwap: +async def get_submarine_swap(swap_id) -> Optional[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]: +async def create_submarine_swap( + data: CreateSubmarineSwap, + swap: BoltzSwapResponse, + swap_id: str, + refund_privkey_wif: str, + payment_hash: str, +) -> Optional[SubmarineSwap]: await db.execute( """ @@ -80,26 +72,22 @@ async def create_submarine_swap(swap: SubmarineSwap) -> Optional[SubmarineSwap]: VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( + swap_id, + data.wallet, + payment_hash, + "pending", 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, + refund_privkey_wif, + data.refund_address, + swap.expectedAmount, + swap.timeoutBlockHeight, swap.address, swap.bip21, - swap.redeem_script, - swap.amount, + swap.redeemScript, + data.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,)) + return await get_submarine_swap(swap_id) async def get_reverse_submarine_swaps( @@ -117,21 +105,6 @@ async def get_reverse_submarine_swaps( 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" @@ -140,7 +113,7 @@ async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap return [ReverseSubmarineSwap(**row) for row in rows] -async def get_reverse_submarine_swap(swap_id) -> SubmarineSwap: +async def get_reverse_submarine_swap(swap_id) -> Optional[ReverseSubmarineSwap]: row = await db.fetchone( "SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,) ) @@ -148,8 +121,31 @@ async def get_reverse_submarine_swap(swap_id) -> SubmarineSwap: async def create_reverse_submarine_swap( - swap: ReverseSubmarineSwap, -) -> Optional[ReverseSubmarineSwap]: + data: CreateReverseSubmarineSwap, + claim_privkey_wif: str, + preimage_hex: str, + swap: BoltzReverseSwapResponse, +) -> ReverseSubmarineSwap: + + swap_id = urlsafe_short_hash() + + reverse_swap = ReverseSubmarineSwap( + id=swap_id, + wallet=data.wallet, + status="pending", + boltz_id=swap.id, + instant_settlement=data.instant_settlement, + preimage=preimage_hex, + claim_privkey=claim_privkey_wif, + lockup_address=swap.lockupAddress, + invoice=swap.invoice, + onchain_amount=swap.onchainAmount, + onchain_address=data.onchain_address, + timeout_block_height=swap.timeoutBlockHeight, + redeem_script=swap.redeemScript, + amount=data.amount, + time=int(time.time()), + ) await db.execute( """ @@ -172,36 +168,93 @@ async def create_reverse_submarine_swap( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - swap.id, + reverse_swap.id, + reverse_swap.wallet, + reverse_swap.status, + reverse_swap.boltz_id, + reverse_swap.instant_settlement, + reverse_swap.preimage, + reverse_swap.claim_privkey, + reverse_swap.lockup_address, + reverse_swap.invoice, + reverse_swap.onchain_amount, + reverse_swap.onchain_address, + reverse_swap.timeout_block_height, + reverse_swap.redeem_script, + reverse_swap.amount, + ), + ) + return reverse_swap + + +async def get_auto_reverse_submarine_swaps( + wallet_ids: List[str], +) -> List[AutoReverseSubmarineSwap]: + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltz.auto_reverse_submarineswap WHERE wallet IN ({q}) order by time DESC", + (*wallet_ids,), + ) + return [AutoReverseSubmarineSwap(**row) for row in rows] + + +async def get_auto_reverse_submarine_swap( + swap_id, +) -> Optional[AutoReverseSubmarineSwap]: + row = await db.fetchone( + "SELECT * FROM boltz.auto_reverse_submarineswap WHERE id = ?", (swap_id,) + ) + return AutoReverseSubmarineSwap(**row) if row else None + + +async def get_auto_reverse_submarine_swap_by_wallet( + wallet_id, +) -> Optional[AutoReverseSubmarineSwap]: + row = await db.fetchone( + "SELECT * FROM boltz.auto_reverse_submarineswap WHERE wallet = ?", (wallet_id,) + ) + return AutoReverseSubmarineSwap(**row) if row else None + + +async def create_auto_reverse_submarine_swap( + swap: CreateAutoReverseSubmarineSwap, +) -> Optional[AutoReverseSubmarineSwap]: + + swap_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO boltz.auto_reverse_submarineswap ( + id, + wallet, + onchain_address, + instant_settlement, + balance, + 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.instant_settlement, + swap.balance, swap.amount, ), ) - return await get_reverse_submarine_swap(swap.id) + return await get_auto_reverse_submarine_swap(swap_id) + + +async def delete_auto_reverse_submarine_swap(swap_id): + await db.execute( + "DELETE FROM boltz.auto_reverse_submarineswap WHERE id = ?", (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: + if swap: await db.execute( "UPDATE boltz.submarineswap SET status='" + status @@ -209,17 +262,23 @@ async def update_swap_status(swap_id: str, status: str): + swap.id + "'" ) - if type(swap) == ReverseSubmarineSwap: - reverse = "reverse" + logger.info( + f"Boltz - swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}" + ) + return swap + + reverse_swap = await get_reverse_submarine_swap(swap_id) + if reverse_swap: await db.execute( "UPDATE boltz.reverse_submarineswap SET status='" + status + "' WHERE id='" - + swap.id + + reverse_swap.id + "'" ) + logger.info( + f"Boltz - reverse swap status change: {status}. boltz_id: {reverse_swap.boltz_id}, wallet: {reverse_swap.wallet}" + ) + return reverse_swap - message = f"Boltz - {reverse} swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}" - logger.info(message) - - return swap + return None diff --git a/lnbits/extensions/boltz/mempool.py b/lnbits/extensions/boltz/mempool.py deleted file mode 100644 index c7d572a9..00000000 --- a/lnbits/extensions/boltz/mempool.py +++ /dev/null @@ -1,93 +0,0 @@ -import asyncio -import json - -import httpx -import websockets -from embit.transaction import Transaction -from loguru import logger - -from lnbits.settings import settings - -from .utils import req_wrap - -websocket_url = f"{settings.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"{settings.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"{settings.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"{settings.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 = bytes.hex(tx.serialize()) - logger.debug(f"Boltz - mempool sending onchain tx...") - req_wrap( - "post", - f"{settings.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 index 925322ec..66648fcc 100644 --- a/lnbits/extensions/boltz/migrations.py +++ b/lnbits/extensions/boltz/migrations.py @@ -44,3 +44,21 @@ async def m001_initial(db): ); """ ) + + +async def m002_auto_swaps(db): + await db.execute( + """ + CREATE TABLE boltz.auto_reverse_submarineswap ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + onchain_address TEXT NOT NULL, + amount INT NOT NULL, + balance INT NOT NULL, + instant_settlement BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/boltz/models.py b/lnbits/extensions/boltz/models.py index 4f4ec9e2..9500b678 100644 --- a/lnbits/extensions/boltz/models.py +++ b/lnbits/extensions/boltz/models.py @@ -1,9 +1,5 @@ -import json -from typing import Dict, List, Optional - -from fastapi.params import Query -from pydantic.main import BaseModel -from sqlalchemy.engine import base +from fastapi import Query +from pydantic import BaseModel class SubmarineSwap(BaseModel): @@ -51,25 +47,22 @@ class CreateReverseSubmarineSwap(BaseModel): wallet: str = Query(...) amount: int = Query(...) instant_settlement: bool = Query(...) - # validate on-address, bcrt1 for regtest addresses - onchain_address: str = Query( - ..., regex="^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$" - ) + onchain_address: str = Query(...) -class SwapStatus(BaseModel): - swap_id: str +class AutoReverseSubmarineSwap(BaseModel): + 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 + amount: int + balance: int + onchain_address: str + instant_settlement: bool + time: int + + +class CreateAutoReverseSubmarineSwap(BaseModel): + wallet: str = Query(...) + amount: int = Query(...) + balance: int = Query(0) + instant_settlement: bool = Query(...) + onchain_address: str = Query(...) diff --git a/lnbits/extensions/boltz/tasks.py b/lnbits/extensions/boltz/tasks.py index d1ace04b..ba394164 100644 --- a/lnbits/extensions/boltz/tasks.py +++ b/lnbits/extensions/boltz/tasks.py @@ -1,129 +1,25 @@ import asyncio -import httpx +from boltz_client.boltz import BoltzNotFoundException, BoltzSwapStatusException +from boltz_client.mempool import MempoolBlockHeightException from loguru import logger +from lnbits.core.crud import get_wallet from lnbits.core.models import Payment -from lnbits.core.services import check_transaction_status +from lnbits.core.services import check_transaction_status, fee_reserve from lnbits.helpers import get_current_extension_name 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 ( + create_reverse_submarine_swap, get_all_pending_reverse_submarine_swaps, get_all_pending_submarine_swaps, - get_reverse_submarine_swap, + get_auto_reverse_submarine_swap_by_wallet, 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.debug(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.debug( - 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)}") +from .models import CreateReverseSubmarineSwap, ReverseSubmarineSwap, SubmarineSwap +from .utils import create_boltz_client, execute_reverse_swap async def wait_for_paid_invoices(): @@ -136,19 +32,149 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if "boltz" != payment.extra.get("tag"): + + await check_for_auto_swap(payment) + + if payment.extra.get("tag") != "boltz": # 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.") + if payment.extra: + swap_id = payment.extra.get("swap_id") + if swap_id: + swap = await get_submarine_swap(swap_id) + if swap: + await update_swap_status(swap_id, "complete") + + +async def check_for_auto_swap(payment: Payment) -> None: + auto_swap = await get_auto_reverse_submarine_swap_by_wallet(payment.wallet_id) + if auto_swap: + wallet = await get_wallet(payment.wallet_id) + if wallet: + reserve = fee_reserve(wallet.balance_msat) / 1000 + balance = wallet.balance_msat / 1000 + amount = balance - auto_swap.balance - reserve + if amount >= auto_swap.amount: + + client = create_boltz_client() + claim_privkey_wif, preimage_hex, swap = client.create_reverse_swap( + amount=int(amount) + ) + new_swap = await create_reverse_submarine_swap( + CreateReverseSubmarineSwap( + wallet=auto_swap.wallet, + amount=int(amount), + instant_settlement=auto_swap.instant_settlement, + onchain_address=auto_swap.onchain_address, + ), + claim_privkey_wif, + preimage_hex, + swap, + ) + await execute_reverse_swap(client, new_swap) + + logger.info( + f"Boltz: auto reverse swap created with amount: {amount}, boltz_id: {new_swap.boltz_id}" + ) + + +""" +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 -> mine block -> 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 -> kill -> boltz does lockup -> not confirmed -> start lnbits -> mine blocks -> should claim/complete + 3. test: create -> kill -> boltz does lockup -> confirmed -> start lnbits -> should claim/complete +""" + + +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: + logger.error( + f"Boltz - startup swap check, database is not created yet, do nothing" + ) return - logger.info( - f"Boltz - lightning invoice is paid, normal swap completed. swap_id: {swap_id}" - ) - await update_swap_status(swap_id, "complete") + client = create_boltz_client() + + if len(swaps) > 0: + logger.debug(f"Boltz - {len(swaps)} pending swaps") + for swap in swaps: + await check_swap(swap, client) + + if len(reverse_swaps) > 0: + logger.debug(f"Boltz - {len(reverse_swaps)} pending reverse swaps") + for reverse_swap in reverse_swaps: + await check_reverse_swap(reverse_swap, client) + + +async def check_swap(swap: SubmarineSwap, client): + try: + 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: + try: + _ = client.swap_status(swap.id) + except: + txs = client.mempool.get_txs_from_address(swap.address) + if len(txs) == 0: + await update_swap_status(swap.id, "timeout") + else: + await client.refund_swap( + privkey_wif=swap.refund_privkey, + lockup_address=swap.address, + receive_address=swap.refund_address, + redeem_script_hex=swap.redeem_script, + timeout_block_height=swap.timeout_block_height, + ) + await update_swap_status(swap.id, "refunded") + except BoltzNotFoundException as exc: + logger.debug(f"Boltz - swap: {swap.boltz_id} does not exist.") + await update_swap_status(swap.id, "failed") + except MempoolBlockHeightException as exc: + logger.debug( + f"Boltz - tried to refund swap: {swap.id}, but has not reached the timeout." + ) + except Exception as exc: + logger.error(f"Boltz - unhandled exception, swap: {swap.id} - {str(exc)}") + + +async def check_reverse_swap(reverse_swap: ReverseSubmarineSwap, client): + try: + _ = client.swap_status(reverse_swap.boltz_id) + await client.claim_reverse_swap( + lockup_address=reverse_swap.lockup_address, + receive_address=reverse_swap.onchain_address, + privkey_wif=reverse_swap.claim_privkey, + preimage_hex=reverse_swap.preimage, + redeem_script_hex=reverse_swap.redeem_script, + zeroconf=reverse_swap.instant_settlement, + ) + await update_swap_status(reverse_swap.id, "complete") + + except BoltzSwapStatusException as exc: + logger.debug(f"Boltz - swap_status: {str(exc)}") + await update_swap_status(reverse_swap.id, "failed") + # should only happen while development when regtest is reset + except BoltzNotFoundException as exc: + logger.debug(f"Boltz - reverse swap: {reverse_swap.boltz_id} does not exist.") + await update_swap_status(reverse_swap.id, "failed") + except Exception as exc: + logger.error( + f"Boltz - unhandled exception, reverse swap: {reverse_swap.id} - {str(exc)}" + ) diff --git a/lnbits/extensions/boltz/templates/boltz/_api_docs.html b/lnbits/extensions/boltz/templates/boltz/_api_docs.html index 0edc413a..f8474c57 100644 --- a/lnbits/extensions/boltz/templates/boltz/_api_docs.html +++ b/lnbits/extensions/boltz/templates/boltz/_api_docs.html @@ -1,242 +1,93 @@ - - + + + + +

NON CUSTODIAL atomic swap service

+
+ 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 + time. +
+

+ Link: + https://boltz.exchange + +
+ README: + read more +

+

+ Extension created by, + dni +

+
+
+ + +

+ Fee Information +

+ + Every swap consists of 2 onchain transactions, lockup and claim / refund, + routing fees and a Boltz fee of 0.5%. + +
+ - - -
- Boltz.exchange: Do onchain to offchain and vice-versa swaps -
+ You want to swap out 100.000 sats, Lightning to Onchain: +
    +
  • Onchain lockup tx fee: ~500 sats
  • +
  • Onchain claim tx fee: 1000 sats (hardcoded)
  • +
  • Routing fees (paid by you): unknown
  • +
  • Boltz fees: 500 sats
  • +
  • Fees total: 2000 sats + routing fees
  • +
  • You receive: 98.000 sats
  • +

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

-

- Link : - https://boltz.exchange - -

-

- More details -

-

- Created by, - dni + onchain_amount_received = amount - (amount * boltz_fee / 100) - + lockup_fee - claim_fee

+

98.000 = 100.000 - 500 - 500 - 1000

-
-
- - - - - 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 }}" - -
-
+ + You want to swap in 100.000 sats, Onchain to Lightning: +
    +
  • Onchain lockup tx fee: whatever you choose when paying
  • +
  • Onchain claim tx fee: ~500 sats
  • +
  • Routing fees (paid by boltz): unknown
  • +
  • Boltz fees: 500 sats (0.5%)
  • +
  • Fees total: 1000 sats + lockup_fee
  • +
  • You pay onchain: 101.000 sats + lockup_fee
  • +
  • You receive lightning: 100.000 sats
  • +
+

+ onchain_payment + lockup_fee = amount + (amount * boltz_fee / 100) + + claim_fee + lockup_fee +

+

101.000 + lockup_fee = 100.000 + 500 + 500 + lockup_fee

+
- - - - 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/_autoReverseSwapDialog.html b/lnbits/extensions/boltz/templates/boltz/_autoReverseSwapDialog.html new file mode 100644 index 00000000..c9c682a8 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_autoReverseSwapDialog.html @@ -0,0 +1,83 @@ + + + + + + + + mininum balance kept in wallet after a swap + the fee_reserve + + + +
+
+ + + Create Onchain TX when transaction is in mempool, but not + confirmed yet. + + +
+
+ +
+ + + Cancel +
+
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_autoReverseSwapList.html b/lnbits/extensions/boltz/templates/boltz/_autoReverseSwapList.html new file mode 100644 index 00000000..b297524f --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_autoReverseSwapList.html @@ -0,0 +1,54 @@ + + +
+
+
Auto Lightning -> Onchain
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_buttons.html b/lnbits/extensions/boltz/templates/boltz/_buttons.html new file mode 100644 index 00000000..3817b076 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_buttons.html @@ -0,0 +1,35 @@ + + + + + Send onchain funds offchain (BTC -> LN) + + + + + Send offchain funds to onchain address (LN -> BTC) + + + + + Automatically send offchain funds to onchain address (LN -> BTC) with a + predefined threshold + + + + diff --git a/lnbits/extensions/boltz/templates/boltz/_checkSwapDialog.html b/lnbits/extensions/boltz/templates/boltz/_checkSwapDialog.html new file mode 100644 index 00000000..e59702d2 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_checkSwapDialog.html @@ -0,0 +1,113 @@ + + +
pending swaps
+ + {% raw %} + + + {% endraw %} + +
pending reverse swaps
+ + {% raw %} + + + {% endraw %} + +
+ Close +
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_qrDialog.html b/lnbits/extensions/boltz/templates/boltz/_qrDialog.html new file mode 100644 index 00000000..053ef65e --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_qrDialog.html @@ -0,0 +1,31 @@ + + + + + +
+ {% 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 +
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_reverseSubmarineSwapDialog.html b/lnbits/extensions/boltz/templates/boltz/_reverseSubmarineSwapDialog.html new file mode 100644 index 00000000..5b3cf861 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_reverseSubmarineSwapDialog.html @@ -0,0 +1,72 @@ + + + + + + + +
+
+ + + Create Onchain TX when transaction is in mempool, but not + confirmed yet. + + +
+
+ +
+ + + Cancel +
+
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_reverseSubmarineSwapList.html b/lnbits/extensions/boltz/templates/boltz/_reverseSubmarineSwapList.html new file mode 100644 index 00000000..fc9668d0 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_reverseSubmarineSwapList.html @@ -0,0 +1,66 @@ + + +
+
+
Lightning -> Onchain
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_statusDialog.html b/lnbits/extensions/boltz/templates/boltz/_statusDialog.html new file mode 100644 index 00000000..f6c14abc --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_statusDialog.html @@ -0,0 +1,29 @@ + + +
+ {% raw %} + Status: {{ statusDialog.data.status }}
+
+ {% endraw %} +
+
+ Refund + + Download refundfile + Close +
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_submarineSwapDialog.html b/lnbits/extensions/boltz/templates/boltz/_submarineSwapDialog.html new file mode 100644 index 00000000..bf6aaa18 --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_submarineSwapDialog.html @@ -0,0 +1,58 @@ + + + + + + + + +
+ + + Cancel +
+
+
+
diff --git a/lnbits/extensions/boltz/templates/boltz/_submarineSwapList.html b/lnbits/extensions/boltz/templates/boltz/_submarineSwapList.html new file mode 100644 index 00000000..b42e1dee --- /dev/null +++ b/lnbits/extensions/boltz/templates/boltz/_submarineSwapList.html @@ -0,0 +1,78 @@ + + +
+
+
Onchain -> Lightning
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
diff --git a/lnbits/extensions/boltz/templates/boltz/index.html b/lnbits/extensions/boltz/templates/boltz/index.html index b7312de7..308c3a46 100644 --- a/lnbits/extensions/boltz/templates/boltz/index.html +++ b/lnbits/extensions/boltz/templates/boltz/index.html @@ -1,531 +1,19 @@ {% 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 %} - -
-
+
+ {% include "boltz/_buttons.html" %} {% include + "boltz/_submarineSwapList.html" %} {% include + "boltz/_reverseSubmarineSwapList.html" %} {% include + "boltz/_autoReverseSwapList.html" %}
-
- - -
{{SITE_TITLE}} Boltz extension
-
- - - {% include "boltz/_api_docs.html" %} - -
+
+ {% 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 -
-
-
+ {% include "boltz/_submarineSwapDialog.html" %} {% include + "boltz/_reverseSubmarineSwapDialog.html" %} {% include + "boltz/_autoReverseSwapDialog.html" %} {% include "boltz/_qrDialog.html" %} {% + include "boltz/_statusDialog.html" %}
{% endblock %} {% block scripts %} {{ window_vars(user) }} diff --git a/lnbits/extensions/boltz/utils.py b/lnbits/extensions/boltz/utils.py index 4fb2edda..7623fb6f 100644 --- a/lnbits/extensions/boltz/utils.py +++ b/lnbits/extensions/boltz/utils.py @@ -1,10 +1,25 @@ +import asyncio import calendar import datetime +from typing import Awaitable -import httpx -from loguru import logger +from boltz_client.boltz import BoltzClient, BoltzConfig -from lnbits.core.services import fee_reserve, get_wallet +from lnbits.core.services import fee_reserve, get_wallet, pay_invoice +from lnbits.settings import settings + +from .models import ReverseSubmarineSwap + + +def create_boltz_client() -> BoltzClient: + config = BoltzConfig( + network=settings.boltz_network, + api_url=settings.boltz_url, + mempool_url=f"{settings.boltz_mempool_space_url}/api", + mempool_ws_url=f"{settings.boltz_mempool_space_url_ws}/api/v1/ws", + referral_id="lnbits", + ) + return BoltzClient(config) async def check_balance(data) -> bool: @@ -23,22 +38,50 @@ def get_timestamp(): return calendar.timegm(date.utctimetuple()) -def req_wrap(funcname, *args, **kwargs): - try: +async def execute_reverse_swap(client, swap: ReverseSubmarineSwap): + # claim_task is watching onchain address for the lockup transaction to arrive / confirm + # and if the lockup is there, claim the onchain revealing preimage for hold invoice + claim_task = asyncio.create_task( + client.claim_reverse_swap( + privkey_wif=swap.claim_privkey, + preimage_hex=swap.preimage, + lockup_address=swap.lockup_address, + receive_address=swap.onchain_address, + redeem_script_hex=swap.redeem_script, + ) + ) + # pay_task is paying the hold invoice which gets held until you reveal your preimage when claiming your onchain funds + pay_task = pay_invoice_and_update_status( + swap.id, + claim_task, + pay_invoice( + wallet_id=swap.wallet, + payment_request=swap.invoice, + description=f"reverse swap for {swap.onchain_amount} sats on boltz.exchange", + extra={"tag": "boltz", "swap_id": swap.id, "reverse": True}, + ), + ) + + # they need to run be concurrently, because else pay_task will lock the eventloop and claim_task will not be executed. + # the lockup transaction can only happen after you pay the invoice, which cannot be redeemed immediatly -> hold invoice + # after getting the lockup transaction, you can claim the onchain funds revealing the preimage for boltz to redeem the hold invoice + asyncio.gather(claim_task, pay_task) + + +def pay_invoice_and_update_status( + swap_id: str, wstask: asyncio.Task, awaitable: Awaitable +) -> asyncio.Task: + async def _pay_invoice(awaitable): + from .crud import update_swap_status + 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 + awaited = await awaitable + await update_swap_status(swap_id, "complete") + return awaited + except asyncio.exceptions.CancelledError: + """lnbits process was exited, do nothing and handle it in startup script""" + except: + wstask.cancel() + await update_swap_status(swap_id, "failed") + + return asyncio.create_task(_pay_invoice(awaitable)) diff --git a/lnbits/extensions/boltz/views.py b/lnbits/extensions/boltz/views.py index b6864113..4b0e6d53 100644 --- a/lnbits/extensions/boltz/views.py +++ b/lnbits/extensions/boltz/views.py @@ -1,11 +1,10 @@ from urllib.parse import urlparse -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, Request from fastapi.templating import Jinja2Templates from starlette.responses import HTMLResponse -from lnbits.core.models import Payment, User +from lnbits.core.models import User from lnbits.decorators import check_user_exists from . import boltz_ext, boltz_renderer @@ -16,7 +15,6 @@ 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 index 34f4033e..ab32fac9 100644 --- a/lnbits/extensions/boltz/views_api.py +++ b/lnbits/extensions/boltz/views_api.py @@ -1,34 +1,23 @@ -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 fastapi import Depends, Query, status from starlette.exceptions import HTTPException -from starlette.requests import Request from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key +from lnbits.helpers import urlsafe_short_hash from lnbits.settings import settings 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_auto_reverse_submarine_swap, create_reverse_submarine_swap, create_submarine_swap, - get_pending_reverse_submarine_swaps, - get_pending_submarine_swaps, + delete_auto_reverse_submarine_swap, + get_auto_reverse_submarine_swap_by_wallet, + get_auto_reverse_submarine_swaps, get_reverse_submarine_swap, get_reverse_submarine_swaps, get_submarine_swap, @@ -36,12 +25,14 @@ from .crud import ( update_swap_status, ) from .models import ( + AutoReverseSubmarineSwap, + CreateAutoReverseSubmarineSwap, CreateReverseSubmarineSwap, CreateSubmarineSwap, ReverseSubmarineSwap, SubmarineSwap, ) -from .utils import check_balance +from .utils import check_balance, create_boltz_client, execute_reverse_swap @boltz_ext.get( @@ -76,17 +67,8 @@ async def api_submarineswap( ): 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") - + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [swap.dict() for swap in await get_submarine_swaps(wallet_ids)] @@ -109,35 +91,29 @@ async def api_submarineswap( }, }, ) -async def api_submarineswap_refund( - swap_id: str, - g: WalletTypeInfo = Depends(require_admin_key), -): - if swap_id == None: +async def api_submarineswap_refund(swap_id: str): + if not swap_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="swap_id missing" ) - swap = await get_submarine_swap(swap_id) - if swap == None: + if not swap: 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)) + client = create_boltz_client() + await client.refund_swap( + privkey_wif=swap.refund_privkey, + lockup_address=swap.address, + receive_address=swap.refund_address, + redeem_script_hex=swap.redeem_script, + timeout_block_height=swap.timeout_block_height, + ) await update_swap_status(swap.id, "refunded") return swap @@ -153,37 +129,43 @@ async def api_submarineswap_refund( """, response_description="create swap", response_model=SubmarineSwap, + dependencies=[Depends(require_admin_key)], responses={ - 405: {"description": "not allowed method, insufficient balance"}, + 405: { + "description": "auto reverse swap is active, a swap would immediatly be swapped out again." + }, 500: {"description": "boltz error"}, }, ) -async def api_submarineswap_create( - data: CreateSubmarineSwap, - wallet: WalletTypeInfo = Depends(require_admin_key), -): - try: - swap_data = await create_swap(data) - except httpx.RequestError as exc: +async def api_submarineswap_create(data: CreateSubmarineSwap): + + auto_swap = await get_auto_reverse_submarine_swap_by_wallet(data.wallet) + if auto_swap: raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Unreachable: {exc.request.url!r}.", + status_code=HTTPStatus.METHOD_NOT_ALLOWED, + detail="auto reverse swap is active, a swap would immediatly be swapped out again.", ) - 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() + + client = create_boltz_client() + swap_id = urlsafe_short_hash() + 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}, + ) + refund_privkey_wif, swap = client.create_swap(payment_request) + new_swap = await create_submarine_swap( + data, swap, swap_id, refund_privkey_wif, payment_hash + ) + return new_swap.dict() if new_swap else None # REVERSE SWAP @boltz_ext.get( "/api/v1/swap/reverse", name=f"boltz.get /swap/reverse", - summary="get a list of reverse swaps a swap", + summary="get a list of reverse swaps", description=""" This endpoint gets a list of reverse swaps. """, @@ -192,13 +174,14 @@ async def api_submarineswap_create( response_model=List[ReverseSubmarineSwap], ) async def api_reverse_submarineswap( - g: WalletTypeInfo = Depends(get_key_type), # type:ignore + 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 [swap.dict() for swap in await get_reverse_submarine_swaps(wallet_ids)] + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + return [swap for swap in await get_reverse_submarine_swaps(wallet_ids)] @boltz_ext.post( @@ -211,6 +194,7 @@ async def api_reverse_submarineswap( """, response_description="create reverse swap", response_model=ReverseSubmarineSwap, + dependencies=[Depends(require_admin_key)], responses={ 405: {"description": "not allowed method, insufficient balance"}, 500: {"description": "boltz error"}, @@ -218,30 +202,88 @@ async def api_reverse_submarineswap( ) async def api_reverse_submarineswap_create( data: CreateReverseSubmarineSwap, - wallet: WalletTypeInfo = Depends(require_admin_key), -): +) -> ReverseSubmarineSwap: if not await check_balance(data): raise HTTPException( status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="Insufficient balance." ) + client = create_boltz_client() + claim_privkey_wif, preimage_hex, swap = client.create_reverse_swap( + amount=data.amount + ) + new_swap = await create_reverse_submarine_swap( + data, claim_privkey_wif, preimage_hex, swap + ) + await execute_reverse_swap(client, new_swap) + return new_swap - 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.get( + "/api/v1/swap/reverse/auto", + name=f"boltz.get /swap/reverse/auto", + summary="get a list of auto reverse swaps", + description=""" + This endpoint gets a list of auto reverse swaps. + """, + response_description="list of auto reverse swaps", + dependencies=[Depends(get_key_type)], + response_model=List[AutoReverseSubmarineSwap], +) +async def api_auto_reverse_submarineswap( + g: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), +): + wallet_ids = [g.wallet.id] + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + return [swap.dict() for swap in await get_auto_reverse_submarine_swaps(wallet_ids)] + + +@boltz_ext.post( + "/api/v1/swap/reverse/auto", + status_code=status.HTTP_201_CREATED, + name=f"boltz.post /swap/reverse/auto", + summary="create a auto reverse submarine swap", + description=""" + This endpoint creates a auto reverse submarine swap + """, + response_description="create auto reverse swap", + response_model=AutoReverseSubmarineSwap, + dependencies=[Depends(require_admin_key)], + responses={ + 405: { + "description": "auto reverse swap is active, only 1 swap per wallet possible." + }, + }, +) +async def api_auto_reverse_submarineswap_create(data: CreateAutoReverseSubmarineSwap): + + auto_swap = await get_auto_reverse_submarine_swap_by_wallet(data.wallet) + if auto_swap: + raise HTTPException( + status_code=HTTPStatus.METHOD_NOT_ALLOWED, + detail="auto reverse swap is active, only 1 swap per wallet possible.", + ) + + swap = await create_auto_reverse_submarine_swap(data) + return swap.dict() if swap else None + + +@boltz_ext.delete( + "/api/v1/swap/reverse/auto/{swap_id}", + name=f"boltz.delete /swap/reverse/auto", + summary="delete a auto reverse submarine swap", + description=""" + This endpoint deletes a auto reverse submarine swap + """, + response_description="delete auto reverse swap", + dependencies=[Depends(require_admin_key)], +) +async def api_auto_reverse_submarineswap_delete(swap_id: str): + await delete_auto_reverse_submarine_swap(swap_id) + return "OK" @boltz_ext.post( @@ -252,65 +294,22 @@ async def api_reverse_submarineswap_create( This endpoint attempts to get the status of the swap. """, response_description="status of swap json", + dependencies=[Depends(require_admin_key)], responses={ 404: {"description": "when swap_id is not found"}, }, ) -async def api_swap_status( - swap_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -): +async def api_swap_status(swap_id: str): swap = await get_submarine_swap(swap_id) or await get_reverse_submarine_swap( swap_id ) - if swap == None: + if not swap: 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), - 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) - ) + client = create_boltz_client() + status = client.swap_status(swap.boltz_id) return status @@ -325,14 +324,5 @@ async def api_check_swaps( 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"] + client = create_boltz_client() + return {"minimal": client.limit_minimal, "maximal": client.limit_maximal} diff --git a/lnbits/extensions/cashu/templates/cashu/mint.html b/lnbits/extensions/cashu/templates/cashu/mint.html index bcb919ff..a6959ec2 100644 --- a/lnbits/extensions/cashu/templates/cashu/mint.html +++ b/lnbits/extensions/cashu/templates/cashu/mint.html @@ -4,18 +4,19 @@
- -

{{ mint_name }}

+ +

{{ mint_name }}

+ Open walletclick to open wallet
@@ -58,24 +59,34 @@

This service is in BETA
- We hold no responsibility for people losing access to funds. Use at - your own risk! + Cashu is still experimental and in active development. There are + likely bugs in this implementation so please use this with caution. We + hold no responsibility for people losing access to funds. Use at your + own risk!

- - {% endblock %} {% block scripts %} - - - - {% endblock %}
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/cashu/templates/cashu/wallet.html b/lnbits/extensions/cashu/templates/cashu/wallet.html index d075009e..1bf6241f 100644 --- a/lnbits/extensions/cashu/templates/cashu/wallet.html +++ b/lnbits/extensions/cashu/templates/cashu/wallet.html @@ -1479,15 +1479,15 @@ page_container %} }, constructOutputs: async function (amounts, secrets) { - const blindedMessages = [] + const outputs = [] const rs = [] for (let i = 0; i < amounts.length; i++) { const {B_, r} = await step1Alice(secrets[i]) - blindedMessages.push({amount: amounts[i], B_: B_}) + outputs.push({amount: amounts[i], B_: B_}) rs.push(r) } return { - blindedMessages, + outputs, rs } }, @@ -1581,25 +1581,26 @@ page_container %} mintApi: async function (amounts, payment_hash, verbose = true) { /* asks the mint to check whether the invoice with payment_hash has been paid - and requests signing of the attached outputs (blindedMessages) + and requests signing of the attached outputs. */ console.log('### promises', payment_hash) try { let secrets = await this.generateSecrets(amounts) - let {blindedMessages, rs} = await this.constructOutputs( - amounts, - secrets - ) + let {outputs, rs} = await this.constructOutputs(amounts, secrets) const promises = await LNbits.api.request( 'POST', `/cashu/api/v1/${this.mintId}/mint?payment_hash=${payment_hash}`, '', { - blinded_messages: blindedMessages + outputs } ) - console.log('### promises data', promises.data) - let proofs = await this.constructProofs(promises.data, secrets, rs) + console.log('### promises data', promises.data.promises) + let proofs = await this.constructProofs( + promises.data.promises, + secrets, + rs + ) return proofs } catch (error) { console.error(error) @@ -1682,16 +1683,11 @@ page_container %} 'number of secrets does not match number of outputs.' ) } - let {blindedMessages, rs} = await this.constructOutputs( - amounts, - secrets - ) + let {outputs, rs} = await this.constructOutputs(amounts, secrets) const payload = { amount, proofs, - outputs: { - blinded_messages: blindedMessages - } + outputs } console.log('payload', JSON.stringify(payload)) @@ -1881,10 +1877,7 @@ page_container %} 'amount with fees', amount ) - // if (amount > balance()) { - // LNbits.utils.notifyApiError('Balance too low') - // return - // } + let {fristProofs, scndProofs} = await this.splitToSend( this.proofs, amount @@ -2132,6 +2125,19 @@ page_container %} return paid }, + findTokenForAmount: function (amount) { + for (const token of this.proofs) { + const index = token.promises?.findIndex(p => p.amount === amount) + if (index >= 0) { + return { + promise: token.promises[index], + secret: token.secrets[index], + r: token.rs[index] + } + } + } + }, + ////////////// WORKERS ////////////// clearAllWorkers: function () { @@ -2220,76 +2226,6 @@ page_container %} //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - findTokenForAmount: function (amount) { - for (const token of this.proofs) { - const index = token.promises?.findIndex(p => p.amount === amount) - if (index >= 0) { - return { - promise: token.promises[index], - secret: token.secrets[index], - r: token.rs[index] - } - } - } - }, - - // checkInvoice: function () { - // console.log('#### checkInvoice') - // try { - // const invoice = decode(this.payInvoiceData.data.request) - - // const cleanInvoice = { - // msat: invoice.human_readable_part.amount, - // sat: invoice.human_readable_part.amount / 1000, - // fsat: LNbits.utils.formatSat( - // invoice.human_readable_part.amount / 1000 - // ) - // } - - // _.each(invoice.data.tags, tag => { - // if (_.isObject(tag) && _.has(tag, 'description')) { - // if (tag.description === 'payment_hash') { - // cleanInvoice.hash = tag.value - // } else if (tag.description === 'description') { - // cleanInvoice.description = tag.value - // } else if (tag.description === 'expiry') { - // var expireDate = new Date( - // (invoice.data.time_stamp + tag.value) * 1000 - // ) - // cleanInvoice.expireDate = Quasar.utils.date.formatDate( - // expireDate, - // 'YYYY-MM-DDTHH:mm:ss.SSSZ' - // ) - // cleanInvoice.expired = false // TODO - // } - // } - - // this.payInvoiceData.invoice = cleanInvoice - // }) - - // console.log( - // '#### this.payInvoiceData.invoice', - // this.payInvoiceData.invoice - // ) - // } catch (error) { - // this.$q.notify({ - // timeout: 5000, - // type: 'warning', - // message: 'Could not decode invoice', - // caption: error + '', - // position: 'top', - // actions: [ - // { - // icon: 'close', - // color: 'white', - // handler: () => {} - // } - // ] - // }) - // throw error - // } - // }, - ////////////// STORAGE ///////////// getLocalstorageToFile: async function () { diff --git a/lnbits/extensions/cashu/views_api.py b/lnbits/extensions/cashu/views_api.py index b66bbdc1..2017df00 100644 --- a/lnbits/extensions/cashu/views_api.py +++ b/lnbits/extensions/cashu/views_api.py @@ -12,7 +12,8 @@ from cashu.core.base import ( GetMintResponse, Invoice, MeltRequest, - MintRequest, + PostMintRequest, + PostMintResponse, PostSplitResponse, SplitRequest, ) @@ -204,10 +205,10 @@ async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintR @cashu_ext.post("/api/v1/{cashu_id}/mint") async def mint( - data: MintRequest, + data: PostMintRequest, cashu_id: str = Query(None), payment_hash: str = Query(None), -) -> List[BlindedSignature]: +) -> PostMintResponse: """ Requests the minting of tokens belonging to a paid payment request. Call this endpoint after `GET /mint`. @@ -245,7 +246,7 @@ async def mint( ) try: - total_requested = sum([bm.amount for bm in data.blinded_messages]) + total_requested = sum([bm.amount for bm in data.outputs]) if total_requested > invoice.amount: raise HTTPException( status_code=HTTPStatus.PAYMENT_REQUIRED, @@ -257,10 +258,8 @@ async def mint( status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid." ) - promises = await ledger._generate_promises( - B_s=data.blinded_messages, keyset=keyset - ) - return promises + promises = await ledger._generate_promises(B_s=data.outputs, keyset=keyset) + return PostMintResponse(promises=promises) except (Exception, HTTPException) as e: logger.debug(f"Cashu: /melt {str(e) or getattr(e, 'detail')}") # unset issued flag because something went wrong @@ -274,10 +273,8 @@ async def mint( ) else: # only used for testing when LIGHTNING=false - promises = await ledger._generate_promises( - B_s=data.blinded_messages, keyset=keyset - ) - return promises + promises = await ledger._generate_promises(B_s=data.outputs, keyset=keyset) + return PostMintResponse(promises=promises) @cashu_ext.post("/api/v1/{cashu_id}/melt") @@ -421,7 +418,7 @@ async def split( ) amount = payload.amount - outputs = payload.outputs.blinded_messages + outputs = payload.outputs assert outputs, Exception("no outputs provided.") split_return = None try: diff --git a/lnbits/extensions/copilot/templates/copilot/compose.html b/lnbits/extensions/copilot/templates/copilot/compose.html index f8f9d809..2ec4c4f7 100644 --- a/lnbits/extensions/copilot/templates/copilot/compose.html +++ b/lnbits/extensions/copilot/templates/copilot/compose.html @@ -15,7 +15,8 @@
+ +