diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 41447c63e877..1306df500714 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1865,6 +1865,7 @@ async def pay_invoice( full_path: LNPaymentPath = None, channels: Optional[Sequence[Channel]] = None, # my own direct channels budget: Optional[PaymentFeeBudget] = None, # to limit max fee + probe_only: bool = False, # checks if a route for the payment exists, without actually sending the payment ) -> Tuple[bool, List[HtlcLog]]: """Attempt to pay a Lightning invoice (find routes, do MPP, send HTLCs). @@ -1909,7 +1910,7 @@ async def pay_invoice( attempts = 30 success = False try: - await self.pay_to_node( + probe_only_result = await self.pay_to_node( node_pubkey=invoice_pubkey, payment_hash=payment_hash, payment_secret=payment_secret, @@ -1921,7 +1922,12 @@ async def pay_invoice( full_path=full_path, channels=channels, budget=budget, + probe_only=probe_only, ) + + if probe_only: + return probe_only_result, [] + success = True except PaymentFailure as e: self.logger.info(f'payment failure: {e!r}') @@ -1956,10 +1962,13 @@ async def pay_to_node( budget: PaymentFeeBudget, channels: Optional[Sequence[Channel]] = None, fw_payment_key: str = None, # for forwarding - ) -> None: + probe_only: bool = False, # checks if a route for the payment exists, without actually sending the payment + ) -> bool: """ Can raise PaymentFailure, ChannelDBNotLoaded, or OnionRoutingFailure (if forwarding trampoline). + + returns bool: In case of probe_only, true/false denoting if a route to the destination exists. """ assert budget @@ -2018,8 +2027,15 @@ async def pay_to_node( channels=channels, budget=budget._replace(fee_msat=remaining_fee_budget_msat), ) + + can_route = False + # 2. send htlcs async for sent_htlc_info, cltv_delta, trampoline_onion in routes: + if probe_only: + can_route = True + continue + await self.pay_to_route( paysession=paysession, sent_htlc_info=sent_htlc_info, @@ -2027,6 +2043,9 @@ async def pay_to_node( trampoline_onion=trampoline_onion, fw_payment_key=fw_payment_key, ) + + if probe_only: + return can_route # invoice_status is triggered in self.set_invoice_status when it actually changes. # It is also triggered here to update progress for a lightning payment in the GUI # (e.g. attempt counter) @@ -2050,11 +2069,18 @@ async def pay_to_node( # max attempts or timeout if (attempts is not None and len(log) >= attempts) or (attempts is None and time.time() - paysession.start_time > self.PAYMENT_TIMEOUT): raise PaymentFailure('Giving up after %d attempts'%len(log)) + + return False except PaymentSuccess: pass + except Exception: + if probe_only: + return False + + raise finally: paysession.is_active = False - if paysession.can_be_deleted(): + if probe_only or paysession.can_be_deleted(): self._paysessions.pop(payment_key) paysession.logger.info(f"pay_to_node ending session for RHASH={payment_hash.hex()}") diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py index 7d645e7e8f1c..9daef5ba05f3 100644 --- a/electrum/plugins/swapserver/server.py +++ b/electrum/plugins/swapserver/server.py @@ -132,5 +132,5 @@ async def create_normal_swap(self, r): async def create_swap(self, r): request = await r.json() - response = self.sm.server_create_swap(request) + response = await self.sm.server_create_swap(request) return web.json_response(response) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index f83d653f9d9d..1b13e1fc6f29 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -37,7 +37,7 @@ run_sync_function_on_asyncio_thread, trigger_callback, NoDynamicFeeEstimates, UserFacingException, ) from . import lnutil -from .lnutil import hex_to_bytes, REDEEM_AFTER_DOUBLE_SPENT_DELAY, Keypair +from .lnutil import hex_to_bytes, REDEEM_AFTER_DOUBLE_SPENT_DELAY, Keypair, NoPathFound from .bolt11 import decode_bolt11_invoice from .stored_dict import StoredObject, stored_at from . import constants @@ -79,7 +79,7 @@ # different length which would still allow for claiming the onchain # coins but the invoice couldn't be settled -# Unified witness-script for all swaps. Historically with Boltz-backend, this was the reverse-swap script. +# Witness-script for all reverse swaps and the new normal swap following the new flow. Historically with Boltz-backend, this was the reverse-swap script. WITNESS_TEMPLATE_SWAP = [ opcodes.OP_SIZE, OPPushDataGeneric(None), # idx 1. length of preimage @@ -99,6 +99,24 @@ opcodes.OP_CHECKSIG ] +# Witness-script for old forward swap flow. +WITNESS_TEMPLATE_SWAP_V1 = [ + opcodes.OP_HASH160, + OPPushDataGeneric(lambda x: x == 20), # idx 1. payment_hash + opcodes.OP_EQUAL, + opcodes.OP_IF, + OPPushDataPubkey, # idx 4. server_pubkey + opcodes.OP_ELSE, + OPPushDataGeneric(None), # idx 6. locktime + opcodes.OP_CHECKLOCKTIMEVERIFY, + opcodes.OP_DROP, + OPPushDataPubkey, # idx 9. client_pubkey + opcodes.OP_ENDIF, + opcodes.OP_CHECKSIG +] + +CAP_FORWARD_V1 = "forwardv1" # supports forward swaps with v1 flow + def _check_swap_scriptcode( *, @@ -150,6 +168,21 @@ def _construct_swap_scriptcode( values={1: 32, 5: ripemd(payment_hash), 7: claim_pubkey, 10: locktime, 13: refund_pubkey} ) +def _construct_swap_scriptcode_v1( + payment_hash: bytes, + locktime: int, + server_pubkey: bytes, + client_pubkey: bytes, +) -> bytes: + assert isinstance(payment_hash, bytes) and len(payment_hash) == 32 + assert isinstance(locktime, int) and (0 <= locktime <= bitcoin.NLOCKTIME_BLOCKHEIGHT_MAX) + assert isinstance(server_pubkey, bytes) and len(server_pubkey) == 33 + assert isinstance(client_pubkey, bytes) and len(client_pubkey) == 33 + return construct_script( + WITNESS_TEMPLATE_SWAP_V1, + values={1: ripemd(payment_hash), 4: server_pubkey, 6: locktime, 9: client_pubkey} + ) + class SwapServerError(Exception): def __init__(self, message=None): @@ -781,6 +814,55 @@ def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) lightning_amount_sat=lightning_amount_sat) return swap + async def create_reverse_swap_v1(self, *, invoice: str, refund_pubkey: bytes) -> SwapData: + """ server method for v1 workflow: + + - User generates an LN invoice with RHASH, and knows preimage. + - User creates on-chain output locked to RHASH. + - Server pays LN invoice. By completing payment, user reveals preimage. + - Server spends the on-chain output using preimage. + """ + + height = self.network.get_local_height() + locktime = height + LOCKTIME_DELTA_REFUND + if self.network.blockchain().is_tip_stale(): + raise Exception("our blockchain tip is stale") + lnaddr = decode_bolt11_invoice(invoice) + payment_hash = lnaddr.paymenthash + lightning_amount_sat = int(lnaddr.get_amount_sat()) # should return int + + privkey = os.urandom(32) + our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) + onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False) + + if not onchain_amount_sat: + raise Exception("no onchain amount") + + success, log = await self.lnworker.pay_invoice( + Invoice.from_bech32(invoice), + probe_only=True + ) + + if not success: + raise NoPathFound("no LN route to pay the invoice found") + + self.logger.info(f'client requested forward swap; lightning_amount_sat={lightning_amount_sat}, ' + f'onchain_amount_sat={onchain_amount_sat}, height={height}, locktime={locktime}') + redeem_script = _construct_swap_scriptcode_v1( + payment_hash=payment_hash, + locktime=locktime, + server_pubkey=our_pubkey, + client_pubkey=refund_pubkey, + ) + swap = self.add_reverse_swap_v1( + redeem_script=redeem_script, + locktime=locktime, + privkey=privkey, + payment_hash=payment_hash, + onchain_amount_sat=onchain_amount_sat, + lightning_amount_sat=lightning_amount_sat) + return swap + def add_reverse_swap( self, *, @@ -827,6 +909,40 @@ def add_reverse_swap( self.add_lnwatcher_callback(swap) return swap + def add_reverse_swap_v1( + self, + *, + redeem_script: bytes, + locktime: int, # onchain + privkey: bytes, + lightning_amount_sat: int, + onchain_amount_sat: int, + payment_hash: bytes + ) -> SwapData: + if payment_hash.hex() in self._swaps: + raise Exception("payment_hash already in use") + + lockup_address = script_to_p2wsh(redeem_script) + swap = SwapData( + redeem_script=redeem_script, + locktime=locktime, + privkey=privkey, + preimage=None, + prepay_hash=None, + lockup_address=lockup_address, + onchain_amount=onchain_amount_sat, + claim_to_output=None, + lightning_amount=lightning_amount_sat, + is_reverse=True, + is_redeemed=False, + funding_txid=None, + spending_txid=None, + ) + swap._payment_hash = payment_hash + self._add_or_reindex_swap(swap, is_new=True) + self.add_lnwatcher_callback(swap) + return swap + def server_add_swap_invoice(self, request: dict) -> dict: """ server method. (client-forward-swap phase2) @@ -856,6 +972,37 @@ def server_add_swap_invoice(self, request: dict) -> dict: self.wallet.save_invoice(invoice) return {} + def server_add_swap_invoice_v1(self, invoice: str, refundPublicKey: str) -> dict: + """ server method. + (client-forward-swap v1) + """ + invoice = Invoice.from_bech32(invoice) + key = invoice.rhash + payment_hash = bytes.fromhex(key) + their_pubkey = bytes.fromhex(refundPublicKey) + with self.swaps_lock: + assert key in self._swaps + swap = self._swaps[key] + self.logger.info(f'server_add_swap_invoice: found swap is: {swap}') + + assert swap.lightning_amount == int(invoice.get_amount_sat()) + assert swap.is_reverse is True + assert swap.spending_txid is None + # check their_pubkey by recalculating redeem_script + our_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True) + redeem_script = _construct_swap_scriptcode_v1( + payment_hash=payment_hash, locktime=swap.locktime, server_pubkey=our_pubkey, client_pubkey=their_pubkey, + ) + self.logger.info(f'server_add_swap_invoice: redeem scripts:') + self.logger.info(f'server_add_swap_invoice: - {redeem_script}') + self.logger.info(f'server_add_swap_invoice: - {swap.redeem_script}') + assert swap.redeem_script == redeem_script + assert key not in self.invoices_to_pay + + assert self.wallet.get_invoice(invoice.get_id()) is None + self.wallet.save_invoice(invoice) + return {} + async def normal_swap( self, *, @@ -1438,13 +1585,12 @@ def server_create_normal_swap(self, request): } return response - def server_create_swap(self, request): - # reverse for client, forward for server - # requesting a normal swap (old protocol) will raise an exception + async def server_create_swap(self, request): #request = await r.json() req_type = request['type'] assert request['pairId'] == 'BTC/BTC' if req_type == 'reversesubmarine': + # reverse for client, forward for server lightning_amount_sat=request['invoiceAmount'] payment_hash=bytes.fromhex(request['preimageHash']) their_pubkey=bytes.fromhex(request['claimPublicKey']) @@ -1465,7 +1611,26 @@ def server_create_swap(self, request): "onchainAmount": swap.onchain_amount, } elif req_type == 'submarine': - raise Exception('Deprecated API. Please upgrade your version of Electrum') + # client is doing a normal swap (old protocol) + their_invoice = request['invoice'] + refund_pubkey = bytes.fromhex(request['refundPublicKey']) + assert len(refund_pubkey) == 33 + + swap = await self.create_reverse_swap_v1( + invoice=their_invoice, + refund_pubkey=refund_pubkey + ) + + self.server_add_swap_invoice_v1(request['invoice'], request['refundPublicKey']) + + response = { + "id": swap.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 response @@ -1785,6 +1950,7 @@ async def publish_offer(self, sm: 'SwapManager') -> None: 'max_reverse_amount': sm._max_reverse, 'relays': sm.config.NOSTR_RELAYS, 'pow_nonce': hex(sm.config.SWAPSERVER_ANN_POW_NONCE), + 'capabilities': [ CAP_FORWARD_V1 ], # announce support for the old forward swap protocol flow } # the first value of a single letter tag is indexed and can be filtered for tags = [['d', f'electrum-swapserver-{self.NOSTR_EVENT_VERSION}'], @@ -1985,8 +2151,8 @@ async def _handle_requests(self) -> None: self.logger.info(f'handle_request: id={event_id} {method} {request}') if method == 'addswapinvoice': # client-forward-swap phase2 r = self.sm.server_add_swap_invoice(request) - elif method == 'createswap': # client-reverse-swap - r = self.sm.server_create_swap(request) + elif method == 'createswap': # v1: client-forward-swap & client-reverse-swap + r = await self.sm.server_create_swap(request) elif method == 'createnormalswap': # client-forward-swap phase1 r = self.sm.server_create_normal_swap(request) else: