mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-19 01:43:42 +01:00
Boltz.exchange Extension (#922)
* initial commit and still draft, ready for review * forgot to uncomment this line * fee estimation and blockheight * resolve conversation with michael, to use mempool websockets instead of boltz status event * Update lnbits/extensions/boltz/boltz.py Co-authored-by: michael1011 <me@michael1011.at> * add status to swaps, add sorting and data into listing * add swap status checks, change urls to docker test setup, dynamic minimum and maximum limits * fix docker hosts for development * add api endpoints to _api_docs * add wallet name and id, to list and status information * fix status_update for reverse_swaps * chore: format with black * more blackformatting and refactoring create_swap() * fix variable bug * check if swap is already refunded * use create_task instead of ensure_future * add mempool and boltz urls depending on DEBUG .env * raise exception in mempool fails * fix onchain txs, sending funds to wrong address and add a refund address for normal swaps beforehand * add status to swaps, add sorting and data into listing * add swap status checks, change urls to docker test setup, dynamic minimum and maximum limits * add wallet name and id, to list and status information * fix status_update for reverse_swaps * chore: format with black * use create_task instead of ensure_future * add mempool and boltz urls depending on DEBUG .env * fix onchain txs, sending funds to wrong address and add a refund address for normal swaps beforehand * black formatting * add some logging with loguru, and remove function duplication * cleanup readme * updates/suggestions from calle Co-authored-by: calle <93376500+callebtc@users.noreply.github.com> * remove unused comments * Update API Endpoints Co-authored-by: calle <93376500+callebtc@users.noreply.github.com> * un-factor get_boltz_pairs * added a explaination for the onchain tx * remove unused template file * rename api endpoints * fix isort and prettier * more verbose logging!! * add boltz to mock_data.zip * new mockdata * remove comment * better readme * fix mempool urls * change /refund /check /status to post requests * first step in tests2 * add first tests * change refund,check,status to post requests * next try on tests * overall code improvements * just testing tests * throw http exceptions in views_api * require admincheck for refund,check,status and added fastapi documentation for those * added more tests * black * many code improvements * adding tests * temp fix test * fix race condition when pay_invoice fails * test are working * add boltz env variables * add startup check, bugfixes, improvements * improve on status checking * remove check_invoice_status * more fixes and tests * testing testing testing * make tests run again inside regtest * fix bad error :O * fix postgres boolean bug and add swap test * Update README.md Update README.md Update README.md Update README.md * some mypy * blacked * the missing commit? * fix api_docs readme link * better refunding error catching fix * check swaps now also shows pending reverse swap, ui improvements, tooltips * add backend check for boltz limits fixup * many improvements, startup check for swaps working, reverse needs more testing * little last fixes * remove unused logic * fastapi documentation fixup * formatting and remove unused tests * fix test * fix swapstatus model * Update lnbits/extensions/boltz/tasks.py Co-authored-by: calle <93376500+callebtc@users.noreply.github.com> * Update lnbits/extensions/boltz/views_api.py Co-authored-by: calle <93376500+callebtc@users.noreply.github.com> * balance check msg, format * fix mypy data override * fix swapstatus, remove can refund column * Update lnbits/extensions/boltz/README.md Co-authored-by: michael1011 <me@michael1011.at> * empty lines * fix error message when swap is not found * remove preimage_hash from database * fix api_docs html fix api_docs html * catch boltz network exceptions better * formatting * check for timeout on swap at get request Co-authored-by: michael1011 <me@michael1011.at> Co-authored-by: fusion44 <some.fusion@gmail.com> Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
parent
5fecb02b8d
commit
78a98ca97d
4
Makefile
4
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 \
|
||||
|
40
lnbits/extensions/boltz/README.md
Normal file
40
lnbits/extensions/boltz/README.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Swap on [Boltz](https://boltz.exchange)
|
||||
providing **trustless** and **account-free** swap services since **2018.**
|
||||
move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times.
|
||||
* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2)
|
||||
* [Documentation](https://docs.boltz.exchange/en/latest/)
|
||||
* [Discord](https://discord.gg/d6EK85KK)
|
||||
* [Twitter](https://twitter.com/Boltzhq)
|
||||
|
||||
# usage
|
||||
This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds.
|
||||
|
||||
## create normal swap
|
||||
1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds.
|
||||
---
|
||||
![create swap](https://imgur.com/OyOh3Nm.png)
|
||||
---
|
||||
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
|
||||
---
|
||||
![pay onchain tx](https://imgur.com/r2UhwCY.png)
|
||||
---
|
||||
3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet.
|
||||
|
||||
if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either).
|
||||
|
||||
## create reverse swap
|
||||
1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails.
|
||||
---
|
||||
![reverse swap](https://imgur.com/UEAPpbs.png)
|
||||
---
|
||||
if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds.
|
||||
|
||||
# refund locked onchain funds from a normal swap
|
||||
if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc.
|
||||
this can happen if boltz is not able to pay your lightning invoice after you locked up your funds.
|
||||
in case that happens, there is a info icon in the Swap (In) List which opens following dialog.
|
||||
---
|
||||
![refund](https://imgur.com/pN81ltf.png)
|
||||
----
|
||||
if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website.
|
||||
if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK)
|
26
lnbits/extensions/boltz/__init__.py
Normal file
26
lnbits/extensions/boltz/__init__.py
Normal file
@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_boltz")
|
||||
|
||||
boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"])
|
||||
|
||||
|
||||
def boltz_renderer():
|
||||
return template_renderer(["lnbits/extensions/boltz/templates"])
|
||||
|
||||
|
||||
from .tasks import check_for_pending_swaps, wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def boltz_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(check_for_pending_swaps())
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
424
lnbits/extensions/boltz/boltz.py
Normal file
424
lnbits/extensions/boltz/boltz.py
Normal file
@ -0,0 +1,424 @@
|
||||
import asyncio
|
||||
import os
|
||||
from binascii import hexlify, unhexlify
|
||||
from hashlib import sha256
|
||||
from typing import Awaitable, Union
|
||||
|
||||
import httpx
|
||||
from embit import ec, script
|
||||
from embit.networks import NETWORKS
|
||||
from embit.transaction import SIGHASH, Transaction, TransactionInput, TransactionOutput
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL
|
||||
|
||||
from .crud import update_swap_status
|
||||
from .mempool import (
|
||||
get_fee_estimation,
|
||||
get_mempool_blockheight,
|
||||
get_mempool_fees,
|
||||
get_mempool_tx,
|
||||
get_mempool_tx_from_txs,
|
||||
send_onchain_tx,
|
||||
wait_for_websocket_message,
|
||||
)
|
||||
from .models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
SwapStatus,
|
||||
)
|
||||
from .utils import check_balance, get_timestamp, req_wrap
|
||||
|
||||
net = NETWORKS[BOLTZ_NETWORK]
|
||||
logger.debug(f"BOLTZ_URL: {BOLTZ_URL}")
|
||||
logger.debug(f"Bitcoin Network: {net['name']}")
|
||||
|
||||
|
||||
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
||||
if not check_boltz_limits(data.amount):
|
||||
msg = f"Boltz - swap not in boltz limits"
|
||||
logger.warning(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
swap_id = urlsafe_short_hash()
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=data.wallet,
|
||||
amount=data.amount,
|
||||
memo=f"swap of {data.amount} sats on boltz.exchange",
|
||||
extra={"tag": "boltz", "swap_id": swap_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
msg = f"Boltz - create_invoice failed {str(exc)}"
|
||||
logger.error(msg)
|
||||
raise
|
||||
|
||||
refund_privkey = ec.PrivateKey(os.urandom(32), True, net)
|
||||
refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8")
|
||||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
json={
|
||||
"type": "submarine",
|
||||
"pairId": "BTC/BTC",
|
||||
"orderSide": "sell",
|
||||
"refundPublicKey": refund_pubkey_hex,
|
||||
"invoice": payment_request,
|
||||
"referralId": "lnbits",
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
res = res.json()
|
||||
logger.info(
|
||||
f"Boltz - created normal swap, boltz_id: {res['id']}. wallet: {data.wallet}"
|
||||
)
|
||||
return SubmarineSwap(
|
||||
id=swap_id,
|
||||
time=get_timestamp(),
|
||||
wallet=data.wallet,
|
||||
amount=data.amount,
|
||||
payment_hash=payment_hash,
|
||||
refund_privkey=refund_privkey.wif(net),
|
||||
refund_address=data.refund_address,
|
||||
boltz_id=res["id"],
|
||||
status="pending",
|
||||
address=res["address"],
|
||||
expected_amount=res["expectedAmount"],
|
||||
timeout_block_height=res["timeoutBlockHeight"],
|
||||
bip21=res["bip21"],
|
||||
redeem_script=res["redeemScript"],
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
explanation taken from electrum
|
||||
send on Lightning, receive on-chain
|
||||
- User generates preimage, RHASH. Sends RHASH to server.
|
||||
- Server creates an LN invoice for RHASH.
|
||||
- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
|
||||
- Server creates on-chain output locked to RHASH.
|
||||
- User spends on-chain output, revealing preimage.
|
||||
- Server fulfills HTLC using preimage.
|
||||
Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
|
||||
"""
|
||||
|
||||
|
||||
async def create_reverse_swap(
|
||||
data: CreateReverseSubmarineSwap,
|
||||
) -> [ReverseSubmarineSwap, asyncio.Task]:
|
||||
if not check_boltz_limits(data.amount):
|
||||
msg = f"Boltz - reverse swap not in boltz limits"
|
||||
logger.warning(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
swap_id = urlsafe_short_hash()
|
||||
|
||||
if not await check_balance(data):
|
||||
logger.error(f"Boltz - reverse swap, insufficient balance.")
|
||||
return False
|
||||
|
||||
claim_privkey = ec.PrivateKey(os.urandom(32), True, net)
|
||||
claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8")
|
||||
preimage = os.urandom(32)
|
||||
preimage_hash = sha256(preimage).hexdigest()
|
||||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
json={
|
||||
"type": "reversesubmarine",
|
||||
"pairId": "BTC/BTC",
|
||||
"orderSide": "buy",
|
||||
"invoiceAmount": data.amount,
|
||||
"preimageHash": preimage_hash,
|
||||
"claimPublicKey": claim_pubkey_hex,
|
||||
"referralId": "lnbits",
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
res = res.json()
|
||||
|
||||
logger.info(
|
||||
f"Boltz - created reverse swap, boltz_id: {res['id']}. wallet: {data.wallet}"
|
||||
)
|
||||
|
||||
swap = ReverseSubmarineSwap(
|
||||
id=swap_id,
|
||||
amount=data.amount,
|
||||
wallet=data.wallet,
|
||||
onchain_address=data.onchain_address,
|
||||
instant_settlement=data.instant_settlement,
|
||||
claim_privkey=claim_privkey.wif(net),
|
||||
preimage=preimage.hex(),
|
||||
status="pending",
|
||||
boltz_id=res["id"],
|
||||
timeout_block_height=res["timeoutBlockHeight"],
|
||||
lockup_address=res["lockupAddress"],
|
||||
onchain_amount=res["onchainAmount"],
|
||||
redeem_script=res["redeemScript"],
|
||||
invoice=res["invoice"],
|
||||
time=get_timestamp(),
|
||||
)
|
||||
logger.debug(f"Boltz - waiting for onchain tx, reverse swap_id: {swap.id}")
|
||||
task = create_task_log_exception(
|
||||
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_initial)
|
||||
)
|
||||
return swap, task
|
||||
|
||||
|
||||
def start_onchain_listener(swap: ReverseSubmarineSwap) -> asyncio.Task:
|
||||
return create_task_log_exception(
|
||||
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_restart)
|
||||
)
|
||||
|
||||
|
||||
async def start_confirmation_listener(
|
||||
swap: ReverseSubmarineSwap, mempool_lockup_tx
|
||||
) -> asyncio.Task:
|
||||
logger.debug(f"Boltz - reverse swap, waiting for confirmation...")
|
||||
|
||||
tx, txid, *_ = mempool_lockup_tx
|
||||
|
||||
confirmed = await wait_for_websocket_message({"track-tx": txid}, "txConfirmed")
|
||||
if confirmed:
|
||||
logger.debug(f"Boltz - reverse swap lockup transaction confirmed! claiming...")
|
||||
await create_claim_tx(swap, mempool_lockup_tx)
|
||||
else:
|
||||
logger.debug(f"Boltz - reverse swap lockup transaction still not confirmed.")
|
||||
|
||||
|
||||
def create_task_log_exception(swap_id: str, awaitable: Awaitable) -> asyncio.Task:
|
||||
async def _log_exception(awaitable):
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as e:
|
||||
logger.error(f"Boltz - reverse swap failed!: {swap_id} - {e}")
|
||||
await update_swap_status(swap_id, "failed")
|
||||
|
||||
return asyncio.create_task(_log_exception(awaitable))
|
||||
|
||||
|
||||
async def swap_websocket_callback_initial(swap):
|
||||
wstask = asyncio.create_task(
|
||||
wait_for_websocket_message(
|
||||
{"track-address": swap.lockup_address}, "address-transactions"
|
||||
)
|
||||
)
|
||||
logger.debug(
|
||||
f"Boltz - created task, waiting on mempool websocket for address: {swap.lockup_address}"
|
||||
)
|
||||
|
||||
# create_task is used because pay_invoice is stuck as long as boltz does not
|
||||
# see the onchain claim tx and it ends up in deadlock
|
||||
task: asyncio.Task = create_task_log_exception(
|
||||
swap.id,
|
||||
pay_invoice(
|
||||
wallet_id=swap.wallet,
|
||||
payment_request=swap.invoice,
|
||||
description=f"reverse swap for {swap.amount} sats on boltz.exchange",
|
||||
extra={"tag": "boltz", "swap_id": swap.id, "reverse": True},
|
||||
),
|
||||
)
|
||||
logger.debug(f"Boltz - task pay_invoice created, reverse swap_id: {swap.id}")
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[task, wstask], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
message = done.pop().result()
|
||||
|
||||
# pay_invoice already failed, do not wait for onchain tx anymore
|
||||
if message is None:
|
||||
logger.debug(f"Boltz - pay_invoice already failed cancel websocket task.")
|
||||
wstask.cancel()
|
||||
raise
|
||||
|
||||
return task, message
|
||||
|
||||
|
||||
async def swap_websocket_callback_restart(swap):
|
||||
logger.debug(f"Boltz - swap_websocket_callback_restart called...")
|
||||
message = await wait_for_websocket_message(
|
||||
{"track-address": swap.lockup_address}, "address-transactions"
|
||||
)
|
||||
return None, message
|
||||
|
||||
|
||||
async def wait_for_onchain_tx(swap: ReverseSubmarineSwap, callback):
|
||||
task, txs = await callback(swap)
|
||||
mempool_lockup_tx = get_mempool_tx_from_txs(txs, swap.lockup_address)
|
||||
if mempool_lockup_tx:
|
||||
tx, txid, *_ = mempool_lockup_tx
|
||||
if swap.instant_settlement or tx["status"]["confirmed"]:
|
||||
logger.debug(
|
||||
f"Boltz - reverse swap instant settlement, claiming immediatly..."
|
||||
)
|
||||
await create_claim_tx(swap, mempool_lockup_tx)
|
||||
else:
|
||||
await start_confirmation_listener(swap, mempool_lockup_tx)
|
||||
try:
|
||||
if task:
|
||||
await task
|
||||
except:
|
||||
logger.error(
|
||||
f"Boltz - could not await pay_invoice task, but sent onchain. should never happen!"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Boltz - mempool lockup tx not found.")
|
||||
|
||||
|
||||
async def create_claim_tx(swap: ReverseSubmarineSwap, mempool_lockup_tx):
|
||||
tx = await create_onchain_tx(swap, mempool_lockup_tx)
|
||||
await send_onchain_tx(tx)
|
||||
logger.debug(f"Boltz - onchain tx sent, reverse swap completed")
|
||||
await update_swap_status(swap.id, "complete")
|
||||
|
||||
|
||||
async def create_refund_tx(swap: SubmarineSwap):
|
||||
mempool_lockup_tx = get_mempool_tx(swap.address)
|
||||
tx = await create_onchain_tx(swap, mempool_lockup_tx)
|
||||
await send_onchain_tx(tx)
|
||||
|
||||
|
||||
def check_block_height(block_height: int):
|
||||
current_block_height = get_mempool_blockheight()
|
||||
if current_block_height <= block_height:
|
||||
msg = f"refund not possible, timeout_block_height ({block_height}) is not yet exceeded ({current_block_height})"
|
||||
logger.debug(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
|
||||
"""
|
||||
a submarine swap consists of 2 onchain tx's a lockup and a redeem tx.
|
||||
we create a tx to redeem the funds locked by the onchain lockup tx.
|
||||
claim tx for reverse swaps, refund tx for normal swaps they are the same
|
||||
onchain redeem tx, the difference between them is the private key, onchain_address,
|
||||
input sequence and input script_sig
|
||||
"""
|
||||
|
||||
|
||||
async def create_onchain_tx(
|
||||
swap: Union[ReverseSubmarineSwap, SubmarineSwap], mempool_lockup_tx
|
||||
) -> Transaction:
|
||||
is_refund_tx = type(swap) == SubmarineSwap
|
||||
if is_refund_tx:
|
||||
check_block_height(swap.timeout_block_height)
|
||||
privkey = ec.PrivateKey.from_wif(swap.refund_privkey)
|
||||
onchain_address = swap.refund_address
|
||||
preimage = b""
|
||||
sequence = 0xFFFFFFFE
|
||||
else:
|
||||
privkey = ec.PrivateKey.from_wif(swap.claim_privkey)
|
||||
preimage = unhexlify(swap.preimage)
|
||||
onchain_address = swap.onchain_address
|
||||
sequence = 0xFFFFFFFF
|
||||
|
||||
locktime = swap.timeout_block_height
|
||||
redeem_script = unhexlify(swap.redeem_script)
|
||||
|
||||
fees = get_fee_estimation()
|
||||
|
||||
tx, txid, vout_cnt, vout_amount = mempool_lockup_tx
|
||||
|
||||
script_pubkey = script.address_to_scriptpubkey(onchain_address)
|
||||
|
||||
vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)]
|
||||
vout = [TransactionOutput(vout_amount - fees, script_pubkey)]
|
||||
tx = Transaction(vin=vin, vout=vout)
|
||||
|
||||
if is_refund_tx:
|
||||
tx.locktime = locktime
|
||||
|
||||
# TODO: 2 rounds for fee calculation, look at vbytes after signing and do another TX
|
||||
s = script.Script(data=redeem_script)
|
||||
for i, inp in enumerate(vin):
|
||||
if is_refund_tx:
|
||||
rs = bytes([34]) + bytes([0]) + bytes([32]) + sha256(redeem_script).digest()
|
||||
tx.vin[i].script_sig = script.Script(data=rs)
|
||||
h = tx.sighash_segwit(i, s, vout_amount)
|
||||
sig = privkey.sign(h).serialize() + bytes([SIGHASH.ALL])
|
||||
witness_items = [sig, preimage, redeem_script]
|
||||
tx.vin[i].witness = script.Witness(items=witness_items)
|
||||
|
||||
return tx
|
||||
|
||||
|
||||
def get_swap_status(swap: Union[SubmarineSwap, ReverseSubmarineSwap]) -> SwapStatus:
|
||||
swap_status = SwapStatus(
|
||||
wallet=swap.wallet,
|
||||
swap_id=swap.id,
|
||||
)
|
||||
|
||||
try:
|
||||
boltz_request = get_boltz_status(swap.boltz_id)
|
||||
swap_status.boltz = boltz_request["status"]
|
||||
except httpx.HTTPStatusError as exc:
|
||||
json = exc.response.json()
|
||||
swap_status.boltz = json["error"]
|
||||
if "could not find" in swap_status.boltz:
|
||||
swap_status.exists = False
|
||||
|
||||
if type(swap) == SubmarineSwap:
|
||||
swap_status.reverse = False
|
||||
swap_status.address = swap.address
|
||||
else:
|
||||
swap_status.reverse = True
|
||||
swap_status.address = swap.lockup_address
|
||||
|
||||
swap_status.block_height = get_mempool_blockheight()
|
||||
swap_status.timeout_block_height = (
|
||||
f"{str(swap.timeout_block_height)} -> current: {str(swap_status.block_height)}"
|
||||
)
|
||||
|
||||
if swap_status.block_height >= swap.timeout_block_height:
|
||||
swap_status.hit_timeout = True
|
||||
|
||||
mempool_tx = get_mempool_tx(swap_status.address)
|
||||
swap_status.lockup = mempool_tx
|
||||
if mempool_tx == None:
|
||||
swap_status.has_lockup = False
|
||||
swap_status.confirmed = False
|
||||
swap_status.mempool = "transaction.unknown"
|
||||
swap_status.message = "lockup tx not in mempool"
|
||||
else:
|
||||
swap_status.has_lockup = True
|
||||
tx, *_ = mempool_tx
|
||||
if tx["status"]["confirmed"] == True:
|
||||
swap_status.mempool = "transaction.confirmed"
|
||||
swap_status.confirmed = True
|
||||
else:
|
||||
swap_status.confirmed = False
|
||||
swap_status.mempool = "transaction.unconfirmed"
|
||||
|
||||
return swap_status
|
||||
|
||||
|
||||
def check_boltz_limits(amount):
|
||||
try:
|
||||
pairs = get_boltz_pairs()
|
||||
limits = pairs["pairs"]["BTC/BTC"]["limits"]
|
||||
return amount >= limits["minimal"] and amount <= limits["maximal"]
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def get_boltz_pairs():
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_URL}/getpairs",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return res.json()
|
||||
|
||||
|
||||
def get_boltz_status(boltzid):
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/swapstatus",
|
||||
json={"id": boltzid},
|
||||
)
|
||||
return res.json()
|
6
lnbits/extensions/boltz/config.json
Normal file
6
lnbits/extensions/boltz/config.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Boltz",
|
||||
"short_description": "Perform onchain/offchain swaps via https://boltz.exchange/",
|
||||
"icon": "swap_horiz",
|
||||
"contributors": ["dni"]
|
||||
}
|
225
lnbits/extensions/boltz/crud.py
Normal file
225
lnbits/extensions/boltz/crud.py
Normal file
@ -0,0 +1,225 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
)
|
||||
|
||||
"""
|
||||
Submarine Swaps
|
||||
"""
|
||||
|
||||
|
||||
async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_pending_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[SubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE status='pending' order by time DESC",
|
||||
)
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_submarine_swap(swap_id) -> SubmarineSwap:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return SubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_submarine_swap(swap: SubmarineSwap) -> Optional[SubmarineSwap]:
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
payment_hash,
|
||||
status,
|
||||
boltz_id,
|
||||
refund_privkey,
|
||||
refund_address,
|
||||
expected_amount,
|
||||
timeout_block_height,
|
||||
address,
|
||||
bip21,
|
||||
redeem_script,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
swap.id,
|
||||
swap.wallet,
|
||||
swap.payment_hash,
|
||||
swap.status,
|
||||
swap.boltz_id,
|
||||
swap.refund_privkey,
|
||||
swap.refund_address,
|
||||
swap.expected_amount,
|
||||
swap.timeout_block_height,
|
||||
swap.address,
|
||||
swap.bip21,
|
||||
swap.redeem_script,
|
||||
swap.amount,
|
||||
),
|
||||
)
|
||||
return await get_submarine_swap(swap.id)
|
||||
|
||||
|
||||
async def delete_submarine_swap(swap_id):
|
||||
await db.execute("DELETE FROM boltz.submarineswap WHERE id = ?", (swap_id,))
|
||||
|
||||
|
||||
async def get_reverse_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[ReverseSubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_pending_reverse_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[ReverseSubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE status='pending' order by time DESC"
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_reverse_submarine_swap(swap_id) -> SubmarineSwap:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return ReverseSubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_reverse_submarine_swap(
|
||||
swap: ReverseSubmarineSwap,
|
||||
) -> Optional[ReverseSubmarineSwap]:
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.reverse_submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
status,
|
||||
boltz_id,
|
||||
instant_settlement,
|
||||
preimage,
|
||||
claim_privkey,
|
||||
lockup_address,
|
||||
invoice,
|
||||
onchain_amount,
|
||||
onchain_address,
|
||||
timeout_block_height,
|
||||
redeem_script,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
swap.id,
|
||||
swap.wallet,
|
||||
swap.status,
|
||||
swap.boltz_id,
|
||||
swap.instant_settlement,
|
||||
swap.preimage,
|
||||
swap.claim_privkey,
|
||||
swap.lockup_address,
|
||||
swap.invoice,
|
||||
swap.onchain_amount,
|
||||
swap.onchain_address,
|
||||
swap.timeout_block_height,
|
||||
swap.redeem_script,
|
||||
swap.amount,
|
||||
),
|
||||
)
|
||||
return await get_reverse_submarine_swap(swap.id)
|
||||
|
||||
|
||||
async def update_swap_status(swap_id: str, status: str):
|
||||
|
||||
reverse = ""
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
if swap is None:
|
||||
swap = await get_reverse_submarine_swap(swap_id)
|
||||
|
||||
if swap is None:
|
||||
return None
|
||||
|
||||
if type(swap) == SubmarineSwap:
|
||||
await db.execute(
|
||||
"UPDATE boltz.submarineswap SET status='"
|
||||
+ status
|
||||
+ "' WHERE id='"
|
||||
+ swap.id
|
||||
+ "'"
|
||||
)
|
||||
if type(swap) == ReverseSubmarineSwap:
|
||||
reverse = "reverse"
|
||||
await db.execute(
|
||||
"UPDATE boltz.reverse_submarineswap SET status='"
|
||||
+ status
|
||||
+ "' WHERE id='"
|
||||
+ swap.id
|
||||
+ "'"
|
||||
)
|
||||
|
||||
message = f"Boltz - {reverse} swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}"
|
||||
logger.info(message)
|
||||
|
||||
return swap
|
97
lnbits/extensions/boltz/mempool.py
Normal file
97
lnbits/extensions/boltz/mempool.py
Normal file
@ -0,0 +1,97 @@
|
||||
import asyncio
|
||||
import json
|
||||
from binascii import hexlify
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from embit.transaction import Transaction
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
|
||||
|
||||
from .utils import req_wrap
|
||||
|
||||
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
|
||||
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
|
||||
|
||||
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
|
||||
|
||||
|
||||
async def wait_for_websocket_message(send, message_string):
|
||||
async for websocket in websockets.connect(websocket_url):
|
||||
try:
|
||||
await websocket.send(json.dumps({"action": "want", "data": ["blocks"]}))
|
||||
await websocket.send(json.dumps(send))
|
||||
async for raw in websocket:
|
||||
message = json.loads(raw)
|
||||
if message_string in message:
|
||||
return message.get(message_string)
|
||||
except websockets.ConnectionClosed:
|
||||
continue
|
||||
|
||||
|
||||
def get_mempool_tx(address):
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/address/{address}/txs",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
txs = res.json()
|
||||
return get_mempool_tx_from_txs(txs, address)
|
||||
|
||||
|
||||
def get_mempool_tx_from_txs(txs, address):
|
||||
if len(txs) == 0:
|
||||
return None
|
||||
tx = txid = vout_cnt = vout_amount = None
|
||||
for a_tx in txs:
|
||||
for i, vout in enumerate(a_tx["vout"]):
|
||||
if vout["scriptpubkey_address"] == address:
|
||||
tx = a_tx
|
||||
txid = a_tx["txid"]
|
||||
vout_cnt = i
|
||||
vout_amount = vout["value"]
|
||||
# should never happen
|
||||
if tx == None:
|
||||
raise Exception("mempool tx not found")
|
||||
if txid == None:
|
||||
raise Exception("mempool txid not found")
|
||||
return tx, txid, vout_cnt, vout_amount
|
||||
|
||||
|
||||
def get_fee_estimation() -> int:
|
||||
# TODO: hardcoded maximum tx size, in the future we try to get the size of the tx via embit
|
||||
# we need a function like Transaction.vsize()
|
||||
tx_size_vbyte = 200
|
||||
mempool_fees = get_mempool_fees()
|
||||
return mempool_fees * tx_size_vbyte
|
||||
|
||||
|
||||
def get_mempool_fees() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/v1/fees/recommended",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
fees = res.json()
|
||||
return int(fees["economyFee"])
|
||||
|
||||
|
||||
def get_mempool_blockheight() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/blocks/tip/height",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
return int(res.text)
|
||||
|
||||
|
||||
async def send_onchain_tx(tx: Transaction):
|
||||
raw = hexlify(tx.serialize())
|
||||
logger.debug(f"Boltz - mempool sending onchain tx...")
|
||||
req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
content=raw,
|
||||
)
|
46
lnbits/extensions/boltz/migrations.py
Normal file
46
lnbits/extensions/boltz/migrations.py
Normal file
@ -0,0 +1,46 @@
|
||||
async def m001_initial(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltz.submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
payment_hash TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
refund_address TEXT NOT NULL,
|
||||
refund_privkey TEXT NOT NULL,
|
||||
expected_amount INT NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
bip21 TEXT NOT NULL,
|
||||
redeem_script TEXT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltz.reverse_submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
onchain_address TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
instant_settlement BOOLEAN NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
redeem_script TEXT NOT NULL,
|
||||
preimage TEXT NOT NULL,
|
||||
claim_privkey TEXT NOT NULL,
|
||||
lockup_address TEXT NOT NULL,
|
||||
invoice TEXT NOT NULL,
|
||||
onchain_amount INT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
75
lnbits/extensions/boltz/models.py
Normal file
75
lnbits/extensions/boltz/models.py
Normal file
@ -0,0 +1,75 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi.params import Query
|
||||
from pydantic.main import BaseModel
|
||||
from sqlalchemy.engine import base # type: ignore
|
||||
|
||||
|
||||
class SubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
payment_hash: str
|
||||
time: int
|
||||
status: str
|
||||
refund_privkey: str
|
||||
refund_address: str
|
||||
boltz_id: str
|
||||
expected_amount: int
|
||||
timeout_block_height: int
|
||||
address: str
|
||||
bip21: str
|
||||
redeem_script: str
|
||||
|
||||
|
||||
class CreateSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...) # type: ignore
|
||||
refund_address: str = Query(...) # type: ignore
|
||||
amount: int = Query(...) # type: ignore
|
||||
|
||||
|
||||
class ReverseSubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
onchain_address: str
|
||||
instant_settlement: bool
|
||||
time: int
|
||||
status: str
|
||||
boltz_id: str
|
||||
preimage: str
|
||||
claim_privkey: str
|
||||
lockup_address: str
|
||||
invoice: str
|
||||
onchain_amount: int
|
||||
timeout_block_height: int
|
||||
redeem_script: str
|
||||
|
||||
|
||||
class CreateReverseSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...) # type: ignore
|
||||
amount: int = Query(...) # type: ignore
|
||||
instant_settlement: bool = Query(...) # type: ignore
|
||||
# validate on-address, bcrt1 for regtest addresses
|
||||
onchain_address: str = Query(
|
||||
..., regex="^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$"
|
||||
) # type: ignore
|
||||
|
||||
|
||||
class SwapStatus(BaseModel):
|
||||
swap_id: str
|
||||
wallet: str
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
boltz: str = ""
|
||||
mempool: str = ""
|
||||
address: str = ""
|
||||
block_height: int = 0
|
||||
timeout_block_height: str = ""
|
||||
lockup: Optional[dict] = {}
|
||||
has_lockup: bool = False
|
||||
hit_timeout: bool = False
|
||||
confirmed: bool = True
|
||||
exists: bool = True
|
||||
reverse: bool = False
|
153
lnbits/extensions/boltz/tasks.py
Normal file
153
lnbits/extensions/boltz/tasks.py
Normal file
@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import check_transaction_status
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .boltz import (
|
||||
create_claim_tx,
|
||||
create_refund_tx,
|
||||
get_swap_status,
|
||||
start_confirmation_listener,
|
||||
start_onchain_listener,
|
||||
)
|
||||
from .crud import (
|
||||
get_all_pending_reverse_submarine_swaps,
|
||||
get_all_pending_submarine_swaps,
|
||||
get_reverse_submarine_swap,
|
||||
get_submarine_swap,
|
||||
update_swap_status,
|
||||
)
|
||||
|
||||
"""
|
||||
testcases for boltz startup
|
||||
A. normal swaps
|
||||
1. test: create -> kill -> start -> startup invoice listeners -> pay onchain funds -> should complete
|
||||
2. test: create -> kill -> pay onchain funds -> start -> startup check -> should complete
|
||||
3. test: create -> kill -> mine blocks and hit timeout -> start -> should go timeout/failed
|
||||
4. test: create -> kill -> pay to less onchain funds -> mine blocks hit timeout -> start lnbits -> should be refunded
|
||||
|
||||
B. reverse swaps
|
||||
1. test: create instant -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should claim/complete
|
||||
2. test: create instant -> kill -> no lockup -> start lnbits -> should start onchain listener -> boltz does lockup -> should claim/complete (difficult to test)
|
||||
3. test: create -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should start tx listener -> after confirmation -> should claim/complete
|
||||
4. test: create -> kill -> boltz does lockup -> confirmed -> start lnbits -> should claim/complete
|
||||
5. test: create -> kill -> boltz does lockup -> hit timeout -> boltz refunds -> start -> should timeout
|
||||
"""
|
||||
|
||||
|
||||
async def check_for_pending_swaps():
|
||||
try:
|
||||
swaps = await get_all_pending_submarine_swaps()
|
||||
reverse_swaps = await get_all_pending_reverse_submarine_swaps()
|
||||
if len(swaps) > 0 or len(reverse_swaps) > 0:
|
||||
logger.debug(f"Boltz - startup swap check")
|
||||
except:
|
||||
# database is not created yet, do nothing
|
||||
return
|
||||
|
||||
if len(swaps) > 0:
|
||||
logger.debug(f"Boltz - {len(swaps)} pending swaps")
|
||||
for swap in swaps:
|
||||
try:
|
||||
swap_status = get_swap_status(swap)
|
||||
# should only happen while development when regtest is reset
|
||||
if swap_status.exists is False:
|
||||
logger.warning(f"Boltz - swap: {swap.boltz_id} does not exist.")
|
||||
await update_swap_status(swap.id, "failed")
|
||||
continue
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
swap.wallet, swap.payment_hash
|
||||
)
|
||||
|
||||
if payment_status.paid:
|
||||
logger.debug(
|
||||
f"Boltz - swap: {swap.boltz_id} got paid while offline."
|
||||
)
|
||||
await update_swap_status(swap.id, "complete")
|
||||
else:
|
||||
if swap_status.hit_timeout:
|
||||
if not swap_status.has_lockup:
|
||||
logger.warning(
|
||||
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
|
||||
)
|
||||
await update_swap_status(swap.id, "timeout")
|
||||
else:
|
||||
logger.debug(f"Boltz - refunding swap: {swap.id}...")
|
||||
await create_refund_tx(swap)
|
||||
await update_swap_status(swap.id, "refunded")
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Boltz - swap: {swap.id} - {str(exc)}")
|
||||
|
||||
if len(reverse_swaps) > 0:
|
||||
logger.debug(f"Boltz - {len(reverse_swaps)} pending reverse swaps")
|
||||
for reverse_swap in reverse_swaps:
|
||||
try:
|
||||
swap_status = get_swap_status(reverse_swap)
|
||||
|
||||
if swap_status.exists is False:
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} does not exist."
|
||||
)
|
||||
await update_swap_status(reverse_swap.id, "failed")
|
||||
continue
|
||||
|
||||
# if timeout hit, boltz would have already refunded
|
||||
if swap_status.hit_timeout:
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} timeout."
|
||||
)
|
||||
await update_swap_status(reverse_swap.id, "timeout")
|
||||
continue
|
||||
|
||||
if not swap_status.has_lockup:
|
||||
# start listener for onchain address
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted onchain address listener."
|
||||
)
|
||||
await start_onchain_listener(reverse_swap)
|
||||
continue
|
||||
|
||||
if reverse_swap.instant_settlement or swap_status.confirmed:
|
||||
await create_claim_tx(reverse_swap, swap_status.lockup)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted confirmation listener."
|
||||
)
|
||||
await start_confirmation_listener(reverse_swap, swap_status.lockup)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Boltz - reverse swap: {reverse_swap.id} - {str(exc)}")
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if "boltz" != payment.extra.get("tag"):
|
||||
# not a boltz invoice
|
||||
return
|
||||
|
||||
await payment.set_pending(False)
|
||||
swap_id = payment.extra.get("swap_id")
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
|
||||
if not swap:
|
||||
logger.error(f"swap_id: {swap_id} not found.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Boltz - lightning invoice is paid, normal swap completed. swap_id: {swap_id}"
|
||||
)
|
||||
await update_swap_status(swap_id, "complete")
|
236
lnbits/extensions/boltz/templates/boltz/_api_docs.html
Normal file
236
lnbits/extensions/boltz/templates/boltz/_api_docs.html
Normal file
@ -0,0 +1,236 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="About Boltz"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<img
|
||||
src="https://boltz.exchange/static/media/Shape.6c1a92b3.svg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="https://boltz.exchange/static/media/Boltz.02fb7acb.svg"
|
||||
style="padding: 5px 9px"
|
||||
alt=""
|
||||
/>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Boltz.exchange: Do onchain to offchain and vice-versa swaps
|
||||
</h5>
|
||||
<p>
|
||||
Submarine and Reverse Submarine Swaps on LNbits via boltz.exchange
|
||||
API<br />
|
||||
</p>
|
||||
<p>
|
||||
Link :
|
||||
<a target="_blank" href="https://boltz.exchange"
|
||||
>https://boltz.exchange
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
|
||||
>More details</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<small
|
||||
>Created by,
|
||||
<a target="_blank" href="https://github.com/dni">dni</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/reverse">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/reverse</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON list of reverse submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
|
||||
{{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="POST swap/reverse"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span>
|
||||
/boltz/api/v1/swap/reverse</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "onchain_address": <string>,
|
||||
"amount": <integer>, "instant_settlement":
|
||||
<boolean>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON create a reverse-submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
|
||||
{{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-blue">GET</span> /boltz/api/v1/swap</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON list of submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="POST swap">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span> /boltz/api/v1/swap</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "refund_address": <string>,
|
||||
"amount": <integer>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON create a submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/refund">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span>
|
||||
/boltz/api/v1/swap/refund/{swap_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON submarine swap</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/refund/{swap_id} -H
|
||||
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/status">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span>
|
||||
/boltz/api/v1/swap/status/{swap_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (text/plain)
|
||||
</h5>
|
||||
<code>swap status</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/status/{swap_id} -H
|
||||
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/check">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/check</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON pending swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/check -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET boltz-config">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/boltz</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (text/plain)
|
||||
</h5>
|
||||
<code>JSON boltz config</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/boltz -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET mempool-url">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/mempool</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (text/plain)
|
||||
</h5>
|
||||
<code>mempool url</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/mempool -H "X-Api-Key:
|
||||
{{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
1005
lnbits/extensions/boltz/templates/boltz/index.html
Normal file
1005
lnbits/extensions/boltz/templates/boltz/index.html
Normal file
File diff suppressed because it is too large
Load Diff
44
lnbits/extensions/boltz/utils.py
Normal file
44
lnbits/extensions/boltz/utils.py
Normal file
@ -0,0 +1,44 @@
|
||||
import calendar
|
||||
import datetime
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import fee_reserve, get_wallet
|
||||
|
||||
|
||||
async def check_balance(data) -> bool:
|
||||
# check if we can pay the invoice before we create the actual swap on boltz
|
||||
amount_msat = data.amount * 1000
|
||||
fee_reserve_msat = fee_reserve(amount_msat)
|
||||
wallet = await get_wallet(data.wallet)
|
||||
assert wallet
|
||||
if wallet.balance_msat - fee_reserve_msat < amount_msat:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_timestamp():
|
||||
date = datetime.datetime.utcnow()
|
||||
return calendar.timegm(date.utctimetuple())
|
||||
|
||||
|
||||
def req_wrap(funcname, *args, **kwargs):
|
||||
try:
|
||||
try:
|
||||
func = getattr(httpx, funcname)
|
||||
except AttributeError:
|
||||
logger.error('httpx function not found "%s"' % funcname)
|
||||
else:
|
||||
res = func(*args, timeout=30, **kwargs)
|
||||
res.raise_for_status()
|
||||
return res
|
||||
except httpx.RequestError as exc:
|
||||
msg = f"Unreachable: {exc.request.url!r}."
|
||||
logger.error(msg)
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
msg = f"HTTP Status Error: {exc.response.status_code} while requesting {exc.request.url!r}."
|
||||
logger.error(msg)
|
||||
logger.error(exc.response.json()["error"])
|
||||
raise
|
23
lnbits/extensions/boltz/views.py
Normal file
23
lnbits/extensions/boltz/views.py
Normal file
@ -0,0 +1,23 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import boltz_ext, boltz_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@boltz_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
root_url = urlparse(str(request.url)).netloc
|
||||
wallet_ids = [wallet.id for wallet in user.wallets]
|
||||
return boltz_renderer().TemplateResponse(
|
||||
"boltz/index.html",
|
||||
{"request": request, "user": user.dict(), "root_url": root_url},
|
||||
)
|
338
lnbits/extensions/boltz/views_api.py
Normal file
338
lnbits/extensions/boltz/views_api.py
Normal file
@ -0,0 +1,338 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.param_functions import Body
|
||||
from fastapi.params import Depends, Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL
|
||||
|
||||
from . import boltz_ext
|
||||
from .boltz import (
|
||||
create_refund_tx,
|
||||
create_reverse_swap,
|
||||
create_swap,
|
||||
get_boltz_pairs,
|
||||
get_swap_status,
|
||||
)
|
||||
from .crud import (
|
||||
create_reverse_submarine_swap,
|
||||
create_submarine_swap,
|
||||
get_pending_reverse_submarine_swaps,
|
||||
get_pending_submarine_swaps,
|
||||
get_reverse_submarine_swap,
|
||||
get_reverse_submarine_swaps,
|
||||
get_submarine_swap,
|
||||
get_submarine_swaps,
|
||||
update_swap_status,
|
||||
)
|
||||
from .models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
)
|
||||
from .utils import check_balance
|
||||
|
||||
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap/mempool",
|
||||
name=f"boltz.get /swap/mempool",
|
||||
summary="get a the mempool url",
|
||||
description="""
|
||||
This endpoint gets the URL from mempool.space
|
||||
""",
|
||||
response_description="mempool.space url",
|
||||
response_model=str,
|
||||
)
|
||||
async def api_mempool_url():
|
||||
return BOLTZ_MEMPOOL_SPACE_URL
|
||||
|
||||
|
||||
# NORMAL SWAP
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap",
|
||||
name=f"boltz.get /swap",
|
||||
summary="get a list of swaps a swap",
|
||||
description="""
|
||||
This endpoint gets a list of normal swaps.
|
||||
""",
|
||||
response_description="list of normal swaps",
|
||||
dependencies=[Depends(get_key_type)],
|
||||
response_model=List[SubmarineSwap],
|
||||
)
|
||||
async def api_submarineswap(
|
||||
g: WalletTypeInfo = Depends(get_key_type),
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
for swap in await get_pending_submarine_swaps(wallet_ids):
|
||||
swap_status = get_swap_status(swap)
|
||||
if swap_status.hit_timeout:
|
||||
if not swap_status.has_lockup:
|
||||
logger.warning(
|
||||
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
|
||||
)
|
||||
await update_swap_status(swap.id, "timeout")
|
||||
|
||||
return [swap.dict() for swap in await get_submarine_swaps(wallet_ids)]
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/refund",
|
||||
name=f"boltz.swap_refund",
|
||||
summary="refund of a swap",
|
||||
description="""
|
||||
This endpoint attempts to refund a normal swaps, creates onchain tx and sets swap status ro refunded.
|
||||
""",
|
||||
response_description="refunded swap with status set to refunded",
|
||||
dependencies=[Depends(require_admin_key)],
|
||||
response_model=SubmarineSwap,
|
||||
responses={
|
||||
400: {"description": "when swap_id is missing"},
|
||||
404: {"description": "when swap is not found"},
|
||||
405: {"description": "when swap is not pending"},
|
||||
500: {
|
||||
"description": "when something goes wrong creating the refund onchain tx"
|
||||
},
|
||||
},
|
||||
)
|
||||
async def api_submarineswap_refund(
|
||||
swap_id: str,
|
||||
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
if swap_id == None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="swap_id missing"
|
||||
)
|
||||
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
if swap == None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
|
||||
)
|
||||
|
||||
if swap.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="swap is not pending."
|
||||
)
|
||||
|
||||
try:
|
||||
await create_refund_tx(swap)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
|
||||
|
||||
await update_swap_status(swap.id, "refunded")
|
||||
return swap
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
name=f"boltz.post /swap",
|
||||
summary="create a submarine swap",
|
||||
description="""
|
||||
This endpoint creates a submarine swap
|
||||
""",
|
||||
response_description="create swap",
|
||||
response_model=SubmarineSwap,
|
||||
responses={
|
||||
405: {"description": "not allowed method, insufficient balance"},
|
||||
500: {"description": "boltz error"},
|
||||
},
|
||||
)
|
||||
async def api_submarineswap_create(
|
||||
data: CreateSubmarineSwap,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
try:
|
||||
swap_data = await create_swap(data)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code, detail=exc.response.json()["error"]
|
||||
)
|
||||
swap = await create_submarine_swap(swap_data)
|
||||
return swap.dict()
|
||||
|
||||
|
||||
# REVERSE SWAP
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap/reverse",
|
||||
name=f"boltz.get /swap/reverse",
|
||||
summary="get a list of reverse swaps a swap",
|
||||
description="""
|
||||
This endpoint gets a list of reverse swaps.
|
||||
""",
|
||||
response_description="list of reverse swaps",
|
||||
dependencies=[Depends(get_key_type)],
|
||||
response_model=List[ReverseSubmarineSwap],
|
||||
)
|
||||
async def api_reverse_submarineswap(
|
||||
g: WalletTypeInfo = Depends(get_key_type), # type:ignore
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
return [swap.dict() for swap in await get_reverse_submarine_swaps(wallet_ids)]
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/reverse",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
name=f"boltz.post /swap/reverse",
|
||||
summary="create a reverse submarine swap",
|
||||
description="""
|
||||
This endpoint creates a reverse submarine swap
|
||||
""",
|
||||
response_description="create reverse swap",
|
||||
response_model=ReverseSubmarineSwap,
|
||||
responses={
|
||||
405: {"description": "not allowed method, insufficient balance"},
|
||||
500: {"description": "boltz error"},
|
||||
},
|
||||
)
|
||||
async def api_reverse_submarineswap_create(
|
||||
data: CreateReverseSubmarineSwap,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
|
||||
if not await check_balance(data):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="Insufficient balance."
|
||||
)
|
||||
|
||||
try:
|
||||
swap_data, task = await create_reverse_swap(data)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code, detail=exc.response.json()["error"]
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
|
||||
|
||||
swap = await create_reverse_submarine_swap(swap_data)
|
||||
return swap.dict()
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/status",
|
||||
name=f"boltz.swap_status",
|
||||
summary="shows the status of a swap",
|
||||
description="""
|
||||
This endpoint attempts to get the status of the swap.
|
||||
""",
|
||||
response_description="status of swap json",
|
||||
responses={
|
||||
404: {"description": "when swap_id is not found"},
|
||||
},
|
||||
)
|
||||
async def api_swap_status(
|
||||
swap_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
|
||||
):
|
||||
swap = await get_submarine_swap(swap_id) or await get_reverse_submarine_swap(
|
||||
swap_id
|
||||
)
|
||||
if swap == None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
|
||||
)
|
||||
try:
|
||||
status = get_swap_status(swap)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/check",
|
||||
name=f"boltz.swap_check",
|
||||
summary="list all pending swaps",
|
||||
description="""
|
||||
This endpoint gives you 2 lists of pending swaps and reverse swaps.
|
||||
""",
|
||||
response_description="list of pending swaps",
|
||||
)
|
||||
async def api_check_swaps(
|
||||
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
status = []
|
||||
try:
|
||||
for swap in await get_pending_submarine_swaps(wallet_ids):
|
||||
status.append(get_swap_status(swap))
|
||||
for reverseswap in await get_pending_reverse_submarine_swaps(wallet_ids):
|
||||
status.append(get_swap_status(reverseswap))
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap/boltz",
|
||||
name=f"boltz.get /swap/boltz",
|
||||
summary="get a boltz configuration",
|
||||
description="""
|
||||
This endpoint gets configuration for boltz. (limits, fees...)
|
||||
""",
|
||||
response_description="dict of boltz config",
|
||||
response_model=dict,
|
||||
)
|
||||
async def api_boltz_config():
|
||||
try:
|
||||
res = get_boltz_pairs()
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
return res["pairs"]["BTC/BTC"]
|
@ -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"
|
||||
)
|
||||
|
@ -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
|
||||
|
Binary file not shown.
@ -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"]
|
||||
|
0
tests/extensions/boltz/__init__.py
Normal file
0
tests/extensions/boltz/__init__.py
Normal file
25
tests/extensions/boltz/conftest.py
Normal file
25
tests/extensions/boltz/conftest.py
Normal file
@ -0,0 +1,25 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet, get_wallet
|
||||
from lnbits.extensions.boltz.boltz import create_reverse_swap, create_swap
|
||||
from lnbits.extensions.boltz.models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
)
|
||||
from tests.mocks import WALLET
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def reverse_swap(from_wallet):
|
||||
data = CreateReverseSubmarineSwap(
|
||||
wallet=from_wallet.id,
|
||||
instant_settlement=True,
|
||||
onchain_address="bcrt1q4vfyszl4p8cuvqh07fyhtxve5fxq8e2ux5gx43",
|
||||
amount=20_000,
|
||||
)
|
||||
return await create_reverse_swap(data)
|
146
tests/extensions/boltz/test_api.py
Normal file
146
tests/extensions/boltz/test_api.py
Normal file
@ -0,0 +1,146 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from tests.helpers import is_fake, is_regtest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mempool_url(client):
|
||||
response = await client.get("/boltz/api/v1/swap/mempool")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_boltz_config(client):
|
||||
response = await client.get("/boltz/api/v1/swap/boltz")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoints_unauthenticated(client):
|
||||
response = await client.get("/boltz/api/v1/swap?all_wallets=true")
|
||||
assert response.status_code == 401
|
||||
response = await client.get("/boltz/api/v1/swap/reverse?all_wallets=true")
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap")
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/reverse")
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/status")
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/check")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoints_inkey(client, inkey_headers_to):
|
||||
response = await client.get(
|
||||
"/boltz/api/v1/swap?all_wallets=true", headers=inkey_headers_to
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response = await client.get(
|
||||
"/boltz/api/v1/swap/reverse?all_wallets=true", headers=inkey_headers_to
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = await client.post("/boltz/api/v1/swap", headers=inkey_headers_to)
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/reverse", headers=inkey_headers_to)
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/refund", headers=inkey_headers_to)
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/status", headers=inkey_headers_to)
|
||||
assert response.status_code == 401
|
||||
response = await client.post("/boltz/api/v1/swap/check", headers=inkey_headers_to)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoints_adminkey_nocontent(client, adminkey_headers_to):
|
||||
response = await client.post("/boltz/api/v1/swap", headers=adminkey_headers_to)
|
||||
assert response.status_code == 204
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/reverse", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 204
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/refund", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 204
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/status", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(is_regtest, reason="this test is only passes with fakewallet")
|
||||
async def test_endpoints_adminkey_fakewallet(client, from_wallet, adminkey_headers_to):
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/check", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 200
|
||||
swap = {
|
||||
"wallet": from_wallet.id,
|
||||
"refund_address": "bcrt1q3cwq33y435h52gq3qqsdtczh38ltlnf69zvypm",
|
||||
"amount": 50_000,
|
||||
}
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap", json=swap, headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 405
|
||||
reverse_swap = {
|
||||
"wallet": from_wallet.id,
|
||||
"instant_settlement": True,
|
||||
"onchain_address": "bcrt1q4vfyszl4p8cuvqh07fyhtxve5fxq8e2ux5gx43",
|
||||
"amount": 50_000,
|
||||
}
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/reverse", json=reverse_swap, headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 201
|
||||
reverse_swap = response.json()
|
||||
assert reverse_swap["id"] is not None
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/status",
|
||||
params={"swap_id": reverse_swap["id"]},
|
||||
headers=adminkey_headers_to,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/status",
|
||||
params={"swap_id": "wrong"},
|
||||
headers=adminkey_headers_to,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/refund",
|
||||
params={"swap_id": "wrong"},
|
||||
headers=adminkey_headers_to,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(is_fake, reason="this test is only passes with regtest")
|
||||
async def test_endpoints_adminkey_regtest(client, from_wallet, adminkey_headers_to):
|
||||
swap = {
|
||||
"wallet": from_wallet.id,
|
||||
"refund_address": "bcrt1q3cwq33y435h52gq3qqsdtczh38ltlnf69zvypm",
|
||||
"amount": 50_000,
|
||||
}
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap", json=swap, headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
reverse_swap = {
|
||||
"wallet": from_wallet.id,
|
||||
"instant_settlement": True,
|
||||
"onchain_address": "bcrt1q4vfyszl4p8cuvqh07fyhtxve5fxq8e2ux5gx43",
|
||||
"amount": 50_000,
|
||||
}
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/reverse", json=reverse_swap, headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 201
|
31
tests/extensions/boltz/test_swap.py
Normal file
31
tests/extensions/boltz/test_swap.py
Normal file
@ -0,0 +1,31 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from lnbits.extensions.boltz.boltz import create_reverse_swap, create_swap
|
||||
from lnbits.extensions.boltz.crud import (
|
||||
create_reverse_submarine_swap,
|
||||
create_submarine_swap,
|
||||
get_reverse_submarine_swap,
|
||||
get_submarine_swap,
|
||||
)
|
||||
from tests.extensions.boltz.conftest import reverse_swap
|
||||
from tests.helpers import is_fake, is_regtest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(is_fake, reason="this test is only passes in regtest")
|
||||
async def test_create_reverse_swap(client, reverse_swap):
|
||||
swap, wait_for_onchain = reverse_swap
|
||||
assert swap.status == "pending"
|
||||
assert swap.id is not None
|
||||
assert swap.boltz_id is not None
|
||||
assert swap.claim_privkey is not None
|
||||
assert swap.onchain_address is not None
|
||||
assert swap.lockup_address is not None
|
||||
newswap = await create_reverse_submarine_swap(swap)
|
||||
await wait_for_onchain
|
||||
newswap = await get_reverse_submarine_swap(swap.id)
|
||||
assert newswap is not None
|
||||
assert newswap.status == "complete"
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user