tsubmarine swaps, initial implementation: - server uses Boltz API (https://docs.boltz.exchange/en/latest/) - reverse swaps only - command-line only - electrum - Electrum Bitcoin wallet (HTM) git clone https://git.parazyd.org/electrum (DIR) Log (DIR) Files (DIR) Refs (DIR) Submodules --- (DIR) commit 1e67e55303987aa643018a314c1245be597286ef (DIR) parent 368229a4c37bb57bdff188be4e9ec59bde7441ee (HTM) Author: ThomasV <thomasv@electrum.org> Date: Tue, 19 May 2020 13:23:44 +0200 submarine swaps, initial implementation: - server uses Boltz API (https://docs.boltz.exchange/en/latest/) - reverse swaps only - command-line only Diffstat: M electrum/commands.py | 10 ++++++++++ A electrum/submarine_swaps.py | 154 +++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 0 deletions(-) --- (DIR) diff --git a/electrum/commands.py b/electrum/commands.py t@@ -1100,6 +1100,16 @@ class Commands: """ return the local watchtower's ctn of channel. used in regtests """ return await self.network.local_watchtower.sweepstore.get_ctn(channel_point, None) + @command('wn') + async def reverse_swap(self, amount, wallet: Abstract_Wallet = None): + from .submarine_swaps import reverse_swap + amount_sat = satoshis(amount) + return await reverse_swap(amount_sat, wallet, self.network) + + @command('wn') + async def claim_swap(self, key, wallet: Abstract_Wallet = None): + from .submarine_swaps import claim_swap + return await claim_swap(key, wallet) def eval_bool(x: str) -> bool: if x == 'false': return False (DIR) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py t@@ -0,0 +1,154 @@ +import asyncio +import json +import os +from .crypto import sha256, hash_160 +from .ecc import ECPrivkey +from .bitcoin import address_to_script, script_to_p2wsh, opcodes +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 + + +# todo: +# - integrate with lnwatcher +# - forward swaps + +API_URL = 'http://ecdsa.org:9001' + +WITNESS_TEMPLATE_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 create_claim_tx(txid, index, witness_script, preimage, privkey:bytes, amount_sat, address, fee): + pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) + prevout = TxOutpoint(txid=bytes.fromhex(txid), out_idx=index) + txin = PartialTxInput(prevout=prevout) + txin.script_type = 'p2wsh' + txin.script_sig = b'' + txin.pubkeys = [pubkey] + txin.num_sig = 1 + txin.witness_script = witness_script + txin._trusted_value_sats = amount_sat + txout = PartialTxOutput(scriptpubkey=bytes.fromhex(address_to_script(address)), value=amount_sat - fee) + tx = PartialTransaction.from_io([txin], [txout], version=2) + sig = bytes.fromhex(tx.sign_txin(0, privkey)) + witness = construct_witness([sig, preimage, witness_script]) + tx.inputs()[0].witness = bytes.fromhex(witness) + assert tx.is_complete() + return tx + + +@log_exceptions +async def _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, onchain_amount, address): + # add address to lnwatcher + lnwatcher = lnworker.lnwatcher + lnwatcher.add_address(lockup_address) + while True: + h = lnwatcher.get_address_history(lockup_address) + if not h: + await asyncio.sleep(1) + continue + for txid, height in h: + tx = lnwatcher.db.get_transaction(txid) + # find relevant output + for i, o in enumerate(tx.outputs()): + if o.address == lockup_address: + break + else: + continue + # create claim tx + fee = lnwatcher.config.estimate_fee(136, allow_fallback_to_static_rates=True) + tx = create_claim_tx(txid, i, redeem_script, preimage, privkey, onchain_amount, address, fee) + try: + await lnwatcher.network.broadcast_transaction(tx) + break + except: + continue + +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']) + lockup_address = data['lockupAddress'] + preimage = bytes.fromhex(data['preimage']) + privkey = bytes.fromhex(data['privkey']) + await _claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, onchain_amount, address) + +@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'] + timeout_block_height = 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_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 + cltv = int.from_bytes(parsed_script[10][1], byteorder='little') + assert cltv - 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 to lnwatcher + f = asyncio.ensure_future(_claim_swap(lnworker, lockup_address, redeem_script, preimage, privkey, onchain_amount, address)) + # initiate payment. + success, log = await lnworker._pay(invoice, attempts=1) + # discard data; this should be done by lnwatcher + if success: + swaps.pop(response_id) + return { + 'id':response_id, + 'success':success, + }