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/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/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 98bd12988..39f2013cb 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, @@ -65,23 +65,27 @@ 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, + ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, ) -from .lnonion import decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket 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: @@ -115,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() @@ -860,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 @@ -1563,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()) @@ -2234,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 @@ -2261,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, @@ -2281,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): @@ -2295,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) @@ -2376,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) @@ -2407,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: @@ -3027,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], ) @@ -3349,6 +3385,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/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 3eb4313f6..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 @@ -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: @@ -559,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: @@ -580,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,