diff --git a/electrum/gui/qt/receive_tab.py b/electrum/gui/qt/receive_tab.py index 74377b319f6b..fe60c56989bb 100644 --- a/electrum/gui/qt/receive_tab.py +++ b/electrum/gui/qt/receive_tab.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from .main_window import ElectrumWindow + from electrum.wallet import Request class ReceiveTab(QWidget, MessageBoxMixin, Logger): @@ -101,6 +102,9 @@ def __init__(self, window: 'ElectrumWindow'): self.receive_zeroconf_button = QPushButton(_('Accept')) self.receive_zeroconf_button.clicked.connect(self.on_accept_zeroconf) + self.previous_request = None # type: Optional['Request'] + self.confirmed_zeroconf_for_this_request = False # type: bool + def on_receive_rebalance(): if self.receive_rebalance_button.suggestion: chan1, chan2, delta = self.receive_rebalance_button.suggestion @@ -221,7 +225,7 @@ def toggle_receive_qr(self): def update_receive_widgets(self): b = self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE - self.receive_widget.update_visibility(b) + self.receive_widget.update_visibility(b, bool(self.receive_help_text.text())) def update_current_request(self): if len(self.request_list.selectionModel().selectedRows(0)) > 1: @@ -229,6 +233,9 @@ def update_current_request(self): else: key = self.request_list.get_current_key() req = self.wallet.get_request(key) if key else None + if req != self.previous_request: + self.previous_request = req + self.confirmed_zeroconf_for_this_request = False if req is None: self.receive_e.setText('') self.addr = self.URI = self.lnaddr = '' @@ -243,7 +250,7 @@ def update_current_request(self): self.ln_help = help_texts.ln_help can_rebalance = help_texts.can_rebalance() can_swap = help_texts.can_swap() - can_zeroconf = help_texts.can_zeroconf() + can_zeroconf = help_texts.can_zeroconf() if not self.confirmed_zeroconf_for_this_request else False self.receive_rebalance_button.suggestion = help_texts.ln_rebalance_suggestion self.receive_swap_button.suggestion = help_texts.ln_swap_suggestion self.receive_rebalance_button.setVisible(can_rebalance) @@ -253,25 +260,26 @@ def update_current_request(self): self.receive_zeroconf_button.setVisible(can_zeroconf) self.receive_zeroconf_button.setEnabled(can_zeroconf) text, data, help_text, title = self.get_tab_data() + if self.confirmed_zeroconf_for_this_request and help_texts.can_zeroconf(): + help_text = '' + # set help before receive_e so we don't flicker from qr to help + self.receive_help_text.setText(help_text) self.receive_e.setText(text) self.receive_qr.setData(data) - self.receive_help_text.setText(help_text) for w in [self.receive_e, self.receive_qr]: w.setEnabled(bool(text) and (not help_text or can_zeroconf)) w.setToolTip(help_text) # macOS hack (similar to #4777) self.receive_e.repaint() # always show - if can_zeroconf: - # show the help message if zeroconf so user can first accept it and still sees the invoice - # after accepting - self.receive_widget.show_help() self.receive_widget.setVisible(True) self.toggle_qr_button.setEnabled(True) self.update_receive_qr_window() def on_accept_zeroconf(self): self.receive_zeroconf_button.setVisible(False) + self.confirmed_zeroconf_for_this_request = True + self.receive_help_text.setText('') self.update_receive_widgets() def get_tab_data(self): @@ -386,8 +394,8 @@ def __init__(self, receive_tab: 'ReceiveTab', textedit: QWidget, qr: QWidget, he self.setLayout(vbox) - def update_visibility(self, is_qr): - if str(self.textedit.toPlainText()): + def update_visibility(self, is_qr: bool, show_help: bool): + if str(self.textedit.toPlainText()) and not show_help: self.help_widget.setVisible(False) self.textedit.setVisible(not is_qr) self.qr.setVisible(is_qr) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index bf9b80717780..3db9173c3466 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -41,7 +41,7 @@ from .crypto import sha256, sha256d from .transaction import Transaction, PartialTransaction, TxInput, Sighash from .logging import Logger -from .lntransport import LNPeerAddr +from .lntransport import LNPeerAddr, extract_nodeid, ConnStringFormatError from .lnonion import OnionRoutingFailure from . import lnutil from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, ChannelConstraints, @@ -59,7 +59,7 @@ from .lnsweep import sweep_their_ctx_to_remote_backup from .lnhtlc import HTLCManager from .lnmsg import encode_msg, decode_msg -from .address_synchronizer import TX_HEIGHT_LOCAL +from .address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONFIRMED from .lnutil import CHANNEL_OPENING_TIMEOUT_BLOCKS, CHANNEL_OPENING_TIMEOUT_SEC from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnutil import format_short_channel_id @@ -224,6 +224,7 @@ def get_state(self) -> ChannelState: return self._state def is_funded(self) -> bool: + # NOTE: also true for unfunded zeroconf channels (OPEN > FUNDED) return self.get_state() >= ChannelState.FUNDED def is_open(self) -> bool: @@ -375,26 +376,33 @@ def update_unfunded_state(self) -> None: self.logger.warning(f"dropping incoming channel, funding tx not found in mempool") self.lnworker.remove_channel(self.channel_id) elif self.is_zeroconf() and state in [ChannelState.OPEN, ChannelState.CLOSING, ChannelState.FORCE_CLOSING]: - chan_age = now() - self.storage['init_timestamp'] # handling zeroconf channels with no funding tx, can happen if broadcasting fails on LSP side - # or if the LSP did double spent the funding tx/never published it intentionally - # only remove a timed out OPEN channel if we are connected to the network to prevent removing it if we went - # offline before seeing the funding tx - if state != ChannelState.OPEN or chan_age > ZEROCONF_TIMEOUT and self.lnworker.network.is_connected(): - # we delete the channel if its in closing state (either initiated manually by client or by LSP on failure) - # or if the channel is not seeing any funding tx after 10 minutes to prevent further usage (limit damage) - self.set_state(ChannelState.REDEEMED, force=True) - local_balance_sat = int(self.balance(LOCAL) // 1000) - if local_balance_sat > 0: + # or if the LSP did double spent the funding tx/never published it intentionally. + if not self.lnworker.wallet.is_up_to_date() or not self.lnworker.network \ + or self.lnworker.network.blockchain().is_tip_stale(): + # ensure we are up to date to prevent accidentally dropping a channel that is funded + return + chan_age = now() - self.storage['init_timestamp'] + if chan_age > ZEROCONF_TIMEOUT: + # freeze the channel to avoid receiving even more into this unfunded channel. + # NOTE: we don't reject htlcs arriving on frozen channels, this only really + # stops us from including the channel in invoice routing hints. + if isinstance(self, Channel): + self.set_frozen_for_receiving(True) + + # un-trust the LSP so the user doesn't accept another channel from the same provider + # compare the node id's as the user might already have changed to another one + if self.node_id == self.lnworker.trusted_zeroconf_node_id: + self.lnworker.config.ZEROCONF_TRUSTED_NODE = '' + + if self.has_funding_timed_out(): + self.lnworker.remove_channel(self.channel_id) + # remove remaining local transactions from the wallet, this will also remove child transactions (closing tx) + # self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid) + if (local_balance_sat := int(self.balance(LOCAL) // 1000)) > 0: self.logger.warning( f"we may have been scammed out of {local_balance_sat} sat by our " f"JIT provider: {self.lnworker.config.ZEROCONF_TRUSTED_NODE} or he didn't use our preimage") - self.lnworker.config.ZEROCONF_TRUSTED_NODE = '' - # FIXME this is broken: lnwatcher.unwatch_channel does not exist - self.lnworker.lnwatcher.unwatch_channel(self.get_funding_address(), self.funding_outpoint.to_str()) - # remove remaining local transactions from the wallet, this will also remove child transactions (closing tx) - self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid) - self.lnworker.remove_channel(self.channel_id) def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) -> None: self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp) @@ -420,6 +428,9 @@ def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) # remove zeroconf flag as we are now confirmed, this is to prevent an electrum server causing # us to remove a channel later in update_unfunded_state by omitting its funding tx self.remove_zeroconf_flag() + # unfreeze in case it was frozen in update_unfunded_state + if isinstance(self, Channel): + self.set_frozen_for_receiving(False) def update_closed_state(self, *, funding_txid: str, funding_height: TxMinedInfo, closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None: @@ -843,7 +854,8 @@ def can_be_deleted(self) -> bool: return self.is_redeemed() def has_funding_timed_out(self): - if self.is_initiator() or self.is_funded(): + funding_height = self.get_funding_height() + if self.is_initiator() or funding_height and funding_height[1] > TX_HEIGHT_UNCONFIRMED: return False if self.lnworker.network.blockchain().is_tip_stale() or not self.lnworker.wallet.is_up_to_date(): return False diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index fc5d99b8796d..bf8ec005ac37 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -101,6 +101,11 @@ def __init__( self.pubkey = pubkey # remote pubkey self.privkey = self.transport.privkey # local privkey self.features = self.lnworker.features # type: LnFeatures + if lnworker == lnworker.network.lngossip or \ + lnworker.config.ZEROCONF_TRUSTED_NODE and pubkey != lnworker.trusted_zeroconf_node_id: + # don't signal zeroconf support if we are client (a trusted node is configured), + # and Peer is not our trusted node + self.features &= ~LnFeatures.OPTION_ZEROCONF_OPT self.their_features = LnFeatures(0) # type: LnFeatures self.node_ids = [self.pubkey, privkey_to_pubkey(self.privkey)] assert self.node_ids[0] != self.node_ids[1] @@ -1260,13 +1265,16 @@ async def on_open_channel(self, payload): # store the temp id now, so that it is recognized for e.g. 'error' messages self.temp_id_to_id[temp_chan_id] = None self._cleanup_temp_channelids() - channel_opening_fee_tlv = open_channel_tlvs.get('channel_opening_fee', {}) - channel_opening_fee = channel_opening_fee_tlv.get('channel_opening_fee') - if channel_opening_fee: - # todo check that the fee is reasonable + channel_opening_fee = open_channel_tlvs.get('channel_opening_fee', {}).get('channel_opening_fee') + if channel_opening_fee: # just-in-time channel opening assert is_zeroconf - self.logger.info(f"just-in-time opening fee: {channel_opening_fee} msat") - pass + # the opening fee consists of the fee configured by the LSP + mining fees of the funding tx + channel_opening_fee_sat = channel_opening_fee // 1000 + if channel_opening_fee_sat > funding_sat * 0.1: + # TODO: if there will be some discovery channel where LSPs announce their fees + # we should compare against the fees they announced here. + raise Exception(f"{channel_opening_fee_sat=} exceeding fee limit, rejecting channel ({funding_sat=})") + self.logger.info(f"just-in-time channel: {channel_opening_fee_sat=}") if channel_type & ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX: multisig_funding_keypair = lnutil.derive_multisig_funding_key_if_they_opened( diff --git a/electrum/lnworker.py b/electrum/lnworker.py index b532e5a194ab..f0435d1eb9ae 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -74,7 +74,7 @@ OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, RecvMPPResolution, ReceivedMPPStatus, ReceivedMPPHtlc, - PaymentSuccess, ChannelType, LocalConfig, Keypair, + PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, ) from .lnonion import ( decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, @@ -1010,7 +1010,7 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No features = LNWALLET_FEATURES if self.config.ENABLE_ANCHOR_CHANNELS: features |= LnFeatures.OPTION_ANCHORS_ZERO_FEE_HTLC_OPT - if self.config.ACCEPT_ZEROCONF_CHANNELS: + if self.config.OPEN_ZEROCONF_CHANNELS: features |= LnFeatures.OPTION_ZEROCONF_OPT if self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS or self.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: features |= LnFeatures.OPTION_ONION_MESSAGE_OPT @@ -1485,34 +1485,42 @@ async def open_channel_just_in_time( payment_hash: bytes, next_onion: OnionPacket, ) -> str: + assert self.config.OPEN_ZEROCONF_CHANNELS # if an exception is raised during negotiation, we raise an OnionRoutingFailure. # this will cancel the incoming HTLC + next_chan: Optional[Channel] = None # prevent settling the htlc until the channel opening was successful so we can fail it if needed self.dont_settle_htlcs[payment_hash.hex()] = None try: - funding_sat = 2 * (next_amount_msat_htlc // 1000) # try to fully spend htlcs + assert self.config.ZEROCONF_CHANNEL_SIZE_PERCENT >= 120, "ZEROCONF_CHANNEL_SIZE_PERCENT below min of 120%" + assert self.config.ZEROCONF_OPENING_FEE_PPM >= 0, f"invalid {self.config.ZEROCONF_OPENING_FEE_PPM=}" + funding_sat = (self.config.ZEROCONF_CHANNEL_SIZE_PERCENT * (next_amount_msat_htlc // 1000)) // 100 password = self.wallet.get_unlocked_password() if self.wallet.has_password() else None - channel_opening_fee = next_amount_msat_htlc // 100 - if channel_opening_fee // 1000 < self.config.ZEROCONF_MIN_OPENING_FEE: - self.logger.info(f'rejecting JIT channel: payment too low') + channel_opening_base_fee_msat = (next_amount_msat_htlc * self.config.ZEROCONF_OPENING_FEE_PPM) // 1_000_000 + if channel_opening_base_fee_msat // 1000 < self.config.ZEROCONF_MIN_OPENING_FEE: + self.logger.info( + f'rejecting JIT channel: {(channel_opening_base_fee_msat // 1000)=} < {self.config.ZEROCONF_MIN_OPENING_FEE=}' + ) raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'payment too low') - self.logger.info(f'channel opening fee (sats): {channel_opening_fee//1000}') next_chan, funding_tx = await self.open_channel_with_peer( next_peer, funding_sat, push_sat=0, zeroconf=True, public=False, - opening_fee=channel_opening_fee, + opening_base_fee_msat=channel_opening_base_fee_msat, password=password, ) async def wait_for_channel(): while not next_chan.is_open(): await asyncio.sleep(1) await util.wait_for2(wait_for_channel(), LN_P2P_NETWORK_TIMEOUT) - next_chan.save_remote_scid_alias(self._scid_alias_of_node(next_peer.pubkey)) - self.logger.info(f'JIT channel is open') - next_amount_msat_htlc -= channel_opening_fee + self.logger.info(f'JIT channel is open (will forward htlc and await preimage now)') + self.logger.info(f'channel opening fee (sats): {channel_opening_base_fee_msat//1000} + {funding_tx.get_fee()} mining fee') + next_amount_msat_htlc -= channel_opening_base_fee_msat + funding_tx.get_fee() * 1000 + if next_amount_msat_htlc < 1_000: + self.logger.info(f'rejecting JIT channel: payment too low after deducting mining fees') + raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'payment too low after deducting mining fees') # fixme: some checks are missing htlc = next_peer.send_htlc( chan=next_chan, @@ -1525,12 +1533,32 @@ async def wait_for_preimage(): await asyncio.sleep(1) await util.wait_for2(wait_for_preimage(), LN_P2P_NETWORK_TIMEOUT) - # We have been paid and can broadcast - # todo: if broadcasting raise an exception, we should try to rebroadcast - await self.network.broadcast_transaction(funding_tx) - except OnionRoutingFailure: - raise - except Exception: + # We have been paid and can broadcast. + # Channel providers should run their own, trusted Electrum server as + # we could lose funds here if the server broadcasts the tx but omits it from us + first_broadcast_ts = time.time() + while time.time() - first_broadcast_ts < ZEROCONF_TIMEOUT * 0.75: + if await self.network.try_broadcasting(funding_tx, "jit channel funding"): + break + await asyncio.sleep(30) + # we cannot rely on success of try_broadcasting to determine broadcasting success + # as broadcasting might fail with some harmless error like 'transaction already in mempool' + tx_mined_info = self.wallet.adb.get_tx_height(funding_tx.txid()) + if tx_mined_info.height() > TX_HEIGHT_LOCAL: + self.logger.debug(f"found our jit channel funding tx: {tx_mined_info.height()=}") + break + else: + raise OnionRoutingFailure( + code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, + data=b'failed to broadcast funding transaction', + ) + except Exception as e: + self.logger.warning(f"failed to open just in time channel: {repr(e)}") + if next_chan: + await self._cleanup_failed_jit_channel(next_chan) + self._preimages.pop(payment_hash.hex(), None) + if isinstance(e, OnionRoutingFailure): + raise raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') finally: del self.dont_settle_htlcs[payment_hash.hex()] @@ -1538,13 +1566,30 @@ async def wait_for_preimage(): htlc_key = serialize_htlc_key(next_chan.get_scid_or_local_alias(), htlc.htlc_id) return htlc_key + async def _cleanup_failed_jit_channel(self, chan: Channel): + """ + Removes a just in time channel where we didn't broadcast the funding + transaction, e.g. when the client didn't release the preimage. + """ + funding_height = chan.get_funding_height() + if funding_height is not None and funding_height[1] > TX_HEIGHT_LOCAL: + raise Exception("must not delete the channel if it has been broadcast") + # try to be nice and send shutdown to signal peer that this channel is dead + try: + await util.wait_for2(self.close_channel(chan.channel_id), LN_P2P_NETWORK_TIMEOUT) + except Exception: + self.logger.debug(f"sending chan shutdown to failed zeroconf peer failed ", exc_info=True) + chan.set_state(ChannelState.REDEEMED, force=True) + self.lnwatcher.adb.remove_transaction(chan.funding_outpoint.txid) + self.remove_channel(chan.channel_id) + @log_exceptions async def open_channel_with_peer( self, peer, funding_sat, *, push_sat: int = 0, public: bool = False, zeroconf: bool = False, - opening_fee: int = None, + opening_base_fee_msat: Optional[int] = None, password=None): if self.config.ENABLE_ANCHOR_CHANNELS: self.wallet.unlock(password) @@ -1556,6 +1601,9 @@ async def open_channel_with_peer( funding_sat=funding_sat, node_id=node_id, fee_policy=fee_policy) + if opening_base_fee_msat: + # add funding tx fee on top of the opening fee to avoid opening channels at a loss + opening_base_fee_msat += funding_tx.get_fee() * 1000 chan, funding_tx = await self._open_channel_coroutine( peer=peer, funding_tx=funding_tx, @@ -1563,7 +1611,7 @@ async def open_channel_with_peer( push_sat=push_sat, public=public, zeroconf=zeroconf, - opening_fee=opening_fee, + opening_fee=opening_base_fee_msat, password=password) return chan, funding_tx @@ -3351,17 +3399,24 @@ def receive_requires_jit_channel(self, amount_msat: Optional[int]) -> bool: return False def can_get_zeroconf_channel(self) -> bool: - if not self.config.ACCEPT_ZEROCONF_CHANNELS and self.config.ZEROCONF_TRUSTED_NODE: - # check if zeroconf is accepted and client has trusted zeroconf node configured + if not self.config.OPEN_ZEROCONF_CHANNELS: return False - try: - node_id = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0] - except ConnStringFormatError: - # invalid connection string + node_id = self.trusted_zeroconf_node_id + if not node_id: return False # only return True if we are connected to the zeroconf provider return self.lnpeermgr.get_peer_by_pubkey(node_id) is not None + @property + def trusted_zeroconf_node_id(self) -> Optional[bytes]: + if not self.config.ZEROCONF_TRUSTED_NODE: + return None + try: + return extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0] + except ConnStringFormatError: + self.logger.warning(f"invalid zeroconf node connection string configured") + return None + def _suggest_channels_for_rebalance(self, direction, amount_sat) -> Sequence[Tuple[Channel, int]]: """ Suggest a channel and amount to send/receive with that channel, so that we will be able to receive/send amount_sat @@ -3998,7 +4053,7 @@ async def _maybe_forward_trampoline( # do we have a connection to the node? next_peer = self.lnpeermgr.get_peer_by_pubkey(outgoing_node_id) - if next_peer and next_peer.accepts_zeroconf(): + if next_peer and next_peer.accepts_zeroconf() and self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): self.logger.info(f'JIT: found next_peer') for next_chan in next_peer.channels.values(): if next_chan.can_pay(amt_to_forward): diff --git a/electrum/simple_config.py b/electrum/simple_config.py index f1bdf240cb07..56bfca4beeb8 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -954,9 +954,15 @@ def __setattr__(self, name, value): # anchor outputs channels ENABLE_ANCHOR_CHANNELS = ConfigVar('enable_anchor_channels', default=True, type_=bool) # zeroconf channels - ACCEPT_ZEROCONF_CHANNELS = ConfigVar('accept_zeroconf_channels', default=False, type_=bool) + OPEN_ZEROCONF_CHANNELS = ConfigVar('open_zeroconf_channels', default=False, type_=bool) ZEROCONF_TRUSTED_NODE = ConfigVar('zeroconf_trusted_node', default='', type_=str) + # minimum absolute fee in sat for which we will open a channel just in time ZEROCONF_MIN_OPENING_FEE = ConfigVar('zeroconf_min_opening_fee', default=5000, type_=int) + # fee in ppm of the outgoing htlcs value we charge for opening new channels just in time + ZEROCONF_OPENING_FEE_PPM = ConfigVar('zeroconf_opening_fee_ppm', default=10_000, type_=int) + # size of the channel the lsp opens to the client in percent of the outgoing htlcs value + # (before deducting fees). required to be at least 120% to leave some buffer for the channel reserve + ZEROCONF_CHANNEL_SIZE_PERCENT = ConfigVar('zeroconf_channel_size_percent', default=200, type_=int) LN_UTXO_RESERVE = ConfigVar( 'ln_utxo_reserve', default=10000, diff --git a/electrum/wallet.py b/electrum/wallet.py index 00f5c93f3c3f..e0c1292704c1 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3458,7 +3458,7 @@ def get_help_texts_for_receive_request(self, req: Request) -> ReceiveRequestHelp zeroconf_nodeid = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0] except Exception: zeroconf_nodeid = None - can_get_zeroconf_channel = (self.lnworker and self.config.ACCEPT_ZEROCONF_CHANNELS + can_get_zeroconf_channel = (self.lnworker and self.config.OPEN_ZEROCONF_CHANNELS and self.lnworker.lnpeermgr.get_peer_by_pubkey(zeroconf_nodeid) is not None) status = self.get_invoice_status(req) diff --git a/tests/regtest.py b/tests/regtest.py index 6c0953ec2f05..ae18044d8673 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -151,12 +151,12 @@ def test_fw_fail_htlc(self): class TestLightningJIT(TestLightning): agents = { 'alice': { - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'bob': { 'lightning_listen': 'localhost:9735', 'lightning_forward_payments': 'true', - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'carol': { } @@ -170,13 +170,13 @@ class TestLightningJITTrampoline(TestLightningJIT): agents = { 'alice': { 'use_gossip': 'false', - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'bob': { 'lightning_listen': 'localhost:9735', 'lightning_forward_payments': 'true', 'lightning_forward_trampoline_payments': 'true', - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'carol': { 'use_gossip': 'false', diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index e51af808c6fa..f9f19751c01c 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -779,10 +779,24 @@ if [[ $1 == "just_in_time" ]]; then echo "carol pays alice" # note: set amount to 0.001 to test failure: 'payment too low' invoice=$($alice add_request 0.01 --lightning --memo "invoice" | jq -r ".lightning_invoice") - success=$($carol lnpay $invoice| jq '.success') - if [[ $success != "true" ]]; then - echo "JIT payment failed" - exit 1 + success=$($carol lnpay $invoice | jq -r ".success") + if [[ "$success" != "true" ]]; then + echo "jit payment failed" + exit 1 + fi + # try again, multiple jit openings should work without issues + new_blocks 3 + echo "carol pays alice again" + invoice=$($alice add_request 0.04 --lightning --memo "invoice2" | jq -r ".lightning_invoice") + success=$($carol lnpay $invoice | jq -r ".success") + if [[ "$success" != "true" ]]; then + echo "jit payment failed" + exit 1 + fi + alice_chan_count=$($alice list_channels | jq '. | length') + if [[ "$alice_chan_count" != "2" ]]; then + echo "alice should have two jit channels" + exit 1 fi fi diff --git a/tests/test_lnchannel.py b/tests/test_lnchannel.py index 7d74c54946d6..e3a89f06d484 100644 --- a/tests/test_lnchannel.py +++ b/tests/test_lnchannel.py @@ -40,7 +40,8 @@ from electrum.crypto import privkey_to_pubkey from electrum.lnutil import ( SENT, LOCAL, REMOTE, RECEIVED, UpdateAddHtlc, LnFeatures, secret_to_pubkey, ChannelType, - effective_htlc_tx_weight, LocalConfig, RemoteConfig, OnlyPubkeyKeypair, + effective_htlc_tx_weight, LocalConfig, RemoteConfig, OnlyPubkeyKeypair, ZEROCONF_TIMEOUT, + CHANNEL_OPENING_TIMEOUT_SEC, ) from electrum.logging import console_stderr_handler from electrum.lnchannel import ChannelState, Channel @@ -787,6 +788,67 @@ def test_unfunded_channel_can_be_removed(self): self.alice_channel._state = ChannelState.OPENING self.assertFalse(self.alice_channel.can_be_deleted()) + async def test_update_unfunded_zeroconf_channel(self): + """Cover the zeroconf branch of update_unfunded_state""" + chan = self.bob_channel + chan.set_state(ChannelState.OPEN, force=True) + bob = self.bob_lnwallet + self.assertFalse(chan.is_initiator()) + trusted_node = f"{chan.node_id.hex()}@127.0.0.1:9735" + chan.storage['channel_type'] |= ChannelType.OPTION_ZEROCONF + self.assertTrue(chan.is_zeroconf()) + # add channel to lnwallet/db + bob._channels[chan.channel_id] = chan + bob.db.get('channels')[chan.channel_id.hex()] = "something" + self.assertIsNotNone(bob.get_channel_by_id(chan.channel_id)) + chan.storage['init_height'] = 0 # checked by has_funding_timed_out + chan.storage['init_timestamp'] = int(time.time()) + self.assertEqual(chan.get_state(), ChannelState.OPEN) + self.assertEqual(chan.balance(LOCAL), 500000000000) + bob.config.ZEROCONF_TRUSTED_NODE = trusted_node + + chan.update_unfunded_state() + + # assert nothing happened + self.assertIsNotNone(bob.get_channel_by_id(chan.channel_id)) + self.assertIsNotNone(bob.db.get('channels').get(chan.channel_id.hex())) + self.assertEqual(chan.get_state(), ChannelState.OPEN) + self.assertEqual(bob.config.ZEROCONF_TRUSTED_NODE, trusted_node) + + # now time out zeroconf funding and try again, however her wallet is not up to date + chan.storage['init_timestamp'] -= ZEROCONF_TIMEOUT + 1 + bob.wallet.is_up_to_date = lambda: False + + chan.update_unfunded_state() + + # assert nothing happened again + self.assertIsNotNone(bob.get_channel_by_id(chan.channel_id)) + self.assertIsNotNone(bob.db.get('channels').get(chan.channel_id.hex())) + self.assertEqual(chan.get_state(), ChannelState.OPEN) + self.assertEqual(bob.config.ZEROCONF_TRUSTED_NODE, trusted_node) + self.assertFalse(chan.is_frozen_for_receiving()) + + # now her wallet is synced, and the channel is still unfunded + bob.wallet.is_up_to_date = lambda: True + + chan.update_unfunded_state() + + # check zeroconf provider gets unset + self.assertEqual(bob.config.ZEROCONF_TRUSTED_NODE, "") + self.assertFalse(chan.has_funding_timed_out()) + self.assertTrue(chan.is_frozen_for_receiving()) + + # time out funding (~2 weeks) + chan.storage['init_timestamp'] -= CHANNEL_OPENING_TIMEOUT_SEC + 1 + self.assertTrue(chan.has_funding_timed_out()) + + chan.update_unfunded_state() + + # check that channel got removed, now that funding has timed out + self.assertIsNone(self.alice_lnwallet.get_channel_by_id(chan.channel_id)) + self.assertIsNone(self.alice_lnwallet.db.get('channels').get(chan.channel_id.hex())) + + class TestChannelAnchors(TestChannel): TEST_ANCHOR_CHANNELS = True diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 8669931c24fc..7af6087787cb 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -634,6 +634,55 @@ async def test_maybe_save_remote_update(self): with self.assertRaises(InvalidGossipMsg): ChannelDB.verify_channel_update(payload, start_node=alice_bob_peer.pubkey) + async def test_zeroconf_feature_bit(self): + workers = self.prepare_lnwallets(self.GRAPH_DEFINITIONS['single_chan']) + + with self.subTest(msg="zeroconf is disabled in Alice LNWallet, so peers shouldn't signal it either"): + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() + self.assertFalse(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + # enable zeroconf in alice LNWallet + workers['alice'].features |= LnFeatures.OPTION_ZEROCONF_OPT + + with self.subTest(msg="no trusted zeroconf node, zeroconf should be signaled in new peers"): + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() # alice is LSP + self.assertTrue(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + with self.subTest(msg="trusted node is configured, but it is not bob"): + workers['alice'].config.ZEROCONF_TRUSTED_NODE = f"{os.urandom(33).hex()}@1.1.1.1:9735" + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() # alice is client + self.assertFalse(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + with self.subTest(msg="trusted node is configured, but it is invalid"): + workers['alice'].config.ZEROCONF_TRUSTED_NODE = f"{os.urandom(8).hex()}@1.1.1.1:9735" + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() # alice is client + self.assertFalse(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + with self.subTest(msg="Alice uses Bob as her trusted LSP"): + workers['alice'].config.ZEROCONF_TRUSTED_NODE = workers['bob'].node_keypair.pubkey.hex() + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() + self.assertTrue(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + class TestPeerDirect(TestPeer): diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 1b7637dee0a7..d35c90d96ab9 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -1,14 +1,22 @@ import logging import os +import asyncio +from unittest import mock +from decimal import Decimal +from electrum.address_synchronizer import TX_HEIGHT_LOCAL import electrum.trampoline from . import ElectrumTestCase from .test_lnchannel import create_test_channels -from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, LnFeatures -from electrum.lntransport import LNPeerAddr +from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures from electrum.logging import console_stderr_handler +from electrum.lntransport import LNPeerAddr from electrum.invoices import LN_EXPIRY_NEVER, PR_UNPAID +from electrum.lnpeer import Peer +from electrum.lnchannel import Channel, ChannelState +from electrum.lnonion import OnionPacket, OnionRoutingFailure +from electrum.crypto import sha256 class TestLNWallet(ElectrumTestCase): @@ -144,3 +152,266 @@ async def test_trampoline_invoice_features_and_routing_hints(self): lnaddr3, _ = wallet.get_bolt11_invoice(payment_info=pi2, message='test', fallback_address=None) hint_node_ids3 = {route[0][0] for route in lnaddr3.get_routing_info('r')} self.assertEqual(hint_node_ids3, {trampoline_pubkey}) + + async def test_open_channel_just_in_time_success(self): + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + next_chan = mock.Mock(spec=Channel) + next_chan.get_scid_or_local_alias.return_value = bytes(8) + + funding_tx = mock.Mock() + funding_tx.txid.return_value = os.urandom(32).hex() + funding_tx.get_fee = lambda: 250 + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(next_chan, funding_tx)) + wallet.network.try_broadcasting = mock.AsyncMock(return_value=True) + + preimage = os.urandom(32) + payment_hash = sha256(preimage) + + htlc = mock.Mock() + htlc.htlc_id = 0 + next_peer.send_htlc.return_value = htlc + + task = asyncio.create_task(wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=payment_hash, + next_onion=mock.Mock(spec=OnionPacket) + )) + + await asyncio.sleep(0.1) + wallet.save_preimage(payment_hash, preimage) + htlc_key = await task + htlc_key_correct = serialize_htlc_key(next_chan.get_scid_or_local_alias(), htlc.htlc_id) + self.assertEqual(htlc_key, htlc_key_correct) + + wallet.open_channel_with_peer.assert_called_once() + next_peer.send_htlc.assert_called_once() + wallet.network.try_broadcasting.assert_called() + + async def test_open_channel_just_in_time_failure_channel_open(self): + """The channel opening failed on the LSP side because the client rejected the incoming channel""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + next_peer = mock.Mock(spec=Peer) + wallet.open_channel_with_peer = mock.AsyncMock(side_effect=Exception("peer rejected incoming channel")) + preimage = os.urandom(32) + wallet.save_preimage(sha256(preimage), preimage) + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=sha256(preimage), + next_onion=mock.Mock(spec=OnionPacket) + ) + + self.assertIsNone(wallet.get_preimage(sha256(preimage))) + wallet._cleanup_failed_jit_channel.assert_not_called() + + async def test_open_channel_just_in_time_failure_send_htlc(self): + """The LSP fails to forward the htlc to the client""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + chan = mock.Mock(spec=Channel) + funding_tx = mock.Mock() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx)) + next_peer.send_htlc.side_effect = Exception("couldn't send htlc, peer disconnected") + preimage = os.urandom(32) + wallet.save_preimage(sha256(preimage), preimage) + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=sha256(preimage), + next_onion=mock.Mock(spec=OnionPacket) + ) + + self.assertIsNone(wallet.get_preimage(sha256(preimage))) + wallet._cleanup_failed_jit_channel.assert_called_once_with(chan) + + async def test_open_channel_just_in_time_failure_preimage_timeout(self): + """The client never releases the preimage""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + chan = mock.Mock(spec=Channel) + funding_tx = mock.Mock() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx)) + + htlc = mock.Mock() + next_peer.send_htlc.return_value = htlc + + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with mock.patch('electrum.lnworker.LN_P2P_NETWORK_TIMEOUT', 0.01): + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=os.urandom(32), + next_onion=mock.Mock(spec=OnionPacket) + ) + + wallet._cleanup_failed_jit_channel.assert_called_once_with(chan) + + async def test_open_channel_just_in_time_failure_broadcast(self): + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + chan = mock.Mock(spec=Channel) + + funding_tx = mock.Mock() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx)) + + preimage = os.urandom(32) + wallet.save_preimage(sha256(preimage), preimage) + + wallet.network.try_broadcasting = mock.AsyncMock(return_value=False) + wallet.wallet.adb.get_tx_height = mock.Mock(return_value=mock.Mock(height=lambda: TX_HEIGHT_LOCAL)) + + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with mock.patch('electrum.lnworker.ZEROCONF_TIMEOUT', 0.01), \ + mock.patch('electrum.lnworker.asyncio.sleep', new_callable=mock.AsyncMock): + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=sha256(preimage), + next_onion=mock.Mock(spec=OnionPacket) + ) + + self.assertIsNone(wallet.get_preimage(sha256(preimage))) + wallet._cleanup_failed_jit_channel.assert_called_once_with(chan) + + async def test_open_channel_just_in_time_config_disabled(self): + """open_channel_just_in_time rejects to open a channel if the config is disabled""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = False + + with self.assertRaises(AssertionError): + await wallet.open_channel_just_in_time( + next_peer=mock.Mock(spec=Peer), + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=os.urandom(32), + next_onion=mock.Mock(spec=OnionPacket) + ) + + async def test_cleanup_failed_jit_channel(self): + wallet = self.lnwallet_anchors + + chan = mock.Mock(spec=Channel) + chan_id = os.urandom(32).hex() + chan.channel_id = chan_id + funding_txid = os.urandom(32).hex() + chan.funding_outpoint = mock.Mock() + chan.funding_outpoint.txid = funding_txid + chan.get_funding_height.return_value = None + + # close_channel fails with exception + wallet.close_channel = mock.AsyncMock(side_effect=Exception("peer disconnected")) + wallet.remove_channel = mock.Mock() + wallet.lnwatcher = mock.Mock() + wallet.lnwatcher.adb = mock.Mock() + wallet.lnwatcher.adb.remove_transaction = mock.Mock() + + await wallet._cleanup_failed_jit_channel(chan) + + wallet.close_channel.assert_called_once_with(chan_id) + chan.set_state.assert_called_once_with(ChannelState.REDEEMED, force=True) + wallet.lnwatcher.adb.remove_transaction.assert_called_once_with(funding_txid) + wallet.remove_channel.assert_called_once_with(chan_id) + + async def test_receive_requires_jit_channel(self): + wallet = self.lnwallet_anchors + + with self.subTest(msg="cannot get jit channel"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=False) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0)) + self.assertFalse(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="could get zeroconf channel but doesn't need one"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(2000)) + self.assertFalse(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="could get zeroconf channel and needs one"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(500)) + self.assertTrue(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="could get one but can receive exactly the requested amount"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(1000)) + self.assertFalse(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="0 amount invoice, could get channel but can receive something"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(1)) + self.assertFalse(wallet.receive_requires_jit_channel(None)) + + with self.subTest(msg="0 amount invoice (None amount), cannot receive anything and can get channel"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0)) + self.assertTrue(wallet.receive_requires_jit_channel(None)) + + with self.subTest(msg="0 amount invoice (0 msat), cannot receive anything, could get channel"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0)) + self.assertTrue(wallet.receive_requires_jit_channel(0)) + + async def test_can_get_zeroconf_channel(self): + wallet = self.lnwallet_anchors + valid_peer = "02" * 33 + "@localhost:9735" + + with self.subTest(msg="disabled in config"): + wallet.config.OPEN_ZEROCONF_CHANNELS = False + wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, but no trusted node configured"): + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = '' + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, invalid trusted node string"): + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = "invalid_node_string" + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, valid trusted node, but not connected"): + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, valid trusted node, and connected"): + wallet.lnpeermgr.get_peer_by_pubkey = mock.Mock(return_value=mock.Mock(spec=Peer)) + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer + self.assertTrue(wallet.can_get_zeroconf_channel())