tsubmarine_swaps: add SwapManager - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 17ff6ffa0883d5b73845c3800fe5f2e04889e576 DIR parent b26ad81e69b23cadebd9e2efb8d2861927d94543 HTML Author: ThomasV <thomasv@electrum.org> Date: Sun, 24 May 2020 13:42:36 +0200 submarine_swaps: add SwapManager Diffstat: M electrum/commands.py | 15 ++------------- M electrum/gui/qt/swap_dialog.py | 8 ++++---- M electrum/lnworker.py | 2 ++ M electrum/submarine_swaps.py | 347 +++++++++++++++---------------- 4 files changed, 175 insertions(+), 197 deletions(-) --- DIR diff --git a/electrum/commands.py b/electrum/commands.py t@@ -1103,23 +1103,12 @@ class Commands: @command('wnp') async def submarine_swap(self, amount, password=None, wallet: Abstract_Wallet = None): - return await submarine_swaps.normal_swap(satoshis(amount), wallet, self.network, password) + return await wallet.lnworker.swap_manager.normal_swap(satoshis(amount), password) @command('wn') async def reverse_swap(self, amount, wallet: Abstract_Wallet = None): - return await submarine_swaps.reverse_swap(satoshis(amount), wallet, self.network) + return await wallet.lnworker.swap_manager.reverse_swap(satoshis(amount)) - @command('n') - async def get_pairs(self): - return await submarine_swaps.get_pairs(self.network) - - @command('wn') - async def claim_swap(self, key, wallet: Abstract_Wallet = None): - return await submarine_swaps.claim_swap(key, wallet) - - @command('wn') - async def refund_swap(self, key, wallet: Abstract_Wallet = None): - return await submarine_swaps.refund_swap(key, wallet) def eval_bool(x: str) -> bool: if x == 'false': return False DIR diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py t@@ -16,7 +16,6 @@ from .util import (MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButto from .amountedit import BTCAmountEdit, FreezableLineEdit -from electrum import submarine_swaps as ss import asyncio from .util import read_QIcon t@@ -26,6 +25,7 @@ class SwapDialog(WindowModalDialog): def __init__(self, window): WindowModalDialog.__init__(self, window, _('Submarine Swap')) self.window = window + self.swap_manager = self.window.wallet.lnworker.swap_manager self.network = window.network self.normal_fee = 0 self.lockup_fee = 0 t@@ -85,7 +85,7 @@ class SwapDialog(WindowModalDialog): self.send_amount_e.follows = False def get_pairs(self): - fut = asyncio.run_coroutine_threadsafe(ss.get_pairs(self.network), self.network.asyncio_loop) + fut = asyncio.run_coroutine_threadsafe(self.swap_manager.get_pairs(), self.network.asyncio_loop) pairs = fut.result() print(pairs) fees = pairs['pairs']['BTC/BTC']['fees'] t@@ -125,9 +125,9 @@ class SwapDialog(WindowModalDialog): return if self.is_reverse: amount_sat = self.send_amount_e.get_amount() - coro = ss.reverse_swap(amount_sat, self.window.wallet, self.network) + coro = self.swap_manager.reverse_swap(amount_sat) else: amount_sat = self.recv_amount_e.get_amount() password = self.window.protect(lambda x: x, []) - coro = ss.normal_swap(amount_sat, self.window.wallet, self.network, password) + coro = self.swap_manager.normal_swap(amount_sat, password) asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -70,6 +70,7 @@ from .crypto import pw_encode_bytes, pw_decode_bytes, PW_HASH_VERSION_LATEST from .lnutil import ChannelBackupStorage from .lnchannel import ChannelBackup from .channel_db import UpdateStatus +from .submarine_swaps import SwapManager if TYPE_CHECKING: from .network import Network t@@ -556,6 +557,7 @@ class LNWallet(LNWorker): self.lnwatcher = LNWalletWatcher(self, network) self.lnwatcher.start_network(network) self.network = network + self.swap_manager = SwapManager(self.wallet, network) for chan in self.channels.values(): self.lnwatcher.add_channel(chan.funding_outpoint.to_str(), chan.get_funding_address()) DIR diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py t@@ -6,10 +6,10 @@ from .ecc import ECPrivkey from .bitcoin import address_to_script, script_to_p2wsh, redeem_script_to_address, opcodes, p2wsh_nested_script, push_script, is_segwit_address from .transaction import TxOutpoint, PartialTxInput, PartialTxOutput, PartialTransaction, construct_witness from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey -from .transaction import Transaction from .util import log_exceptions from .bitcoin import dust_threshold from typing import TYPE_CHECKING +from .logging import Logger if TYPE_CHECKING: from .network import Network t@@ -73,182 +73,169 @@ def create_claim_tx(txin, witness_script, preimage, privkey:bytes, address, amou return tx -@log_exceptions -async def _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, address, locktime, is_refund=False): - lnwatcher = lnworker.lnwatcher - utxos = lnwatcher.get_addr_utxo(lockup_address) - delta = lnwatcher.network.get_local_height() - locktime - if is_refund and delta < 0: - print('height not reached for refund', delta, locktime) - return - for txin in list(utxos.values()): - fee = lnwatcher.config.estimate_fee(136, allow_fallback_to_static_rates=True) - amount_sat = txin._trusted_value_sats - fee - if amount_sat < dust_threshold(): - print('txo lower than dust threshold') - continue - tx = create_claim_tx(txin, redeem_script, preimage, privkey, address, amount_sat, locktime, is_refund) - await lnwatcher.network.broadcast_transaction(tx) - - -@log_exceptions -async def claim_swap(key, wallet): - lnworker = wallet.lnworker - address = wallet.get_unused_address() - swaps = wallet.db.get_dict('submarine_swaps') - data = swaps[key] - onchain_amount = data['onchainAmount'] - redeem_script = bytes.fromhex(data['redeemScript']) - locktime = data['timeoutBlockHeight'] - lockup_address = data['lockupAddress'] - preimage = bytes.fromhex(data['preimage']) - privkey = bytes.fromhex(data['privkey']) - callback = lambda: _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, address, locktime, is_refund=False) - lnworker.lnwatcher.add_callback(lockup_address, callback) - return True - - -@log_exceptions -async def refund_swap(key, wallet): - lnworker = wallet.lnworker - address = wallet.get_unused_address() - swaps = wallet.db.get_dict('submarine_swaps') - data = swaps[key] - lockup_address = data['address'] - redeem_script = bytes.fromhex(data['redeemScript']) - locktime = data['timeoutBlockHeight'] - preimage = bytes.fromhex(data['preimage']) - privkey = bytes.fromhex(data['privkey']) - callback = lambda: _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, address, locktime, is_refund=True) - lnworker.lnwatcher.add_callback(lockup_address, callback) - return True - - -@log_exceptions -async def normal_swap(amount_sat, wallet: 'Abstract_Wallet', network: 'Network', password): - lnworker = wallet.lnworker - privkey = os.urandom(32) - pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) - key = await lnworker._add_request_coro(amount_sat, 'swap', expiry=3600*24) - request = wallet.get_request(key) - invoice = request['invoice'] - lnaddr = lnworker._check_invoice(invoice, amount_sat) - payment_hash = lnaddr.paymenthash - preimage = lnworker.get_preimage(payment_hash) - address = wallet.get_unused_address() - request_data = { - "type": "submarine", - "pairId": "BTC/BTC", - "orderSide": "sell", - "invoice": invoice, - "refundPublicKey": pubkey.hex() - } - response = await network._send_http_on_proxy( - 'post', - API_URL + '/createswap', - json=request_data, - timeout=30) - data = json.loads(response) - response_id = data["id"] - 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 - redeem_script = bytes.fromhex(redeem_script) - parsed_script = [x for x in script_GetOp(redeem_script)] - assert match_script_against_template(redeem_script, WITNESS_TEMPLATE_SWAP) - assert script_to_p2wsh(redeem_script.hex()) == lockup_address - assert hash_160(preimage) == parsed_script[1][1] - assert pubkey == parsed_script[9][1] - # verify that we will have enought time to get our tx confirmed - assert locktime == int.from_bytes(parsed_script[6][1], byteorder='little') - assert locktime - network.get_local_height() == 140 - # save swap data in wallet in case we need a refund - data['privkey'] = privkey.hex() - data['preimage'] = preimage.hex() - swaps = wallet.db.get_dict('submarine_swaps') - swaps[response_id] = data - callback = lambda: _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, address, locktime, is_refund=True) - lnworker.lnwatcher.add_callback(lockup_address, callback) - outputs = [PartialTxOutput.from_address_and_value(lockup_address, onchain_amount)] - tx = wallet.create_transaction(outputs=outputs, rbf=False, password=password) - await network.broadcast_transaction(tx) - # - attempt = await lnworker.await_payment(payment_hash) - return { - 'id':response_id, - 'success':attempt.success, - } - - -@log_exceptions -async def reverse_swap(amount_sat, wallet: 'Abstract_Wallet', network: 'Network'): - privkey = os.urandom(32) - pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) - preimage = os.urandom(32) - preimage_hash = sha256(preimage) - address = wallet.get_unused_address() - request_data = { - "type": "reversesubmarine", - "pairId": "BTC/BTC", - "orderSide": "buy", - "invoiceAmount": amount_sat, - "preimageHash": preimage_hash.hex(), - "claimPublicKey": pubkey.hex() - } - response = await network._send_http_on_proxy( - 'post', - API_URL + '/createswap', - json=request_data, - timeout=30) - data = json.loads(response) - invoice = data['invoice'] - 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 - redeem_script = bytes.fromhex(redeem_script) - parsed_script = [x for x in script_GetOp(redeem_script)] - assert match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP) - assert script_to_p2wsh(redeem_script.hex()) == lockup_address - assert hash_160(preimage) == parsed_script[5][1] - assert pubkey == parsed_script[7][1] - # verify that we will have enought time to get our tx confirmed - assert locktime == int.from_bytes(parsed_script[10][1], byteorder='little') - assert locktime - network.get_local_height() > 10 - # verify invoice preimage_hash - lnworker = wallet.lnworker - lnaddr = lnworker._check_invoice(invoice, amount_sat) - assert lnaddr.paymenthash == preimage_hash - # save swap data in wallet in case payment fails - data['privkey'] = privkey.hex() - data['preimage'] = preimage.hex() - # save data to wallet file - swaps = wallet.db.get_dict('submarine_swaps') - swaps[response_id] = data - # add callback to lnwatcher - callback = lambda: _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, address, locktime, is_refund=False) - lnworker.lnwatcher.add_callback(lockup_address, callback) - # initiate payment. - success, log = await lnworker._pay(invoice, attempts=5) - # discard data; this should be done by lnwatcher - if success: - swaps.pop(response_id) - return { - 'id':response_id, - 'success':success, - } - - -@log_exceptions -async def get_pairs(network): - response = await network._send_http_on_proxy( - 'get', - API_URL + '/getpairs', - timeout=30) - data = json.loads(response) - return data + +class SwapManager(Logger): + + @log_exceptions + async def _claim_swap(self, lockup_address, redeem_script, preimage, privkey, locktime, is_refund=False): + utxos = self.lnwatcher.get_addr_utxo(lockup_address) + if not utxos: + return + delta = self.network.get_local_height() - locktime + if is_refund and delta < 0: + self.logger.info(f'height not reached for refund {lockup_address} {delta}, {locktime}') + return + for txin in list(utxos.values()): + fee = self.lnwatcher.config.estimate_fee(136, allow_fallback_to_static_rates=True) + amount_sat = txin._trusted_value_sats - fee + if amount_sat < dust_threshold(): + self.logger.info('utxo value below dust threshold') + continue + address = self.wallet.get_unused_address() + tx = create_claim_tx(txin, redeem_script, preimage, privkey, address, amount_sat, locktime, is_refund) + await self.network.broadcast_transaction(tx) + + def __init__(self, wallet: 'Abstract_Wallet', network:'Network'): + Logger.__init__(self) + self.network = network + self.wallet = wallet + self.lnworker = wallet.lnworker + self.lnwatcher = self.wallet.lnworker.lnwatcher + swaps = self.wallet.db.get_dict('submarine_swaps') + for key, data in swaps.items(): + redeem_script = bytes.fromhex(data['redeemScript']) + locktime = data['timeoutBlockHeight'] + preimage = bytes.fromhex(data['preimage']) + privkey = bytes.fromhex(data['privkey']) + if data.get('invoice'): + lockup_address = data['lockupAddress'] + is_refund = False + else: + lockup_address = data['address'] + is_refund = True + self.add_lnwatcher_callback(lockup_address, redeem_script, preimage, privkey, locktime, is_refund) + + def add_lnwatcher_callback(self, lockup_address, redeem_script, preimage, privkey, locktime, is_refund): + callback = lambda: self._claim_swap(lockup_address, redeem_script, preimage, privkey, locktime, is_refund=is_refund) + self.lnwatcher.add_callback(lockup_address, callback) + + @log_exceptions + async def normal_swap(self, amount_sat, password): + privkey = os.urandom(32) + pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) + key = await self.lnworker._add_request_coro(amount_sat, 'swap', expiry=3600*24) + request = self.wallet.get_request(key) + invoice = request['invoice'] + lnaddr = self.lnworker._check_invoice(invoice, amount_sat) + payment_hash = lnaddr.paymenthash + preimage = self.lnworker.get_preimage(payment_hash) + request_data = { + "type": "submarine", + "pairId": "BTC/BTC", + "orderSide": "sell", + "invoice": invoice, + "refundPublicKey": pubkey.hex() + } + response = await self.network._send_http_on_proxy( + 'post', + API_URL + '/createswap', + json=request_data, + timeout=30) + data = json.loads(response) + response_id = data["id"] + 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 + redeem_script = bytes.fromhex(redeem_script) + parsed_script = [x for x in script_GetOp(redeem_script)] + assert match_script_against_template(redeem_script, WITNESS_TEMPLATE_SWAP) + assert script_to_p2wsh(redeem_script.hex()) == lockup_address + assert hash_160(preimage) == parsed_script[1][1] + assert pubkey == parsed_script[9][1] + # verify that they are not locking up funds for more than a day + assert locktime == int.from_bytes(parsed_script[6][1], byteorder='little') + assert locktime - self.network.get_local_height() < 144 + # save swap data in wallet in case we need a refund + data['privkey'] = privkey.hex() + data['preimage'] = preimage.hex() + swaps = self.wallet.db.get_dict('submarine_swaps') + swaps[response_id] = data + self.add_lnwatcher_callback(lockup_address, redeem_script, preimage, privkey, locktime, is_refund=True) + outputs = [PartialTxOutput.from_address_and_value(lockup_address, onchain_amount)] + tx = self.wallet.create_transaction(outputs=outputs, rbf=False, password=password) + await self.network.broadcast_transaction(tx) + # + attempt = await self.lnworker.await_payment(payment_hash) + return { + 'id':response_id, + 'success':attempt.success, + } + + @log_exceptions + async def reverse_swap(self, amount_sat): + privkey = os.urandom(32) + pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) + preimage = os.urandom(32) + preimage_hash = sha256(preimage) + request_data = { + "type": "reversesubmarine", + "pairId": "BTC/BTC", + "orderSide": "buy", + "invoiceAmount": amount_sat, + "preimageHash": preimage_hash.hex(), + "claimPublicKey": pubkey.hex() + } + response = await self.network._send_http_on_proxy( + 'post', + API_URL + '/createswap', + json=request_data, + timeout=30) + data = json.loads(response) + invoice = data['invoice'] + 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 + redeem_script = bytes.fromhex(redeem_script) + parsed_script = [x for x in script_GetOp(redeem_script)] + assert match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP) + assert script_to_p2wsh(redeem_script.hex()) == lockup_address + assert hash_160(preimage) == parsed_script[5][1] + assert pubkey == parsed_script[7][1] + # verify that we will have enought time to get our tx confirmed + assert locktime == int.from_bytes(parsed_script[10][1], byteorder='little') + assert locktime - self.network.get_local_height() > 10 + # verify invoice preimage_hash + lnaddr = self.lnworker._check_invoice(invoice, amount_sat) + assert lnaddr.paymenthash == preimage_hash + # save swap data in wallet in case payment fails + data['privkey'] = privkey.hex() + data['preimage'] = preimage.hex() + # save data to wallet file + swaps = self.wallet.db.get_dict('submarine_swaps') + swaps[response_id] = data + # add callback to lnwatcher + self.add_lnwatcher_callback(lockup_address, redeem_script, preimage, privkey, locktime, is_refund=False) + # initiate payment. + success, log = await self.lnworker._pay(invoice, attempts=5) + # discard data; this should be done by lnwatcher + if success: + swaps.pop(response_id) + return { + 'id':response_id, + 'success':success, + } + + @log_exceptions + async def get_pairs(self): + response = await self.network._send_http_on_proxy( + 'get', + API_URL + '/getpairs', + timeout=30) + data = json.loads(response) + return data