diff --git a/boltz_client/boltz.py b/boltz_client/boltz.py index 5975332..7774194 100644 --- a/boltz_client/boltz.py +++ b/boltz_client/boltz.py @@ -1,9 +1,11 @@ """ boltz_client main module """ +import asyncio from dataclasses import dataclass from typing import Optional import httpx +from embit.transaction import Transaction from .helpers import req_wrap from .mempool import MempoolClient @@ -28,12 +30,24 @@ def __init__(self, message: str, status: str): self.status = status +class BoltzSwapTransactionException(Exception): + def __init__(self, message: str, status: str): + self.message = message + self.status = status + + +@dataclass +class BoltzSwapTransactionResponse: + transactionHex: Optional[str] = None + timeoutBlockHeight: Optional[str] = None + + @dataclass class BoltzSwapStatusResponse: status: str failureReason: Optional[str] = None zeroConfRejected: Optional[str] = None - transaction: Optional[str] = None + transaction: Optional[dict] = None @dataclass @@ -61,7 +75,7 @@ class BoltzReverseSwapResponse: class BoltzConfig: network: str = "main" api_url: str = "https://boltz.exchange/api" - mempool_url: str = "https://mempool.space/api" + mempool_url: str = "https://mempool.space/api/v1" mempool_ws_url: str = "wss://mempool.space/api/v1/ws" referral_id: str = "dni" @@ -95,8 +109,8 @@ def check_version(self): ) def get_fee_estimation(self, feerate: Optional[int]) -> 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() + # 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 = feerate if feerate else self.mempool.get_fees() return mempool_fees * tx_size_vbyte @@ -117,8 +131,10 @@ def set_limits(self) -> None: def check_limits(self, amount: int) -> None: valid = self.limit_minimal <= amount <= self.limit_maximal if not valid: - msg = f"Boltz - swap not in boltz limits, amount: {amount}, min: {self.limit_minimal}, max: {self.limit_maximal}" - raise BoltzLimitException(msg) + raise BoltzLimitException( + f"Boltz - swap not in boltz limits, amount: {amount}, " + f"min: {self.limit_minimal}, max: {self.limit_maximal}" + ) def swap_status(self, boltz_id: str) -> BoltzSwapStatusResponse: data = self.request( @@ -134,8 +150,44 @@ def swap_status(self, boltz_id: str) -> BoltzSwapStatusResponse: return status + def swap_transaction(self, boltz_id: str) -> BoltzSwapTransactionResponse: + data = self.request( + "post", + f"{self._cfg.api_url}/getswaptransaction", + json={"id": boltz_id}, + headers={"Content-Type": "application/json"}, + ) + res = BoltzSwapTransactionResponse(**data) + print(res) + + # if res.failureReason: + # raise BoltzSwapTransctionException(res.failureReason, res.status) + + return res + + async def wait_for_txid(self, boltz_id: str) -> str: + while True: + try: + tx_hex = self.swap_transaction(boltz_id) + tx = Transaction.from_string(tx_hex.transactionHex) + return tx.txid().hex() + except Exception: + await asyncio.sleep(5) + + async def wait_for_txid_on_status(self, boltz_id: str) -> str: + while True: + try: + status = self.swap_status(boltz_id) + assert status.transaction + id = status.transaction.get('id') + assert id + return id + except Exception: + await asyncio.sleep(5) + async def claim_reverse_swap( self, + boltz_id: str, lockup_address: str, receive_address: str, privkey_wif: str, @@ -144,7 +196,8 @@ async def claim_reverse_swap( zeroconf: bool = False, feerate: Optional[int] = None, ): - lockup_tx = await self.mempool.get_tx_from_address(lockup_address) + lockup_txid = await self.wait_for_txid_on_status(boltz_id) + lockup_tx = await self.mempool.get_tx_from_txid(lockup_txid, lockup_address) if not zeroconf and lockup_tx.status != "confirmed": await self.mempool.wait_for_tx_confirmed(lockup_tx.txid) @@ -163,6 +216,7 @@ async def claim_reverse_swap( async def refund_swap( self, + boltz_id: str, privkey_wif: str, lockup_address: str, receive_address: str, @@ -171,7 +225,8 @@ async def refund_swap( feerate: Optional[int] = None, ) -> str: self.mempool.check_block_height(timeout_block_height) - lockup_tx = await self.mempool.get_tx_from_address(lockup_address) + lockup_txid = await self.wait_for_txid(boltz_id) + lockup_tx = await self.mempool.get_tx_from_txid(lockup_txid, lockup_address) txid, transaction = create_refund_tx( lockup_tx=lockup_tx, privkey_wif=privkey_wif, diff --git a/boltz_client/cli.py b/boltz_client/cli.py index 75e2a06..4fc14c7 100644 --- a/boltz_client/cli.py +++ b/boltz_client/cli.py @@ -12,6 +12,13 @@ config = BoltzConfig() +# use for manual testing +# config = BoltzConfig( +# network="regtest", +# api_url="http://localhost:9001", +# mempool_url="http://localhost:8999/api/v1", +# mempool_ws_url="ws://localhost:8999/api/v1/ws", +# ) @click.group() def command_group(): @@ -37,7 +44,7 @@ def create_swap(payment_request): click.echo(f"boltz_id: {swap.id}") click.echo() click.echo( - f"mempool.space url: {config.mempool_url.replace('/api', '')}/address/{swap.address}" + f"mempool.space url: {config.mempool_url}/address/{swap.address}" ) click.echo() click.echo(f"refund privkey in wif: {refund_privkey_wif}") @@ -52,18 +59,20 @@ def create_swap(payment_request): click.echo("run this command if you need to refund:") click.echo("CHANGE YOUR_RECEIVEADDRESS to your onchain address!!!") click.echo( - f"boltz refund-swap {refund_privkey_wif} {swap.address} YOUR_RECEIVEADDRESS " + f"boltz refund-swap {swap.id} {refund_privkey_wif} {swap.address} YOUR_RECEIVEADDRESS " f"{swap.redeemScript} {swap.timeoutBlockHeight}" ) @click.command() +@click.argument("boltz_id", type=str) @click.argument("privkey_wif", type=str) @click.argument("lockup_address", type=str) @click.argument("receive_address", type=str) @click.argument("redeem_script_hex", type=str) @click.argument("timeout_block_height", type=int) def refund_swap( + boltz_id: str, privkey_wif: str, lockup_address: str, receive_address: str, @@ -76,11 +85,12 @@ def refund_swap( client = BoltzClient(config) txid = asyncio.run( client.refund_swap( - privkey_wif, - lockup_address, - receive_address, - redeem_script_hex, - timeout_block_height, + boltz_id=boltz_id, + privkey_wif=privkey_wif, + lockup_address=lockup_address, + receive_address=receive_address, + redeem_script_hex=redeem_script_hex, + timeout_block_height=timeout_block_height, ) ) click.echo("swap refunded!") @@ -105,7 +115,7 @@ def create_reverse_swap(sats: int): click.echo() click.echo(f"boltz_id: {swap.id}") click.echo( - f"mempool.space url: {config.mempool_url.replace('/api', '')}/address/{swap.lockupAddress}" + f"mempool.space url: {config.mempool_url}/address/{swap.lockupAddress}" ) click.echo() click.echo("invoice:") @@ -115,7 +125,7 @@ def create_reverse_swap(sats: int): click.echo("run this command after you see the lockup transaction:") click.echo("CHANGE YOUR_RECEIVEADDRESS to your onchain address!!!") click.echo( - f"boltz claim-reverse-swap {swap.lockupAddress} YOUR_RECEIVEADDRESS " + f"boltz claim-reverse-swap {swap.id} {swap.lockupAddress} YOUR_RECEIVEADDRESS " f"{claim_privkey_wif} {preimage_hex} {swap.redeemScript}" ) @@ -153,12 +163,13 @@ def create_reverse_swap_and_claim( txid = asyncio.run( client.claim_reverse_swap( - swap.lockupAddress, - receive_address, - claim_privkey_wif, - preimage_hex, - swap.redeemScript, - zeroconf, + boltz_id=swap.id, + lockup_address=swap.lockupAddress, + receive_address=receive_address, + privkey_wif=claim_privkey_wif, + preimage_hex=preimage_hex, + redeem_script_hex=swap.redeemScript, + zeroconf=zeroconf, ) ) @@ -167,6 +178,7 @@ def create_reverse_swap_and_claim( @click.command() +@click.argument("boltz_id", type=str) @click.argument("lockup_address", type=str) @click.argument("receive_address", type=str) @click.argument("privkey_wif", type=str) @@ -174,6 +186,7 @@ def create_reverse_swap_and_claim( @click.argument("redeem_script_hex", type=str) @click.argument("zeroconf", type=bool, default=False) def claim_reverse_swap( + boltz_id: str, lockup_address: str, receive_address: str, privkey_wif: str, @@ -188,12 +201,13 @@ def claim_reverse_swap( txid = asyncio.run( client.claim_reverse_swap( - lockup_address, - receive_address, - privkey_wif, - preimage_hex, - redeem_script_hex, - zeroconf, + boltz_id=boltz_id, + lockup_address=lockup_address, + receive_address=receive_address, + privkey_wif=privkey_wif, + preimage_hex=preimage_hex, + redeem_script_hex=redeem_script_hex, + zeroconf=zeroconf, ) ) diff --git a/boltz_client/mempool.py b/boltz_client/mempool.py index 9273317..e6611d2 100644 --- a/boltz_client/mempool.py +++ b/boltz_client/mempool.py @@ -42,7 +42,10 @@ def request(self, funcname, *args, **kwargs) -> dict: msg = f"unreachable: {exc.request.url!r}." raise MempoolApiException(f"mempool api connection error: {msg}") from exc except httpx.HTTPStatusError as exc: - msg = f"{exc.response.status_code} while requesting {exc.request.url!r}. message: {exc.response.text}" + msg = ( + f"{exc.response.status_code} while requesting " + f"{exc.request.url!r}. message: {exc.response.text}" + ) raise MempoolApiException(f"mempool api status error: {msg}") from exc async def wait_for_websocket_message(self, send, message_key): @@ -86,17 +89,30 @@ def find_tx_and_output(self, txs, address: str) -> Optional[LockupData]: if len(txs) == 0: return None for tx in txs: - for i, vout in enumerate(tx["vout"]): - if vout["scriptpubkey_address"] == address: - status = "confirmed" if tx["status"]["confirmed"] else "unconfirmed" - return LockupData( - txid=tx["txid"], - vout_cnt=i, - vout_amount=vout["value"], - status=status, - ) + output = self.find_output(tx, address) + if output: + return output + return None + + def find_output(self, tx, address: str) -> Optional[LockupData]: + for i, vout in enumerate(tx["vout"]): + if vout["scriptpubkey_address"] == address: + status = "confirmed" if tx["status"]["confirmed"] else "unconfirmed" + return LockupData( + txid=tx["txid"], + vout_cnt=i, + vout_amount=vout["value"], + status=status, + ) return None + def get_tx(self, txid: str): + return self.request( + "get", + f"{self._api_url}/tx/{txid}", + headers={"Content-Type": "application/json"}, + ) + def get_txs_from_address(self, address: str): return self.request( "get", @@ -104,6 +120,13 @@ def get_txs_from_address(self, address: str): headers={"Content-Type": "application/json"}, ) + async def get_tx_from_txid(self, txid: str, address: str) -> LockupData: + while True: + output = self.find_output(self.get_tx(txid), address) + if output: + return output + await asyncio.sleep(3) + async def get_tx_from_address(self, address: str) -> LockupData: txs = self.get_txs_from_address(address) if len(txs) == 0: @@ -116,7 +139,7 @@ async def get_tx_from_address(self, address: str) -> LockupData: def get_fees(self) -> int: data = self.request( "get", - f"{self._api_url}/v1/fees/recommended", + f"{self._api_url}/fees/recommended", headers={"Content-Type": "application/json"}, ) return int(data["halfHourFee"]) @@ -132,8 +155,10 @@ def get_blockheight(self) -> int: def check_block_height(self, timeout_block_height: int) -> None: current_block_height = self.get_blockheight() if current_block_height < timeout_block_height: - msg = f"current_block_height ({current_block_height}) has not yet exceeded ({timeout_block_height})" - raise MempoolBlockHeightException(msg) + raise MempoolBlockHeightException( + f"current_block_height ({current_block_height}) " + f"has not yet exceeded ({timeout_block_height})" + ) def send_onchain_tx(self, tx_hex: str): return self.request(