import asyncio import json import os from typing import TYPE_CHECKING, Optional, Dict, Union, Sequence, Tuple, Iterable from decimal import Decimal import math import time import attr import aiohttp from . import lnutil from .crypto import sha256, hash_160 from .ecc import ECPrivkey from .bitcoin import (script_to_p2wsh, opcodes, p2wsh_nested_script, push_script, is_segwit_address, construct_witness) from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey from .util import log_exceptions, BelowDustLimit, OldTaskGroup from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY from .bitcoin import dust_threshold, DummyAddress from .logging import Logger from .lnutil import hex_to_bytes from .lnaddr import lndecode from .json_db import StoredObject, stored_in from . import constants from .address_synchronizer import TX_HEIGHT_LOCAL from .i18n import _ from .bitcoin import construct_script from .crypto import ripemd from .invoices import Invoice from .network import TxBroadcastServerReturnedError from .lnonion import OnionRoutingFailure, OnionFailureCode if TYPE_CHECKING: from .network import Network from .wallet import Abstract_Wallet from .lnwatcher import LNWalletWatcher from .lnworker import LNWallet from .lnchannel import Channel from .simple_config import SimpleConfig CLAIM_FEE_SIZE = 136 LOCKUP_FEE_SIZE = 153 # assuming 1 output, 2 outputs MIN_LOCKTIME_DELTA = 60 LOCKTIME_DELTA_REFUND = 70 MAX_LOCKTIME_DELTA = 100 MIN_FINAL_CLTV_DELTA_FOR_CLIENT = 3 * 144 # note: put in invoice, but is not enforced by receiver in lnpeer.py assert MIN_LOCKTIME_DELTA <= LOCKTIME_DELTA_REFUND <= MAX_LOCKTIME_DELTA assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_FOR_INVOICE assert MAX_LOCKTIME_DELTA < MIN_FINAL_CLTV_DELTA_FOR_CLIENT # The script of the reverse swaps has one extra check in it to verify # that the length of the preimage is 32. This is required because in # the reverse swaps the preimage is generated by the user and to # settle the hold invoice, you need a preimage with 32 bytes . If that # check wasn't there the user could generate a preimage with a # different length which would still allow for claiming the onchain # coins but the invoice couldn't be settled WITNESS_TEMPLATE_REVERSE_SWAP = [ opcodes.OP_SIZE, OPPushDataGeneric(None), opcodes.OP_EQUAL, opcodes.OP_IF, opcodes.OP_HASH160, OPPushDataGeneric(lambda x: x == 20), opcodes.OP_EQUALVERIFY, OPPushDataPubkey, opcodes.OP_ELSE, opcodes.OP_DROP, OPPushDataGeneric(None), opcodes.OP_CHECKLOCKTIMEVERIFY, opcodes.OP_DROP, OPPushDataPubkey, opcodes.OP_ENDIF, opcodes.OP_CHECKSIG ] def check_reverse_redeem_script( *, redeem_script: str, lockup_address: str, payment_hash: bytes, locktime: int, refund_pubkey: bytes = None, claim_pubkey: bytes = None, ) -> None: redeem_script = bytes.fromhex(redeem_script) parsed_script = [x for x in script_GetOp(redeem_script)] if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP): raise Exception("rswap check failed: scriptcode does not match template") if script_to_p2wsh(redeem_script.hex()) != lockup_address: raise Exception("rswap check failed: inconsistent scriptcode and address") if ripemd(payment_hash) != parsed_script[5][1]: raise Exception("rswap check failed: our preimage not in script") if claim_pubkey and claim_pubkey != parsed_script[7][1]: raise Exception("rswap check failed: our pubkey not in script") if refund_pubkey and refund_pubkey != parsed_script[13][1]: raise Exception("rswap check failed: our pubkey not in script") if locktime != int.from_bytes(parsed_script[10][1], byteorder='little'): raise Exception("rswap check failed: inconsistent locktime and script") class SwapServerError(Exception): def __str__(self): return _("The swap server errored or is unreachable.") def now(): return int(time.time()) @stored_in('submarine_swaps') @attr.s class SwapData(StoredObject): is_reverse = attr.ib(type=bool) # for whoever is running code (PoV of client or server) locktime = attr.ib(type=int) onchain_amount = attr.ib(type=int) # in sats lightning_amount = attr.ib(type=int) # in sats redeem_script = attr.ib(type=bytes, converter=hex_to_bytes) preimage = attr.ib(type=Optional[bytes], converter=hex_to_bytes) prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes) privkey = attr.ib(type=bytes, converter=hex_to_bytes) lockup_address = attr.ib(type=str) receive_address = attr.ib(type=str) funding_txid = attr.ib(type=Optional[str]) spending_txid = attr.ib(type=Optional[str]) is_redeemed = attr.ib(type=bool) _funding_prevout = None # type: Optional[TxOutpoint] # for RBF _payment_hash = None @property def payment_hash(self) -> bytes: return self._payment_hash def create_claim_tx( *, txin: PartialTxInput, witness_script: bytes, address: str, amount_sat: int, locktime: int, ) -> PartialTransaction: """Create tx to either claim successful reverse-swap, or to get refunded for timed-out forward-swap. """ txin.script_sig = b'' txin.witness_script = witness_script txout = PartialTxOutput.from_address_and_value(address, amount_sat) tx = PartialTransaction.from_io([txin], [txout], version=2, locktime=locktime) tx.set_rbf(True) return tx class SwapManager(Logger): network: Optional['Network'] = None lnwatcher: Optional['LNWalletWatcher'] = None def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'): Logger.__init__(self) self.normal_fee = 0 self.lockup_fee = 0 self.claim_fee = 0 # part of the boltz prococol, not used by Electrum self.percentage = 0 self._min_amount = None self._max_amount = None self.wallet = wallet self.lnworker = lnworker self.taskgroup = None self.dummy_address = DummyAddress.SWAP self.swaps = self.wallet.db.get_dict('submarine_swaps') # type: Dict[str, SwapData] self._swaps_by_funding_outpoint = {} # type: Dict[TxOutpoint, SwapData] self._swaps_by_lockup_address = {} # type: Dict[str, SwapData] for payment_hash_hex, swap in self.swaps.items(): payment_hash = bytes.fromhex(payment_hash_hex) swap._payment_hash = payment_hash self._add_or_reindex_swap(swap) if not swap.is_reverse and not swap.is_redeemed: self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback) self.prepayments = {} # type: Dict[bytes, bytes] # fee_rhash -> rhash for k, swap in self.swaps.items(): if swap.prepay_hash is not None: self.prepayments[swap.prepay_hash] = bytes.fromhex(k) # api url self.api_url = wallet.config.SWAPSERVER_URL # init default min & max self.init_min_max_values() def start_network(self, *, network: 'Network', lnwatcher: 'LNWalletWatcher'): assert network assert lnwatcher assert self.network is None, "already started" self.network = network self.lnwatcher = lnwatcher for k, swap in self.swaps.items(): if swap.is_redeemed: continue self.add_lnwatcher_callback(swap) self.taskgroup = OldTaskGroup() asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop) async def main_loop(self): self.logger.info("starting taskgroup.") try: async with self.taskgroup as group: await group.spawn(self.pay_pending_invoices()) except Exception as e: self.logger.exception("taskgroup died.") finally: self.logger.info("taskgroup stopped.") async def stop(self): await self.taskgroup.cancel_remaining() async def pay_invoice(self, key): self.logger.info(f'trying to pay invoice {key}') self.invoices_to_pay[key] = 1000000000000 # lock try: invoice = self.wallet.get_invoice(key) success, log = await self.lnworker.pay_invoice(invoice.lightning_invoice, attempts=10) except Exception as e: self.logger.info(f'exception paying {key}, will not retry') self.invoices_to_pay.pop(key, None) return if not success: self.logger.info(f'failed to pay {key}, will retry in 10 minutes') self.invoices_to_pay[key] = now() + 600 else: self.logger.info(f'paid invoice {key}') self.invoices_to_pay.pop(key, None) async def pay_pending_invoices(self): self.invoices_to_pay = {} while True: await asyncio.sleep(5) for key, not_before in list(self.invoices_to_pay.items()): if now() < not_before: continue await self.taskgroup.spawn(self.pay_invoice(key)) def cancel_normal_swap(self, swap: SwapData): """ we must not have broadcast the funding tx """ if swap.funding_txid is not None: self.logger.info(f'cannot cancel swap {swap.payment_hash.hex()}: already funded') return self._fail_swap(swap, 'user cancelled') def _fail_swap(self, swap: SwapData, reason: str): self.logger.info(f'failing swap {swap.payment_hash.hex()}: {reason}') if not swap.is_reverse and swap.payment_hash in self.lnworker.hold_invoice_callbacks: self.lnworker.unregister_hold_invoice(swap.payment_hash) payment_secret = self.lnworker.get_payment_secret(swap.payment_hash) payment_key = swap.payment_hash + payment_secret e = OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'') self.lnworker.save_forwarding_failure(payment_key.hex(), failure_message=e) self.lnwatcher.remove_callback(swap.lockup_address) if swap.funding_txid is None: self.swaps.pop(swap.payment_hash.hex()) @log_exceptions async def _claim_swap(self, swap: SwapData) -> None: assert self.network assert self.lnwatcher if not self.lnwatcher.adb.is_up_to_date(): return current_height = self.network.get_local_height() remaining_time = swap.locktime - current_height txos = self.lnwatcher.adb.get_addr_outputs(swap.lockup_address) for txin in txos.values(): if swap.is_reverse and txin.value_sats() < swap.onchain_amount: # amount too low, we must not reveal the preimage continue break else: # swap not funded. txin = None # if it is a normal swap, we might have double spent the funding tx # in that case we need to fail the HTLCs if remaining_time <= 0: self._fail_swap(swap, 'expired') if txin: # the swap is funded # note: swap.funding_txid can change due to RBF, it will get updated here: swap.funding_txid = txin.prevout.txid.hex() swap._funding_prevout = txin.prevout self._add_or_reindex_swap(swap) # to update _swaps_by_funding_outpoint funding_height = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex()) spent_height = txin.spent_height if spent_height is not None: swap.spending_txid = txin.spent_txid if spent_height > 0: if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY: self.logger.info(f'stop watching swap {swap.lockup_address}') self.lnwatcher.remove_callback(swap.lockup_address) swap.is_redeemed = True elif spent_height == TX_HEIGHT_LOCAL: if funding_height.conf > 0 or (swap.is_reverse and self.wallet.config.LIGHTNING_ALLOW_INSTANT_SWAPS): tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) try: await self.network.broadcast_transaction(tx) except TxBroadcastServerReturnedError: self.logger.info(f'error broadcasting claim tx {txin.spent_txid}') elif funding_height.height == TX_HEIGHT_LOCAL: # the funding tx was double spent. # this will remove both funding and child (spending tx) from adb self.lnwatcher.adb.remove_transaction(swap.funding_txid) swap.funding_txid = None swap.spending_txid = None else: # spending tx is in mempool pass if not swap.is_reverse: if swap.preimage is None and spent_height is not None: # extract the preimage, add it to lnwatcher tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) preimage = tx.inputs()[0].witness_elements()[1] if sha256(preimage) == swap.payment_hash: swap.preimage = preimage self.logger.info(f'found preimage: {preimage.hex()}') self.lnworker.preimages[swap.payment_hash.hex()] = preimage.hex() # note: we must check the payment secret before we broadcast the funding tx else: # refund tx if spent_height > 0: self._fail_swap(swap, 'refund tx confirmed') return if remaining_time > 0: # too early for refund return else: if swap.preimage is None: swap.preimage = self.lnworker.get_preimage(swap.payment_hash) if swap.preimage is None: if funding_height.conf <= 0: return key = swap.payment_hash.hex() if remaining_time <= MIN_LOCKTIME_DELTA: if key in self.invoices_to_pay: # fixme: should consider cltv of ln payment self.logger.info(f'locktime too close {key} {remaining_time}') self.invoices_to_pay.pop(key, None) return if key not in self.invoices_to_pay: self.invoices_to_pay[key] = 0 return if self.network.config.TEST_SWAPSERVER_REFUND: # for testing: do not create claim tx return if spent_height is not None: return try: tx = self._create_and_sign_claim_tx(txin=txin, swap=swap, config=self.wallet.config) except BelowDustLimit: self.logger.info('utxo value below dust threshold') return self.logger.info(f'adding claim tx {tx.txid()}') self.wallet.adb.add_transaction(tx) swap.spending_txid = tx.txid() def get_claim_fee(self): return self.get_fee(CLAIM_FEE_SIZE) def get_fee(self, size): # note: 'size' is in vbytes return self._get_fee(size=size, config=self.wallet.config) @classmethod def _get_fee(cls, *, size, config: 'SimpleConfig'): return config.estimate_fee(size, allow_fallback_to_static_rates=True) def get_swap(self, payment_hash: bytes) -> Optional[SwapData]: # for history swap = self.swaps.get(payment_hash.hex()) if swap: return swap payment_hash = self.prepayments.get(payment_hash) if payment_hash: return self.swaps.get(payment_hash.hex()) def add_lnwatcher_callback(self, swap: SwapData) -> None: callback = lambda: self._claim_swap(swap) self.lnwatcher.add_callback(swap.lockup_address, callback) async def hold_invoice_callback(self, payment_hash: bytes) -> None: # note: this assumes the wallet has been unlocked key = payment_hash.hex() if key in self.swaps: swap = self.swaps[key] if swap.funding_txid is None: password = self.wallet.get_unlocked_password() for batch_rbf in [True, False]: tx = self.create_funding_tx(swap, None, password=password, batch_rbf=batch_rbf) try: await self.broadcast_funding_tx(swap, tx) except TxBroadcastServerReturnedError: continue break def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, their_pubkey: bytes = None): """ server method """ assert lightning_amount_sat locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND our_privkey = os.urandom(32) our_pubkey = ECPrivkey(our_privkey).get_public_key_bytes(compressed=True) onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive redeem_script = construct_script( WITNESS_TEMPLATE_REVERSE_SWAP, {1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey} ) swap, invoice, prepay_invoice = self.add_normal_swap( redeem_script=redeem_script, locktime=locktime, onchain_amount_sat=onchain_amount_sat, lightning_amount_sat=lightning_amount_sat, payment_hash=payment_hash, our_privkey=our_privkey, prepay=True, ) self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback) return swap, invoice, prepay_invoice def add_normal_swap( self, *, redeem_script: str, locktime: int, # onchain onchain_amount_sat: int, lightning_amount_sat: int, payment_hash: bytes, our_privkey: bytes, prepay: bool, channels: Optional[Sequence['Channel']] = None, min_final_cltv_expiry_delta: Optional[int] = None, ) -> Tuple[SwapData, str, Optional[str]]: """creates a hold invoice""" if prepay: prepay_amount_sat = self.get_claim_fee() * 2 invoice_amount_sat = lightning_amount_sat - prepay_amount_sat else: invoice_amount_sat = lightning_amount_sat _, invoice = self.lnworker.get_bolt11_invoice( payment_hash=payment_hash, amount_msat=invoice_amount_sat * 1000, message='Submarine swap', expiry=300, fallback_address=None, channels=channels, min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, ) # add payment info to lnworker self.lnworker.add_payment_info_for_hold_invoice(payment_hash, invoice_amount_sat) if prepay: prepay_hash = self.lnworker.create_payment_info(amount_msat=prepay_amount_sat*1000) _, prepay_invoice = self.lnworker.get_bolt11_invoice( payment_hash=prepay_hash, amount_msat=prepay_amount_sat * 1000, message='Submarine swap mining fees', expiry=300, fallback_address=None, channels=channels, min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, ) self.lnworker.bundle_payments([payment_hash, prepay_hash]) self.prepayments[prepay_hash] = payment_hash else: prepay_invoice = None prepay_hash = None lockup_address = script_to_p2wsh(redeem_script) receive_address = self.wallet.get_receiving_address() swap = SwapData( redeem_script = bytes.fromhex(redeem_script), locktime = locktime, privkey = our_privkey, preimage = None, prepay_hash = prepay_hash, lockup_address = lockup_address, onchain_amount = onchain_amount_sat, receive_address = receive_address, lightning_amount = lightning_amount_sat, is_reverse = False, is_redeemed = False, funding_txid = None, spending_txid = None, ) swap._payment_hash = payment_hash self._add_or_reindex_swap(swap) self.add_lnwatcher_callback(swap) return swap, invoice, prepay_invoice def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) -> SwapData: """ server method. """ assert lightning_amount_sat is not None locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND privkey = os.urandom(32) our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False) preimage = os.urandom(32) payment_hash = sha256(preimage) redeem_script = construct_script( WITNESS_TEMPLATE_REVERSE_SWAP, {1:32, 5:ripemd(payment_hash), 7:our_pubkey, 10:locktime, 13:their_pubkey} ) swap = self.add_reverse_swap( redeem_script=redeem_script, locktime=locktime, privkey=privkey, preimage=preimage, payment_hash=payment_hash, prepay_hash=None, onchain_amount_sat=onchain_amount_sat, lightning_amount_sat=lightning_amount_sat) return swap def add_reverse_swap( self, *, redeem_script: str, locktime: int, # onchain privkey: bytes, lightning_amount_sat: int, onchain_amount_sat: int, preimage: bytes, payment_hash: bytes, prepay_hash: Optional[bytes] = None, ) -> SwapData: lockup_address = script_to_p2wsh(redeem_script) receive_address = self.wallet.get_receiving_address() swap = SwapData( redeem_script = bytes.fromhex(redeem_script), locktime = locktime, privkey = privkey, preimage = preimage, prepay_hash = prepay_hash, lockup_address = lockup_address, onchain_amount = onchain_amount_sat, receive_address = receive_address, lightning_amount = lightning_amount_sat, is_reverse = True, is_redeemed = False, funding_txid = None, spending_txid = None, ) if prepay_hash: self.prepayments[prepay_hash] = payment_hash swap._payment_hash = payment_hash self._add_or_reindex_swap(swap) self.add_lnwatcher_callback(swap) return swap def add_invoice(self, invoice: str, pay_now: bool = False) -> None: invoice = Invoice.from_bech32(invoice) key = invoice.rhash payment_hash = bytes.fromhex(key) assert key in self.swaps swap = self.swaps[key] assert swap.lightning_amount == int(invoice.get_amount_sat()) self.wallet.save_invoice(invoice) if pay_now: # check that we have the preimage assert sha256(swap.preimage) == payment_hash assert swap.spending_txid is None self.invoices_to_pay[key] = 0 async def normal_swap( self, *, lightning_amount_sat: int, expected_onchain_amount_sat: int, password, tx: PartialTransaction = None, channels = None, ) -> Optional[str]: """send on-chain BTC, receive on Lightning Old (removed) flow: - User generates an LN invoice with RHASH, and knows preimage. - User creates on-chain output locked to RHASH. - Server pays LN invoice. User reveals preimage. - Server spends the on-chain output using preimage. cltv safety requirement: (onchain_locktime > LN_locktime), otherwise server is vulnerable New flow: - User requests swap - Server creates preimage, sends RHASH to user - User creates hold invoice, sends it to server - Server sends HTLC, user holds it - User creates on-chain output locked to RHASH - Server spends the on-chain output using preimage (revealing the preimage) - User fulfills HTLC using preimage cltv safety requirement: (onchain_locktime < LN_locktime), otherwise client is vulnerable """ assert self.network assert self.lnwatcher swap, invoice = await self.request_normal_swap( lightning_amount_sat=lightning_amount_sat, expected_onchain_amount_sat=expected_onchain_amount_sat, channels=channels, ) tx = self.create_funding_tx(swap, tx, password=password) return await self.wait_for_htlcs_and_broadcast(swap=swap, invoice=invoice, tx=tx) async def request_normal_swap( self, *, lightning_amount_sat: int, expected_onchain_amount_sat: int, channels: Optional[Sequence['Channel']] = None, ) -> Tuple[SwapData, str]: refund_privkey = os.urandom(32) refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True) self.logger.info('requesting preimage hash for swap') request_data = { "invoiceAmount": lightning_amount_sat, "refundPublicKey": refund_pubkey.hex() } response = await self.network.async_send_http_on_proxy( 'post', self.api_url + '/createnormalswap', json=request_data, timeout=30) data = json.loads(response) payment_hash = bytes.fromhex(data["preimageHash"]) zeroconf = data["acceptZeroConf"] onchain_amount = data["expectedAmount"] locktime = data["timeoutBlockHeight"] lockup_address = data["address"] redeem_script = data["redeemScript"] # verify redeem_script is built with our pubkey and preimage check_reverse_redeem_script( redeem_script=redeem_script, lockup_address=lockup_address, payment_hash=payment_hash, locktime=locktime, refund_pubkey=refund_pubkey, ) # check that onchain_amount is not more than what we estimated if onchain_amount > expected_onchain_amount_sat: raise Exception(f"fswap check failed: onchain_amount is more than what we estimated: " f"{onchain_amount} > {expected_onchain_amount_sat}") # verify that they are not locking up funds for too long if locktime - self.network.get_local_height() > MAX_LOCKTIME_DELTA: raise Exception("fswap check failed: locktime too far in future") swap, invoice, _ = self.add_normal_swap( redeem_script=redeem_script, locktime=locktime, lightning_amount_sat=lightning_amount_sat, onchain_amount_sat=onchain_amount, payment_hash=payment_hash, our_privkey=refund_privkey, prepay=False, channels=channels, # When the client is doing a normal swap, we create a ln-invoice with larger than usual final_cltv_delta. # If the user goes offline after broadcasting the funding tx (but before it is mined and # the server claims it), they need to come back online before the held ln-htlc expires (see #8940). # If the held ln-htlc expires, and the funding tx got confirmed, the server will have claimed the onchain # funds, and the ln-htlc will be timed out onchain (and channel force-closed). i.e. the user loses the swap # amount. Increasing the final_cltv_delta the user puts in the invoice extends this critical window. min_final_cltv_expiry_delta=MIN_FINAL_CLTV_DELTA_FOR_CLIENT, ) return swap, invoice async def wait_for_htlcs_and_broadcast( self, *, swap: SwapData, invoice: str, tx: Transaction, ) -> Optional[str]: payment_hash = swap.payment_hash refund_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True) async def callback(payment_hash): await self.broadcast_funding_tx(swap, tx) self.lnworker.register_hold_invoice(payment_hash, callback) # send invoice to server and wait for htlcs request_data = { "preimageHash": payment_hash.hex(), "invoice": invoice, "refundPublicKey": refund_pubkey.hex(), } response = await self.network.async_send_http_on_proxy( 'post', self.api_url + '/addswapinvoice', json=request_data, timeout=30) data = json.loads(response) # wait for funding tx lnaddr = lndecode(invoice) while swap.funding_txid is None and not lnaddr.is_expired(): await asyncio.sleep(0.1) return swap.funding_txid def create_funding_tx( self, swap: SwapData, tx: Optional[PartialTransaction], *, password, batch_rbf: Optional[bool] = None, ) -> PartialTransaction: # create funding tx # note: rbf must not decrease payment # this is taken care of in wallet._is_rbf_allowed_to_touch_tx_output if tx is None: funding_output = PartialTxOutput.from_address_and_value(swap.lockup_address, swap.onchain_amount) tx = self.wallet.create_transaction( outputs=[funding_output], rbf=True, password=password, batch_rbf=batch_rbf, ) else: tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address) tx.set_rbf(True) self.wallet.sign_transaction(tx, password) return tx @log_exceptions async def request_swap_for_tx(self, tx: 'PartialTransaction') -> Optional[Tuple[SwapData, str, PartialTransaction]]: for o in tx.outputs(): if o.address == self.dummy_address: change_amount = o.value break else: return await self.get_pairs() lightning_amount_sat = self.get_recv_amount(change_amount, is_reverse=False) swap, invoice = await self.request_normal_swap( lightning_amount_sat = lightning_amount_sat, expected_onchain_amount_sat=change_amount) tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address) return swap, invoice, tx @log_exceptions async def broadcast_funding_tx(self, swap: SwapData, tx: Transaction) -> None: swap.funding_txid = tx.txid() await self.network.broadcast_transaction(tx) async def reverse_swap( self, *, lightning_amount_sat: int, expected_onchain_amount_sat: int, channels: Optional[Sequence['Channel']] = None, ) -> Optional[str]: """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. - if the server requested a fee prepayment (using 'minerFeeInvoice'), the server will have the preimage for that. The user will send HTLCs for both the main RHASH, and for the fee prepayment. Once both MPP sets arrive at the server, the server will fulfill the HTLCs for the fee prepayment (before creating the on-chain output). - 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. """ assert self.network assert self.lnwatcher privkey = os.urandom(32) our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) preimage = os.urandom(32) payment_hash = sha256(preimage) request_data = { "type": "reversesubmarine", "pairId": "BTC/BTC", "orderSide": "buy", "invoiceAmount": lightning_amount_sat, "preimageHash": payment_hash.hex(), "claimPublicKey": our_pubkey.hex() } response = await self.network.async_send_http_on_proxy( 'post', self.api_url + '/createswap', json=request_data, timeout=30) data = json.loads(response) invoice = data['invoice'] fee_invoice = data.get('minerFeeInvoice') lockup_address = data['lockupAddress'] redeem_script = data['redeemScript'] locktime = data['timeoutBlockHeight'] onchain_amount = data["onchainAmount"] response_id = data['id'] # verify redeem_script is built with our pubkey and preimage check_reverse_redeem_script( redeem_script=redeem_script, lockup_address=lockup_address, payment_hash=payment_hash, locktime=locktime, refund_pubkey=None, claim_pubkey=our_pubkey, ) # check that the onchain amount is what we expected if onchain_amount < expected_onchain_amount_sat: raise Exception(f"rswap check failed: onchain_amount is less than what we expected: " f"{onchain_amount} < {expected_onchain_amount_sat}") # verify that we will have enough time to get our tx confirmed if locktime - self.network.get_local_height() <= MIN_LOCKTIME_DELTA: raise Exception("rswap check failed: locktime too close") # verify invoice payment_hash lnaddr = self.lnworker._check_invoice(invoice) invoice_amount = int(lnaddr.get_amount_sat()) if lnaddr.paymenthash != payment_hash: raise Exception("rswap check failed: inconsistent RHASH and invoice") # check that the lightning amount is what we requested if fee_invoice: fee_lnaddr = self.lnworker._check_invoice(fee_invoice) invoice_amount += fee_lnaddr.get_amount_sat() prepay_hash = fee_lnaddr.paymenthash else: prepay_hash = None if int(invoice_amount) != lightning_amount_sat: raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) " f"not what we requested ({lightning_amount_sat})") # save swap data to wallet file swap = self.add_reverse_swap( redeem_script=redeem_script, locktime=locktime, privkey=privkey, preimage=preimage, payment_hash=payment_hash, prepay_hash=prepay_hash, onchain_amount_sat=onchain_amount, lightning_amount_sat=lightning_amount_sat) # initiate fee payment. if fee_invoice: asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice)) # we return if we detect funding async def wait_for_funding(swap): while swap.funding_txid is None: await asyncio.sleep(1) # initiate main payment tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice, channels=channels)), asyncio.create_task(wait_for_funding(swap))] await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) return swap.funding_txid def _add_or_reindex_swap(self, swap: SwapData) -> None: if swap.payment_hash.hex() not in self.swaps: self.swaps[swap.payment_hash.hex()] = swap if swap._funding_prevout: self._swaps_by_funding_outpoint[swap._funding_prevout] = swap self._swaps_by_lockup_address[swap.lockup_address] = swap def init_pairs(self) -> None: """ for server """ self.percentage = 0.5 self._min_amount = 20000 self._max_amount = 10000000 self.normal_fee = self.get_fee(CLAIM_FEE_SIZE) self.lockup_fee = self.get_fee(LOCKUP_FEE_SIZE) self.claim_fee = self.get_fee(CLAIM_FEE_SIZE) async def get_pairs(self) -> None: """Might raise SwapServerError.""" from .network import Network try: response = await Network.async_send_http_on_proxy( 'get', self.api_url + '/getpairs', timeout=30) except aiohttp.ClientError as e: self.logger.error(f"Swap server errored: {e!r}") raise SwapServerError() from e # we assume server response is well-formed; otherwise let an exception propagate to the crash reporter pairs = json.loads(response) # cache data to disk with open(self.pairs_filename(), 'w', encoding='utf-8') as f: f.write(json.dumps(pairs)) fees = pairs['pairs']['BTC/BTC']['fees'] self.percentage = fees['percentage'] self.normal_fee = fees['minerFees']['baseAsset']['normal'] self.lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup'] self.claim_fee = fees['minerFees']['baseAsset']['reverse']['claim'] limits = pairs['pairs']['BTC/BTC']['limits'] self._min_amount = limits['minimal'] self._max_amount = limits['maximal'] assert pairs.get('htlcFirst') is True def pairs_filename(self): return os.path.join(self.wallet.config.path, 'swap_pairs') def init_min_max_values(self): # use default values if we never requested pairs try: with open(self.pairs_filename(), 'r', encoding='utf-8') as f: pairs = json.loads(f.read()) limits = pairs['pairs']['BTC/BTC']['limits'] self._min_amount = limits['minimal'] self._max_amount = limits['maximal'] except Exception: self._min_amount = 10000 self._max_amount = 10000000 def get_max_amount(self): return self._max_amount def get_min_amount(self): return self._min_amount def check_invoice_amount(self, x): return x >= self.get_min_amount() and x <= self.get_max_amount() def _get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]: """For a given swap direction and amount we send, returns how much we will receive. Note: in the reverse direction, the mining fee for the on-chain claim tx is NOT accounted for. In the reverse direction, the result matches what the swap server returns as response["onchainAmount"]. """ if send_amount is None: return x = Decimal(send_amount) percentage = Decimal(self.percentage) if is_reverse: if not self.check_invoice_amount(x): return # see/ref: # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L948 percentage_fee = math.ceil(percentage * x / 100) base_fee = self.lockup_fee x -= percentage_fee + base_fee x = math.floor(x) if x < dust_threshold(): return else: x -= self.normal_fee percentage_fee = math.ceil(x * percentage / (100 + percentage)) x -= percentage_fee if not self.check_invoice_amount(x): return x = int(x) return x def _get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]: """For a given swap direction and amount we want to receive, returns how much we will need to send. Note: in the reverse direction, the mining fee for the on-chain claim tx is NOT accounted for. In the forward direction, the result matches what the swap server returns as response["expectedAmount"]. """ if not recv_amount: return x = Decimal(recv_amount) percentage = Decimal(self.percentage) if is_reverse: # see/ref: # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L928 # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L958 base_fee = self.lockup_fee x += base_fee x = math.ceil(x / ((100 - percentage) / 100)) if not self.check_invoice_amount(x): return else: if not self.check_invoice_amount(x): return # see/ref: # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L708 # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/rates/FeeProvider.ts#L90 percentage_fee = math.ceil(percentage * x / 100) x += percentage_fee + self.normal_fee x = int(x) return x def get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]: # first, add percentage fee recv_amount = self._get_recv_amount(send_amount, is_reverse=is_reverse) # sanity check calculation can be inverted if recv_amount is not None: inverted_send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse) # accept off-by ones as amt_rcv = recv_amt(send_amt(amt_rcv)) only up to +-1 if abs(send_amount - inverted_send_amount) > 1: raise Exception(f"calc-invert-sanity-check failed. is_reverse={is_reverse}. " f"send_amount={send_amount} -> recv_amount={recv_amount} -> inverted_send_amount={inverted_send_amount}") # second, add on-chain claim tx fee if is_reverse and recv_amount is not None: recv_amount -= self.get_claim_fee() return recv_amount def get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]: # first, add on-chain claim tx fee if is_reverse and recv_amount is not None: recv_amount += self.get_claim_fee() # second, add percentage fee send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse) # sanity check calculation can be inverted if send_amount is not None: inverted_recv_amount = self._get_recv_amount(send_amount, is_reverse=is_reverse) if recv_amount != inverted_recv_amount: raise Exception(f"calc-invert-sanity-check failed. is_reverse={is_reverse}. " f"recv_amount={recv_amount} -> send_amount={send_amount} -> inverted_recv_amount={inverted_recv_amount}") return send_amount def get_swaps_by_funding_tx(self, tx: Transaction) -> Iterable[SwapData]: swaps = [] for txout_idx, _txo in enumerate(tx.outputs()): prevout = TxOutpoint(txid=bytes.fromhex(tx.txid()), out_idx=txout_idx) if swap := self._swaps_by_funding_outpoint.get(prevout): swaps.append(swap) return swaps def get_swap_by_claim_tx(self, tx: Transaction) -> Optional[SwapData]: # note: we don't batch claim txs atm (batch_rbf cannot combine them # as the inputs do not belong to the wallet) if not (len(tx.inputs()) == 1 and len(tx.outputs()) == 1): return None txin = tx.inputs()[0] return self.get_swap_by_claim_txin(txin) def get_swap_by_claim_txin(self, txin: TxInput) -> Optional[SwapData]: return self._swaps_by_funding_outpoint.get(txin.prevout) def is_lockup_address_for_a_swap(self, addr: str) -> bool: return bool(self._swaps_by_lockup_address.get(addr)) def add_txin_info(self, txin: PartialTxInput) -> None: """Add some info to a claim txin. note: even without signing, this is useful for tx size estimation. """ swap = self.get_swap_by_claim_txin(txin) if not swap: return preimage = swap.preimage if swap.is_reverse else 0 witness_script = swap.redeem_script txin.script_sig = b'' txin.witness_script = witness_script sig_dummy = b'\x00' * 71 # DER-encoded ECDSA sig, with low S and low R witness = [sig_dummy, preimage, witness_script] txin.witness_sizehint = len(bytes.fromhex(construct_witness(witness))) @classmethod def sign_tx(cls, tx: PartialTransaction, swap: SwapData) -> None: preimage = swap.preimage if swap.is_reverse else 0 witness_script = swap.redeem_script txin = tx.inputs()[0] assert len(tx.inputs()) == 1, f"expected 1 input for swap claim tx. found {len(tx.inputs())}" assert txin.prevout.txid.hex() == swap.funding_txid txin.script_sig = b'' txin.witness_script = witness_script sig = bytes.fromhex(tx.sign_txin(0, swap.privkey)) witness = [sig, preimage, witness_script] txin.witness = bytes.fromhex(construct_witness(witness)) @classmethod def _create_and_sign_claim_tx( cls, *, txin: PartialTxInput, swap: SwapData, config: 'SimpleConfig', ) -> PartialTransaction: # FIXME the mining fee should depend on swap.is_reverse. # the txs are not the same size... amount_sat = txin.value_sats() - cls._get_fee(size=CLAIM_FEE_SIZE, config=config) if amount_sat < dust_threshold(): raise BelowDustLimit() if swap.is_reverse: # successful reverse swap locktime = 0 # preimage will be set in sign_tx else: # timing out forward swap locktime = swap.locktime tx = create_claim_tx( txin=txin, witness_script=swap.redeem_script, address=swap.receive_address, amount_sat=amount_sat, locktime=locktime, ) cls.sign_tx(tx, swap) return tx def max_amount_forward_swap(self) -> Optional[int]: """ returns None if we cannot swap """ max_swap_amt_ln = self.get_max_amount() max_recv_amt_ln = int(self.lnworker.num_sats_can_receive()) max_amt_ln = int(min(max_swap_amt_ln, max_recv_amt_ln)) max_amt_oc = self.get_send_amount(max_amt_ln, is_reverse=False) or 0 min_amt_oc = self.get_send_amount(self.get_min_amount(), is_reverse=False) or 0 return max_amt_oc if max_amt_oc >= min_amt_oc else None