electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

submarine_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