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