From b7a512845ff9b945867190d849ccdea4efb69625 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 8 Oct 2025 15:34:16 +0200 Subject: [PATCH] onion_message: factor out get_blinded_paths_to_me from get_blinded_reply_paths. the former also calculates payinfo information for payment scenarios. include payment_relay struct for payment blinded_paths. --- electrum/onion_message.py | 130 +++++++++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 23 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 1fdb3bb90..74cc19d46 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -31,10 +31,11 @@ import dataclasses from random import random from types import MappingProxyType -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, List +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple import electrum_ecc as ecc +from electrum.channel_db import get_mychannel_policy from electrum.lnrouter import PathEdge from electrum.logging import get_logger, Logger from electrum.crypto import sha256, get_ecdh @@ -42,7 +43,7 @@ from electrum.lnmsg import OnionWireSerializer from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, OnionHopsDataSingle, decrypt_onionmsg_data_tlv, encrypt_onionmsg_data_tlv, get_shared_secrets_along_route, new_onion_packet, encrypt_hops_recipient_data) -from electrum.lnutil import LnFeatures +from electrum.lnutil import LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED from electrum.util import OldTaskGroup, log_exceptions @@ -61,6 +62,7 @@ logger = get_logger(__name__) REQUEST_REPLY_PATHS_MAX = 3 +PAYMENT_PATHS_MAX = 3 class NoRouteFound(Exception): @@ -93,6 +95,7 @@ def create_blinded_path( is_non_final_node = i < len(path) - 1 if is_non_final_node: + # spec: alt: short_channel_id instead of next_node_id recipient_data = { # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length 'next_node_id': {'node_id': path[i+1]} @@ -122,6 +125,16 @@ def create_blinded_path( return blinded_path +def encode_blinded_path(blinded_path: dict): + with io.BytesIO() as blinded_path_fd: + OnionWireSerializer.write_field( + fd=blinded_path_fd, + field_type='blinded_path', + count=1, + value=blinded_path) + return blinded_path_fd.getvalue() + + def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: shared_secret = get_ecdh(privkey, blinding) b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) @@ -371,35 +384,106 @@ def get_blinded_reply_paths( max_paths: int = REQUEST_REPLY_PATHS_MAX, preferred_node_id: bytes = None ) -> Sequence[dict]: - """construct a list of blinded reply_paths. + """construct a list of blinded reply-paths for onion message. + """ + mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path + paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, + preferred_node_id=preferred_node_id, onion_message=True) + return paths + + +def get_blinded_paths_to_me( + lnwallet: 'LNWallet', + final_recipient_data: dict, + *, + max_paths: int = PAYMENT_PATHS_MAX, + preferred_node_id: bytes = None, + onion_message: bool = False +) -> Tuple[Sequence[dict], Sequence[dict]]: + """construct a list of blinded paths. current logic: - - uses current onion_message capable channel peers if exist - - otherwise, uses current onion_message capable peers + - uses channels peers if not onion_message + - uses current onion_message capable channel peers if exist and if onion_message + - otherwise, uses current onion_message capable peers if onion_message - prefers preferred_node_id if given - - reply_path introduction points are direct peers only (TODO: longer reply paths)""" + - reply_path introduction points are direct peers only (TODO: longer paths)""" # TODO: build longer paths and/or add dummy hops to increase privacy my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - my_onionmsg_channels = [chan for chan in my_active_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and - lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] - my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + my_channels = my_active_channels + if onion_message: + my_channels = [chan for chan in my_active_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and + lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] result = [] + payinfo = [] mynodeid = lnwallet.node_keypair.pubkey - mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path - if len(my_onionmsg_channels): - # randomize list, but prefer preferred_node_id - rchans = sorted(my_onionmsg_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0) - for chan in rchans[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], mydata) - result.append(blinded_path) - elif len(my_onionmsg_peers): - # randomize list, but prefer preferred_node_id - rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0) - for peer in rpeers[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], mydata) - result.append(blinded_path) + local_height = lnwallet.network.get_local_height() - return result + if len(my_channels): + # randomize list, but prefer preferred_node_id + rchans = sorted(my_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0) + for chan in rchans[:max_paths]: + hop_extras = None + if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) + # get policy + cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) + + dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED + + # TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop + # and determine the cltv delta sums and fee sums of the hops for the payinfo struct. + # current assumption is len(blinded_path) == 2 (us and peer) + sum_cltv_expiry_delta = cp.cltv_delta + sum_fee_base_msat = cp.fee_base_msat + sum_fee_proportional_millionths = cp.fee_proportional_millionths + # path htlc limits + blinded_path_min_htlc_msat = cp.htlc_minimum_msat + blinded_path_max_htlc_msat = cp.htlc_maximum_msat + + hop_extras = [{ + # spec: MUST include encrypted_data_tlv.payment_relay for each non-final node. + 'payment_relay': { + 'cltv_expiry_delta': cp.cltv_delta, + 'fee_base_msat': cp.fee_base_msat, + 'fee_proportional_millionths': cp.fee_proportional_millionths, + }, + # spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node: + # + # max_cltv_expiry to the largest block height at which the route is allowed to be used, starting + # from the final node's chosen max_cltv_expiry height at which the route should expire, adding + # the final node's min_final_cltv_expiry_delta and then adding + # encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop. + # + # htlc_minimum_msat to the largest minimum HTLC value the nodes will allow. + 'payment_constraints': { + 'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta, + 'htlc_minimum_msat': blinded_path_min_htlc_msat + } + }] + payinfo.append({ + 'fee_base_msat': sum_fee_base_msat, + 'fee_proportional_millionths': sum_fee_proportional_millionths, + 'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED, + 'htlc_minimum_msat': blinded_path_min_htlc_msat, + 'htlc_maximum_msat': blinded_path_max_htlc_msat, + 'flen': 0, + 'features': bytes(0) + }) + blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data, + hop_extras=hop_extras) + result.append(blinded_path) + elif onion_message: + # we can use peers even without channels for onion messages + my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if + peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + if len(my_onionmsg_peers): + # randomize list, but prefer preferred_node_id + rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0) + for peer in rpeers[:max_paths]: + blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) + result.append(blinded_path) + + return result, payinfo class Timeout(Exception): pass