From d62b627a0b0e690df409d6bc782bc95bb2d6aa7e Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 17 Sep 2025 14:09:03 +0200 Subject: [PATCH 1/2] lnpeer: move htlc forwarding funcs to lnworker forwarding happens independent of the peer that received the htlc to forward and fits better in lnworker. --- electrum/lnpeer.py | 324 +----------------------------------------- electrum/lnworker.py | 329 ++++++++++++++++++++++++++++++++++++++++++- tests/test_lnpeer.py | 4 + 3 files changed, 332 insertions(+), 325 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 48b442693..a30a85bfd 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1926,57 +1926,6 @@ class Peer(Logger, EventListener): self.send_message("commitment_signed", channel_id=chan.channel_id, signature=sig_64, num_htlcs=len(htlc_sigs), htlc_signature=b"".join(htlc_sigs)) return True - def create_onion_for_route( - self, *, - route: 'LNPaymentRoute', - amount_msat: int, - total_msat: int, - payment_hash: bytes, - min_final_cltv_delta: int, - payment_secret: bytes, - trampoline_onion: Optional[OnionPacket] = None, - ): - # add features learned during "init" for direct neighbour: - route[0].node_features |= self.features - local_height = self.network.get_local_height() - final_cltv_abs = local_height + min_final_cltv_delta - hops_data, amount_msat, cltv_abs = calc_hops_data_for_payment( - route, - amount_msat, - final_cltv_abs=final_cltv_abs, - total_msat=total_msat, - payment_secret=payment_secret) - num_hops = len(hops_data) - self.logger.info(f"lnpeer.pay len(route)={len(route)}") - for i in range(len(route)): - self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}") - assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) - session_key = os.urandom(32) # session_key - # if we are forwarding a trampoline payment, add trampoline onion - if trampoline_onion: - self.logger.info(f'adding trampoline onion to final payload') - trampoline_payload = hops_data[-1].payload - trampoline_payload["trampoline_onion_packet"] = { - "version": trampoline_onion.version, - "public_key": trampoline_onion.public_key, - "hops_data": trampoline_onion.hops_data, - "hmac": trampoline_onion.hmac - } - if t_hops_data := trampoline_onion._debug_hops_data: # None if trampoline-forwarding - t_route = trampoline_onion._debug_route - assert t_route is not None - self.logger.info(f"lnpeer.pay len(t_route)={len(t_route)}") - for i in range(len(t_route)): - self.logger.info(f" {i}: t_node={t_route[i].end_node.hex()} hop_data={t_hops_data[i]!r}") - # create onion packet - payment_path_pubkeys = [x.node_id for x in route] - onion = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=payment_hash) # must use another sessionkey - self.logger.info(f"starting payment. len(route)={len(hops_data)}.") - # create htlc - if cltv_abs > local_height + lnutil.NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: - raise PaymentFailure(f"htlc expiry too far into future. (in {cltv_abs-local_height} blocks)") - return onion, amount_msat, cltv_abs, session_key - def send_htlc( self, *, @@ -2019,7 +1968,7 @@ class Peer(Logger, EventListener): assert len(route) > 0 if not chan.can_send_update_add_htlc(): raise PaymentFailure("Channel cannot send update_add_htlc") - onion, amount_msat, cltv_abs, session_key = self.create_onion_for_route( + onion, amount_msat, cltv_abs, session_key = self.lnworker.create_onion_for_route( route=route, amount_msat=amount_msat, total_msat=total_msat, @@ -2131,273 +2080,6 @@ class Peer(Logger, EventListener): chan.receive_htlc(htlc, onion_packet) util.trigger_callback('htlc_added', chan, htlc, RECEIVED) - - async def maybe_forward_htlc( - self, *, - incoming_chan: Channel, - htlc: UpdateAddHtlc, - processed_onion: ProcessedOnionPacket, - ) -> str: - - # Forward HTLC - # FIXME: there are critical safety checks MISSING here - # - for example; atm we forward first and then persist "forwarding_info", - # so if we segfault in-between and restart, we might forward an HTLC twice... - # (same for trampoline forwarding) - # - we could check for the exposure to dust HTLCs, see: - # https://github.com/ACINQ/eclair/pull/1985 - - def log_fail_reason(reason: str): - self.logger.debug( - f"maybe_forward_htlc. will FAIL HTLC: inc_chan={incoming_chan.get_id_for_log()}. " - f"{reason}. inc_htlc={str(htlc)}. onion_payload={processed_onion.hop_data.payload}") - - forwarding_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS - if not forwarding_enabled: - log_fail_reason("forwarding is disabled") - raise OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'') - chain = self.network.blockchain() - if chain.is_tip_stale(): - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - try: - _next_chan_scid = processed_onion.hop_data.payload["short_channel_id"]["short_channel_id"] # type: bytes - next_chan_scid = ShortChannelID(_next_chan_scid) - except Exception: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - try: - next_amount_msat_htlc = processed_onion.hop_data.payload["amt_to_forward"]["amt_to_forward"] - except Exception: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - try: - next_cltv_abs = processed_onion.hop_data.payload["outgoing_cltv_value"]["outgoing_cltv_value"] - except Exception: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - - next_chan = self.lnworker.get_channel_by_short_id(next_chan_scid) - - if self.lnworker.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): - next_peer = self.lnworker.get_peer_by_static_jit_scid_alias(next_chan_scid) - else: - next_peer = None - - if not next_chan and next_peer and next_peer.accepts_zeroconf(): - # check if an already existing channel can be used. - # todo: split the payment - for next_chan in next_peer.channels.values(): - if next_chan.can_pay(next_amount_msat_htlc): - break - else: - return await self.lnworker.open_channel_just_in_time( - next_peer=next_peer, - next_amount_msat_htlc=next_amount_msat_htlc, - next_cltv_abs=next_cltv_abs, - payment_hash=htlc.payment_hash, - next_onion=processed_onion.next_packet) - - local_height = chain.height() - if next_chan is None: - log_fail_reason(f"cannot find next_chan {next_chan_scid}") - raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') - outgoing_chan_upd = next_chan.get_outgoing_gossip_channel_update(scid=next_chan_scid)[2:] - outgoing_chan_upd_len = len(outgoing_chan_upd).to_bytes(2, byteorder="big") - outgoing_chan_upd_message = outgoing_chan_upd_len + outgoing_chan_upd - if not next_chan.can_send_update_add_htlc(): - log_fail_reason( - f"next_chan {next_chan.get_id_for_log()} cannot send ctx updates. " - f"chan state {next_chan.get_state()!r}, peer state: {next_chan.peer_state!r}") - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) - if not next_chan.can_pay(next_amount_msat_htlc): - log_fail_reason(f"transient error (likely due to insufficient funds): not next_chan.can_pay(amt)") - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) - if htlc.cltv_abs - next_cltv_abs < next_chan.forwarding_cltv_delta: - log_fail_reason( - f"INCORRECT_CLTV_EXPIRY. " - f"{htlc.cltv_abs=} - {next_cltv_abs=} < {next_chan.forwarding_cltv_delta=}") - data = htlc.cltv_abs.to_bytes(4, byteorder="big") + outgoing_chan_upd_message - raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_CLTV_EXPIRY, data=data) - if htlc.cltv_abs - lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED <= local_height \ - or next_cltv_abs <= local_height: - raise OnionRoutingFailure(code=OnionFailureCode.EXPIRY_TOO_SOON, data=outgoing_chan_upd_message) - if max(htlc.cltv_abs, next_cltv_abs) > local_height + lnutil.NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: - raise OnionRoutingFailure(code=OnionFailureCode.EXPIRY_TOO_FAR, data=b'') - forwarding_fees = fee_for_edge_msat( - forwarded_amount_msat=next_amount_msat_htlc, - fee_base_msat=next_chan.forwarding_fee_base_msat, - fee_proportional_millionths=next_chan.forwarding_fee_proportional_millionths) - if htlc.amount_msat - next_amount_msat_htlc < forwarding_fees: - data = next_amount_msat_htlc.to_bytes(8, byteorder="big") + outgoing_chan_upd_message - raise OnionRoutingFailure(code=OnionFailureCode.FEE_INSUFFICIENT, data=data) - if self._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(htlc.payment_hash): - log_fail_reason(f"RHASH corresponds to payreq we created") - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - self.logger.info( - f"maybe_forward_htlc. will forward HTLC: inc_chan={incoming_chan.short_channel_id}. inc_htlc={str(htlc)}. " - f"next_chan={next_chan.get_id_for_log()}.") - - next_peer = self.lnworker.peers.get(next_chan.node_id) - if next_peer is None: - log_fail_reason(f"next_peer offline ({next_chan.node_id.hex()})") - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) - try: - next_htlc = next_peer.send_htlc( - chan=next_chan, - payment_hash=htlc.payment_hash, - amount_msat=next_amount_msat_htlc, - cltv_abs=next_cltv_abs, - onion=processed_onion.next_packet, - ) - except BaseException as e: - log_fail_reason(f"error sending message to next_peer={next_chan.node_id.hex()}") - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) - - htlc_key = serialize_htlc_key(next_chan.get_scid_or_local_alias(), next_htlc.htlc_id) - return htlc_key - - @log_exceptions - async def maybe_forward_trampoline( - self, *, - payment_hash: bytes, - inc_cltv_abs: int, - outer_onion: ProcessedOnionPacket, - trampoline_onion: ProcessedOnionPacket, - fw_payment_key: str, - ) -> None: - - forwarding_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS - forwarding_trampoline_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS - if not (forwarding_enabled and forwarding_trampoline_enabled): - self.logger.info(f"trampoline forwarding is disabled. failing htlc.") - raise OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'') - payload = trampoline_onion.hop_data.payload - payment_data = payload.get('payment_data') - try: - payment_secret = payment_data['payment_secret'] if payment_data else os.urandom(32) - outgoing_node_id = payload["outgoing_node_id"]["outgoing_node_id"] - amt_to_forward = payload["amt_to_forward"]["amt_to_forward"] - out_cltv_abs = payload["outgoing_cltv_value"]["outgoing_cltv_value"] - if "invoice_features" in payload: - self.logger.info('forward_trampoline: legacy') - next_trampoline_onion = None - invoice_features = payload["invoice_features"]["invoice_features"] - invoice_routing_info = payload["invoice_routing_info"]["invoice_routing_info"] - r_tags = decode_routing_info(invoice_routing_info) - self.logger.info(f'r_tags {r_tags}') - # TODO legacy mpp payment, use total_msat from trampoline onion - else: - self.logger.info('forward_trampoline: end-to-end') - invoice_features = LnFeatures.BASIC_MPP_OPT - next_trampoline_onion = trampoline_onion.next_packet - r_tags = [] - except Exception as e: - self.logger.exception('') - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - - if self._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(payment_hash): - self.logger.debug( - f"maybe_forward_trampoline. will FAIL HTLC(s). " - f"RHASH corresponds to payreq we created. {payment_hash.hex()=}") - raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - - # these are the fee/cltv paid by the sender - # pay_to_node will raise if they are not sufficient - total_msat = outer_onion.hop_data.payload["payment_data"]["total_msat"] - budget = PaymentFeeBudget( - fee_msat=total_msat - amt_to_forward, - cltv=inc_cltv_abs - out_cltv_abs, - ) - self.logger.info(f'trampoline forwarding. budget={budget}') - self.logger.info(f'trampoline forwarding. {inc_cltv_abs=}, {out_cltv_abs=}') - # To convert abs vs rel cltvs, we need to guess blockheight used by original sender as "current blockheight". - # Blocks might have been mined since. - # - if we skew towards the past, we decrease our own cltv_budget accordingly (which is ok) - # - if we skew towards the future, we decrease the cltv_budget for the subsequent nodes in the path, - # which can result in them failing the payment. - # So we skew towards the past and guess that there has been 1 new block mined since the payment began: - local_height_of_onion_creator = self.network.get_local_height() - 1 - cltv_budget_for_rest_of_route = out_cltv_abs - local_height_of_onion_creator - - if budget.fee_msat < 1000: - raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') - if budget.cltv < 576: - raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') - - # do we have a connection to the node? - next_peer = self.lnworker.peers.get(outgoing_node_id) - if next_peer and next_peer.accepts_zeroconf(): - self.logger.info(f'JIT: found next_peer') - for next_chan in next_peer.channels.values(): - if next_chan.can_pay(amt_to_forward): - # todo: detect if we can do mpp - self.logger.info(f'jit: next_chan can pay') - break - else: - scid_alias = self.lnworker._scid_alias_of_node(next_peer.pubkey) - route = [RouteEdge( - start_node=next_peer.pubkey, - end_node=outgoing_node_id, - short_channel_id=scid_alias, - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_delta=144, - node_features=0 - )] - next_onion, amount_msat, cltv_abs, session_key = self.create_onion_for_route( - route=route, - amount_msat=amt_to_forward, - total_msat=amt_to_forward, - payment_hash=payment_hash, - min_final_cltv_delta=cltv_budget_for_rest_of_route, - payment_secret=payment_secret, - trampoline_onion=next_trampoline_onion, - ) - await self.lnworker.open_channel_just_in_time( - next_peer=next_peer, - next_amount_msat_htlc=amt_to_forward, - next_cltv_abs=cltv_abs, - payment_hash=payment_hash, - next_onion=next_onion) - return - - try: - await self.lnworker.pay_to_node( - node_pubkey=outgoing_node_id, - payment_hash=payment_hash, - payment_secret=payment_secret, - amount_to_pay=amt_to_forward, - min_final_cltv_delta=cltv_budget_for_rest_of_route, - r_tags=r_tags, - invoice_features=invoice_features, - fwd_trampoline_onion=next_trampoline_onion, - budget=budget, - attempts=100, - fw_payment_key=fw_payment_key, - ) - except OnionRoutingFailure as e: - raise - except FeeBudgetExceeded: - raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') - except PaymentFailure as e: - self.logger.debug( - f"maybe_forward_trampoline. PaymentFailure for {payment_hash.hex()=}, {payment_secret.hex()=}: {e!r}") - raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') - - def _maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(self, payment_hash: bytes) -> bool: - """Returns True if the HTLC should be failed. - We must not forward HTLCs with a matching payment_hash to a payment request we created. - Example attack: - - Bob creates payment request with HASH1, for 1 BTC; and gives the payreq to Alice - - Alice sends htlc A->B->C, for 1 sat, with HASH1 - - Bob must not release the preimage of HASH1 - """ - payment_info = self.lnworker.get_payment_info(payment_hash) - is_our_payreq = payment_info and payment_info.direction == RECEIVED - # note: If we don't have the preimage for a payment request, then it must be a hold invoice. - # Hold invoices are created by other parties (e.g. a counterparty initiating a submarine swap), - # and it is the other party choosing the payment_hash. If we failed HTLCs with payment_hashes colliding - # with hold invoices, then a party that can make us save a hold invoice for an arbitrary hash could - # also make us fail arbitrary HTLCs. - return bool(is_our_payreq and self.lnworker.get_preimage(payment_hash)) - def check_accepted_htlc( self, *, chan: Channel, @@ -2502,7 +2184,7 @@ class Peer(Logger, EventListener): return None, None # use the htlc key if we are forwarding payment_key = serialize_htlc_key(chan.get_scid_or_local_alias(), htlc.htlc_id) - callback = lambda: self.maybe_forward_htlc( + callback = lambda: self.lnworker.maybe_forward_htlc( incoming_chan=chan, htlc=htlc, processed_onion=processed_onion) @@ -2572,7 +2254,7 @@ class Peer(Logger, EventListener): already_forwarded=already_forwarded, ) else: - callback = lambda: self.maybe_forward_trampoline( + callback = lambda: self.lnworker.maybe_forward_trampoline( payment_hash=payment_hash, inc_cltv_abs=htlc.cltv_abs, # TODO: use max or enforce same value across mpp parts outer_onion=processed_onion, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 98bd12988..a4f667808 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -32,7 +32,7 @@ from .i18n import _ from .json_db import stored_in from .channel_db import UpdateStatus, ChannelDBNotLoaded, get_mychannel_info, get_mychannel_policy -from . import constants, util +from . import constants, util, lnutil from .util import ( profiler, OldTaskGroup, ESocksProxy, NetworkRetryManager, JsonRPCClient, NotEnoughFunds, EventListener, event_listener, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions, ignore_exceptions, @@ -71,17 +71,21 @@ from .lnutil import ( NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, RecvMPPResolution, ReceivedMPPStatus, ) -from .lnonion import decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket +from .lnonion import ( + decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, + ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, +) from .lnmsg import decode_msg from .lnrouter import ( - RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, LNPathInconsistent + RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, + LNPathInconsistent, fee_for_edge_msat, ) from .lnwatcher import LNWatcher from .submarine_swaps import SwapManager from .mpp_split import suggest_splits, SplitConfigRating from .trampoline import ( create_trampoline_route_and_onion, is_legacy_relay, trampolines_by_id, hardcoded_trampoline_nodes, - is_hardcoded_trampoline + is_hardcoded_trampoline, decode_routing_info ) if TYPE_CHECKING: @@ -3349,6 +3353,323 @@ class LNWallet(LNWorker): util.trigger_callback('channels_updated', self.wallet) self.lnwatcher.add_channel(cb) + async def maybe_forward_htlc( + self, *, + incoming_chan: Channel, + htlc: UpdateAddHtlc, + processed_onion: ProcessedOnionPacket, + ) -> str: + + # Forward HTLC + # FIXME: there are critical safety checks MISSING here + # - for example; atm we forward first and then persist "forwarding_info", + # so if we segfault in-between and restart, we might forward an HTLC twice... + # (same for trampoline forwarding) + # - we could check for the exposure to dust HTLCs, see: + # https://github.com/ACINQ/eclair/pull/1985 + + def log_fail_reason(reason: str): + self.logger.debug( + f"_maybe_forward_htlc. will FAIL HTLC: inc_chan={incoming_chan.get_id_for_log()}. " + f"{reason}. inc_htlc={str(htlc)}. onion_payload={processed_onion.hop_data.payload}") + + forwarding_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS + if not forwarding_enabled: + log_fail_reason("forwarding is disabled") + raise OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'') + chain = self.network.blockchain() + if chain.is_tip_stale(): + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + try: + _next_chan_scid = processed_onion.hop_data.payload["short_channel_id"]["short_channel_id"] # type: bytes + next_chan_scid = ShortChannelID(_next_chan_scid) + except Exception: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + try: + next_amount_msat_htlc = processed_onion.hop_data.payload["amt_to_forward"]["amt_to_forward"] + except Exception: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + try: + next_cltv_abs = processed_onion.hop_data.payload["outgoing_cltv_value"]["outgoing_cltv_value"] + except Exception: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + + next_chan = self.get_channel_by_short_id(next_chan_scid) + + if self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): + next_peer = self.get_peer_by_static_jit_scid_alias(next_chan_scid) + else: + next_peer = None + + if not next_chan and next_peer and next_peer.accepts_zeroconf(): + # check if an already existing channel can be used. + # todo: split the payment + for next_chan in next_peer.channels.values(): + if next_chan.can_pay(next_amount_msat_htlc): + break + else: + return await self.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=next_amount_msat_htlc, + next_cltv_abs=next_cltv_abs, + payment_hash=htlc.payment_hash, + next_onion=processed_onion.next_packet) + + local_height = chain.height() + if next_chan is None: + log_fail_reason(f"cannot find next_chan {next_chan_scid}") + raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') + outgoing_chan_upd = next_chan.get_outgoing_gossip_channel_update(scid=next_chan_scid)[2:] + outgoing_chan_upd_len = len(outgoing_chan_upd).to_bytes(2, byteorder="big") + outgoing_chan_upd_message = outgoing_chan_upd_len + outgoing_chan_upd + if not next_chan.can_send_update_add_htlc(): + log_fail_reason( + f"next_chan {next_chan.get_id_for_log()} cannot send ctx updates. " + f"chan state {next_chan.get_state()!r}, peer state: {next_chan.peer_state!r}") + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) + if not next_chan.can_pay(next_amount_msat_htlc): + log_fail_reason(f"transient error (likely due to insufficient funds): not next_chan.can_pay(amt)") + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) + if htlc.cltv_abs - next_cltv_abs < next_chan.forwarding_cltv_delta: + log_fail_reason( + f"INCORRECT_CLTV_EXPIRY. " + f"{htlc.cltv_abs=} - {next_cltv_abs=} < {next_chan.forwarding_cltv_delta=}") + data = htlc.cltv_abs.to_bytes(4, byteorder="big") + outgoing_chan_upd_message + raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_CLTV_EXPIRY, data=data) + if htlc.cltv_abs - lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED <= local_height \ + or next_cltv_abs <= local_height: + raise OnionRoutingFailure(code=OnionFailureCode.EXPIRY_TOO_SOON, data=outgoing_chan_upd_message) + if max(htlc.cltv_abs, next_cltv_abs) > local_height + lnutil.NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: + raise OnionRoutingFailure(code=OnionFailureCode.EXPIRY_TOO_FAR, data=b'') + forwarding_fees = fee_for_edge_msat( + forwarded_amount_msat=next_amount_msat_htlc, + fee_base_msat=next_chan.forwarding_fee_base_msat, + fee_proportional_millionths=next_chan.forwarding_fee_proportional_millionths) + if htlc.amount_msat - next_amount_msat_htlc < forwarding_fees: + data = next_amount_msat_htlc.to_bytes(8, byteorder="big") + outgoing_chan_upd_message + raise OnionRoutingFailure(code=OnionFailureCode.FEE_INSUFFICIENT, data=data) + if self._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(htlc.payment_hash): + log_fail_reason(f"RHASH corresponds to payreq we created") + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + self.logger.info( + f"maybe_forward_htlc. will forward HTLC: inc_chan={incoming_chan.short_channel_id}. inc_htlc={str(htlc)}. " + f"next_chan={next_chan.get_id_for_log()}.") + + next_peer = self.peers.get(next_chan.node_id) + if next_peer is None: + log_fail_reason(f"next_peer offline ({next_chan.node_id.hex()})") + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) + try: + next_htlc = next_peer.send_htlc( + chan=next_chan, + payment_hash=htlc.payment_hash, + amount_msat=next_amount_msat_htlc, + cltv_abs=next_cltv_abs, + onion=processed_onion.next_packet, + ) + except BaseException as e: + log_fail_reason(f"error sending message to next_peer={next_chan.node_id.hex()}") + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) + + htlc_key = serialize_htlc_key(next_chan.get_scid_or_local_alias(), next_htlc.htlc_id) + return htlc_key + + @log_exceptions + async def maybe_forward_trampoline( + self, *, + payment_hash: bytes, + inc_cltv_abs: int, + outer_onion: ProcessedOnionPacket, + trampoline_onion: ProcessedOnionPacket, + fw_payment_key: str, + ) -> None: + + forwarding_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS + forwarding_trampoline_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS + if not (forwarding_enabled and forwarding_trampoline_enabled): + self.logger.info(f"trampoline forwarding is disabled. failing htlc.") + raise OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'') + payload = trampoline_onion.hop_data.payload + payment_data = payload.get('payment_data') + try: + payment_secret = payment_data['payment_secret'] if payment_data else os.urandom(32) + outgoing_node_id = payload["outgoing_node_id"]["outgoing_node_id"] + amt_to_forward = payload["amt_to_forward"]["amt_to_forward"] + out_cltv_abs = payload["outgoing_cltv_value"]["outgoing_cltv_value"] + if "invoice_features" in payload: + self.logger.info('forward_trampoline: legacy') + next_trampoline_onion = None + invoice_features = payload["invoice_features"]["invoice_features"] + invoice_routing_info = payload["invoice_routing_info"]["invoice_routing_info"] + r_tags = decode_routing_info(invoice_routing_info) + self.logger.info(f'r_tags {r_tags}') + # TODO legacy mpp payment, use total_msat from trampoline onion + else: + self.logger.info('forward_trampoline: end-to-end') + invoice_features = LnFeatures.BASIC_MPP_OPT + next_trampoline_onion = trampoline_onion.next_packet + r_tags = [] + except Exception as e: + self.logger.exception('') + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + + if self._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(payment_hash): + self.logger.debug( + f"maybe_forward_trampoline. will FAIL HTLC(s). " + f"RHASH corresponds to payreq we created. {payment_hash.hex()=}") + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + + # these are the fee/cltv paid by the sender + # pay_to_node will raise if they are not sufficient + total_msat = outer_onion.hop_data.payload["payment_data"]["total_msat"] + budget = PaymentFeeBudget( + fee_msat=total_msat - amt_to_forward, + cltv=inc_cltv_abs - out_cltv_abs, + ) + self.logger.info(f'trampoline forwarding. budget={budget}') + self.logger.info(f'trampoline forwarding. {inc_cltv_abs=}, {out_cltv_abs=}') + # To convert abs vs rel cltvs, we need to guess blockheight used by original sender as "current blockheight". + # Blocks might have been mined since. + # - if we skew towards the past, we decrease our own cltv_budget accordingly (which is ok) + # - if we skew towards the future, we decrease the cltv_budget for the subsequent nodes in the path, + # which can result in them failing the payment. + # So we skew towards the past and guess that there has been 1 new block mined since the payment began: + local_height_of_onion_creator = self.network.get_local_height() - 1 + cltv_budget_for_rest_of_route = out_cltv_abs - local_height_of_onion_creator + + if budget.fee_msat < 1000: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') + if budget.cltv < 576: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') + + # do we have a connection to the node? + next_peer = self.peers.get(outgoing_node_id) + if next_peer and next_peer.accepts_zeroconf(): + self.logger.info(f'JIT: found next_peer') + for next_chan in next_peer.channels.values(): + if next_chan.can_pay(amt_to_forward): + # todo: detect if we can do mpp + self.logger.info(f'jit: next_chan can pay') + break + else: + scid_alias = self._scid_alias_of_node(next_peer.pubkey) + route = [RouteEdge( + start_node=next_peer.pubkey, + end_node=outgoing_node_id, + short_channel_id=scid_alias, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=144, + node_features=0 + )] + next_onion, amount_msat, cltv_abs, session_key = self.create_onion_for_route( + route=route, + amount_msat=amt_to_forward, + total_msat=amt_to_forward, + payment_hash=payment_hash, + min_final_cltv_delta=cltv_budget_for_rest_of_route, + payment_secret=payment_secret, + trampoline_onion=next_trampoline_onion, + ) + await self.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=amt_to_forward, + next_cltv_abs=cltv_abs, + payment_hash=payment_hash, + next_onion=next_onion) + return + + try: + await self.pay_to_node( + node_pubkey=outgoing_node_id, + payment_hash=payment_hash, + payment_secret=payment_secret, + amount_to_pay=amt_to_forward, + min_final_cltv_delta=cltv_budget_for_rest_of_route, + r_tags=r_tags, + invoice_features=invoice_features, + fwd_trampoline_onion=next_trampoline_onion, + budget=budget, + attempts=100, + fw_payment_key=fw_payment_key, + ) + except OnionRoutingFailure as e: + raise + except FeeBudgetExceeded: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') + except PaymentFailure as e: + self.logger.debug( + f"maybe_forward_trampoline. PaymentFailure for {payment_hash.hex()=}, {payment_secret.hex()=}: {e!r}") + raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') + + def _maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(self, payment_hash: bytes) -> bool: + """Returns True if the HTLC should be failed. + We must not forward HTLCs with a matching payment_hash to a payment request we created. + Example attack: + - Bob creates payment request with HASH1, for 1 BTC; and gives the payreq to Alice + - Alice sends htlc A->B->C, for 1 sat, with HASH1 + - Bob must not release the preimage of HASH1 + """ + payment_info = self.get_payment_info(payment_hash) + is_our_payreq = payment_info and payment_info.direction == RECEIVED + # note: If we don't have the preimage for a payment request, then it must be a hold invoice. + # Hold invoices are created by other parties (e.g. a counterparty initiating a submarine swap), + # and it is the other party choosing the payment_hash. If we failed HTLCs with payment_hashes colliding + # with hold invoices, then a party that can make us save a hold invoice for an arbitrary hash could + # also make us fail arbitrary HTLCs. + return bool(is_our_payreq and self.get_preimage(payment_hash)) + + def create_onion_for_route( + self, *, + route: 'LNPaymentRoute', + amount_msat: int, + total_msat: int, + payment_hash: bytes, + min_final_cltv_delta: int, + payment_secret: bytes, + trampoline_onion: Optional[OnionPacket] = None, + ): + # add features learned during "init" for direct neighbour: + route[0].node_features |= self.features + local_height = self.network.get_local_height() + final_cltv_abs = local_height + min_final_cltv_delta + hops_data, amount_msat, cltv_abs = calc_hops_data_for_payment( + route, + amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + payment_secret=payment_secret) + num_hops = len(hops_data) + self.logger.info(f"pay len(route)={len(route)}") + for i in range(len(route)): + self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}") + assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) + session_key = os.urandom(32) # session_key + # if we are forwarding a trampoline payment, add trampoline onion + if trampoline_onion: + self.logger.info(f'adding trampoline onion to final payload') + trampoline_payload = hops_data[-1].payload + trampoline_payload["trampoline_onion_packet"] = { + "version": trampoline_onion.version, + "public_key": trampoline_onion.public_key, + "hops_data": trampoline_onion.hops_data, + "hmac": trampoline_onion.hmac + } + if t_hops_data := trampoline_onion._debug_hops_data: # None if trampoline-forwarding + t_route = trampoline_onion._debug_route + assert t_route is not None + self.logger.info(f"lnpeer.pay len(t_route)={len(t_route)}") + for i in range(len(t_route)): + self.logger.info(f" {i}: t_node={t_route[i].end_node.hex()} hop_data={t_hops_data[i]!r}") + # create onion packet + payment_path_pubkeys = [x.node_id for x in route] + onion = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=payment_hash) # must use another sessionkey + self.logger.info(f"starting payment. len(route)={len(hops_data)}.") + # create htlc + if cltv_abs > local_height + lnutil.NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: + raise PaymentFailure(f"htlc expiry too far into future. (in {cltv_abs-local_height} blocks)") + return onion, amount_msat, cltv_abs, session_key + def save_forwarding_failure( self, payment_key: str, diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 3eb4313f6..85c241520 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -347,6 +347,10 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]): current_target_feerate_per_kw = LNWallet.current_target_feerate_per_kw current_low_feerate_per_kw_srk_channel = LNWallet.current_low_feerate_per_kw_srk_channel maybe_cleanup_mpp = LNWallet.maybe_cleanup_mpp + create_onion_for_route = LNWallet.create_onion_for_route + maybe_forward_htlc = LNWallet.maybe_forward_htlc + maybe_forward_trampoline = LNWallet.maybe_forward_trampoline + _maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created = LNWallet._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created class MockTransport: From 286fc4b86e4d23cb9af15b9061b3d709e7592bcb Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 26 Sep 2025 16:11:20 +0200 Subject: [PATCH 2/2] lnworker: enforce creation of PaymentInfo for b11 Enforce that the information used to create a bolt11 invoice using `get_bolt11_invoice()` is similar to the related instance of PaymentInfo by requiring a PaymentInfo as argument for `get_bolt11_invoice()`. This way the invoice cannot differ from the created PaymentInfo. This allows to use the information in PaymentInfo for validation of incoming htlcs more reliably. To cover all required information for the creation of a b11 invoice the PaymentInfo class has to be extended with a expiry and min_final_cltv_expiry. This requires a db upgrade. --- electrum/commands.py | 24 ++++----- electrum/gui/qt/main_window.py | 4 +- electrum/lnutil.py | 7 +-- electrum/lnworker.py | 86 +++++++++++++++++++++---------- electrum/plugins/nwc/nwcserver.py | 15 +++--- electrum/submarine_swaps.py | 31 ++++++----- electrum/wallet.py | 12 +++-- electrum/wallet_db.py | 15 +++++- tests/test_lnpeer.py | 20 +++---- 9 files changed, 134 insertions(+), 80 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 09bc31a14..c66169813 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -68,7 +68,7 @@ from .wallet import ( ) from .address_synchronizer import TX_HEIGHT_LOCAL from .mnemonic import Mnemonic -from .lnutil import (channel_id_from_funding_tx, LnFeatures, SENT, MIN_FINAL_CLTV_DELTA_FOR_INVOICE, +from .lnutil import (channel_id_from_funding_tx, LnFeatures, SENT, MIN_FINAL_CLTV_DELTA_ACCEPTED, PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) from .plugin import run_hook, DeviceMgr, Plugins from .version import ELECTRUM_VERSION @@ -1382,7 +1382,7 @@ class Commands(Logger): amount: Optional[Decimal] = None, memo: str = "", expiry: int = 3600, - min_final_cltv_expiry_delta: int = MIN_FINAL_CLTV_DELTA_FOR_INVOICE * 2, + min_final_cltv_expiry_delta: int = MIN_FINAL_CLTV_DELTA_ACCEPTED * 2, wallet: Abstract_Wallet = None ) -> dict: """ @@ -1399,23 +1399,23 @@ class Commands(Logger): assert payment_hash not in wallet.lnworker.payment_info, "Payment hash already used!" assert payment_hash not in wallet.lnworker.dont_settle_htlcs, "Payment hash already used!" assert wallet.lnworker.get_preimage(bfh(payment_hash)) is None, "Already got a preimage for this payment hash!" - assert MIN_FINAL_CLTV_DELTA_FOR_INVOICE < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value" + assert MIN_FINAL_CLTV_DELTA_ACCEPTED < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value" amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None inbound_capacity = wallet.lnworker.num_sats_can_receive() assert inbound_capacity > satoshis(amount or 0), \ f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment" - lnaddr, invoice = wallet.lnworker.get_bolt11_invoice( - payment_hash=bfh(payment_hash), - amount_msat=satoshis(amount) * 1000 if amount else None, - message=memo, - expiry=expiry, - min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, - fallback_address=None - ) wallet.lnworker.add_payment_info_for_hold_invoice( bfh(payment_hash), - satoshis(amount) if amount else None, + lightning_amount_sat=satoshis(amount) if amount else None, + min_final_cltv_delta=min_final_cltv_expiry_delta, + exp_delay=expiry, + ) + info = wallet.lnworker.get_payment_info(bfh(payment_hash)) + lnaddr, invoice = wallet.lnworker.get_bolt11_invoice( + payment_info=info, + message=memo, + fallback_address=None ) wallet.lnworker.dont_settle_htlcs[payment_hash] = None wallet.set_label(payment_hash, memo) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 9fbbcb5d9..31f0b8446 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2758,7 +2758,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.closing_warning_callbacks.append(warning_callback) def _check_ongoing_force_closures(self) -> Optional[str]: - from electrum.lnutil import MIN_FINAL_CLTV_DELTA_FOR_INVOICE + from electrum.lnutil import MIN_FINAL_CLTV_DELTA_ACCEPTED if not self.wallet.has_lightning(): return None if not self.network: @@ -2767,7 +2767,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if not force_closes: return # fixme: this is inaccurate, we need local_height - cltv_of_htlc - cltv_delta = MIN_FINAL_CLTV_DELTA_FOR_INVOICE + cltv_delta = MIN_FINAL_CLTV_DELTA_ACCEPTED msg = '\n\n'.join([ _("Pending channel force-close"), messages.MSG_FORCE_CLOSE_WARNING.format(cltv_delta), diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 025cc9ad8..c21d9271b 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -505,9 +505,10 @@ MIN_FUNDING_SAT = 200_000 # the minimum cltv_expiry accepted for newly received HTLCs # note: when changing, consider Blockchain.is_tip_stale() MIN_FINAL_CLTV_DELTA_ACCEPTED = 144 -# set it a tiny bit higher for invoices as blocks could get mined -# during forward path of payment -MIN_FINAL_CLTV_DELTA_FOR_INVOICE = MIN_FINAL_CLTV_DELTA_ACCEPTED + 3 + +# buffer added to min_final_cltv_delta of created bolt11 invoices to make verifying the cltv delta +# of incoming payment htlcs reliable even if some blocks have been mined during forwarding +MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE = 3 # the deadline for offered HTLCs: # the deadline after which the channel has to be failed and timed out on-chain diff --git a/electrum/lnworker.py b/electrum/lnworker.py index a4f667808..39f2013cb 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -65,11 +65,11 @@ from .lnchannel import Channel, AbstractChannel, ChannelState, PeerState, HTLCWi from .lnrater import LNRater from .lnutil import ( get_compressed_pubkey_from_bech32, serialize_htlc_key, deserialize_htlc_key, PaymentFailure, generate_keypair, - LnKeyFamily, LOCAL, REMOTE, MIN_FINAL_CLTV_DELTA_FOR_INVOICE, SENT, RECEIVED, HTLCOwner, UpdateAddHtlc, LnFeatures, + LnKeyFamily, LOCAL, REMOTE, MIN_FINAL_CLTV_DELTA_ACCEPTED, SENT, RECEIVED, HTLCOwner, UpdateAddHtlc, LnFeatures, ShortChannelID, HtlcLog, NoPathFound, InvalidGossipMsg, FeeBudgetExceeded, ImportedChannelBackupStorage, OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, - RecvMPPResolution, ReceivedMPPStatus, + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, ReceivedMPPStatus, RecvMPPResolution, ) from .lnonion import ( decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, @@ -119,12 +119,22 @@ class PaymentInfo: amount_msat: Optional[int] direction: int status: int + min_final_cltv_delta: int + expiry_delay: int + creation_ts: int = dataclasses.field(default_factory=lambda: int(time.time())) + + @property + def expiration_ts(self): + return self.creation_ts + self.expiry_delay def validate(self): assert isinstance(self.payment_hash, bytes) and len(self.payment_hash) == 32 assert self.amount_msat is None or isinstance(self.amount_msat, int) assert isinstance(self.direction, int) assert isinstance(self.status, int) + assert isinstance(self.min_final_cltv_delta, int) + assert isinstance(self.expiry_delay, int) and self.expiry_delay > 0 + assert isinstance(self.creation_ts, int) def __post_init__(self): self.validate() @@ -864,7 +874,8 @@ class LNWallet(LNWorker): LNWorker.__init__(self, self.node_keypair, features, config=self.config) self.lnwatcher = LNWatcher(self) self.lnrater: LNRater = None - self.payment_info = self.db.get_dict('lightning_payments') # RHASH -> amount, direction, is_paid + # lightning_payments: RHASH -> amount_msat, direction, status, min_final_cltv_delta, expiry_delay, creation_ts + self.payment_info = self.db.get_dict('lightning_payments') # type: dict[str, Tuple[Optional[int], int, int, int, int, int]] self._preimages = self.db.get_dict('lightning_preimages') # RHASH -> preimage self._bolt11_cache = {} # note: this sweep_address is only used as fallback; as it might result in address-reuse @@ -1567,6 +1578,8 @@ class LNWallet(LNWorker): amount_msat=amount_to_pay, direction=SENT, status=PR_UNPAID, + min_final_cltv_delta=min_final_cltv_delta, + expiry_delay=LN_EXPIRY_NEVER, ) self.save_payment_info(info) self.wallet.set_label(key, lnaddr.get_description()) @@ -2238,17 +2251,13 @@ class LNWallet(LNWorker): def get_bolt11_invoice( self, *, - payment_hash: bytes, - amount_msat: Optional[int], + payment_info: PaymentInfo, message: str, - expiry: int, # expiration of invoice (in seconds, relative) fallback_address: Optional[str], channels: Optional[Sequence[Channel]] = None, - min_final_cltv_expiry_delta: Optional[int] = None, ) -> Tuple[LnAddr, str]: - assert isinstance(payment_hash, bytes), f"expected bytes, but got {type(payment_hash)}" - - pair = self._bolt11_cache.get(payment_hash) + amount_msat = payment_info.amount_msat + pair = self._bolt11_cache.get(payment_info.payment_hash) if pair: lnaddr, invoice = pair assert lnaddr.get_amount_msat() == amount_msat @@ -2265,19 +2274,16 @@ class LNWallet(LNWorker): if needs_jit: # jit only works with single htlcs, mpp will cause LSP to open channels for each htlc invoice_features &= ~ LnFeatures.BASIC_MPP_OPT & ~ LnFeatures.BASIC_MPP_REQ - payment_secret = self.get_payment_secret(payment_hash) + payment_secret = self.get_payment_secret(payment_info.payment_hash) amount_btc = amount_msat/Decimal(COIN*1000) if amount_msat else None - if expiry == 0: - expiry = LN_EXPIRY_NEVER - if min_final_cltv_expiry_delta is None: - min_final_cltv_expiry_delta = MIN_FINAL_CLTV_DELTA_FOR_INVOICE + min_final_cltv_delta = payment_info.min_final_cltv_delta + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE lnaddr = LnAddr( - paymenthash=payment_hash, + paymenthash=payment_info.payment_hash, amount=amount_btc, tags=[ ('d', message), - ('c', min_final_cltv_expiry_delta), - ('x', expiry), + ('c', min_final_cltv_delta), + ('x', payment_info.expiry_delay), ('9', invoice_features), ('f', fallback_address), ] + routing_hints, @@ -2285,7 +2291,7 @@ class LNWallet(LNWorker): payment_secret=payment_secret) invoice = lnencode(lnaddr, self.node_keypair.privkey) pair = lnaddr, invoice - self._bolt11_cache[payment_hash] = pair + self._bolt11_cache[payment_info.payment_hash] = pair return pair def get_payment_secret(self, payment_hash): @@ -2299,14 +2305,23 @@ class LNWallet(LNWorker): payment_secret = self.get_payment_secret(payment_hash) return payment_hash + payment_secret - def create_payment_info(self, *, amount_msat: Optional[int], write_to_disk=True) -> bytes: + def create_payment_info( + self, *, + amount_msat: Optional[int], + min_final_cltv_delta: Optional[int] = None, + exp_delay: int = LN_EXPIRY_NEVER, + write_to_disk=True + ) -> bytes: payment_preimage = os.urandom(32) payment_hash = sha256(payment_preimage) + min_final_cltv_delta = min_final_cltv_delta or MIN_FINAL_CLTV_DELTA_ACCEPTED info = PaymentInfo( payment_hash=payment_hash, amount_msat=amount_msat, direction=RECEIVED, status=PR_UNPAID, + min_final_cltv_delta=min_final_cltv_delta, + expiry_delay=exp_delay ) self.save_preimage(payment_hash, payment_preimage, write_to_disk=False) self.save_payment_info(info, write_to_disk=False) @@ -2380,22 +2395,34 @@ class LNWallet(LNWorker): key = payment_hash.hex() with self.lock: if key in self.payment_info: - amount_msat, direction, status = self.payment_info[key] + stored_tuple = self.payment_info[key] + amount_msat, direction, status, min_final_cltv_delta, expiry_delay, creation_ts = stored_tuple return PaymentInfo( payment_hash=payment_hash, amount_msat=amount_msat, direction=direction, status=status, + min_final_cltv_delta=min_final_cltv_delta, + expiry_delay=expiry_delay, + creation_ts=creation_ts, ) return None - def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]): + def add_payment_info_for_hold_invoice( + self, + payment_hash: bytes, *, + lightning_amount_sat: Optional[int], + min_final_cltv_delta: int, + exp_delay: int, + ): amount = lightning_amount_sat * 1000 if lightning_amount_sat else None info = PaymentInfo( payment_hash=payment_hash, amount_msat=amount, direction=RECEIVED, status=PR_UNPAID, + min_final_cltv_delta=min_final_cltv_delta, + expiry_delay=exp_delay, ) self.save_payment_info(info, write_to_disk=False) @@ -2411,9 +2438,12 @@ class LNWallet(LNWorker): if old_info := self.get_payment_info(payment_hash=info.payment_hash): if info == old_info: return # already saved + if info.direction == SENT: + # allow saving of newer PaymentInfo if it is a sending attempt + old_info = dataclasses.replace(old_info, creation_ts=info.creation_ts) if info != dataclasses.replace(old_info, status=info.status): # differs more than in status. let's fail - raise Exception("payment_hash already in use") + raise Exception(f"payment_hash already in use: {info=} != {old_info=}") key = info.payment_hash.hex() self.payment_info[key] = dataclasses.astuple(info)[1:] # drop the payment hash at index 0 if write_to_disk: @@ -3031,12 +3061,14 @@ class LNWallet(LNWorker): raise Exception('Rebalance requires two different channels') if self.uses_trampoline() and chan1.node_id == chan2.node_id: raise Exception('Rebalance requires channels from different trampolines') - payment_hash = self.create_payment_info(amount_msat=amount_msat) - lnaddr, invoice = self.get_bolt11_invoice( - payment_hash=payment_hash, + payment_hash = self.create_payment_info( amount_msat=amount_msat, + exp_delay=3600, + ) + info = self.get_payment_info(payment_hash) + lnaddr, invoice = self.get_bolt11_invoice( + payment_info=info, message='rebalance', - expiry=3600, fallback_address=None, channels=[chan2], ) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index 64b16a56b..c2cd925f5 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -480,12 +480,11 @@ class NWCServer(Logger, EventListener): address=None ) req: Request = self.wallet.get_request(key) + info = self.wallet.lnworker.get_payment_info(req.payment_hash) try: lnaddr, b11 = self.wallet.lnworker.get_bolt11_invoice( - payment_hash=req.payment_hash, - amount_msat=amount_msat, + payment_info=info, message=description, - expiry=expiry, fallback_address=None ) except Exception: @@ -538,11 +537,10 @@ class NWCServer(Logger, EventListener): b11 = invoice.lightning_invoice elif self.wallet.get_request(invoice.rhash): direction = "incoming" + info = self.wallet.lnworker.get_payment_info(invoice.payment_hash) _, b11 = self.wallet.lnworker.get_bolt11_invoice( - payment_hash=bytes.fromhex(invoice.rhash), - amount_msat=invoice.amount_msat, + payment_info=info, message=invoice.message, - expiry=invoice.exp, fallback_address=None ) @@ -749,11 +747,10 @@ class NWCServer(Logger, EventListener): request: Optional[Request] = self.wallet.get_request(key) if not request or not request.is_lightning() or not status == PR_PAID: return + info = self.wallet.lnworker.get_payment_info(request.payment_hash) _, b11 = self.wallet.lnworker.get_bolt11_invoice( - payment_hash=request.payment_hash, - amount_msat=request.get_amount_msat(), + payment_info=info, message=request.message, - expiry=request.exp, fallback_address=None ) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 99468eb2e..b0b623083 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -36,7 +36,8 @@ from .util import ( 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, + MIN_FINAL_CLTV_DELTA_ACCEPTED) from .lnaddr import lndecode from .json_db import StoredObject, stored_in from . import constants @@ -66,7 +67,6 @@ MAX_LOCKTIME_DELTA = 100 MIN_FINAL_CLTV_DELTA_FOR_CLIENT = 3 * 144 # note: put in invoice, but is not enforced by receiver in lnpeer.py assert MIN_LOCKTIME_DELTA <= LOCKTIME_DELTA_REFUND <= MAX_LOCKTIME_DELTA assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED -assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_FOR_INVOICE assert MAX_LOCKTIME_DELTA < MIN_FINAL_CLTV_DELTA_FOR_CLIENT @@ -645,33 +645,38 @@ class SwapManager(Logger): else: invoice_amount_sat = lightning_amount_sat + # add payment info to lnworker + self.lnworker.add_payment_info_for_hold_invoice( + payment_hash, + lightning_amount_sat=invoice_amount_sat, + min_final_cltv_delta=min_final_cltv_expiry_delta or MIN_FINAL_CLTV_DELTA_ACCEPTED, + exp_delay=300, + ) + info = self.lnworker.get_payment_info(payment_hash) lnaddr1, invoice = self.lnworker.get_bolt11_invoice( - payment_hash=payment_hash, - amount_msat=invoice_amount_sat * 1000, + payment_info=info, message='Submarine swap', - expiry=300, fallback_address=None, channels=channels, - min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, ) margin_to_get_refund_tx_mined = MIN_LOCKTIME_DELTA if not (locktime + margin_to_get_refund_tx_mined < self.network.get_local_height() + lnaddr1.get_min_final_cltv_delta()): raise Exception( f"onchain locktime ({locktime}+{margin_to_get_refund_tx_mined}) " f"too close to LN-htlc-expiry ({self.network.get_local_height()+lnaddr1.get_min_final_cltv_delta()})") - # add payment info to lnworker - self.lnworker.add_payment_info_for_hold_invoice(payment_hash, invoice_amount_sat) if prepay: - prepay_hash = self.lnworker.create_payment_info(amount_msat=prepay_amount_sat*1000) + prepay_hash = self.lnworker.create_payment_info( + amount_msat=prepay_amount_sat*1000, + min_final_cltv_delta=min_final_cltv_expiry_delta or MIN_FINAL_CLTV_DELTA_ACCEPTED, + exp_delay=300, + ) + info = self.lnworker.get_payment_info(prepay_hash) lnaddr2, prepay_invoice = self.lnworker.get_bolt11_invoice( - payment_hash=prepay_hash, - amount_msat=prepay_amount_sat * 1000, + payment_info=info, message='Submarine swap prepayment', - expiry=300, fallback_address=None, channels=channels, - min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, ) self.lnworker.bundle_payments([payment_hash, prepay_hash]) self._prepayments[prepay_hash] = payment_hash diff --git a/electrum/wallet.py b/electrum/wallet.py index 7bf807e61..9bd1e9c22 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3011,11 +3011,11 @@ class Abstract_Wallet(ABC, Logger, EventListener): return '' amount_msat = req.get_amount_msat() or None assert (amount_msat is None or amount_msat > 0), amount_msat + info = self.lnworker.get_payment_info(payment_hash) + assert info.amount_msat == amount_msat, f"{info.amount_msat=} != {amount_msat=}" lnaddr, invoice = self.lnworker.get_bolt11_invoice( - payment_hash=payment_hash, - amount_msat=amount_msat, + payment_info=info, message=req.message, - expiry=req.exp, fallback_address=None) return invoice @@ -3031,7 +3031,11 @@ class Abstract_Wallet(ABC, Logger, EventListener): timestamp = int(Request._get_cur_time()) if address is None: assert self.has_lightning() - payment_hash = self.lnworker.create_payment_info(amount_msat=amount_msat, write_to_disk=False) + payment_hash = self.lnworker.create_payment_info( + amount_msat=amount_msat, + exp_delay=exp_delay, + write_to_disk=False, + ) else: payment_hash = None outputs = [PartialTxOutput.from_address_and_value(address, amount_sat)] if address else [] diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index f527d5d47..95d038e9a 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -73,7 +73,7 @@ class WalletUnfinished(WalletFileException): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 60 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 61 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -236,6 +236,7 @@ class WalletDBUpgrader(Logger): self._convert_version_58() self._convert_version_59() self._convert_version_60() + self._convert_version_61() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1157,6 +1158,18 @@ class WalletDBUpgrader(Logger): cb['multisig_funding_privkey'] = None self.data['seed_version'] = 60 + def _convert_version_61(self): + if not self._is_upgrade_method_needed(60, 60): + return + # adding additional fields to PaymentInfo + lightning_payments = self.data.get('lightning_payments', {}) + expiry_never = 100 * 365 * 24 * 60 * 60 + migration_time = int(time.time()) + for rhash, (amount_msat, direction, is_paid) in list(lightning_payments.items()): + new = (amount_msat, direction, is_paid, 147, expiry_never, migration_time) + lightning_payments[rhash] = new + self.data['seed_version'] = 61 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 85c241520..f7d84020a 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -41,7 +41,7 @@ from electrum.lnworker import PaymentInfo, RECEIVED from electrum.lnonion import OnionFailureCode, OnionRoutingFailure from electrum.lnutil import UpdateAddHtlc from electrum.lnutil import LOCAL, REMOTE -from electrum.invoices import PR_PAID, PR_UNPAID, Invoice +from electrum.invoices import PR_PAID, PR_UNPAID, Invoice, LN_EXPIRY_NEVER from electrum.interface import GracefulDisconnect from electrum.simple_config import SimpleConfig from electrum.fee_policy import FeeTimeEstimates, FEE_ETA_TARGETS @@ -563,15 +563,8 @@ class TestPeer(ElectrumTestCase): payment_preimage = os.urandom(32) if payment_hash is None: payment_hash = sha256(payment_preimage) - info = PaymentInfo( - payment_hash=payment_hash, - amount_msat=amount_msat, - direction=RECEIVED, - status=PR_UNPAID, - ) if payment_preimage: w2.save_preimage(payment_hash, payment_preimage) - w2.save_payment_info(info) if include_routing_hints: routing_hints = w2.calc_routing_hints_for_invoice(amount_msat) else: @@ -584,7 +577,16 @@ class TestPeer(ElectrumTestCase): else: payment_secret = None if min_final_cltv_delta is None: - min_final_cltv_delta = lnutil.MIN_FINAL_CLTV_DELTA_FOR_INVOICE + min_final_cltv_delta = lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED + info = PaymentInfo( + payment_hash=payment_hash, + amount_msat=amount_msat, + direction=RECEIVED, + status=PR_UNPAID, + min_final_cltv_delta=min_final_cltv_delta, + expiry_delay=LN_EXPIRY_NEVER, + ) + w2.save_payment_info(info) lnaddr1 = LnAddr( paymenthash=payment_hash, amount=amount_btc,