URI: 
       tsubmarine_swaps.py - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
       tsubmarine_swaps.py (19542B)
       ---
            1 import asyncio
            2 import json
            3 import os
            4 from typing import TYPE_CHECKING, Optional, Dict, Union
            5 
            6 import attr
            7 
            8 from .crypto import sha256, hash_160
            9 from .ecc import ECPrivkey
           10 from .bitcoin import (script_to_p2wsh, opcodes, p2wsh_nested_script, push_script,
           11                       is_segwit_address, construct_witness)
           12 from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction
           13 from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey
           14 from .util import log_exceptions
           15 from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY, ln_dummy_address, LN_MAX_HTLC_VALUE_MSAT
           16 from .bitcoin import dust_threshold
           17 from .logging import Logger
           18 from .lnutil import hex_to_bytes
           19 from .json_db import StoredObject
           20 from . import constants
           21 
           22 if TYPE_CHECKING:
           23     from .network import Network
           24     from .wallet import Abstract_Wallet
           25     from .lnwatcher import LNWalletWatcher
           26     from .lnworker import LNWallet
           27 
           28 
           29 API_URL_MAINNET = 'https://swaps.electrum.org/api'
           30 API_URL_TESTNET = 'https://swaps.electrum.org/testnet'
           31 API_URL_REGTEST = 'https://localhost/api'
           32 
           33 
           34 
           35 WITNESS_TEMPLATE_SWAP = [
           36     opcodes.OP_HASH160,
           37     OPPushDataGeneric(lambda x: x == 20),
           38     opcodes.OP_EQUAL,
           39     opcodes.OP_IF,
           40     OPPushDataPubkey,
           41     opcodes.OP_ELSE,
           42     OPPushDataGeneric(None),
           43     opcodes.OP_CHECKLOCKTIMEVERIFY,
           44     opcodes.OP_DROP,
           45     OPPushDataPubkey,
           46     opcodes.OP_ENDIF,
           47     opcodes.OP_CHECKSIG
           48 ]
           49 
           50 
           51 # The script of the reverse swaps has one extra check in it to verify
           52 # that the length of the preimage is 32. This is required because in
           53 # the reverse swaps the preimage is generated by the user and to
           54 # settle the hold invoice, you need a preimage with 32 bytes . If that
           55 # check wasn't there the user could generate a preimage with a
           56 # different length which would still allow for claiming the onchain
           57 # coins but the invoice couldn't be settled
           58 
           59 WITNESS_TEMPLATE_REVERSE_SWAP = [
           60     opcodes.OP_SIZE,
           61     OPPushDataGeneric(None),
           62     opcodes.OP_EQUAL,
           63     opcodes.OP_IF,
           64     opcodes.OP_HASH160,
           65     OPPushDataGeneric(lambda x: x == 20),
           66     opcodes.OP_EQUALVERIFY,
           67     OPPushDataPubkey,
           68     opcodes.OP_ELSE,
           69     opcodes.OP_DROP,
           70     OPPushDataGeneric(None),
           71     opcodes.OP_CHECKLOCKTIMEVERIFY,
           72     opcodes.OP_DROP,
           73     OPPushDataPubkey,
           74     opcodes.OP_ENDIF,
           75     opcodes.OP_CHECKSIG
           76 ]
           77 
           78 
           79 @attr.s
           80 class SwapData(StoredObject):
           81     is_reverse = attr.ib(type=bool)
           82     locktime = attr.ib(type=int)
           83     onchain_amount = attr.ib(type=int)  # in sats
           84     lightning_amount = attr.ib(type=int)  # in sats
           85     redeem_script = attr.ib(type=bytes, converter=hex_to_bytes)
           86     preimage = attr.ib(type=bytes, converter=hex_to_bytes)
           87     prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
           88     privkey = attr.ib(type=bytes, converter=hex_to_bytes)
           89     lockup_address = attr.ib(type=str)
           90     funding_txid = attr.ib(type=Optional[str])
           91     spending_txid = attr.ib(type=Optional[str])
           92     is_redeemed = attr.ib(type=bool)
           93 
           94 
           95 def create_claim_tx(
           96         *,
           97         txin: PartialTxInput,
           98         witness_script: bytes,
           99         preimage: Union[bytes, int],  # 0 if timing out forward-swap
          100         privkey: bytes,
          101         address: str,
          102         amount_sat: int,
          103         locktime: int,
          104 ) -> PartialTransaction:
          105     """Create tx to either claim successful reverse-swap,
          106     or to get refunded for timed-out forward-swap.
          107     """
          108     if is_segwit_address(txin.address):
          109         txin.script_type = 'p2wsh'
          110         txin.script_sig = b''
          111     else:
          112         txin.script_type = 'p2wsh-p2sh'
          113         txin.redeem_script = bytes.fromhex(p2wsh_nested_script(witness_script.hex()))
          114         txin.script_sig = bytes.fromhex(push_script(txin.redeem_script.hex()))
          115     txin.witness_script = witness_script
          116     txout = PartialTxOutput.from_address_and_value(address, amount_sat)
          117     tx = PartialTransaction.from_io([txin], [txout], version=2, locktime=locktime)
          118     #tx.set_rbf(True)
          119     sig = bytes.fromhex(tx.sign_txin(0, privkey))
          120     witness = [sig, preimage, witness_script]
          121     txin.witness = bytes.fromhex(construct_witness(witness))
          122     return tx
          123 
          124 
          125 class SwapManager(Logger):
          126 
          127     network: Optional['Network'] = None
          128     lnwatcher: Optional['LNWalletWatcher'] = None
          129 
          130     def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'):
          131         Logger.__init__(self)
          132         self.normal_fee = 0
          133         self.lockup_fee = 0
          134         self.percentage = 0
          135         self.min_amount = 0
          136         self._max_amount = 0
          137         self.wallet = wallet
          138         self.lnworker = lnworker
          139         self.swaps = self.wallet.db.get_dict('submarine_swaps')  # type: Dict[str, SwapData]
          140         self.prepayments = {}  # type: Dict[bytes, bytes] # fee_preimage -> preimage
          141         for k, swap in self.swaps.items():
          142             if swap.is_reverse and swap.prepay_hash is not None:
          143                 self.prepayments[swap.prepay_hash] = bytes.fromhex(k)
          144         # api url
          145         if constants.net == constants.BitcoinMainnet:
          146             self.api_url = API_URL_MAINNET
          147         elif constants.net == constants.BitcoinTestnet:
          148             self.api_url = API_URL_TESTNET
          149         else:
          150             self.api_url = API_URL_REGTEST
          151 
          152     def start_network(self, *, network: 'Network', lnwatcher: 'LNWalletWatcher'):
          153         assert network
          154         assert lnwatcher
          155         self.network = network
          156         self.lnwatcher = lnwatcher
          157         for k, swap in self.swaps.items():
          158             if swap.is_redeemed:
          159                 continue
          160             self.add_lnwatcher_callback(swap)
          161 
          162     @log_exceptions
          163     async def _claim_swap(self, swap: SwapData) -> None:
          164         assert self.network
          165         assert self.lnwatcher
          166         if not self.lnwatcher.is_up_to_date():
          167             return
          168         current_height = self.network.get_local_height()
          169         delta = current_height - swap.locktime
          170         if not swap.is_reverse and delta < 0:
          171             # too early for refund
          172             return
          173         txos = self.lnwatcher.get_addr_outputs(swap.lockup_address)
          174         for txin in txos.values():
          175             if swap.is_reverse and txin.value_sats() < swap.onchain_amount:
          176                 self.logger.info('amount too low, we should not reveal the preimage')
          177                 continue
          178             spent_height = txin.spent_height
          179             if spent_height is not None:
          180                 if spent_height > 0 and current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY:
          181                     self.logger.info(f'stop watching swap {swap.lockup_address}')
          182                     self.lnwatcher.remove_callback(swap.lockup_address)
          183                     swap.is_redeemed = True
          184                 continue
          185             amount_sat = txin.value_sats() - self.get_claim_fee()
          186             if amount_sat < dust_threshold():
          187                 self.logger.info('utxo value below dust threshold')
          188                 continue
          189             address = self.wallet.get_receiving_address()
          190             if swap.is_reverse:  # successful reverse swap
          191                 preimage = swap.preimage
          192                 locktime = 0
          193             else:  # timing out forward swap
          194                 preimage = 0
          195                 locktime = swap.locktime
          196             tx = create_claim_tx(
          197                 txin=txin,
          198                 witness_script=swap.redeem_script,
          199                 preimage=preimage,
          200                 privkey=swap.privkey,
          201                 address=address,
          202                 amount_sat=amount_sat,
          203                 locktime=locktime,
          204             )
          205             await self.network.broadcast_transaction(tx)
          206             # save txid
          207             if swap.is_reverse:
          208                 swap.spending_txid = tx.txid()
          209             else:
          210                 self.wallet.set_label(tx.txid(), 'Swap refund')
          211 
          212     def get_claim_fee(self):
          213         return self.wallet.config.estimate_fee(136, allow_fallback_to_static_rates=True)
          214 
          215     def get_swap(self, payment_hash: bytes) -> Optional[SwapData]:
          216         # for history
          217         swap = self.swaps.get(payment_hash.hex())
          218         if swap:
          219             return swap
          220         payment_hash = self.prepayments.get(payment_hash)
          221         if payment_hash:
          222             return self.swaps.get(payment_hash.hex())
          223 
          224     def add_lnwatcher_callback(self, swap: SwapData) -> None:
          225         callback = lambda: self._claim_swap(swap)
          226         self.lnwatcher.add_callback(swap.lockup_address, callback)
          227 
          228     async def normal_swap(
          229             self,
          230             *,
          231             lightning_amount_sat: int,
          232             expected_onchain_amount_sat: int,
          233             password,
          234             tx: PartialTransaction = None,
          235     ) -> str:
          236         """send on-chain BTC, receive on Lightning
          237 
          238         - User generates an LN invoice with RHASH, and knows preimage.
          239         - User creates on-chain output locked to RHASH.
          240         - Server pays LN invoice. User reveals preimage.
          241         - Server spends the on-chain output using preimage.
          242         """
          243         assert self.network
          244         assert self.lnwatcher
          245         privkey = os.urandom(32)
          246         pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
          247         lnaddr, invoice = await self.lnworker.create_invoice(
          248             amount_msat=lightning_amount_sat * 1000,
          249             message='swap',
          250             expiry=3600 * 24,
          251         )
          252         payment_hash = lnaddr.paymenthash
          253         preimage = self.lnworker.get_preimage(payment_hash)
          254         request_data = {
          255             "type": "submarine",
          256             "pairId": "BTC/BTC",
          257             "orderSide": "sell",
          258             "invoice": invoice,
          259             "refundPublicKey": pubkey.hex()
          260         }
          261         response = await self.network._send_http_on_proxy(
          262             'post',
          263             self.api_url + '/createswap',
          264             json=request_data,
          265             timeout=30)
          266         data = json.loads(response)
          267         response_id = data["id"]
          268         zeroconf = data["acceptZeroConf"]
          269         onchain_amount = data["expectedAmount"]
          270         locktime = data["timeoutBlockHeight"]
          271         lockup_address = data["address"]
          272         redeem_script = data["redeemScript"]
          273         # verify redeem_script is built with our pubkey and preimage
          274         redeem_script = bytes.fromhex(redeem_script)
          275         parsed_script = [x for x in script_GetOp(redeem_script)]
          276         if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_SWAP):
          277             raise Exception("fswap check failed: scriptcode does not match template")
          278         if script_to_p2wsh(redeem_script.hex()) != lockup_address:
          279             raise Exception("fswap check failed: inconsistent scriptcode and address")
          280         if hash_160(preimage) != parsed_script[1][1]:
          281             raise Exception("fswap check failed: our preimage not in script")
          282         if pubkey != parsed_script[9][1]:
          283             raise Exception("fswap check failed: our pubkey not in script")
          284         if locktime != int.from_bytes(parsed_script[6][1], byteorder='little'):
          285             raise Exception("fswap check failed: inconsistent locktime and script")
          286         # check that onchain_amount is not more than what we estimated
          287         if onchain_amount > expected_onchain_amount_sat:
          288             raise Exception(f"fswap check failed: onchain_amount is more than what we estimated: "
          289                             f"{onchain_amount} > {expected_onchain_amount_sat}")
          290         # verify that they are not locking up funds for more than a day
          291         if locktime - self.network.get_local_height() >= 144:
          292             raise Exception("fswap check failed: locktime too far in future")
          293         # create funding tx
          294         funding_output = PartialTxOutput.from_address_and_value(lockup_address, onchain_amount)
          295         if tx is None:
          296             tx = self.wallet.create_transaction(outputs=[funding_output], rbf=False, password=password)
          297         else:
          298             dummy_output = PartialTxOutput.from_address_and_value(ln_dummy_address(), expected_onchain_amount_sat)
          299             tx.outputs().remove(dummy_output)
          300             tx.add_outputs([funding_output])
          301             tx.set_rbf(False)
          302             self.wallet.sign_transaction(tx, password)
          303         # save swap data in wallet in case we need a refund
          304         swap = SwapData(
          305             redeem_script = redeem_script,
          306             locktime = locktime,
          307             privkey = privkey,
          308             preimage = preimage,
          309             prepay_hash = None,
          310             lockup_address = lockup_address,
          311             onchain_amount = expected_onchain_amount_sat,
          312             lightning_amount = lightning_amount_sat,
          313             is_reverse = False,
          314             is_redeemed = False,
          315             funding_txid = tx.txid(),
          316             spending_txid = None,
          317         )
          318         self.swaps[payment_hash.hex()] = swap
          319         self.add_lnwatcher_callback(swap)
          320         await self.network.broadcast_transaction(tx)
          321         return tx.txid()
          322 
          323     async def reverse_swap(
          324             self,
          325             *,
          326             lightning_amount_sat: int,
          327             expected_onchain_amount_sat: int,
          328     ) -> bool:
          329         """send on Lightning, receive on-chain
          330 
          331         - User generates preimage, RHASH. Sends RHASH to server.
          332         - Server creates an LN invoice for RHASH.
          333         - User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
          334         - Server creates on-chain output locked to RHASH.
          335         - User spends on-chain output, revealing preimage.
          336         - Server fulfills HTLC using preimage.
          337         """
          338         assert self.network
          339         assert self.lnwatcher
          340         privkey = os.urandom(32)
          341         pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
          342         preimage = os.urandom(32)
          343         preimage_hash = sha256(preimage)
          344         request_data = {
          345             "type": "reversesubmarine",
          346             "pairId": "BTC/BTC",
          347             "orderSide": "buy",
          348             "invoiceAmount": lightning_amount_sat,
          349             "preimageHash": preimage_hash.hex(),
          350             "claimPublicKey": pubkey.hex()
          351         }
          352         response = await self.network._send_http_on_proxy(
          353             'post',
          354             self.api_url + '/createswap',
          355             json=request_data,
          356             timeout=30)
          357         data = json.loads(response)
          358         invoice = data['invoice']
          359         fee_invoice = data.get('minerFeeInvoice')
          360         lockup_address = data['lockupAddress']
          361         redeem_script = data['redeemScript']
          362         locktime = data['timeoutBlockHeight']
          363         onchain_amount = data["onchainAmount"]
          364         response_id = data['id']
          365         # verify redeem_script is built with our pubkey and preimage
          366         redeem_script = bytes.fromhex(redeem_script)
          367         parsed_script = [x for x in script_GetOp(redeem_script)]
          368         if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP):
          369             raise Exception("rswap check failed: scriptcode does not match template")
          370         if script_to_p2wsh(redeem_script.hex()) != lockup_address:
          371             raise Exception("rswap check failed: inconsistent scriptcode and address")
          372         if hash_160(preimage) != parsed_script[5][1]:
          373             raise Exception("rswap check failed: our preimage not in script")
          374         if pubkey != parsed_script[7][1]:
          375             raise Exception("rswap check failed: our pubkey not in script")
          376         if locktime != int.from_bytes(parsed_script[10][1], byteorder='little'):
          377             raise Exception("rswap check failed: inconsistent locktime and script")
          378         # check that the onchain amount is what we expected
          379         if onchain_amount < expected_onchain_amount_sat:
          380             raise Exception(f"rswap check failed: onchain_amount is less than what we expected: "
          381                             f"{onchain_amount} < {expected_onchain_amount_sat}")
          382         # verify that we will have enough time to get our tx confirmed
          383         if locktime - self.network.get_local_height() <= 60:
          384             raise Exception("rswap check failed: locktime too close")
          385         # verify invoice preimage_hash
          386         lnaddr = self.lnworker._check_invoice(invoice)
          387         invoice_amount = lnaddr.get_amount_sat()
          388         if lnaddr.paymenthash != preimage_hash:
          389             raise Exception("rswap check failed: inconsistent RHASH and invoice")
          390         # check that the lightning amount is what we requested
          391         if fee_invoice:
          392             fee_lnaddr = self.lnworker._check_invoice(fee_invoice)
          393             invoice_amount += fee_lnaddr.get_amount_sat()
          394             prepay_hash = fee_lnaddr.paymenthash
          395         else:
          396             prepay_hash = None
          397         if int(invoice_amount) != lightning_amount_sat:
          398             raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) "
          399                             f"not what we requested ({lightning_amount_sat})")
          400         # save swap data to wallet file
          401         swap = SwapData(
          402             redeem_script = redeem_script,
          403             locktime = locktime,
          404             privkey = privkey,
          405             preimage = preimage,
          406             prepay_hash = prepay_hash,
          407             lockup_address = lockup_address,
          408             onchain_amount = onchain_amount,
          409             lightning_amount = lightning_amount_sat,
          410             is_reverse = True,
          411             is_redeemed = False,
          412             funding_txid = None,
          413             spending_txid = None,
          414         )
          415         self.swaps[preimage_hash.hex()] = swap
          416         # add callback to lnwatcher
          417         self.add_lnwatcher_callback(swap)
          418         # initiate payment.
          419         if fee_invoice:
          420             self.prepayments[prepay_hash] = preimage_hash
          421             asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice, attempts=10))
          422         # initiate payment.
          423         success, log = await self.lnworker.pay_invoice(invoice, attempts=10)
          424         return success
          425 
          426     async def get_pairs(self) -> None:
          427         assert self.network
          428         response = await self.network._send_http_on_proxy(
          429             'get',
          430             self.api_url + '/getpairs',
          431             timeout=30)
          432         pairs = json.loads(response)
          433         fees = pairs['pairs']['BTC/BTC']['fees']
          434         self.percentage = fees['percentage']
          435         self.normal_fee = fees['minerFees']['baseAsset']['normal']
          436         self.lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup']
          437         limits = pairs['pairs']['BTC/BTC']['limits']
          438         self.min_amount = limits['minimal']
          439         self._max_amount = limits['maximal']
          440 
          441     def get_max_amount(self):
          442         return min(self._max_amount, LN_MAX_HTLC_VALUE_MSAT // 1000)
          443 
          444     def check_invoice_amount(self, x):
          445         return x >= self.min_amount and x <= self._max_amount
          446 
          447     def get_recv_amount(self, send_amount: Optional[int], is_reverse: bool) -> Optional[int]:
          448         if send_amount is None:
          449             return
          450         x = send_amount
          451         if is_reverse:
          452             if not self.check_invoice_amount(x):
          453                 return
          454             x = int(x * (100 - self.percentage) / 100)
          455             x -= self.lockup_fee
          456             x -= self.get_claim_fee()
          457             if x < dust_threshold():
          458                 return
          459         else:
          460             x -= self.normal_fee
          461             x = int(x / ((100 + self.percentage) / 100))
          462             if not self.check_invoice_amount(x):
          463                 return
          464         return x
          465 
          466     def get_send_amount(self, recv_amount: Optional[int], is_reverse: bool) -> Optional[int]:
          467         if not recv_amount:
          468             return
          469         x = recv_amount
          470         if is_reverse:
          471             x += self.lockup_fee
          472             x += self.get_claim_fee()
          473             x = int(x * 100 / (100 - self.percentage)) + 1
          474             if not self.check_invoice_amount(x):
          475                 return
          476         else:
          477             if not self.check_invoice_amount(x):
          478                 return
          479             x = int(x * 100 / (100 + self.percentage)) + 1
          480             x += self.normal_fee
          481         return x
          482