From 098c65d73244bdffbcae4d4051656d41cbec46b8 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 20 Oct 2022 13:40:01 +0200 Subject: [PATCH 1/5] submarine swap server plugin: - hold invoices - uses the same web API as the Boltz backend --- electrum/plugins/swapserver/__init__.py | 6 + electrum/plugins/swapserver/cmdline.py | 31 ++++ electrum/plugins/swapserver/qt.py | 31 ++++ electrum/plugins/swapserver/server.py | 138 ++++++++++++++++ electrum/plugins/swapserver/swapserver.py | 58 +++++++ electrum/simple_config.py | 4 +- electrum/submarine_swaps.py | 187 +++++++++++++++++++--- electrum/tests/regtest.py | 3 + electrum/tests/regtest/regtest.sh | 25 ++- 9 files changed, 455 insertions(+), 28 deletions(-) create mode 100644 electrum/plugins/swapserver/__init__.py create mode 100644 electrum/plugins/swapserver/cmdline.py create mode 100644 electrum/plugins/swapserver/qt.py create mode 100644 electrum/plugins/swapserver/server.py create mode 100644 electrum/plugins/swapserver/swapserver.py diff --git a/electrum/plugins/swapserver/__init__.py b/electrum/plugins/swapserver/__init__.py new file mode 100644 index 000000000..867fb643e --- /dev/null +++ b/electrum/plugins/swapserver/__init__.py @@ -0,0 +1,6 @@ +from electrum.i18n import _ + +fullname = _('SwapServer') +description = '' + +available_for = ['qt', 'cmdline'] diff --git a/electrum/plugins/swapserver/cmdline.py b/electrum/plugins/swapserver/cmdline.py new file mode 100644 index 000000000..7add7a386 --- /dev/null +++ b/electrum/plugins/swapserver/cmdline.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2023 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from .swapserver import SwapServerPlugin + +class Plugin(SwapServerPlugin): + pass + diff --git a/electrum/plugins/swapserver/qt.py b/electrum/plugins/swapserver/qt.py new file mode 100644 index 000000000..7add7a386 --- /dev/null +++ b/electrum/plugins/swapserver/qt.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2023 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from .swapserver import SwapServerPlugin + +class Plugin(SwapServerPlugin): + pass + diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py new file mode 100644 index 000000000..6a2f2ad70 --- /dev/null +++ b/electrum/plugins/swapserver/server.py @@ -0,0 +1,138 @@ +import os +import asyncio +import attr +import random +from collections import defaultdict + +from aiohttp import ClientResponse +from aiohttp import web, client_exceptions +from aiorpcx import timeout_after, TaskTimeout, ignore_after +from aiorpcx import NetAddress + + +from electrum.util import log_exceptions, ignore_exceptions +from electrum.logging import Logger +from electrum.util import EventListener, event_listener +from electrum.invoices import PR_PAID, PR_EXPIRED + + +class SwapServer(Logger, EventListener): + """ + public API: + - getpairs + - createswap + """ + + WWW_DIR = os.path.join(os.path.dirname(__file__), 'www') + + def __init__(self, config, wallet): + Logger.__init__(self) + self.config = config + self.wallet = wallet + self.addr = NetAddress.from_string(self.config.SWAPSERVER_ADDRESS) + self.register_callbacks() # eventlistener + + self.pending = defaultdict(asyncio.Event) + self.pending_msg = {} + + @ignore_exceptions + @log_exceptions + async def run(self): + self.root = '/root' + app = web.Application() + app.add_routes([web.get('/api/getpairs', self.get_pairs)]) + app.add_routes([web.post('/api/createswap', self.create_swap)]) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=str(self.addr.host), port=self.addr.port, ssl_context=self.config.get_ssl_context()) + await site.start() + self.logger.info(f"now running and listening. addr={self.addr}") + + async def get_pairs(self, r): + sm = self.wallet.lnworker.swap_manager + sm.init_pairs() + pairs = { + "info": [], + "warnings": [], + "pairs": { + "BTC/BTC": { + "hash": "dfe692a026d6964601bfd79703611af333d1d5aa49ef5fedd288f5a620fced60", + "rate": 1, + "limits": { + "maximal": sm._max_amount, + "minimal": sm._min_amount, + "maximalZeroConf": { + "baseAsset": 0, + "quoteAsset": 0 + } + }, + "fees": { + "percentage": 0.5, + "minerFees": { + "baseAsset": { + "normal": sm.normal_fee, + "reverse": { + "claim": sm.claim_fee, + "lockup": sm.lockup_fee + } + }, + "quoteAsset": { + "normal": sm.normal_fee, + "reverse": { + "claim": sm.claim_fee, + "lockup": sm.lockup_fee + } + } + } + } + } + } + } + return web.json_response(pairs) + + async def create_swap(self, r): + sm = self.wallet.lnworker.swap_manager + sm.init_pairs() + request = await r.json() + req_type = request['type'] + assert request['pairId'] == 'BTC/BTC' + if req_type == 'reversesubmarine': + lightning_amount_sat=request['invoiceAmount'] + payment_hash=bytes.fromhex(request['preimageHash']) + their_pubkey=bytes.fromhex(request['claimPublicKey']) + assert len(payment_hash) == 32 + assert len(their_pubkey) == 33 + swap, payment_hash, invoice = sm.add_server_swap( + lightning_amount_sat=lightning_amount_sat, + payment_hash=payment_hash, + their_pubkey=their_pubkey + ) + response = { + 'id': payment_hash.hex(), + 'invoice': invoice, + 'minerFeeInvoice': None, + 'lockupAddress': swap.lockup_address, + 'redeemScript': swap.redeem_script.hex(), + 'timeoutBlockHeight': swap.locktime, + "onchainAmount": swap.onchain_amount, + } + elif req_type == 'submarine': + their_invoice=request['invoice'] + their_pubkey=bytes.fromhex(request['refundPublicKey']) + assert len(their_pubkey) == 33 + swap, payment_hash, invoice = sm.add_server_swap( + invoice=their_invoice, + their_pubkey=their_pubkey + ) + response = { + "id": payment_hash.hex(), + "acceptZeroConf": False, + "expectedAmount": swap.onchain_amount, + "timeoutBlockHeight": swap.locktime, + "address": swap.lockup_address, + "redeemScript": swap.redeem_script.hex() + } + else: + raise Exception('unsupported request type:' + req_type) + return web.json_response(response) diff --git a/electrum/plugins/swapserver/swapserver.py b/electrum/plugins/swapserver/swapserver.py new file mode 100644 index 000000000..d061c8bc1 --- /dev/null +++ b/electrum/plugins/swapserver/swapserver.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2023 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import asyncio +import os +import random +from electrum.plugin import BasePlugin, hook +from electrum.util import log_exceptions, ignore_exceptions +from electrum import ecc + +from .server import SwapServer + + +class SwapServerPlugin(BasePlugin): + + def __init__(self, parent, config, name): + BasePlugin.__init__(self, parent, config, name) + self.config = config + self.server = None + + @hook + def daemon_wallet_loaded(self, daemon, wallet): + # we use the first wallet loaded + if self.server is not None: + return + if self.config.get('offline'): + return + + self.server = SwapServer(self.config, wallet) + sm = wallet.lnworker.swap_manager + jobs = [ + sm.pay_pending_invoices(), + self.server.run(), + ] + asyncio.run_coroutine_threadsafe(daemon._run(jobs=jobs), daemon.asyncio_loop) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index b0feab9e5..92ed68396 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -966,7 +966,7 @@ class SimpleConfig(Logger): # submarine swap server SWAPSERVER_URL_MAINNET = ConfigVar('swapserver_url_mainnet', default='https://swaps.electrum.org/api', type_=str) SWAPSERVER_URL_TESTNET = ConfigVar('swapserver_url_testnet', default='https://swaps.electrum.org/testnet', type_=str) - SWAPSERVER_URL_REGTEST = ConfigVar('swapserver_url_regtest', default='https://localhost/api', type_=str) + SWAPSERVER_URL_REGTEST = ConfigVar('swapserver_url_regtest', default='http://localhost:5455/api', type_=str) # connect to remote WT WATCHTOWER_CLIENT_ENABLED = ConfigVar('use_watchtower', default=False, type_=bool) WATCHTOWER_CLIENT_URL = ConfigVar('watchtower_url', default=None, type_=str) @@ -981,6 +981,8 @@ class SimpleConfig(Logger): PAYSERVER_ROOT = ConfigVar('payserver_root', default='/r', type_=str) PAYSERVER_ALLOW_CREATE_INVOICE = ConfigVar('payserver_allow_create_invoice', default=False, type_=bool) + SWAPSERVER_ADDRESS = ConfigVar('swapserver_address', default='localhost:5455', type_=str) + PLUGIN_TRUSTEDCOIN_NUM_PREPAY = ConfigVar('trustedcoin_prepay', default=20, type_=int) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 4e7c7fe8e..32dda9eae 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -33,7 +33,10 @@ if TYPE_CHECKING: +CLAIM_FEE_SIZE = 136 +LOCKUP_FEE_SIZE = 153 # assuming 1 output, 2 outputs +MIN_LOCKTIME_DELTA = 60 WITNESS_TEMPLATE_SWAP = [ opcodes.OP_HASH160, @@ -102,14 +105,11 @@ class SwapData(StoredObject): is_redeemed = attr.ib(type=bool) _funding_prevout = None # type: Optional[TxOutpoint] # for RBF - __payment_hash = None + _payment_hash = None @property def payment_hash(self) -> bytes: - if self.__payment_hash is None: - self.__payment_hash = sha256(self.preimage) - return self.__payment_hash - + return self._payment_hash def create_claim_tx( *, @@ -139,6 +139,7 @@ class SwapManager(Logger): Logger.__init__(self) self.normal_fee = 0 self.lockup_fee = 0 + self.claim_fee = 0 # part of the boltz prococol, not used by Electrum self.percentage = 0 self._min_amount = None self._max_amount = None @@ -149,6 +150,7 @@ class SwapManager(Logger): self._swaps_by_funding_outpoint = {} # type: Dict[TxOutpoint, SwapData] self._swaps_by_lockup_address = {} # type: Dict[str, SwapData] for payment_hash, swap in self.swaps.items(): + swap._payment_hash = bytes.fromhex(payment_hash) self._add_or_reindex_swap(swap) self.prepayments = {} # type: Dict[bytes, bytes] # fee_rhash -> rhash @@ -171,6 +173,30 @@ class SwapManager(Logger): continue self.add_lnwatcher_callback(swap) + async def pay_pending_invoices(self): + # for server + self.invoices_to_pay = set() + while True: + await asyncio.sleep(1) + for key in list(self.invoices_to_pay): + swap = self.swaps.get(key) + if not swap: + continue + invoice = self.wallet.get_invoice(key) + if not invoice: + continue + current_height = self.network.get_local_height() + delta = swap.locktime - current_height + if delta <= MIN_LOCKTIME_DELTA: + # fixme: should consider cltv of ln payment + self.logger.info(f'locktime too close {key}') + continue + success, log = await self.lnworker.pay_invoice(invoice.lightning_invoice, attempts=1) + if not success: + self.logger.info(f'failed to pay invoice {key}') + continue + self.invoices_to_pay.remove(key) + @log_exceptions async def _claim_swap(self, swap: SwapData) -> None: assert self.network @@ -187,9 +213,31 @@ class SwapManager(Logger): swap.funding_txid = txin.prevout.txid.hex() swap._funding_prevout = txin.prevout self._add_or_reindex_swap(swap) # to update _swaps_by_funding_outpoint + funding_conf = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex()).conf spent_height = txin.spent_height + + if swap.is_reverse and swap.preimage is None: + if funding_conf <= 0: + continue + preimage = self.lnworker.get_preimage(swap.payment_hash) + if preimage is None: + self.invoices_to_pay.add(swap.payment_hash.hex()) + continue + swap.preimage = preimage + if spent_height is not None: swap.spending_txid = txin.spent_txid + if not swap.is_reverse and swap.preimage is None: + # we need to extract the preimage, add it to lnwatcher + # + tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) + preimage = tx.inputs()[0].witness_elements()[1] + assert swap.payment_hash == sha256(preimage) + swap.preimage = preimage + self.logger.info(f'found preimage: {preimage.hex()}') + self.lnworker.preimages[swap.payment_hash.hex()] = preimage.hex() + # note: we must check the payment secret before we broadcast the funding tx + if spent_height > 0: if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY: self.logger.info(f'stop watching swap {swap.lockup_address}') @@ -205,6 +253,10 @@ class SwapManager(Logger): if not swap.is_reverse and delta < 0: # too early for refund return + # + if swap.is_reverse and swap.preimage is None: + self.logger.info('preimage not available yet') + continue try: tx = self._create_and_sign_claim_tx(txin=txin, swap=swap, config=self.wallet.config) except BelowDustLimit: @@ -215,11 +267,14 @@ class SwapManager(Logger): swap.spending_txid = tx.txid() def get_claim_fee(self): - return self._get_claim_fee(config=self.wallet.config) + return self.get_fee(CLAIM_FEE_SIZE) + + def get_fee(self, size): + return self._get_fee(size=size, config=self.wallet.config) @classmethod - def _get_claim_fee(cls, *, config: 'SimpleConfig'): - return config.estimate_fee(136, allow_fallback_to_static_rates=True) + def _get_fee(cls, *, size, config: 'SimpleConfig'): + return config.estimate_fee(size, allow_fallback_to_static_rates=True) def get_swap(self, payment_hash: bytes) -> Optional[SwapData]: # for history @@ -234,6 +289,73 @@ class SwapManager(Logger): callback = lambda: self._claim_swap(swap) self.lnwatcher.add_callback(swap.lockup_address, callback) + async def hold_invoice_callback(self, payment_hash): + key = payment_hash.hex() + if key in self.swaps: + swap = self.swaps[key] + if swap.funding_txid is None: + await self.start_normal_swap(swap, None, None) + + def add_server_swap(self, *, lightning_amount_sat=None, payment_hash=None, invoice=None, their_pubkey=None): + from .bitcoin import construct_script + from .crypto import ripemd + from .lnaddr import lndecode + from .invoices import Invoice + + locktime = self.network.get_local_height() + 140 + privkey = os.urandom(32) + our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) + is_reverse_for_server = (invoice is not None) + if is_reverse_for_server: + # client is doing a normal swap + lnaddr = lndecode(invoice) + payment_hash = lnaddr.paymenthash + lightning_amount_sat = int(lnaddr.get_amount_sat()) # should return int + onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False) + redeem_script = construct_script( + WITNESS_TEMPLATE_SWAP, + {1:ripemd(payment_hash), 4:our_pubkey, 6:locktime, 9:their_pubkey} + ) + self.wallet.save_invoice(Invoice.from_bech32(invoice)) + else: + onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) + lnaddr, invoice = self.lnworker.get_bolt11_invoice( + payment_hash=payment_hash, + amount_msat=lightning_amount_sat * 1000, + message='Submarine swap', + expiry=3600 * 24, + fallback_address=None, + channels=None, + ) + # add payment info to lnworker + self.lnworker.add_payment_info_for_hold_invoice(payment_hash, lightning_amount_sat) + self.lnworker.register_callback_for_hold_invoice(payment_hash, self.hold_invoice_callback, 60*60*24) + redeem_script = construct_script( + WITNESS_TEMPLATE_REVERSE_SWAP, + {1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey} + ) + lockup_address = script_to_p2wsh(redeem_script) + receive_address = self.wallet.get_receiving_address() + swap = SwapData( + redeem_script = bytes.fromhex(redeem_script), + locktime = locktime, + privkey = privkey, + preimage = None, + prepay_hash = None, + lockup_address = lockup_address, + onchain_amount = onchain_amount_sat, + receive_address = receive_address, + lightning_amount = lightning_amount_sat, + is_reverse = is_reverse_for_server, + is_redeemed = False, + funding_txid = None, + spending_txid = None, + ) + swap._payment_hash = payment_hash + self._add_or_reindex_swap(swap) + self.add_lnwatcher_callback(swap) + return swap, payment_hash, invoice + async def normal_swap( self, *, @@ -304,18 +426,6 @@ class SwapManager(Logger): # verify that they are not locking up funds for more than a day if locktime - self.network.get_local_height() >= 144: raise Exception("fswap check failed: locktime too far in future") - # create funding tx - # note: rbf must not decrease payment - # this is taken care of in wallet._is_rbf_allowed_to_touch_tx_output - funding_output = PartialTxOutput.from_address_and_value(lockup_address, onchain_amount) - if tx is None: - tx = self.wallet.create_transaction(outputs=[funding_output], rbf=True, password=password) - else: - dummy_output = PartialTxOutput.from_address_and_value(ln_dummy_address(), expected_onchain_amount_sat) - tx.outputs().remove(dummy_output) - tx.add_outputs([funding_output]) - tx.set_rbf(True) - self.wallet.sign_transaction(tx, password) # save swap data in wallet in case we need a refund receive_address = self.wallet.get_receiving_address() swap = SwapData( @@ -325,7 +435,7 @@ class SwapManager(Logger): preimage = preimage, prepay_hash = None, lockup_address = lockup_address, - onchain_amount = expected_onchain_amount_sat, + onchain_amount = onchain_amount, receive_address = receive_address, lightning_amount = lightning_amount_sat, is_reverse = False, @@ -333,10 +443,28 @@ class SwapManager(Logger): funding_txid = None, spending_txid = None, ) + swap._payment_hash = payment_hash self._add_or_reindex_swap(swap) self.add_lnwatcher_callback(swap) + return await self.start_normal_swap(swap, tx, password) + + @log_exceptions + async def start_normal_swap(self, swap, tx, password): + # create funding tx + # note: rbf must not decrease payment + # this is taken care of in wallet._is_rbf_allowed_to_touch_tx_output + funding_output = PartialTxOutput.from_address_and_value(swap.lockup_address, swap.onchain_amount) + if tx is None: + tx = self.wallet.create_transaction(outputs=[funding_output], rbf=True, password=password) + else: + dummy_output = PartialTxOutput.from_address_and_value(ln_dummy_address(), swap.onchain_amount) + tx.outputs().remove(dummy_output) + tx.add_outputs([funding_output]) + tx.set_rbf(True) + self.wallet.sign_transaction(tx, password) await self.network.broadcast_transaction(tx) - return tx.txid() + swap.funding_txid = tx.txid() + return swap.funding_txid async def reverse_swap( self, @@ -401,7 +529,7 @@ class SwapManager(Logger): raise Exception(f"rswap check failed: onchain_amount is less than what we expected: " f"{onchain_amount} < {expected_onchain_amount_sat}") # verify that we will have enough time to get our tx confirmed - if locktime - self.network.get_local_height() <= 60: + if locktime - self.network.get_local_height() <= MIN_LOCKTIME_DELTA: raise Exception("rswap check failed: locktime too close") # verify invoice preimage_hash lnaddr = self.lnworker._check_invoice(invoice) @@ -435,6 +563,7 @@ class SwapManager(Logger): funding_txid = None, spending_txid = None, ) + swap._payment_hash = preimage_hash self._add_or_reindex_swap(swap) # add callback to lnwatcher self.add_lnwatcher_callback(swap) @@ -459,6 +588,15 @@ class SwapManager(Logger): self._swaps_by_funding_outpoint[swap._funding_prevout] = swap self._swaps_by_lockup_address[swap.lockup_address] = swap + def init_pairs(self) -> None: + """ for server """ + self.percentage = 0.5 + self._min_amount = 20000 + self._max_amount = 10000000 + self.normal_fee = self.get_fee(CLAIM_FEE_SIZE) + self.lockup_fee = self.get_fee(LOCKUP_FEE_SIZE) + self.claim_fee = self.get_fee(CLAIM_FEE_SIZE) + async def get_pairs(self) -> None: """Might raise SwapServerError.""" from .network import Network @@ -479,6 +617,7 @@ class SwapManager(Logger): self.percentage = fees['percentage'] self.normal_fee = fees['minerFees']['baseAsset']['normal'] self.lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup'] + self.claim_fee = fees['minerFees']['baseAsset']['reverse']['claim'] limits = pairs['pairs']['BTC/BTC']['limits'] self._min_amount = limits['minimal'] self._max_amount = limits['maximal'] @@ -650,7 +789,7 @@ class SwapManager(Logger): ) -> PartialTransaction: # FIXME the mining fee should depend on swap.is_reverse. # the txs are not the same size... - amount_sat = txin.value_sats() - cls._get_claim_fee(config=config) + amount_sat = txin.value_sats() - cls._get_fee(size=CLAIM_FEE_SIZE, config=config) if amount_sat < dust_threshold(): raise BelowDustLimit() if swap.is_reverse: # successful reverse swap diff --git a/electrum/tests/regtest.py b/electrum/tests/regtest.py index 53eaa3e11..090425c60 100644 --- a/electrum/tests/regtest.py +++ b/electrum/tests/regtest.py @@ -47,6 +47,9 @@ class TestLightningAB(TestLightning): def test_collaborative_close(self): self.run_shell(['collaborative_close']) + def test_submarine_swap(self): + self.run_shell(['reverse_swap']) + def test_backup(self): self.run_shell(['backup']) diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh index 38df96509..288127816 100755 --- a/electrum/tests/regtest/regtest.sh +++ b/electrum/tests/regtest/regtest.sh @@ -83,10 +83,11 @@ if [[ $1 == "init" ]]; then # alice is funded, bob is listening if [[ $2 == "bob" ]]; then $bob setconfig --offline lightning_listen localhost:9735 - else - echo "funding $2" - $bitcoin_cli sendtoaddress $($agent getunusedaddress -o) 1 + $bob setconfig --offline use_swapserver true + #else fi + echo "funding $2" + $bitcoin_cli sendtoaddress $($agent getunusedaddress -o) 1 fi @@ -170,6 +171,24 @@ if [[ $1 == "collaborative_close" ]]; then fi +if [[ $1 == "reverse_swap" ]]; then + wait_for_balance alice 1 + echo "alice opens channel" + bob_node=$($bob nodeid) + channel=$($alice open_channel $bob_node 0.15) + new_blocks 3 + wait_until_channel_open alice + echo "alice initiates swap" + dryrun=$($alice reverse_swap 0.02 dryrun) + echo $dryrun | jq + onchain_amount=$(echo $dryrun| jq -r ".onchain_amount") + $alice reverse_swap 0.02 $onchain_amount + new_blocks 1 + sleep 1 + new_blocks 1 +fi + + if [[ $1 == "extract_preimage" ]]; then # instead of settling bob will broadcast $bob enable_htlc_settle false From 351ff1e4b568ccbe43edf66fd8078c5f2d4a0660 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 15 Jun 2023 12:04:36 +0200 Subject: [PATCH 2/5] swapserver: support prepayment of fees --- electrum/plugins/swapserver/server.py | 6 +++--- electrum/submarine_swaps.py | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py index 6a2f2ad70..da961af1c 100644 --- a/electrum/plugins/swapserver/server.py +++ b/electrum/plugins/swapserver/server.py @@ -103,7 +103,7 @@ class SwapServer(Logger, EventListener): their_pubkey=bytes.fromhex(request['claimPublicKey']) assert len(payment_hash) == 32 assert len(their_pubkey) == 33 - swap, payment_hash, invoice = sm.add_server_swap( + swap, payment_hash, invoice, prepay_invoice = sm.add_server_swap( lightning_amount_sat=lightning_amount_sat, payment_hash=payment_hash, their_pubkey=their_pubkey @@ -111,7 +111,7 @@ class SwapServer(Logger, EventListener): response = { 'id': payment_hash.hex(), 'invoice': invoice, - 'minerFeeInvoice': None, + 'minerFeeInvoice': prepay_invoice, 'lockupAddress': swap.lockup_address, 'redeemScript': swap.redeem_script.hex(), 'timeoutBlockHeight': swap.locktime, @@ -121,7 +121,7 @@ class SwapServer(Logger, EventListener): their_invoice=request['invoice'] their_pubkey=bytes.fromhex(request['refundPublicKey']) assert len(their_pubkey) == 33 - swap, payment_hash, invoice = sm.add_server_swap( + swap, payment_hash, invoice, prepay_invoice = sm.add_server_swap( invoice=their_invoice, their_pubkey=their_pubkey ) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 32dda9eae..47ef82b54 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -317,19 +317,32 @@ class SwapManager(Logger): {1:ripemd(payment_hash), 4:our_pubkey, 6:locktime, 9:their_pubkey} ) self.wallet.save_invoice(Invoice.from_bech32(invoice)) + prepay_invoice = None else: onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) + prepay_amount_sat = self.get_claim_fee() * 2 + main_amount_sat = lightning_amount_sat - prepay_amount_sat lnaddr, invoice = self.lnworker.get_bolt11_invoice( payment_hash=payment_hash, - amount_msat=lightning_amount_sat * 1000, + amount_msat=main_amount_sat * 1000, message='Submarine swap', expiry=3600 * 24, fallback_address=None, channels=None, ) # add payment info to lnworker - self.lnworker.add_payment_info_for_hold_invoice(payment_hash, lightning_amount_sat) + self.lnworker.add_payment_info_for_hold_invoice(payment_hash, main_amount_sat) self.lnworker.register_callback_for_hold_invoice(payment_hash, self.hold_invoice_callback, 60*60*24) + prepay_hash = self.lnworker.create_payment_info(amount_msat=prepay_amount_sat*1000) + _, prepay_invoice = self.lnworker.get_bolt11_invoice( + payment_hash=prepay_hash, + amount_msat=prepay_amount_sat * 1000, + message='prepay', + expiry=3600 * 24, + fallback_address=None, + channels=None, + ) + self.lnworker.bundle_payments([payment_hash, prepay_hash]) redeem_script = construct_script( WITNESS_TEMPLATE_REVERSE_SWAP, {1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey} @@ -354,7 +367,7 @@ class SwapManager(Logger): swap._payment_hash = payment_hash self._add_or_reindex_swap(swap) self.add_lnwatcher_callback(swap) - return swap, payment_hash, invoice + return swap, payment_hash, invoice, prepay_invoice async def normal_swap( self, From 1411b75584002647931e82972df84f10e339c575 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Wed, 28 Jun 2023 14:23:32 +0200 Subject: [PATCH 3/5] swapserver: add test for refund path --- electrum/commands.py | 8 +++--- electrum/lnworker.py | 5 ++++ electrum/simple_config.py | 1 + electrum/submarine_swaps.py | 24 +++++++++++------ electrum/tests/regtest.py | 7 +++-- electrum/tests/regtest/regtest.sh | 43 ++++++++++++++++++++++++++++--- 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 77838f22e..2d55c4bb0 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1318,22 +1318,22 @@ class Commands: await sm.get_pairs() lightning_amount_sat = satoshis(lightning_amount) onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) - success = None + funding_txid = None elif lightning_amount == 'dryrun': await sm.get_pairs() onchain_amount_sat = satoshis(onchain_amount) lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True) - success = None + funding_txid = None else: lightning_amount_sat = satoshis(lightning_amount) claim_fee = sm.get_claim_fee() onchain_amount_sat = satoshis(onchain_amount) + claim_fee - success = await wallet.lnworker.swap_manager.reverse_swap( + funding_txid = await wallet.lnworker.swap_manager.reverse_swap( lightning_amount_sat=lightning_amount_sat, expected_onchain_amount_sat=onchain_amount_sat, ) return { - 'success': success, + 'funding_txid': funding_txid, 'lightning_amount': format_satoshis(lightning_amount_sat), 'onchain_amount': format_satoshis(onchain_amount_sat), } diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 33acb0d00..f928bd6e2 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -2704,3 +2704,8 @@ class LNWallet(LNWorker): self._channel_backups[bfh(channel_id)] = cb util.trigger_callback('channels_updated', self.wallet) self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address()) + + def fail_trampoline_forwarding(self, payment_key): + """ use this to fail htlcs received for hold invoices""" + e = OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') + self.trampoline_forwarding_failures[payment_key] = e diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 92ed68396..0ccfafb14 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -967,6 +967,7 @@ class SimpleConfig(Logger): SWAPSERVER_URL_MAINNET = ConfigVar('swapserver_url_mainnet', default='https://swaps.electrum.org/api', type_=str) SWAPSERVER_URL_TESTNET = ConfigVar('swapserver_url_testnet', default='https://swaps.electrum.org/testnet', type_=str) SWAPSERVER_URL_REGTEST = ConfigVar('swapserver_url_regtest', default='http://localhost:5455/api', type_=str) + TEST_SWAPSERVER_REFUND = ConfigVar('test_swapserver_refund', default=False, type_=bool) # connect to remote WT WATCHTOWER_CLIENT_ENABLED = ConfigVar('use_watchtower', default=False, type_=bool) WATCHTOWER_CLIENT_URL = ConfigVar('watchtower_url', default=None, type_=str) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 47ef82b54..122b2e07f 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -232,11 +232,18 @@ class SwapManager(Logger): # tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) preimage = tx.inputs()[0].witness_elements()[1] - assert swap.payment_hash == sha256(preimage) - swap.preimage = preimage - self.logger.info(f'found preimage: {preimage.hex()}') - self.lnworker.preimages[swap.payment_hash.hex()] = preimage.hex() - # note: we must check the payment secret before we broadcast the funding tx + if sha256(preimage) == swap.payment_hash: + swap.preimage = preimage + self.logger.info(f'found preimage: {preimage.hex()}') + self.lnworker.preimages[swap.payment_hash.hex()] = preimage.hex() + # note: we must check the payment secret before we broadcast the funding tx + else: + # refund tx + if spent_height > 0: + self.logger.info(f'found confirmed refund') + payment_secret = self.lnworker.get_payment_secret(swap.payment_hash) + payment_key = swap.payment_hash + payment_secret + self.lnworker.fail_trampoline_forwarding(payment_key) if spent_height > 0: if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY: @@ -257,6 +264,8 @@ class SwapManager(Logger): if swap.is_reverse and swap.preimage is None: self.logger.info('preimage not available yet') continue + if swap.is_reverse and self.network.config.TEST_SWAPSERVER_REFUND: + continue try: tx = self._create_and_sign_claim_tx(txin=txin, swap=swap, config=self.wallet.config) except BelowDustLimit: @@ -586,13 +595,12 @@ class SwapManager(Logger): asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice, attempts=10)) # we return if we detect funding async def wait_for_funding(swap): - while swap.spending_txid is None: + while swap.funding_txid is None: await asyncio.sleep(1) # initiate main payment tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice, attempts=10, channels=channels)), asyncio.create_task(wait_for_funding(swap))] await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - success = swap.spending_txid is not None - return success + return swap.funding_txid def _add_or_reindex_swap(self, swap: SwapData) -> None: if swap.payment_hash.hex() not in self.swaps: diff --git a/electrum/tests/regtest.py b/electrum/tests/regtest.py index 090425c60..263b79663 100644 --- a/electrum/tests/regtest.py +++ b/electrum/tests/regtest.py @@ -47,8 +47,11 @@ class TestLightningAB(TestLightning): def test_collaborative_close(self): self.run_shell(['collaborative_close']) - def test_submarine_swap(self): - self.run_shell(['reverse_swap']) + def test_swapserver_success(self): + self.run_shell(['swapserver_success']) + + def test_swapserver_refund(self): + self.run_shell(['swapserver_refund']) def test_backup(self): self.run_shell(['backup']) diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh index 288127816..363d7c585 100755 --- a/electrum/tests/regtest/regtest.sh +++ b/electrum/tests/regtest/regtest.sh @@ -15,6 +15,19 @@ function new_blocks() $bitcoin_cli generatetoaddress $1 $($bitcoin_cli getnewaddress) > /dev/null } +function wait_until_htlcs_settled() +{ + msg="wait until $1's local_unsettled_sent is zero" + cmd="./run_electrum --regtest -D /tmp/$1" + while unsettled=$($alice list_channels | jq '.[] | .local_unsettled_sent') && [ $unsettled != "0" ]; do + sleep 1 + msg="$msg." + printf "$msg\r" + done + printf "\n" +} + + function wait_for_balance() { msg="wait until $1's balance reaches $2" @@ -171,7 +184,7 @@ if [[ $1 == "collaborative_close" ]]; then fi -if [[ $1 == "reverse_swap" ]]; then +if [[ $1 == "swapserver_success" ]]; then wait_for_balance alice 1 echo "alice opens channel" bob_node=$($bob nodeid) @@ -180,12 +193,34 @@ if [[ $1 == "reverse_swap" ]]; then wait_until_channel_open alice echo "alice initiates swap" dryrun=$($alice reverse_swap 0.02 dryrun) - echo $dryrun | jq onchain_amount=$(echo $dryrun| jq -r ".onchain_amount") - $alice reverse_swap 0.02 $onchain_amount + swap=$($alice reverse_swap 0.02 $onchain_amount) + echo $swap | jq + funding_txid=$(echo $swap| jq -r ".funding_txid") new_blocks 1 - sleep 1 + wait_until_spent $funding_txid 0 + wait_until_htlcs_settled alice +fi + + +if [[ $1 == "swapserver_refund" ]]; then + $alice setconfig test_swapserver_refund true + wait_for_balance alice 1 + echo "alice opens channel" + bob_node=$($bob nodeid) + channel=$($alice open_channel $bob_node 0.15) + new_blocks 3 + wait_until_channel_open alice + echo "alice initiates swap" + dryrun=$($alice reverse_swap 0.02 dryrun) + onchain_amount=$(echo $dryrun| jq -r ".onchain_amount") + swap=$($alice reverse_swap 0.02 $onchain_amount) + echo $swap | jq + funding_txid=$(echo $swap| jq -r ".funding_txid") + new_blocks 140 + wait_until_spent $funding_txid 0 new_blocks 1 + wait_until_htlcs_settled alice fi From 69a1242ea8e7a168c6162d78e05589735d67c9af Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 25 Jul 2023 14:52:47 +0200 Subject: [PATCH 4/5] restructure submarine_swaps._claim_swap --- electrum/submarine_swaps.py | 67 +++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 122b2e07f..bd4074dcb 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -215,21 +215,25 @@ class SwapManager(Logger): self._add_or_reindex_swap(swap) # to update _swaps_by_funding_outpoint funding_conf = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex()).conf spent_height = txin.spent_height - - if swap.is_reverse and swap.preimage is None: - if funding_conf <= 0: - continue - preimage = self.lnworker.get_preimage(swap.payment_hash) - if preimage is None: - self.invoices_to_pay.add(swap.payment_hash.hex()) - continue - swap.preimage = preimage - if spent_height is not None: swap.spending_txid = txin.spent_txid - if not swap.is_reverse and swap.preimage is None: - # we need to extract the preimage, add it to lnwatcher - # + if spent_height > 0: + if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY: + self.logger.info(f'stop watching swap {swap.lockup_address}') + self.lnwatcher.remove_callback(swap.lockup_address) + swap.is_redeemed = True + elif spent_height == TX_HEIGHT_LOCAL: + if txin.block_height > 0 or self.wallet.config.LIGHTNING_ALLOW_INSTANT_SWAPS: + tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) + self.logger.info(f'broadcasting tx {txin.spent_txid}') + await self.network.broadcast_transaction(tx) + else: + # spending tx is in mempool + pass + + if not swap.is_reverse: + if swap.preimage is None and spent_height is not None: + # extract the preimage, add it to lnwatcher tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) preimage = tx.inputs()[0].witness_elements()[1] if sha256(preimage) == swap.payment_hash: @@ -245,26 +249,23 @@ class SwapManager(Logger): payment_key = swap.payment_hash + payment_secret self.lnworker.fail_trampoline_forwarding(payment_key) - if spent_height > 0: - if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY: - self.logger.info(f'stop watching swap {swap.lockup_address}') - self.lnwatcher.remove_callback(swap.lockup_address) - swap.is_redeemed = True - elif spent_height == TX_HEIGHT_LOCAL: - if txin.block_height > 0 or self.wallet.config.LIGHTNING_ALLOW_INSTANT_SWAPS: - tx = self.lnwatcher.adb.get_transaction(txin.spent_txid) - self.logger.info(f'broadcasting tx {txin.spent_txid}') - await self.network.broadcast_transaction(tx) - # already in mempool - continue - if not swap.is_reverse and delta < 0: - # too early for refund - return - # - if swap.is_reverse and swap.preimage is None: - self.logger.info('preimage not available yet') - continue - if swap.is_reverse and self.network.config.TEST_SWAPSERVER_REFUND: + if delta < 0: + # too early for refund + continue + else: + if swap.preimage is None: + if funding_conf <= 0: + continue + preimage = self.lnworker.get_preimage(swap.payment_hash) + if preimage is None: + self.invoices_to_pay.add(swap.payment_hash.hex()) + continue + swap.preimage = preimage + if self.network.config.TEST_SWAPSERVER_REFUND: + # for testing: do not create claim tx + continue + + if spent_height is not None: continue try: tx = self._create_and_sign_claim_tx(txin=txin, swap=swap, config=self.wallet.config) From 1b14692f304e10c2f50468ccb79cad7a3ebaf8ee Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 25 Jul 2023 15:28:28 +0200 Subject: [PATCH 5/5] swapserver: cleanup, add description --- electrum/plugins/swapserver/__init__.py | 11 ++++++++++- electrum/plugins/swapserver/server.py | 2 -- electrum/tests/regtest/regtest.sh | 1 - 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/electrum/plugins/swapserver/__init__.py b/electrum/plugins/swapserver/__init__.py index 867fb643e..d757b7956 100644 --- a/electrum/plugins/swapserver/__init__.py +++ b/electrum/plugins/swapserver/__init__.py @@ -1,6 +1,15 @@ from electrum.i18n import _ fullname = _('SwapServer') -description = '' +description = """ +Submarine swap server for an Electrum daemon. + +Example setup: + + electrum -o setconfig use_swapserver True + electrum -o setconfig swapserver_address localhost:5455 + electrum daemon -v + +""" available_for = ['qt', 'cmdline'] diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py index da961af1c..d5fee453b 100644 --- a/electrum/plugins/swapserver/server.py +++ b/electrum/plugins/swapserver/server.py @@ -38,7 +38,6 @@ class SwapServer(Logger, EventListener): @ignore_exceptions @log_exceptions async def run(self): - self.root = '/root' app = web.Application() app.add_routes([web.get('/api/getpairs', self.get_pairs)]) app.add_routes([web.post('/api/createswap', self.create_swap)]) @@ -57,7 +56,6 @@ class SwapServer(Logger, EventListener): "warnings": [], "pairs": { "BTC/BTC": { - "hash": "dfe692a026d6964601bfd79703611af333d1d5aa49ef5fedd288f5a620fced60", "rate": 1, "limits": { "maximal": sm._max_amount, diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh index 363d7c585..0bfc65a1f 100755 --- a/electrum/tests/regtest/regtest.sh +++ b/electrum/tests/regtest/regtest.sh @@ -97,7 +97,6 @@ if [[ $1 == "init" ]]; then if [[ $2 == "bob" ]]; then $bob setconfig --offline lightning_listen localhost:9735 $bob setconfig --offline use_swapserver true - #else fi echo "funding $2" $bitcoin_cli sendtoaddress $($agent getunusedaddress -o) 1