From 261f94984e1808d7ff32d0d14a50feeb6b749dde Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 01/45] lnonion: support payment path blinding move decryption of recipient_data to process_onion_packet, add handling of blinding in peer msgs, handle properties in ProcessedOnionPacket in case of path blinding, move repeated next blinding key derivation to func lnonion.next_blinding_from_shared_secret() Co-Authored-By: f321x --- electrum/lnonion.py | 74 ++++++++++++++++++++++++++++++++--- electrum/lnpeer.py | 27 ++++++++++--- electrum/lnutil.py | 8 +++- electrum/lnwire/peer_wire.csv | 5 ++- electrum/lnworker.py | 5 ++- electrum/onion_message.py | 54 ++++++++++--------------- electrum/wallet_db.py | 14 ++++++- tests/test_onion_message.py | 16 ++------ 8 files changed, 141 insertions(+), 62 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 043213a82d21..43102d66be69 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -54,6 +54,7 @@ class UnsupportedOnionPacketVersion(Exception): pass class InvalidOnionMac(Exception): pass class InvalidOnionPubkey(Exception): pass class InvalidPayloadSize(Exception): pass +class InvalidBlindedOnion(Exception): pass @dataclass(frozen=True, kw_only=True) @@ -193,8 +194,7 @@ def get_blinded_node_id(node_id: bytes, shared_secret: bytes): return blinded_node_id.get_public_key_bytes() -def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: - shared_secret = get_ecdh(privkey, blinding) +def blinding_privkey(privkey: bytes, shared_secret: bytes) -> bytes: b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) b_hmac_int = int.from_bytes(b_hmac, byteorder="big") @@ -389,7 +389,9 @@ class ProcessedOnionPacket(NamedTuple): are_we_final: bool hop_data: OnionHopsDataSingle next_packet: OnionPacket - trampoline_onion_packet: OnionPacket + trampoline_onion_packet: Optional[OnionPacket] + blinded_path_recipient_data: Optional[MappingProxyType] = None + next_path_key: Optional[bytes] = None @property def amt_to_forward(self) -> Optional[int]: @@ -404,19 +406,39 @@ def outgoing_cltv_value(self) -> Optional[int]: @property def next_chan_scid(self) -> Optional[ShortChannelID]: k1 = k2 = 'short_channel_id' + if self.blinded_path_recipient_data is not None: + return self.get_from_recipient_data(k1, k2, ShortChannelID) return self._get_from_payload(k1, k2, ShortChannelID) + @property + def next_node_id(self) -> Optional[bytes]: + if self.blinded_path_recipient_data is not None: + return self.get_from_recipient_data('next_node_id', 'node_id', bytes) + return None + @property def total_msat(self) -> Optional[int]: + if self.blinded_path_recipient_data is not None: + return self._get_from_payload('total_amount_msat', 'total_msat', int) return self._get_from_payload('payment_data', 'total_msat', int) @property def payment_secret(self) -> Optional[bytes]: + if self.blinded_path_recipient_data is not None: + return None return self._get_from_payload('payment_data', 'payment_secret', bytes) def _get_from_payload(self, k1: str, k2: str, res_type: type): + return self._get_from(self.hop_data.payload, k1, k2, res_type) + + def get_from_recipient_data(self, k1: str, k2: str, res_type: type): + assert self.blinded_path_recipient_data is not None + return self._get_from(self.blinded_path_recipient_data, k1, k2, res_type) + + @staticmethod + def _get_from(payload: Mapping, k1: str, k2: str, res_type: type): try: - result = self.hop_data.payload[k1][k2] + result = payload[k1][k2] return res_type(result) except Exception: return None @@ -429,6 +451,7 @@ def process_onion_packet( *, associated_data: bytes = b'', is_trampoline=False, + current_path_key: Optional[bytes] = None, tlv_stream_name='payload') -> ProcessedOnionPacket: # TODO: check Onion features ( PERM|NODE|3 (required_node_feature_missing ) if onion_packet.version != 0: @@ -436,6 +459,11 @@ def process_onion_packet( if not ecc.ECPubkey.is_pubkey_bytes(onion_packet.public_key): raise InvalidOnionPubkey() is_onion_message = tlv_stream_name == 'onionmsg_tlv' + recipient_data_shared_secret = None + if current_path_key: + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + # the onion is encrypted to our blinded node id + our_onion_private_key = blinding_privkey(our_onion_private_key, recipient_data_shared_secret) shared_secret = get_ecdh(our_onion_private_key, onion_packet.public_key) # check message integrity mu_key = get_bolt04_onion_key(b'mu', shared_secret) @@ -454,6 +482,28 @@ def process_onion_packet( next_hops_data = xor_bytes(padded_header, stream_bytes) next_hops_data_fd = io.BytesIO(next_hops_data) hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd, tlv_stream_name=tlv_stream_name) + + blinded_path_recipient_data = {} + initial_path_key = hop_data.payload.get('current_path_key', {}).get('path_key') + encrypted_recipient_data = hop_data.payload.get('encrypted_recipient_data', {}).get('encrypted_recipient_data') + if encrypted_recipient_data is not None: + # we are part of a blinded path + if bool(initial_path_key) == bool(current_path_key): + raise InvalidBlindedOnion("need exactly one path key") + if not current_path_key: # we are the introduction point + current_path_key = initial_path_key + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + assert recipient_data_shared_secret + try: + blinded_path_recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=recipient_data_shared_secret, + encrypted_recipient_data=encrypted_recipient_data, + ) + except Exception as e: + raise InvalidBlindedOnion from e + elif current_path_key or initial_path_key: + raise InvalidBlindedOnion("got path key without encrypted_recipient_data") + # trampoline trampoline_onion_packet = hop_data.payload.get('trampoline_onion_packet') if trampoline_onion_packet: @@ -473,7 +523,20 @@ def process_onion_packet( else: # we are an intermediate node; forwarding are_we_final = False - return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet) + + next_path_key = blinded_path_recipient_data.get('next_path_key_override', {}).get('path_key') + if not are_we_final and current_path_key and not next_path_key: + assert recipient_data_shared_secret + next_path_key = next_blinding_from_shared_secret(current_path_key, recipient_data_shared_secret) + + return ProcessedOnionPacket( + are_we_final, + hop_data, + next_onion_packet, + trampoline_onion_packet, + util.make_object_immutable(blinded_path_recipient_data) if current_path_key else None, + next_path_key, + ) def compare_trampoline_onions( @@ -689,6 +752,7 @@ class OnionFailureCode(IntEnum): EXPIRY_TOO_FAR = 21 INVALID_ONION_PAYLOAD = PERM | 22 MPP_TIMEOUT = 23 + INVALID_ONION_BLINDING = BADONION | PERM | 24 TRAMPOLINE_FEE_INSUFFICIENT = NODE | 51 TRAMPOLINE_EXPIRY_TOO_SOON = NODE | 52 diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 4b88f87d6e7d..e2d463308c67 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -32,7 +32,7 @@ from .transaction import PartialTxOutput, match_script_against_template, Sighash from .logging import Logger from . import lnonion -from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, +from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, InvalidBlindedOnion, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, OnionParsingError) @@ -1959,6 +1959,7 @@ def send_htlc( cltv_abs: int, onion: OnionPacket, session_key: Optional[bytes] = None, + next_path_key: Optional[bytes] = None ) -> UpdateAddHtlc: assert chan.can_send_update_add_htlc(), f"cannot send updates: {chan.short_channel_id}" htlc = UpdateAddHtlc(amount_msat=amount_msat, payment_hash=payment_hash, cltv_abs=cltv_abs, timestamp=int(time.time())) @@ -1966,6 +1967,10 @@ def send_htlc( if session_key: chan.set_onion_key(htlc.htlc_id, session_key) # should it be the outer onion secret? self.logger.info(f"starting payment. htlc: {htlc}") + extra = {} + if next_path_key: + extra = {'update_add_htlc_tlvs': {'blinded_path': {'path_key': next_path_key}}} + self.send_message( "update_add_htlc", channel_id=chan.channel_id, @@ -1973,7 +1978,9 @@ def send_htlc( cltv_expiry=htlc.cltv_abs, amount_msat=htlc.amount_msat, payment_hash=htlc.payment_hash, - onion_routing_packet=onion.to_bytes()) + onion_routing_packet=onion.to_bytes(), + **extra, + ) self.maybe_send_commitment(chan) return htlc @@ -2080,12 +2087,14 @@ def on_update_add_htlc(self, chan: Channel, payload): cltv_abs = payload["cltv_expiry"] amount_msat_htlc = payload["amount_msat"] onion_packet = payload["onion_routing_packet"] + path_key = payload.get("update_add_htlc_tlvs", {}).get("blinded_path", {}).get("path_key") htlc = UpdateAddHtlc( amount_msat=amount_msat_htlc, payment_hash=payment_hash, cltv_abs=cltv_abs, timestamp=int(time.time()), - htlc_id=htlc_id) + htlc_id=htlc_id, + path_key=path_key) self.logger.info(f"on_update_add_htlc. chan {chan.short_channel_id}. htlc={str(htlc)}") if chan.get_state() != ChannelState.OPEN: raise RemoteMisbehaving(f"received update_add_htlc while chan.get_state() != OPEN. state was {chan.get_state()!r}") @@ -2348,6 +2357,7 @@ def _fail_htlc_set( processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) if raw_error: @@ -2869,6 +2879,7 @@ def _run_htlc_switch_iteration(self): processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, + current_path_key=htlc.path_key, is_trampoline=False, ) payment_key: str = self._check_unfulfilled_htlc( @@ -2976,6 +2987,7 @@ def log_fail_reason(reason: str): processed_onion = self._process_incoming_onion_packet( onion_packet=self._parse_onion_packet(mpp_htlc.unprocessed_onion), payment_hash=mpp_htlc.htlc.payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) onion_payload = processed_onion.hop_data.payload @@ -3028,6 +3040,7 @@ def _check_unfulfilled_htlc_set( processed_onion = self._process_incoming_onion_packet( onion_packet=self._parse_onion_packet(mpp_htlc.unprocessed_onion), payment_hash=payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, # this is always the outer onion ) processed_onions[mpp_htlc] = (processed_onion, None) @@ -3284,9 +3297,10 @@ def _process_incoming_onion_packet( self, onion_packet: OnionPacket, *, payment_hash: bytes, + current_path_key: Optional[bytes] = None, is_trampoline: bool = False) -> ProcessedOnionPacket: onion_hash = onion_packet.onion_hash - cache_key = sha256(onion_hash + payment_hash + bytes([is_trampoline])) # type: ignore + cache_key = sha256(onion_hash + payment_hash + bytes([is_trampoline]) + (current_path_key or b'')) # type: ignore if cached_onion := self._processed_onion_cache.get(cache_key): return cached_onion try: @@ -3294,7 +3308,8 @@ def _process_incoming_onion_packet( onion_packet, our_onion_private_key=self.privkey, associated_data=payment_hash, - is_trampoline=is_trampoline) + is_trampoline=is_trampoline, + current_path_key=current_path_key) self._processed_onion_cache[cache_key] = processed_onion except UnsupportedOnionPacketVersion: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_VERSION, data=onion_hash) @@ -3302,6 +3317,8 @@ def _process_incoming_onion_packet( raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_KEY, data=onion_hash) except InvalidOnionMac: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_HMAC, data=onion_hash) + except InvalidBlindedOnion: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=onion_hash) except Exception as e: self.logger.warning(f"error processing onion packet: {e!r}") raise OnionParsingError(data=onion_hash) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8baff6182aed..06b8a5d24919 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1931,16 +1931,19 @@ class UpdateAddHtlc: cltv_abs: int htlc_id: Optional[int] = dataclasses.field(default=None) timestamp: int = dataclasses.field(default_factory=lambda: int(time.time())) + path_key: Optional[bytes] = None @staticmethod @stored_at('/channels/*/log/*/adds/*', tuple) - def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc': + def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp, path_key = None) -> 'UpdateAddHtlc': return UpdateAddHtlc( amount_msat=amount_msat, payment_hash=bytes.fromhex(rhash), cltv_abs=cltv_abs, htlc_id=htlc_id, - timestamp=timestamp) + timestamp=timestamp, + path_key=None if not path_key else bytes.fromhex(path_key), + ) def to_json(self): self._validate() @@ -1952,6 +1955,7 @@ def _validate(self): assert isinstance(self.cltv_abs, int) and self.cltv_abs <= NLOCKTIME_BLOCKHEIGHT_MAX, self.cltv_abs assert isinstance(self.htlc_id, int) or self.htlc_id is None, self.htlc_id assert isinstance(self.timestamp, int), self.timestamp + assert self.path_key is None or (isinstance(self.path_key, bytes) and len(self.path_key) == 33) def __post_init__(self): self._validate() diff --git a/electrum/lnwire/peer_wire.csv b/electrum/lnwire/peer_wire.csv index 2dafaede3ed3..e5d6c7baaa97 100644 --- a/electrum/lnwire/peer_wire.csv +++ b/electrum/lnwire/peer_wire.csv @@ -116,8 +116,9 @@ msgdata,update_add_htlc,amount_msat,u64, msgdata,update_add_htlc,payment_hash,sha256, msgdata,update_add_htlc,cltv_expiry,u32, msgdata,update_add_htlc,onion_routing_packet,byte,1366 -tlvtype,update_add_htlc_tlvs,blinding_point,0 -tlvdata,update_add_htlc_tlvs,blinding_point,blinding,point, +msgdata,update_add_htlc,tlvs,update_add_htlc_tlvs, +tlvtype,update_add_htlc_tlvs,blinded_path,0 +tlvdata,update_add_htlc_tlvs,blinded_path,path_key,point, msgtype,update_fulfill_htlc,130 msgdata,update_fulfill_htlc,channel_id,channel_id, msgdata,update_fulfill_htlc,id,u64, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 874c74987f8f..a732521079e2 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1493,6 +1493,7 @@ async def open_channel_just_in_time( next_cltv_abs: int, payment_hash: bytes, next_onion: OnionPacket, + next_path_key: Optional[bytes] = None, ) -> str: assert self.config.OPEN_ZEROCONF_CHANNELS # if an exception is raised during negotiation, we raise an OnionRoutingFailure. @@ -1536,7 +1537,8 @@ async def wait_for_channel(): payment_hash=payment_hash, amount_msat=next_amount_msat_htlc, cltv_abs=next_cltv_abs, - onion=next_onion) + onion=next_onion, + next_path_key=next_path_key) async def wait_for_preimage(): while self.get_preimage(payment_hash) is None: await asyncio.sleep(1) @@ -4064,6 +4066,7 @@ def log_fail_reason(reason: str): amount_msat=next_amount_msat_htlc, cltv_abs=next_cltv_abs, onion=processed_onion.next_packet, + next_path_key=processed_onion.next_path_key, ) except BaseException as e: log_fail_reason(f"error sending message to next_peer={next_chan.node_id.hex()}") diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 6aa1050eca4c..8b5cad0bf908 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -29,16 +29,16 @@ import time import random -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union, Mapping import electrum_ecc as ecc from electrum.channel_db import get_mychannel_policy from electrum.lnrouter import PathEdge, NoChannelPolicy from electrum.logging import get_logger, Logger -from electrum.crypto import sha256, get_ecdh +from electrum.crypto import get_ecdh from electrum.lnmsg import OnionWireSerializer -from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, blinding_privkey, +from electrum.lnonion import (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, next_blinding_from_shared_secret) @@ -732,7 +732,7 @@ def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes: # TODO: use payload to determine prefix? return b'electrum' + key - def _get_request_for_path_id(self, recipient_data: dict) -> Optional[Request]: + def _get_request_for_path_id(self, recipient_data: Mapping) -> Optional[Request]: path_id = recipient_data.get('path_id', {}).get('data') if not path_id: return None @@ -745,7 +745,7 @@ def _get_request_for_path_id(self, recipient_data: dict) -> Optional[Request]: self.logger.warning('not a reply to our request (unknown request)') return req - def on_onion_message_received(self, recipient_data: dict, payload: dict) -> None: + def on_onion_message_received(self, recipient_data: Mapping, payload: Mapping) -> None: # we are destination, sanity checks # - if `encrypted_data_tlv` contains `allowed_features`: # - MUST ignore the message if: @@ -766,11 +766,11 @@ def on_onion_message_received(self, recipient_data: dict, payload: dict) -> None else: self.on_onion_message_received_reply(req, recipient_data, payload) - def on_onion_message_received_reply(self, request: Request, recipient_data: dict, payload: dict) -> None: + def on_onion_message_received_reply(self, request: Request, recipient_data: Mapping, payload: Mapping) -> None: assert request is not None, 'Request is mandatory' request.future.set_result((recipient_data, payload)) - def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: dict) -> None: + def on_onion_message_received_unsolicited(self, recipient_data: Mapping, payload: Mapping) -> None: self.logger.debug('unsolicited onion_message received') self.logger.debug(f'payload: {payload!r}') @@ -801,10 +801,9 @@ def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: d def on_onion_message_forward( self, - recipient_data: dict, + recipient_data: Mapping, onion_packet: OnionPacket, - blinding: bytes, - shared_secret: bytes + next_path_key: bytes, ) -> None: if recipient_data.get('path_id'): self.logger.error('cannot forward onion_message, path_id in encrypted_data_tlv') @@ -832,17 +831,6 @@ def on_onion_message_forward( self.logger.info(f'next node {next_node_id.hex()} not a peer, dropping message') return - # blinding override? - next_path_key_override = recipient_data.get('next_path_key_override') - if next_path_key_override: - next_path_key = next_path_key_override.get('path_key') - else: - # E_i+1=SHA256(E_i||ss_i) * E_i - blinding_factor = sha256(blinding + shared_secret) - blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big") - next_public_key_int = ecc.ECPubkey(blinding) * blinding_factor_int - next_path_key = next_public_key_int.get_public_key_bytes() - if is_dummy_hop: self.process_onion_message_packet(next_path_key, onion_packet) return @@ -865,28 +853,26 @@ def on_onion_message(self, payload: dict) -> None: onion_packet = OnionPacket.from_bytes(packet) self.process_onion_message_packet(path_key, onion_packet) - def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacket) -> None: - our_privkey = blinding_privkey(self.lnwallet.node_keypair.privkey, blinding) - processed_onion_packet = process_onion_packet(onion_packet, our_privkey, tlv_stream_name='onionmsg_tlv') + def process_onion_message_packet(self, path_key: bytes, onion_packet: OnionPacket) -> None: + processed_onion_packet = process_onion_packet( + onion_packet, + self.lnwallet.node_keypair.privkey, + current_path_key=path_key, + tlv_stream_name='onionmsg_tlv', + ) payload = processed_onion_packet.hop_data.payload - self.logger.debug(f'onion peeled: {processed_onion_packet!r}') if not processed_onion_packet.are_we_final: if any([x not in ['encrypted_recipient_data'] for x in payload.keys()]): - self.logger.error('unexpected data in payload') # non-final nodes only encrypted_recipient_data + self.logger.error(f'unexpected data in {payload.keys()=}') # non-final nodes only encrypted_recipient_data return - # decrypt - shared_secret = get_ecdh(self.lnwallet.node_keypair.privkey, blinding) - recipient_data = decrypt_onionmsg_data_tlv( - shared_secret=shared_secret, - encrypted_recipient_data=payload['encrypted_recipient_data']['encrypted_recipient_data'] - ) - + recipient_data = processed_onion_packet.blinded_path_recipient_data self.logger.debug(f'parsed recipient_data: {recipient_data!r}') if processed_onion_packet.are_we_final: self.on_onion_message_received(recipient_data, payload) else: - self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, blinding, shared_secret) + assert isinstance(processed_onion_packet.next_path_key, bytes), processed_onion_packet + self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, processed_onion_packet.next_path_key) diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index c20180a6d595..15a33aba32d0 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -71,7 +71,7 @@ def __init__(self, wallet_db: 'WalletDB'): # 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 = 71 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 72 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -259,6 +259,7 @@ def upgrade(self): self._convert_version_69() self._convert_version_70() self._convert_version_71() + self._convert_version_72() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1435,6 +1436,17 @@ def _convert_version_71(self): self.data['genesis_blockhash'] = constants.net.GENESIS self.data['seed_version'] = 71 + def _convert_version_72(self): + """ + Addition of `path_key` field to lnutil.UpdateAddHtlc. + Doesn't need any specific upgrade as UpdateAddHtlc.from_tuple() has None default arg for `path_key`. + This just bumps the seed_version to prevent cryptic errors when the user tries to open a db with blinded + htlcs in an older Electrum version. + """ + if not self._is_upgrade_method_needed(71, 71): + return + self.data['seed_version'] = 72 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 8595fae36a3c..c26dcf2bcf50 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -164,17 +164,9 @@ def test_decode_onion_message(self): def test_decrypt_onion_message(self): o = OnionPacket.from_bytes(ONION_MESSAGE_PACKET) our_privkey = bfh(test_vectors['decrypt']['hops'][0]['privkey']) - blinding = bfh(test_vectors['route']['first_path_key']) + path_key = bfh(test_vectors['route']['first_path_key']) - shared_secret = get_ecdh(our_privkey, blinding) - b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) - b_hmac_int = int.from_bytes(b_hmac, byteorder="big") - - our_privkey_int = int.from_bytes(our_privkey, byteorder="big") - our_privkey_int = our_privkey_int * b_hmac_int % ecc.CURVE_ORDER - our_privkey = our_privkey_int.to_bytes(32, byteorder="big") - - p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv') + p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv', current_path_key=path_key) self.assertEqual(p.hop_data.blind_fields, {}) self.assertEqual(p.hop_data.hmac, bfh('a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2')) @@ -191,8 +183,8 @@ def test_decrypt_onion_message(self): }) def test_blinding_privkey(self): - a = blinding_privkey(bfh('4141414141414141414141414141414141414141414141414141414141414141'), - bfh('031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f')) + a = blinding_privkey(bfh('4141414141414141414141414141414141414141414141414141414141414141'), # privkey + bfh('16a5d6594dbaa363305952ed9354fa9c38ddda718d218c5da6be19f0eaa11613')) # shared secret self.assertEqual(a, bfh('7e959bf6bdd3a98caf26cbbee7b69678381d5fa2882c6c12eb2042c2367264b0')) def test_create_blinded_path(self): From 631c3f929d7c5350e04d81025e2010ed4ac21aab Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 02/45] lnpeer/lnworker: validate and forward blinded path htlcs check encrypted_recipient_data constraints, fail invalid blinded htlcs with INVALID_ONION_BLINDING, reject being the final recipient of a blinded payment for now, maybe_forward_htlc: derive forwarding parameters from payment_relay data, allow next hop lookup by node_id, validate_features: reject unknown odd feature bits in blinded path context Co-Authored-By: f321x --- electrum/lnpeer.py | 62 +++++++++++++++++++++++++- electrum/lnrouter.py | 8 ++++ electrum/lnutil.py | 9 +++- electrum/lnworker.py | 101 ++++++++++++++++++++++++++++++------------- 4 files changed, 146 insertions(+), 34 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index e2d463308c67..ac6de3583c00 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -2110,6 +2110,46 @@ def on_update_add_htlc(self, chan: Channel, payload): chan.receive_htlc(htlc, onion_packet) util.trigger_callback('htlc_added', chan, htlc, RECEIVED) + @staticmethod + def _check_encrypted_recipient_data( + htlc: UpdateAddHtlc, + processed_onion: ProcessedOnionPacket, + log_fail_reason: Callable[[str], None], + ): + """Check constraints inside the blinded path recipient data.""" + exc_invalid_onion_blinding = OnionRoutingFailure(OnionFailureCode.INVALID_ONION_BLINDING, bytes(32)) + if not processed_onion.blinded_path_recipient_data: + log_fail_reason("empty encrypted_recipient_data for blinded htlc") + raise exc_invalid_onion_blinding + + # bolt-04 payment_constraint check for blinded payments + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L306-L309 + if 'payment_constraints' in processed_onion.blinded_path_recipient_data: + max_cltv_expiry = processed_onion.get_from_recipient_data('payment_constraints', 'max_cltv_expiry', int) + htlc_minimum_msat = processed_onion.get_from_recipient_data('payment_constraints', 'htlc_minimum_msat', int) + if max_cltv_expiry is None or htlc_minimum_msat is None \ + or htlc.cltv_abs > max_cltv_expiry or htlc.amount_msat < htlc_minimum_msat: + log_fail_reason(f"htlc exceeds blinded path payment_constraints: {max_cltv_expiry=} {htlc_minimum_msat=}") + raise exc_invalid_onion_blinding + + if not processed_onion.are_we_final and 'path_id' in processed_onion.blinded_path_recipient_data: + log_fail_reason("path_id field in non-final recipient data") + raise exc_invalid_onion_blinding + + # allowed_features check, in practice they should be empty + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L310-L315 + feature_bytes = processed_onion.get_from_recipient_data('allowed_features', 'features', bytes) or b'' + allowed_features = LnFeatures(int.from_bytes(feature_bytes, byteorder="big", signed=False)) + try: + validate_features(allowed_features, context=LnFeatureContexts.BLINDED_PATH) + except IncompatibleOrInsaneFeatures as e: + log_fail_reason(f"unsupported features in blinded path recipient data: {repr(e)} {allowed_features.get_names()=}") + raise exc_invalid_onion_blinding + + if processed_onion.next_node_id and processed_onion.next_chan_scid: + log_fail_reason(f"{processed_onion.blinded_path_recipient_data=} contains both node_id and next_chan_scid") + raise exc_invalid_onion_blinding + @staticmethod def _check_accepted_final_htlc( *, chan: Channel, @@ -2136,9 +2176,26 @@ def _check_accepted_final_htlc( code=OnionFailureCode.FINAL_INCORRECT_CLTV_EXPIRY, data=htlc.cltv_abs.to_bytes(4, byteorder="big")) + exc_invalid_onion_blinding = OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) exc_incorrect_or_unknown_pd = OnionRoutingFailure( code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, - data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + + if processed_onion.blinded_path_recipient_data is not None: # payment over blinded path + # spec: MUST return an error if the payload contains other tlv fields than allowed_payload_keys + allowed_payload_fields = ('encrypted_recipient_data', 'current_path_key', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat') + if any(f not in allowed_payload_fields for f in processed_onion.hop_data.payload): + log_fail_reason(f"unknown key in blinded payload: {processed_onion.hop_data.payload.keys()=}") + raise exc_invalid_onion_blinding + + path_id = processed_onion.get_from_recipient_data('path_id', 'data', bytes) + if not path_id: + log_fail_reason(f"'path_id' missing in recipient_data") + raise exc_invalid_onion_blinding + + log_fail_reason('we cannot receive blinded payments yet.') + raise exc_invalid_onion_blinding + if (total_msat := processed_onion.total_msat) is None: log_fail_reason(f"'total_msat' missing from onion") raise exc_incorrect_or_unknown_pd @@ -2187,6 +2244,9 @@ def _check_unfulfilled_htlc( _log_fail_reason(f"our chain tip is stale: {local_height=}") raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + if processed_onion.blinded_path_recipient_data is not None: + self._check_encrypted_recipient_data(htlc=htlc, processed_onion=processed_onion, log_fail_reason=_log_fail_reason) + payment_hash = htlc.payment_hash if not processed_onion.are_we_final: if outer_onion_payment_secret: diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 790303e77c28..181f1adb1b5a 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -61,6 +61,14 @@ def fee_for_edge_msat(forwarded_amount_msat: int, fee_base_msat: int, fee_propor + (forwarded_amount_msat * fee_proportional_millionths // 1_000_000) +def amount_after_fee_msat(*, incoming_amount_msat: int, fee_base_msat: int, fee_proportional_millionths: int) -> int: + """Amount to forward after subtracting our fee from the incoming amount, as specified in bolt 04. + https://github.com/lightning/bolts/blob/ca82e0c909437123e80486fde5829bb9fd605dd0/04-onion-routing.md?plain=1#L339-L340 + """ + return ((incoming_amount_msat - fee_base_msat) * 1_000_000 + 1_000_000 + fee_proportional_millionths - 1) \ + // (1_000_000 + fee_proportional_millionths) + + @attr.s(slots=True) class PathEdge: start_node = attr.ib(type=bytes, kw_only=True, repr=lambda val: val.hex()) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 06b8a5d24919..d5176f6e9ad3 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1846,8 +1846,13 @@ def validate_features(features: int, *, context: LnFeatureContexts) -> LnFeature features = LnFeatures(features) enabled_features = list_enabled_bits(features) for fbit in enabled_features: - if (1 << fbit) & LN_FEATURES_IMPLEMENTED == 0 and fbit % 2 == 0: - raise UnknownEvenFeatureBits(fbit) + if (1 << fbit) & LN_FEATURES_IMPLEMENTED == 0: + if fbit % 2 == 0: + raise UnknownEvenFeatureBits(fbit) + if context == LnFeatureContexts.BLINDED_PATH: + # bolt 04 requires unknown odd features to be failed :/ + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L313 + raise IncompatibleOrInsaneFeatures(f"unknown odd features in blinded path context: {features.get_names()=}") if not features.validate_transitive_dependencies(context=context): raise IncompatibleOrInsaneFeatures(f"not all transitive dependencies are set. " f"features={features}") diff --git a/electrum/lnworker.py b/electrum/lnworker.py index a732521079e2..3d8599debcb3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -82,7 +82,7 @@ from .lnmsg import decode_msg from .lnrouter import ( RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, - LNPathInconsistent, fee_for_edge_msat, + LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, ) from .lnwatcher import LNWatcher from .submarine_swaps import SwapManager @@ -3987,39 +3987,72 @@ def log_fail_reason(reason: str): chain = self.network.blockchain() if chain.is_tip_stale(): raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - if (next_chan_scid := processed_onion.next_chan_scid) is None: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if (next_amount_msat_htlc := processed_onion.amt_to_forward) is None: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if (next_cltv_abs := processed_onion.outgoing_cltv_value) is None: + # a blinded payment may reference the next hop by node id or scid + next_chan_scid = processed_onion.next_chan_scid + next_node_id = processed_onion.next_node_id + if next_chan_scid is None and next_node_id is None: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - next_chan = self.get_channel_by_short_id(next_chan_scid) + if processed_onion.blinded_path_recipient_data is None: + if (next_amount_msat_htlc := processed_onion.amt_to_forward) is None: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + if (next_cltv_abs := processed_onion.outgoing_cltv_value) is None: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + else: + # blinded path, calculate values from payment_relay constraints. + allowed_payload_fields = ('encrypted_recipient_data', 'current_path_key') + if any(f not in allowed_payload_fields for f in processed_onion.hop_data.payload): + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) + + cltv_expiry_delta = processed_onion.get_from_recipient_data('payment_relay', 'cltv_expiry_delta', int) + fee_base_msat = processed_onion.get_from_recipient_data('payment_relay', 'fee_base_msat', int) + fee_proportional_millionths = processed_onion.get_from_recipient_data('payment_relay', 'fee_proportional_millionths', int) + if not all(v is not None and v >= 0 for v in (cltv_expiry_delta, fee_base_msat, fee_proportional_millionths)): + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) + + next_cltv_abs = htlc.cltv_abs - cltv_expiry_delta + next_amount_msat_htlc = amount_after_fee_msat( + incoming_amount_msat=htlc.amount_msat, + fee_base_msat=fee_base_msat, + fee_proportional_millionths=fee_proportional_millionths, + ) + if next_cltv_abs <= 0 or next_amount_msat_htlc <= 0: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) - if self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): + next_chan = self.get_channel_by_short_id(next_chan_scid) if next_chan_scid else None + next_peer = self.lnpeermgr.get_peer_by_pubkey(next_node_id) if next_node_id else None + if next_chan_scid and not next_peer and 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): + # search for a fitting channel if: + # 1. blinded forwarding requested 'next_node_id' + # 2. next_peer is a zeroconf peer (next_chan_scid is a zeroconf peer alias) + # If 'next_chan_scid' is given we don't fall back to another channel + if next_peer and (not next_chan_scid or next_peer.accepts_zeroconf()): + # check if an existing channel can be used. + for _next_chan in next_peer.channels.values(): + # todo: split the payment + if _next_chan.can_pay(next_amount_msat_htlc): + next_chan = _next_chan 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) + + if not next_chan and next_peer and next_peer.accepts_zeroconf(): + 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, + next_path_key=processed_onion.next_path_key) local_height = chain.height() if next_chan is None: - log_fail_reason(f"cannot find next_chan {next_chan_scid}") + log_fail_reason(f"cannot find next_chan {(next_chan_scid or b'').hex()=} {(next_node_id or b'').hex()=}") raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') - outgoing_chan_upd = next_chan.get_outgoing_gossip_channel_update(scid=next_chan_scid)[2:] + # send no channel_update for blinded payments, it will be overridden with INVALID_ONION_BLINDING later, and bolt04 says: + # TODO: ...'the channel_update field is no longer mandatory and nodes are expected to transition away from including it'... + outgoing_chan_upd = next_chan.get_outgoing_gossip_channel_update(scid=next_chan_scid)[2:] \ + if processed_onion.blinded_path_recipient_data is None else b'' 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(): @@ -4041,11 +4074,17 @@ def log_fail_reason(reason: str): 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: + if processed_onion.blinded_path_recipient_data is not None: + max_amt_to_forward = amount_after_fee_msat( + incoming_amount_msat=htlc.amount_msat, + fee_base_msat=next_chan.forwarding_fee_base_msat, + fee_proportional_millionths=next_chan.forwarding_fee_proportional_millionths) + else: + max_amt_to_forward = htlc.amount_msat - 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 next_amount_msat_htlc > max_amt_to_forward: 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): @@ -4055,10 +4094,10 @@ def log_fail_reason(reason: str): 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.lnpeermgr.get_peer_by_pubkey(next_chan.node_id) - if next_peer is None: + if next_peer is None and (next_peer := self.lnpeermgr.get_peer_by_pubkey(next_chan.node_id)) 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) + assert next_peer.pubkey == next_chan.node_id try: next_htlc = next_peer.send_htlc( chan=next_chan, From 209aa11019e9d588bb4104be77b1043b65e4325a Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 3 Jul 2026 13:32:13 +0200 Subject: [PATCH 03/45] lnpeer: use INVALID_ONION_BLINDING for errors inside path Minor refactor of htlc failure handling to override routing errors with `INVALID_ONION_BLINDING` if they are part of a blinded payment. Also implements random delay at introduction point against probing. --- electrum/lnpeer.py | 191 ++++++++++++++++++++++++++++++++----------- tests/test_lnpeer.py | 35 ++++++++ 2 files changed, 177 insertions(+), 49 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index ac6de3583c00..696af30d8f1a 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -7,9 +7,11 @@ from collections import OrderedDict, defaultdict import asyncio import os +import random import time from typing import Tuple, Dict, TYPE_CHECKING, Optional, Union, Set, Callable, Coroutine, List, Any from datetime import datetime +import enum import functools from functools import partial import inspect @@ -66,6 +68,12 @@ LN_P2P_NETWORK_TIMEOUT = 20 +class HtlcContext(enum.Enum): + DEFAULT = enum.auto() # not part of a blinded path (or we are both introduction node and final recipient) + INTRO_NODE = enum.auto() # we are the introduction point of a blinded path + INSIDE_PATH = enum.auto() # we are a non-introduction node inside a blinded path + + class Peer(Logger, EventListener): # note: in general this class is NOT thread-safe. Most methods are assumed to be running on asyncio thread. @@ -129,6 +137,7 @@ def __init__( self.taskgroup = OldTaskGroup() # HTLCs offered by REMOTE, that we started removing but are still active: self.received_htlcs_pending_removal = set() # type: Set[Tuple[Channel, int]] + self._deferred_fail_htlcs = LRUCache(maxsize=1000) # type: LRUCache[Tuple[Channel, int], float] self.received_htlc_removed_event = asyncio.Event() self._htlc_switch_iterstart_event = asyncio.Event() self._htlc_switch_iterdone_event = asyncio.Event() @@ -2396,7 +2405,6 @@ def _fail_htlc_set( assert htlc_set.resolution in (RecvMPPResolution.FAILED, RecvMPPResolution.EXPIRED) raw_error, error_code, error_data = error_tuple - local_height = self.network.blockchain().height() payment_hash = htlc_set.get_payment_hash() assert payment_hash is not None, "Empty htlc set?" for mpp_htlc in list(htlc_set.htlcs): @@ -2413,6 +2421,8 @@ def _fail_htlc_set( self.logger.debug(f"{mpp_htlc=} was already failed before, dropping it.") htlc_set = htlc_set._replace(htlcs=htlc_set.htlcs - {mpp_htlc}) continue + if not self._blinded_intro_fail_delay_elapsed(chan, htlc_id): + continue # failure already decided, don't re-process until the delay has elapsed onion_packet = self._parse_onion_packet(mpp_htlc.unprocessed_onion) processed_onion_packet = self._process_incoming_onion_packet( onion_packet, @@ -2420,9 +2430,9 @@ def _fail_htlc_set( current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) - if raw_error: - error_bytes = obfuscate_onion_error(raw_error, onion_packet.public_key, self.privkey) - else: + + error = raw_error + if not error: assert isinstance(error_code, (OnionFailureCode, int)) if error_code == OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS: amount_to_forward = processed_onion_packet.amt_to_forward @@ -2435,20 +2445,71 @@ def _fail_htlc_set( is_trampoline=True, ) amount_to_forward = processed_trampoline_onion_packet.amt_to_forward - error_data = amount_to_forward.to_bytes(8, byteorder="big") - e = OnionRoutingFailure(code=error_code, data=error_data or b'') - error_bytes = e.to_wire_msg(onion_packet, self.privkey, local_height) - self.fail_htlc( + if amount_to_forward is not None: + error_data = amount_to_forward.to_bytes(8, byteorder="big") + error = OnionRoutingFailure(code=error_code, data=error_data or b'') + + if self._fail_incoming_htlc( chan=chan, - htlc_id=htlc_id, - error_bytes=error_bytes, - ) - htlc_set = htlc_set._replace(htlcs=htlc_set.htlcs - {mpp_htlc}) + htlc=mpp_htlc.htlc, + htlc_context=self._get_htlc_context(htlc=mpp_htlc.htlc, processed_onion=processed_onion_packet), + error=error, + onion_packet=onion_packet, + ): + htlc_set = htlc_set._replace(htlcs=htlc_set.htlcs - {mpp_htlc}) self.lnworker.received_mpp_htlcs[payment_key] = htlc_set # save updated set - def fail_htlc(self, *, chan: Channel, htlc_id: int, error_bytes: bytes): - self.logger.info(f"fail_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") + def _fail_incoming_htlc( + self, *, + chan: Channel, + htlc: UpdateAddHtlc, + htlc_context: HtlcContext, + error: Union[bytes, OnionRoutingFailure], + onion_packet: Optional[OnionPacket], + can_defer: bool = True, + ) -> bool: + """ + Decides: + * error to return: `error` is what we intended to send. Inside a blinded path we might + override errors with `INVALID_ONION_BLINDING`. + * message type: update_fail_malformed_htlc (onion parsing errors and errors inside + a blinded path) or update_fail_htlc (everything else). + * timing: as blinded path introduction node the failure is deferred until a random + delay has elapsed (unless `can_defer` is False). + Returns False if failing was deferred, in which case the htlc must not be deleted and failed again + in the next switch iteration. + """ + assert chan.can_update_ctx(proposer=LOCAL), f"cannot send updates: {chan.short_channel_id}" + send_malformed = isinstance(error, OnionParsingError) + if htlc_context in (HtlcContext.INTRO_NODE, HtlcContext.INSIDE_PATH): + # override any local or downstream error, within a blinded path only invalid_onion_blinding may be returned + if onion_packet is not None: + error_data = onion_packet.onion_hash + else: # we couldn't parse the onion, the parsing error carries the hash of the raw payload + assert isinstance(error, OnionParsingError), f"unparsed onion but got {error!r}" + error_data = error.data + error = OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=error_data) + send_malformed = htlc_context == HtlcContext.INSIDE_PATH + if htlc_context == HtlcContext.INTRO_NODE and can_defer \ + and self._maybe_defer_blinded_intro_fail(chan, htlc): + return False # don't fail yet, wait for delay + + if send_malformed: + assert isinstance(error, OnionRoutingFailure), repr(error) + self._send_update_fail_malformed_htlc(chan=chan, htlc_id=htlc.htlc_id, reason=error) + else: + assert onion_packet is not None, "cannot construct an onion error without the onion" + if isinstance(error, bytes): # pre-encrypted error from a downstream node, just wrap it + error_bytes = obfuscate_onion_error(error, onion_packet.public_key, self.privkey) + else: + error_bytes = error.to_wire_msg(onion_packet, self.privkey, self.network.get_local_height()) + self._send_update_fail_htlc(chan=chan, htlc_id=htlc.htlc_id, error_bytes=error_bytes) + self._deferred_fail_htlcs.pop((chan, htlc.htlc_id), None) + return True + + def _send_update_fail_htlc(self, *, chan: Channel, htlc_id: int, error_bytes: bytes): + self.logger.info(f"_send_update_fail_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") assert chan.can_update_ctx(proposer=LOCAL), f"cannot send updates: {chan.short_channel_id}" self.received_htlcs_pending_removal.add((chan, htlc_id)) chan.fail_htlc(htlc_id) @@ -2460,8 +2521,8 @@ def fail_htlc(self, *, chan: Channel, htlc_id: int, error_bytes: bytes): reason=error_bytes) self.maybe_send_commitment(chan) - def fail_malformed_htlc(self, *, chan: Channel, htlc_id: int, reason: OnionParsingError): - self.logger.info(f"fail_malformed_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") + def _send_update_fail_malformed_htlc(self, *, chan: Channel, htlc_id: int, reason: OnionRoutingFailure): + self.logger.info(f"_send_update_fail_malformed_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") assert chan.can_update_ctx(proposer=LOCAL), f"cannot send updates: {chan.short_channel_id}" if not (reason.code & OnionFailureCodeMetaFlag.BADONION and len(reason.data) == 32): raise Exception(f"unexpected reason when sending 'update_fail_malformed_htlc': {reason!r}") @@ -2475,6 +2536,39 @@ def fail_malformed_htlc(self, *, chan: Channel, htlc_id: int, reason: OnionParsi failure_code=reason.code) self.maybe_send_commitment(chan) + @staticmethod + def _get_htlc_context(*, htlc: UpdateAddHtlc, processed_onion: Optional[ProcessedOnionPacket]) -> HtlcContext: + """ + Return the context of the htlc (see `HtlcContext`) to decide on how we should fail given htlc. + https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/02-peer-protocol.md?plain=1#L2972-L2984 + """ + if htlc.path_key: # we are got a `path_key` in `update_add_htlc` and are therefore inside a blinded path + return HtlcContext.INSIDE_PATH + if processed_onion is not None and processed_onion.blinded_path_recipient_data is not None \ + and not processed_onion.are_we_final: + return HtlcContext.INTRO_NODE + # if we are introduction node and final, the sender knows we are the recipient, so it seems fine + # to send a regular error (instead of overriding with INVALID_ONION_BLINDING) + return HtlcContext.DEFAULT + + def _maybe_defer_blinded_intro_fail(self, chan: Channel, htlc: UpdateAddHtlc) -> bool: + """ + As introduction point we should delay the failure by a random delay to prevent senders + from probing positions inside the blinded path. + https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/02-peer-protocol.md?plain=1#L2979 + """ + if (chan, htlc.htlc_id) not in self._deferred_fail_htlcs: + delay = random.uniform(1.5, 3.0) + self._deferred_fail_htlcs[(chan, htlc.htlc_id)] = min(time.time() + delay, htlc.timestamp + 10) + return not self._blinded_intro_fail_delay_elapsed(chan, htlc.htlc_id) + + def _blinded_intro_fail_delay_elapsed(self, chan: Channel, htlc_id: int) -> bool: + """True if the htlc is ready to be failed (delay elapsed or no delay).""" + if self.lnworker.lnpeermgr.stopping_soon: + return True + deadline = self._deferred_fail_htlcs.get((chan, htlc_id)) + return deadline is None or time.time() >= deadline + def on_revoke_and_ack(self, chan: Channel, payload) -> None: self.logger.info(f'on_revoke_and_ack. chan {chan.short_channel_id}. ctn: {chan.get_oldest_unrevoked_ctn(REMOTE)}') if not chan.can_update_ctx(proposer=REMOTE): @@ -2924,18 +3018,16 @@ def _run_htlc_switch_iteration(self): continue htlc = chan.hm.get_htlc_by_id(REMOTE, htlc_id) + if not self._blinded_intro_fail_delay_elapsed(chan, htlc_id): + continue # failure already decided, don't re-process until the delay has elapsed + + onion_packet = None # type: Optional[OnionPacket] + processed_onion_packet = None # type: Optional[ProcessedOnionPacket] + error = None # type: Optional[OnionRoutingFailure] + unexpected_exc = None # type: Optional[Exception] + htlc_context = None # type: Optional[HtlcContext] try: onion_packet = self._parse_onion_packet(onion_packet_hex) - except OnionParsingError as e: - self.fail_malformed_htlc( - chan=chan, - htlc_id=htlc.htlc_id, - reason=e, - ) - del unfulfilled[htlc_id] - continue - - try: processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, @@ -2953,31 +3045,32 @@ def _run_htlc_switch_iteration(self): htlc=htlc, unprocessed_onion_packet=onion_packet_hex, # outer onion if trampoline ) - except OnionParsingError as e: # could be raised when parsing the inner trampoline onion - self.fail_malformed_htlc( - chan=chan, - htlc_id=htlc.htlc_id, - reason=e, - ) - except Exception as e: + except InvalidBlindedOnion: + error = OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) + htlc_context = HtlcContext.INTRO_NODE if htlc.path_key is None else HtlcContext.INSIDE_PATH + except OnionRoutingFailure as e: # Fail the htlc directly if it fails to pass these tests, it will not get added to a htlc set. # https://github.com/lightning/bolts/blob/14272b1bd9361750cfdb3e5d35740889a6b510b5/04-onion-routing.md?plain=1#L388 - reraise = False - if isinstance(e, OnionRoutingFailure): - orf = e - else: - orf = OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - reraise = True # propagate this out, as this might suggest a bug - error_bytes = orf.to_wire_msg(onion_packet, self.privkey, self.network.get_local_height()) - self.fail_htlc( - chan=chan, - htlc_id=htlc.htlc_id, - error_bytes=error_bytes, - ) - if reraise: - raise - finally: + error = e + except Exception as e: + error = OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + unexpected_exc = e # propagate this out after failing the htlc, as this might suggest a bug + + if error is None: + del unfulfilled[htlc_id] + continue + htlc_context = htlc_context or self._get_htlc_context(htlc=htlc, processed_onion=processed_onion_packet) + if self._fail_incoming_htlc( + chan=chan, + htlc=htlc, + htlc_context=htlc_context, + error=error, + onion_packet=onion_packet, + can_defer=unexpected_exc is None, + ): del unfulfilled[htlc_id] + if unexpected_exc is not None: + raise unexpected_exc # 2. Step: Acting on sets of htlcs. # Doing further checks that have to be done on sets of htlcs (e.g. total amount checks) @@ -3378,7 +3471,7 @@ def _process_incoming_onion_packet( except InvalidOnionMac: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_HMAC, data=onion_hash) except InvalidBlindedOnion: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=onion_hash) + raise # similar to OnionParsingError, except that we know this was a blinded payment except Exception as e: self.logger.warning(f"error processing onion packet: {e!r}") raise OnionParsingError(data=onion_hash) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 19e023115521..254145695acc 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -782,6 +782,41 @@ async def test_simple_payment_failure_with_hold_invoice(self): with self.assertRaises(PaymentFailure): await self._test_simple_payment(test_trampoline=test_trampoline, test_hold_invoice=True, test_failure=True) + async def test_blinded_htlc_fails_with_invalid_onion_blinding(self): + """ + Alice -> Bob. Alice sets a path_key in update_add_htlc, Bob should fail with invalid_onion_blinding. + """ + graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) + p1, p2 = graph.peers.values() + w1, w2 = graph.workers.values() + path_key = ECPrivkey.generate_random_key().get_public_key_bytes() + orig_send_htlc = p1.send_htlc + def send_htlc_with_path_key(**kwargs): + return orig_send_htlc(**{**kwargs, 'next_path_key': path_key}) + + async def pay(pay_req): + result, log = await w1.pay_invoice(pay_req) + self.assertFalse(result) + self.assertEqual(OnionFailureCode.INVALID_ONION_BLINDING, log[0].failure_msg.code) + raise PaymentFailure() + + async def f(): + async with OldTaskGroup() as group: + for peer in (p1, p2): + await group.spawn(peer._message_loop()) + await group.spawn(peer.htlc_switch()) + for peer in (p1, p2): + await peer.initialized + lnaddr, pay_req = self.prepare_invoice(w2) + await group.spawn(pay(pay_req)) + + with mock.patch.object(p1, 'send_htlc', send_htlc_with_path_key): + with self.assertLogs('electrum', level='INFO') as logs: + with self.assertRaises(PaymentFailure): + await f() + self.assertTrue(any('bob->alice' in msg and 'fail_malformed_htlc' in msg for msg in logs.output)) + self.assertTrue(any('alice->bob' in msg and 'on_update_fail_malformed_htlc' in msg for msg in logs.output)) + async def test_check_invoice_before_payment(self): graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) p1, p2 = graph.peers.values() From 29fb667a0c86a80ab790694b56913beb261b999a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Dec 2025 17:58:35 +0100 Subject: [PATCH 04/45] lnworker: add ROUTE_BLINDING features to LNWALLET_FEATURES ... and lnutil.LN_FEATURES_IMPLEMENTED --- electrum/lnutil.py | 1 + electrum/lnworker.py | 1 + 2 files changed, 2 insertions(+) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index d5176f6e9ad3..80ac7c2772cc 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1713,6 +1713,7 @@ def name_minimal(self): | LnFeatures.OPTION_ANCHORS_OPT | LnFeatures.OPTION_ANCHORS_REQ | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_OPT | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_REQ | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_REQ + | LnFeatures.OPTION_ROUTE_BLINDING_OPT | LnFeatures.OPTION_ROUTE_BLINDING_REQ ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 3d8599debcb3..87ccc5081825 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -210,6 +210,7 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ + | LnFeatures.OPTION_ROUTE_BLINDING_OPT ) LNGOSSIP_FEATURES = ( From 078aa51fecf5e740261f0abea40321754d0a0ecf Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 17 Jul 2026 10:40:26 +0200 Subject: [PATCH 05/45] lnworker: add ONION_MESSAGE features to LNWALLET_FEATURES ... and lnutil.LN_FEATURES_IMPLEMENTED --- electrum/lnutil.py | 1 + electrum/lnworker.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 80ac7c2772cc..cd5d0e83c4c4 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1714,6 +1714,7 @@ def name_minimal(self): | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_OPT | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_REQ | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_REQ | LnFeatures.OPTION_ROUTE_BLINDING_OPT | LnFeatures.OPTION_ROUTE_BLINDING_REQ + | LnFeatures.OPTION_ONION_MESSAGE_OPT | LnFeatures.OPTION_ONION_MESSAGE_REQ ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 87ccc5081825..da3c1f5e405b 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -211,6 +211,7 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ | LnFeatures.OPTION_ROUTE_BLINDING_OPT + | LnFeatures.OPTION_ONION_MESSAGE_OPT ) LNGOSSIP_FEATURES = ( @@ -1023,8 +1024,6 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No features = LNWALLET_FEATURES if self.config.OPEN_ZEROCONF_CHANNELS: features |= LnFeatures.OPTION_ZEROCONF_OPT - if self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS or self.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: - features |= LnFeatures.OPTION_ONION_MESSAGE_OPT if self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS and self.config.LIGHTNING_USE_GOSSIP: features |= LnFeatures.GOSSIP_QUERIES_OPT # signal we have gossip to fetch Logger.__init__(self) From ede5061a18988bb618ec29a2b0408f6d59533a24 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 4 May 2026 16:11:37 +0200 Subject: [PATCH 06/45] lnpeer: add send_onion_message method Adds a separate method to `Peer` specifically for sending onion messages. This allows to 'enqueue' a (onion) message send before the peer is initialized without having to externalize waiting for init from `Peer`. This way we can establish a peer connection in `OnionMessageManager` and immediately fire an onion message on the `Peer`. Using the Queue also prevents the timing/retry logic in `OnionMessageManager` from breaking down if a single peer (of multiple available ones) takes a long time to send for whatever reason. Also creates a simpler API for callers (they don't have to pass a message_name and packet len). --- electrum/lnpeer.py | 45 +++++++++++++++++++++++++++++++++++++ electrum/onion_message.py | 16 ++++--------- tests/test_onion_message.py | 2 +- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 696af30d8f1a..ca0742ca4e90 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -66,6 +66,7 @@ LN_P2P_NETWORK_TIMEOUT = 20 +ONION_MESSAGE_SEND_DELAY = 0.25 class HtlcContext(enum.Enum): @@ -150,6 +151,9 @@ def __init__( self._last_commitsig_sent_time = time.monotonic() self._last_ping_recv_time = min(0, time.monotonic()) + self._onion_message_send_queue = asyncio.Queue(maxsize=100) # type: asyncio.Queue[tuple[bytes, bytes, bytes]] + self._pending_onion_messages = set() # type: set[bytes] + def send_message(self, message_name: str, **kwargs): assert util.get_running_loop() == util.get_asyncio_loop(), f"this must be run on the asyncio thread!" assert type(message_name) is str @@ -162,6 +166,46 @@ def send_message(self, message_name: str, **kwargs): self.transport.send_bytes(raw_msg) # could `await self.transport.writer.drain()`, but not async + def send_onion_message(self, *, path_key: bytes, onion_message_packet: bytes): + """ + Can be called from any thread and before peer is initialized. + If called with the same message again, while its predecessor wasn't sent yet, the duplicate attempt gets dropped. + """ + def add(): + msg_id = sha256(path_key + onion_message_packet) + if msg_id in self._pending_onion_messages: + return + try: + self._onion_message_send_queue.put_nowait((path_key, onion_message_packet, msg_id)) + except asyncio.QueueFull: + return # onion messages are unreliable, it's fine to drop it, OnionMessageManager will retry + self._pending_onion_messages.add(msg_id) + + util.run_sync_function_on_asyncio_thread(add, block=False) + + async def _send_onion_messages(self): + await self.initialized + while True: + path_key, onion_message_packet, msg_id = await self._onion_message_send_queue.get() + try: + # maybe we connected as direct connection fallback, and it turns out the peer doesn't support onion messages + if not self.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT): + self.logger.debug(f"dropping enqueued onion message, peer doesn't support onion messages") + continue + raw_msg = encode_msg( + 'onion_message', + path_key=path_key, + len=len(onion_message_packet), + onion_message_packet=onion_message_packet, + ) + await self.transport.send_bytes_and_drain(raw_msg) + except Exception: + self.logger.warning(f"failed to send onion message: {onion_message_packet.hex()}, {path_key.hex()}", exc_info=True) + finally: + self._pending_onion_messages.remove(msg_id) + # prevent getting rate limited by blasting the peer after init + await asyncio.sleep(ONION_MESSAGE_SEND_DELAY) + def _store_raw_msg_if_local_update(self, raw_msg: bytes, *, message_name: str, channel_id: Optional[bytes]): is_commitment_signed = message_name == "commitment_signed" if not (message_name.startswith("update_") or is_commitment_signed): @@ -576,6 +620,7 @@ async def main_loop(self): await group.spawn(self._forward_gossip()) if self.network.lngossip != self.lnworker: await group.spawn(self.htlc_switch()) + await group.spawn(self._send_onion_messages()) async def _process_gossip(self): while True: diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 8b5cad0bf908..62f609edd7f6 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -386,11 +386,9 @@ def send_onion_message_to( path_key = ecc.ECPrivkey(session_key).get_public_key_bytes() - peer.send_message( - "onion_message", + peer.send_onion_message( path_key=path_key, - len=len(packet_b), - onion_message_packet=packet_b + onion_message_packet=packet_b, ) @@ -606,15 +604,9 @@ async def process_forward_queue(self) -> None: onion_packet_b = onion_packet.to_bytes() next_peer = self.lnwallet.lnpeermgr.get_peer_by_pubkey(node_id) - if not next_peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT): - self.logger.debug('forward dropped, next peer is not ONION_MESSAGE capable') - continue - - next_peer.send_message( - "onion_message", + next_peer.send_onion_message( path_key=blinding, - len=len(onion_packet_b), - onion_message_packet=onion_packet_b + onion_message_packet=onion_packet_b, ) except BaseException as e: self.logger.debug(f'error while sending {node_id=} e={e!r}') diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index c26dcf2bcf50..6f824b2eeee7 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -278,7 +278,7 @@ async def wait_one_htlc_switch_iteration(self, *args): def is_initialized(self): return True - def send_message(self, *args, **kwargs): + def send_onion_message(self, *args, **kwargs): if self.on_send_message: self.on_send_message(*args, **kwargs) From 53d1277c78ec6baf8795646616d5f0cffa77e0dc Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 23 Jul 2026 10:41:56 +0200 Subject: [PATCH 07/45] onion_message: only pick trampoline with onion message support Only pick trampoline nodes that support onion messages when creating a route to deliver a onion message. --- electrum/onion_message.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 62f609edd7f6..e4d443fb0b60 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -192,7 +192,11 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque raise NoRouteFound('no path found, peer_addr available', peer_address=peer_addr) else: # if we use trampoline just assume the trampoline will open a direct connection to the next_node_id - trampoline_peers = [p for p in lnwallet.lnpeermgr.peers.values() if lnwallet.is_trampoline_peer(p.pubkey)] + trampoline_peers = [ + p for p in lnwallet.lnpeermgr.peers.values() \ + if lnwallet.is_trampoline_peer(p.pubkey) \ + and p.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT) + ] if trampoline_peers: random_trampoline_peer = random.choice(trampoline_peers) return [ From 6ba858c8ccf332f5847871e0bfc09572d2bc5d80 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 23 Jul 2026 08:53:42 +0200 Subject: [PATCH 08/45] commands: use `OnionMessageManager.submit_send` ... for the `send_onion_message` CLI command to make it properly use the retry and direct connection logic. --- electrum/commands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/electrum/commands.py b/electrum/commands.py index 37686f9c9a49..39aaf1e8be46 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -2271,7 +2271,11 @@ async def send_onion_message(self, node_id_or_blinded_path_hex: str, message: st } try: - send_onion_message_to(wallet.lnworker, node_id_or_blinded_path, destination_payload) + wallet.lnworker.onion_message_manager.submit_send( + payload=destination_payload, + node_id_or_blinded_paths=node_id_or_blinded_path, + ).add_done_callback(lambda t: t.cancelled() or t.exception()) + # not awaiting task as there won't be a response for 'message' so it would raise Timeout return {'success': True} except Exception as e: msg = str(e) From e39762cafbdbdedc0cbe1a812422e07f294ea019 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 24 Nov 2025 15:16:20 +0100 Subject: [PATCH 09/45] onion_message: perform a direct peer connection for sending request when we have tried all blinded paths and for none of them we could find a route and we have a network address available for the destination (either node_id or blinded path introduction point). Co-Authored-By: Sander van Grieken --- electrum/onion_message.py | 138 ++++++++++++++++++++++++++++++------ electrum/simple_config.py | 8 +++ tests/test_onion_message.py | 109 ++++++++++++++++++++++++---- 3 files changed, 218 insertions(+), 37 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index e4d443fb0b60..dc1eba764533 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -28,6 +28,8 @@ import threading import time import random +from enum import Enum, auto +from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union, Mapping @@ -38,13 +40,14 @@ from electrum.logging import get_logger, Logger from electrum.crypto import get_ecdh from electrum.lnmsg import OnionWireSerializer +from electrum.lntransport import LNPeerAddr from electrum.lnonion import (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, next_blinding_from_shared_secret) from electrum.lnutil import (LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) -from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy +from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy, wait_for2 def now() -> float: @@ -55,7 +58,6 @@ def now() -> float: from electrum.lnworker import LNWallet from electrum.network import Network from electrum.lnrouter import NodeInfo - from electrum.lntransport import LNPeerAddr from electrum.lnchannel import Channel from asyncio import Task @@ -76,6 +78,22 @@ def __init__(self, *args, peer_address: 'LNPeerAddr' = None): self.peer_address = peer_address +class DestinationState(Enum): + UNTRIED = auto() + SENT = auto() + NOT_REACHABLE = auto() # no route, no address + ADDR_KNOWN = auto() # no route, address known, no connection attempted + CONNECTION_ONGOING = auto() # direct connection in process + CONNECTION_ESTABLISHED = auto() + + +@dataclass +class OnionMessageDestination: + node_id_or_blinded_path: bytes + state: DestinationState = DestinationState.UNTRIED + peer_addr: Optional[LNPeerAddr] = None + + def create_blinded_path( session_key: bytes, path: Sequence[bytes], @@ -390,6 +408,10 @@ def send_onion_message_to( path_key = ecc.ECPrivkey(session_key).get_public_key_bytes() + # fail early if we already know peer doesn't support onion messages + if peer.is_initialized() and not peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT): + raise NoRouteFound("peer doesn't support onion messages") + peer.send_onion_message( path_key=path_key, onion_message_packet=packet_b, @@ -544,24 +566,35 @@ class OnionMessageManager(Logger): FORWARD_RETRY_TIMEOUT = 4 FORWARD_RETRY_DELAY = 2 FORWARD_MAX_QUEUE = 3 + MAX_CONCURRENT_DIRECT_CONNECTION_ATTEMPTS = 10 # allocated once for sending and once for forwarding class Request: def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[bytes]]): self.future = asyncio.Future() self.payload = payload - self.node_id_or_blinded_paths = node_id_or_blinded_paths self.current_index: int = 0 # ensure node_id_or_blinded_paths is list - if isinstance(self.node_id_or_blinded_paths, bytes): - self.node_id_or_blinded_paths = [self.node_id_or_blinded_paths] + if isinstance(node_id_or_blinded_paths, bytes): + node_id_or_blinded_paths = [node_id_or_blinded_paths] + self.destinations: list[OnionMessageDestination] = [ + OnionMessageDestination(node_id_or_blinded_path=dest) for dest in node_id_or_blinded_paths + ] - def get_next_destination(self) -> bytes: + def get_next_destination(self) -> Optional[OnionMessageDestination]: """get next path (round-robin)""" - dests = self.node_id_or_blinded_paths - dest = dests[self.current_index] - self.current_index = (self.current_index + 1) % len(dests) - return dest + dests = self.destinations + waiting_for_connection = False + for _ in dests: + dest = dests[self.current_index] + self.current_index = (self.current_index + 1) % len(dests) + if dest.state is DestinationState.CONNECTION_ONGOING: + waiting_for_connection = True + if dest.state not in (DestinationState.NOT_REACHABLE, DestinationState.CONNECTION_ONGOING): + return dest + if waiting_for_connection: + return None + raise NoRouteFound def __init__(self, lnwallet: 'LNWallet'): Logger.__init__(self) @@ -572,6 +605,8 @@ def __init__(self, lnwallet: 'LNWallet'): self.pending_lock = threading.Lock() self.send_queue = asyncio.PriorityQueue() self.forward_queue = asyncio.PriorityQueue() + self._send_direct_connection_attempts = set() # type: set[bytes] # node ids we're currently trying to connect to + self._forward_direct_connection_attempts = set() # type: set[bytes] def start_network(self, *, network: 'Network') -> None: assert network @@ -612,10 +647,9 @@ async def process_forward_queue(self) -> None: path_key=blinding, onion_message_packet=onion_packet_b, ) - except BaseException as e: - self.logger.debug(f'error while sending {node_id=} e={e!r}') - # TODO: it is debatable whether we want to retry a forward. - self.forward_queue.put_nowait((now() + self.FORWARD_RETRY_DELAY, expires, onion_packet, blinding, node_id)) + except Exception as e: + # we don't retry on error, forwarding is best effort + self.logger.debug(f'error while forwarding onion message to {node_id=} e={e!r}') def submit_forward( self, *, @@ -653,10 +687,9 @@ async def process_send_queue(self) -> None: self._send_pending_message(key) except BaseException as e: self.logger.debug(f'error while sending {key=}: ', exc_info=True) + # NOTE: above, when passing the caught exception instance e directly it leads to GeneratorExit() in the + # coroutine awaiting it req.future.set_exception(copy.copy(e)) - # NOTE: above, when passing the caught exception instance e directly it leads to GeneratorExit() in - if isinstance(e, NoRouteFound) and e.peer_address: - await self.lnwallet.lnpeermgr.add_peer(str(e.peer_address)) else: self.logger.debug(f'resubmit {key=}') self.send_queue.put_nowait((now() + self.REQUEST_REPLY_RETRY_DELAY, expires, key)) @@ -708,6 +741,8 @@ def _send_pending_message(self, key: bytes) -> None: req = self.pending[key] payload = req.payload dest = req.get_next_destination() + if dest is None: + return # wait for connection to finish self.logger.debug(f'send_pending_message {key=} {payload=} {dest=}') @@ -719,10 +754,68 @@ def _send_pending_message(self, key: bytes) -> None: reply_paths = get_blinded_reply_paths(self.lnwallet, path_id, max_paths=1) final_payload['reply_path'] = {'path': reply_paths} - # NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route) - # when retrying, this is currently not done. - # (send_onion_message_to decides path, without knowledge of prev attempts) - send_onion_message_to(self.lnwallet, dest, final_payload) + try: + # NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route) + # when retrying, this is currently not done. + # (send_onion_message_to decides path, without knowledge of prev attempts) + send_onion_message_to(self.lnwallet, dest.node_id_or_blinded_path, final_payload) + dest.state = DestinationState.SENT + except NoRouteFound as e: + if dest.state in (DestinationState.UNTRIED, DestinationState.SENT): + dest.state = DestinationState.NOT_REACHABLE + if e.peer_address and self.lnwallet.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS: + dest.state, dest.peer_addr = DestinationState.ADDR_KNOWN, e.peer_address + if dest.state is DestinationState.CONNECTION_ESTABLISHED: + # maybe the peer disconnected again + dest.state = DestinationState.NOT_REACHABLE + if not any(d.state is DestinationState.UNTRIED for d in req.destinations): + if all(d.state is DestinationState.NOT_REACHABLE for d in req.destinations): + raise + for dest_ in req.destinations: # connect to all peers we know the address of + if dest_.state is not DestinationState.ADDR_KNOWN or dest_.peer_addr is None: + continue + addr = dest_.peer_addr + if addr.pubkey in self._send_direct_connection_attempts \ + or addr.pubkey in self._forward_direct_connection_attempts: + continue # already connecting, keep item in the queue + if len(self._send_direct_connection_attempts) >= self.MAX_CONCURRENT_DIRECT_CONNECTION_ATTEMPTS: + break + task = self._connect_to_fallback_address(addr, connection_set=self._send_direct_connection_attempts) + def _handle_failed_connection(t, dest_=dest_): + failed = t.cancelled() or t.exception() + dest_.state = DestinationState.NOT_REACHABLE if failed else DestinationState.CONNECTION_ESTABLISHED + if failed and not req.future.done() \ + and all(d_.state is DestinationState.NOT_REACHABLE for d_ in req.destinations): + req.future.set_exception(NoRouteFound('all destinations unreachable')) + task.add_done_callback(_handle_failed_connection) + dest_.state = DestinationState.CONNECTION_ONGOING + # keep trying until all direct connections attempts failed or request expiry + + assert dest.state != DestinationState.UNTRIED, dest + + def _connect_to_fallback_address(self, peer_address: 'LNPeerAddr', *, connection_set: set[bytes]) -> asyncio.Task: + """fire-and-forget connection with a new peer""" + from electrum.lnpeer import LN_P2P_NETWORK_TIMEOUT + assert peer_address.pubkey not in self._send_direct_connection_attempts \ + and peer_address.pubkey not in self._forward_direct_connection_attempts + async def _add_peer(): + try: + if not self.lnwallet.lnpeermgr._can_retry_addr(peer_address, urgent=True): + raise ConnectionAbortedError("already tried to connect to this peer, not trying again now") + peer = await self.lnwallet.lnpeermgr.add_peer(str(peer_address)) + # timeout might be longer than message queue timeout itself, so the msg could be dropped after connection + await wait_for2(peer.initialized, LN_P2P_NETWORK_TIMEOUT) + if not peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT): + raise ConnectionAbortedError("peer doesn't support onion messages") + except Exception as e: + self.logger.debug(f"failed to establish new peer connection: {e}") + raise + + self.logger.info(f'attempting direct peer connection to {str(peer_address)}') + connection_set.add(peer_address.pubkey) + task = asyncio.create_task(_add_peer()) # util._set_custom_task_factory keeps ref too + task.add_done_callback(lambda t: connection_set.discard(peer_address.pubkey)) + return task def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes: # TODO: use payload to determine prefix? @@ -764,7 +857,8 @@ def on_onion_message_received(self, recipient_data: Mapping, payload: Mapping) - def on_onion_message_received_reply(self, request: Request, recipient_data: Mapping, payload: Mapping) -> None: assert request is not None, 'Request is mandatory' - request.future.set_result((recipient_data, payload)) + if not request.future.done(): + request.future.set_result((recipient_data, payload)) def on_onion_message_received_unsolicited(self, recipient_data: Mapping, payload: Mapping) -> None: self.logger.debug('unsolicited onion_message received') diff --git a/electrum/simple_config.py b/electrum/simple_config.py index cf76ffa3f409..3c2361ce7bec 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -726,6 +726,14 @@ def __setattr__(self, name, value): short_desc=lambda: _('Show Fiat balances'), ) + ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = ConfigVar( + 'onion_message_direct_connections', default=True, type_=bool, + short_desc=lambda: _("Open direct connections to deliver onion messages"), + long_desc=lambda: _("Opening a direct network connection to deliver an onion message allows delivery even when no " + "route to the destination can be found via the public lightning network graph. The trade-off " + "is that the node you connect to directly will see your IP address (unless a proxy like Tor is used)."), + ) + LIGHTNING_LISTEN = ConfigVar( 'lightning_listen', default=None, type_=str, long_desc=lambda: _("""By default the client does not listen on any port for incoming BOLT-08 transports. diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 6f824b2eeee7..9696ed356e55 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -271,6 +271,8 @@ def __init__(self, pubkey, on_send_message=None, their_features=ONION_MESSAGE_CA self.pubkey = pubkey self.on_send_message = on_send_message self.their_features = their_features + self.initialized = asyncio.Future() + self.initialized.set_result(True) async def wait_one_htlc_switch_iteration(self, *args): pass @@ -303,6 +305,17 @@ def keypair(privkey: ECPrivkey): self.dave = keypair(ECPrivkey(privkey_bytes=b'\x44'*32)) self.eve = keypair(ECPrivkey(privkey_bytes=b'\x45'*32)) self.fred = keypair(ECPrivkey(privkey_bytes=b'\x46'*32)) + self.gerald = keypair(ECPrivkey(privkey_bytes=b'\x47'*32)) + self.harry = keypair(ECPrivkey(privkey_bytes=b'\x48'*32)) + self.ivan = keypair(ECPrivkey(privkey_bytes=b'\x49'*32)) + + async def run_test_exception(self, t): + t1 = t.submit_send( + payload={'message': {'text': 'no_onionmsg_peers'.encode('utf-8')}}, + node_id_or_blinded_paths=self.harry.pubkey) + + with self.assertRaises(NoOnionMessagePeers): + await t1 async def run_test1(self, t): t1 = t.submit_send( @@ -339,13 +352,49 @@ async def run_test4(self, t, rkey): self.assertEqual(t4_result, ({'path_id': {'data': b'electrum' + rkey}}, {})) async def run_test5(self, t): - t5 = t.submit_send( - payload={'message': {'text': 'no_peer'.encode('utf-8')}}, + lnw = t.lnwallet + self.assertFalse(self.eve.pubkey in lnw.lnpeermgr.peers) + + lnw.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = True + t5_1 = t.submit_send( + payload={'message': {'text': 'no_route_peer_address_known'.encode('utf-8')}}, node_id_or_blinded_paths=self.eve.pubkey) + with self.assertRaises(Timeout) as c: + await t5_1 + + self.assertTrue(self.eve.pubkey in lnw.lnpeermgr.peers) + del lnw.lnpeermgr._peers[self.eve.pubkey] + + t5_2 = t.submit_send( + payload={'message': {'text': 'no_route_no_peer_address'.encode('utf-8')}}, + node_id_or_blinded_paths=self.gerald.pubkey) + + # will not find route to gerald, and doesn't have gerald's address + with self.assertRaises(NoRouteFound) as c: + await t5_2 + + self.assertIsNone(c.exception.peer_address) + + # no route/addr for first target, addr for second target + t5_3 = t.submit_send( + payload={'message': {'text': 'multiple_destinations_peer_addr_for_one'.encode('utf-8')}}, + node_id_or_blinded_paths=[self.gerald.pubkey, self.eve.pubkey]) + + with self.assertRaises(Timeout) as c: + await t5_3 + + self.assertTrue(self.eve.pubkey in lnw.lnpeermgr.peers) + del lnw.lnpeermgr._peers[self.eve.pubkey] + + lnw.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = False + t5_4 = t.submit_send( + payload={'message': {'text': 'no_route_peer_address_known_but_ignored'.encode('utf-8')}}, + node_id_or_blinded_paths=self.eve.pubkey) # will not find route to eve, but has eve's address, but we are configured to not direct connect with self.assertRaises(NoRouteFound) as c: - await t5 + await t5_4 + self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey)) async def run_test6(self, t, rkey): @@ -359,6 +408,29 @@ async def run_test6(self, t, rkey): t6_result = await t6 self.assertEqual(t6_result, ({'path_id': {'data': b'electrum' + rkey}}, {})) + async def run_test7(self, t): + """test we use the fallback address for a peer if a previous message succeeded but a subsequent attempt failed""" + lnw = t.lnwallet + lnw.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = True + lnw.lnpeermgr._peers[self.ivan.pubkey] = MockPeer(self.ivan.pubkey) + lnw.channel_db._addresses[self.ivan.pubkey] = {NetAddress('localhost', '4321'): int(time.time())} + + t7 = t.submit_send( + payload={'message': {'text': 'sent_then_route_breaks'.encode('utf-8')}}, + node_id_or_blinded_paths=self.ivan.pubkey) + + # let the first send succeed via the connected peer, then break the route + await asyncio.sleep(2*TIME_STEP) + del lnw.lnpeermgr._peers[self.ivan.pubkey] + + with self.assertRaises(Timeout): + await t7 + + # the retries should have picked the address and opened a direct connection + self.assertTrue(self.ivan.pubkey in lnw.lnpeermgr.peers) + del lnw.lnpeermgr._peers[self.ivan.pubkey] + lnw.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = False + async def test_request_and_reply(self): n = MockNetwork() lnw = self.create_mock_lnwallet(name='test_request_and_reply') @@ -366,7 +438,7 @@ async def test_request_and_reply(self): # mock add_peer for direct connection fallback async def mock__add_peer(host, port, node_id): mock_peer = MockPeer(pubkey=node_id) - # lnw.lnpeermgr._peers[node_id] = mock_peer + lnw.lnpeermgr._peers[node_id] = mock_peer return mock_peer lnw.lnpeermgr._add_peer = mock__add_peer @@ -380,21 +452,27 @@ def slowwithreply(key, *args, **kwargs): time.sleep(2*TIME_STEP) t.on_onion_message_received({'path_id': {'data': b'electrum' + key}}, {}) - rkey1 = bfh('0102030405060708') - rkey2 = bfh('0102030405060709') - rkey3 = bfh('010203040506070a') - - lnw.lnpeermgr._peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) - lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) - lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) - lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) - lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} - lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=partial(withreply, rkey3)) t = OnionMessageManager(lnw) t.start_network(network=n) try: await asyncio.sleep(TIME_STEP) + + await self.run_test_exception(t) + lnw.lnpeermgr._peers[self.harry.pubkey] = MockPeer(self.harry.pubkey, their_features=LnFeatures(0)) + await self.run_test_exception(t) + + rkey1 = bfh('0102030405060708') + rkey2 = bfh('0102030405060709') + rkey3 = bfh('010203040506070a') + + lnw.lnpeermgr._peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) + lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) + lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) + lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) + lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} + lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=partial(withreply, rkey3)) + self.logger.debug('tests in sequence') await self.run_test1(t) await self.run_test2(t) @@ -402,6 +480,7 @@ def slowwithreply(key, *args, **kwargs): await self.run_test4(t, rkey2) await self.run_test5(t) await self.run_test6(t, rkey3) + await self.run_test7(t) self.logger.debug('tests in parallel') async with OldTaskGroup() as group: await group.spawn(self.run_test1(t)) @@ -428,7 +507,7 @@ def on_send(to: str, *args, **kwargs): self.assertEqual(to, 'bob') self.was_sent = True # validate what's sent to bob - self.assertEqual(bfh(HOPS[1]['E']), kwargs['blinding']) + self.assertEqual(bfh(HOPS[1]['E']), kwargs['path_key']) message_type, payload = decode_msg(bfh(test_vectors['decrypt']['hops'][1]['onion_message'])) self.assertEqual(message_type, 'onion_message') self.assertEqual(payload['onion_message_packet'], kwargs['onion_message_packet']) From 0127579c3ffc7edb3b61aa5067deb8630dd196f8 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 17 Jul 2026 13:22:56 +0200 Subject: [PATCH 10/45] onion message: decrease delay until all dests were tried Decrease the delay between onion message sending attempts if we have multiple destinations and haven't tried them all yet. There is no point in waiting 5s to try another blinded path, proceeding quickly improves the UX. --- electrum/onion_message.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index dc1eba764533..05c26d7ceb21 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -692,7 +692,10 @@ async def process_send_queue(self) -> None: req.future.set_exception(copy.copy(e)) else: self.logger.debug(f'resubmit {key=}') - self.send_queue.put_nowait((now() + self.REQUEST_REPLY_RETRY_DELAY, expires, key)) + delay = self.REQUEST_REPLY_RETRY_DELAY + if any(d.state is DestinationState.UNTRIED for d in req.destinations): + delay = 0 # quickly try all destinations + self.send_queue.put_nowait((now() + delay, expires, key)) def _remove_pending_message(self, key: bytes) -> None: with self.pending_lock: From a8cea96a6799b8f038514915fd663a893cd61197 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 17 Jul 2026 10:20:26 +0200 Subject: [PATCH 11/45] tests: onion_message: fix test_forward The test would swallow assert exceptions in the `on_send` callback, store them in Future and then assert them in test scope. --- tests/test_onion_message.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 9696ed356e55..464c6c335526 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -25,7 +25,7 @@ create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, create_route_to_introduction_point, get_blinded_paths_to_me, NoOnionMessagePeers ) -from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop +from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop, wait_for2 from electrum.logging import console_stderr_handler from . import ElectrumTestCase @@ -501,16 +501,11 @@ async def test_forward(self): lnw = self.create_mock_lnwallet(name='alice') lnw.node_keypair = self.alice - self.was_sent = False + sent = asyncio.Future() def on_send(to: str, *args, **kwargs): - self.assertEqual(to, 'bob') - self.was_sent = True - # validate what's sent to bob - self.assertEqual(bfh(HOPS[1]['E']), kwargs['path_key']) - message_type, payload = decode_msg(bfh(test_vectors['decrypt']['hops'][1]['onion_message'])) - self.assertEqual(message_type, 'onion_message') - self.assertEqual(payload['onion_message_packet'], kwargs['onion_message_packet']) + if not sent.done(): + sent.set_result((to, kwargs)) lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=partial(on_send, 'bob')) lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(on_send, 'carol')) @@ -524,6 +519,7 @@ def on_send(to: str, *args, **kwargs): 'len': len(onionmsg), 'onion_message_packet': onionmsg }) + to, kwargs = await wait_for2(sent, timeout=100*TIME_STEP) finally: await asyncio.sleep(2*TIME_STEP) @@ -531,7 +527,12 @@ def on_send(to: str, *args, **kwargs): await t.stop() await lnw.stop() - self.assertTrue(self.was_sent) + self.assertEqual(to, 'bob') + # validate what's sent to bob + self.assertEqual(bfh(HOPS[1]['E']), kwargs['path_key']) + message_type, payload = decode_msg(bfh(test_vectors['decrypt']['hops'][1]['onion_message'])) + self.assertEqual(message_type, 'onion_message') + self.assertEqual(payload['onion_message_packet'], kwargs['onion_message_packet']) async def test_receive_unsolicited(self): n = MockNetwork() From e1635e12980c00640fe164dba17f0b3f76be3049 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Jul 2026 16:28:25 +0200 Subject: [PATCH 12/45] onion message: trampoline forwarder direct connection Clients rely on trampoline nodes opening a direct connection to a given onion message recipient if they are unable to connect directly to the recipient themselves (e.g. they don't know the IP as they don't have access to the gossip). --- electrum/onion_message.py | 43 ++++++++++---- tests/test_onion_message.py | 109 ++++++++++++++++++++++++++++++++++-- 2 files changed, 137 insertions(+), 15 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 05c26d7ceb21..02dad13aeddd 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -167,7 +167,7 @@ def encode_blinded_path(blinded_path: dict): return blinded_path_fd.getvalue() -def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bool: +def is_onion_message_node(_node_id: bytes, node_info: Optional['NodeInfo']) -> bool: if not node_info: return False return LnFeatures(node_info.features).supports(LnFeatures.OPTION_ONION_MESSAGE_OPT) @@ -563,8 +563,8 @@ class OnionMessageManager(Logger): SLEEP_DELAY = 1 REQUEST_REPLY_TIMEOUT = 30 REQUEST_REPLY_RETRY_DELAY = 5 - FORWARD_RETRY_TIMEOUT = 4 FORWARD_RETRY_DELAY = 2 + FORWARD_RETRY_TIMEOUT = 15 FORWARD_MAX_QUEUE = 3 MAX_CONCURRENT_DIRECT_CONNECTION_ATTEMPTS = 10 # allocated once for sending and once for forwarding @@ -643,6 +643,33 @@ async def process_forward_queue(self) -> None: onion_packet_b = onion_packet.to_bytes() next_peer = self.lnwallet.lnpeermgr.get_peer_by_pubkey(node_id) + if not next_peer: + self.logger.debug(f"not connected to next onion message hop: {node_id.hex()=}") + # clients rely on trampoline nodes opening a direct connection for onion message forwarding + # if they don't have access to the gossip and cannot open a connection by themselves + if self.lnwallet.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS \ + and self.lnwallet.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS \ + and self.lnwallet.channel_db: + node_info = self.lnwallet.channel_db.get_node_info_for_node_id(node_id) + if not is_onion_message_node(node_id, node_info): + continue + if peer_addr := self.lnwallet.channel_db.get_last_good_address(node_id): + delayed_forward_item = (scheduled, expires, onion_packet, blinding, node_id) + def _requeue_forward(t, forward_item = delayed_forward_item): + _, _, _, _, node_id_ = forward_item + if t.cancelled() or t.exception() or not self.lnwallet.lnpeermgr.get_peer_by_pubkey(node_id_): + return + self.forward_queue.put_nowait(forward_item) + if peer_addr.pubkey in self._send_direct_connection_attempts \ + or peer_addr.pubkey in self._forward_direct_connection_attempts \ + or len(self._forward_direct_connection_attempts) >= self.MAX_CONCURRENT_DIRECT_CONNECTION_ATTEMPTS: + # we're already trying to connect to this node, try forwarding again later + self.forward_queue.put_nowait((scheduled + self.FORWARD_RETRY_DELAY, expires, onion_packet, blinding, node_id)) + else: + task = self._connect_to_fallback_address(peer_addr, connection_set=self._forward_direct_connection_attempts) + task.add_done_callback(_requeue_forward) + continue + next_peer.send_onion_message( path_key=blinding, onion_message_packet=onion_packet_b, @@ -902,11 +929,10 @@ def on_onion_message_forward( self.logger.error('cannot forward onion_message, path_id in encrypted_data_tlv') return - next_node_id = recipient_data.get('next_node_id') - if not next_node_id: + next_node_id = recipient_data.get('next_node_id', {}).get('node_id') + if not isinstance(next_node_id, bytes): self.logger.error('cannot forward onion_message, next_node_id missing in encrypted_data_tlv') return - next_node_id = next_node_id['node_id'] is_dummy_hop = False if next_node_id == self.lnwallet.node_keypair.pubkey: @@ -914,15 +940,10 @@ def on_onion_message_forward( is_dummy_hop = True else: # not a dummy hop, check if we are configured to forward - if not self.network.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS: + if not self.lnwallet.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS: self.logger.info( 'onion_message dropped (not forwarding due to lightning_forward_payments config option disabled') return - # is next_node one of our peers? - next_peer = self.lnwallet.lnpeermgr.get_peer_by_pubkey(next_node_id) - if not next_peer: - self.logger.info(f'next node {next_node_id.hex()} not a peer, dropping message') - return if is_dummy_hop: self.process_onion_message_packet(next_path_key, onion_packet) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 464c6c335526..e77812ab078d 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -18,6 +18,7 @@ get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize, encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv) from electrum.crypto import get_ecdh, privkey_to_pubkey +from electrum.channel_db import NodeInfo from electrum.lntransport import LNPeerAddr from electrum.lnutil import (LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) @@ -25,11 +26,11 @@ create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, create_route_to_introduction_point, get_blinded_paths_to_me, NoOnionMessagePeers ) -from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop, wait_for2 +from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop, wait_for2, now from electrum.logging import console_stderr_handler from . import ElectrumTestCase -from .test_lnpeer import TestPeer, inject_chan_into_gossipdb +from .test_lnpeer import TestPeer, inject_chan_into_gossipdb, SuccessfulTest TIME_STEP = 0.01 # run tests 100 x faster @@ -261,7 +262,6 @@ def __init__(self): self.asyncio_loop = get_asyncio_loop() self.taskgroup = OldTaskGroup() self.config = SimpleConfig() - self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True ONION_MESSAGE_CAPABLE_PEER_FEATURES = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT) @@ -496,10 +496,11 @@ def slowwithreply(key, *args, **kwargs): await t.stop() await lnw.stop() - async def test_forward(self): + async def test_forward_vector(self): n = MockNetwork() lnw = self.create_mock_lnwallet(name='alice') lnw.node_keypair = self.alice + lnw.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True sent = asyncio.Future() @@ -534,6 +535,59 @@ def on_send(to: str, *args, **kwargs): self.assertEqual(message_type, 'onion_message') self.assertEqual(payload['onion_message_packet'], kwargs['onion_message_packet']) + async def test_forward_trampoline_direct_connection(self): + # a trampoline forwarder should open a direct connection to the next node if it + # is not a peer yet, and forward the onion message over the new connection + n = MockNetwork() + lnw = self.create_mock_lnwallet(name='alice') + lnw.node_keypair = self.alice + lnw.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True + lnw.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True + lnw.channel_db._addresses[self.bob.pubkey] = {NetAddress('localhost', '9735'): int(time.time())} + lnw.channel_db._nodes[self.bob.pubkey] = NodeInfo( + node_id=self.bob.pubkey, + features=LnFeatures.OPTION_ONION_MESSAGE_OPT, + timestamp=now() - 3600, + alias='bob', + raw=None, + ) + + sent_to_bob = asyncio.Future() + + # mock add_peer for direct connection to bob + async def mock__add_peer(host, port, node_id): + def on_send(*args, **kwargs): + if not sent_to_bob.done(): + sent_to_bob.set_result(kwargs) + mock_peer = MockPeer(pubkey=node_id, on_send_message=on_send) + lnw.lnpeermgr._peers[node_id] = mock_peer + return mock_peer + lnw.lnpeermgr._add_peer = mock__add_peer + + t = OnionMessageManager(lnw) + t.FORWARD_RETRY_TIMEOUT = 500 * TIME_STEP # CI can be slow + t.start_network(network=n) + + onionmsg = bfh(test_vectors['onionmessage']['onion_message_packet']) + try: + self.assertIsNone(lnw.lnpeermgr.get_peer_by_pubkey(self.bob.pubkey)) + t.on_onion_message({ + 'path_key': bfh(test_vectors['route']['first_path_key']), + 'len': len(onionmsg), + 'onion_message_packet': onionmsg + }) + await wait_for2(sent_to_bob, timeout=500 * TIME_STEP) + finally: + await t.stop() + await lnw.stop() + + self.assertIsNotNone(lnw.lnpeermgr.get_peer_by_pubkey(self.bob.pubkey)) + message_type, payload = decode_msg(bfh(test_vectors['decrypt']['hops'][1]['onion_message'])) + self.assertEqual(sent_to_bob.result(), { + 'path_key': payload['path_key'], + 'onion_message_packet': payload['onion_message_packet'], + }) + async def test_receive_unsolicited(self): n = MockNetwork() lnw = self.create_mock_lnwallet(name='dave') @@ -563,6 +617,53 @@ def my_on_onion_message_received_unsolicited(*args, **kwargs): class TestOnionMessageUtils(TestPeer): + async def test_forward_onion_message(self): + # A -> B -> C forward, no direct connection fallback + line_graph = self.GRAPH_DEFINITIONS['line_graph'] + graph = self.prepare_chans_and_peers_in_graph({ + 'alice': line_graph['alice'], + 'bob': line_graph['bob'], # sets EXPERIMENTAL_LN_FORWARD_PAYMENTS + 'carol': {}, + }) + alice, bob, carol = graph.workers.values() + + # alice needs gossip to find the route + for a, b in (('alice', 'bob'), ('bob', 'carol')): + inject_chan_into_gossipdb( + channel_db=alice.channel_db, + chanAB=graph.channels[(a, b)][0], + chanBA=graph.channels[(b, a)][0], + ) + + received = asyncio.Future() + def on_received_unsolicited(_recipient_data, payload): + if not received.done(): + received.set_result(payload) + carol.onion_message_manager.on_onion_message_received_unsolicited = on_received_unsolicited + + async def f(): + async with OldTaskGroup() as group: + for peer in graph.peers.values(): + await group.spawn(peer._message_loop()) + await group.spawn(peer._send_onion_messages()) + for peer in graph.peers.values(): + await peer.initialized + alice.onion_message_manager.start_network(network=alice.network) + bob.onion_message_manager.start_network(network=bob.network) + + send_task = alice.onion_message_manager.submit_send( + payload={'message': {'text': b'hello carol'}}, + node_id_or_blinded_paths=carol.node_keypair.pubkey) + payload = await asyncio.wait_for(received, timeout=1) + self.assertEqual(payload['message']['text'], b'hello carol') + self.assertIn('reply_path', payload) + send_task.cancel() + raise SuccessfulTest() + + with patch('electrum.onion_message.is_onion_message_node', return_value=True): + with self.assertRaises(SuccessfulTest): + await f() + async def test_get_blinded_paths_to_me_payment(self): # A <- B (alice generates blinded path from bob to herself) graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) From b89452d899d1040ba32af1fce69dbdd4c8c408c0 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 22 Jul 2026 17:47:00 +0200 Subject: [PATCH 13/45] test_onion_message: more onion message unittests Test some of the direct connection behavior to prevent regressions. --- tests/test_onion_message.py | 278 +++++++++++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 2 deletions(-) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index e77812ab078d..7345ee541bdc 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -7,11 +7,12 @@ from functools import partial from unittest.mock import patch from aiorpcx import NetAddress +from typing import Callable import electrum_ecc as ecc from electrum_ecc import ECPrivkey -from electrum import SimpleConfig +from electrum import SimpleConfig, lnpeer from electrum.lnmsg import decode_msg, OnionWireSerializer from electrum.lnonion import ( OnionHopsDataSingle, OnionPacket, process_onion_packet, get_bolt04_onion_key, encrypt_onionmsg_data_tlv, @@ -23,13 +24,14 @@ from electrum.lnutil import (LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) from electrum.onion_message import ( - create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, + create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, DestinationState, create_route_to_introduction_point, get_blinded_paths_to_me, NoOnionMessagePeers ) from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop, wait_for2, now from electrum.logging import console_stderr_handler from . import ElectrumTestCase +from .lnhelpers import PeerInTests, transport_pair from .test_lnpeer import TestPeer, inject_chan_into_gossipdb, SuccessfulTest @@ -40,6 +42,14 @@ OnionMessageManager.FORWARD_RETRY_TIMEOUT *= TIME_STEP OnionMessageManager.FORWARD_RETRY_DELAY *= TIME_STEP + +async def wait_until(condition: Callable, *, timeout=100 * TIME_STEP): + """wait until condition() is True (or raise TimeoutError)""" + async def poll(): + while not condition(): + await asyncio.sleep(TIME_STEP) + await wait_for2(poll(), timeout) + # test vectors https://github.com/lightning/bolts/pull/759/files path = os.path.join(os.path.dirname(__file__), 'blinded-onion-message-onion-test.json') test_vectors = read_json_file(path) @@ -496,6 +506,122 @@ def slowwithreply(key, *args, **kwargs): await t.stop() await lnw.stop() + async def test_get_next_destination(self): + req = OnionMessageManager.Request( + payload={'message': {'text': b'roundrobin'}}, + node_id_or_blinded_paths=[b'\x02' * 33, b'\x03' * 33, b'\x04' * 33]) + d1, d2, d3 = req.destinations + self.assertTrue(all(d.state is DestinationState.UNTRIED for d in req.destinations)) + + # untried destinations are cycled through + self.assertIs(d1, req.get_next_destination()) + self.assertIs(d2, req.get_next_destination()) + self.assertIs(d3, req.get_next_destination()) + self.assertIs(d1, req.get_next_destination()) + + # unreachable destinations are skipped + d2.state = DestinationState.NOT_REACHABLE + self.assertIs(d3, req.get_next_destination()) + self.assertIs(d1, req.get_next_destination()) + + # SENT, ADDR_KNOWN and CONNECTION_ESTABLISHED get returned + d1.state = DestinationState.SENT + d3.state = DestinationState.ADDR_KNOWN + self.assertIs(d3, req.get_next_destination()) + self.assertIs(d1, req.get_next_destination()) + d1.state = DestinationState.CONNECTION_ESTABLISHED + self.assertIs(d3, req.get_next_destination()) + self.assertIs(d1, req.get_next_destination()) + + # only an ongoing connection attempt left + d1.state = DestinationState.CONNECTION_ONGOING + d3.state = DestinationState.NOT_REACHABLE + self.assertIsNone(req.get_next_destination()) + self.assertIsNone(req.get_next_destination()) + + # all destinations unreachable + d1.state = DestinationState.NOT_REACHABLE + with self.assertRaises(NoRouteFound): + req.get_next_destination() + + async def test_send_direct_connection_failure(self): + # if the direct connection fallback fails for all destinations, the request + # should fail immediately instead of idling until Timeout + n = MockNetwork() + lnw = self.create_mock_lnwallet(name='test_send_direct_connection_failure') + lnw.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = True + lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey) # to build reply paths + lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} + lnw.channel_db._addresses[self.fred.pubkey] = {NetAddress('localhost', '1235'): int(time.time())} + + connect_attempts = [] + async def mock__add_peer(host, port, node_id): + connect_attempts.append(node_id) + raise ConnectionRefusedError + lnw.lnpeermgr._add_peer = mock__add_peer + + t = OnionMessageManager(lnw) + t.REQUEST_REPLY_TIMEOUT = 500 * TIME_STEP # the failed connection must resolve the request, not the timeout + t.start_network(network=n) + try: + t1 = t.submit_send( + payload={'message': {'text': 'direct_connection_fails'.encode('utf-8')}}, + node_id_or_blinded_paths=self.eve.pubkey) + with self.assertRaises(NoRouteFound) as c: + await t1 + self.assertIn('all destinations unreachable', str(c.exception)) + self.assertEqual([self.eve.pubkey], connect_attempts) + self.assertFalse(t._send_direct_connection_attempts) # ongoing connections are cleaned up + + # connection succeeds, but the peer turns out to not support onion messages + async def mock__add_peer_no_om(host, port, node_id): + mock_peer = MockPeer(node_id, their_features=LnFeatures(0)) + lnw.lnpeermgr._peers[node_id] = mock_peer + return mock_peer + lnw.lnpeermgr._add_peer = mock__add_peer_no_om + t2 = t.submit_send( + payload={'message': {'text': 'connected_peer_without_om_support'.encode('utf-8')}}, + node_id_or_blinded_paths=self.fred.pubkey) + with self.assertRaises(NoRouteFound) as c: + await t2 + self.assertIn('all destinations unreachable', str(c.exception)) + self.assertFalse(t._send_direct_connection_attempts) + finally: + await t.stop() + await lnw.stop() + + async def test_untried_destinations_skip_retry_delay(self): + # with multiple destinations, the next untried destination should be tried + # immediately instead of after REQUEST_REPLY_RETRY_DELAY + n = MockNetwork() + lnw = self.create_mock_lnwallet(name='test_untried_destinations_skip_retry_delay') + rkey = os.urandom(8) + + bob_sent = asyncio.Event() + def bob_noreply(*args, **kwargs): + bob_sent.set() + + def fred_reply(*args, **kwargs): + t.on_onion_message_received({'path_id': {'data': b'electrum' + rkey}}, {}) + + lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=bob_noreply) + lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=fred_reply) + + t = OnionMessageManager(lnw) + t.REQUEST_REPLY_RETRY_DELAY = 60 # large delay would time out if the delay skip doesn't work + t.start_network(network=n) + try: + task = t.submit_send( + payload={'message': {'text': 'skip_retry_delay'.encode('utf-8')}}, + node_id_or_blinded_paths=[self.bob.pubkey, self.fred.pubkey], + key=rkey) + result = await task + self.assertEqual(result, ({'path_id': {'data': b'electrum' + rkey}}, {})) + self.assertTrue(bob_sent.is_set()) + finally: + await t.stop() + await lnw.stop() + async def test_forward_vector(self): n = MockNetwork() lnw = self.create_mock_lnwallet(name='alice') @@ -588,6 +714,80 @@ def on_send(*args, **kwargs): 'onion_message_packet': payload['onion_message_packet'], }) + def _prepare_trampoline_forwarder(self): + """trampoline forwarder that is not connected to the next hop (bob), but knows bob's address from gossip. + Any direct connection attempt to bob will fail.""" + lnw = self.create_mock_lnwallet(name='alice') + lnw.node_keypair = self.alice + lnw.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True + lnw.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True + lnw.channel_db._addresses[self.bob.pubkey] = {NetAddress('localhost', '9735'): int(time.time())} + lnw.channel_db._nodes[self.bob.pubkey] = NodeInfo( + node_id=self.bob.pubkey, + features=LnFeatures.OPTION_ONION_MESSAGE_OPT, + timestamp=now() - 3600, + alias='bob', + raw=None, + ) + + connect_attempted = asyncio.Event() + async def mock__add_peer(host, port, node_id): + connect_attempted.set() + raise ConnectionRefusedError + lnw.lnpeermgr._add_peer = mock__add_peer + + onionmsg = bfh(test_vectors['onionmessage']['onion_message_packet']) + onion_message = { + 'path_key': bfh(test_vectors['route']['first_path_key']), + 'len': len(onionmsg), + 'onion_message_packet': onionmsg, + } + return lnw, onion_message, connect_attempted + + async def test_forward_no_direct_connection_when_gated(self): + # direct connections for forwarding are only opened by trampoline forwarders, + # and only if the next hop announces onion message support in its gossip + n = MockNetwork() + lnw, onion_message, connect_attempted = self._prepare_trampoline_forwarder() + t = OnionMessageManager(lnw) + t.start_network(network=n) + try: + lnw.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = False + t.on_onion_message(onion_message) + self.assertEqual(1, t.forward_queue.qsize()) + await wait_until(lambda: t.forward_queue.qsize() == 0) + await asyncio.sleep(2 * TIME_STEP) + self.assertFalse(connect_attempted.is_set()) # dropped, we are not a trampoline forwarder + + lnw.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True + lnw.channel_db._nodes[self.bob.pubkey] = \ + lnw.channel_db._nodes[self.bob.pubkey]._replace(features=LnFeatures(0)) + t.on_onion_message(onion_message) + self.assertEqual(1, t.forward_queue.qsize()) + await wait_until(lambda: t.forward_queue.qsize() == 0) + await asyncio.sleep(2 * TIME_STEP) + self.assertFalse(connect_attempted.is_set()) # dropped, bob doesn't announce onion message support + finally: + await t.stop() + await lnw.stop() + + async def test_forward_direct_connection_failure_drops_msg(self): + # a failed direct connection attempt should drop the pending forward + n = MockNetwork() + lnw, onion_message, connect_attempted = self._prepare_trampoline_forwarder() + t = OnionMessageManager(lnw) + t.start_network(network=n) + try: + t.on_onion_message(onion_message) + await wait_for2(connect_attempted.wait(), timeout=100 * TIME_STEP) + await wait_until(lambda: not t._forward_direct_connection_attempts) # attempt set is cleaned up + await asyncio.sleep(2 * TIME_STEP) # let _requeue_forward run + self.assertIsNone(lnw.lnpeermgr.get_peer_by_pubkey(self.bob.pubkey)) + self.assertEqual(0, t.forward_queue.qsize()) + finally: + await t.stop() + await lnw.stop() + async def test_receive_unsolicited(self): n = MockNetwork() lnw = self.create_mock_lnwallet(name='dave') @@ -615,6 +815,80 @@ def my_on_onion_message_received_unsolicited(*args, **kwargs): await lnw.stop() +class TestPeerOnionMessages(ElectrumTestCase): + """test for Peer onion message send queue""" + + def _create_peer(self) -> tuple: + lnw = self.create_mock_lnwallet(name='alice') + remote_keypair = Keypair(pubkey=privkey_to_pubkey(b'\x42' * 32), privkey=b'\x42' * 32) + transport, remote_transport = transport_pair(lnw.node_keypair, remote_keypair, 'alice->bob', 'bob->alice') + peer = PeerInTests(lnw, remote_keypair.pubkey, transport) + return peer, remote_transport + + async def test_send_onion_message_queued_until_init(self): + peer, remote_transport = self._create_peer() + path_key = ECPrivkey.generate_random_key().get_public_key_bytes() + packet_a, packet_b = os.urandom(121), os.urandom(121) + + # can be called before the peer is initialized, a duplicate of a queued message is dropped + peer.send_onion_message(path_key=path_key, onion_message_packet=packet_a) + peer.send_onion_message(path_key=path_key, onion_message_packet=packet_a) # duplicate + peer.send_onion_message(path_key=path_key, onion_message_packet=packet_b) + self.assertEqual(2, peer._onion_message_send_queue.qsize()) + + with patch.object(lnpeer, 'ONION_MESSAGE_SEND_DELAY', TIME_STEP): + send_loop = asyncio.create_task(peer._send_onion_messages()) + try: + await asyncio.sleep(2 * TIME_STEP) + self.assertTrue(remote_transport.queue.empty()) # nothing is sent before init + + peer.their_features = ONION_MESSAGE_CAPABLE_PEER_FEATURES + peer.initialized.set_result(True) + + raw_msg = await wait_for2(remote_transport.queue.get(), timeout=1) + self.assertEqual(('onion_message', { + 'path_key': path_key, + 'len': len(packet_a), + 'onion_message_packet': packet_a, + }), decode_msg(raw_msg)) + raw_msg = await wait_for2(remote_transport.queue.get(), timeout=1) + self.assertEqual(packet_b, decode_msg(raw_msg)[1]['onion_message_packet']) + await asyncio.sleep(2 * TIME_STEP) + self.assertTrue(remote_transport.queue.empty()) # the duplicate was dropped + self.assertFalse(peer._pending_onion_messages) + + # after delivery, the same message is accepted again + peer.send_onion_message(path_key=path_key, onion_message_packet=packet_a) + raw_msg = await wait_for2(remote_transport.queue.get(), timeout=1) + self.assertEqual(packet_a, decode_msg(raw_msg)[1]['onion_message_packet']) + finally: + send_loop.cancel() + + async def test_send_onion_message_peer_without_feature(self): + # if it turns out after init that the peer doesn't support onion messages, the message is dropped + peer, remote_transport = self._create_peer() + peer.initialized.set_result(True) # their_features remains empty + send_loop = asyncio.create_task(peer._send_onion_messages()) + try: + peer.send_onion_message( + path_key=ECPrivkey.generate_random_key().get_public_key_bytes(), + onion_message_packet=os.urandom(121)) + await wait_until(lambda: not peer._pending_onion_messages) + self.assertTrue(remote_transport.queue.empty()) + finally: + send_loop.cancel() + + async def test_send_onion_message_queue_full(self): + # excess messages are dropped and don't pollute the dedup set + peer, _remote_transport = self._create_peer() + path_key = ECPrivkey.generate_random_key().get_public_key_bytes() + maxsize = peer._onion_message_send_queue.maxsize + for i in range(maxsize + 50): + peer.send_onion_message(path_key=path_key, onion_message_packet=i.to_bytes(4, 'big')) + self.assertEqual(maxsize, peer._onion_message_send_queue.qsize()) + self.assertEqual(maxsize, len(peer._pending_onion_messages)) + + class TestOnionMessageUtils(TestPeer): async def test_forward_onion_message(self): From 0fa6fad67c7729a2b96c468e48dade2ce91dc340 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 23 Mar 2026 16:19:41 +0100 Subject: [PATCH 14/45] PaymentIdentifier: _do_finalize: don't catch all exc Only catch UserFacingException. The scope of Exception is way too large, this caught all kinds of exceptions from LNWallet etc. It is not useful to show the user an error popup with e.g. "KeyError". --- electrum/payment_identifier.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index aed2124540f0..d6164075f217 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -11,8 +11,8 @@ from .i18n import _ from .invoices import Invoice from .logging import Logger -from .util import parse_max_spend, InvoiceError -from .util import get_asyncio_loop, log_exceptions +from .util import get_asyncio_loop, log_exceptions, UserFacingException, parse_max_spend, InvoiceError, \ + send_exception_to_crash_reporter from .transaction import PartialTxOutput from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url, try_resolve_lnurlpay, LNURL6Data, @@ -388,7 +388,7 @@ async def _do_finalize( from .invoices import Invoice try: if not self.lnurl_data: - raise Exception("Unexpected missing LNURL data") + assert self.lnurl_data, "Unexpected missing LNURL data" if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): self.error = _('Amount must be between {} and {} sat.').format( @@ -420,6 +420,8 @@ async def _do_finalize( self.error = str(e) self.logger.error(f"_do_finalize() got error: {e!r}") self.set_state(PaymentIdentifierState.ERROR) + if not isinstance(e, UserFacingException): + send_exception_to_crash_reporter(e) finally: if on_finished: on_finished(self) From fd492e61aad9c65f48c026531d66338c9c0f2673 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 10:56:54 +0200 Subject: [PATCH 15/45] lnonion: introduce BlindedPath dataclasses Introduce dataclasses for blinded path related structures. This allows safer and cleaner handling. - BlindedPathHop - BlindedPath - BlindedPayInfo - BlindedPathInfo --- electrum/commands.py | 3 + electrum/lnonion.py | 115 ++++++++++++++++++- electrum/lnutil.py | 1 + electrum/onion_message.py | 223 ++++++++++++++++-------------------- tests/test_onion_message.py | 51 +++++---- 5 files changed, 244 insertions(+), 149 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 39aaf1e8be46..745ceaf99fc3 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -48,6 +48,7 @@ from .lnworker import LN_P2P_NETWORK_TIMEOUT from .logging import Logger from .onion_message import create_blinded_path, send_onion_message_to +from .lnonion import BlindedPath from .submarine_swaps import NostrTransport from .util import ( bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal, @@ -2265,6 +2266,8 @@ async def send_onion_message(self, node_id_or_blinded_path_hex: str, message: st node_id_or_blinded_path = bfh(node_id_or_blinded_path_hex) assert len(node_id_or_blinded_path) >= 33 + if len(node_id_or_blinded_path) > 33: # assume blinded path + node_id_or_blinded_path = [BlindedPath.decode(node_id_or_blinded_path)] destination_payload = { 'message': {'text': message.encode('utf-8')} diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 43102d66be69..7a7a9e9c333b 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -36,8 +36,9 @@ from .crypto import sha256, hmac_oneshot, chacha20_encrypt, get_ecdh, chacha20_poly1305_encrypt, chacha20_poly1305_decrypt from .util import profiler, xor_bytes, bfh -from .lnutil import (PaymentFailure, NUM_MAX_HOPS_IN_PAYMENT_PATH, - NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag) +from .lnutil import (PaymentFailure, NUM_MAX_HOPS_IN_PAYMENT_PATH, LnFeatureContexts, + NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag, LnFeatures, + NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, validate_features, IncompatibleOrInsaneFeatures) from .lnmsg import OnionWireSerializer, read_bigsize_int, write_bigsize_int from . import lnmsg from . import util @@ -159,6 +160,116 @@ def onion_hash(self) -> bytes: return sha256(self.to_bytes()) +@dataclass(frozen=True, kw_only=True) +class BlindedPathHop: + blinded_node_id: bytes + enclen: int + encrypted_recipient_data: bytes + + def __post_init__(self): + ecc.ECPubkey(b=self.blinded_node_id) + + +@dataclass(frozen=True, kw_only=True) +class BlindedPath: + """ + https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/04-onion-routing.md?plain=1#L441 + """ + first_node_id: bytes + first_path_key: bytes + num_hops: bytes + path: list[BlindedPathHop] + + @property + def hop_count(self) -> int: + return int.from_bytes(self.num_hops, byteorder='big', signed=False) + + def __post_init__(self): + # if num_hops is 0 in any blinded_path in offer_paths: MUST NOT respond to the offer + assert isinstance(self.num_hops, bytes), type(self.num_hops) + assert isinstance(self.path, list), self.path + if self.hop_count == 0: + raise ValueError('invalid num_hops of 0') + if not self.path: + raise ValueError('empty path') + if not len(self.path) == self.hop_count: + raise ValueError(f'{len(self.path)=} != {self.hop_count=}') + # ecc.ECPubkey(b=self.first_node_id) # fails bolt 12 test vectors using dummy node ids + ecc.ECPubkey(b=self.first_path_key) + + @classmethod + def decode(cls, blinded_path: bytes) -> 'BlindedPath': + with io.BytesIO(blinded_path) as blinded_path_fd: + blinded_path = OnionWireSerializer.read_field( + fd=blinded_path_fd, + field_type='blinded_path', + count=1) + return cls.from_dict(blinded_path) + + @classmethod + def from_dict(cls, d: dict) -> 'BlindedPath': + if isinstance(d['path'], Mapping): # single path + d['path'] = [d['path']] + return BlindedPath( + first_node_id=d['first_node_id'], + first_path_key=d['first_path_key'], + num_hops=d['num_hops'], + path=[BlindedPathHop(**p) for p in d['path']], + ) + + +@dataclass(frozen=True) +class BlindedPayInfo: + fee_base_msat: int + fee_proportional_millionths: int + cltv_expiry_delta: int + htlc_minimum_msat: int + htlc_maximum_msat: int + features: LnFeatures + + def __post_init__(self): + if self.cltv_expiry_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: + raise ValueError(f"unreasonably long {self.cltv_expiry_delta=}") + + @property + def requires_unknown_mandatory_features(self) -> bool: + """ + MUST NOT use the corresponding invoice_paths.path if payinfo.features has any unknown even bits set. + """ + try: + validate_features(self.features, context=LnFeatureContexts.BLINDED_PAYINFO) + except IncompatibleOrInsaneFeatures: + return True + return False + + @classmethod + def from_dict(cls, d: dict) -> 'BlindedPayInfo': + return BlindedPayInfo( + fee_base_msat=int(d['fee_base_msat']), + fee_proportional_millionths=int(d['fee_proportional_millionths']), + cltv_expiry_delta=int(d['cltv_expiry_delta']), + htlc_minimum_msat=int(d['htlc_minimum_msat']), + htlc_maximum_msat=int(d['htlc_maximum_msat']), + features=LnFeatures(int.from_bytes(d['features'], byteorder="big", signed=False)) + ) + + def to_dict(self) -> dict: + return { + 'fee_base_msat': self.fee_base_msat, + 'fee_proportional_millionths': self.fee_proportional_millionths, + 'cltv_expiry_delta': self.cltv_expiry_delta, + 'htlc_minimum_msat': self.htlc_minimum_msat, + 'htlc_maximum_msat': self.htlc_maximum_msat, + 'flen': len(self.features.to_tlv_bytes()), + 'features': self.features.to_tlv_bytes() + } + + +class BlindedPathInfo(NamedTuple): + path: BlindedPath + payinfo: Optional[BlindedPayInfo] + + def get_bolt04_onion_key(key_type: bytes, secret: bytes) -> bytes: if key_type not in (b'rho', b'mu', b'um', b'ammag', b'pad', b'blinded_node_id'): raise Exception('invalid key_type {}'.format(key_type)) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index cd5d0e83c4c4..515b6bddf1c9 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1415,6 +1415,7 @@ class LnFeatureContexts(enum.Flag): BOLT12_INVREQ = enum.auto() BOLT12_INVOICE = enum.auto() BLINDED_PATH = enum.auto() + BLINDED_PAYINFO = enum.auto() LNFC = LnFeatureContexts diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 02dad13aeddd..547e473409a0 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -27,10 +27,10 @@ import os import threading import time +import dataclasses import random from enum import Enum, auto from dataclasses import dataclass - from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union, Mapping import electrum_ecc as ecc @@ -39,14 +39,14 @@ from electrum.lnrouter import PathEdge, NoChannelPolicy from electrum.logging import get_logger, Logger from electrum.crypto import get_ecdh -from electrum.lnmsg import OnionWireSerializer from electrum.lntransport import LNPeerAddr from electrum.lnonion import (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, - next_blinding_from_shared_secret) + next_blinding_from_shared_secret, BlindedPayInfo, BlindedPath, BlindedPathHop, + BlindedPathInfo) from electrum.lnutil import (LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, - MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) + validate_features, IncompatibleOrInsaneFeatures, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy, wait_for2 @@ -89,7 +89,7 @@ class DestinationState(Enum): @dataclass class OnionMessageDestination: - node_id_or_blinded_path: bytes + node_id_or_blinded_path: bytes | BlindedPath state: DestinationState = DestinationState.UNTRIED peer_addr: Optional[LNPeerAddr] = None @@ -102,7 +102,7 @@ def create_blinded_path( hop_extras: Optional[Sequence[dict]] = None, dummy_hops: Optional[int] = 0, channels: Optional[Sequence['Channel']] = None, -) -> dict: +) -> 'BlindedPath': # dummy hops could be inserted anywhere in the path, but for compatibility just add them at the end # because blinded paths are usually constructed towards ourselves, and we know we can handle dummy hops. if dummy_hops: @@ -113,7 +113,7 @@ def create_blinded_path( blinding = ecc.ECPrivkey(session_key).get_public_key_bytes() - onionmsg_hops = [] + onionmsg_hops: list[BlindedPathHop] = [] shared_secrets, blinded_node_ids = get_shared_secrets_along_route(path, session_key) for i, node_id in enumerate(path): is_non_final_node = i < len(path) - 1 @@ -140,31 +140,19 @@ def create_blinded_path( encrypted_recipient_data = encrypt_onionmsg_data_tlv(shared_secret=shared_secrets[i], **recipient_data) - hopdata = { - 'blinded_node_id': blinded_node_ids[i], - 'enclen': len(encrypted_recipient_data), - 'encrypted_recipient_data': encrypted_recipient_data - } + hopdata = BlindedPathHop( + blinded_node_id=blinded_node_ids[i], + enclen=len(encrypted_recipient_data), + encrypted_recipient_data=encrypted_recipient_data, + ) onionmsg_hops.append(hopdata) - blinded_path = { - 'first_node_id': introduction_point, - 'first_path_key': blinding, - 'num_hops': bytes([len(onionmsg_hops)]), - 'path': onionmsg_hops - } - - 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() + return BlindedPath( + first_node_id=introduction_point, + first_path_key=blinding, + num_hops=bytes([len(onionmsg_hops)]), + path=onionmsg_hops, + ) def is_onion_message_node(_node_id: bytes, node_info: Optional['NodeInfo']) -> bool: @@ -227,7 +215,7 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque def create_route_to_introduction_point( lnwallet: 'LNWallet', - blinded_path: dict, + blinded_path: BlindedPath, introduction_point: bytes, session_key: bytes ): @@ -238,7 +226,7 @@ def create_route_to_introduction_point( # if blinded path introduction point is our direct peer, no need to route-find if peer: # start of blinded path is our peer - path_key = blinded_path['first_path_key'] + path_key = blinded_path.first_path_key return peer, path_key, hops_data, blinded_node_ids path = create_onion_message_route_to(lnwallet, introduction_point) @@ -272,7 +260,7 @@ def create_route_to_introduction_point( tlv_stream_name='onionmsg_tlv', blind_fields={ 'next_node_id': {'node_id': introduction_point}, - 'next_path_key_override': {'path_key': blinded_path['first_path_key']}, + 'next_path_key_override': {'path_key': blinded_path.first_path_key}, }, ) hops_data.append(final_hop_pre_ip) @@ -285,88 +273,74 @@ def create_route_to_introduction_point( def send_onion_message_to( lnwallet: 'LNWallet', - node_id_or_blinded_path: bytes, + node_id_or_blinded_path: bytes | BlindedPath, destination_payload: dict, session_key: bytes = None ) -> None: if session_key is None: session_key = os.urandom(32) - if len(node_id_or_blinded_path) > 33: # assume blinded path - with io.BytesIO(node_id_or_blinded_path) as blinded_path_fd: - try: - blinded_path = OnionWireSerializer.read_field( - fd=blinded_path_fd, - field_type='blinded_path', - count=1) - logger.debug(f'blinded path: {blinded_path!r}') - except Exception as e: - logger.error(f'e!r') - raise - - introduction_point = blinded_path['first_node_id'] - if len(introduction_point) != 33: - raise Exception('first_node_id not a nodeid but a sciddir, which is not supported') - # Note: blinded_path specifies type sciddir_or_nodeid for first_node_id - # but only nodeid is supported in onion_message context; - # https://github.com/lightning/bolts/blob/master/04-onion-routing.md - # "MUST set first_node_id to N0" - - if lnwallet.node_keypair.pubkey == introduction_point: - hops_data = [] - blinded_node_ids = [] - - # blinded path introduction point is me - our_blinding = blinded_path['first_path_key'] - our_payload = blinded_path['path'][0] - remaining_blinded_path = blinded_path['path'][1:] - assert len(remaining_blinded_path) > 0, 'sending to myself?' - - # decrypt - shared_secret = get_ecdh(lnwallet.node_keypair.privkey, our_blinding) - recipient_data = decrypt_onionmsg_data_tlv( - shared_secret=shared_secret, - encrypted_recipient_data=our_payload['encrypted_recipient_data'] - ) - - peer = lnwallet.lnpeermgr.get_peer_by_pubkey(recipient_data['next_node_id']['node_id']) - assert peer, 'next_node_id not a peer' - - # blinding override? - next_path_key_override = recipient_data.get('next_path_key_override') - if next_path_key_override: - next_path_key = next_path_key_override.get('path_key') - else: - next_path_key = next_blinding_from_shared_secret(our_blinding, shared_secret) + if isinstance(node_id_or_blinded_path, BlindedPath): + blinded_path = node_id_or_blinded_path + introduction_point = blinded_path.first_node_id + if len(introduction_point) != 33: + raise Exception('first_node_id not a nodeid but a sciddir, which is not supported') + # Note: blinded_path specifies type sciddir_or_nodeid for first_node_id + # but only nodeid is supported in onion_message context; + # https://github.com/lightning/bolts/blob/master/04-onion-routing.md + # "MUST set first_node_id to N0" + + if lnwallet.node_keypair.pubkey == introduction_point: + hops_data = [] + blinded_node_ids = [] + + # blinded path introduction point is me + our_blinding = blinded_path.first_path_key + our_payload = blinded_path.path[0] + remaining_blinded_path = blinded_path.path[1:] + assert len(remaining_blinded_path) > 0, 'sending to myself?' + + # decrypt + shared_secret = get_ecdh(lnwallet.node_keypair.privkey, our_blinding) + recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=shared_secret, + encrypted_recipient_data=our_payload.encrypted_recipient_data, + ) - path_key = next_path_key + peer = lnwallet.lnpeermgr.get_peer_by_pubkey(recipient_data['next_node_id']['node_id']) + assert peer, 'next_node_id not a peer' + # blinding override? + next_path_key_override = recipient_data.get('next_path_key_override') + if next_path_key_override: + next_path_key = next_path_key_override.get('path_key') else: - # we need a route to introduction point - r = create_route_to_introduction_point(lnwallet, blinded_path, introduction_point, session_key) - peer, path_key, hops_data, blinded_node_ids = r - - remaining_blinded_path = blinded_path['path'] - if not isinstance(remaining_blinded_path, list): # doesn't return list when num items == 1 - remaining_blinded_path = [remaining_blinded_path] - - # append (remaining) blinded path and payload - blinded_path_blinded_ids = [] - for i, onionmsg_hop in enumerate(remaining_blinded_path): - blinded_path_blinded_ids.append(onionmsg_hop.get('blinded_node_id')) - payload = { - 'encrypted_recipient_data': {'encrypted_recipient_data': onionmsg_hop['encrypted_recipient_data']} - } - if i == len(remaining_blinded_path) - 1: # final hop - payload.update(destination_payload) - hop = OnionHopsDataSingle(tlv_stream_name='onionmsg_tlv', payload=payload) - hops_data.append(hop) + next_path_key = next_blinding_from_shared_secret(our_blinding, shared_secret) + path_key = next_path_key + else: + # we need a route to introduction point + r = create_route_to_introduction_point(lnwallet, blinded_path, introduction_point, session_key) + peer, path_key, hops_data, blinded_node_ids = r + remaining_blinded_path = blinded_path.path + + # append (remaining) blinded path and payload + blinded_path_blinded_ids = [] + for i, onionmsg_hop in enumerate(remaining_blinded_path): + blinded_path_blinded_ids.append(onionmsg_hop.blinded_node_id) + payload = { + 'encrypted_recipient_data': {'encrypted_recipient_data': onionmsg_hop.encrypted_recipient_data} + } + if i == len(remaining_blinded_path) - 1: # final hop + payload.update(destination_payload) + hop = OnionHopsDataSingle(tlv_stream_name='onionmsg_tlv', payload=payload) + hops_data.append(hop) payment_path_pubkeys = blinded_node_ids + blinded_path_blinded_ids packet = new_onion_packet(payment_path_pubkeys, session_key, hops_data, onion_message=True) packet_b = packet.to_bytes() else: # node pubkey + assert isinstance(node_id_or_blinded_path, bytes) and len(node_id_or_blinded_path) == 33 pubkey = node_id_or_blinded_path if lnwallet.node_keypair.pubkey == pubkey: @@ -403,7 +377,7 @@ def send_onion_message_to( hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(payment_path_pubkeys, session_key) encrypt_hops_recipient_data(hops_data, hop_shared_secrets) - packet = new_onion_packet(blinded_node_ids, session_key, hops_data) + packet = new_onion_packet(blinded_node_ids, session_key, hops_data, onion_message=True) packet_b = packet.to_bytes() path_key = ecc.ECPrivkey(session_key).get_public_key_bytes() @@ -423,11 +397,11 @@ def get_blinded_reply_paths( path_id: bytes, *, max_paths: int = REQUEST_REPLY_PATHS_MAX, -) -> Sequence[dict]: +) -> Sequence[BlindedPathInfo]: """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, onion_message=True) + paths = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, onion_message=True) return paths @@ -438,7 +412,7 @@ def get_blinded_paths_to_me( max_paths: int = PAYMENT_PATHS_MAX, my_channels: Optional[Sequence['Channel']] = None, onion_message: bool = False -) -> Tuple[Sequence[dict], Sequence[dict]]: +) -> Sequence[BlindedPathInfo]: """construct a list of blinded paths. current logic: - uses active channel peers if my_channels not provided @@ -457,19 +431,17 @@ def get_blinded_paths_to_me( lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(required_features)] result = [] - payinfos = [] mynodeid = lnwallet.node_keypair.pubkey if my_channels: rchans = random_shuffled_copy(my_channels) for chan in rchans[:max_paths]: - hop_extras = None + payinfo, hop_extras = None, None if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) try: payinfo, hop_extras = _get_payinfo_for_blinded_path(chan, lnwallet) except NoChannelPolicy: logger.warning(f"missing remote channel_update for {chan.short_channel_id}") continue - payinfos.append(payinfo) blinded_path = create_blinded_path( session_key=os.urandom(32), path=[chan.node_id, mynodeid], @@ -477,7 +449,10 @@ def get_blinded_paths_to_me( hop_extras=hop_extras, channels=[chan] if not onion_message else None, ) - result.append(blinded_path) + result.append(BlindedPathInfo( + path=blinded_path, + payinfo=payinfo, + )) if not result: if not onion_message: @@ -491,13 +466,16 @@ def get_blinded_paths_to_me( rpeers = random_shuffled_copy(my_onionmsg_peers) for peer in rpeers[:max_paths]: blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) - result.append(blinded_path) + result.append(BlindedPathInfo( + path=blinded_path, + payinfo=None, + )) assert result - return result, payinfos + return result -def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet'): +def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet') -> tuple[BlindedPayInfo, list[dict]]: cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) if not cp: raise NoChannelPolicy(chan.short_channel_id) @@ -533,15 +511,14 @@ def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet'): 'htlc_minimum_msat': blinded_path_min_htlc_msat } }] - payinfo = { - '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 + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, - 'htlc_minimum_msat': blinded_path_min_htlc_msat, - 'htlc_maximum_msat': blinded_path_max_htlc_msat, - 'flen': 0, - 'features': b'', - } + payinfo = BlindedPayInfo( + 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 + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, + htlc_minimum_msat=blinded_path_min_htlc_msat, + htlc_maximum_msat=blinded_path_max_htlc_msat, + features=LnFeatures(0), + ) return payinfo, hop_extras @@ -569,7 +546,7 @@ class OnionMessageManager(Logger): MAX_CONCURRENT_DIRECT_CONNECTION_ATTEMPTS = 10 # allocated once for sending and once for forwarding class Request: - def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[bytes]]): + def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[BlindedPath]]): self.future = asyncio.Future() self.payload = payload self.current_index: int = 0 @@ -732,7 +709,7 @@ def _remove_pending_message(self, key: bytes) -> None: def submit_send( self, *, payload: dict, - node_id_or_blinded_paths: Union[bytes, Sequence[bytes]], + node_id_or_blinded_paths: Union[bytes, Sequence[BlindedPath]], key: Optional[bytes] = None) -> 'Task': """Add onion message to queue for sending. Queued onion message payloads are supplied with a path_id and a reply_path to determine which request @@ -782,7 +759,7 @@ def _send_pending_message(self, key: bytes) -> None: # unless explicitly set in payload, generate reply_path here path_id = self._path_id_from_payload_and_key(payload, key) reply_paths = get_blinded_reply_paths(self.lnwallet, path_id, max_paths=1) - final_payload['reply_path'] = {'path': reply_paths} + final_payload['reply_path'] = {'path': [dataclasses.asdict(x.path) for x in reply_paths]} try: # NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 7345ee541bdc..34be28ab975f 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -17,7 +17,7 @@ from electrum.lnonion import ( OnionHopsDataSingle, OnionPacket, process_onion_packet, get_bolt04_onion_key, encrypt_onionmsg_data_tlv, get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize, - encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv) + encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv, BlindedPath) from electrum.crypto import get_ecdh, privkey_to_pubkey from electrum.channel_db import NodeInfo from electrum.lntransport import LNPeerAddr @@ -204,14 +204,14 @@ def test_create_blinded_path(self): final_recipient_data = {'path_id': {'data': bfh('0102')}} rp = create_blinded_path(session_key, [pubkey], final_recipient_data) - self.assertEqual(pubkey, rp['first_node_id']) - self.assertEqual(bfh('022ed557f5ad336b31a49857e4e9664954ac33385aa20a93e2d64bfe7f08f51277'), rp['first_path_key']) - self.assertEqual(b"\x01", rp['num_hops']) + self.assertEqual(pubkey, rp.first_node_id) + self.assertEqual(bfh('022ed557f5ad336b31a49857e4e9664954ac33385aa20a93e2d64bfe7f08f51277'), rp.first_path_key) + self.assertEqual(b"\x01", rp.num_hops) self.assertEqual([{ 'blinded_node_id': bfh('031e5d91e6c417f6e8c16d1086db1887edef7be9334f5e744d04edb8da7507481e'), 'enclen': 20, 'encrypted_recipient_data': bfh('2dbaa54a819775aa0548ab85db68c5099e7b1180') - }], rp['path']) + }], [dataclasses.asdict(p) for p in rp.path]) # TODO: serialization test to test_lnmsg.py with io.BytesIO() as blinded_path_fd: @@ -219,7 +219,7 @@ def test_create_blinded_path(self): fd=blinded_path_fd, field_type='blinded_path', count=1, - value=rp) + value=dataclasses.asdict(rp)) blinded_path = blinded_path_fd.getvalue() self.assertEqual(blinded_path, bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619022ed557f5ad336b31a49857e4e9664954ac33385aa20a93e2d64bfe7f08f5127701031e5d91e6c417f6e8c16d1086db1887edef7be9334f5e744d04edb8da7507481e00142dbaa54a819775aa0548ab85db68c5099e7b1180')) @@ -251,10 +251,10 @@ def test_create_onionmessage_to_blinded_path_via_alice(self): encrypt_hops_recipient_data(hops_data, hop_shared_secrets) blinded_path_blinded_ids = [] - for i, x in enumerate(blinded_path_to_dave.get('path')): - blinded_path_blinded_ids.append(x.get('blinded_node_id')) - payload = {'encrypted_recipient_data': {'encrypted_recipient_data': x.get('encrypted_recipient_data')}} - if i == len(blinded_path_to_dave.get('path')) - 1: + for i, x in enumerate(blinded_path_to_dave.path): + blinded_path_blinded_ids.append(x.blinded_node_id) + payload = {'encrypted_recipient_data': {'encrypted_recipient_data': x.encrypted_recipient_data}} + if i == len(blinded_path_to_dave.path) - 1: # add final recipient payload payload['message'] = {'text': bfh(test_vectors['onionmessage']['unknown_tag_1'])} hops_data.append( @@ -953,12 +953,11 @@ async def test_get_blinded_paths_to_me_payment(self): alice.lnpeermgr.get_peer_by_pubkey(bob.node_keypair.pubkey).their_features |= LnFeatures.OPTION_ROUTE_BLINDING_OPT final_recipient_data = {'path_id': {'data': os.urandom(32)}} - paths, payinfos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False) + blinded_path_infos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False) - self.assertEqual(len(paths), 1) - self.assertEqual(len(payinfos), 1) + self.assertEqual(len(blinded_path_infos), 1) - self.assertEqual(payinfos[0], { + self.assertEqual(blinded_path_infos[0].payinfo.to_dict(), { 'fee_base_msat': bob_chan.forwarding_fee_base_msat, 'fee_proportional_millionths': bob_chan.forwarding_fee_proportional_millionths, 'cltv_expiry_delta': bob_chan.forwarding_cltv_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, @@ -968,13 +967,13 @@ async def test_get_blinded_paths_to_me_payment(self): 'features': bytes(0), }) - blinded_path = paths[0] - self.assertEqual(len(blinded_path['path']), 2) - self.assertEqual(blinded_path['first_node_id'], bob.node_keypair.pubkey) - self.assertEqual(len(blinded_path['first_path_key']), 33) - self.assertEqual(blinded_path['num_hops'], len(blinded_path['path']).to_bytes(length=1, byteorder='big')) - self.assertIn('blinded_node_id', blinded_path['path'][0]) - self.assertIn('encrypted_recipient_data', blinded_path['path'][0]) + blinded_path = blinded_path_infos[0].path + self.assertEqual(len(blinded_path.path), 2) + self.assertEqual(blinded_path.first_node_id, bob.node_keypair.pubkey) + self.assertEqual(len(blinded_path.first_path_key), 33) + self.assertEqual(blinded_path.num_hops, len(blinded_path.path).to_bytes(length=1, byteorder='big')) + self.assertIsNotNone(blinded_path.path[0].blinded_node_id) + self.assertIsNotNone(blinded_path.path[0].encrypted_recipient_data) async def test_create_route_to_introduction_point(self): # A -- B -- C -- D -- E @@ -986,9 +985,13 @@ async def test_create_route_to_introduction_point(self): session_key = os.urandom(32) introduction_point = edward.node_keypair.pubkey first_path_key = ecc.ECPrivkey.generate_random_key().get_public_key_bytes() - blinded_path = { - 'first_path_key': first_path_key, - } + BlindedPath.__post_init__ = lambda _: None # disable sanity checks + blinded_path = BlindedPath( + first_path_key=first_path_key, + first_node_id=None, + num_hops=None, + path=None, + ) with self.assertRaises(NoRouteFound): create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key) From 225417422a5eac85067b3bd8e99697f6669c92b0 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:09:23 +0200 Subject: [PATCH 16/45] bolt12: add BOLT12 classes, encode/decode Introduce typed dataclass hierarchy for BOLT12 objects: - BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice - validation in __post_init__() methods - helper functions - unittests (test_bolt12.py) Co-Authored-By: Sander van Grieken --- electrum/bolt12.py | 529 ++++++++++++++++++++++++++++++++++++++++ electrum/commands.py | 3 +- electrum/segwit_addr.py | 6 +- tests/test_bolt12.py | 467 +++++++++++++++++++++++++++++++++++ 4 files changed, 1001 insertions(+), 4 deletions(-) create mode 100644 electrum/bolt12.py create mode 100644 tests/test_bolt12.py diff --git a/electrum/bolt12.py b/electrum/bolt12.py new file mode 100644 index 000000000000..599728379b29 --- /dev/null +++ b/electrum/bolt12.py @@ -0,0 +1,529 @@ +# -*- coding: utf-8 -*- +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2025 The Electrum developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import copy +import io +import time +from dataclasses import dataclass, field, asdict, fields +from functools import cached_property +import re +from typing import Optional, Tuple, Iterable, Type, TypeVar, Any, ClassVar, Sequence +from abc import ABC, abstractmethod + +import electrum_ecc as ecc + +from . import constants +from .util import chunks +from .lnmsg import OnionWireSerializer +from .lnutil import LnFeatures, validate_features, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, LnFeatureContexts +from .onion_message import BlindedPath, BlindedPayInfo +from .segwit_addr import ( + bech32_decode, convertbits, bech32_encode, Encoding, INVALID_BECH32, + CHARSET as BECH32_CHARSET, encode_segwit_address, +) + + +DEFAULT_INVOICE_EXPIRY = 7200 + + +TBOLT12Base = TypeVar("TBOLT12Base", bound="BOLT12Base") + + +@dataclass(frozen=True, kw_only=True) +class BOLT12Base(ABC): + tlv_stream_name: ClassVar[str] + signing_key_path: ClassVar[Optional[tuple[str, ...]]] + hrp: ClassVar[str] # human-readable part of the bech32 encoded string + _unknown_fields: dict[str, Any] = field(default_factory=dict) + + @classmethod + def decode(cls: Type[TBOLT12Base], data: str | bytes) -> TBOLT12Base: + d = bolt12_bech32_to_bytes(data) if isinstance(data, str) else data + with io.BytesIO(d) as fd: + protocol_dict = OnionWireSerializer.read_tlv_stream( + fd=fd, + tlv_stream_name=cls.tlv_stream_name, + signing_key_path=cls.signing_key_path, + ) + return cls.deserialize(protocol_dict) + + def encode(self, *, signing_key: bytes = None, as_bech32: bool = False) -> str | bytes: + if self.signing_key_path: + # if no signing_key is passed we keep the existing signature, else a new one is created + assert signing_key or any(f.name.endswith('_signature') and getattr(self, f.name) for f in fields(self)) + else: + assert signing_key is None, "cannot sign offer" + + data = self.serialize(with_signature=False if signing_key else True) + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream( + fd=fd, + tlv_stream_name=self.tlv_stream_name, + signing_key=signing_key, + **data, + ) + if not as_bech32: + return fd.getvalue() + return bolt12_tlv_bytes_to_bech32(fd.getvalue(), type(self)) + + _ENCODE_MAP = {} + def serialize(self, *, with_signature: bool = False) -> dict: + protocol_dict = copy.deepcopy(self._unknown_fields) + for f in fields(self): + if f.name.startswith('_'): + continue + if f.name.endswith('_signature') and not with_signature: + continue + value = getattr(self, f.name) + if value is None: + continue + if isinstance(value, LnFeatures) and value == LnFeatures(0): + continue + key = 'signature' if f.name.endswith('_signature') else f.name + protocol_dict[key] = self._ENCODE_MAP[f.name](value) + return protocol_dict + + @classmethod + @abstractmethod + def deserialize(cls: Type[TBOLT12Base], protocol_dict: dict) -> TBOLT12Base: + pass + + @property + def is_expired(self) -> bool: + now = int(time.time()) + expiry_time = None + if type(self) == BOLT12Invoice: + expiry_time = self.invoice_created_at + self.invoice_relative_expiry + elif type(self) == BOLT12Offer: + expiry_time = self.offer_absolute_expiry + return now > expiry_time if expiry_time is not None else False + + +@dataclass(frozen=True, kw_only=True) +class BOLT12Offer(BOLT12Base): + """ + https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/12-offer-encoding.md?plain=1#L182 + """ + tlv_stream_name = 'offer' + signing_key_path = None # offers are not signed + hrp = 'lno' + + offer_features: Optional[LnFeatures] = None + offer_chains: Optional[list[bytes]] = None + offer_metadata: Optional[bytes] = None + offer_currency: Optional[str] = None + offer_amount: Optional[int] = None + offer_description: Optional[str] = None + offer_absolute_expiry: Optional[int] = None + offer_paths: Optional[tuple[BlindedPath, ...]] = None + offer_issuer: Optional[str] = None + offer_quantity_max: Optional[int] = None + offer_issuer_id: Optional[bytes] = None + + def __post_init__(self): + # if the chain for the invoice is not solely bitcoin: + # MUST specify offer_chains the offer is valid for. + if not matches_our_chain(self.offer_chains): + # instance might be offerless invreq, invreq __post_init__ chain check has priority + if type(self) == BOLT12Offer and 'invreq_chain' not in self._unknown_fields: + raise NoMatchingChainError() + if self.offer_chains is not None and not self.offer_chains: + raise ValueError('empty offer_chains') + # if offer_features contains unknown even bits that are non-zero: MUST NOT respond to the offer + if self.offer_features: + validate_features(self.offer_features, context=LnFeatureContexts.BOLT12_OFFER) + # if offer_amount is set and offer_description is not set: MUST NOT respond to the offer + if self.offer_amount is not None: + if self.offer_amount <= 0: # MUST set `offer_amount` greater than zero. + raise ValueError(f"offer amount must be > 0") + if self.offer_description is None: + raise ValueError('missing offer_description, but has offer_amount') + # if offer_currency is set and offer_amount is not set: MUST NOT respond to the offer + if self.offer_currency is not None and self.offer_amount is None: + raise ValueError('missing offer_amount, but has offer_currency') + # if neither offer_issuer_id nor offer_paths are set: MUST NOT respond to the offer + if not self.offer_issuer_id and not self.offer_paths: + # instance can be offerless invreq, or instantiated in BOLT12InvoiceRequest.deserialize() + if not getattr(self, 'invreq_payer_id', self._unknown_fields.get('invreq_payer_id')): + raise ValueError('neither offer_issuer_id nor offer_paths are given') + if self.offer_issuer_id is not None: + ecc.ECPubkey(b=self.offer_issuer_id) + + @classmethod + def deserialize(cls, o: dict) -> 'BOLT12Offer': + o = copy.deepcopy(o) + if (offer_features := o.pop('offer_features', {}).get('features')) is not None: + offer_features = LnFeatures(int.from_bytes(offer_features, byteorder="big", signed=False)) + if (offer_chains := o.pop('offer_chains', {}).get('chains')) is not None: + offer_chains = [chain for chain in chunks(offer_chains, 32)] + if (offer_paths := o.pop('offer_paths', {}).get('paths')) is not None: + offer_paths = tuple(BlindedPath.from_dict(p) for p in offer_paths) + + return BOLT12Offer( + offer_chains=offer_chains, + offer_metadata=o.pop('offer_metadata', {}).get('data'), + offer_currency=o.pop('offer_currency', {}).get('iso4217'), + offer_amount=o.pop('offer_amount', {}).get('amount'), + offer_description=o.pop('offer_description', {}).get('description'), + offer_features=offer_features, + offer_absolute_expiry=o.pop('offer_absolute_expiry', {}).get('seconds_from_epoch'), + offer_paths=offer_paths, + offer_issuer=o.pop('offer_issuer', {}).get('issuer'), + offer_quantity_max=o.pop('offer_quantity_max', {}).get('max'), + offer_issuer_id=o.pop('offer_issuer_id', {}).get('id'), + _unknown_fields=o, + ) + + _ENCODE_MAP = BOLT12Base._ENCODE_MAP | { + 'offer_chains': lambda v: {'chains': b''.join(v)}, + 'offer_metadata': lambda v: {'data': v}, + 'offer_currency': lambda v: {'iso4217': v}, + 'offer_amount': lambda v: {'amount': v}, + 'offer_description': lambda v: {'description': v}, + 'offer_features': lambda v: {'features': v.to_tlv_bytes()}, + 'offer_absolute_expiry': lambda v: {'seconds_from_epoch': v}, + 'offer_paths': lambda v: {'paths': [asdict(p) for p in v]}, + 'offer_issuer': lambda v: {'issuer': v}, + 'offer_quantity_max': lambda v: {'max': v}, + 'offer_issuer_id': lambda v: {'id': v}, + } + + +@dataclass(frozen=True, kw_only=True) +class BOLT12InvoiceRequest(BOLT12Offer): + """ + https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/12-offer-encoding.md?plain=1#L357 + """ + tlv_stream_name = 'invoice_request' + signing_key_path = ('invreq_payer_id', 'key') + hrp = 'lnr' + + invreq_metadata: bytes + invreq_chain: Optional[bytes] = None + invreq_amount: Optional[int] = None + invreq_features: Optional[LnFeatures] = None + invreq_quantity: Optional[int] = None + invreq_payer_id: bytes + invreq_payer_note: Optional[str] = None + invreq_paths: Optional[tuple[BlindedPath, ...]] = None + invreq_bip_353_name: Optional[Tuple[str, str]] = None # name, domain + invreq_signature: Optional[bytes] = None # sig for incoming req is validated in OnionWireSerializer + + def __post_init__(self): + super().__post_init__() + # MUST reject the invoice request if invreq_payer_id or invreq_metadata are not present + if not self.invreq_payer_id or not self.invreq_metadata: + raise ValueError(f"{bool(self.invreq_payer_id)=} or {bool(self.invreq_metadata)=} missing") + if self.invreq_features: + validate_features(self.invreq_features, context=LnFeatureContexts.BOLT12_INVREQ) + # if offer_issuer_id or offer_paths are present (response to an offer): + if self.offer_issuer_id or self.offer_paths: + # if offer_quantity_max is present: + if self.offer_quantity_max is not None: + if self.invreq_quantity is None: + # MUST reject the invoice request if there is no invreq_quantity field. + raise ValueError(f"{self.offer_quantity_max} is given but no invreq_quantity") + # if offer_quantity_max is non-zero: + if self.offer_quantity_max: + # MUST reject the invoice request if invreq_quantity is zero, OR greater than offer_quantity_max + if not self.invreq_quantity or self.invreq_quantity > self.offer_quantity_max: + raise ValueError(f"{self.invreq_quantity=} is zero or greater than offer_quantity_max") + else: + # otherwise: MUST reject the invoice request if there is an invreq_quantity field + if self.invreq_quantity is not None: + raise ValueError("invreq_quantity given but no offer_quantity_max") + # if offer_amount is present: + if (expected_amount := self.offer_amount) is not None: + # MUST calculate the expected amount using the offer_amount + if self.offer_currency and self.offer_currency.upper() != 'BTC': + # TODO: if offer_currency is not the invreq_chain currency, convert to the invreq_chain currency + raise NotImplementedError("no fx conversion support yet, will this be used?") + # if invreq_quantity is present, multiply by invreq_quantity.quantity + if self.invreq_quantity: + # NOTE: not allowing self.invreq_quantity of 0 here, this seems unsafe? + expected_amount *= self.invreq_quantity + # if invreq_amount is present + if self.invreq_amount is not None: + # MUST reject the invoice request if invreq_amount.msat is less than the expected amount. + if self.invreq_amount < expected_amount: + raise ValueError(f"{self.invreq_amount=} < {expected_amount=}") + # MAY reject the invoice request if invreq_amount.msat greatly exceeds the expected amount + elif self.invreq_amount > int(expected_amount * 1.5): + raise ValueError(f"{self.invreq_amount=} > {int(expected_amount * 1.5)=}") + # otherwise (no offer_amount): + else: + # MUST reject the invoice request if it does not contain invreq_amount + if self.invreq_amount is None: + raise ValueError("no offer_amount and no invreq_amount") + # otherwise (no offer_issuer_id or offer_paths, not a response to our offer): + else: + # MUST reject the invoice request if any of the following are present: + if self.offer_chains is not None or self.offer_features is not None or self.offer_quantity_max is not None: + raise ValueError("offer_chains, offer_features or offer_quantity_max present") + # MUST reject the invoice request if invreq_amount is not present + if self.invreq_amount is None: + raise ValueError("invreq_amount missing") + if not matches_our_chain([self.invreq_chain] if self.invreq_chain else None): + raise NoMatchingChainError() + if self.invreq_bip_353_name is not None: + name, domain = self.invreq_bip_353_name + if not validate_bip_353_name(name, domain): + raise ValueError(f"invalid bip 353 name: {self.invreq_bip_353_name}") + + @classmethod + def deserialize(cls, ir: dict) -> 'BOLT12InvoiceRequest': + ir = copy.deepcopy(ir) + offer = BOLT12Offer.deserialize(ir) + d = offer._unknown_fields + if (invreq_features := d.pop('invreq_features', {}).get('features')) is not None: + invreq_features = LnFeatures(int.from_bytes(invreq_features, byteorder="big", signed=False)) + if (invreq_paths := d.pop('invreq_paths', {}).get('paths')) is not None: + invreq_paths = tuple(BlindedPath.from_dict(p) for p in invreq_paths) + if invreq_bip_353_name := d.pop('invreq_bip_353_name', None): + name, domain = invreq_bip_353_name['name'], invreq_bip_353_name['domain'] + invreq_bip_353_name = (name, domain) + + offer_fields = {f.name: getattr(offer, f.name) for f in fields(BOLT12Offer) if not f.name.startswith('_')} + + return BOLT12InvoiceRequest( + **offer_fields, + invreq_metadata=d.pop('invreq_metadata', {}).get('blob'), + invreq_chain=d.pop('invreq_chain', {}).get('chain'), + invreq_amount=d.pop('invreq_amount', {}).get('msat'), + invreq_features=invreq_features, + invreq_quantity=d.pop('invreq_quantity', {}).get('quantity'), + invreq_payer_id=d.pop('invreq_payer_id', {}).get('key'), + invreq_payer_note=d.pop('invreq_payer_note', {}).get('note'), + invreq_paths=invreq_paths, + invreq_bip_353_name=invreq_bip_353_name, + invreq_signature=d.pop('signature', {}).get('sig'), + _unknown_fields=d, + ) + + _ENCODE_MAP = BOLT12Offer._ENCODE_MAP | { + 'invreq_metadata': lambda v: {'blob': v}, + 'invreq_chain': lambda v: {'chain': v}, + 'invreq_amount': lambda v: {'msat': v}, + 'invreq_features': lambda v: {'features': v.to_tlv_bytes()}, + 'invreq_quantity': lambda v: {'quantity': v}, + 'invreq_payer_id': lambda v: {'key': v}, + 'invreq_payer_note': lambda v: {'note': v}, + 'invreq_paths': lambda v: {'paths': [asdict(p) for p in v]}, + 'invreq_bip_353_name': lambda v: {'name': v}, + 'invreq_signature': lambda v: {'sig': v}, + } + + +@dataclass(frozen=True, kw_only=True) +class BOLT12Invoice(BOLT12InvoiceRequest): + tlv_stream_name = 'invoice' + signing_key_path = ('invoice_node_id', 'node_id') + hrp = 'lni' + + invoice_paths: tuple[BlindedPath, ...] + invoice_blindedpay: tuple[BlindedPayInfo, ...] + invoice_created_at: int + invoice_relative_expiry: int = DEFAULT_INVOICE_EXPIRY + invoice_payment_hash: bytes + invoice_amount: int + invoice_fallbacks: Optional[tuple[dict]] = None + invoice_features: Optional[LnFeatures] = None + invoice_node_id: bytes + invoice_signature: Optional[bytes] = None + + def __post_init__(self): + super().__post_init__() + # MUST reject the invoice if invoice_amount is not present + if self.invoice_amount is None: + raise ValueError("invoice_amount missing") + # MUST reject the invoice if invoice_created_at is not present + if self.invoice_created_at is None: + raise ValueError("invoice_created_at missing") + elif self.invoice_created_at > int(time.time()) + 100: + raise ValueError(f"invoice_created_at in the future: {self.invoice_created_at}") + # MUST reject the invoice if invoice_payment_hash is not present + if self.invoice_payment_hash is None: + raise ValueError("invoice_payment_hash missing") + # MUST reject the invoice if invoice_node_id is not present + if self.invoice_node_id is None: + raise ValueError("invoice_node_id missing") + if self.invoice_features: + validate_features(self.invoice_features, context=LnFeatureContexts.BOLT12_INVOICE) + # MUST reject the invoice if invoice_paths is not present or is empty + if not self.invoice_paths: + raise ValueError("invoice_paths missing or empty") + # MUST reject the invoice if invoice_blindedpay is not present. + if not self.invoice_blindedpay: + raise ValueError("invoice_blindedpay missing or empty") + # MUST reject the invoice if invoice_blindedpay does not contain exactly one blinded_payinfo per invoice_paths.blinded_path. + if len(self.invoice_blindedpay) != len(self.invoice_paths): + raise ValueError("invoice_blindedpay length mismatch") + if all(payinfo.requires_unknown_mandatory_features for payinfo in self.invoice_blindedpay): + # MUST reject the invoice if this leaves no usable paths. + raise ValueError("no payinfo with sane features") + if any(p.cltv_expiry_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE for p in self.invoice_blindedpay): + raise ValueError(f"Invoice wants us to risk locking funds for unreasonably long: {self.invoice_blindedpay}") + # if offer_issuer_id is present (invoice_request for an offer): + if self.offer_issuer_id is not None: + # MUST reject the invoice if invoice_node_id is not equal to offer_issuer_id + if self.invoice_node_id != self.offer_issuer_id: + raise ValueError(f"{self.offer_issuer_id.hex()=} != {self.invoice_node_id.hex()=}") + # otherwise, if offer_paths is present (invoice_request for an offer without id): + elif self.offer_paths is not None: + # MUST reject the invoice if invoice_node_id is not equal to the final blinded_node_id it sent the invoice request to. + # NOTE: check is less strict than the spec, but doesn't require us to keep state to + # which blinded_node_id we sent the invreq to. The benefit is we can (always, implicitly) check it here. + if not any(p.path[-1].blinded_node_id == self.invoice_node_id for p in self.offer_paths): + raise ValueError(f"{self.invoice_node_id=} doesn't match any last blinded node id of offer paths") + if self.invreq_amount is not None: + if self.invoice_amount != self.invreq_amount: + raise ValueError("invoice_amount != invreq_amount") + + @classmethod + def deserialize(cls, inv: dict) -> 'BOLT12Invoice': + inv = copy.deepcopy(inv) + invoice_signature = inv.pop('signature', {}).get('sig') + invreq = BOLT12InvoiceRequest.deserialize(inv) + d = invreq._unknown_fields + + if (invoice_features := d.pop('invoice_features', {}).get('features')) is not None: + invoice_features = LnFeatures(int.from_bytes(invoice_features, byteorder="big", signed=False)) + if (invoice_paths := d.pop('invoice_paths', {}).get('paths')) is not None: + invoice_paths = tuple(BlindedPath.from_dict(p) for p in invoice_paths) + if (invoice_blindedpay := d.pop('invoice_blindedpay', {}).get('payinfo')) is not None: + invoice_blindedpay = tuple(BlindedPayInfo.from_dict(p) for p in invoice_blindedpay) + if (invoice_fallbacks := d.pop('invoice_fallbacks', {}).get('fallbacks')) is not None: + invoice_fallbacks = tuple(invoice_fallbacks) + + parent_fields = {f.name: getattr(invreq, f.name) for f in fields(BOLT12InvoiceRequest) if not f.name.startswith('_')} + + return BOLT12Invoice( + **parent_fields, + invoice_paths=invoice_paths, + invoice_blindedpay=invoice_blindedpay, + invoice_created_at=d.pop('invoice_created_at', {}).get('timestamp'), + invoice_relative_expiry=d.pop('invoice_relative_expiry', {}).get('seconds_from_creation', DEFAULT_INVOICE_EXPIRY), + invoice_payment_hash=d.pop('invoice_payment_hash', {}).get('payment_hash'), + invoice_amount=d.pop('invoice_amount', {}).get('msat'), + invoice_fallbacks=invoice_fallbacks, + invoice_features=invoice_features, + invoice_node_id=d.pop('invoice_node_id', {}).get('node_id'), + invoice_signature=invoice_signature, + _unknown_fields=d + ) + + _ENCODE_MAP = BOLT12InvoiceRequest._ENCODE_MAP | { + 'invoice_paths': lambda v: {'paths': [asdict(p) for p in v]}, + 'invoice_blindedpay': lambda v: {'payinfo': [p.to_dict() for p in v]}, + 'invoice_created_at': lambda v: {'timestamp': v}, + 'invoice_relative_expiry': lambda v: {'seconds_from_creation': v}, + 'invoice_payment_hash': lambda v: {'payment_hash': v}, + 'invoice_amount': lambda v: {'msat': v}, + 'invoice_fallbacks': lambda v: {'fallbacks': list(v)}, + 'invoice_features': lambda v: {'features': v.to_tlv_bytes()}, + 'invoice_node_id': lambda v: {'node_id': v}, + 'invoice_signature': lambda v: {'sig': v}, + } + + @cached_property + def fallback_address(self) -> Optional[str]: + fallbacks = self.invoice_fallbacks or () + for fba in fallbacks: + version_bytes, witprog = fba.get('version'), fba.get('address', b'') + if version_bytes is not None and 2 <= len(witprog) <= 40: + version = int.from_bytes(version_bytes, signed=False, byteorder='big') + if version <= 16: + address = encode_segwit_address(constants.net.SEGWIT_HRP, version, witprog) + return address + return None + + +def is_offer(data: str) -> bool: + try: + data = remove_bolt12_whitespace(data) + except ValueError: + return False + d = bech32_decode(data, ignore_long_length=True, with_checksum=False) + if d == INVALID_BECH32: + return False + return d.hrp == 'lno' + + +def matches_our_chain(chains: Optional[Iterable[bytes]]) -> bool: + # chains is a 32 bytes record list stored in a single bytes object (see TODO above lnmsg._read_field) + if not chains: + # empty chains is indicative of only Bitcoin mainnet + return True if constants.net == constants.BitcoinMainnet else False + our_chain_hash = constants.net.rev_genesis_bytes() + return our_chain_hash in chains + + +def bolt12_bech32_to_bytes(data: str) -> bytes: + data = remove_bolt12_whitespace(data) + d = bech32_decode(data, ignore_long_length=True, with_checksum=False) + if d == INVALID_BECH32: + raise ValueError(f"Failed to bech32 decode: {data[:64]=}...") + d = convertbits(d.data, 5, 8, pad=False) + if d is None: + raise ValueError(f"Invalid bech32 data: {data[:64]=}...") + return bytes(d) + + +def bolt12_tlv_bytes_to_bech32(bolt12_tlv: bytes, bolt12_type: type[BOLT12Base]) -> str: + bech32_data = convertbits(list(bolt12_tlv), 8, 5, True) + return bech32_encode(Encoding.BECH32, bolt12_type.hrp, bech32_data, with_checksum=False) + + +# offer/request/invoice uses different chain than we do +class NoMatchingChainError(Exception): pass + + +def remove_bolt12_whitespace(bolt12_bech32: str) -> str: + """ + Readers of a bolt12 string: + if it encounters a + followed by zero or more whitespace characters between two bech32 characters: + MUST remove the + and whitespace. + """ + assert isinstance(bolt12_bech32, str) + res = re.sub( + r'(?<=[' + BECH32_CHARSET + r'])\+\s*(?=[' + BECH32_CHARSET + r'])', + '', + bolt12_bech32, + flags=re.IGNORECASE, + ) + if '+' in res: + raise ValueError('Invalid bolt 12 whitespace') + return res + + +def validate_bip_353_name(name: str, domain: str) -> bool: + """ + MUST reject the (invoice request) if name or domain contain any bytes + which are not 0-9, a-z, A-Z, -, _ or . + """ + for s in (name, domain): + if not re.match(r'^[a-zA-Z0-9._-]+$', s): + return False + return True diff --git a/electrum/commands.py b/electrum/commands.py index 745ceaf99fc3..6c71dc51975f 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -25,6 +25,7 @@ import io import sys import datetime +import dataclasses import time import argparse import json @@ -2315,7 +2316,7 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: fd=blinded_path_fd, field_type='blinded_path', count=1, - value=blinded_path) + value=dataclasses.asdict(blinded_path)) encoded_blinded_path = blinded_path_fd.getvalue() return encoded_blinded_path.hex() diff --git a/electrum/segwit_addr.py b/electrum/segwit_addr.py index d94f6277c23c..42666f97f4bb 100644 --- a/electrum/segwit_addr.py +++ b/electrum/segwit_addr.py @@ -74,7 +74,7 @@ def bech32_verify_checksum(hrp, data): return None -def bech32_create_checksum(encoding: Encoding, hrp: str, data: List[int]) -> List[int]: +def bech32_create_checksum(encoding: Encoding, hrp: str, data: Sequence[int]) -> List[int]: """Compute the checksum values given HRP and data.""" values = bech32_hrp_expand(hrp) + data const = BECH32M_CONST if encoding == Encoding.BECH32M else BECH32_CONST @@ -82,9 +82,9 @@ def bech32_create_checksum(encoding: Encoding, hrp: str, data: List[int]) -> Lis return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] -def bech32_encode(encoding: Encoding, hrp: str, data: List[int]) -> str: +def bech32_encode(encoding: Encoding, hrp: str, data: Sequence[int], *, with_checksum=True) -> str: """Compute a Bech32 or Bech32m string given HRP and data values.""" - combined = data + bech32_create_checksum(encoding, hrp, data) + combined = (data + bech32_create_checksum(encoding, hrp, data)) if with_checksum else data return hrp + '1' + ''.join([CHARSET[d] for d in combined]) diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py new file mode 100644 index 000000000000..43907d54374f --- /dev/null +++ b/tests/test_bolt12.py @@ -0,0 +1,467 @@ +import io +import time +from dataclasses import fields + +from electrum_ecc import ECPrivkey + +from electrum import segwit_addr, lnutil +from electrum.bolt12 import ( + is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, +) +from electrum.crypto import privkey_to_pubkey +from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer +from electrum.lnonion import OnionHopsDataSingle +from electrum.lnutil import LnFeatures +from electrum.segwit_addr import INVALID_BECH32, bech32_encode, Encoding, convertbits +from electrum.util import bfh + +from . import ElectrumTestCase + + +def bech32_decode(x): + return segwit_addr.bech32_decode(x, ignore_long_length=True, with_checksum=False) + + +class TestBolt12(ElectrumTestCase): + + def test_bolt12_bech32_to_bytes(self): + valid_bolt12_strings = ( + ("lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3up5nwlwchur9zukwx743mvm0rvftrhskna22pcvtkyhufn5rc97j3gzqffs859lkadpfasgwxj47xvml7jgekez0lpfuwzhegyxsn2lzdx86qpny7xrmgwj6lphxcfauu22kenqnty4tqdlgnh8tyg87lamqe84nmh2vn0a2n908l7z7cfjghjsuusv7k079upfw0x7dpzavqpwj8swx9ee9q9cumg07fk4gvlajyhy6lfjv0cfe9gqxg0gykehtgjkxwzz24rqdssj4fjcm8xhv2rwel04ed4up2h5sf8n6y7scr0q5rt65k06s6u3mvefzer7qq", + "08022ee00a0010ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f0349bbeec5f8328b9671bd58ed9b78d8958ef0b4faa5070c5d897e26741e0be94502025303d0bfb75a14f60871a55f199bffa48cdb227fc29e3857ca08684d5f134c7d0033278c3da1d2d7c373613de714ab66609ac95581bf44ee759107f7fbb064f59eeea64dfd54caf3ffc2f613245e50e720cf59fe2f02973cde6845d6002e91e0e31739280b8e6d0ff26d5433fd912e4d7d3263f09c9500321e825b375a25633842554606c212aa658d9cd76286ecfdf5cb6bc0aaf4824f3d13d0c0de0a0d7aa59fa86b91db3291647e00"), + ("lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3uprukkghkdufdz6adxl0ejhy0lmzfykj08u6df9v4v2c93qknz8eggzq2jyyszrrt35mmkyl7efrv5x8a3wspk07pghey4a5kcm4ef76p0ksqpnh7fqgmq9eaf7ntqspcksqkqk8ngvjtjp585mqw3qata3xe8aycgkpprk87yqcxhh705dxauxkghsc9xywqpez7lt5gw67kqwejl83unmuc7r44h32durffs4rmpcgrhxa8x8y9gqxgy9w2tqgpxqk0tl487a9ssuchyh5p9t3le3n5ylhevggk6ly8wzxvds0jawct4spe2tzqfp5d34kah9ss", + "08022ee00a0010ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f023e5ac8bd9bc4b45aeb4df7e65723ffb1249693cfcd35256558ac1620b4c47ca10202a44240431ae34deec4ffb291b2863f62e806cff0517c92bda5b1bae53ed05f680033bf92046c05cf53e9ac100e2d0058163cd0c92e41a1e9b03a20eafb1364fd26116084763f880c1af7f3e8d37786b22f0c14c47003917beba21daf580eccbe78f27be63c3ad6f1537834a6151ec3840ee6e9cc7215003208572960404c0b3d7fa9fdd2c21cc5c97a04ab8ff319d09fbe58845b5f21dc2331b07cbaec2eb00e54b10121a3635b76e584"), + ("lno1qsgve2uaxjsvf885prfgawzf6d09vzsqpczxnzdgesgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3upc0taarq42pfspgzxdytktyf8jmx8symyy4p3w6wr26m3c3l3munszqwpuu8l2fxnf0awqf3fq069lw2ple9zlxuqx0whmxnl6sjavs98h6qpngsrfw245mh995tw4qlkh87ulgg00fm8p90s70sslvm23yv77qk6e70dq6a9c5fa9qg74w8gpfd2tqs3eavpaedey0agxmzacdrcd9vwrmgjemeym4ge0p5unp66tkz47wh7x4asqxtv35cx0sc23qkgt27az82w7n6tngn4r9wzuam3ztpps7q3sd60d42dltr37gagfy2asevymltrchy43zgtzzq68dcmv6rprk4yzfv4wl65vw6q2uz8tzc0d40v4ltdcuwnw9t3eyq", + "0410ccab9d34a0c49cf408d28eb849d35e560a000e046989a8cc10ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f0387afbd182aa0a601408cd22ecb224f2d98f026c84a862ed386ad6e388fe3be4e020383ce1fea49a697f5c04c5207e8bf7283fc945f370067bafb34ffa84bac814f7d00334406972ab4ddca5a2dd507ed73fb9f421ef4ece12be1e7c21f66d51233de05b59f3da0d74b8a27a5023d571d014b54b04239eb03dcb7247f506d8bb868f0d2b1c3da259de49baa32f0d3930eb4bb0abe75fc6af60032d91a60cf861510590b57ba23a9de9e97344ea32b85ceee2258430f02306e9edaa9bf58e3e4750922bb0cb09bfac78b92b112162103476e36cd0c23b54824b2aefea8c7680ae08eb161edabd95fadb8e3a6e2ae3920"), + ("lno1pqpq05q2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3upgkdz0l3yd0yt3u9kmp5cg6kfw65kh0n0q9m022xe8y0mq69wmm9gzq0ckt2qqs73uyx2pe3tcmrhf7hszkh3393gwc420x2uew4846tgk5qpn0clyjyaa0cfxc9ryff0qfx48wv8u65w4a7fxfsqeln9ahjz9qcf0t0umrgxnzcly4es6ct97ddjcvgcehvpc09sqwsjc0cusxrwvx44j62pskt9t75ex5px84294a48c0h5jx8gqx20wkjusrgktsa53c23ksrhpuncrmdvetfjhdpgmnu9tej3m9nlm9m9tzhnl39mje2gxdsu2zlejvp63xy", + "080207d00a0010ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f028b344ffc48d79171e16db0d308d592ed52d77cde02edea51b2723f60d15dbd950203f165a80087a3c21941cc578d8ee9f5e02b5e312c50ec554f32b99754f5d2d16a00337e3e4913bd7e126c14644a5e049aa7730fcd51d5ef9264c019fccbdbc8450612f5bf9b1a0d3163e4ae61ac2cbe6b65862319bb03879600742587e39030dcc356b2d2830b2cabf5326a04c7aa8b5ed4f87de9231d00329eeb4b901a2cb87691c2a3680ee1e4f03db5995a6576851b9f0abcca3b2cffb2ecab15e7f89772ca9066c38a17f326075131") + ) + for bolt12_str, expected_bytes_hex in valid_bolt12_strings: + result = bolt12_bech32_to_bytes(bolt12_str) + self.assertEqual(result.hex(), expected_bytes_hex) + + def test_decode(self): + # https://bootstrap.bolt12.org/examples + offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx293pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmc' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + self.assertTrue(is_offer(offer)) + od = BOLT12Offer.decode(offer) + self.assertEqual(od.offer_description, "Offer by rusty's node") + self.assertEqual(od.offer_issuer_id, bfh('023f3219da03ee29a12de4107e6c5c364f607382f065f2bfed91ddd6b91079bfbc')) + + offer = 'lno1pqqnyzsmx5cx6umpwssx6atvw35j6ut4v9h8g6t50ysx7enxv4epyrmjw4ehgcm0wfczucm0d5hxzag5qqtzzq3lxgva5qlw9xsjmeqs0ek9cdj0vpec9ur972l7mywa66u3q7dlhs' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + od = BOLT12Offer.decode(offer) + self.assertEqual(od.offer_amount, 50) + self.assertEqual(od.offer_description, '50msat multi-quantity offer') + self.assertEqual(od.offer_issuer, 'rustcorp.com.au') + self.assertEqual(od.offer_quantity_max, 0) + self.assertEqual(od.offer_issuer_id, bfh('023f3219da03ee29a12de4107e6c5c364f607382f065f2bfed91ddd6b91079bfbc')) + + # TODO: tests below use recurrence (tlv record type 26) which is not supported/generated from wire specs + # (c-lightning carries patches re-adding these, but for now we ignore them) + + offer = 'lno1pqqkgzs5xycrqmtnv96zqetkv4e8jgrdd9h82ar9zgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqq7q' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqqkgz38xycrqmtnv96zqetkv4e8jgrdd9h82ar99ss82upqw3hjqargwfjk2gr5d9kk2ucjzpe82um50yhx77nvv938xtn0wfn3vggz8uepnksrac56zt0yzplxchpkfas88qhsvhetlmv3mhttjyreh77p5qsq8s0qzqs' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqqkgz3zxycrqmtnv96zqetkv4e8jgryv9ujcgrxwfhk6gp3949xzm3dxgcryvgjzpe82um50yhx77nvv938xtn0wfn3vggz8uepnksrac56zt0yzplxchpkfas88qhsvhetlmv3mhttjyreh77p5qspqysq2q2laenqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqpq86q2fgcnqvpsd4ekzapqv4mx2uneyqcnqgryv9uhxtpqveex7mfqxyk55ctw95erqv339ss8qcteyqcksu3qvfjkvmmjv5s8gmeqxcczqum9vdhkuernypkxzar9zgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqy9pcpsqqq8pqqpuyqzszhlwvcqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqpq86q2xycnqvpsd4ekzapqv4mx2uneyqcnqgryv9uhxtpqveex7mfqxyk55ctw95erqv339ss8qun094exzarpzgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqy9pczqqp5hsqqgd9uqzqpgptlhxvqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yfpqun4wd68jtn00fkxzcnn9ehhyeckyypr7vsemgp7u2dp9hjpqlnvtsmy7crnstcxtu4lakgam44ezpuml0q6qgqsz' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + def test_decode_offer(self): + # default test + offer = 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + self.assertTrue(is_offer(offer)) + + od = BOLT12Offer.decode(offer) + self.assertEqual(od.offer_description, 'first test offer') + self.assertEqual(od.offer_issuer_id, bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')) + + def test_decode_invreq(self): + invreq_bech32 = "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss" + with self.assertRaises(NotImplementedError): + # TODO: no currency conversion support + BOLT12InvoiceRequest.decode(invreq_bech32) + + # minimal invreq from eclair repo + privkey = ECPrivkey(bfh("527d410ec920b626ece685e8af9abc976a48dbf2fe698c1b35d90a1c5fa2fbca")) + invreq_bech32 = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" + invreq = BOLT12InvoiceRequest.decode(invreq_bech32) + + self.assertEqual(invreq.invreq_amount, 21_000) + self.assertEqual(invreq.invreq_metadata, bfh("abcdef")) + self.assertEqual(invreq.invreq_payer_id, privkey.get_public_key_bytes()) + + data = { + 'offer_issuer_id': {'id': invreq.offer_issuer_id}, + 'invreq_amount': {'msat': invreq.invreq_amount}, + 'invreq_metadata': {'blob': invreq.invreq_metadata}, + 'invreq_payer_id': {'key': privkey.get_public_key_bytes()}, + } + self.assertEqual(invreq.serialize(with_signature=False), data) + + def test_invreq_offer_quantity_max(self): + """Tests the offer_quantity_max/invreq_quantity checks""" + def make_invreq(*, offer_quantity_max=None, invreq_quantity=None): + return BOLT12InvoiceRequest( + offer_issuer_id=bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a'), + invreq_amount=5555, + invreq_payer_id=privkey_to_pubkey(bfh('42' * 32)), + invreq_metadata=bfh('deadbeef'), + offer_quantity_max=offer_quantity_max, + invreq_quantity=invreq_quantity, + ) + + # 0 offer_quantity_max allows arbitrary invreq_quantity + self.assertEqual(7, make_invreq(offer_quantity_max=0, invreq_quantity=7).invreq_quantity) + + # non-zero max: quantity in [1, max] is accepted, zero or above max is rejected + self.assertEqual(3, make_invreq(offer_quantity_max=5, invreq_quantity=3).invreq_quantity) + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=5, invreq_quantity=6) + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=5, invreq_quantity=0) + + # max present requires an invreq_quantity; max absent forbids one + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=0, invreq_quantity=None) + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=None, invreq_quantity=2) + + def test_encode_offer(self): + data = { + 'offer_description': {'description': 'first test offer'}, + 'offer_issuer_id': {'id': bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')} + } + offer_tlv = BOLT12Offer.deserialize(data).encode(as_bech32=False) + self.assertEqual(offer_tlv, bfh('0a1066697273742074657374206f66666572162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')) + offer_tlv_5bit = convertbits(list(offer_tlv), 8, 5) + bech32_offer = bech32_encode(Encoding.BECH32, 'lno', offer_tlv_5bit, with_checksum=False) + self.assertEqual(bech32_offer, 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495') + self.assertEqual(BOLT12Offer.decode(bech32_offer).serialize(with_signature=False), data) + + def test_encode_invreq(self): + payer_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + kp = lnutil.Keypair(privkey_to_pubkey(payer_key), payer_key) + + data = { + 'offer_description': {'description': 'first test offer'}, + 'offer_issuer_id': {'id': bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')}, + 'invreq_amount': {'msat': 5555}, + 'invreq_payer_note': {'note': 'invreq for first test offer'}, + 'invreq_payer_id': {'key': kp.pubkey}, + 'invreq_metadata': {'blob': bfh('deadbeef')} + } + invreq_tlv = BOLT12InvoiceRequest.deserialize(data).encode(signing_key=payer_key, as_bech32=False) + self.assertEqual(invreq_tlv, bfh('0004deadbeef0a1066697273742074657374206f66666572162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a520215b358210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c591b696e7672657120666f722066697273742074657374206f66666572f0406b3de34892023353e1d0f5765e7c34b5e952e6d6a9492b91a2f98c0817434362bd07a6c216dce7709bd16a2b533dec22cf8a9303310a29b7621e090d27f9dfb1')) + self.assertEqual(BOLT12InvoiceRequest.decode(invreq_tlv).serialize(with_signature=False), data) + + def test_encode_invoice(self): + signing_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + kp = lnutil.Keypair(privkey_to_pubkey(signing_key), signing_key) + + data = { + 'offer_metadata': {'data': bfh('01020304050607')}, + 'offer_amount': {'amount': 1}, + 'offer_description': {'description': 'test_encode_invoice'}, + 'offer_issuer_id': {'id': kp.pubkey}, + 'invreq_payer_id': {'key': kp.pubkey}, + 'invreq_metadata': {'blob': bfh('deadbeef')}, + 'invoice_node_id': {'node_id': kp.pubkey}, + 'invoice_amount': {'msat': 21_000}, + 'invoice_created_at': {'timestamp': 1770883131}, + 'invoice_payment_hash': {'payment_hash': bfh('cab10c2a10467d7dc4512a910530ef35d1028662b65c8a30656136ff957c6589')}, + 'invoice_relative_expiry': {'seconds_from_creation': 7200}, + 'invoice_paths': {'paths': [ + { + 'first_node_id': kp.pubkey, + 'first_path_key': kp.pubkey, + 'num_hops': bytes([1]), + 'path': [{ + 'blinded_node_id': kp.pubkey, + 'enclen': 5, + 'encrypted_recipient_data': b'12345', + }], + } + ]}, + 'invoice_blindedpay': {'payinfo': [ + { + 'fee_base_msat': 100, + 'fee_proportional_millionths': 1000, + 'cltv_expiry_delta': 200, + 'htlc_minimum_msat': 1, + 'htlc_maximum_msat': 1_000_000, + 'flen': len(LnFeatures(0).to_tlv_bytes()), + 'features': LnFeatures(0).to_tlv_bytes(), + } + ]} + } + invoice = BOLT12Invoice.deserialize(data) + invoice_tlv = invoice.encode(signing_key=kp.privkey, as_bech32=False) + self.assertEqual(invoice_tlv, bfh('0004deadbeef0407010203040506070801010a13746573745f656e636f64655f696e766f696365162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619582102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619a06b02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661902eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f2836866190102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661900053132333435a21c00000064000003e800c8000000000000000100000000000f42400000a404698d883ba6021c20a820cab10c2a10467d7dc4512a910530ef35d1028662b65c8a30656136ff957c6589aa025208b02102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619f040d4541fa9209eea9e31ce9acaff42314e0de12adc85b2b64fef6ae954295cbe023d85023a9b53ca18e17de17df3b8431723554d182e271545edb6b8519c8eb35b')) + # also test decoding back + invoice = BOLT12Invoice.decode(invoice_tlv) + self.assertEqual(invoice.serialize(with_signature=False), data) + self.assertEqual(invoice.invoice_blindedpay[0].fee_base_msat, data['invoice_blindedpay']['payinfo'][0]['fee_base_msat']) + + def test_subtype_encode_decode(self): + invreq_bech32 = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" + invreq = BOLT12InvoiceRequest.decode(invreq_bech32) + invreq_pl_tlv = invreq.encode(signing_key=bfh('4141414141414141414141414141414141414141414141414141414141414141'), as_bech32=False) + + ohds = OnionHopsDataSingle( + tlv_stream_name='onionmsg_tlv', + payload={ + 'invoice_request': {'invoice_request': invreq_pl_tlv}, + 'reply_path': { + 'path': { + 'first_node_id': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'first_path_key': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'num_hops': 2, + 'path': [ + { + 'blinded_node_id': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'enclen': 5, + 'encrypted_recipient_data': bfh('0000000000'), + }, + { + 'blinded_node_id': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'enclen': 6, + 'encrypted_recipient_data': bfh('001111222233'), + } + ] + } + }, + }, + blind_fields={ + 'padding': {'padding': b''}, + } + ) + + ohds_b = ohds.to_bytes() + self.assertEqual(ohds_b, bfh('fd012902940309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e50309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5020309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5000500000000000309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5000600111122223340910003abcdef1621030df2e35b922d9e9bbaebd8b3017907f473b1e4774e0038d593e98242d33cf039520252085821024dd975c3e7a1df0d717bee91415f25952199bc30ec62ff6226be6c3137b3e5e1f040dc8f84d6ba1a027766c3412e5c9f44d8a265818e584e5220b509d387dd127d27975dbb93c2f3a072af735b5ed4000ae32dda9075f34894bb58eaf25cb1aeab630000000000000000000000000000000000000000000000000000000000000000')) + + with io.BytesIO(ohds_b) as fd: + ohds2 = OnionHopsDataSingle.from_fd(fd, tlv_stream_name='onionmsg_tlv') + self.assertEqual(ohds2.payload['invoice_request']['invoice_request'], invreq_pl_tlv) + self.assertTrue('reply_path' in ohds2.payload) + + # test nested complex types with count > 1 + offer_data = { + "offer_absolute_expiry": {"seconds_from_epoch": 1763136094}, + "offer_amount": {"amount": 1000}, + "offer_description": {"description": "ABCD"}, + "offer_issuer_id": {"id": bfh("0325c5bc9c9b4fe688e82784f17bc14e81e3f786e9a8c663e9bbec2412af0c0339")}, + "offer_paths": {"paths": [ + { + "first_node_id": bfh("02d4ad66692f3e39773a5917d55db2c8b81839425c4489532fd5d166466fce56d4"), + "first_path_key": bfh("02b4d2e30315f7a6322fb57ed420ef8f9c541d7331a8b3a086c2692c49209be811"), + "num_hops": bytes([2]), # num_hops is defined as byte, not int + "path": [ + { + "blinded_node_id": bfh("034b1da9c0afa084c604f74f839de006d550422facc3b4be83323702892f7f5949"), + "enclen": 51, + "encrypted_recipient_data": bfh("42f0018dcfe5185602618b718f7aa72b1b97d8e85b97f88b8fdad95b80fd93a21d9a975cf544e8c4b5c2f519bc83bab84bda6b") + }, + { + "blinded_node_id": bfh("021a4900c95fcb5ef59284203e005b505d17cdaa066b13134d98930fb4ff1425f4"), + "enclen": 50, + "encrypted_recipient_data": bfh("9b66a56801a3da6b3149d8b5df0ce9f25df7605b689dd662c40fc5782cbee2786903e83f6827fa52c93af2acdb8e123c72e0") + } + ] + }, + { + "first_node_id": bfh("031a10cc4d1aea5a59e7888f3eb2f0509e3fc58dae63deff87ba34f217ae419cf7"), + "first_path_key": bfh("029a5a12f3b9c0132176ab5347f49486f3d2572aa9d9c3d8ebf622e80a4131f268"), + "num_hops": bytes([2]), # num_hops is defined as byte, not int + "path": [ + { + "blinded_node_id": bfh("0250fce42de743a914b821de93c0033713e9b27c8ada26424c0e75c461c1337e1a"), + "enclen": 51, + "encrypted_recipient_data": bfh("c2e291a9bcf57b57e115d161f49bd8682044bcd11db3adb96ba4d8d99827650aa5691d48c78822c9ae26c446ffa03a41fbc1de") + }, + { + "blinded_node_id": bfh("02718474dc3bd8fb42af40c27ff98da911008f4020b90835d4a39ffea084406614"), + "enclen": 50, + "encrypted_recipient_data": bfh("9b0fc9045ff50ea82babad699c610e14607343dd70ca12dc5575edb28b5673e3660a3eb1b62fd5b6b7d14fd651d5bbee3ad3") + } + ] + } + ]} + } + + offer = BOLT12Offer.deserialize(offer_data).encode(as_bech32=True) + decoded = BOLT12Offer.decode(offer).serialize() + self.assertEqual(offer_data, decoded) + + def test_invoice_request_schnorr_signature(self): + invreq = 'lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz' + data = BOLT12InvoiceRequest.decode(invreq) + + payer_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + invreq_pl_tlv = data.encode(signing_key=payer_key, as_bech32=False) + + self.assertEqual(invreq_pl_tlv, bfh('0003abcdef1621030df2e35b922d9e9bbaebd8b3017907f473b1e4774e0038d593e98242d33cf039520252085821024dd975c3e7a1df0d717bee91415f25952199bc30ec62ff6226be6c3137b3e5e1f0406f02f053bc4186dc980ece06c57e6fa867d61839700fa0f58fc383bfd8e40c428b942a7c157dc77b49a2172fa44aeb0a6e77194fe87df4a7575b71011bbe0332')) + + def test_schnorr_signature(self): + """encode+decode invoice to test signature validation""" + # the signing key is different from the encoded node_id, so the signature is invalid + signing_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + with self.assertRaises(MsgInvalidSignature): + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + encoded = invoice.encode(signing_key=signing_key, as_bech32=False) + BOLT12Invoice.decode(encoded) + + # now use the same key as used inside the Invoice payload + signing_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + encoded = invoice.encode(signing_key=signing_key, as_bech32=False) + BOLT12Invoice.decode(encoded) + + def test_fallback_address(self): + # invoice without fallback address + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + self.assertIsNone(invoice.fallback_address) + + # invoice with fallback address + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gy2c9cqqq29vsj0npht2n230kazsz9ymxypzhzn04umqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsqp9r3ynq3f88wwcc5hsy6as87txrnse8tmhay4dkkz36rcncj2dleazl4pg7j2tzzqazk37ztmztm75fspm4fwkvut2uzehsjd750jw') + fallback_address = { + 'version': bytes([0]), + 'address': bfh('56424f986eb54d517dba2808a4d988115c537d79'), + 'len': len(bfh('56424f986eb54d517dba2808a4d988115c537d79')), + } + self.assertEqual(invoice.invoice_fallbacks[0], fallback_address) + self.assertEqual(invoice.fallback_address, 'bc1q2epylxrwk4x4zld69qy2fkvgz9w9xltef9uv0h') + + def test_is_expired(self): + offer_expired = BOLT12Offer.decode('lno1qsqszzqzq05q5ptyv4ekxuswq9u3ypr5v4ehg93pq02w80w8hqqpleka0d5j3usclz8mhtgmm6228k9t63hccc9snhrnw') + self.assertTrue(offer_expired.is_expired) + offer_no_expiry = BOLT12Offer.decode('lno1qsqszzqzq05q5ptyv4ekxusjq36x2um5zcssxjvgvuq4xn8frm3alch8h0lwfeh78expwcy4h2zvdzq2d57d2n3u') + self.assertFalse(offer_no_expiry.is_expired) + invreq_doesnt_expire = BOLT12InvoiceRequest.decode("lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz") + self.assertFalse(invreq_doesnt_expire.is_expired) + invoice_expired = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + self.assertTrue(invoice_expired.is_expired) + invoice_not_expired = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5czrhxkfl75zpj43ps4pq3na0hz9z253q5cw7dw3q2rx9dju3gcx2cfkl72hcevf4gp9yz9syypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx0sgpp90znnmygs2r9ynxdz63etw3xp28lh2lhf3wpn9g86l57w5cl3evgltx0xjl6yal4taxn4p6z3kzxy6qm2s46qtaauf6y8yvdm035p') + self.assertFalse(invoice_not_expired.is_expired) + with self.assertRaises(ValueError): + _invoice_created_in_the_future = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yqj3q26275cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsqp09v7ll0jmwe52hf45calr0e2wyzyxljautwshuazc7z60shrtz8nk5vuez686cnp5aqpmk39c6k8u8ptg9hxn8jlmlcqkx4grq7hv') + + def test_serde_complex_fields(self): + payer_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + + invreq_bech32 = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" + invreq = BOLT12InvoiceRequest.decode(invreq_bech32).serialize(with_signature=False) + dummy_path = { + "blinded_node_id": bfh("034b1da9c0afa084c604f74f839de006d550422facc3b4be83323702892f7f5949"), + "enclen": 51, + "encrypted_recipient_data": bfh( + "42f0018dcfe5185602618b718f7aa72b1b97d8e85b97f88b8fdad95b80fd93a21d9a975cf544e8c4b5c2f519bc83bab84bda6b") + } + + # test complex field cardinality + invreq['offer_paths'] = { + 'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': [dummy_path]} + ] + } + + invreq_pl_tlv = BOLT12InvoiceRequest.deserialize(invreq).encode(signing_key=payer_key, as_bech32=False) + + with io.BytesIO(invreq_pl_tlv) as f: + deser = OnionWireSerializer.read_tlv_stream(fd=f, tlv_stream_name='invoice_request') + self.assertEqual(len(deser['offer_paths']['paths']), 3) + + # test complex field all members required + invreq = {'offer_paths': {'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1])} + ]}} + + # assertRaises on generic Exception used in lnmsg encode/write_tlv_stream makes flake8 complain + # so work around this for now (TODO: refactor lnmsg generic exceptions) + # with self.assertRaises(Exception): + try: + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream( + fd=fd, + tlv_stream_name='invoice_request', + signing_key=payer_key, + **invreq, + ) + except Exception as e: + pass + else: + raise Exception('Exception expected') + + # test complex field count matches parameters + invreq = { + 'offer_paths': {'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': []} + ]} + } + + with self.assertRaises(AssertionError): + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream( + fd=fd, + tlv_stream_name='invoice_request', + signing_key=payer_key, + **invreq, + ) From 2ff976f8eb9c484fc9bc54eee76295afeae188a6 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 10 Feb 2026 14:27:44 +0100 Subject: [PATCH 17/45] tests: add bolt12 string formatting test vector Adds unittest to check if we handle malformed bolt12 strings according to the specification using the format-string-test.json test vector. https://github.com/lightning/bolts/blob/5f31faa0b6e2cdbe32171d79464305f90bda9585/bolt12/format-string-test.json --- tests/bolt12_format_string_test.json | 62 ++++++++++++++++++++++++++++ tests/test_bolt12.py | 21 ++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tests/bolt12_format_string_test.json diff --git a/tests/bolt12_format_string_test.json b/tests/bolt12_format_string_test.json new file mode 100644 index 000000000000..46e543b8b29b --- /dev/null +++ b/tests/bolt12_format_string_test.json @@ -0,0 +1,62 @@ +[ + { + "comment": "A complete string is valid", + "valid": true, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Uppercase is valid", + "valid": true, + "string": "LNO1PQPS7SJQPGTYZM3QV4UXZMTSD3JJQER9WD3HY6TSW35K7MSJZFPY7NZ5YQCNYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD5XVXG" + }, + { + "comment": "+ can join anywhere", + "valid": true, + "string": "l+no1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Multiple + can join", + "valid": true, + "string": "lno1pqps7sjqpgt+yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+5k7msjzfpy7nz5yqcn+ygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+5xvxg" + }, + { + "comment": "+ can be followed by whitespace", + "valid": true, + "string": "lno1pqps7sjqpgt+ yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+ 5k7msjzfpy7nz5yqcn+\nygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+\r\n 5xvxg" + }, + { + "comment": "+ can be followed by whitespace, UPPERCASE", + "valid": true, + "string": "LNO1PQPS7SJQPGT+ YZM3QV4UXZMTSD3JJQER9WD3HY6TSW3+ 5K7MSJZFPY7NZ5YQCN+\nYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD+\r\n 5XVXG" + }, + { + "comment": "Mixed case is invalid", + "valid": false, + "string": "LnO1PqPs7sJqPgTyZm3qV4UxZmTsD3JjQeR9Wd3hY6TsW35k7mSjZfPy7nZ5YqCnYgRfDeJ82uM5Wf5k2uCkYyPwA3EyT44h6tXtXqUqH7Lz5dJgE4AfGfJn7k4rGrKuAg0jSd5xVxG" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+ " + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+ lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "ln++o1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + } +] diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py index 43907d54374f..9de919e5ab6d 100644 --- a/tests/test_bolt12.py +++ b/tests/test_bolt12.py @@ -1,6 +1,8 @@ import io +import json import time from dataclasses import fields +from pathlib import Path from electrum_ecc import ECPrivkey @@ -39,6 +41,25 @@ def test_bolt12_bech32_to_bytes(self): result = bolt12_bech32_to_bytes(bolt12_str) self.assertEqual(result.hex(), expected_bytes_hex) + def test_bolt12_string_formatting(self): + """ + Test if we handle string formatting according to bolt 12 using the format-string-test.json + test vector. + https://github.com/lightning/bolts/blob/5f31faa0b6e2cdbe32171d79464305f90bda9585/bolt12/format-string-test.json + """ + with open(Path(__file__).parent / 'bolt12_format_string_test.json', 'r') as f: + tests = json.load(f) + for test in tests: + valid, string, msg = test['valid'], test['string'], f"{test['comment']}: {test['string']}" + if valid: + self.assertTrue(is_offer(string), msg=msg) + result = BOLT12Offer.decode(string) + self.assertIsInstance(result, BOLT12Offer) + else: + self.assertFalse(is_offer(string), msg=msg) + with self.assertRaises(ValueError, msg=msg): + BOLT12Offer.decode(string) + def test_decode(self): # https://bootstrap.bolt12.org/examples offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx293pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmc' From 5f26958c690a3bac28e0cf7e4ffe80f6dc4172b0 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 12 Feb 2026 14:46:09 +0100 Subject: [PATCH 18/45] tests: add bolt12 offer test vector Tests BOLT12Offer validation against the test vector json from the bolts repository. --- tests/bolt12_offers_test.json | 596 ++++++++++++++++++++++++++++++++++ tests/test_bolt12.py | 27 +- 2 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 tests/bolt12_offers_test.json diff --git a/tests/bolt12_offers_test.json b/tests/bolt12_offers_test.json new file mode 100644 index 000000000000..b3343effeda1 --- /dev/null +++ b/tests/bolt12_offers_test.json @@ -0,0 +1,596 @@ +[ + { + "description": "Minimal bolt12 offer", + "valid": true, + "bolt12": "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "fields": [ + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with description (but no amount)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "description is 'Test vectors'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for testnet", + "valid": true, + "bolt12": "lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is testnet", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin (redundant)", + "valid": true, + "bolt12": "lno1qgsxlc5vp2m0rvmjcxn2y34wv0m5lyc7sdj7zksgn35dvxgqqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is bitcoin", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin or liquidv1", + "valid": true, + "bolt12": "lno1qfqpge38tqmzyrdjj3x2qkdr5y80dlfw56ztq6yd9sme995g3gsxqqm0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq9qc4r9wd6zqan9vd6x7unnzcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "chains[0] is liquidv1, chains[1] is bitcoin", + "fields": [ + { + "type": 2, + "length": 64, + "hex": "1466275836220db2944ca059a3a10ef6fd2ea684b0688d2c379296888a2060036fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with metadata", + "valid": true, + "bolt12": "lno1qsgqqqqqqqqqqqqqqqqqqqqqqqqqqzsv23jhxapqwejkxar0wfe3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "metadata is 16 zero bytes", + "fields": [ + { + "type": 4, + "length": 16, + "hex": "00000000000000000000000000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with amount", + "valid": true, + "bolt12": "lno1pqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is 10000msat", + "fields": [ + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with currency", + "valid": true, + "bolt12": "lno1qcp4256ypqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is USD $100.00", + "fields": [ + { + "type": 6, + "length": 3, + "hex": "555344" + }, + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with expiry", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucwq3ay997czcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "expiry is 2035-01-01", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 14, + "length": 4, + "hex": "7a4297d8" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with issuer", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucjy358garswvaz7tmzdak8gvfj9ehhyeeqgf85c4p3xgsxjmnyw4ehgunfv4e3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "issuer is 'https://bolt12.org BOLT12 industries'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 18, + "length": 36, + "hex": "68747470733a2f2f626f6c7431322e6f726720424f4c54313220696e6475737472696573" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyz3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 5", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "05" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with unlimited (or unknown) quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qqtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "quantity_max is unknown/unlimited", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with single quantity (weird but valid)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyq3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 1", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "01" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with feature", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvp5yqqqqqqqqqqqqqqqqqqqqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "feature bit 99 set", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 12, + "length": 13, + "hex": "08000000000000000000000000" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zyg3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "same, with blinded path first_node_id using sciddir", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs3yqqqqqqqqqqqqp2qgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqgzyg3zyg3zyg3z93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "short_channel_id is 0x0x42, direction is 0", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 137, + "hex": "00000000000000002a0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with no issuer_id and blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + } + ] + }, + { + "description": "... and with second blinded path via 1x2x3 (direction 1), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsl5qj5qeyv5l2cs6y3qqzesrth7mlzrlp3xg7xhulusczm04x6g6nms9trspqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqsqqqqqqqqqqqqqqqqqqqqqqqqqqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqpqg3zyg3zyg3zygpqqqqzqqqqgqqxqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqqgqqqqqqqqqqqqqqqqqqqqqqqqqqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqqsg3zyg3zyg3zygtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x22*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 298, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202001000000000000000000000000000000000020202020202020202020202020202020202020202020202020202020202020202000811111111111111110100000100000200030202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200082222222222222222" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "unknown odd field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxfppf5x2mrvdamk7unvvs", + "field info": "type 33 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 33, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "unknown odd experimental field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx078wdv5gg2dpjkcmr0wahhymry", + "field info": "type 1000000033 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 1000000033, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "Malformed: fields out of order", + "valid": false, + "bolt12": "lno1zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszpgz5znzfgdzs" + }, + { + "description": "Malformed: unknown even TLV type 78", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpysgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Malformed: empty", + "valid": false, + "bolt12": "lno1" + }, + { + "description": "Malformed: truncated at type", + "valid": false, + "bolt12": "lno1pg" + }, + { + "description": "Malformed: truncated in length", + "valid": false, + "bolt12": "lno1pt7s" + }, + { + "description": "Malformed: truncated after length", + "valid": false, + "bolt12": "lno1pgpq" + }, + { + "description": "Malformed: truncated in description", + "valid": false, + "bolt12": "lno1pgpyz" + }, + { + "description": "Malformed: invalid offer_chains length", + "valid": false, + "bolt12": "lno1qgqszzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated currency UTF-8", + "valid": false, + "bolt12": "lno1qcqcqzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid currency UTF-8", + "valid": false, + "bolt12": "lno1qcplllhapqpq86q2q4qkc6trv5tzzq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9s" + }, + { + "description": "Malformed: truncated description UTF-8", + "valid": false, + "bolt12": "lno1pgqcq93pqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqy" + }, + { + "description": "Malformed: invalid description UTF-8", + "valid": false, + "bolt12": "lno1pgpgqsgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: truncated offer_paths", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqgpzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: zero num_hops in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated onionmsg_hop in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: bad first_node_id in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad path_key in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcpqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad blinded_node_id in onionmsg_hop", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yqvqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yq5qgytzzqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqg" + }, + { + "description": "Malformed: invalid offer_issuer_id", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps" + }, + { + "description": "Contains type >= 80", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains type > 1999999999", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06ae4jsq9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown even type (1000000002)", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06wu6egp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown feature 122", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvzqzqqqqqqqqqqqqqqqqqqqqqqqqpvggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs" + }, + { + "description": "Missing offer_description, but has offer_amount", + "valid": false, + "bolt12": "lno1pqpzwyqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Missing offer_amount with offer_currency", + "valid": false, + "bolt12": "lno1qcp4256ypgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Missing offer_issuer_id and no offer_path", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc" + }, + { + "description": "Second offer_path is empty", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsespjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygszqqqqyqqqqsqqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsq" + }, + { + "description": "offer_chains with zero entries", + "valid": false, + "bolt12": "lno1qgqpvggrt0j7j3uzp9n549hxpu0sxlmpwe2ql5qplgwkg628wrzk5acfcskq" + } +] diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py index 9de919e5ab6d..6a6f21100df2 100644 --- a/tests/test_bolt12.py +++ b/tests/test_bolt12.py @@ -8,12 +8,13 @@ from electrum import segwit_addr, lnutil from electrum.bolt12 import ( - is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, + is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, NoMatchingChainError ) from electrum.crypto import privkey_to_pubkey -from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer +from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer, \ + MsgInvalidFieldOrder, MalformedMsg from electrum.lnonion import OnionHopsDataSingle -from electrum.lnutil import LnFeatures +from electrum.lnutil import LnFeatures, UnknownEvenFeatureBits from electrum.segwit_addr import INVALID_BECH32, bech32_encode, Encoding, convertbits from electrum.util import bfh @@ -139,6 +140,26 @@ def test_decode_offer(self): self.assertEqual(od.offer_description, 'first test offer') self.assertEqual(od.offer_issuer_id, bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')) + # now test the bolt12 test vectors + # https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/bolt12/offers-test.json + with open(Path(__file__).parent / 'bolt12_offers_test.json', 'r') as f: + tests = json.load(f) + for test in tests: + valid, string, msg = test['valid'], test['bolt12'], f"{test['description']}: {test['bolt12']}" + try: + if valid: + self.assertTrue(is_offer(string), msg=msg) + try: + BOLT12Offer.decode(string) + except NoMatchingChainError: + continue # this unittest runs on mainnet, there are some testnet vectors + else: + expected_exc = (ValueError, MsgInvalidFieldOrder, UnknownMandatoryTLVRecordType, NoMatchingChainError, MalformedMsg, UnknownEvenFeatureBits) + with self.assertRaises(expected_exc, msg=msg): + BOLT12Offer.decode(string) + except Exception as e: + raise Exception(msg) from e + def test_decode_invreq(self): invreq_bech32 = "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss" with self.assertRaises(NotImplementedError): From 9c4bcd42106f0daff17d76155fb927dda2f7ef69 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 5 Nov 2025 14:44:51 +0100 Subject: [PATCH 19/45] commands: add decode_bolt12 command Co-Authored-By: f321x --- electrum/commands.py | 22 +++++++++++++++-- tests/test_commands.py | 56 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 6c71dc51975f..9b0939451d7d 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -44,16 +44,18 @@ import electrum_ecc as ecc -from . import util +from . import util, bolt12 +from .bolt12 import BOLT12Invoice, BOLT12Offer, BOLT12InvoiceRequest from .lnmsg import OnionWireSerializer from .lnworker import LN_P2P_NETWORK_TIMEOUT from .logging import Logger from .onion_message import create_blinded_path, send_onion_message_to from .lnonion import BlindedPath +from .segwit_addr import INVALID_BECH32 from .submarine_swaps import NostrTransport from .util import ( bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal, - UserFacingException, InvalidPassword + UserFacingException, InvalidPassword, json_encode ) from . import bitcoin from .bitcoin import is_address, hash_160, COIN @@ -2321,6 +2323,22 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: return encoded_blinded_path.hex() + @command('') + async def decode_bolt12(self, bech32: str): + """Decode bolt12 object + + arg:str:bech32:Offer, Invoice Request or Invoice + """ + dec = bolt12.bech32_decode(bech32, ignore_long_length=True, with_checksum=False) + if dec == INVALID_BECH32: + raise UserFacingException('invalid bech32') + d = { + 'lni': BOLT12Invoice.decode, + 'lno': BOLT12Offer.decode, + 'lnr': BOLT12InvoiceRequest.decode, + }[dec.hrp](bech32) + return json_encode(d.serialize(with_signature=True)) + def plugin_command(s, plugin_name): """Decorator to register a cli command inside a plugin. To be used within a commands.py file diff --git a/tests/test_commands.py b/tests/test_commands.py index 81801464e107..85896d708f18 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,7 @@ import asyncio import binascii import datetime +import json import os.path import unittest from unittest import mock @@ -10,7 +11,8 @@ import electrum from electrum.commands import Commands, eval_bool -from electrum import storage, wallet +from electrum import storage, wallet, constants +from electrum.constants import BitcoinMainnet from electrum.lnutil import RECEIVED, channel_id_from_funding_tx from electrum.lnworker import RecvMPPResolution from electrum.wallet import Abstract_Wallet @@ -860,3 +862,55 @@ async def test_delete_channel_backup(self): result = await cmds.delete_channel_backup(self._CHANNEL_POINT, wallet=w) self.assertIsNone(result) w.lnworker.remove_channel_backup.assert_called_once_with(chan_id) + + async def test_decode_bolt12(self, *mock_args): + cmds = Commands(config=self.config) + + # set net to mainnet as the offers below contain the mainnet chain + prev_net = constants.net + constants.net = BitcoinMainnet + + # offer + offer = 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495' + decoded = json.loads(await cmds.decode_bolt12(bech32=offer)) + self.assertEqual(decoded['offer_description']['description'], 'first test offer') + self.assertEqual( + decoded['offer_issuer_id']['id'], + '03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a', + ) + + # invoice_request + invoice_request = ('lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2g' + 'p9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43q' + 'eu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5' + 'd4g78a9fkwu3kke6hcxdr6t2n7vz') + decoded = json.loads(await cmds.decode_bolt12(bech32=invoice_request)) + self.assertEqual(decoded['invreq_amount']['msat'], 21_000) + self.assertEqual(decoded['invreq_metadata']['blob'], 'abcdef') + self.assertIn('invreq_payer_id', decoded) + self.assertIn('signature', decoded) + + # invoice + invoice = ('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3e' + 'yt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5' + 'djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrk' + 'uag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj96' + '6ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqx' + 'gqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsa' + 'u6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad' + '2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8k' + 'wm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + decoded = json.loads(await cmds.decode_bolt12(bech32=invoice)) + self.assertIn('invoice_amount', decoded) + self.assertIn('invoice_payment_hash', decoded) + self.assertIn('invoice_node_id', decoded) + self.assertIn('invoice_created_at', decoded) + self.assertIn('invoice_paths', decoded) + self.assertIn('signature', decoded) + + # decoding an invalid bech32 string raises + with self.assertRaises(UserFacingException): + await cmds.decode_bolt12(bech32='invalid_string') + + # set back to Testnet + constants.net = prev_net From b2af0f0e8044a44ca756de31470750dbcbe13027 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 15 Apr 2026 11:44:11 +0200 Subject: [PATCH 20/45] bolt12/lnwallet: implement requesting and handling invoices Implements methods in LNWallet to create and send invoice_requests, handles incoming invoices. Supports both offerless and offer based invreqs --- electrum/bolt12.py | 15 +++ electrum/lnutil.py | 1 + electrum/lnworker.py | 209 +++++++++++++++++++++++++++++++++++++- electrum/onion_message.py | 10 +- tests/test_bolt12.py | 21 +++- 5 files changed, 251 insertions(+), 5 deletions(-) diff --git a/electrum/bolt12.py b/electrum/bolt12.py index 599728379b29..ee092be4506e 100644 --- a/electrum/bolt12.py +++ b/electrum/bolt12.py @@ -460,6 +460,17 @@ def fallback_address(self) -> Optional[str]: return None +def extract_shared_fields(source_instance: BOLT12Invoice | BOLT12InvoiceRequest, target_class: type[TBOLT12Base]) -> TBOLT12Base: + """ + Allows to extract the fields of a subclass from the given instance, + e.g. all Offer fields from a given invoice request instance. + """ + return target_class(**{ + f.name: getattr(source_instance, f.name) + for f in fields(target_class) if not f.name.startswith('_') + }) + + def is_offer(data: str) -> bool: try: data = remove_bolt12_whitespace(data) @@ -500,6 +511,10 @@ def bolt12_tlv_bytes_to_bech32(bolt12_tlv: bytes, bolt12_type: type[BOLT12Base]) class NoMatchingChainError(Exception): pass +# wraps remote invoice_error +class Bolt12InvoiceError(Exception): pass + + def remove_bolt12_whitespace(bolt12_bech32: str) -> str: """ Readers of a bolt12 string: diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 515b6bddf1c9..29a16f941a29 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1912,6 +1912,7 @@ class LnKeyFamily(IntEnum): PAYMENT_SECRET_KEY = 8 | BIP32_PRIME NOSTR_KEY = 9 | BIP32_PRIME FUNDING_ROOT_KEY = 10 | BIP32_PRIME + BOLT12_SECRET_KEY = 11 | BIP32_PRIME def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair: diff --git a/electrum/lnworker.py b/electrum/lnworker.py index da3c1f5e405b..ef215cf1e840 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4,9 +4,11 @@ import asyncio import os +import io from decimal import Decimal import random import time +import hmac from enum import IntEnum from typing import ( Optional, Sequence, Tuple, List, Set, Dict, TYPE_CHECKING, NamedTuple, Mapping, Any, Iterable, AsyncGenerator, @@ -27,8 +29,10 @@ import aiohttp import dns.asyncresolver import dns.exception +from electrum_ecc import ECPrivkey, ECPubkey from aiorpcx import run_in_thread, NetAddress, ignore_after +from .bolt12 import BOLT12Offer, BOLT12Invoice, BOLT12InvoiceRequest, Bolt12InvoiceError, extract_shared_fields from .logging import Logger from .i18n import _ from .channel_db import UpdateStatus, ChannelDBNotLoaded, get_mychannel_info, get_mychannel_policy @@ -54,10 +58,11 @@ Transaction, get_script_type_from_output_script, PartialTxOutput, PartialTransaction, PartialTxInput ) from .crypto import ( - sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac + sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac, + hmac_oneshot ) -from .onion_message import OnionMessageManager +from .onion_message import OnionMessageManager, get_blinded_reply_paths, NoOnionMessagePeers from .lntransport import ( LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError @@ -79,7 +84,7 @@ decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, ) -from .lnmsg import decode_msg +from .lnmsg import decode_msg, OnionWireSerializer from .lnrouter import ( RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, @@ -1018,6 +1023,7 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No self.backup_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BACKUP_CIPHER).privkey self.static_payment_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.PAYMENT_BASE) self.payment_secret_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.PAYMENT_SECRET_KEY).privkey + self.bolt12_secret_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BOLT12_SECRET_KEY).privkey self.funding_root_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.FUNDING_ROOT_KEY) Logger.__init__(self) if features is None: @@ -1088,6 +1094,9 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No # preimage available. Might be used in combination with dont_expire_htlcs. self.dont_settle_htlcs = self.db.get_dict('dont_settle_htlcs') # type: Dict[str, None] + # invoice requests awaiting bolt 12 invoice response. path_id: concurrent.futures.Future[BOLT12Invoice, invoice_tlv] + self._pending_bolt12_invoice_requests = {} # type: dict[bytes, asyncio.Future[tuple[BOLT12Invoice, bytes]]] + # payment_hash -> callback: self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]] self._payment_bundles_pkey_to_canon = {} # type: Dict[bytes, bytes] # TODO: persist @@ -4335,3 +4344,197 @@ def get_forwarding_failure(self, payment_key: str) -> Tuple[Optional[bytes], Opt error_bytes = bytes.fromhex(error_hex) if error_hex else None failure_message = OnionRoutingFailure.from_bytes(bytes.fromhex(failure_hex)) if failure_hex else None return error_bytes, failure_message + + async def request_bolt12_invoice( + self, + bolt12_offer: BOLT12Offer, + *, + amount_msat: int, + payer_note: Optional[str] = None, + quantity: Optional[int] = None, + timeout: int = LN_P2P_NETWORK_TIMEOUT, + ) -> tuple[BOLT12Invoice, bytes]: + """ + Creates an invoice_request, sends it to payee, returns the Invoice response once it arrived. + """ + unsigned_invreq, invreq_signing_key = self.create_bolt12_invoice_request( + offer=bolt12_offer, + amount_msat=amount_msat, + payer_note=payer_note, + quantity=quantity, + ) + + node_id_or_blinded_paths = bolt12_offer.offer_paths or bolt12_offer.offer_issuer_id + assert node_id_or_blinded_paths + invreq_tlv = unsigned_invreq.encode(signing_key=invreq_signing_key.get_secret_bytes(), as_bech32=False) + path_id = invreq_signing_key.get_secret_bytes() + reply_paths = get_blinded_reply_paths(self, path_id=path_id, max_paths=1) + req_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(reply_paths[0].path)}, + } + self._pending_bolt12_invoice_requests[invreq_signing_key.get_secret_bytes()] = fut = asyncio.Future() + self.logger.debug(f"sending bolt 12 invoice request") + send_task = self.onion_message_manager.submit_send( + payload=req_payload, + node_id_or_blinded_paths=node_id_or_blinded_paths, + ) + try: + async with util.async_timeout(timeout): + return await fut + finally: + del self._pending_bolt12_invoice_requests[path_id] + send_task.cancel() + + def create_bolt12_invoice_request( + self, + offer: Optional[BOLT12Offer], + *, + amount_msat: int, + payer_note: Optional[str] = None, + quantity: Optional[int] = None, + allow_unblinded: bool = False, + invreq_expiry: Optional[int] = None, + ) -> tuple[BOLT12InvoiceRequest, ECPrivkey]: + """ + Creates an unsigned invoice_request, as response to an offer (payment request), or standalone (e.g. voucher). + """ + invreq_chain = None if constants.net == constants.BitcoinMainnet else constants.net.rev_genesis_bytes() + payer_note = payer_note[:10_000] if payer_note else None # keep the size somewhat sane + + # we could also put the signing key into the path_id, saving some bytes in the invreq, however this is simpler + invreq_key_derivation_entropy = os.urandom(16) + invreq_signing_key = ECPrivkey(hmac_oneshot( + key=self.bolt12_secret_key, + msg=b'invreq_key' + invreq_key_derivation_entropy, + digest='sha-256', + )) + + if offer: # we got an offer and want to pay it, the 'default' flow. + assert invreq_expiry is None, "invreq_expiry is intended for standalone invreq" + if offer.offer_amount is not None: + assert amount_msat >= offer.offer_amount, (amount_msat, offer.offer_amount) + + invreq = BOLT12InvoiceRequest( + **offer.__dict__, + invreq_amount=amount_msat, + invreq_metadata=invreq_key_derivation_entropy, + invreq_payer_id=invreq_signing_key.get_public_key_bytes(), + invreq_payer_note=payer_note, + invreq_features=None, + invreq_chain=invreq_chain, + invreq_quantity=quantity, + ) + else: # standalone invoice_request as offer to pay someone + assert not quantity, "quantity is only allowed for offer requests" + reply_path_infos = None + try: + reply_path_infos = get_blinded_reply_paths(self, path_id=invreq_signing_key.get_secret_bytes()) + except NoOnionMessagePeers as e: + self.logger.debug(f"create_offer: no onion message peers: {str(e)}") + if not allow_unblinded: + raise + + reply_paths = tuple(p.path for p in reply_path_infos) if reply_path_infos else None + invreq_signing_key = invreq_signing_key if reply_paths else ECPrivkey(self.node_keypair.privkey) + invreq = BOLT12InvoiceRequest( + offer_description=payer_note, + offer_absolute_expiry=int(time.time()) + invreq_expiry if invreq_expiry else None, + invreq_amount=amount_msat, + # add some randomness, even when using the node keypair (unblinded), to prevent creating equal invreqs + invreq_metadata=invreq_key_derivation_entropy, + invreq_payer_id=invreq_signing_key.get_public_key_bytes(), + invreq_features=None, + invreq_chain=invreq_chain, + invreq_paths=reply_paths, + ) + + signed_invreq = invreq.encode(signing_key=invreq_signing_key.get_secret_bytes(), as_bech32=False) + # this allows us to verify the authenticity of invreq & offer fields contained in an invoice we receive. + # we can re-assemble & sign the invreq without the signed_invreq hash and compare the hashes + new_metadata = invreq.invreq_metadata + sha256(signed_invreq) + unsigned_invreq = dataclasses.replace(invreq, invreq_metadata=new_metadata) + + return unsigned_invreq, invreq_signing_key + + def on_bolt12_invoice(self, recipient_data: Mapping, payload: Mapping): + # if we hand out an unblinded invreq we might get an empty recipient_data (or malicious fake data?) + path_id: Optional[bytes] = recipient_data.get('path_id', {}).get('data') + # we embedded our random signing key as path_id, so the sender can't guess any valid path id + pending_invoice_request: Optional[asyncio.Future] = self._pending_bolt12_invoice_requests.get(path_id or b'') + + if invoice_error_tlv := payload.get('invoice_error', {}).get('invoice_error'): + self.logger.debug("received bolt 12 invoice error") + if pending_invoice_request: + with io.BytesIO(invoice_error_tlv) as fd: + invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') + pending_invoice_request.set_exception(Bolt12InvoiceError(invoice_error.get('error', {}).get('msg'))) + return + + try: + invoice_tlv = payload['invoice']['invoice'] + invoice = BOLT12Invoice.decode(invoice_tlv) # __post_init__ does spec validation + except Exception as e: + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"received invalid invoice: {e}")) + return + + # TODO: persist paid offerless invreqs, early return if already paid or ongoing payment attempt + is_offerless_invreq = invoice.invreq_paths or invoice.invreq_payer_id == self.node_keypair.pubkey + if not pending_invoice_request and not is_offerless_invreq: + return + + if not self._verify_bolt12_invoice_requested_by_us(invoice): + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"unable to verify invoice content {invoice=}")) + return + self.logger.debug(f'received bolt 12 invoice: {invoice=}') + + # the usage of our reply path for offer based invreqs is already indirectly verified through the + # pending_invoice_request lookup above (if sender uses incorrect path we simply don't handle the invoice) + if invoice.invreq_paths: + assert is_offerless_invreq + encrypted_recipient_data = payload['encrypted_recipient_data']['encrypted_recipient_data'] + if encrypted_recipient_data not in (p.path[-1].encrypted_recipient_data for p in invoice.invreq_paths): + self.logger.warning(f"received invoice for offerless invreq on incorrect path") + return + + if invoice.is_expired: + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"received expired invoice: {invoice=}")) + return + + if invoice.invoice_features is not None: + try: + ln_compare_features(self.features.for_bolt12_invoice(), invoice.invoice_features) + except lnutil.IncompatibleLightningFeatures: + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"incompatible features: {invoice.invoice_features.get_names()}")) + return + + if pending_invoice_request: + pending_invoice_request.set_result((invoice, invoice_tlv)) + return + + if is_offerless_invreq: # and not already_paid: + raise NotImplementedError("initiate payment of invoice?") + + def _verify_bolt12_invoice_requested_by_us(self, invoice: BOLT12Invoice) -> bool: + if not invoice.invreq_metadata or not len(invoice.invreq_metadata) == 48: + return False + invreq_from_invoice: BOLT12InvoiceRequest = extract_shared_fields(invoice, BOLT12InvoiceRequest) + invreq_sig_digest, remaining_metadata = invoice.invreq_metadata[-32:], invoice.invreq_metadata[:-32] + assert len(invreq_sig_digest) == 32 and len(remaining_metadata) == 16, invoice + if invoice.invreq_payer_id == self.node_keypair.pubkey: + signing_key = self.node_keypair.privkey + else: + signing_key = hmac_oneshot( + key=self.bolt12_secret_key, + msg=b'invreq_key' + remaining_metadata, + digest='sha-256', + ) + signed_invreq = dataclasses.replace(invreq_from_invoice, invreq_metadata=remaining_metadata) + our_sig = signed_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + if not util.constant_time_compare(sha256(our_sig), invreq_sig_digest): + return False + return True diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 547e473409a0..00d82dc99d40 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -880,10 +880,18 @@ def on_onion_message_received_unsolicited(self, recipient_data: Mapping, payload # e.g. via a decorator, something like # @onion_message_request_handler(payload_key='invoice_request') for BOLT12 invoice requests. - if 'message' not in payload: + known_payloads = ('message', 'invoice', 'invoice_error') + if not any(known_payload in payload for known_payload in known_payloads): self.logger.error('Unsupported onion message payload') return + if 'invoice' in payload or 'invoice_error' in payload: + try: + self.lnwallet.on_bolt12_invoice(recipient_data, payload) + except Exception as e: + self.logger.warning(f"failed to handle incoming invoice: {e!r}") + return + if 'text' not in payload['message'] or not isinstance(payload['message']['text'], bytes): self.logger.error('Malformed \'message\' payload') return diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py index 6a6f21100df2..d5fa739a9a8d 100644 --- a/tests/test_bolt12.py +++ b/tests/test_bolt12.py @@ -8,7 +8,8 @@ from electrum import segwit_addr, lnutil from electrum.bolt12 import ( - is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, NoMatchingChainError + is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, NoMatchingChainError, + extract_shared_fields ) from electrum.crypto import privkey_to_pubkey from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer, \ @@ -401,6 +402,24 @@ def test_schnorr_signature(self): encoded = invoice.encode(signing_key=signing_key, as_bech32=False) BOLT12Invoice.decode(encoded) + def test_extract_shared_fields(self): + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + invoice_fields = [f.name for f in fields(invoice)] + self.assertTrue(any(f.startswith('invoice_') for f in invoice_fields)) + extracted_offer = extract_shared_fields(invoice, BOLT12Offer) + self.assertEqual(type(extracted_offer), BOLT12Offer) + offer_fields = [f.name for f in fields(extracted_offer)] + self.assertFalse(any(f.startswith('invreq_') or f.startswith('invoice_') for f in offer_fields)) + self.assertTrue(invoice.offer_issuer_id) + self.assertEqual(invoice.offer_issuer_id, extracted_offer.offer_issuer_id) + + extracted_invreq = extract_shared_fields(invoice, BOLT12InvoiceRequest) + invreq_fields = [f.name for f in fields(extracted_invreq)] + self.assertTrue(any(f.startswith('invreq_') for f in invreq_fields)) + self.assertTrue(invoice.invreq_metadata) + self.assertEqual(invoice.invreq_metadata, extracted_invreq.invreq_metadata) + self.assertEqual(invoice.offer_issuer_id, extracted_invreq.offer_issuer_id) + def test_fallback_address(self): # invoice without fallback address invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') From 98b5f2ad4637c62ceed8e55201548b9542a7f743 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 11:33:30 +0200 Subject: [PATCH 21/45] test_lnwallet: add unittests for bolt12 invoice flow Add unittests for the invoice_request creation and invoice handling. --- tests/test_lnwallet.py | 190 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 4c3919f07740..3321becd5e45 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -1,13 +1,22 @@ +import asyncio +import dataclasses import logging import os -import asyncio from unittest import mock from decimal import Decimal from typing import Optional +import time +from unittest.mock import patch + +from electrum_ecc import ECPrivkey from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum import bitcoin import electrum.trampoline +from electrum import constants +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice +from electrum.crypto import sha256, hmac_oneshot +from electrum.lnonion import BlindedPath, BlindedPathHop, BlindedPathInfo, BlindedPayInfo from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner from electrum.logging import console_stderr_handler from electrum.lntransport import LNPeerAddr @@ -474,3 +483,182 @@ async def test_rebalance_channels(self): chan1.set_frozen_for_sending(True) # shouldn't matter, this channel will receive success, log = await alice.rebalance_channels(chan0, chan1, amount_msat=150_000_000) self.assertTrue(success, msg=log) + + async def test_request_bolt12_invoice(self): + wallet = self.lnwallet_anchors + + offer_issuer_id = ECPrivkey.generate_random_key().get_public_key_bytes() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_description="test", + offer_issuer_id=offer_issuer_id, + ) + + introduction_point = ECPrivkey.generate_random_key().get_public_key_bytes() + reply_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=introduction_point, + first_path_key=ECPrivkey.generate_random_key().get_public_key_bytes(), + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=ECPrivkey.generate_random_key().get_public_key_bytes(), + enclen=5, + encrypted_recipient_data=b'12345', + )], + ), + payinfo=None, + )] + + submit_send_calls = [] + def fake_submit_send(*, payload, node_id_or_blinded_paths, key=None): + submit_send_calls.append((payload, node_id_or_blinded_paths)) + return asyncio.Future() + + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send', side_effect=fake_submit_send): + task = asyncio.create_task( + wallet.request_bolt12_invoice(bolt12_offer=offer, amount_msat=21_000) + ) + + start = time.time() + while not wallet._pending_bolt12_invoice_requests: + await asyncio.sleep(0.05) + if time.time() - start > 2: + task.cancel() + self.fail(f"invreq future wasn't registered") + + self.assertEqual(len(submit_send_calls), 1) + payload, destination = submit_send_calls[0] + self.assertEqual(destination, offer_issuer_id) + self.assertIn('invoice_request', payload) + self.assertIn('reply_path', payload) + + self.assertEqual(len(wallet._pending_bolt12_invoice_requests), 1) + path_id, fut = next(iter(wallet._pending_bolt12_invoice_requests.items())) + fut.set_result("invoice") + + self.assertIs(await task, "invoice") + + self.assertNotIn(path_id, wallet._pending_bolt12_invoice_requests) + + def test_create_bolt12_invoice_request_with_offer(self): + wallet = self.lnwallet_anchors + + amount_msat = 10_000 + offer_issuer_id = ECPrivkey.generate_random_key().get_public_key_bytes() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=amount_msat, + offer_description="test offer", + offer_issuer_id=offer_issuer_id, + ) + + # raises if amount is much higher than offer_amount + with self.assertRaises(ValueError): + _ = wallet.create_bolt12_invoice_request(offer=offer, amount_msat=40000) + + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=amount_msat, + payer_note="pls send invoice", + ) + + self.assertIsInstance(unsigned_invreq, BOLT12InvoiceRequest) + self.assertIsInstance(signing_key, ECPrivkey) + + # offer fields propagated into the invreq + self.assertEqual(unsigned_invreq.offer_issuer_id, offer_issuer_id) + self.assertEqual(unsigned_invreq.offer_amount, 10_000) + self.assertEqual(unsigned_invreq.offer_description, "test offer") + self.assertEqual(unsigned_invreq.offer_chains, [constants.net.rev_genesis_bytes()]) + + # invreq fields set from our parameters + self.assertEqual(unsigned_invreq.invreq_amount, amount_msat) + self.assertEqual(unsigned_invreq.invreq_payer_note, "pls send invoice") + self.assertEqual(unsigned_invreq.invreq_payer_id, signing_key.get_public_key_bytes()) + self.assertEqual(unsigned_invreq.invreq_chain, constants.net.rev_genesis_bytes()) + # invreq_metadata is the 16-byte entropy concatenated with sha256(signed_invreq_tlv) + self.assertEqual(len(unsigned_invreq.invreq_metadata), 16 + 32) + + # standalone-only fields are absent + self.assertIsNone(unsigned_invreq.invreq_paths) + + # derived signing key is not the node key + self.assertNotEqual(signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + + # test the stateless authenticity scheme + entropy, invreq_sig_digest = unsigned_invreq.invreq_metadata[:16], unsigned_invreq.invreq_metadata[-32:] + derived_privkey = hmac_oneshot( + key=wallet.bolt12_secret_key, + msg=b'invreq_key' + entropy, + digest='sha-256', + ) + self.assertEqual(derived_privkey, signing_key.get_secret_bytes()) + + signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) + resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + self.assertEqual(sha256(resigned), invreq_sig_digest) + + def test_create_bolt12_invoice_request_without_offer(self): + wallet = self.lnwallet_anchors + + fake_pubkey = ECPrivkey.generate_random_key().get_public_key_bytes() + reply_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=fake_pubkey, + first_path_key=fake_pubkey, + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=fake_pubkey, + enclen=5, + encrypted_recipient_data=b'12345', + )], + ), + payinfo=None, + )] + + amount_msat = 42_000 + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths): + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=amount_msat, + payer_note="standalone invreq", + allow_unblinded=False, + ) + + self.assertIsInstance(unsigned_invreq, BOLT12InvoiceRequest) + self.assertIsInstance(signing_key, ECPrivkey) + + # not a response to an offer: offer identity fields must be empty + self.assertIsNone(unsigned_invreq.offer_issuer_id) + self.assertIsNone(unsigned_invreq.offer_paths) + self.assertIsNone(unsigned_invreq.offer_amount) + self.assertIsNone(unsigned_invreq.offer_chains) + # payer_note is stored in offer_description for standalone invreqs + self.assertEqual(unsigned_invreq.offer_description, "standalone invreq") + + # invreq fields set from our parameters + self.assertEqual(unsigned_invreq.invreq_amount, amount_msat) + self.assertEqual(unsigned_invreq.invreq_payer_id, signing_key.get_public_key_bytes()) + self.assertEqual(unsigned_invreq.invreq_chain, constants.net.rev_genesis_bytes()) + self.assertEqual(len(unsigned_invreq.invreq_metadata), 16 + 32) + + # blinded reply paths are attached so the payee can respond + self.assertEqual(len(unsigned_invreq.invreq_paths), 1) + self.assertEqual(unsigned_invreq.invreq_paths[0], reply_paths[0].path) + + # with reply paths available, the derived signing key is used (not node key) + self.assertNotEqual(signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + + # authenticity scheme (same as offer-based invreqs) + entropy, stored_digest = unsigned_invreq.invreq_metadata[:16], unsigned_invreq.invreq_metadata[-32:] + derived_privkey = hmac_oneshot( + key=wallet.bolt12_secret_key, + msg=b'invreq_key' + entropy, + digest='sha-256', + ) + self.assertEqual(derived_privkey, signing_key.get_secret_bytes()) + + signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) + resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + self.assertEqual(sha256(resigned), stored_digest) From f9dc878cbd84fd2ff85d18faee97e474263c60cd Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:24:32 +0200 Subject: [PATCH 22/45] invoices: make `Invoice` handle bolt 12 invoices Store BOLT12 invoices as bech32-encoded strings (lni...) in the lightning_invoice field of the persisted Invoice class. Decode on demand and cache instance via cached_property. The `Invoice` can act as sort of abstraction over bolt11 and bolt12 invoices for the GUI. Co-Authored-By: Sander van Grieken --- electrum/gui/qml/qeinvoice.py | 4 +- electrum/invoices.py | 94 ++++++++++++++++++++++++++-------- electrum/lnworker.py | 11 ++-- electrum/payment_identifier.py | 18 ++----- tests/test_lnpeer.py | 4 +- 5 files changed, 85 insertions(+), 46 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 3a8bad13c7f8..278bbbeefae8 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -265,7 +265,7 @@ def set_lnprops(self): if not self.invoiceType == QEInvoice.Type.LightningInvoice: return - lnaddr = self._effectiveInvoice._lnaddr + lnaddr = self._effectiveInvoice.bolt11_invoice ln_routing_info = lnaddr.get_routing_info('r') self._logger.debug(str(ln_routing_info)) @@ -391,7 +391,7 @@ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[Optional[bool], Option assert self.status in [PR_UNPAID, PR_FAILED] if self.invoiceType == QEInvoice.Type.LightningInvoice: if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt: - lnaddr = self._effectiveInvoice._lnaddr + lnaddr = self._effectiveInvoice.bolt11_invoice if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000: return False, _('Cannot pay less than the amount specified in the invoice') else: diff --git a/electrum/invoices.py b/electrum/invoices.py index 55ad4fc5f0b9..5d8ac7eae6a4 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -1,6 +1,6 @@ import time -from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any, Sequence -from decimal import Decimal +from functools import cached_property +from typing import List, Optional, Union, Dict, Any, Sequence import attr @@ -8,11 +8,10 @@ from .i18n import _ from .util import age, InvoiceError, format_satoshis from .bip21 import create_bip21_uri -from .lnutil import hex_to_bytes +from .lnutil import hex_to_bytes, LnFeatures from .bolt11 import decode_bolt11_invoice, BOLT11Addr -from . import constants +from .bolt12 import BOLT12Invoice from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC -from .bitcoin import address_to_script from .transaction import PartialTxOutput from .crypto import sha256d @@ -209,17 +208,19 @@ def _validate_amount(self, attribute, value): @classmethod def from_bech32(cls, invoice: str) -> 'Invoice': - """Constructs Invoice object from BOLT-11 string. + """Constructs Invoice object from BOLT-11 or BOLT-12 string. Might raise InvoiceError. """ + is_bolt12 = invoice.startswith('lni') try: - lnaddr = decode_bolt11_invoice(invoice) + inv_obj = BOLT12Invoice.decode(invoice) if is_bolt12 else decode_bolt11_invoice(invoice) except Exception as e: raise InvoiceError(e) from e - amount_msat = lnaddr.get_amount_msat() - timestamp = lnaddr.date - exp_delay = lnaddr.get_expiry() - message = lnaddr.get_description() + assert isinstance(inv_obj, BOLT12Invoice if is_bolt12 else BOLT11Addr) + amount_msat = inv_obj.invoice_amount if is_bolt12 else inv_obj.get_amount_msat() + timestamp = inv_obj.invoice_created_at if is_bolt12 else inv_obj.date + exp_delay = inv_obj.invoice_relative_expiry if is_bolt12 else inv_obj.get_expiry() + message = inv_obj.offer_description if is_bolt12 else inv_obj.get_description() return Invoice( message=message, amount_msat=amount_msat, @@ -257,12 +258,16 @@ def as_dict(self, status): @attr.s class Invoice(BaseInvoice): lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str] - __lnaddr = None _broadcasting_status = None # can be None or PR_BROADCASTING or PR_BROADCAST def is_lightning(self): return self.lightning_invoice is not None + def set_amount_msat(self, amount_msat: Union[int, str]) -> None: + if self.bolt12_invoice and amount_msat != self.bolt12_invoice.invoice_amount: + raise Exception("cannot overwrite bolt12 invoice amount, request correct invoice beforehand") + super().set_amount_msat(amount_msat) + def get_broadcasting_status(self): return self._broadcasting_status @@ -271,35 +276,80 @@ def get_address(self) -> Optional[str]: if self.outputs: address = self.outputs[0].address if len(self.outputs) > 0 else None if not address and self.is_lightning(): - address = self._lnaddr.get_fallback_address() or None + address = self.get_lightning_fallback_address() return address + def get_lightning_fallback_address(self) -> Optional[str]: + assert self.is_lightning() + if bolt12 := self.bolt12_invoice: + return bolt12.fallback_address + return self.bolt11_invoice.get_fallback_address() or None + + @property + def bolt12_invoice(self) -> Optional['BOLT12Invoice']: + inv = self._decoded_invoice + return inv if isinstance(inv, BOLT12Invoice) else None + + @property + def bolt11_invoice(self) -> Optional['BOLT11Addr']: + inv = self._decoded_invoice + return inv if isinstance(inv, BOLT11Addr) else None + + @cached_property + def _decoded_invoice(self) -> Optional['BOLT12Invoice | BOLT11Addr']: + # _ prefix is necessary to prevent JsonDB from attempting to persist the cached attribute + if not self.lightning_invoice: + return None + if self.lightning_invoice.startswith('lni'): + return BOLT12Invoice.decode(self.lightning_invoice) + return decode_bolt11_invoice(self.lightning_invoice) + + @property + def features(self) -> LnFeatures: + if b11 := self.bolt11_invoice: + return b11.get_features() + if b12 := self.bolt12_invoice: + return b12.invoice_features or LnFeatures(0) + raise AssertionError(self) + @property - def _lnaddr(self) -> BOLT11Addr: - if self.__lnaddr is None: - self.__lnaddr = decode_bolt11_invoice(self.lightning_invoice) - return self.__lnaddr + def payment_hash(self) -> bytes: + if b11 := self.bolt11_invoice: + assert b11.paymenthash + return b11.paymenthash + if b12 := self.bolt12_invoice: + return b12.invoice_payment_hash + raise AssertionError(self) @property def rhash(self) -> str: + return self.payment_hash.hex() + + @property + def issuer_pubkey(self) -> Optional[str]: assert self.is_lightning() - return self._lnaddr.paymenthash.hex() + if b11 := self.bolt11_invoice: + return b11.pubkey.serialize().hex() + # Note: b12 invoice_node_id can be blinded, so showing it to the user could be potentially misleading + return self.bolt12_invoice.invoice_node_id.hex() @lightning_invoice.validator def _validate_invoice_str(self, attribute, value): if value is not None: - lnaddr = decode_bolt11_invoice(value) # this checks the str can be decoded - self.__lnaddr = lnaddr # save it, just to avoid having to recompute later + if value.startswith('lni'): + assert isinstance(self.bolt12_invoice, BOLT12Invoice) + else: + assert isinstance(self.bolt11_invoice, BOLT11Addr) def can_be_paid_onchain(self) -> bool: if self.is_lightning(): - return bool(self._lnaddr.get_fallback_address()) or (bool(self.outputs)) + return bool(self.get_lightning_fallback_address()) or (bool(self.outputs)) else: return True def to_debug_json(self) -> Dict[str, Any]: d = self.to_json() - d["lnaddr"] = self._lnaddr.to_debug_json() + d["lnaddr"] = self.bolt11_invoice.to_debug_json() if self.bolt11_invoice else self.bolt12_invoice.serialize(with_signature=True) return d diff --git a/electrum/lnworker.py b/electrum/lnworker.py index ef215cf1e840..348993504ede 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1909,13 +1909,12 @@ async def pay_invoice( bolt11 = invoice.lightning_invoice lnaddr = self._check_bolt11_invoice(bolt11, amount_msat=amount_msat) min_final_cltv_delta = lnaddr.get_min_final_cltv_delta() - payment_hash = lnaddr.paymenthash - key = payment_hash.hex() + payment_hash = invoice.payment_hash + key = invoice.rhash payment_secret = lnaddr.payment_secret invoice_pubkey = lnaddr.pubkey.serialize() - invoice_features = lnaddr.get_features() r_tags = lnaddr.get_routing_info('r') - amount_to_pay = lnaddr.get_amount_msat() + amount_to_pay = invoice.get_amount_msat() status = self.get_payment_status(payment_hash, direction=SENT) if status == PR_PAID: raise PaymentFailure(_("This invoice has been paid already")) @@ -1930,7 +1929,7 @@ async def pay_invoice( status=PR_UNPAID, min_final_cltv_delta=min_final_cltv_delta, expiry_delay=LN_EXPIRY_NEVER, - invoice_features=invoice_features, + invoice_features=invoice.features, ) self.save_payment_info(info) self.wallet.set_label(key, lnaddr.get_description()) @@ -1949,7 +1948,7 @@ async def pay_invoice( amount_to_pay=amount_to_pay, min_final_cltv_delta=min_final_cltv_delta, r_tags=r_tags, - invoice_features=invoice_features, + invoice_features=invoice.features, attempts=attempts, full_path=full_path, channels=channels, diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index d6164075f217..7d9680e4c10b 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -546,7 +546,9 @@ def get_fields_for_GUI(self) -> FieldsForGUI: recipient = key + ' <' + address + '>' elif self.bolt11: - recipient, amount, description = self._get_bolt11_fields() + recipient = self.bolt11.issuer_pubkey + amount = self.bolt11.get_amount_sat() + description = self.bolt11.get_message() elif self.lnurl and self.lnurl_data: assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}" @@ -579,18 +581,6 @@ def get_fields_for_GUI(self) -> FieldsForGUI: return FieldsForGUI(recipient=recipient, amount=amount, description=description, comment=comment, validated=validated, amount_range=amount_range) - def _get_bolt11_fields(self): - lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr - pubkey = lnaddr.pubkey.serialize().hex() - for k, v in lnaddr.tags: - if k == 'd': - description = v - break - else: - description = '' - amount = lnaddr.get_amount_sat() - return pubkey, amount, description - async def resolve_openalias(self, key: str) -> Optional[dict]: parts = key.split(sep=',') # assuming single line if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): @@ -628,7 +618,7 @@ def invoice_from_payment_identifier( invoice = pi.bolt11 if not invoice: return - if invoice._lnaddr.get_amount_msat() is None: + if invoice.get_amount_msat() is None: invoice.set_amount_msat(int(amount_sat * 1000)) return invoice else: diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 254145695acc..00487f35d8f1 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -866,7 +866,7 @@ async def try_pay_with_too_low_final_cltv_delta(lnaddr, w1=w1, w2=w2): lnaddr.tags = [tag for tag in lnaddr.tags if tag[0] != 'c'] + [['c', 144]] b11 = encode_bolt11_invoice(lnaddr, w2.node_keypair.privkey) pay_req = Invoice.from_bech32(b11) - assert pay_req._lnaddr.get_min_final_cltv_delta() == 144 # what w1 will use to pay + assert pay_req.bolt11_invoice.get_min_final_cltv_delta() == 144 # what w1 will use to pay result, log = await w1.pay_invoice(pay_req) if not result: raise PaymentFailure() @@ -995,7 +995,7 @@ async def try_pay_invoice_twice(pay_req: Invoice, w1=w1): result, log = await w1.pay_invoice(pay_req) assert result is True # now pay the same invoice again, the payment should be rejected by w2 - w1.set_payment_status(pay_req._lnaddr.paymenthash, PR_UNPAID, direction=lnutil.SENT) + w1.set_payment_status(pay_req.bolt11_invoice.paymenthash, PR_UNPAID, direction=lnutil.SENT) result, log = await w1.pay_invoice(pay_req) if not result: # w1.pay_invoice returned a payment failure as the payment got rejected by w2 From a9e66378c6f6df8d843e08d1d15058f358d5823e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 May 2024 12:16:28 +0200 Subject: [PATCH 23/45] payment_identifier: initial support for bolt12 offers --- electrum/gui/qml/qeinvoice.py | 8 +- electrum/gui/qt/send_tab.py | 2 +- electrum/payment_identifier.py | 157 ++++++++++++++++++++----------- tests/test_payment_identifier.py | 6 +- 4 files changed, 112 insertions(+), 61 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 278bbbeefae8..5e914c34dfc3 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -596,7 +596,7 @@ def _update_from_payment_identifier(self): self.validationSuccess.emit() return elif self._pi.type == PaymentIdentifierType.BOLT11: - lninvoice = self._pi.bolt11 + lninvoice = self._pi.lightning_invoice if not self._wallet.wallet.has_lightning() and not lninvoice.get_address(): self.validationError.emit('no_lightning', _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) @@ -607,8 +607,8 @@ def _update_from_payment_identifier(self): self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() elif self._pi.type == PaymentIdentifierType.BIP21: - if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11: - lninvoice = self._pi.bolt11 + if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.lightning_invoice: + lninvoice = self._pi.lightning_invoice self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() else: @@ -668,7 +668,7 @@ def on_finished(pi): else: self.lnurlError.emit('lnurl', pi.get_error()) else: - self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11) + self.on_lnurl_invoice(self.amountOverride.satsInt, pi.lightning_invoice) self._busy = True self.busyChanged.emit() diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 91d4e72e6cb2..444fff601175 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -566,7 +566,7 @@ def on_finalize_done(self, pi: PaymentIdentifier): if pi.error: self.show_error(pi.error) return - invoice = pi.bolt11 + invoice = invoice_from_payment_identifier(pi, self.wallet) self.pending_invoice = invoice self.logger.debug(f'after finalize invoice: {invoice!r}') self.do_pay_invoice(invoice) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 7d9680e4c10b..7867d0aecdde 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -6,7 +6,7 @@ from enum import IntEnum from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple, Union -from . import bitcoin +from . import bitcoin, bolt12 from .contacts import AliasNotFoundException from .i18n import _ from .invoices import Invoice @@ -32,6 +32,8 @@ def maybe_extract_bech32_lightning_payment_identifier(data: str) -> Optional[str data = remove_uri_prefix(data, prefix=LIGHTNING_URI_SCHEME) if not data.startswith('ln'): return None + if bolt12.is_offer(data): + return data decoded_bech32 = bech32_decode(data, ignore_long_length=True) if not decoded_bech32.hrp or not decoded_bech32.data: return None @@ -76,6 +78,7 @@ class PaymentIdentifierState(IntEnum): NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11 LNURLW_FINALIZE = 5 # PI contains resolved LNURLw, user needs to enter amount and initiate withdraw + BOLT12_OFFER_FINALIZE = 8 # PI contains a bolt12 offer, but needs amount and comment to resolve to a bolt12 invoice ERROR = 50 # generic error NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccessful MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX @@ -95,6 +98,7 @@ class PaymentIdentifierType(IntEnum): OPENALIAS = 10 LNADDR = 11 DOMAINLIKE = 12 + BOLT12_OFFER = 13 class FieldsForGUI(NamedTuple): @@ -116,6 +120,7 @@ class PaymentIdentifier(Logger): * lightning-URI (containing bolt11 or lnurl) * lnurl-URI (lud17 lnurlw/lnurlp URI) * bolt11 invoice + * bolt12 offer * lnurl * lightning address """ @@ -133,7 +138,7 @@ def __init__(self, wallet: Optional['Abstract_Wallet'], text: str): # more than one of those may be set self.multiline_outputs = None self._is_max = False - self.bolt11 = None # type: Optional[Invoice] + self.lightning_invoice = None # type: Optional[Invoice] self.bip21 = None self.spk = None self.spk_is_address = False @@ -144,6 +149,8 @@ def __init__(self, wallet: Optional['Abstract_Wallet'], text: str): # self.lnurl = None # type: Optional[str] self.lnurl_data = None # type: Optional[LNURLData] + # + self.bolt12_offer = None # type: Optional[bolt12.BOLT12Offer] self.parse(text) @@ -163,7 +170,7 @@ def need_resolve(self): return self._state == PaymentIdentifierState.NEED_RESOLVE def need_finalize(self): - return self._state == PaymentIdentifierState.LNURLP_FINALIZE + return self._state in [PaymentIdentifierState.LNURLP_FINALIZE, PaymentIdentifierState.BOLT12_OFFER_FINALIZE] def is_valid(self): return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY] @@ -172,16 +179,17 @@ def is_available(self): return self._state in [PaymentIdentifierState.AVAILABLE] def is_lightning(self): - return bool(self.lnurl) or bool(self.bolt11) + return bool(self.lnurl) or bool(self.lightning_invoice) or bool(self.bolt12_offer) def is_onchain(self): if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.OPENALIAS]: return True - if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]: - return bool(self.bolt11) and bool(self.bolt11.get_address()) + if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR, + PaymentIdentifierType.BOLT12_OFFER]: + return bool(self.lightning_invoice) and bool(self.lightning_invoice.get_address()) if self._type == PaymentIdentifierType.BIP21: - return bool(self.bip21.get('address', None)) or (bool(self.bolt11) and bool(self.bolt11.get_address())) + return bool(self.bip21.get('address', None)) or (bool(self.lightning_invoice) and bool(self.lightning_invoice.get_address())) def is_multiline(self): return bool(self.multiline_outputs) @@ -193,7 +201,9 @@ def is_amount_locked(self): if self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('amount')) elif self._type == PaymentIdentifierType.BOLT11: - return bool(self.bolt11.get_amount_sat()) + return bool(self.lightning_invoice.get_amount_sat()) + elif self._type == PaymentIdentifierType.BOLT12_OFFER: + return bool(self.bolt12_offer.offer_amount) elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): @@ -225,20 +235,31 @@ def parse(self, text: str): self.set_state(PaymentIdentifierState.INVALID) else: self.set_state(PaymentIdentifierState.AVAILABLE) - elif invoice_or_lnurl := maybe_extract_bech32_lightning_payment_identifier(text): - if invoice_or_lnurl.startswith('lnurl'): + elif valid_bech32_lightning_pi := maybe_extract_bech32_lightning_payment_identifier(text): + if valid_bech32_lightning_pi.startswith('lnurl'): self._type = PaymentIdentifierType.LNURL try: - self.lnurl = decode_lnurl(invoice_or_lnurl) + self.lnurl = decode_lnurl(valid_bech32_lightning_pi) self.set_state(PaymentIdentifierState.NEED_RESOLVE) except Exception as e: self.error = _("Error parsing LNURL") + f":\n{e}" self.set_state(PaymentIdentifierState.INVALID) return + elif bolt12.is_offer(valid_bech32_lightning_pi): + self.logger.debug(f'BOLT12 offer') + try: + self.bolt12_offer = bolt12.BOLT12Offer.decode(valid_bech32_lightning_pi) + self._type = PaymentIdentifierType.BOLT12_OFFER + self.set_state(PaymentIdentifierState.BOLT12_OFFER_FINALIZE) + except Exception as e: + self.logger.debug(f"error parsing bolt12 offer", exc_info=True) + self.error = _("Error parsing BOLT12 offer") + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) + return else: self._type = PaymentIdentifierType.BOLT11 try: - self.bolt11 = Invoice.from_bech32(invoice_or_lnurl) + self.lightning_invoice = Invoice.from_bech32(valid_bech32_lightning_pi) except InvoiceError as e: self.error = self._get_error_from_invoiceerror(e) self.set_state(PaymentIdentifierState.INVALID) @@ -262,12 +283,12 @@ def parse(self, text: str): bolt11 = out.get('lightning') if bolt11: try: - self.bolt11 = Invoice.from_bech32(bolt11) + self.lightning_invoice = Invoice.from_bech32(bolt11) # carry BIP21 onchain address in Invoice.outputs in case bolt11 doesn't contain a fallback # address but the BIP21 URI has one. if bip21_address := self.bip21.get('address'): amount = self.bip21.get('amount', 0) - self.bolt11.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)] + self.lightning_invoice.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)] except InvoiceError as e: self.logger.debug(self._get_error_from_invoiceerror(e)) elif not self.bip21.get('address'): @@ -307,7 +328,7 @@ def parse(self, text: str): self.set_state(PaymentIdentifierState.INVALID) def resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None]) -> None: - assert self._state == PaymentIdentifierState.NEED_RESOLVE + assert self.need_resolve() coro = self._do_resolve(on_finished=on_finished) asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) @@ -373,7 +394,7 @@ def finalize( comment: str = None, on_finished: Callable[['PaymentIdentifier'], None] = None, ): - assert self._state == PaymentIdentifierState.LNURLP_FINALIZE + assert self.need_finalize() coro = self._do_finalize(amount_sat=amount_sat, comment=comment, on_finished=on_finished) asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) @@ -387,37 +408,59 @@ async def _do_finalize( ): from .invoices import Invoice try: - if not self.lnurl_data: + if self._state == PaymentIdentifierState.LNURLP_FINALIZE: assert self.lnurl_data, "Unexpected missing LNURL data" - if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): - self.error = _('Amount must be between {} and {} sat.').format( - self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) - self.set_state(PaymentIdentifierState.INVALID_AMOUNT) - return + if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): + self.error = _('Amount must be between {} and {} sat.').format( + self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) + self.set_state(PaymentIdentifierState.INVALID_AMOUNT) + return - if self.lnurl_data.comment_allowed == 0: - comment = None - params = {'amount': amount_sat * 1000} - if comment: - params['comment'] = comment + if self.lnurl_data.comment_allowed == 0: + comment = None + params = {'amount': amount_sat * 1000} + if comment: + params['comment'] = comment - try: - invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params) - except LNURLError as e: - self.error = f"LNURL request encountered error: {e}" - self.set_state(PaymentIdentifierState.ERROR) - return + try: + invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params) + except LNURLError as e: + self.error = f"LNURL request encountered error: {e}" + self.set_state(PaymentIdentifierState.ERROR) + return - bolt11_invoice = invoice_data.get('pr') - invoice = Invoice.from_bech32(bolt11_invoice) - if invoice.get_amount_sat() != amount_sat: - raise Exception("lnurl returned invoice with wrong amount") - # this will change what is returned by get_fields_for_GUI - self.bolt11 = invoice - self.set_state(PaymentIdentifierState.AVAILABLE) + bolt11_invoice = invoice_data.get('pr') + invoice = Invoice.from_bech32(bolt11_invoice) + if invoice.get_amount_sat() != amount_sat: + raise Exception("lnurl returned invoice with wrong amount") + # this will change what is returned by get_fields_for_GUI + self.lightning_invoice = invoice + self.set_state(PaymentIdentifierState.AVAILABLE) + elif self._state == PaymentIdentifierState.BOLT12_OFFER_FINALIZE: + assert self.bolt12_offer + try: + if not self.wallet.has_lightning(): + raise UserFacingException(_('Wallet is not lightning-enabled')) + amount_msat = amount_sat * 1000 + try: + invoice, invoice_tlv = await self.wallet.lnworker.request_bolt12_invoice( + self.bolt12_offer, + amount_msat=amount_msat, + payer_note=comment, + ) + except Exception as e: + raise UserFacingException(_("Failed to request Invoice:") + f" {str(e)}") from e + invoice_bech32 = bolt12.bolt12_tlv_bytes_to_bech32(invoice_tlv, bolt12.BOLT12Invoice) + self.lightning_invoice = Invoice.from_bech32(invoice_bech32) + assert self.lightning_invoice.get_amount_msat() == amount_msat, (self.lightning_invoice, amount_msat) + self.set_state(PaymentIdentifierState.AVAILABLE) + self.logger.debug(f'BOLT12 invoice_request reply: {invoice!r}') + except asyncio.TimeoutError: + self.error = _('Timeout requesting invoice') + self.set_state(PaymentIdentifierState.NOT_FOUND) except Exception as e: - self.error = str(e) + self.error = str(e) or repr(e) self.logger.error(f"_do_finalize() got error: {e!r}") self.set_state(PaymentIdentifierState.ERROR) if not isinstance(e, UserFacingException): @@ -545,10 +588,17 @@ def get_fields_for_GUI(self) -> FieldsForGUI: description = name recipient = key + ' <' + address + '>' - elif self.bolt11: - recipient = self.bolt11.issuer_pubkey - amount = self.bolt11.get_amount_sat() - description = self.bolt11.get_message() + elif self.lightning_invoice: + recipient = self.lightning_invoice.issuer_pubkey + amount = self.lightning_invoice.get_amount_sat() + description = self.lightning_invoice.get_message() + + elif self.bolt12_offer: + offer_amount = self.bolt12_offer.offer_amount + if offer_amount: + amount = Decimal(offer_amount) / 1000 # msat->sat + description = self.bolt12_offer.offer_description + recipient = self.bolt12_offer.offer_issuer elif self.lnurl and self.lnurl_data: assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}" @@ -597,8 +647,10 @@ async def resolve_openalias(self, key: str) -> Optional[dict]: return None def has_expired(self): - if self.bolt11: - return self.bolt11.has_expired() + if self.lightning_invoice: + return self.lightning_invoice.has_expired() + elif self.bolt12_offer: + return self.bolt12_offer.is_expired elif self.bip21: expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0 return bool(expires) and expires < time.time() @@ -608,17 +660,16 @@ def has_expired(self): def invoice_from_payment_identifier( pi: 'PaymentIdentifier', wallet: 'Abstract_Wallet', - amount_sat: Union[int, str], - message: str = None + amount_sat: Optional[Union[int, str]] = None, + message: Optional[str] = None ) -> Optional[Invoice]: assert pi.state in [PaymentIdentifierState.AVAILABLE,] assert pi.is_onchain() if amount_sat == '!' else True # MAX should only be allowed if pi has onchain destination if pi.is_lightning() and not amount_sat == '!': - invoice = pi.bolt11 - if not invoice: - return - if invoice.get_amount_msat() is None: + if not (invoice := pi.lightning_invoice): + return None + if invoice.get_amount_msat() is None and amount_sat is not None: invoice.set_amount_msat(int(amount_sat * 1000)) return invoice else: diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index f19fb6baaeaf..a3169fd1860f 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -85,7 +85,7 @@ def test_bolt11(self): self.assertEqual(PaymentIdentifierType.BOLT11, pi.type) self.assertFalse(pi.is_amount_locked()) self.assertFalse(pi.is_error()) - self.assertIsNotNone(pi.bolt11) + self.assertIsNotNone(pi.lightning_invoice) for pi_str in [ f'lightning: {bolt11}', @@ -99,7 +99,7 @@ def test_bolt11(self): pi = PaymentIdentifier(None, bolt_11_w_fallback) self.assertTrue(pi.is_valid()) self.assertEqual(PaymentIdentifierType.BOLT11, pi.type) - self.assertIsNotNone(pi.bolt11) + self.assertIsNotNone(pi.lightning_invoice) self.assertTrue(pi.is_lightning()) self.assertTrue(pi.is_onchain()) self.assertTrue(pi.is_amount_locked()) @@ -143,7 +143,7 @@ def test_bip21(self): self.assertTrue(pi.is_lightning()) self.assertTrue(pi.is_onchain()) self.assertIsNotNone(pi.bip21) - self.assertIsNotNone(pi.bolt11) + self.assertIsNotNone(pi.lightning_invoice) self.assertTrue(pi.has_expired()) self.assertEqual('unit_test', pi.bip21.get('message')) From a3383039b3c2a72a08ab8af120d088da85c989a6 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 14:08:42 +0100 Subject: [PATCH 24/45] tests: test_payment_identifier: test bolt12 pi Add unnittests for bolt12 payment identifier. --- tests/test_payment_identifier.py | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index a3169fd1860f..6cc4e52010d5 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -37,15 +37,21 @@ def setUp(self): def test_maybe_extract_bech32_lightning_payment_identifier(self): bolt11 = "lnbc1ps9zprzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsdqq9qypqszpyrpe4tym8d3q87d43cgdhhlsrt78epu7u99mkzttmt2wtsx0304rrw50addkryfrd3vn3zy467vxwlmf4uz7yvntuwjr2hqjl9lw5cqwtp2dy" + bolt12 = "lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3uprukkghkdufdz6adxl0ejhy0lmzfykj08u6df9v4v2c93qknz8eggzq2jyyszrrt35mmkyl7efrv5x8a3wspk07pghey4a5kcm4ef76p0ksqpnh7fqgmq9eaf7ntqspcksqkqk8ngvjtjp585mqw3qata3xe8aycgkpprk87yqcxhh705dxauxkghsc9xywqpez7lt5gw67kqwejl83unmuc7r44h32durffs4rmpcgrhxa8x8y9gqxgy9w2tqgpxqk0tl487a9ssuchyh5p9t3le3n5ylhevggk6ly8wzxvds0jawct4spe2tzqfp5d34kah9ss" lnurl = "lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9" self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"{bolt11}".upper())) self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt11}")) self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt11} ".upper())) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f"{bolt12}".upper())) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt12}")) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt12} ".upper())) self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(lnurl)) self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{lnurl} ".upper())) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt12}")) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt12}")) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"garbage text")) def test_remove_uri_prefix(self): @@ -109,6 +115,38 @@ def test_bolt11(self): self.assertFalse(pi.need_finalize()) self.assertFalse(pi.is_multiline()) + def test_bolt12(self): + offers = [ + ('lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3up5nwlwchur9zukwx743mvm0rvftrhskna22pcvtkyhufn5rc97j3gzqffs859lkadpfasgwxj47xvml7jgekez0lpfuwzhegyxsn2lzdx86qpny7xrmgwj6lphxcfauu22kenqnty4tqdlgnh8tyg87lamqe84nmh2vn0a2n908l7z7cfjghjsuusv7k079upfw0x7dpzavqpwj8swx9ee9q9cumg07fk4gvlajyhy6lfjv0cfe9gqxg0gykehtgjkxwzz24rqdssj4fjcm8xhv2rwel04ed4up2h5sf8n6y7scr0q5rt65k06s6u3mvefzer7qq', True), + ('lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878s9h02lqjy3hxc0x67pvwmu3evl6nsnvyy6adl2vn0dym4m2hdtrlnqgprp7jxwvnz5zj7xhz0fwel78cj0u90zgzpwr6we8j0nwzuv5tx9egqxdn72n27tdyers8ffdc2n75cydcl4tkd5lee0trwaekj9luzz5ydqh6cz07448ldts3yzkdk09ekl9t53ryq9lvvpuq90cmylys5saumem93wtvfd77z4alynefyj7ua7kr69dnfqqet7nsydwqa9ghdfy8udkc7x86ydl5l4nrsctfl8d3w4ejcceh9zqh0acy4cc4rcv6wv7zr6gh7fwsjzu8q', False), + ] + for bolt12, amount_locked in offers: + for valid_pi_str in [ + f'{bolt12}', + f' {bolt12}', + f'{bolt12} ', + f'lightning:{bolt12}', + f' lightning:{bolt12}', + f'lightning:{bolt12} ', + f'lightning:{bolt12.upper()}', + f'lightning:{bolt12}'.upper(), + ]: + pi = PaymentIdentifier(None, valid_pi_str) + self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.BOLT12_OFFER, pi.type) + self.assertEqual(pi.is_amount_locked(), amount_locked) + self.assertFalse(pi.is_error()) + self.assertIsNotNone(pi.bolt12_offer) + self.assertTrue(pi.need_finalize()) + self.assertFalse(pi.is_multiline()) + + for invalid_pi_str in [ + f'lightning: {bolt12}', + f'bitcoin:{bolt12}' + ]: + pi = PaymentIdentifier(None, invalid_pi_str) + self.assertFalse(pi.is_valid()) + def test_bip21(self): bip21 = 'bitcoin:bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293?message=unit_test' for pi_str in [ From 1fefe0527501c14021c8e5e82b41cb422d8a807d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sun, 9 Mar 2025 14:04:14 +0100 Subject: [PATCH 25/45] bolt12: route blinding test --- tests/route-blinding-test.json | 179 +++++++++++++++++++++++++++++++++ tests/test_route_blinding.py | 117 +++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 tests/route-blinding-test.json create mode 100644 tests/test_route_blinding.py diff --git a/tests/route-blinding-test.json b/tests/route-blinding-test.json new file mode 100644 index 000000000000..fdd195b3253d --- /dev/null +++ b/tests/route-blinding-test.json @@ -0,0 +1,179 @@ +{ + "comment": "test vector for using blinded routes", + "generate": { + "comment": "This section contains test data for creating a blinded route. This route is the concatenation of two blinded routes: one from Dave to Eve and one from Bob to Carol.", + "hops": [ + { + "comment": "Bob creates a Bob -> Carol route with the following session_key and concatenates it with the Dave -> Eve route.", + "session_key": "0202020202020202020202020202020202020202020202020202020202020202", + "alias": "Bob", + "node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x1729", + "payment_relay": { + "cltv_expiry_delta": 36, + "fee_proportional_millionths": 150, + "fee_base_msat": 10000 + }, + "payment_constraints": { + "max_cltv_expiry": 748005, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + }, + "unknown_tag_561": "123456" + }, + "encoded_tlvs": "011a0000000000000000000000000000000000000000000000000000020800000000000006c10a0800240000009627100c06000b69e505dc0e00fd023103123456", + "path_privkey": "0202020202020202020202020202020202020202020202020202020202020202", + "path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "shared_secret": "76771bab0cc3d0de6e6f60147fd7c9c7249a5ced3d0612bdfaeec3b15452229d", + "rho": "ba217b23c0978d84c4a19be8a9ff64bc1b40ed0d7ecf59521567a5b3a9a1dd48", + "encrypted_data": "cd4100ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c49982088b49f2e70b99287fdee0aa58aa39913ab405813b999f66783aa2fe637b3cda91ffc0913c30324e2c6ce327e045183e4bffecb", + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25" + }, + { + "comment": "Notice the next_path_key_override tlv in Carol's payload, indicating that Bob concatenated his route with another blinded route starting at Dave.", + "alias": "Carol", + "node_id": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", + "tlvs": { + "short_channel_id": "0x0x1105", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "payment_relay": { + "cltv_expiry_delta": 48, + "fee_proportional_millionths": 100, + "fee_base_msat": 500 + }, + "payment_constraints": { + "max_cltv_expiry": 747969, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + } + }, + "encoded_tlvs": "020800000000000004510821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0a0800300000006401f40c06000b69c105dc0e00", + "path_privkey": "0a2aa791ac81265c139237b2b84564f6000b1d4d0e68d4b9cc97c5536c9b61c1", + "path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0", + "shared_secret": "dc91516ec6b530a3d641c01f29b36ed4dc29a74e063258278c0eeed50313d9b8", + "rho": "d1e62bae1a8e169da08e6204997b60b1a7971e0f246814c648125c35660f5416", + "encrypted_data": "cc0f16524fd7f8bb0b1d8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f57ff62da5aaec5d7b10d59b04d8a9d77e472b9b3ecc2179334e411be22fa4c02b467c7e", + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7" + }, + { + "comment": "Eve creates a Dave -> Eve blinded route using the following session_key.", + "session_key": "0101010101010101010101010101010101010101010101010101010101010101", + "alias": "Dave", + "node_id": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x561", + "payment_relay": { + "cltv_expiry_delta": 144, + "fee_proportional_millionths": 250 + }, + "payment_constraints": { + "max_cltv_expiry": 747921, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + } + }, + "encoded_tlvs": "01230000000000000000000000000000000000000000000000000000000000000000000000020800000000000002310a060090000000fa0c06000b699105dc0e00", + "path_privkey": "0101010101010101010101010101010101010101010101010101010101010101", + "path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "shared_secret": "dc46f3d1d99a536300f17bc0512376cc24b9502c5d30144674bfaa4b923d9057", + "rho": "393aa55d35c9e207a8f28180b81628a31dff558c84959cdc73130f8c321d6a06", + "encrypted_data": "0fa0a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9c43815fd4bcebf6f58c546da0cd8a9bf5cebd0d554802f6c0255e28e4a27343f761fe518cd897463187991105", + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf" + }, + { + "comment": "Eve is the final recipient, so she included a path_id in her own payload to verify that the route is used when she expects it.", + "alias": "Eve", + "node_id": "02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000", + "path_id": "deadbeef", + "payment_constraints": { + "max_cltv_expiry": 747777, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [113] + }, + "unknown_tag_65535": "06c1" + }, + "encoded_tlvs": "011a00000000000000000000000000000000000000000000000000000604deadbeef0c06000b690105dc0e0f020000000000000000000000000000fdffff0206c1", + "path_privkey": "62e8bcd6b5f7affe29bec4f0515aab2eebd1ce848f4746a9638aa14e3024fb1b", + "path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a", + "shared_secret": "352a706b194c2b6d0a04ba1f617383fb816dc5f8f9ac0b60dd19c9ae3b517289", + "rho": "719d0307340b1c68b79865111f0de6e97b093a30bc603cebd1beb9eef116f2d8", + "encrypted_data": "da1a7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60724a2e4d3f0489ad884f7f3f77149209f0df51efd6b276294a02e3949c7254fbc8b5cab58212d9a78983e1cf86fe218b30c4ca8f6d8", + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae" + } + ] + }, + "route": { + "comment": "This section contains the resulting blinded route, which can then be used inside onion messages or payments.", + "first_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "first_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "hops": [ + { + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25", + "encrypted_data": "cd4100ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c49982088b49f2e70b99287fdee0aa58aa39913ab405813b999f66783aa2fe637b3cda91ffc0913c30324e2c6ce327e045183e4bffecb" + }, + { + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "encrypted_data": "cc0f16524fd7f8bb0b1d8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f57ff62da5aaec5d7b10d59b04d8a9d77e472b9b3ecc2179334e411be22fa4c02b467c7e" + }, + { + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "encrypted_data": "0fa0a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9c43815fd4bcebf6f58c546da0cd8a9bf5cebd0d554802f6c0255e28e4a27343f761fe518cd897463187991105" + }, + { + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "encrypted_data": "da1a7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60724a2e4d3f0489ad884f7f3f77149209f0df51efd6b276294a02e3949c7254fbc8b5cab58212d9a78983e1cf86fe218b30c4ca8f6d8" + } + ] + }, + "unblind": { + "comment": "This section contains test data for unblinding the route at each intermediate hop.", + "hops": [ + { + "alias": "Bob", + "node_privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "blinded_privkey": "d12fec0332c3e9d224789a17ebd93595f37d37bd8ef8bd3d2e6ce50acb9e554f", + "decrypted_data": "011a0000000000000000000000000000000000000000000000000000020800000000000006c10a0800240000009627100c06000b69e505dc0e00fd023103123456", + "next_path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0" + }, + { + "alias": "Carol", + "node_privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0", + "blinded_privkey": "bfa697fbbc8bbc43ca076e6dd60d306038a32af216b9dc6fc4e59e5ae28823c1", + "decrypted_data": "020800000000000004510821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0a0800300000006401f40c06000b69c105dc0e00", + "next_path_key": "03af5ccc91851cb294e3a364ce63347709a08cdffa58c672e9a5c587ddd1bbca60", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + }, + { + "alias": "Dave", + "node_privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "blinded_privkey": "cebc115c7fce4c295dc396dea6c79115b289b8ceeceea2ed61cf31428d88fc4e", + "decrypted_data": "01230000000000000000000000000000000000000000000000000000000000000000000000020800000000000002310a060090000000fa0c06000b699105dc0e00", + "next_path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a" + }, + { + "alias": "Eve", + "node_privkey": "4545454545454545454545454545454545454545454545454545454545454545", + "path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a", + "blinded_privkey": "ff4e07da8d92838bedd019ce532eb990ed73b574e54a67862a1df81b40c0d2af", + "decrypted_data": "011a00000000000000000000000000000000000000000000000000000604deadbeef0c06000b690105dc0e0f020000000000000000000000000000fdffff0206c1", + "next_path_key": "038fc6859a402b96ce4998c537c823d6ab94d1598fca02c788ba5dd79fbae83589" + } + ] + } +} diff --git a/tests/test_route_blinding.py b/tests/test_route_blinding.py new file mode 100644 index 000000000000..008b86f4becc --- /dev/null +++ b/tests/test_route_blinding.py @@ -0,0 +1,117 @@ +import os + +from electrum.lnonion import get_shared_secrets_along_route, OnionHopsDataSingle, encrypt_hops_recipient_data +from electrum.lnutil import LnFeatures +from electrum.util import read_json_file, bfh + +from tests import ElectrumTestCase + +# test vectors https://github.com/lightning/bolts/pull/765/files +path = os.path.join(os.path.dirname(__file__), 'route-blinding-test.json') +test_vectors = read_json_file(path) +HOPS = test_vectors['generate']['hops'] +BOB = HOPS[0] +CAROL = HOPS[1] +DAVE = HOPS[2] +EVE = HOPS[3] + +BOB_TLVS = BOB['tlvs'] +CAROL_TLVS = CAROL['tlvs'] +DAVE_TLVS = DAVE['tlvs'] +EVE_TLVS = EVE['tlvs'] + +BOB_PUBKEY = bfh(test_vectors['route']['first_node_id']) +CAROL_PUBKEY = bfh(CAROL['node_id']) +DAVE_PUBKEY = bfh(DAVE['node_id']) +EVE_PUBKEY = bfh(EVE['node_id']) + + +class TestPaymentRouteBlinding(ElectrumTestCase): + + def test_blinded_path_payload_tlv_concat(self): + + hop_shared_secrets1, blinded_node_ids1 = get_shared_secrets_along_route([BOB_PUBKEY, CAROL_PUBKEY], bfh(BOB['session_key'])) + hop_shared_secrets2, blinded_node_ids2 = get_shared_secrets_along_route([DAVE_PUBKEY, EVE_PUBKEY], bfh(DAVE['session_key'])) + hop_shared_secrets = hop_shared_secrets1 + hop_shared_secrets2 + blinded_node_ids = blinded_node_ids1 + blinded_node_ids2 + + for i, ss in enumerate(hop_shared_secrets): + self.assertEqual(ss, bfh(HOPS[i]['shared_secret'])) + for i, ss in enumerate(blinded_node_ids): + self.assertEqual(ss, bfh(HOPS[i]['blinded_node_id'])) + + hops_data = [ + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(BOB_TLVS['padding'])}, + 'short_channel_id': {'short_channel_id': 1729}, # FIXME scid from "0x0x1729" testvector repr + 'payment_relay': { + 'cltv_expiry_delta': BOB_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': BOB_TLVS['payment_relay']['fee_proportional_millionths'], + 'fee_base_msat': BOB_TLVS['payment_relay']['fee_base_msat'], + }, + 'payment_constraints': { + 'max_cltv_expiry': BOB_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': BOB_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + 'unknown_tag_561': {'data': bfh(BOB_TLVS['unknown_tag_561'])}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'short_channel_id': {'short_channel_id': 1105}, + 'next_path_key_override': {'path_key': bfh(CAROL_TLVS['next_path_key_override'])}, + 'payment_relay': { + 'cltv_expiry_delta': CAROL_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': CAROL_TLVS['payment_relay']['fee_proportional_millionths'], + 'fee_base_msat': CAROL_TLVS['payment_relay']['fee_base_msat'], + }, + 'payment_constraints': { + 'max_cltv_expiry': CAROL_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': CAROL_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(DAVE_TLVS['padding'])}, + 'short_channel_id': {'short_channel_id': 561}, + 'payment_relay': { + 'cltv_expiry_delta': DAVE_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': DAVE_TLVS['payment_relay']['fee_proportional_millionths'], + # 'fee_base_msat': DAVE_TLVS['payment_relay']['fee_base_msat'], + # FIXME: mandatory but not in test vectors ? + 'fee_base_msat': 0 + }, + 'payment_constraints': { + 'max_cltv_expiry': DAVE_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': DAVE_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(EVE_TLVS['padding'])}, + 'path_id': {'data': bfh(EVE_TLVS['path_id'])}, + 'payment_constraints': { + 'max_cltv_expiry': EVE_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': EVE_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': LnFeatures(1 << EVE_TLVS['allowed_features']['features'][0]).to_tlv_bytes()}, + 'unknown_tag_65535': {'data': bfh(EVE_TLVS['unknown_tag_65535'])}, + } + ), + ] + + encrypt_hops_recipient_data(hops_data, hop_shared_secrets) + + for i, hop in enumerate(hops_data): + self.assertEqual(hop.payload['encrypted_recipient_data']['encrypted_recipient_data'], + bfh(HOPS[i]['encrypted_data']), f'hop {i} not matching') From 06cbbd91bdf12579e7497e17811bb9006c7da2c9 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 May 2026 16:02:18 +0200 Subject: [PATCH 26/45] lnonion: implement hops data assembly for blinded payments Implement `calc_hops_data_for_blinded_payment`, which takes a route to the introduction point of a blinded payment as well as the blinded path and assembles the hop payloads for the whole payment (unblinded path + blinded path). Co-Authored-By: f321x --- electrum/lnonion.py | 81 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 7a7a9e9c333b..a95562ee9109 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -40,12 +40,14 @@ NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag, LnFeatures, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, validate_features, IncompatibleOrInsaneFeatures) from .lnmsg import OnionWireSerializer, read_bigsize_int, write_bigsize_int +from .logging import get_logger from . import lnmsg from . import util if TYPE_CHECKING: from .lnrouter import LNPaymentRoute +_logger = get_logger(__name__) HOPS_DATA_SIZE = 1300 # also sometimes called routingInfoSize in bolt-04 PER_HOP_HMAC_SIZE = 32 @@ -333,7 +335,7 @@ def new_onion_packet( onion_message: bool = False ) -> OnionPacket: num_hops = len(payment_path_pubkeys) - assert num_hops == len(hops_data) + assert num_hops == len(hops_data), f"{num_hops=}, {hops_data=}" hop_shared_secrets, _ = get_shared_secrets_along_route(payment_path_pubkeys, session_key) payload_size = 0 @@ -447,8 +449,7 @@ def calc_hops_data_for_payment( }} hops_data = [OnionHopsDataSingle(payload=hop_payload)] # payloads, backwards from last hop (but excluding the first edge): - for edge_index in range(len(route) - 1, 0, -1): - route_edge = route[edge_index] + for route_edge in reversed(route[1:]): hop_payload = { "amt_to_forward": {"amt_to_forward": amt}, "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, @@ -462,6 +463,80 @@ def calc_hops_data_for_payment( return hops_data, amt, cltv_abs +def calc_hops_data_for_blinded_payment( + route_to_introduction_point: 'LNPaymentRoute', + recipient_amount_msat: int, + *, + final_cltv_abs: int, + total_msat: int, + invoice_blinded_path_info: 'BlindedPathInfo', +) -> Tuple[List[OnionHopsDataSingle], List[bytes], int, int]: + """ + Returns the hops_data to be used for constructing an onion packet, + and the amount_msat and cltv_abs to be used on our immediate channel. + https://github.com/lightning/bolts/blob/444805d12ab98c30006173bb190cd9d6fce9e405/04-onion-routing.md?plain=1#L264 + """ + from .lnrouter import fee_for_edge_msat + invoice_blinded_path, invoice_payinfo = invoice_blinded_path_info.path, invoice_blinded_path_info.payinfo + assert invoice_blinded_path and invoice_payinfo + if len(route_to_introduction_point) > NUM_MAX_EDGES_IN_PAYMENT_PATH: + raise PaymentFailure(f"too long route ({len(route_to_introduction_point)} edges)") + + hops_data = [] + amt = recipient_amount_msat + inv_hops = invoice_blinded_path.path + num_hops = len(inv_hops) + if not invoice_payinfo.htlc_minimum_msat <= recipient_amount_msat <= invoice_payinfo.htlc_maximum_msat: + raise Exception(f'{invoice_payinfo=} htlc limits cannot fit {recipient_amount_msat=}') + + _logger.debug('inv_hops: ' + repr(inv_hops)) + # assemble data for the hops on the given blinded path + for i, inv_hop in enumerate(reversed(inv_hops)): + # each hop gets their encrypted recipient data + payload: dict = { + 'encrypted_recipient_data': {'encrypted_recipient_data': inv_hop.encrypted_recipient_data} + } + if i == 0: # recipient + payload.update({ + 'amt_to_forward': {'amt_to_forward': recipient_amount_msat}, + 'outgoing_cltv_value': {'outgoing_cltv_value': final_cltv_abs}, + 'total_amount_msat': {'total_msat': total_msat}, + }) + if i == num_hops - 1: # introduction point + payload['current_path_key'] = {'path_key': invoice_blinded_path.first_path_key} + _logger.debug(f'inv_hop[{num_hops - 1 - i}].payload: ' + repr(payload)) + hops_data.append(OnionHopsDataSingle(payload=payload)) + + # add the fees + cltv for the (whole) blinded path, amt is then what the introduction point gets + amt += fee_for_edge_msat( + forwarded_amount_msat=recipient_amount_msat, + fee_base_msat=invoice_payinfo.fee_base_msat, + fee_proportional_millionths=invoice_payinfo.fee_proportional_millionths, + ) + cltv_abs = final_cltv_abs + invoice_payinfo.cltv_expiry_delta + _logger.debug(f'blinded payment introduction point {amt=} for {recipient_amount_msat=}, {cltv_abs=}') + + # payloads for the unblinded part of the path, backwards from pre-IP node (excluding the first edge) + for i, route_edge in enumerate(reversed(route_to_introduction_point[1:])): + hop_payload = { + "amt_to_forward": {"amt_to_forward": amt}, + "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, + "short_channel_id": {"short_channel_id": route_edge.short_channel_id}, + } + + hops_data.append(OnionHopsDataSingle(payload=hop_payload)) + amt += route_edge.fee_for_edge(amt) + cltv_abs += route_edge.cltv_delta + + _logger.debug(f'route_edge[{len(route_to_introduction_point) - 1 - i}].payload: ' + repr(hop_payload) + \ + f'\nedge_in_amt: {amt}, edge_in_cltv: {cltv_abs}' + \ + f'\n--> {route_edge.end_node.hex()}') + + hops_data.reverse() + blinded_path_blinded_node_pubkeys = [x.blinded_node_id for x in inv_hops][1:] + return hops_data, blinded_path_blinded_node_pubkeys, amt, cltv_abs + + def _generate_filler(key_type: bytes, hops_data: Sequence[OnionHopsDataSingle], shared_secrets: Sequence[bytes], data_size:int) -> bytes: num_hops = len(hops_data) From a28e698596985b8caf18610baf25634fe3a0f395 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:14:22 +0200 Subject: [PATCH 27/45] lnutil, lnonion: add RoutingInfo abstractions Introduce RoutingInfo class to make the lightning payment flow (LNWallet.pay_invoice and following) less specific to bolt11 but act on generic `RoutingInfo`. --- electrum/invoices.py | 23 ++++++++++++++++++++++- electrum/lnutil.py | 21 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/electrum/invoices.py b/electrum/invoices.py index 5d8ac7eae6a4..1371df5a0043 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -8,7 +8,8 @@ from .i18n import _ from .util import age, InvoiceError, format_satoshis from .bip21 import create_bip21_uri -from .lnutil import hex_to_bytes, LnFeatures +from .lnutil import hex_to_bytes, RoutingInfo, BlindedRoutingInfo, UnblindedRoutingInfo, LnFeatures +from .lnonion import BlindedPathInfo from .bolt11 import decode_bolt11_invoice, BOLT11Addr from .bolt12 import BOLT12Invoice from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC @@ -333,6 +334,26 @@ def issuer_pubkey(self) -> Optional[str]: # Note: b12 invoice_node_id can be blinded, so showing it to the user could be potentially misleading return self.bolt12_invoice.invoice_node_id.hex() + def get_routing_info(self) -> 'RoutingInfo': + assert self.is_lightning(), self + if b12 := self.bolt12_invoice: + return BlindedRoutingInfo( + paths=tuple( + BlindedPathInfo(path=path, payinfo=payinfo) + for path, payinfo in zip(b12.invoice_paths, b12.invoice_blindedpay) + ), + invoice_features=b12.invoice_features or LnFeatures(0), + ) + else: + b11 = self.bolt11_invoice + return UnblindedRoutingInfo( + node_pubkey=b11.pubkey.serialize(), + r_tags=b11.get_routing_info('r'), + payment_secret=b11.payment_secret, + final_cltv_delta=b11.get_min_final_cltv_delta(), + invoice_features=b11.get_features(), + ) + @lightning_invoice.validator def _validate_invoice_str(self, attribute, value): if value is not None: diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 29a16f941a29..d236d90ba54c 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1,8 +1,11 @@ # Copyright (C) 2018 The Electrum developers # Distributed under the MIT software license, see the accompanying # file LICENCE or http://www.opensource.org/licenses/mit-license.php +import os from enum import IntFlag, IntEnum import enum +from abc import ABC, abstractmethod +from collections import defaultdict from typing import NamedTuple, List, Tuple, Mapping, Optional, TYPE_CHECKING, Union, Dict, Set, Sequence, FrozenSet import sys import time @@ -35,7 +38,7 @@ if TYPE_CHECKING: from .lnchannel import Channel, AbstractChannel from .lnrouter import LNPaymentRoute - from .lnonion import OnionRoutingFailure + from .lnonion import OnionRoutingFailure, BlindedPathInfo, BlindedPayInfo from .simple_config import SimpleConfig @@ -2147,3 +2150,19 @@ def reverse_from_total_amount(cls, *, total_amount_msat: int, config: 'SimpleCon fees_msat = max(total_amount_msat - amount_minus_fees, cutoff_clamped) fees_msat = min(fees_msat, total_amount_msat) # to handle (invalid?) inputs below cutoff_clamped return fees_msat + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class UnblindedRoutingInfo: + node_pubkey: bytes + payment_secret: bytes + final_cltv_delta: int + r_tags: Sequence[Sequence[Sequence[bytes | int]]] + invoice_features: LnFeatures + +@dataclasses.dataclass(kw_only=True, frozen=True) +class BlindedRoutingInfo: + paths: tuple['BlindedPathInfo', ...] + invoice_features: LnFeatures + +RoutingInfo = Union[UnblindedRoutingInfo, BlindedRoutingInfo] From 75039137459d2c3f5f314e230f552a4f0a2412c5 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 4 Jun 2026 17:38:16 +0200 Subject: [PATCH 28/45] lnutil: add final cltv delta offset Add a random offset to the `min_final_cltv_delta` of lightning payments. Renames the `RoutingInfo` fields to `final_cltv_delta` to make the distinction that this is not the same value as from the invoice more clear. For the blinded payments we need to add some value to the local_height for the final onion, however using a constant would allow the recipient to rather easily detect the sender is using Electrum. So using the same random offset as lightning-kmp/Phoenix seems good. At the same time we can add it to the unblinded payments as well, they profit from additional privacy against the forwarders. --- electrum/invoices.py | 6 ++++-- electrum/lnutil.py | 17 +++++++++++++++-- tests/regtest/regtest.sh | 12 ++++++------ tests/test_lnpeer.py | 9 +++++---- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/electrum/invoices.py b/electrum/invoices.py index 1371df5a0043..5a41c0f971c2 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -8,7 +8,7 @@ from .i18n import _ from .util import age, InvoiceError, format_satoshis from .bip21 import create_bip21_uri -from .lnutil import hex_to_bytes, RoutingInfo, BlindedRoutingInfo, UnblindedRoutingInfo, LnFeatures +from .lnutil import hex_to_bytes, RoutingInfo, BlindedRoutingInfo, UnblindedRoutingInfo, LnFeatures, get_final_cltv_offset from .lnonion import BlindedPathInfo from .bolt11 import decode_bolt11_invoice, BOLT11Addr from .bolt12 import BOLT12Invoice @@ -343,14 +343,16 @@ def get_routing_info(self) -> 'RoutingInfo': for path, payinfo in zip(b12.invoice_paths, b12.invoice_blindedpay) ), invoice_features=b12.invoice_features or LnFeatures(0), + final_cltv_delta=get_final_cltv_offset(), ) else: b11 = self.bolt11_invoice + final_cltv_delta = b11.get_min_final_cltv_delta() + get_final_cltv_offset() return UnblindedRoutingInfo( node_pubkey=b11.pubkey.serialize(), r_tags=b11.get_routing_info('r'), payment_secret=b11.payment_secret, - final_cltv_delta=b11.get_min_final_cltv_delta(), + final_cltv_delta=final_cltv_delta, invoice_features=b11.get_features(), ) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index d236d90ba54c..fdf3a45b3ade 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -17,7 +17,7 @@ import dataclasses import attr -from .util import bfh, UserFacingException, list_enabled_bits, is_hex_str +from .util import bfh, UserFacingException, list_enabled_bits, is_hex_str, randrange from .util import ShortID as ShortChannelID, format_short_id as format_short_channel_id from .crypto import sha256, pw_decode_with_version_and_mac @@ -535,6 +535,18 @@ class LNProtocolWarning(Exception): TIME_FOR_OFFERED_HTLCS_TO_GET_FAILED_OFFCHAIN_ON_RESTART = 30 +def get_final_cltv_offset() -> int: + """ + Return offset to be added to the expiry height of the recipient. + The offset makes it harder for intermediate nodes to guess their position in the route. + The offset is taken from lightning-kmp (Phoenix). + """ + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L274-L277 + # https://github.com/ACINQ/lightning-kmp/blob/0a857347dc5a6363693ba7ac05cddc247a018f72/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt#L111-L127 + random_offset = 71 + randrange(144 - 71) # [72; 143] + return random_offset + + class RevocationStore: # closely based on code in lightningnetwork/lnd @@ -2156,13 +2168,14 @@ def reverse_from_total_amount(cls, *, total_amount_msat: int, config: 'SimpleCon class UnblindedRoutingInfo: node_pubkey: bytes payment_secret: bytes - final_cltv_delta: int + final_cltv_delta: int # invoice + random offset r_tags: Sequence[Sequence[Sequence[bytes | int]]] invoice_features: LnFeatures @dataclasses.dataclass(kw_only=True, frozen=True) class BlindedRoutingInfo: paths: tuple['BlindedPathInfo', ...] + final_cltv_delta: int # random offset invoice_features: LnFeatures RoutingInfo = Union[UnblindedRoutingInfo, BlindedRoutingInfo] diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index ced9f0b083eb..0102adb501a3 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -484,7 +484,7 @@ if [[ $1 == "lnwatcher_waits_until_fees_go_down" ]]; then assert_utxo_exists $ctx_id $htlc_output_index2 # fee levels rise. now small htlc is ~dust $alice test_inject_fee_etas "{2:300000}" - new_blocks 300 # this goes past the CLTV of the HTLC-output in ctx + new_blocks 450 # this goes past the CLTV of the HTLC-output in ctx wait_until_spent $ctx_id $htlc_output_index2 assert_utxo_exists $ctx_id $htlc_output_index1 new_blocks 24 # note: >20 blocks depth is considered "DEEP" by lnwatcher @@ -604,7 +604,7 @@ if [[ $1 == "redeem_offered_htlcs" ]]; then new_blocks 1 sleep 3 echo "alice balance after closing channel:" $($alice getbalance) - new_blocks 150 + new_blocks 300 # cltv delta + random offset [72;143] sleep 10 new_blocks 1 sleep 3 @@ -708,8 +708,8 @@ if [[ $1 == "breach_with_spent_htlc" ]]; then fi echo "wait for cltv_expiry blocks" # note: this will let alice redeem both to_local and the htlc. - # (to_local needs to_self_delay blocks; htlc needs whatever we put in invoice) - new_blocks 150 + # (to_local needs to_self_delay blocks; htlc needs whatever we put in invoice and the offset add on top when sending) + new_blocks 300 $alice stop $alice daemon -d $alice load_wallet -w /tmp/alice/regtest/wallets/toxic_wallet @@ -794,7 +794,7 @@ if [[ $1 == "fw_fail_htlc" ]]; then ctx_id=$($bob close_channel $chan_id2 --force) new_blocks 1 sleep 1 - new_blocks 150 # cltv before bob can broadcast + new_blocks 300 # cltv before bob can broadcast # index of htlc if [ $TEST_SRK_CHANNELS != True ] ; then # anchors output_index=2 @@ -804,7 +804,7 @@ if [[ $1 == "fw_fail_htlc" ]]; then wait_until_spent $ctx_id $output_index new_blocks 1 # confirm 2nd stage. sleep 1 - new_blocks 100 # deep enough for is_deeply_mined (>20 confs) + new_blocks 30 # deep enough for is_deeply_mined (>20 confs) wait_until_htlcs_settled alice # bob propagates the failure back once the HTLC-timeout tx is deeply mined fi diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 00487f35d8f1..6f42f96ad7f0 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -1963,13 +1963,14 @@ async def wait_for_htlcs(): await asyncio.sleep(0.25) # give w2 some time to do mistakes self.assertEqual(w2.received_mpp_htlcs[payment_key.hex()].resolution, RecvMPPResolution.COMPLETE) if test_expiry: - # we set an expiry delta of 20 blocks before expiry, htlc expiry should be +144 current height + # we set an expiry delta of 20 blocks before expiry, htlc expiry should be current height + 144 + random delay [72;143] # so adding some blocks should get the htlcs failed - w2.network.blockchain()._height += 50 + blocks_remaining = next(iter(w2.received_mpp_htlcs[payment_key.hex()].htlcs)).htlc.cltv_abs - w2.network.blockchain()._height + w2.network.blockchain()._height += (blocks_remaining - 20) await asyncio.sleep(0.1) - # the htlcs should not get failed yet as 144-50 > 20 + # the htlcs should not get failed yet as they still have 1 block left self.assertEqual(w2.received_mpp_htlcs[payment_key.hex()].resolution, RecvMPPResolution.COMPLETE) - w2.network.blockchain()._height += 75 + w2.network.blockchain()._height += 1 return # the htlcs should get failed and pay should return PaymentFailure # saving the preimage should let the htlcs get fulfilled From dbbab1d7b7b04cfb3ea7577e843578e00a298fa4 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:27:41 +0200 Subject: [PATCH 29/45] LNWallet/bolt12: implement sending blinded payments Adapt the LNWallet payment flow to handle blinded/bolt12 payments. Paying via Trampoline is added in a future commit. Co-Authored-By: Sander van Grieken --- electrum/lnpeer.py | 7 +- electrum/lnrouter.py | 31 +++- electrum/lnutil.py | 14 ++ electrum/lnworker.py | 352 +++++++++++++++++++++++++++---------------- tests/lnhelpers.py | 14 +- tests/test_lnpeer.py | 21 +-- 6 files changed, 289 insertions(+), 150 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index ca0742ca4e90..9705d6e5aa78 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -22,6 +22,7 @@ import aiorpcx from aiorpcx import ignore_after +from electrum.lnonion import BlindedPathInfo from .lrucache import LRUCache from .crypto import sha256, sha256d, privkey_to_pubkey from . import bitcoin, util @@ -2045,7 +2046,8 @@ def pay(self, *, total_msat: int, payment_hash: bytes, min_final_cltv_delta: int, - payment_secret: bytes, + payment_secret: Optional[bytes], + blinded_path_info: Optional['BlindedPathInfo'] = None, trampoline_onion: Optional[OnionPacket] = None, ) -> UpdateAddHtlc: @@ -2060,7 +2062,8 @@ def pay(self, *, payment_hash=payment_hash, min_final_cltv_delta=min_final_cltv_delta, payment_secret=payment_secret, - trampoline_onion=trampoline_onion + blinded_path_info=blinded_path_info, + trampoline_onion=trampoline_onion, ) htlc = self.send_htlc( chan=chan, diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 181f1adb1b5a..45ed2f8a32a9 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -30,6 +30,7 @@ import threading from threading import RLock from math import inf +from dataclasses import dataclass import attr @@ -41,6 +42,7 @@ if TYPE_CHECKING: from .lnchannel import Channel + from .lnonion import BlindedPayInfo DEFAULT_PENALTY_BASE_MSAT = 500 # how much base fee we apply for unknown sending capability of a channel DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100 # how much relative fee we apply for unknown sending capability of a channel @@ -137,20 +139,45 @@ def is_trampoline(self): LNPaymentTRoute = Sequence[TrampolineEdge] +@dataclass(frozen=True, kw_only=True) +class FinalForwardFees: + fee_base_msat: int + fee_proportional_millionths: int + + # cltv budget for the last forwarder (e.g. legacy trampoline) + # this is counted into our PaymentFeeBudget + forwarder_cltv_delta: int = 0 + + # also for the last forwarder, but specifically to account for subsequent blinded path + # this is excluded from our PaymentFeeBudget (see comment in PaymentFeeBudget) + blinded_path_cltv_delta: int = 0 + + def fee_for_edge(self, amount_msat: int) -> int: + return fee_for_edge_msat( + forwarded_amount_msat=amount_msat, + fee_base_msat=self.fee_base_msat, + fee_proportional_millionths=self.fee_proportional_millionths, + ) + + def is_route_within_budget( route: LNPaymentRoute, *, budget: PaymentFeeBudget, amount_msat_for_dest: int, # that final receiver gets - cltv_delta_for_dest: int, # that final receiver gets + cltv_delta_for_dest: int, # that final receiver gets (IP cltv_delta or recipient min_final_cltv_delta) + final_forward_fees: Optional['FinalForwardFees'] = None, ) -> bool: """Run some sanity checks on the whole route, before attempting to use it. called when we are paying; so e.g. lower cltv is better """ - if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH: + if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH: # doesn't count blinded path hops return False amt = amount_msat_for_dest cltv_cost_of_route = 0 # excluding cltv_delta_for_dest + if final_forward_fees: + amt += final_forward_fees.fee_for_edge(amt) + cltv_cost_of_route += final_forward_fees.forwarder_cltv_delta for route_edge in reversed(route[1:]): amt += route_edge.fee_for_edge(amt) cltv_cost_of_route += route_edge.cltv_delta diff --git a/electrum/lnutil.py b/electrum/lnutil.py index fdf3a45b3ade..8a0a6cf07a2c 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -2172,10 +2172,24 @@ class UnblindedRoutingInfo: r_tags: Sequence[Sequence[Sequence[bytes | int]]] invoice_features: LnFeatures + @property + def id(self): + """key to prevent concurrent attempts of this payment (invoice)""" + return self.payment_secret + @dataclasses.dataclass(kw_only=True, frozen=True) class BlindedRoutingInfo: paths: tuple['BlindedPathInfo', ...] final_cltv_delta: int # random offset invoice_features: LnFeatures + def __post_init__(self): + assert self.paths + assert all(p.payinfo for p in self.paths) + + @property + def id(self): + first_path = self.paths[0].path + return sha256(first_path.first_node_id + first_path.first_path_key) + RoutingInfo = Union[UnblindedRoutingInfo, BlindedRoutingInfo] diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 348993504ede..b35cb750f50b 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -37,8 +37,7 @@ from .i18n import _ from .channel_db import UpdateStatus, ChannelDBNotLoaded, get_mychannel_info, get_mychannel_policy -from . import constants, util, lnutil -from . import bitcoin +from . import constants, util, lnutil, bitcoin, bolt12 from .util import ( profiler, OldTaskGroup, ESocksProxy, NetworkRetryManager, JsonRPCClient, NotEnoughFunds, EventListener, event_listener, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions, ignore_exceptions, @@ -78,16 +77,17 @@ OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, RecvMPPResolution, ReceivedMPPStatus, ReceivedMPPHtlc, - PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, + PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, UnblindedRoutingInfo, BlindedRoutingInfo, RoutingInfo ) from .lnonion import ( decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, - ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, + ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, calc_hops_data_for_blinded_payment, + BlindedPathInfo, ) from .lnmsg import decode_msg, OnionWireSerializer from .lnrouter import ( RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, - LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, + LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, FinalForwardFees ) from .lnwatcher import LNWatcher from .submarine_swaps import SwapManager @@ -137,7 +137,7 @@ class PaymentInfo: amount_msat: Optional[int] direction: lnutil.Direction status: int - min_final_cltv_delta: int + min_final_cltv_delta: Optional[int] expiry_delay: int creation_ts: int = dataclasses.field(default_factory=lambda: int(time.time())) invoice_features: LnFeatures @@ -153,7 +153,12 @@ def validate(self): if self.direction == RECEIVED: assert self.amount_msat != 0 # use amount_msat=None instead! assert isinstance(self.status, int) - assert isinstance(self.min_final_cltv_delta, int) + if self.min_final_cltv_delta is None: + # when sending blinded payments we don't know the *final* cltv delta as we only see the aggregate + # cltv delta of the given blinded path + assert self.direction == lnutil.Direction.SENT + else: + assert isinstance(self.min_final_cltv_delta, int) assert isinstance(self.expiry_delay, int) and self.expiry_delay > 0, repr(self.expiry_delay) assert isinstance(self.creation_ts, int) assert isinstance(self.invoice_features, LnFeatures) @@ -175,13 +180,14 @@ def calc_db_key(cls, *, payment_hash_hex: str, direction: lnutil.Direction) -> s class SentHtlcInfo(NamedTuple): route: LNPaymentRoute - payment_secret_orig: bytes - payment_secret_bucket: bytes + payment_key: bytes amount_msat: int bucket_msat: int amount_receiver_msat: int trampoline_fee_level: Optional[int] trampoline_route: Optional[LNPaymentRoute] + per_trampoline_payment_secret: Optional[bytes] + blinded_path: Optional[BlindedPathInfo] class ErrorAddingPeer(Exception): pass @@ -863,28 +869,21 @@ class PaySession(Logger): def __init__( self, *, + routing_info: 'RoutingInfo', payment_hash: bytes, - payment_secret: bytes, initial_trampoline_fee_level: int, invoice_features: int, - r_tags, - min_final_cltv_delta: int, # delta for last node (typically from invoice) amount_to_pay: int, # total payment amount final receiver will get - invoice_pubkey: bytes, uses_trampoline: bool, # whether sender uses trampoline or gossip ): assert payment_hash - assert payment_secret self.payment_hash = payment_hash - self.payment_secret = payment_secret - self.payment_key = payment_hash + payment_secret + self.routing_info = routing_info + self.payment_key = payment_hash + routing_info.id Logger.__init__(self) self.invoice_features = LnFeatures(invoice_features) - self.r_tags = r_tags - self.min_final_cltv_delta = min_final_cltv_delta self.amount_to_pay = amount_to_pay - self.invoice_pubkey = invoice_pubkey self.sent_htlcs_q = asyncio.Queue() # type: asyncio.Queue[HtlcLog] self.start_time = time.time() @@ -967,9 +966,10 @@ def add_new_htlc(self, sent_htlc_info: SentHtlcInfo): if self._amount_inflight > self.amount_to_pay: # safety belts raise Exception(f"amount_inflight={self._amount_inflight} > amount_to_pay={self.amount_to_pay}") shi = sent_htlc_info - bkey = shi.payment_secret_bucket + bkey = shi.per_trampoline_payment_secret # if we sent MPP to a trampoline, add item to sent_buckets if self.uses_trampoline and shi.amount_msat != shi.bucket_msat: + assert bkey is not None if bkey not in self._sent_buckets: self._sent_buckets[bkey] = (0, 0) amount_sent, amount_failed = self._sent_buckets[bkey] @@ -979,8 +979,9 @@ def add_new_htlc(self, sent_htlc_info: SentHtlcInfo): def on_htlc_fail_get_fail_amt_to_propagate(self, sent_htlc_info: SentHtlcInfo) -> Optional[int]: shi = sent_htlc_info # check sent_buckets if we use trampoline - bkey = shi.payment_secret_bucket + bkey = shi.per_trampoline_payment_secret if self.uses_trampoline and bkey in self._sent_buckets: + assert bkey is not None amount_sent, amount_failed = self._sent_buckets[bkey] amount_failed += shi.amount_receiver_msat self._sent_buckets[bkey] = amount_sent, amount_failed @@ -995,6 +996,14 @@ def on_htlc_fail_get_fail_amt_to_propagate(self, sent_htlc_info: SentHtlcInfo) - def get_outstanding_amount_to_send(self) -> int: return self.amount_to_pay - self._amount_inflight + def get_blinded_path_to_try(self) -> Optional[BlindedPathInfo]: + # TODO: check direct channels + # TODO: consider amount to send + # TODO: consider previously failed paths + if isinstance(self.routing_info, BlindedRoutingInfo): + return self.routing_info.paths[0] + return None + def can_be_deleted(self) -> bool: """Returns True iff finished sending htlcs AND all pending htlcs have resolved.""" if self.is_active: @@ -1906,15 +1915,18 @@ async def pay_invoice( When paying a hold-invoice, or during a submarine swap, it is often the case that the recipient does not YET know the preimage, and hence they cannot take the money until later. """ - bolt11 = invoice.lightning_invoice - lnaddr = self._check_bolt11_invoice(bolt11, amount_msat=amount_msat) - min_final_cltv_delta = lnaddr.get_min_final_cltv_delta() - payment_hash = invoice.payment_hash - key = invoice.rhash - payment_secret = lnaddr.payment_secret - invoice_pubkey = lnaddr.pubkey.serialize() - r_tags = lnaddr.get_routing_info('r') + assert invoice.is_lightning(), invoice + if bolt12_invoice := invoice.bolt12_invoice: + self._check_bolt12_invoice(bolt12_invoice, amount_msat=amount_msat) + elif bolt11 := invoice.lightning_invoice: + bolt11_addr = self._check_bolt11_invoice(bolt11, amount_msat=amount_msat) + # synchronize amount with invoice, otherwise we potentially have three different amount_msat? + invoice.set_amount_msat(bolt11_addr.get_amount_msat()) + + key = rhash = invoice.rhash + payment_hash = bytes.fromhex(rhash) amount_to_pay = invoice.get_amount_msat() + routing_info = invoice.get_routing_info() status = self.get_payment_status(payment_hash, direction=SENT) if status == PR_PAID: raise PaymentFailure(_("This invoice has been paid already")) @@ -1927,12 +1939,12 @@ async def pay_invoice( amount_msat=amount_to_pay, direction=SENT, status=PR_UNPAID, - min_final_cltv_delta=min_final_cltv_delta, + min_final_cltv_delta=None, expiry_delay=LN_EXPIRY_NEVER, invoice_features=invoice.features, ) self.save_payment_info(info) - self.wallet.set_label(key, lnaddr.get_description()) + self.wallet.set_label(key, invoice.get_message()) self.set_invoice_status(key, PR_INFLIGHT) if budget is None: budget = PaymentFeeBudget.from_invoice_amount(invoice_amount_msat=amount_to_pay, config=self.config) @@ -1942,12 +1954,9 @@ async def pay_invoice( success = False try: await self.pay_to_node( - node_pubkey=invoice_pubkey, + routing_info=routing_info, payment_hash=payment_hash, - payment_secret=payment_secret, amount_to_pay=amount_to_pay, - min_final_cltv_delta=min_final_cltv_delta, - r_tags=r_tags, invoice_features=invoice.features, attempts=attempts, full_path=full_path, @@ -1975,12 +1984,9 @@ async def pay_invoice( @log_exceptions async def pay_to_node( self, *, - node_pubkey: bytes, + routing_info: 'RoutingInfo', payment_hash: bytes, - payment_secret: bytes, amount_to_pay: int, # in msat - min_final_cltv_delta: int, - r_tags, invoice_features: int, attempts: int = None, full_path: LNPaymentPath = None, @@ -1998,26 +2004,25 @@ async def pay_to_node( assert budget.fee_msat >= 0, budget assert budget.cltv >= 0, budget - payment_key = payment_hash + payment_secret + payment_key = payment_hash + routing_info.id assert payment_key not in self._paysessions self._paysessions[payment_key] = paysession = PaySession( + routing_info=routing_info, payment_hash=payment_hash, - payment_secret=payment_secret, initial_trampoline_fee_level=self.config.INITIAL_TRAMPOLINE_FEE_LEVEL, invoice_features=invoice_features, - r_tags=r_tags, - min_final_cltv_delta=min_final_cltv_delta, amount_to_pay=amount_to_pay, - invoice_pubkey=node_pubkey, uses_trampoline=self.uses_trampoline(), ) self.logs[payment_hash.hex()] = log = [] # TODO incl payment_secret in key (re trampoline forwarding) + route_info = f"{len(routing_info.paths)} blinded path(s)" if isinstance(routing_info, BlindedRoutingInfo) \ + else "r_tags:" + str(BOLT11Addr.format_bolt11_routing_info_as_human_readable(routing_info.r_tags)) paysession.logger.info( f"pay_to_node starting session for RHASH={payment_hash.hex()}. " f"using_trampoline={self.uses_trampoline()}. " f"invoice_features={paysession.invoice_features.get_names()}. " - f"r_tags={BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)}. " + f"route_info={route_info}. " f"{amount_to_pay=} msat. {budget=}") if not self.uses_trampoline(): self.logger.info( @@ -2160,15 +2165,27 @@ async def pay_to_route( if not peer: raise PaymentFailure('Dropped peer') await peer.initialized + + if shi.blinded_path: + payment_secret = None + elif shi.per_trampoline_payment_secret: + assert trampoline_onion + payment_secret = shi.per_trampoline_payment_secret + else: + assert isinstance(paysession.routing_info, UnblindedRoutingInfo) + payment_secret = paysession.routing_info.payment_secret + htlc = peer.pay( route=shi.route, chan=chan, amount_msat=shi.amount_msat, total_msat=shi.bucket_msat, payment_hash=paysession.payment_hash, + payment_secret=payment_secret, + blinded_path_info=shi.blinded_path, min_final_cltv_delta=min_final_cltv_delta, - payment_secret=shi.payment_secret_bucket, - trampoline_onion=trampoline_onion) + trampoline_onion=trampoline_onion, + ) key = (paysession.payment_hash, short_channel_id, htlc.htlc_id) self.sent_htlcs_info[key] = shi @@ -2332,6 +2349,17 @@ def _check_bolt11_invoice(self, bolt11_invoice: str, *, amount_msat: int = None) addr.validate_and_compare_features(self.features) return addr + def _check_bolt12_invoice(self, b12i: 'BOLT12Invoice', *, amount_msat: int = None) -> None: + """pre-payment checks for bolt12 invoice external to the parser/__post_init__.""" + # most variables are checked in BlindedPayInfo.__post_init__ + if b12i.is_expired: + raise InvoiceError(_("This invoice has expired")) + # check amount + if amount_msat is not None and amount_msat != b12i.invoice_amount: + raise ValueError("should have requested a higher amount invoice beforehand") + if b12i.invoice_features is not None: + lnutil.ln_compare_features(self.features.for_bolt12_invoice(), b12i.invoice_features) + def is_trampoline_peer(self, node_id: bytes) -> bool: # until trampoline is advertised in lnfeatures, check against hardcoded list if is_hardcoded_trampoline(node_id): @@ -2355,8 +2383,7 @@ def suggest_payment_splits( final_total_msat: int, my_active_channels: Sequence[Channel], invoice_features: LnFeatures, - r_tags: Sequence[Sequence[Sequence[Any]]], - receiver_pubkey: bytes, + routing_info: 'RoutingInfo', ) -> List['SplitConfigRating']: channels_with_funds = { (chan.channel_id, chan.node_id): ( int(chan.available_to_spend(HTLCOwner.LOCAL)), chan.htlc_slots_left(HTLCOwner.LOCAL)) @@ -2364,14 +2391,20 @@ def suggest_payment_splits( } # if we have a direct channel it's preferable to send a single part directly through this # channel, so this bool will disable excluding single part payments - have_direct_channel = any(chan.node_id == receiver_pubkey for chan in my_active_channels) + if isinstance(routing_info, BlindedRoutingInfo): + recipient_pubkeys = {p.path.first_node_id for p in routing_info.paths} + else: + recipient_pubkeys = [routing_info.node_pubkey] + have_direct_channel = any(chan.node_id in recipient_pubkeys for chan in my_active_channels) self.logger.info(f"channels_with_funds: {channels_with_funds}, {have_direct_channel=}") exclude_single_part_payments = False if self.uses_trampoline(): # in the case of a legacy payment, we don't allow splitting via different # trampoline nodes, because of https://github.com/ACINQ/eclair/issues/2127 - is_legacy, _ = is_legacy_relay(invoice_features, r_tags) - exclude_multinode_payments = is_legacy + exclude_multinode_payments = False + if isinstance(routing_info, UnblindedRoutingInfo): + is_legacy, _ = is_legacy_relay(invoice_features, routing_info.r_tags) + exclude_multinode_payments = is_legacy # we don't split within a channel when sending to a trampoline node, # the trampoline node will split for us exclude_single_channel_splits = not self.config.TEST_FORCE_MPP @@ -2405,7 +2438,6 @@ async def create_routes_for_payment( channels: Optional[Sequence[Channel]] = None, budget: PaymentFeeBudget, ) -> AsyncGenerator[Tuple[SentHtlcInfo, int, Optional[OnionPacket]], None]: - """Creates multiple routes for splitting a payment over the available private channels. @@ -2413,6 +2445,8 @@ async def create_routes_for_payment( and mpp is supported by the receiver, we will split the payment.""" trampoline_features = LnFeatures.VAR_ONION_OPT local_height = self.wallet.adb.get_local_height() + routing_info = paysession.routing_info + blinded_path = paysession.get_blinded_path_to_try() fee_related_error = None # type: Optional[FeeBudgetExceeded] if channels: my_active_channels = channels @@ -2427,8 +2461,7 @@ async def create_routes_for_payment( final_total_msat=paysession.amount_to_pay, my_active_channels=my_active_channels, invoice_features=paysession.invoice_features, - r_tags=paysession.r_tags, - receiver_pubkey=paysession.invoice_pubkey, + routing_info=paysession.routing_info, ) for sc in split_configurations: is_multichan_mpp = len(sc.config.items()) > 1 @@ -2442,7 +2475,8 @@ async def create_routes_for_payment( self.logger.info(f"trying split configuration: {sc.config.values()} rating: {sc.rating}") routes = [] try: - is_direct_path = all(node_id == paysession.invoice_pubkey for (chan_id, node_id) in sc.config.keys()) + destination_pubkey = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey + is_direct_path = all(node_id == destination_pubkey for (chan_id, node_id) in sc.config.keys()) if self.uses_trampoline() and not is_direct_path: if fwd_trampoline_onion: raise NoPathFound() @@ -2455,17 +2489,19 @@ async def create_routes_for_payment( # for each trampoline forwarder, construct mpp trampoline for trampoline_node_id, trampoline_parts in per_trampoline_channel_amounts.items(): per_trampoline_amount = sum([x[1] for x in trampoline_parts]) + if not isinstance(routing_info, UnblindedRoutingInfo): + raise NotImplementedError trampoline_route, trampoline_onion, per_trampoline_amount_with_fees, per_trampoline_cltv_delta = create_trampoline_route_and_onion( amount_msat=per_trampoline_amount, total_msat=paysession.amount_to_pay, - min_final_cltv_delta=paysession.min_final_cltv_delta, my_pubkey=self.node_keypair.pubkey, - invoice_pubkey=paysession.invoice_pubkey, - invoice_features=paysession.invoice_features, + min_final_cltv_delta=routing_info.final_cltv_delta, + invoice_pubkey=routing_info.node_pubkey, + invoice_features=routing_info.invoice_features, + r_tags=routing_info.r_tags, + payment_secret=routing_info.payment_secret, node_id=trampoline_node_id, - r_tags=paysession.r_tags, payment_hash=paysession.payment_hash, - payment_secret=paysession.payment_secret, local_height=local_height, trampoline_fee_level=paysession.trampoline_fee_level, next_trampolines=paysession.next_trampolines.get(trampoline_node_id, {}), @@ -2498,13 +2534,15 @@ async def create_routes_for_payment( self.logger.info(f'adding route {part_amount_msat} {delta_fee} {margin}') shi = SentHtlcInfo( route=route, - payment_secret_orig=paysession.payment_secret, - payment_secret_bucket=per_trampoline_secret, + payment_key=paysession.payment_key, amount_msat=part_amount_msat_with_fees, bucket_msat=per_trampoline_amount_with_fees, amount_receiver_msat=part_amount_msat, trampoline_fee_level=paysession.trampoline_fee_level, trampoline_route=trampoline_route, + per_trampoline_payment_secret=per_trampoline_secret, + # blinded path is embedded in the trampoline onion for last trampoline forwarder + blinded_path=None, ) routes.append((shi, per_trampoline_cltv_delta, trampoline_onion)) if per_trampoline_fees != 0: @@ -2517,39 +2555,47 @@ async def create_routes_for_payment( for (chan_id, _), part_amounts_msat in sc.config.items(): for part_amount_msat in part_amounts_msat: channel = self._channels[chan_id] - if is_direct_path: - route = self.create_direct_route( - amount_msat=part_amount_msat, - channel=channel, - ) + if is_direct_path: # to recipient or blinded path introduction node + route = self.create_direct_route(channel=channel) else: assert not self.uses_trampoline() route = await run_in_thread(partial( self.create_route_for_single_htlc, amount_msat=part_amount_msat, - invoice_pubkey=paysession.invoice_pubkey, - r_tags=paysession.r_tags, - invoice_features=paysession.invoice_features, + routing_info=routing_info, + blinded_path=blinded_path, my_sending_channels=[channel] if is_multichan_mpp else my_active_channels, full_path=full_path, )) + final_forward_fee = None + if blinded_path: + payinfo = blinded_path.payinfo + final_forward_fee = FinalForwardFees( + fee_base_msat=payinfo.fee_base_msat, + fee_proportional_millionths=payinfo.fee_proportional_millionths, + ) if not is_route_within_budget( - route, budget=budget, - amount_msat_for_dest=amount_msat, - cltv_delta_for_dest=paysession.min_final_cltv_delta): + route, + budget=budget, + amount_msat_for_dest=amount_msat, + cltv_delta_for_dest=blinded_path.payinfo.cltv_expiry_delta + routing_info.final_cltv_delta \ + if blinded_path else routing_info.final_cltv_delta, + final_forward_fees=final_forward_fee, + ): self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}") raise FeeBudgetExceeded() shi = SentHtlcInfo( route=route, - payment_secret_orig=paysession.payment_secret, - payment_secret_bucket=paysession.payment_secret, + payment_key=paysession.payment_key, amount_msat=part_amount_msat, bucket_msat=paysession.amount_to_pay, amount_receiver_msat=part_amount_msat, trampoline_fee_level=None, trampoline_route=None, + per_trampoline_payment_secret=None, + blinded_path=blinded_path, ) - routes.append((shi, paysession.min_final_cltv_delta, fwd_trampoline_onion)) + routes.append((shi, routing_info.final_cltv_delta, fwd_trampoline_onion)) except NoPathFound: continue except FeeBudgetExceeded as e: @@ -2564,7 +2610,6 @@ async def create_routes_for_payment( def create_direct_route( self, *, - amount_msat: int, # that final receiver gets channel: Channel, ) -> LNPaymentRoute: self.logger.info(f'create_direct_route {channel.node_id.hex()}') @@ -2591,23 +2636,59 @@ def create_direct_route( def create_route_for_single_htlc( self, *, amount_msat: int, # that final receiver gets - invoice_pubkey: bytes, - r_tags, - invoice_features: int, + routing_info: 'RoutingInfo', + blinded_path: Optional['BlindedPathInfo'], my_sending_channels: List[Channel], full_path: Optional[LNPaymentPath], ) -> LNPaymentRoute: - my_sending_aliases = set(chan.get_local_scid_alias() for chan in my_sending_channels) my_sending_channels = {chan.short_channel_id: chan for chan in my_sending_channels if chan.short_channel_id is not None} + + private_route_edges = {} + if not blinded_path: + assert isinstance(routing_info, UnblindedRoutingInfo) + private_route_edges = self._create_private_route_edges_from_r_tags( + routing_info=routing_info, + my_sending_aliases=my_sending_aliases, + my_sending_channels=my_sending_channels, + ) + + # now find a route, end to end: between us and the recipient (unblinded) or introduction point (blinded) + dest_node = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey + try: + route = self.network.path_finder.find_route( + nodeA=self.node_keypair.pubkey, + nodeB=dest_node, + invoice_amount_msat=amount_msat, + path=full_path, + my_sending_channels=my_sending_channels, + private_route_edges=private_route_edges) + except NoChannelPolicy as e: + raise NoPathFound() from e + if not route: + raise NoPathFound() + assert len(route) > 0 + if route[-1].end_node != dest_node: + route_target = "blinded path introduction point" if isinstance(routing_info, BlindedRoutingInfo) else "invoice pubkey" + raise LNPathInconsistent(f"last node_id != {route_target}") + # add recipient features from invoice if unblinded or blinded path features for IP from blinded payinfo + route[-1].node_features |= blinded_path.payinfo.features if blinded_path else routing_info.invoice_features + return route + + def _create_private_route_edges_from_r_tags( + self, + routing_info: 'UnblindedRoutingInfo', + my_sending_aliases: set[Optional[bytes]], + my_sending_channels: dict['ShortChannelID', 'Channel'], + ) -> Dict[ShortChannelID, RouteEdge]: # Collect all private edges from route hints. # Note: if some route hints are multiple edges long, and these paths cross each other, # we allow our path finding to cross the paths; i.e. the route hints are not isolated. private_route_edges = {} # type: Dict[ShortChannelID, RouteEdge] - for private_path in r_tags: + for private_path in routing_info.r_tags: # we need to shift the node pubkey by one towards the destination: - private_path_nodes = [edge[0] for edge in private_path][1:] + [invoice_pubkey] + private_path_nodes = [edge[0] for edge in private_path][1:] + [routing_info.node_pubkey] private_path_rest = [edge[1:] for edge in private_path] start_node = private_path[0][0] # remove aliases from direct routes @@ -2634,34 +2715,17 @@ def create_route_for_single_htlc( cltv_delta = channel_policy.cltv_delta node_info = self.channel_db.get_node_info_for_node_id(node_id=end_node) route_edge = RouteEdge( - start_node=start_node, - end_node=end_node, - short_channel_id=short_channel_id, - fee_base_msat=fee_base_msat, - fee_proportional_millionths=fee_proportional_millionths, - cltv_delta=cltv_delta, - node_features=node_info.features if node_info else 0) + start_node=start_node, + end_node=end_node, + short_channel_id=short_channel_id, + fee_base_msat=fee_base_msat, + fee_proportional_millionths=fee_proportional_millionths, + cltv_delta=cltv_delta, + node_features=node_info.features if node_info else 0) private_route_edges[route_edge.short_channel_id] = route_edge start_node = end_node - # now find a route, end to end: between us and the recipient - try: - route = self.network.path_finder.find_route( - nodeA=self.node_keypair.pubkey, - nodeB=invoice_pubkey, - invoice_amount_msat=amount_msat, - path=full_path, - my_sending_channels=my_sending_channels, - private_route_edges=private_route_edges) - except NoChannelPolicy as e: - raise NoPathFound() from e - if not route: - raise NoPathFound() - assert len(route) > 0 - if route[-1].end_node != invoice_pubkey: - raise LNPathInconsistent("last node_id != invoice pubkey") - # add features from invoice - route[-1].node_features |= invoice_features - return route + + return private_route_edges def _prepare_invoice_features(self, base_features: LnFeatures, *, amount_msat: Optional[int]) -> LnFeatures: if not all((not c.is_open() or c.is_frozen_for_receiving()) or self.is_trampoline_peer(c.node_id) \ @@ -3178,8 +3242,7 @@ def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int): shi = self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)) if shi and htlc_id in chan.onion_keys: chan.pop_onion_key(htlc_id) - payment_key = payment_hash + shi.payment_secret_orig - paysession = self._paysessions[payment_key] + paysession = self._paysessions[shi.payment_key] q = paysession.sent_htlcs_q htlc_log = HtlcLog( success=True, @@ -3188,7 +3251,7 @@ def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int): trampoline_fee_level=shi.trampoline_fee_level) q.put_nowait(htlc_log) if paysession.can_be_deleted(): - self._paysessions.pop(payment_key) + self._paysessions.pop(shi.payment_key) paysession_active = False else: paysession_active = True @@ -3224,8 +3287,7 @@ def htlc_failed( shi = self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)) if shi and htlc_id in chan.onion_keys: onion_key = chan.pop_onion_key(htlc_id) - payment_okey = payment_hash + shi.payment_secret_orig - paysession = self._paysessions[payment_okey] + paysession = self._paysessions[shi.payment_key] q = paysession.sent_htlcs_q # detect if it is part of a bucket # if yes, wait until the bucket completely failed @@ -3260,7 +3322,7 @@ def htlc_failed( trampoline_fee_level=shi.trampoline_fee_level) q.put_nowait(htlc_log) if paysession.can_be_deleted(): - self._paysessions.pop(payment_okey) + self._paysessions.pop(shi.payment_key) paysession_active = False else: paysession_active = True @@ -4131,7 +4193,7 @@ async def _maybe_forward_trampoline( any_trampoline_onion: ProcessedOnionPacket, # any trampoline onion of the incoming htlc set, they should be similar fw_payment_key: str, ) -> None: - + # todo: blinded payment forwarding 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): @@ -4213,6 +4275,7 @@ async def _maybe_forward_trampoline( min_final_cltv_delta=cltv_budget_for_rest_of_route, payment_secret=payment_secret, trampoline_onion=next_trampoline_onion, + blinded_path_info=None, ) await self.open_channel_just_in_time( next_peer=next_peer, @@ -4228,14 +4291,18 @@ async def _maybe_forward_trampoline( if budget.cltv < 576: raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') + routing_info = UnblindedRoutingInfo( + node_pubkey=outgoing_node_id, + payment_secret=payment_secret, + final_cltv_delta=cltv_budget_for_rest_of_route, + r_tags=r_tags, + invoice_features=LnFeatures(invoice_features), + ) try: await self.pay_to_node( - node_pubkey=outgoing_node_id, + routing_info=routing_info, 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, @@ -4286,24 +4353,45 @@ def create_onion_for_route( total_msat: int, payment_hash: bytes, min_final_cltv_delta: int, - payment_secret: bytes, + payment_secret: Optional[bytes], + blinded_path_info: Optional['BlindedPathInfo'], trampoline_onion: Optional[OnionPacket] = None, ): + if trampoline_onion: + assert not blinded_path_info, "blinded path should be in trampoline onion, not passed to outer onion" + assert payment_secret, "Need outer payment secret for onion including the trampoline onion" + else: + assert bool(payment_secret) ^ bool(blinded_path_info), "requires either blinded path or payment secret" + # 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) + if not blinded_path_info: + # can still be a blinded trampoline payment + 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) + payment_path_pubkeys = [x.node_id for x in route] + else: + assert blinded_path_info.path and blinded_path_info.payinfo, blinded_path_info + hops_data, blinded_node_ids, amount_msat, cltv_abs = calc_hops_data_for_blinded_payment( + route_to_introduction_point=route, + recipient_amount_msat=amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path_info, + ) + payment_path_pubkeys = [x.node_id for x in route] + blinded_node_ids + + assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) self.logger.info(f"pay len(route)={len(route)}. for payment_hash={payment_hash.hex()}") 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') @@ -4318,8 +4406,8 @@ def create_onion_for_route( 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] + + session_key = os.urandom(32) # session_key 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 diff --git a/tests/lnhelpers.py b/tests/lnhelpers.py index a36f9335d967..1202860d3700 100644 --- a/tests/lnhelpers.py +++ b/tests/lnhelpers.py @@ -15,7 +15,7 @@ from electrum.lnpeer import Peer from electrum.lnutil import ( LnFeatures, PaymentFeeBudget, LOCAL, REMOTE, ChannelType, LocalConfig, RemoteConfig, - OnlyPubkeyKeypair, secret_to_pubkey, + OnlyPubkeyKeypair, secret_to_pubkey, UnblindedRoutingInfo, ) from electrum.lnchannel import ChannelState, Channel from electrum.lnrouter import LNPathFinder @@ -155,15 +155,19 @@ async def stop(self): await self.channel_db.stopped_event.wait() async def create_routes_from_invoice(self, amount_msat: int, decoded_invoice: BOLT11Addr, *, full_path=None): + routing_info = UnblindedRoutingInfo( + node_pubkey=decoded_invoice.pubkey.serialize(), + r_tags=decoded_invoice.get_routing_info('r'), + payment_secret=decoded_invoice.payment_secret, + final_cltv_delta=decoded_invoice.get_min_final_cltv_delta() + lnutil.get_final_cltv_offset(), + invoice_features=decoded_invoice.get_features(), + ) paysession = PaySession( + routing_info=routing_info, payment_hash=decoded_invoice.paymenthash, - payment_secret=decoded_invoice.payment_secret, initial_trampoline_fee_level=0, invoice_features=decoded_invoice.get_features(), - r_tags=decoded_invoice.get_routing_info('r'), - min_final_cltv_delta=decoded_invoice.get_min_final_cltv_delta(), amount_to_pay=amount_msat, - invoice_pubkey=decoded_invoice.pubkey.serialize(), uses_trampoline=False, ) payment_key = decoded_invoice.paymenthash + decoded_invoice.payment_secret diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 6f42f96ad7f0..a6d6202355e7 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -1046,11 +1046,12 @@ async def pay(): # alice sends htlc BUT NOT COMMITMENT_SIGNED p1.maybe_send_commitment = lambda x: None route1 = (await w1.create_routes_from_invoice(lnaddr2.get_amount_msat(), decoded_invoice=lnaddr2))[0][0].route - paysession1 = w1._paysessions[lnaddr2.paymenthash + lnaddr2.payment_secret] + paysession1 = w1._paysessions[lnaddr2.paymenthash + pay_req2.get_routing_info().id] shi1 = SentHtlcInfo( route=route1, - payment_secret_orig=lnaddr2.payment_secret, - payment_secret_bucket=lnaddr2.payment_secret, + payment_key=paysession1.payment_key, + per_trampoline_payment_secret=None, + blinded_path=None, amount_msat=lnaddr2.get_amount_msat(), bucket_msat=lnaddr2.get_amount_msat(), amount_receiver_msat=lnaddr2.get_amount_msat(), @@ -1066,11 +1067,12 @@ async def pay(): # bob sends htlc BUT NOT COMMITMENT_SIGNED p2.maybe_send_commitment = lambda x: None route2 = (await w2.create_routes_from_invoice(lnaddr1.get_amount_msat(), decoded_invoice=lnaddr1))[0][0].route - paysession2 = w2._paysessions[lnaddr1.paymenthash + lnaddr1.payment_secret] + paysession2 = w2._paysessions[lnaddr1.paymenthash + pay_req1.get_routing_info().id] shi2 = SentHtlcInfo( route=route2, - payment_secret_orig=lnaddr1.payment_secret, - payment_secret_bucket=lnaddr1.payment_secret, + payment_key=paysession2.payment_key, + per_trampoline_payment_secret=None, + blinded_path=None, amount_msat=lnaddr1.get_amount_msat(), bucket_msat=lnaddr1.get_amount_msat(), amount_receiver_msat=lnaddr1.get_amount_msat(), @@ -1670,17 +1672,18 @@ async def test_channel_usage_after_closing(self): # AssertionError is ok since we shouldn't use old routes, and the # route finding should fail when channel is closed async def f(): + paysession = w1._paysessions[lnaddr.paymenthash + pay_req.get_routing_info().id] shi = SentHtlcInfo( route=route, - payment_secret_orig=lnaddr.payment_secret, - payment_secret_bucket=lnaddr.payment_secret, + payment_key=paysession.payment_key, + per_trampoline_payment_secret=None, + blinded_path=None, amount_msat=amount_msat, bucket_msat=amount_msat, amount_receiver_msat=amount_msat, trampoline_fee_level=None, trampoline_route=None, ) - paysession = w1._paysessions[lnaddr.paymenthash + lnaddr.payment_secret] pay = w1.pay_to_route( sent_htlc_info=shi, paysession=paysession, From fb1ac78bba31a7997994651eb285e55d429123b9 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 7 Oct 2025 13:30:46 +0200 Subject: [PATCH 30/45] add unit test: blinded payment onion --- tests/blinded-payment-onion-test.json | 183 ++++++++++++++++++++++++++ tests/test_blinded_payment_onion.py | 104 +++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 tests/blinded-payment-onion-test.json create mode 100644 tests/test_blinded_payment_onion.py diff --git a/tests/blinded-payment-onion-test.json b/tests/blinded-payment-onion-test.json new file mode 100644 index 000000000000..2d14e1788dbd --- /dev/null +++ b/tests/blinded-payment-onion-test.json @@ -0,0 +1,183 @@ +{ + "comment": "test vector for a payment onion sent to a partially blinded route", + "generate": { + "comment": "This section contains test data for creating a payment onion that sends to the provided blinded route.", + "session_key": "0303030303030303030303030303030303030303030303030303030303030303", + "associated_data": "4242424242424242424242424242424242424242424242424242424242424242", + "final_amount_msat": 100000, + "final_cltv": 749000, + "blinded_payinfo": { + "comment": "total costs for using the blinded path", + "fee_base_msat": 10100, + "fee_proportional_millionths": 251, + "cltv_expiry_delta": 150 + }, + "blinded_route": { + "comment": "This section contains a blinded route that the sender will use for his payment, usually obtained from a Bolt 12 invoice.", + "first_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "first_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "hops": [ + { + "alias": "Bob", + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25", + "encrypted_data": "cd7b00ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c499a2888b49f2e72b19446f7e60a818aa2938d8c625415b992b8928a7321edb8f7cea40de362bed082ad51acc6156dca5532fb68" + }, + { + "alias": "Carol", + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "encrypted_data": "cc0f16524fd7f8bb0f4e8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f570f656a5aaecaf1ee8dc9d0fa1d424759be1932a8f29fac08bc2d2a1ed7159f28b" + }, + { + "alias": "Dave", + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "encrypted_data": "0fa1a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9e49895fd4bcebf6f58d6f61a6d41a9bf5aa4b0453437856632e8255c351873143ddf2bb2b0832b091e1b4" + }, + { + "alias": "Eve", + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "encrypted_data": "da1c7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60722a63c688768042ade22f2c22f5724767d171fd221d3e579e43b354cc72e3ef146ada91a892d95fc48662f5b158add0af457da" + } + ] + }, + "full_route": { + "comment": "The sender adds one normal hop through Alice, who doesn't support blinded payments (and doesn't charge a fee). The sender provides the initial blinding point in Bob's onion payload, and encrypted_data for each node in the blinded route.", + "hops": [ + { + "alias": "Alice", + "pubkey": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619", + "payload": "14020301ae2d04030b6e5e0608000000000000000a", + "tlvs": { + "outgoing_channel_id": "0x0x10", + "amt_to_forward": 110125, + "outgoing_cltv_value": 749150 + } + }, + { + "alias": "Bob", + "pubkey": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "payload": "740a4fcd7b00ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c499a2888b49f2e72b19446f7e60a818aa2938d8c625415b992b8928a7321edb8f7cea40de362bed082ad51acc6156dca5532fb680c21024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "tlvs": { + "current_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "encrypted_recipient_data": { + "padding": "0000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x1", + "payment_relay": { + "cltv_expiry_delta": 50, + "fee_proportional_millionths": 0, + "fee_base_msat": 10000 + }, + "payment_constraints": { + "max_cltv_expiry": 750150, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Carol", + "pubkey": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "payload": "510a4fcc0f16524fd7f8bb0f4e8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f570f656a5aaecaf1ee8dc9d0fa1d424759be1932a8f29fac08bc2d2a1ed7159f28b", + "tlvs": { + "encrypted_recipient_data": { + "short_channel_id": "0x0x2", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "payment_relay": { + "cltv_expiry_delta": 75, + "fee_proportional_millionths": 150, + "fee_base_msat": 100 + }, + "payment_constraints": { + "max_cltv_expiry": 750100, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Dave", + "pubkey": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "payload": "510a4f0fa1a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9e49895fd4bcebf6f58d6f61a6d41a9bf5aa4b0453437856632e8255c351873143ddf2bb2b0832b091e1b4", + "tlvs": { + "encrypted_recipient_data": { + "padding": "00000000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x3", + "payment_relay": { + "cltv_expiry_delta": 25, + "fee_proportional_millionths": 100 + }, + "payment_constraints": { + "max_cltv_expiry": 750025, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Eve", + "pubkey": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "payload": "6002030186a004030b6dc80a4fda1c7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60722a63c688768042ade22f2c22f5724767d171fd221d3e579e43b354cc72e3ef146ada91a892d95fc48662f5b158add0af457da12030249f0", + "tlvs": { + "amt_to_forward": 100000, + "total_amount_msat": 150000, + "outgoing_cltv_value": 749000, + "encrypted_recipient_data": { + "padding": "00000000000000000000000000000000000000000000000000000000", + "path_id": "c9cf92f45ade68345bc20ae672e2012f4af487ed4415", + "payment_constraints": { + "max_cltv_expiry": 750000, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + } + ] + }, + "onion": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337dadf610256c6ab518495dce9cdedf9391e21a71dada75be905267ba82f326c0513dda706908cfee834996700f881b2aed106585d61a2690de4ebe5d56ad2013b520af2a3c49316bc590ee83e8c31b1eb11ff766dad27ca993326b1ed582fb451a2ad87fbf6601134c6341c4a2deb6850e25a355be68dbb6923dc89444fdd74a0f700433b667bda345926099f5547b07e97ad903e8a01566a78ae177366239e793dac719de805565b6d0a1d290e273f705cfc56873f8b5e28225f7ded7a1d4ceffae63f91e477be8c917c786435976102a924ba4ba3de6150c829ce01c25428f2f5d05ef023be7d590ecdf6603730db3948f80ca1ed3d85227e64ef77200b9b557f427b6e1073cfa0e63e4485441768b98ab11ba8104a6cee1d7af7bb5ee9c05cf9cf4718901e92e09dfe5cb3af336a953072391c1e91fc2f4b92e124b38e0c6d17ef6ba7bbe93f02046975bb01b7f766fcfc5a755af11a90cc7eb3505986b56e07a7855534d03b79f0dfbfe645b0d6d4185c038771fd25b800aa26b2ed2e30b1e713659468618a2fea04fcd0473284598f76b11b0d159d343bc9711d3bea8d561547bcc8fff12317c0e7b1ee75bcb8082d762b6417f99d0f71ff7c060f6b564ad6827edaffa72eefcc4ce633a8da8d41c19d8f6aebd8878869eb518ccc16dccae6a94c690957598ce0295c1c46af5d7a2f0955b5400526bfd1430f554562614b5d00feff3946427be520dee629b76b6a9c2b1da6701c8ca628a69d6d40e20dd69d6e879d7a052d9c16f544b49738c7ff3cdd0613e9ed00ead7707702d1a6a0b88de1927a50c36beb78f4ff81e3dd97b706307596eebb363d418a891e1cb4589ce86ce81cdc0e1473d7a7dd5f6bb6e147c1f7c46fa879b4512c25704da6cdbb3c123a72e3585dc07b3e5cbe7fecf3a08426eee8c70ddc46ebf98b0bcb14a08c469cb5cfb6702acc0befd17640fa60244eca491280a95fbbc5833d26e4be70fcf798b55e06eb9fcb156942dcf108236f32a5a6c605687ba4f037eddbb1834dcbcd5293a0b66c621346ca5d893d239c26619b24c71f25cecc275e1ab24436ac01c80c0006fab2d95e82e3a0c3ea02d08ec5b24eb39205c49f4b549dcab7a88962336c4624716902f4e08f2b23cfd324f18405d66e9da3627ac34a6873ba2238386313af20d5a13bbd507fdc73015a17e3bd38fae1145f7f70d7cb8c5e1cdf9cf06d1246592a25d56ec2ae44cd7f75aa7f5f4a2b2ee49a41a26be4fab3f3f2ceb7b08510c5e2b7255326e4c417325b333cafe96dde1314a15dd6779a7d5a8a40622260041e936247eec8ec39ca29a1e18161db37497bdd4447a7d5ef3b8d22a2acd7f486b152bb66d3a15afc41dc9245a8d75e1d33704d4471e417ccc8d31645fdd647a2c191692675cf97664951d6ce98237d78b0962ad1433b5a3e49ddddbf57a391b14dcce00b4d7efe5cbb1e78f30d5ef53d66c381a45e275d2dcf6be559acb3c42494a9a2156eb8dcf03dd92b2ebaa697ea628fa0f75f125e4a7daa10f8dcf56ebaf7814557708c75580fad2bbb33e66ad7a4788a7aaac792aaae76138d7ff09df6a1a1920ddcf22e5e7007b15171b51ff81799355232ce39f7d5ceeaf704255d790041d6390a69f42816cba641ec81faa3d7c0fdec59dfe4ca41f31a692eaffc66b083995d86c575aea4514a3e09e8b3a1fa4d1591a2505f253ad0b6bfd9d87f063d2be414d3a427c0506a88ac5bdbef9b50d73bce876f85c196dca435e210e1d6713695b529ddda3350fb5065a6a8288abd265380917bac8ebbc7d5ced564587471dddf90c22ce6dbadea7e7a6723438d4cf6ac6dae27d033a8cadd77ab262e8defb33445ddb2056ec364c7629c33745e2338" + }, + "decrypt": { + "comment": "This section contains the internal values generated by intermediate nodes when decrypting their payload.", + "hops": [ + { + "alias": "Alice", + "onion": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337dadf610256c6ab518495dce9cdedf9391e21a71dada75be905267ba82f326c0513dda706908cfee834996700f881b2aed106585d61a2690de4ebe5d56ad2013b520af2a3c49316bc590ee83e8c31b1eb11ff766dad27ca993326b1ed582fb451a2ad87fbf6601134c6341c4a2deb6850e25a355be68dbb6923dc89444fdd74a0f700433b667bda345926099f5547b07e97ad903e8a01566a78ae177366239e793dac719de805565b6d0a1d290e273f705cfc56873f8b5e28225f7ded7a1d4ceffae63f91e477be8c917c786435976102a924ba4ba3de6150c829ce01c25428f2f5d05ef023be7d590ecdf6603730db3948f80ca1ed3d85227e64ef77200b9b557f427b6e1073cfa0e63e4485441768b98ab11ba8104a6cee1d7af7bb5ee9c05cf9cf4718901e92e09dfe5cb3af336a953072391c1e91fc2f4b92e124b38e0c6d17ef6ba7bbe93f02046975bb01b7f766fcfc5a755af11a90cc7eb3505986b56e07a7855534d03b79f0dfbfe645b0d6d4185c038771fd25b800aa26b2ed2e30b1e713659468618a2fea04fcd0473284598f76b11b0d159d343bc9711d3bea8d561547bcc8fff12317c0e7b1ee75bcb8082d762b6417f99d0f71ff7c060f6b564ad6827edaffa72eefcc4ce633a8da8d41c19d8f6aebd8878869eb518ccc16dccae6a94c690957598ce0295c1c46af5d7a2f0955b5400526bfd1430f554562614b5d00feff3946427be520dee629b76b6a9c2b1da6701c8ca628a69d6d40e20dd69d6e879d7a052d9c16f544b49738c7ff3cdd0613e9ed00ead7707702d1a6a0b88de1927a50c36beb78f4ff81e3dd97b706307596eebb363d418a891e1cb4589ce86ce81cdc0e1473d7a7dd5f6bb6e147c1f7c46fa879b4512c25704da6cdbb3c123a72e3585dc07b3e5cbe7fecf3a08426eee8c70ddc46ebf98b0bcb14a08c469cb5cfb6702acc0befd17640fa60244eca491280a95fbbc5833d26e4be70fcf798b55e06eb9fcb156942dcf108236f32a5a6c605687ba4f037eddbb1834dcbcd5293a0b66c621346ca5d893d239c26619b24c71f25cecc275e1ab24436ac01c80c0006fab2d95e82e3a0c3ea02d08ec5b24eb39205c49f4b549dcab7a88962336c4624716902f4e08f2b23cfd324f18405d66e9da3627ac34a6873ba2238386313af20d5a13bbd507fdc73015a17e3bd38fae1145f7f70d7cb8c5e1cdf9cf06d1246592a25d56ec2ae44cd7f75aa7f5f4a2b2ee49a41a26be4fab3f3f2ceb7b08510c5e2b7255326e4c417325b333cafe96dde1314a15dd6779a7d5a8a40622260041e936247eec8ec39ca29a1e18161db37497bdd4447a7d5ef3b8d22a2acd7f486b152bb66d3a15afc41dc9245a8d75e1d33704d4471e417ccc8d31645fdd647a2c191692675cf97664951d6ce98237d78b0962ad1433b5a3e49ddddbf57a391b14dcce00b4d7efe5cbb1e78f30d5ef53d66c381a45e275d2dcf6be559acb3c42494a9a2156eb8dcf03dd92b2ebaa697ea628fa0f75f125e4a7daa10f8dcf56ebaf7814557708c75580fad2bbb33e66ad7a4788a7aaac792aaae76138d7ff09df6a1a1920ddcf22e5e7007b15171b51ff81799355232ce39f7d5ceeaf704255d790041d6390a69f42816cba641ec81faa3d7c0fdec59dfe4ca41f31a692eaffc66b083995d86c575aea4514a3e09e8b3a1fa4d1591a2505f253ad0b6bfd9d87f063d2be414d3a427c0506a88ac5bdbef9b50d73bce876f85c196dca435e210e1d6713695b529ddda3350fb5065a6a8288abd265380917bac8ebbc7d5ced564587471dddf90c22ce6dbadea7e7a6723438d4cf6ac6dae27d033a8cadd77ab262e8defb33445ddb2056ec364c7629c33745e2338", + "node_privkey": "4141414141414141414141414141414141414141414141414141414141414141" + }, + { + "alias": "Bob", + "onion": "000280caa47c2a0ea677f6a77529e46caa04212153a8d5f829bee1e7339b17e2e2a9a3461d10472364a4ff12344beb6df96fb0c38ec47d1e956ddff5a665190fcca5ed02c3a3903fd8bbd4a4b95b197867c378b67b08f0624cfe80734ba512869c0fa22099beb1f6f1ea325b07ce7449736d7ffad79178b428d8ea2d7bc6578f12dbd788ef933f3b5ba352797c41f6786c3820c96726acf8bddf2cfa5d9c617d2b0bd5ab7b93f7964c98f44cf47db8422f47d11100236a29579f1cafcd38bd979814e1d2bf6d625edf50e1e21bfaf6268e3180dd7aafd3892da281c6dd53c1c366d0fdaf670b6ad84a38d6e8a3f4a80d132d686fd3b7443bc2250023bdb9303190f74c9220481cf99da30b5ec2bdb5a49028f5014e3eaeaa48429a0c78ebd3bb7c7d582c22b7d547cd269f0c4490373a81bf92687e73dac2075b4bda189ce0be225f5f510655e37a6e724a1415bede0a076b92a882cc2a82878ba67aaedf71454eb42b7f8638df8e21d5f708006e5112e2dc0a4afbcfed9f2c7959be812853ca8e313fbc99a0f38f1ee4479c96ccb836632b0808401db159bd2637f7a664013241e4664e994a0a9a3940115a702c60381e66d291e1ade1be2802e1226e311e3201a7c9682b6bc4354caff3d439adb1dfee53ad3fb3dd5e169d64796853bb323129f41213b166a7cac00f728c3e33bd7e59aa2ac0d1341cdb1532b507a0f446e51022a882ac16405442347b70f78c9b6e122f8e70096a4fae4c0405db5b869e0b7b59b09519c4dbf4d4980483906e837da0bee93f668ffaad37d6a4764211a02f95ad2dc2d942c198796741c20a3baf8efb5a53bd9c1a0148318d60a97d0013ab63269097ea295d62c1426d064f0b31c02e74a348ee0509998e701069f5a1e0c1086aed38d2ec87da69fb57a992d88ace3b4a16b0960f5a94936e2e684a9926cf4f911969a2a5d31fed0c7616d30197848253170e51274278873b11f3f5cc1b04b14aa5812524e4d86cbf08306c2aa671288324d7a009b2be533b1d7d0ce6defeeb630b86a9655f1e6424fcb559ed67457c115fba0d0719374802ea68fab299fd3f273be86fa3d2e7456020db2f47c6ec16c21ce6ec65de495e20af1941a5dcd65d910c1cb93f22e1318c173c645c81aed681c9704a8a541ac3d6ff604f46d0260468acbfec1b771b9eb8cd49a2124468dae786571895a569aae18438eaee6343ab2634823119fa2439634645d12e3b4a748b9cc0398b8416a834eb5d9e5cf619bbfaba4894d1c574c738caf530d0862f4cc75eb52bd3921d2d9edb09940edb1e3776423b0046d870ccdcc5d61f72e0440b97a93eeef21fb246a779d339be301a5971400749d6cc9911dfbf9de8ae86fac83c860fdd0e2bfa40af37c99d50e50fd6e5ae86597a201112ed404042b55e132f243dec481a2adc1d5e4b71e1efdea806ea900b2907ce877742d5ecf700ff3640f737863d0dd7207e462ee8d0e17d52047a88ae7446f419560d23968bf64957949e36953155b0ac2511c66be2890b4036329a21e132efb635297a64431899e0c351e50c6682c9b4d79b5d122466d02cd84f206369417d9c194a9349d3c631d72eb7857a9cd542906fc02ad6cdcf9bcf25ace3d826b6623fa5164351e14d3f0de5c8445a2ba3aae26595d0e31c3e307c1d56d4274f61f056145c1b8d6880872b9b10a8bfa4a923cad2edbcf5c50eba48936ed2bcc0be60eb721a74b46704aaae5ad24e2797852195dfacbb30a777d33b63d4dc4f35cfbe5e88fd1944c55a54fd53581446ea061ad29f4671da819ad7488c5dfc700f5f7a1b2af0d6a6e9d9ffc570a6d3209614ab4dc43728f3f0cd7eb4ce36ccd98936bbcbd32627384434bd01e9c0f93b2a5173fba184685e19b9af78afe876aa4e4b4242382b293133771d95a2bd83fa9c62", + "node_privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "next_path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0" + }, + { + "alias": "Carol", + "onion": "000288b48876fb0dc0d7375233ccaf2910dc0dc81ba52e5a7906f00d75e0d58dbd4bb7c2714870529410735f0951e72cbe981e2e167c0d8f3de33a36e39e78465aea2acad1e23c78b6fd342d63e37d214c912b4a0be344618f779138edc1b42a5ca3218ca2fea4be427f6cd0d387160db2bf6c2ba8e82941c8cf3626bd6bed7187f633012ef49df38f6b12963cb639e9eed1b9d269dcebcbd0b25287aa536ec85e7320b02e193122199a745ccbaaebd37f5d4b71f52f9b50feeb793eeef56924a046bc5e7003f6253e0284a8d3fe2e42c3564050f1e753cd32cc258ac0ffa6e05eecad5ba1286f78252e60dd884a65405ab673a85ba52adfa65c1086d4bb37ba2e0848adb2b04379775ad798492b14e8997f30ffa9cf5d432bdf5b246fce008fd876399beed827db58195f4f6192f6ff4ec63cb17fdcb497cb7aec26846a71dd8dca02fc3bb14dd7231a4d62a981bec54b71eb20331096dfa214a0ff4489ee96db663826ae8c850e9f06baa52a47b8eb576363f97e742aab2dc616acc6e74588e1d2ac16694febc90abaf5b1c684163c0e615a68d32633f01934adc8c6bf91fa3fd7aad033b7596d60402494e45e2c1632c40f7bfbd88a81a896a1d28ed6338c83e1eeaa467945d59998eb456c95f94bf1892e8f326ec2d5e0196b7073f106febc6ab8ca5bcc23f77ffc819bc1b5debce418ccc7d8391bbf33bceee6110beba170121bd99f54c956e64970bdab31227b03ee0ea3f01fbd9bd74015f6f82d04fab072e8f85f4370d09f41ee3e48eb959767bd989abb4eea42c4daa0437a7f747d7f9b70eb87b9f9b0b6f283b8205912601a432999b8869fd9fe5bad3572edac24da7184f9298f21ff60923db277264d29c846dd2f228f6fc53b6b60364237de64773f803f174ed10229c374f603ccc5fd3a62cb413ffe6f5630dc646bb33f231b2350537ec39e5d3f2fe1a1cb019ed0b18ad14019cad27afcca8ad70387ca110394c0432774f1aa1fa404b2e086c84a55388d3bd102501c78ef925cce89d76fa04c3f20f2d1f0ce507ac8b37b7913e3949ba12bbc5a4f6bac37c2415622d365bc8b83709a28e3d46f3850c89a3ff4d027fef6e3e4ce5c6c85f663c7eaec3c9730106fb82f53249a905533cfabee812aae51965b24b42f7ab471967bc8e73354e69141ee26a1f03684d5fb9c256a34de8257210e0390dd3962db521ae0a3bdab28300610ab2a634b699e5f092da5a061609ef6414bd805c8171f54ad6f285fb64ce0becca0b61188badcf8ef21190dad629e3fb3e89f55ebba829919540ebf5f8ae4283836d3c9133c1ca3365f6b9394916730411650686e0c2ab9c53b6cda9efdd5cfcb53ba9b6962bb6aa49d0a83a87460b60a9c7d2643ee99afe652883795f14014ec5df61b1e30c041c1fa6487f3c82f1ded5f83ffbef5017e197b7fb77be3b36e284a15e57d45bf9316dcaf97eb78ee4642b731ba05c5063bce1333fab4af6da97c80a96ee599b4df823efbedc250c0abba9783da7ddf2414b2a4774ff2880a7dc6791103e18b8631e39743cf9e87aed71700daa5dc72fdae520324741f92ea3d510ff555dea5e45f15cda87272d4559a12d4777680acb06993840e3c748da82c16cae556015fb2acd0335da11a3388575394048ab71199793ab706abc9d68add2075d79a5cc0f779845ee8b98951be61fd293d6c15b9d4653935bf17cf50bd31f8b79e60dba0e7fd6864754fd94262485a4f65e7eb3e1922f51b1a4dd2b4fd2c20d94d1213fbe90bd603dfc7e15176382e3ce0f43f980d44d23bf3c57f54a15f42c171a8f2511e28ac178c6f01396e50397a57ffb09c5e6c315bd3ae7983577c1a0386c6d5d9a2223438e321b0fedfdee58fa452d57dc11a256834bb49ac9deeec88e4bf563c7340f44a240caec941c7e50f09cf", + "node_privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "next_path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + }, + { + "alias": "Dave", + "onion": "0003f25471c0f2ff549a7fd7859100306bb6c294c209f34c77f782897f184b967c498efc246bdb8e060a6d1cf8dd0d4d732e33311fb96c9e9f1274005fa3d08b41704a1b7224c6300a7caead7baa0a8263eba2e0de6956ee8e4a1958264f47e4cf20d194eb576f5bd249ee4fece563f80fd76dc3eaca8f956188406d83195752b5c90c4b2a5e7ac3a8d5c62b17b551aff48ef6842a7e9326832c9a4a2fd415011150a9e71beb901fd9747bac8add1c694b612730dc86b5b19a0bbbc675947a953316e3303d7b30c182f94def9206671edac9a3ec3e52d28fc28247a1c73ab751bf61c82c3950f617e758f79bd0ba294defb20466eaf1e801462046baad3aec3e5b8868a7b037f23d73a47a7e74c77107334f37388cff863e452820c61d89728fa75c84bc7cdfc06dcdd1911f5f803353926d073efd65251380e174913aae03318ea5b6f0ec83998c55ab99bef62803ea2da9f6d1ea892b90efc4f8ffb685a5201a781da2e6ac5923645638c9709ae32171a00c0cd3d8c7eedfb06b4eedc7d3e566987e2e3805a038f21d78ded5d6c7137a5e8e592f3180ee4d5f4e1289176f67fc38690d0958bc82e240b72b10577f340f1e14b8633f0b6d9729ff4618be2a972400a015a871ba33be70335f652a8d70f2bd32421d6ac2af781d667dad787d6aef4505a15d046579e46eebe757444cffca6d0610f0dd36a7ce57af969bd0c3f7006298ef406a25f689daf58f875d44d2423ebf195b503f11c37c506ea6abe50a463f7bb5e9b964604d832724de768513f6b38bf71715e1feea8a6e86797788d487146891564919da1372016ed8f08c7fcbff66a4a65a3d0fcd8e3daac6eba41f5d65ef2d8075364a9e78b3a273549f6eac4abb72e912e237990367e0d43e89945f8ac3907be5a6c662139485a50cb5ce3f0ba08586c39f6c515368ec3f91b72295f1b7a73a9df322ae9a45d363d6c616be3300083764cbdee31221f25a318f095feacb09f957c96db30fccca47a0215b576c3ed925a0bad05d6400abe318c11f36628c387a4ee38832182cd44b3cd48e5422c1f1e3b57218dfe72c611f5415127720e60f6e2400607e61841b76de1704bcbeb0daf1377ccb2253916de2b6d490bb71ba0a44fea2e94f2423d723934557d5905e01b2b80232a884e258d46dc92ea11e0818d0ece5b914f02049866e151801ab8c9aea155479b354dc91151fb9ba43277458f9760dd859faaa139e3b9ab36a1dbc36a93ef2c90598b20cb30ef3c4f23a2d6178b4d1da668fb328a25d84d30a132d9f2a6a988cbe2e5c2be01cb6db4b4725a50d6cdacf5fb083e7d650a25bec1407fbc047d26076c7596429a29606ad527e97ef0824ad6c1b05831a3e5b71c63a528918a3301cdd4061fc1fcce3da601961f2602a2b002ac8404125c2d52666263858a923e197efcda873c32d86897352e4f2264ad6a1b48acc0fe78ff55cb442cb2bb5fa2880810e1d00aa0247057fb80b7ed36cf9647af41b44ee4a63ee2d6f652526404572520a7d2d9dcde4e62df0c3be89f8471550594cdd16a51a9cacc58729c092c68506162fe65edc2314055d389f724ced189d826a546b5c4d08a43d977b3cf033de5760b71a7cc38ee5851592031aafb467a89b3b6c7ed67b15d44c48d6baedce3e95e08ec7c55038f3eba90ccb900895734f0fb7efe54961ce493369cc56416898a9bed7c2482871c15a7f1eb5ed17c33657fc31333539c2dfb59461af09e7049228113b5c9feea5a6e9959c18c51b19c90995afb9c76f2c0c820964cd7989c993a73925818a656c6a18dcd1a1e3782b2eae06dd5a41250ec2d1c203626ab9920c1673339eff04b1eb0cab85ef5909f571f9b83cdf21697c9f5cfa1c76e7bca955510e2126b3bb989a4ac21cf948f965e48bc363d2997437797b4f770e8b65", + "node_privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "next_path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a" + }, + { + "alias": "Eve", + "onion": "0002ef43c4dfe9aee14248c445406ac7980474ce106c504d9025f57963739130adfd06eb26201baee8866af2d1b7a7ab26595349dad002af0590193aaa8f400ab394f5994ec831aeeecb64421c566e3556cbdd7e7e50deb1fc49fd5e007308ab6494415514abff978899623f9b6065ca1e243bb78170118e8b8c8b53b750b59cc1ec017d167adbb3aabab7c2d84fbf94f5d827239f4c2b9d2c3cfe68fe5641f25e386202a4b6edff2a71e700229df7230c8ca31bd5588f04799e9640c9c20a47cba713f3cc5ad3202e14bb520880f2a8409d8e7835cae21b48a651c2d47fe6af785889ab98f1416f6e4ad67a66ae681e9a8828bad3f9b6890221c4a7ec80531d6b63eb30843f613ce644795bc8bcee60e8f7b36f3fd04de762f103c52efaf36a2f3bbbaac482d6271dc4180c10bcc076c04d06ea7fd8fb6a647e0e10523b05da2d89e4139fb55c2315cd01bdcbd57587fef8442d7ff5620630fd2d2e79739d90be811bf2cba60415d6cba2cea14ba1859f3122cd905c4e12e3e2a1ab6fab54b2ec40e434626e2d3c3195c02c82a8bd64d226c2328ac72ca12197d9908eaf54333717448ce6ed73adc0ac05e2ee1d735131d87918beb8995993dc8f63fe10f2c8eba2be7ab8bb44d9f78f59ef3e4c180bd75e4eef2381450c6f0480d543997305f1d07815993b5aca8d88d474966d9abec93bb069a16aa2da75b87f94576e01d08a17d3e0e3d0370f010733a7d7affb12cdf94c259a62607fce71003535c4727305de5ff7bba3840922844b3a45f62c29715fccf440517ef121450f6962396fba9b07036d085582405dcae6ee95964b66bc7c85b8d02d90091500db3cebf6de584f86b7b55335a8c9aa26381b00747f055cc458a2cadfccf9c29702bf941447beaca6583cca09492a57d4b03b2ca00dbaf41dfd6a9b249381626a7debe475735a7e39e77a363eccf14669046f656cc09ad448da8d8b545e6a604f46dc481786d09a94c63cf23f49ba367d2929466364dbce2a8ffce3dadf8f4cef8a56e1fefa1a3304a953fe83018e57d8a95694b02d994fea2630a9a3d5f1e2f6d6142d503ec4152871f7122d7e566a03261f554639e7a759e0e73846f71d5cace37d91336fc9ca9396bf64ca2cf45fa2db779b3b5c63b04f1c0c1fb79fdfcf5a82b0202df934ae1720a7ce1e047cbec3f82737b50168c974f4623cacce87e3f5bd5232caca7956d28ffedcf11ac5998662c5f6b13c6126584ca2e894d3fcbad4d130bbe22e88a135e0020cdd43853e0b3af3800e9544854d211e873cf68ab683578d501d69ec5dc7fce42ac436d58243880c1b88227b0681c6c9dd8a8ad0793202b15ab63b787b748e258da3e68d0e649fc4ac081a71de8adbc891c113d5f722686b6ac4ed9e3cc247bc4a4643416f480627e9de20f7307f434a499f5c6951c2e8b3ff51d455bf65ceb5ee3dee47b968ac2642e13d8a68f903b73627c2e75788fecca5836371a908eea4f1ea44db2315bc185f77e478efeaaa4da2da13fe7aeaa79ed1d04876a8b2b7b333c5de8c4c9a50274c2eb7b9bd2a3630c57173174781fc9785235f830cefa1c82080eaffdef257f18eedc9ddfd25a696a11a3dc56cd836be72f5f4a2cbb6316d5d3b1ad91a7ec7d877f28d2c29a5525b0b24362699281b0e3b48f38caf1085045fe9089f9e6fb29e4b47aa4cecf68c9bf72073469bd9beeea5e88bfe554cb6a81231149ba7fe7784c154fd8b0f9179ecdf1e9fd5c2939ec1ab16df9cbe9359101ebce933d4f65d3f66f87afaecfe9c046b52f4878b6c430329df7bd879fba8864fcbd9b782bf545734699b9b5a66b466dcedc0c9368803b5b0f1232950cef398ad3e057a5db964bd3e5c8a5717b30b41601a4f11ad63afe404cb6f1e8ea5fd7a8e085b65ca5136146febf4d47928dcc9a9e0", + "node_privkey": "4545454545454545454545454545454545454545454545454545454545454545", + "next_path_key": "038fc6859a402b96ce4998c537c823d6ab94d1598fca02c788ba5dd79fbae83589" + } + ] + } +} diff --git a/tests/test_blinded_payment_onion.py b/tests/test_blinded_payment_onion.py new file mode 100644 index 000000000000..203b4141faea --- /dev/null +++ b/tests/test_blinded_payment_onion.py @@ -0,0 +1,104 @@ +import os + +from electrum.lnonion import ( + new_onion_packet, calc_hops_data_for_blinded_payment, OnionPacket, BlindedPathInfo, BlindedPath, + BlindedPathHop, BlindedPayInfo, +) +from electrum.lnutil import LnFeatures, ShortChannelID +from electrum.util import read_json_file, bfh +from electrum.lnrouter import RouteEdge + +from tests import ElectrumTestCase + +# test vectors https://github.com/lightning/bolts/pull/765/files +path = os.path.join(os.path.dirname(__file__), 'blinded-payment-onion-test.json') +test_vectors = read_json_file(path) +generate = test_vectors['generate'] +full_route = generate['full_route'] +alice_hop = full_route['hops'][0] + +first_node_id = bfh(generate['blinded_route']['first_node_id']) +first_path_key = bfh(generate['blinded_route']['first_path_key']) +blinded_route_hops = generate['blinded_route']['hops'] + +blinded_path_hops = [BlindedPathHop( + blinded_node_id=bfh(hop['blinded_node_id']), + encrypted_recipient_data=bfh(hop['encrypted_data']), + enclen=len(bfh(hop['encrypted_data'])), +) for hop in blinded_route_hops] + +blinded_path = BlindedPath( + path=blinded_path_hops, + first_path_key=first_path_key, + first_node_id=bytes(32), + num_hops=bytes([len(blinded_path_hops)]) +) + +# blinded_payinfo = BlindedPayInfo.from_dict(generate['blinded_payinfo']) +blinded_payinfo = BlindedPayInfo( + fee_base_msat=generate['blinded_payinfo']['fee_base_msat'], + fee_proportional_millionths=generate['blinded_payinfo']['fee_proportional_millionths'], + cltv_expiry_delta=generate['blinded_payinfo']['cltv_expiry_delta'], + htlc_minimum_msat=0, + htlc_maximum_msat=999999999999999, + features=LnFeatures(0), +) + +ONION_MESSAGE_PACKET = bfh(generate['onion']) +session_key = bfh(generate['session_key']) +associated_data = bfh(generate['associated_data']) + + +class TestPaymentRouteBlinding(ElectrumTestCase): + + def test_blinded_payment_onion(self): + # us -> alice -> bob (introduction point) -> remaining blinded path hops + # route[0] is us -> alice edge, skipped by calc_hops_data_for_blinded_payment as we don't need a payload for ourselves + alice_outgoing_channel_id = ShortChannelID.from_str(alice_hop["tlvs"]["outgoing_channel_id"]) + route = [ + RouteEdge( + start_node=bytes(33), # our (sender's) pubkey + end_node=bfh(alice_hop['pubkey']), + short_channel_id=ShortChannelID(0), # sender's channel, not used in payloads + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0), + RouteEdge( + start_node=bfh(alice_hop['pubkey']), + end_node=first_node_id, # Bob (introduction point) + short_channel_id=alice_outgoing_channel_id, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0), + ] + total_msat = 150000 + amount_msat = generate["final_amount_msat"] + final_cltv = generate["final_cltv"] + hops_data, blinded_hops_pubkeys, amt, cltv_abs = calc_hops_data_for_blinded_payment( + route_to_introduction_point=route, + recipient_amount_msat=amount_msat, + final_cltv_abs=final_cltv, + total_msat=total_msat, + invoice_blinded_path_info=BlindedPathInfo(path=blinded_path, payinfo=blinded_payinfo), + ) + + # route provides unblinded pubkeys (Alice, Bob) + payment_path_pubkeys = [x.node_id for x in route] + blinded_hops_pubkeys + + # assert payloads + for i, h in enumerate(hops_data): + payload = h.to_bytes().hex()[0:-64] + ref_payload = generate['full_route']['hops'][i]['payload'] + self.assertEqual(payload, ref_payload) + + packet = new_onion_packet( + payment_path_pubkeys, + session_key, + hops_data, + associated_data=associated_data, + ) + # test final packet + ref_packet = OnionPacket.from_bytes(ONION_MESSAGE_PACKET) + self.assertEqual(packet.to_bytes(), ONION_MESSAGE_PACKET) From 89fbacc9de5f5bf4c29cc21f7f74d07be6a6f244 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 8 Oct 2025 14:34:34 +0200 Subject: [PATCH 31/45] bolt12/lnworker: implement creating offer Implement initial LNWallet method `create_offer` to create bolt12 offer for sharing with external parties. Add CLI command `add_offer` to use this method. --- electrum/commands.py | 31 +++++++++++++++++++++++ electrum/lnworker.py | 53 +++++++++++++++++++++++++++++++++++++++ electrum/onion_message.py | 1 + tests/test_commands.py | 26 +++++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/electrum/commands.py b/electrum/commands.py index 9b0939451d7d..86aa63b47069 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1388,6 +1388,37 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force req = wallet.get_request(key) return wallet.export_request(req) + @command('wnl') + async def add_lightning_offer( + self, + amount: Optional[Decimal] = None, + description: Optional[str] = None, + relative_expiry: Optional[int] = None, + issuer_name: Optional[str] = None, + allow_unblinded: bool = False, + wallet: Abstract_Wallet = None + ): + """Create a bolt12 offer. + + arg:decimal:amount:Requested amount (in btc) + arg:str:description:Description of the request + arg:int:relative_expiry:Time in seconds. + arg:str:issuer_name:Issuer name string + arg:bool:allow_unblinded:Allow revealing node id for unblinded offers + """ + amount_msat = satoshis(amount) * 1000 if amount else None + offer = wallet.lnworker.create_offer( + amount_msat=amount_msat, + description=description, + relative_expiry=relative_expiry, + issuer_name=issuer_name, + allow_unblinded=allow_unblinded, + ) + + return { + 'offer': offer.encode(as_bech32=True) + } + @command('wnl') async def add_hold_invoice( self, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index b35cb750f50b..b26cb8dc4ca1 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4625,3 +4625,56 @@ def _verify_bolt12_invoice_requested_by_us(self, invoice: BOLT12Invoice) -> bool if not util.constant_time_compare(sha256(our_sig), invreq_sig_digest): return False return True + + def create_offer( + self, + *, + amount_msat: Optional[int] = None, + description: Optional[str] = None, + relative_expiry: Optional[int] = None, + issuer_name: Optional[str] = None, + allow_unblinded: bool = False, + ) -> 'BOLT12Offer': + """ + Create n bolt12 offer. + - allow_unblinded only makes sense if node_id is public, or for testing with direct electrum peer. + """ + # We always set an offer_issuer_id which we then can use as invoice_node_id for the invoice creation, + # otherwise we would need to use the blinded node id (and its secret) on which the invoice_request arrived to + # sign the corresponding invoice. This approach is simpler to reason about and implement. + # If we include blinded paths in this offer the path_id is the random private key for offer_issuer_id. + # If we don't include blinded paths (and allow_unblinded is True) we simply use the node keypair. + random_offer_issuer_id_key = ECPrivkey.generate_random_key() + path_id = random_offer_issuer_id_key.get_secret_bytes() + reply_path_infos = None + try: + reply_path_infos = get_blinded_reply_paths(self, path_id) + except NoOnionMessagePeers as e: + self.logger.debug(f"create_offer: no onion message peers: {str(e)}") + if not allow_unblinded: + raise + + reply_paths = tuple(p.path for p in reply_path_infos) if reply_path_infos else None + chains = [constants.net.rev_genesis_bytes()] if constants.net != constants.BitcoinMainnet else None + + # To be able to verify the offer fields inside a received invoice_request we first serialize an offer + # without metadata (or some randomness), then create a hmac on it which will be included as metadata of the final offer. + offer = BOLT12Offer( + # add some randomness if there are no (random) reply_paths (unblinded offer) so we create unique offers + offer_metadata=os.urandom(8) if not reply_paths else b'', + # description must not be None if amount is given (spec) + offer_description=(description or '') if amount_msat is not None else description, + offer_chains=chains, + offer_amount=amount_msat, + offer_absolute_expiry=int(time.time()) + relative_expiry if relative_expiry else None, + offer_issuer_id=self.node_keypair.pubkey if not reply_paths else random_offer_issuer_id_key.get_public_key_bytes(), + offer_issuer=issuer_name, + offer_paths=reply_paths, + ) + encoded_offer = offer.encode(as_bech32=False) + mac = hmac_oneshot(key=self.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + new_metadata = offer.offer_metadata + mac + offer = dataclasses.replace(offer, offer_metadata=new_metadata) + + assert allow_unblinded if offer.offer_issuer_id == self.node_keypair.pubkey else offer.offer_paths, offer + return offer diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 00d82dc99d40..2895945c1055 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -423,6 +423,7 @@ def get_blinded_paths_to_me( - reply_path introduction points are direct peers only (TODO: longer paths) """ # TODO: build longer paths and/or add dummy hops to increase privacy + assert final_recipient_data['path_id']['data'], f"missing path_id: {final_recipient_data}" if not my_channels: my_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] diff --git a/tests/test_commands.py b/tests/test_commands.py index 85896d708f18..130f11f81b3f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -23,6 +23,7 @@ from electrum.util import UserFacingException, NotEnoughFunds from electrum.crypto import sha256 from electrum.bolt11 import decode_bolt11_invoice +from electrum.bolt12 import BOLT12Offer from electrum.daemon import Daemon from electrum import json_db @@ -914,3 +915,28 @@ async def test_decode_bolt12(self, *mock_args): # set back to Testnet constants.net = prev_net + + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') + async def test_add_lightning_offer(self, *mock_args): + wallet: Abstract_Wallet = restore_wallet_from_text__for_unittest( + 'disagree rug lemon bean unaware square alone beach tennis exhibit fix mimic', + path='if_this_exists_mocking_failed_648151893', + config=self.config)['wallet'] + cmds = Commands(config=self.config) + + bolt12_offer = (await cmds.add_lightning_offer( + amount=Decimal(0.001), + description="test cli", + relative_expiry=None, + issuer_name="me", + allow_unblinded=True, + wallet=wallet, + ))['offer'] + decoded_offer = BOLT12Offer.decode(bolt12_offer) + self.assertEqual(len(decoded_offer.offer_metadata), 40) + self.assertEqual(decoded_offer.offer_amount, 100000000) + self.assertEqual(decoded_offer.offer_description, "test cli") + self.assertEqual(decoded_offer.offer_absolute_expiry, None) + self.assertEqual(decoded_offer.offer_issuer, "me") + self.assertEqual(decoded_offer.offer_issuer_id, wallet.lnworker.node_keypair.pubkey) + self.assertEqual(decoded_offer.offer_paths, None) From ffb1d1ef242710050d23e827fbe8c207b02f6aa7 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 13:24:43 +0200 Subject: [PATCH 32/45] test_lnwallet: unittest offer creation --- tests/test_lnwallet.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 3321becd5e45..7dbfa7275961 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -13,6 +13,7 @@ from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum import bitcoin import electrum.trampoline +from electrum.onion_message import NoOnionMessagePeers from electrum import constants from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice from electrum.crypto import sha256, hmac_oneshot @@ -662,3 +663,48 @@ def test_create_bolt12_invoice_request_without_offer(self): signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) self.assertEqual(sha256(resigned), stored_digest) + + def test_create_offer(self): + wallet = self.lnwallet_anchors + + amount_msat = 10_000 + memo = "test" + expiry = 4000 + issuer = "pizza italy" + with self.assertRaises(NoOnionMessagePeers): + wallet.create_offer( + amount_msat=amount_msat, + description=memo, + relative_expiry=expiry, + issuer_name=issuer, + allow_unblinded=False, + ) + + offer = wallet.create_offer( + amount_msat=amount_msat, + description=memo, + relative_expiry=expiry, + issuer_name=issuer, + allow_unblinded=True, + ) + + self.assertEqual(offer.offer_amount, amount_msat) + self.assertEqual(offer.offer_description, memo) + self.assertEqual(offer.offer_issuer, issuer) + self.assertEqual(offer.offer_issuer_id, wallet.node_keypair.pubkey) + self.assertIsNone(offer.offer_paths) + absolute_expiry = int(time.time()) + expiry + self.assertLess(offer.offer_absolute_expiry - absolute_expiry, 2) + + # test that offer metadata is a mac of encoded offer + mac, remaining_metadata = offer.offer_metadata[-32:], offer.offer_metadata[:-32] + offer = dataclasses.replace(offer, offer_metadata=remaining_metadata) + encoded_offer = offer.encode(as_bech32=False) + our_mac = hmac_oneshot(key=wallet.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + self.assertEqual(our_mac, mac) + + # assert two offers signed with node keypair are not equal + # this allows for example to blacklist/revoke certain offers + offer1 = wallet.create_offer(allow_unblinded=True).encode() + offer2 = wallet.create_offer(allow_unblinded=True).encode() + self.assertNotEqual(offer1, offer2) From 115d6e48a2688d42330718cf671a333e6c1c54ba Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 8 Oct 2025 15:47:36 +0100 Subject: [PATCH 33/45] bolt12: handle 'invoice_request' onion message payloads Co-Authored-By: Sander van Grieken --- electrum/bolt12.py | 212 +++++++++++++++++++++++++++++++++- electrum/lnpeer.py | 49 +++++--- electrum/lnworker.py | 236 +++++++++++++++++++++++++++++++++++++- electrum/onion_message.py | 14 ++- tests/test_bolt12.py | 57 ++++++++- tests/test_lnwallet.py | 51 +++++++- 6 files changed, 588 insertions(+), 31 deletions(-) diff --git a/electrum/bolt12.py b/electrum/bolt12.py index ee092be4506e..f4b97a2ce331 100644 --- a/electrum/bolt12.py +++ b/electrum/bolt12.py @@ -29,15 +29,15 @@ from dataclasses import dataclass, field, asdict, fields from functools import cached_property import re -from typing import Optional, Tuple, Iterable, Type, TypeVar, Any, ClassVar, Sequence +from typing import Optional, Tuple, Iterable, Type, TypeVar, Any, ClassVar from abc import ABC, abstractmethod import electrum_ecc as ecc from . import constants from .util import chunks -from .lnmsg import OnionWireSerializer -from .lnutil import LnFeatures, validate_features, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, LnFeatureContexts +from .lnmsg import OnionWireSerializer, write_bigsize_int, read_bigsize_int +from .lnutil import LnFeatures, validate_features, MIN_FINAL_CLTV_DELTA_ACCEPTED, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, LnFeatureContexts from .onion_message import BlindedPath, BlindedPayInfo from .segwit_addr import ( bech32_decode, convertbits, bech32_encode, Encoding, INVALID_BECH32, @@ -259,6 +259,7 @@ def __post_init__(self): # MUST calculate the expected amount using the offer_amount if self.offer_currency and self.offer_currency.upper() != 'BTC': # TODO: if offer_currency is not the invreq_chain currency, convert to the invreq_chain currency + # also adapt invoice_amount_msat property below raise NotImplementedError("no fx conversion support yet, will this be used?") # if invreq_quantity is present, multiply by invreq_quantity.quantity if self.invreq_quantity: @@ -292,6 +293,20 @@ def __post_init__(self): if not validate_bip_353_name(name, domain): raise ValueError(f"invalid bip 353 name: {self.invreq_bip_353_name}") + @property + def invoice_amount_msat(self) -> int: + # this relies on the __post_init__ validation + if isinstance(self, BOLT12Invoice): + return self.invoice_amount + assert isinstance(self, BOLT12InvoiceRequest) + if self.invreq_amount is not None: + return self.invreq_amount + expected_amount = self.offer_amount + assert expected_amount is not None + if self.invreq_quantity: + expected_amount *= self.invreq_quantity + return expected_amount + @classmethod def deserialize(cls, ir: dict) -> 'BOLT12InvoiceRequest': ir = copy.deepcopy(ir) @@ -507,12 +522,199 @@ def bolt12_tlv_bytes_to_bech32(bolt12_tlv: bytes, bolt12_type: type[BOLT12Base]) return bech32_encode(Encoding.BECH32, bolt12_type.hrp, bech32_data, with_checksum=False) +@dataclass(frozen=True, kw_only=True) +class BOLT12InvoicePathIDPayload: + """ + Payment information embedded into the BOLT12Invoice blinded path's path_id so we can hand out invoices + statelessly and reconstruct the full payment context when the actual HTLCs arrive. + + TODO: If this is too large some fields might need to be removed (esp the descriptions texts). + We could also cache some less important things like the description + in memory, assuming that the Invoice is usually paid right after being requested. + A filled path_id payload can reach ~270 bytes realistically. + """ + VERSION: ClassVar[bytes] = b'\x01' + + amount_msat: int + created_at: int + relative_expiry: int + payment_preimage: bytes + min_final_cltv_expiry_delta: int + invoice_features: LnFeatures + payer_id: bytes + offer_metadata_digest: Optional[bytes] = None # allows us to associate the payment with an offer (if we'd persist/cache offers) + quantity: Optional[int] = None + payer_note: Optional[str] = None + description: Optional[str] = None + + def __post_init__(self): # some sanity checks + assert isinstance(self.payment_preimage, bytes) and len(self.payment_preimage) == 32, self.payment_preimage + assert isinstance(self.payer_id, bytes) and len(self.payer_id) == 33, self.payer_id + assert self.amount_msat and self.created_at and self.relative_expiry, (self.amount_msat, self.created_at, self.relative_expiry) + assert self.min_final_cltv_expiry_delta >= MIN_FINAL_CLTV_DELTA_ACCEPTED + validate_features(self.invoice_features, context=LnFeatureContexts.BOLT12_INVOICE) + + def encode(self) -> bytes: + flags = 0 + if self.quantity is not None: + assert self.quantity >= 0 + flags |= 0b0001 + if self.offer_metadata_digest is not None: # we could truncate it to save some space? + assert isinstance(self.offer_metadata_digest, bytes) and len(self.offer_metadata_digest) == 32 + flags |= 0b0010 + + payer_note = self.payer_note[:64] if self.payer_note is not None else None + description = self.description[:64] if self.description is not None else None + if payer_note is not None: + flags |= 0b0100 + if description is not None: + flags |= 0b1000 + + with io.BytesIO() as fd: + fd.write(self.VERSION) + fd.write(self.payment_preimage) + fd.write(self.payer_id) + fd.write(write_bigsize_int(self.amount_msat)) + fd.write(write_bigsize_int(self.created_at)) + fd.write(write_bigsize_int(self.relative_expiry)) + fd.write(write_bigsize_int(self.min_final_cltv_expiry_delta)) + features_bytes = self.invoice_features.to_tlv_bytes() + fd.write(write_bigsize_int(len(features_bytes))) + fd.write(features_bytes) + fd.write(bytes([flags])) + if self.quantity is not None: + fd.write(write_bigsize_int(self.quantity)) + if self.offer_metadata_digest is not None: + fd.write(self.offer_metadata_digest) + if payer_note is not None: + payer_note_bytes = payer_note.encode('utf-8') + fd.write(write_bigsize_int(len(payer_note_bytes))) + fd.write(payer_note_bytes) + if description is not None: + description_bytes = description.encode('utf-8') + fd.write(write_bigsize_int(len(description_bytes))) + fd.write(description_bytes) + return fd.getvalue() + + @classmethod + def decode(cls, data: bytes) -> 'BOLT12InvoicePathIDPayload': + with io.BytesIO(data) as fd: + version = fd.read(1) + if version != cls.VERSION: + raise ValueError(f"unsupported version: {version!r}") + + payment_preimage = fd.read(32) + if len(payment_preimage) != 32: + raise ValueError("path_id truncated: payment_preimage") + payer_id = fd.read(33) + if len(payer_id) != 33: + raise ValueError("path_id truncated: payer_id") + + amount_msat = read_bigsize_int(fd) + created_at = read_bigsize_int(fd) + relative_expiry = read_bigsize_int(fd) + min_final_cltv_expiry_delta = read_bigsize_int(fd) + if not amount_msat or not created_at or not relative_expiry or not min_final_cltv_expiry_delta: + raise ValueError("path_id truncated: amount_msat, created_at, relative_expiry or min_final_cltv_expiry_delta") + + features_len = read_bigsize_int(fd) + if features_len is None: + raise ValueError("path_id truncated: features_len") + features_bytes = fd.read(features_len) + if len(features_bytes) != features_len: + raise ValueError("path_id truncated: invoice_features") + invoice_features = LnFeatures(int.from_bytes(features_bytes, byteorder="big", signed=False)) + + flags_byte = fd.read(1) + if len(flags_byte) != 1: + raise ValueError("path_id truncated: flags") + flags = flags_byte[0] + + quantity: Optional[int] = None + offer_metadata_digest: Optional[bytes] = None + payer_note: Optional[str] = None + description: Optional[str] = None + + if flags & 0b0001: + quantity = read_bigsize_int(fd) + if quantity is None: + raise ValueError("path_id truncated: quantity") + if flags & 0b0010: + offer_metadata_digest = fd.read(32) + if len(offer_metadata_digest) != 32: + raise ValueError("path_id truncated: offer_metadata_digest") + if flags & 0b0100: + note_len = read_bigsize_int(fd) + if note_len is None: + raise ValueError("path_id truncated: payer_note_len") + note_bytes = fd.read(note_len) + if len(note_bytes) != note_len: + raise ValueError("path_id truncated: payer_note") + payer_note = note_bytes.decode('utf-8') + if flags & 0b1000: + desc_len = read_bigsize_int(fd) + if desc_len is None: + raise ValueError("path_id truncated: description_len") + desc_bytes = fd.read(desc_len) + if len(desc_bytes) != desc_len: + raise ValueError("path_id truncated: description") + description = desc_bytes.decode('utf-8') + + if fd.read(1): + raise ValueError("trailing bytes in path_id?") + + return cls( + amount_msat=amount_msat, + created_at=created_at, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + invoice_features=invoice_features, + payer_id=payer_id, + offer_metadata_digest=offer_metadata_digest, + quantity=quantity, + payer_note=payer_note, + description=description, + ) + + # offer/request/invoice uses different chain than we do class NoMatchingChainError(Exception): pass -# wraps remote invoice_error -class Bolt12InvoiceError(Exception): pass +# wraps invoice_error +class Bolt12InvoiceError(Exception): + def __init__(self, msg: str, *, erroneous_field: Optional[int] = None, suggested_value: Optional[bytes] = None): + assert msg + assert suggested_value is None if erroneous_field is None else True + + super().__init__(msg) + self.message = msg + self.erroneous_field = erroneous_field + self.suggested_value = suggested_value + + @classmethod + def from_tlv(cls, tlv: bytes) -> 'Bolt12InvoiceError': + try: + with io.BytesIO(tlv) as fd: + invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') + except Exception: + return cls(msg="malformed invoice error") + return cls( + msg=invoice_error.get('error', {}).get('msg', "received invoice_error without message"), + erroneous_field=invoice_error.get('erroneous_field', {}).get('tlv_fieldnum'), + suggested_value=invoice_error.get('suggested_value', {}).get('value'), + ) + + def to_tlv(self): + data = {'error': {'msg': self.message}} + if self.erroneous_field is not None: + data.update({'erroneous_field': {'tlv_fieldnum': self.erroneous_field}}) + if self.suggested_value is not None: + data.update({'suggested_value': {'value': self.suggested_value}}) + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream(fd=fd, tlv_stream_name='invoice_error', **data) + return fd.getvalue() def remove_bolt12_whitespace(bolt12_bech32: str) -> str: diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 9705d6e5aa78..cd169ab2572b 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -22,10 +22,9 @@ import aiorpcx from aiorpcx import ignore_after -from electrum.lnonion import BlindedPathInfo from .lrucache import LRUCache from .crypto import sha256, sha256d, privkey_to_pubkey -from . import bitcoin, util +from . import bitcoin, util, bolt12 from . import constants from .util import (log_exceptions, ignore_exceptions, chunks, OldTaskGroup, UnrelatedTransactionException, error_text_bytes_to_safe_str, AsyncHangDetector, @@ -38,7 +37,7 @@ from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, InvalidBlindedOnion, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, - OnionParsingError) + OnionParsingError, BlindedPathInfo) from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL from . import lnutil from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, LnFeatureContexts, @@ -2207,14 +2206,14 @@ def _check_encrypted_recipient_data( log_fail_reason(f"{processed_onion.blinded_path_recipient_data=} contains both node_id and next_chan_scid") raise exc_invalid_onion_blinding - @staticmethod def _check_accepted_final_htlc( + self, *, chan: Channel, htlc: UpdateAddHtlc, processed_onion: ProcessedOnionPacket, is_trampoline_onion: bool = False, log_fail_reason: Callable[[str], None], - ) -> tuple[bytes, int, int, OnionRoutingFailure]: + ) -> tuple[Optional[bytes], Optional[bytes], int, int, OnionRoutingFailure]: """ Perform checks that are invariant (results do not depend on height, network conditions, etc.) for htlcs of which we are the receiver (forwarding htlcs will have their checks in maybe_forward_htlc). @@ -2238,6 +2237,8 @@ def _check_accepted_final_htlc( code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + path_id_digest = None + payment_secret_from_onion = None if processed_onion.blinded_path_recipient_data is not None: # payment over blinded path # spec: MUST return an error if the payload contains other tlv fields than allowed_payload_keys allowed_payload_fields = ('encrypted_recipient_data', 'current_path_key', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat') @@ -2250,8 +2251,23 @@ def _check_accepted_final_htlc( log_fail_reason(f"'path_id' missing in recipient_data") raise exc_invalid_onion_blinding - log_fail_reason('we cannot receive blinded payments yet.') - raise exc_invalid_onion_blinding + try: # TODO: encrypt path_id with authentication to prevent forgery? + path_id_payload = bolt12.BOLT12InvoicePathIDPayload.decode(path_id) + except Exception as e: + log_fail_reason(f"couldn't decode blinded payment metadata: {e}: {path_id.hex()}") + raise exc_incorrect_or_unknown_pd + + path_id_payment_hash = sha256(path_id_payload.payment_preimage) + if path_id_payment_hash != htlc.payment_hash: + log_fail_reason(f"sender used blinded path out of context? {path_id_payment_hash.hex()=} != {htlc.payment_hash.hex()}") + raise exc_incorrect_or_unknown_pd + + self.lnworker.save_blinded_payment_info(path_id_payload) + # stop sending the invoice onion message + self.lnworker.cancel_bolt12_invoice_response(path_id_payment_hash) + path_id_digest = sha256(path_id) + else: + payment_secret_from_onion = processed_onion.payment_secret if (total_msat := processed_onion.total_msat) is None: log_fail_reason(f"'total_msat' missing from onion") @@ -2272,11 +2288,11 @@ def _check_accepted_final_htlc( code=OnionFailureCode.FINAL_INCORRECT_HTLC_AMOUNT, data=htlc.amount_msat.to_bytes(8, byteorder="big")) - if (payment_secret_from_onion := processed_onion.payment_secret) is None: + if payment_secret_from_onion is None and path_id_digest is None: log_fail_reason(f"'payment_secret' missing from onion") raise exc_incorrect_or_unknown_pd - return payment_secret_from_onion, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd + return payment_secret_from_onion, path_id_digest, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd def _check_unfulfilled_htlc( self, *, @@ -2316,7 +2332,7 @@ def _check_unfulfilled_htlc( return payment_key # parse parameters and perform checks that are invariant - payment_secret_from_onion, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd = ( + payment_secret_from_onion, path_id_digest, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd = ( self._check_accepted_final_htlc( chan=chan, htlc=htlc, @@ -2324,11 +2340,12 @@ def _check_unfulfilled_htlc( is_trampoline_onion=bool(outer_onion_payment_secret), log_fail_reason=_log_fail_reason, )) + assert bool(payment_secret_from_onion) != bool(path_id_digest), "need either payment secret or blinded payment" # trampoline htlcs of which we are the final receiver will first get grouped by the outer # onions secret to allow grouping a multi-trampoline mpp in different sets. Once a trampoline # payment part is completed (sum(htlcs) >= (trampoline-)amt_to_forward), its htlcs get moved into # the htlc set representing the whole payment (payment key derived from trampoline/invoice secret). - payment_key = (payment_hash + (outer_onion_payment_secret or payment_secret_from_onion)).hex() + payment_key = (payment_hash + (outer_onion_payment_secret or payment_secret_from_onion or path_id_digest)).hex() # for safety, still enforce MIN_FINAL_CLTV_DELTA here even if payment_hash is in dont_expire_htlcs if blocks_to_expiry < MIN_FINAL_CLTV_DELTA_ACCEPTED: @@ -2359,6 +2376,7 @@ def _check_unfulfilled_htlc( _log_fail_reason(f'incorrect trampoline onion {processed_onion=}\n{trampoline_onion=}') raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + assert payment_secret_from_onion, "need payment secret for outer trampoline onion" return self._check_unfulfilled_htlc( chan=chan, htlc=htlc, @@ -2387,10 +2405,11 @@ def _check_unfulfilled_htlc( _log_fail_reason(f"got mpp but we requested no mpp in the invoice: {total_msat=} > {htlc.amount_msat=}") raise exc_incorrect_or_unknown_pd - expected_payment_secret = self.lnworker.get_payment_secret(payment_hash) - if not util.constant_time_compare(payment_secret_from_onion, expected_payment_secret): - _log_fail_reason(f'incorrect payment secret: {payment_secret_from_onion.hex()=}') - raise exc_incorrect_or_unknown_pd + if not path_id_digest: + expected_payment_secret = self.lnworker.get_payment_secret(payment_hash) + if not util.constant_time_compare(payment_secret_from_onion, expected_payment_secret): + _log_fail_reason(f'incorrect payment secret: {payment_secret_from_onion.hex()=}') + raise exc_incorrect_or_unknown_pd invoice_msat = info.amount_msat if channel_opening_fee: diff --git a/electrum/lnworker.py b/electrum/lnworker.py index b26cb8dc4ca1..e3e1f16c5f61 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -61,7 +61,10 @@ hmac_oneshot ) -from .onion_message import OnionMessageManager, get_blinded_reply_paths, NoOnionMessagePeers +from .onion_message import ( + OnionMessageManager, get_blinded_reply_paths, NoOnionMessagePeers, get_blinded_paths_to_me, + NoRouteBlindingChannelPeers, +) from .lntransport import ( LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError @@ -80,7 +83,7 @@ PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, UnblindedRoutingInfo, BlindedRoutingInfo, RoutingInfo ) from .lnonion import ( - decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, + decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, BlindedPath, ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, calc_hops_data_for_blinded_payment, BlindedPathInfo, ) @@ -96,6 +99,7 @@ create_trampoline_route_and_onion, is_legacy_relay, trampolines_by_id, hardcoded_trampoline_nodes, is_hardcoded_trampoline, decode_routing_info, encode_next_trampolines, decode_next_trampolines ) +from .lrucache import LRUCache from .stored_dict import StoredDict if TYPE_CHECKING: @@ -1022,6 +1026,7 @@ class LNWallet(Logger): PAYMENT_TIMEOUT = 120 MPP_SPLIT_PART_FRACTION = 0.2 MPP_SPLIT_PART_MINAMT_MSAT = 5_000_000 + MAX_PENDING_ONION_MESSAGES_INVOICE_REQUEST = 100 def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = None): self.wallet = wallet @@ -1105,6 +1110,8 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No # invoice requests awaiting bolt 12 invoice response. path_id: concurrent.futures.Future[BOLT12Invoice, invoice_tlv] self._pending_bolt12_invoice_requests = {} # type: dict[bytes, asyncio.Future[tuple[BOLT12Invoice, bytes]]] + self._bolt12_invoice_cache = LRUCache(maxsize=100) # type: LRUCache[bytes, tuple[bytes, bytes]] + self._outgoing_bolt12_invoice_responses = LRUCache(maxsize=1000) # type: LRUCache[bytes, bytes] # payment_hash -> onion message key # payment_hash -> callback: self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]] @@ -4338,6 +4345,7 @@ def _maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(self, pa - Alice sends htlc A->B->C, for 1 sat, with HASH1 - Bob must not release the preimage of HASH1 """ + # FIXME: how does this interact with stateless bolt 12 invoices? payment_info = self.get_payment_info(payment_hash, 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), @@ -4553,9 +4561,7 @@ def on_bolt12_invoice(self, recipient_data: Mapping, payload: Mapping): if invoice_error_tlv := payload.get('invoice_error', {}).get('invoice_error'): self.logger.debug("received bolt 12 invoice error") if pending_invoice_request: - with io.BytesIO(invoice_error_tlv) as fd: - invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') - pending_invoice_request.set_exception(Bolt12InvoiceError(invoice_error.get('error', {}).get('msg'))) + pending_invoice_request.set_exception(Bolt12InvoiceError.from_tlv(invoice_error_tlv)) return try: @@ -4621,7 +4627,7 @@ def _verify_bolt12_invoice_requested_by_us(self, invoice: BOLT12Invoice) -> bool digest='sha-256', ) signed_invreq = dataclasses.replace(invreq_from_invoice, invreq_metadata=remaining_metadata) - our_sig = signed_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + our_sig = signed_invreq.encode(signing_key=signing_key, as_bech32=False) if not util.constant_time_compare(sha256(our_sig), invreq_sig_digest): return False return True @@ -4678,3 +4684,221 @@ def create_offer( assert allow_unblinded if offer.offer_issuer_id == self.node_keypair.pubkey else offer.offer_paths, offer return offer + + def _verify_bolt_12_offer_created_by_us(self, offer: BOLT12Offer) -> bool: + """Verify that given bolt12 offer was created by us.""" + if not offer.offer_metadata or len(offer.offer_metadata) < 32: + return False + # the last 32 bytes should be our mac + offer_mac, remaining_metadata = offer.offer_metadata[-32:], offer.offer_metadata[:-32] + offer_without_mac = dataclasses.replace(offer, offer_metadata=remaining_metadata) + encoded_offer = offer_without_mac.encode(as_bech32=False) + our_mac = hmac_oneshot(key=self.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + if not util.constant_time_compare(our_mac, offer_mac): + return False + return True + + def on_bolt12_invoice_request(self, recipient_data: Mapping, payload: Mapping): + # protect against someone bloating our OnionMessageManager + if len(self.onion_message_manager.pending) > self.MAX_PENDING_ONION_MESSAGES_INVOICE_REQUEST: + self.logger.warning(f"not responding to invoice_request, {len(self.onion_message_manager.pending)=} too large") + return + + encrypted_recipient_data = payload['encrypted_recipient_data']['encrypted_recipient_data'] + invreq_tlv = payload['invoice_request']['invoice_request'] + invreq = bolt12.BOLT12InvoiceRequest.decode(invreq_tlv) + invreq_metadata_digest = sha256(invreq.invreq_metadata) + self.logger.info(f'invoice_request: {invreq=}') + + # two possible scenarios: + # 1) not in response to offer (no offer_issuer_id or offer_paths) + # 2) response to offer. + is_response_to_offer = invreq.offer_issuer_id or invreq.offer_paths + + if is_response_to_offer: + reply_path_or_node_id = [BlindedPath.from_dict(payload['reply_path']['path'])] + else: + # spec: MUST use invreq_paths if present, otherwise MUST use invreq_payer_id as the node id to send to. + reply_path_or_node_id = invreq.invreq_paths or invreq.invreq_payer_id + + # we are only sending one response per unique invreq received as the + # sender might throw the same invreq at us many times + response_key = sha256(invreq_tlv)[:8] + if response_key in self.onion_message_manager.pending: + self.logger.debug(f"dropping incoming invreq, response already pending") + return + + try: + if is_response_to_offer: + offer: BOLT12Offer = extract_shared_fields(invreq, BOLT12Offer) + # verify the offer was created by us + if not self._verify_bolt_12_offer_created_by_us(offer): + raise Bolt12InvoiceError("no matching offer for this invoice request") + if offer.offer_paths: + # verify the invreq arrived on a blinded path intended by us for this offer + # MUST ignore the invoice_request if it did not arrive via one of those paths. + if encrypted_recipient_data not in (p.path[-1].encrypted_recipient_data for p in offer.offer_paths): + self.logger.debug(f"ignoring invoice_request arriving not on offer path") + return + else: + # MUST ignore any invoice_request if it arrived via a blinded path. + if payload.get('path_id', {}).get('data') is not None: + self.logger.debug(f"ignoring invoice_request arriving on blinded path for unblinded offer") + return + if offer.offer_absolute_expiry and int(time.time()) > offer.offer_absolute_expiry: + raise Bolt12InvoiceError('offer already expired') + + # spec: if offer_issuer_id is present: MUST set invoice_node_id to the offer_issuer_id + assert invreq.offer_issuer_id, "we only create offers with offer_issuer_id" + if offer.offer_issuer_id == self.node_keypair.pubkey: + invoice_signing_key = self.node_keypair.privkey + else: + invoice_signing_key = recipient_data['path_id']['data'] + else: + # TODO: this could be made deterministic so we can proof later on that we signed given invoice? + invoice_signing_key = os.urandom(32) + + cached_invoice = self._bolt12_invoice_cache.get(invreq_metadata_digest) + if invreq.offer_issuer_id and cached_invoice: + # if offer_issuer_id is present, and invreq_metadata is identical to a previous invoice_request: + # -> MAY simply reply with the previous invoice. + serialized_invoice, payment_hash = cached_invoice + else: + serialized_invoice, payment_hash = self._create_bolt12_invoice_for_invoice_request( + invreq=invreq, + invoice_signing_key=invoice_signing_key, + ) + self._bolt12_invoice_cache[invreq_metadata_digest] = serialized_invoice, payment_hash + except Bolt12InvoiceError as e: + self.logger.debug(f"failed to create bolt12 invoice, sending invoice_error: {str(e)}") + error_payload = {'invoice_error': {'invoice_error': e.to_tlv()}} + self.onion_message_manager.submit_send( + payload=error_payload, + node_id_or_blinded_paths=reply_path_or_node_id, + key=response_key, + ) + return + + destination_payload = { + 'invoice': {'invoice': serialized_invoice}, + } + self._outgoing_bolt12_invoice_responses[payment_hash] = response_key + self.onion_message_manager.submit_send( + payload=destination_payload, + node_id_or_blinded_paths=reply_path_or_node_id, + key=response_key, + ) + + def _create_bolt12_invoice_for_invoice_request( + self, + invreq: 'BOLT12InvoiceRequest', + invoice_signing_key: bytes, + ) -> tuple[bytes, bytes]: # invoice, payment_hash + """invreq is already validated, and it is safe to hand an invoice out for it""" + assert isinstance(invoice_signing_key, bytes) and len(invoice_signing_key) == 32, (invreq, invoice_signing_key) + amount_msat = invreq.invoice_amount_msat + invoice_features = self._prepare_invoice_features(self.features.for_bolt12_invoice(), amount_msat=amount_msat) + payment_preimage = os.urandom(32) + now = int(time.time()) + relative_expiry = bolt12.DEFAULT_INVOICE_EXPIRY + + # we include all the payment metadata into the blinded path so we can reconstruct + # the payment when we actually receive it and don't have to persist anything until then + path_id_payload = bolt12.BOLT12InvoicePathIDPayload( + amount_msat=amount_msat, + created_at=now, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=MIN_FINAL_CLTV_DELTA_ACCEPTED, + invoice_features=invoice_features, + payer_id=invreq.invreq_payer_id, + offer_metadata_digest=sha256(invreq.offer_metadata) if invreq.offer_metadata else None, + quantity=invreq.invreq_quantity, + payer_note=invreq.invreq_payer_note, + description=invreq.offer_description, + ) + recipient_data = { + 'path_id': {'data': path_id_payload.encode()}, + } + + # collect suitable channels for payment + invoice_channels = [ + chan for chan in self.channels.values() + if chan.is_active() and chan.can_receive(amount_msat=amount_msat, check_frozen=False) + ] + if not invoice_channels: + raise Bolt12InvoiceError( + 'no active channels with sufficient receive capacity, cannot receive this payment.') + + try: + invoice_path_info = get_blinded_paths_to_me( + self, final_recipient_data=recipient_data, my_channels=invoice_channels) + except NoRouteBlindingChannelPeers as e: + raise Bolt12InvoiceError("no peers with route blinding support") from e + + assert invoice_path_info + payment_hash = sha256(path_id_payload.payment_preimage) + try: + invoice = BOLT12Invoice( + **invreq.__dict__, + invoice_amount=path_id_payload.amount_msat, + invoice_created_at=path_id_payload.created_at, + invoice_relative_expiry=path_id_payload.relative_expiry, + invoice_payment_hash=payment_hash, + invoice_features=path_id_payload.invoice_features, + invoice_node_id=ECPrivkey(invoice_signing_key).get_public_key_bytes(), + invoice_paths=tuple(p.path for p in invoice_path_info), + invoice_blindedpay=tuple(p.payinfo for p in invoice_path_info), + ) + except Exception as e: + raise Bolt12InvoiceError(str(e)) from e + + signed_invoice = invoice.encode(signing_key=invoice_signing_key, as_bech32=False) + assert isinstance(signed_invoice, bytes) + return signed_invoice, payment_hash + + def save_blinded_payment_info(self, path_id_payload: bolt12.BOLT12InvoicePathIDPayload) -> None: + """ + Store the payment metadata which we included in a BOLT12Invoice when we receive it again with the payment htlcs. + """ + payment_hash = sha256(path_id_payload.payment_preimage) + if payment_hash.hex() not in self._preimages: + self.save_preimage(payment_hash, path_id_payload.payment_preimage) + payment_info_key = PaymentInfo.calc_db_key(payment_hash_hex=payment_hash.hex(), direction=lnutil.Direction.RECEIVED) + if payment_info_key not in self.payment_info: + payment_info = PaymentInfo( + payment_hash=payment_hash, + amount_msat=path_id_payload.amount_msat, + direction=lnutil.Direction.RECEIVED, + status=PR_UNPAID, + min_final_cltv_delta=path_id_payload.min_final_cltv_expiry_delta, + expiry_delay=path_id_payload.relative_expiry, + creation_ts=path_id_payload.created_at, + invoice_features=path_id_payload.invoice_features, + ) + self.save_payment_info(payment_info, write_to_disk=False) + # save request for GUI + message = path_id_payload.description or '' + if payer_note := path_id_payload.payer_note: + if message: + message += f" [{_('payer:')} {payer_note}]" # "our description [payer: payer note]" + else: + message = payer_note + height = self.wallet.adb.get_local_height() + req = Request( + outputs=None, + message=message, + time=payment_info.creation_ts, + amount_msat=payment_info.amount_msat, + exp=payment_info.expiry_delay, + height=height, + payment_hash=payment_hash, + ) + self.wallet.add_payment_request(req, write_to_disk=True) + + def cancel_bolt12_invoice_response(self, payment_hash: bytes): + """Stops sending the invoice response onion message for an invoice_request we received previously""" + onion_message_key = self._outgoing_bolt12_invoice_responses.get(payment_hash) + if onion_message_key: + self.onion_message_manager.remove_pending_message(onion_message_key) + self.logger.debug(f"stopping invoice response for rhash={payment_hash.hex()}") diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 2895945c1055..45985a123667 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -702,7 +702,7 @@ async def process_send_queue(self) -> None: delay = 0 # quickly try all destinations self.send_queue.put_nowait((now() + delay, expires, key)) - def _remove_pending_message(self, key: bytes) -> None: + def remove_pending_message(self, key: bytes) -> None: with self.pending_lock: if key in self.pending: del self.pending[key] @@ -742,7 +742,7 @@ async def _wait_task(self, key: bytes, future: asyncio.Future): try: return await future finally: - self._remove_pending_message(key) + self.remove_pending_message(key) def _send_pending_message(self, key: bytes) -> None: """adds reply_path to payload""" @@ -881,7 +881,7 @@ def on_onion_message_received_unsolicited(self, recipient_data: Mapping, payload # e.g. via a decorator, something like # @onion_message_request_handler(payload_key='invoice_request') for BOLT12 invoice requests. - known_payloads = ('message', 'invoice', 'invoice_error') + known_payloads = ('message', 'invoice', 'invoice_error', 'invoice_request') if not any(known_payload in payload for known_payload in known_payloads): self.logger.error('Unsupported onion message payload') return @@ -893,6 +893,14 @@ def on_onion_message_received_unsolicited(self, recipient_data: Mapping, payload self.logger.warning(f"failed to handle incoming invoice: {e!r}") return + if 'invoice_request' in payload: + try: + self.lnwallet.on_bolt12_invoice_request(recipient_data, payload) + except Exception as e: + self.logger.warning(f"failed to handle invoice_request: {e!r}") + return + + # log 'message' payload if 'text' not in payload['message'] or not isinstance(payload['message']['text'], bytes): self.logger.error('Malformed \'message\' payload') return diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py index d5fa739a9a8d..52e171e12dba 100644 --- a/tests/test_bolt12.py +++ b/tests/test_bolt12.py @@ -1,5 +1,6 @@ import io import json +import os import time from dataclasses import fields from pathlib import Path @@ -9,7 +10,7 @@ from electrum import segwit_addr, lnutil from electrum.bolt12 import ( is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, NoMatchingChainError, - extract_shared_fields + extract_shared_fields, BOLT12InvoicePathIDPayload, Bolt12InvoiceError ) from electrum.crypto import privkey_to_pubkey from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer, \ @@ -18,6 +19,7 @@ from electrum.lnutil import LnFeatures, UnknownEvenFeatureBits from electrum.segwit_addr import INVALID_BECH32, bech32_encode, Encoding, convertbits from electrum.util import bfh +from electrum.lnworker import LNWALLET_FEATURES from . import ElectrumTestCase @@ -420,6 +422,59 @@ def test_extract_shared_fields(self): self.assertEqual(invoice.invreq_metadata, extracted_invreq.invreq_metadata) self.assertEqual(invoice.offer_issuer_id, extracted_invreq.offer_issuer_id) + def test_bolt12_invoice_path_id_payload(self): + amount_msat = 1000 + created_at = int(time.time()) + relative_expiry = created_at + 100 + payment_preimage = os.urandom(32) + min_final_cltv_expiry_delta = 322 + invoice_features = LNWALLET_FEATURES.for_bolt12_invoice() + invreq_payer_id = os.urandom(33) + offer_metadata_digest = os.urandom(32) + invreq_quantity = 5 + invreq_payer_note = "Thanks for the Pizza, it was delicious!" + offer_description = "Pizza Salami - 30 cm - Old Italy" + path_id_payload = BOLT12InvoicePathIDPayload( + amount_msat=amount_msat, + created_at=created_at, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + invoice_features=invoice_features, + payer_id=invreq_payer_id, + offer_metadata_digest=offer_metadata_digest, + quantity=invreq_quantity, + payer_note=invreq_payer_note, + description=offer_description, + ) + encoded = path_id_payload.encode() + decoded = BOLT12InvoicePathIDPayload.decode(encoded) + self.assertEqual(decoded.amount_msat, amount_msat) + self.assertEqual(decoded.created_at, created_at) + self.assertEqual(decoded.relative_expiry, relative_expiry) + self.assertEqual(decoded.payment_preimage, payment_preimage) + self.assertEqual(decoded.min_final_cltv_expiry_delta, min_final_cltv_expiry_delta) + self.assertEqual(decoded.invoice_features, invoice_features) + self.assertEqual(decoded.payer_id, invreq_payer_id) + self.assertEqual(decoded.offer_metadata_digest, offer_metadata_digest) + self.assertEqual(decoded.quantity, invreq_quantity) + self.assertEqual(decoded.payer_note, invreq_payer_note) + self.assertEqual(decoded.description, offer_description) + + def test_bolt12_invoice_error(self): + invoice_error_tlv = bfh("01015203086465616462656566050e616d6f756e7420746f6f206c6f77") + invoice_error = Bolt12InvoiceError.from_tlv(invoice_error_tlv) + invoice_error = invoice_error.to_tlv() + self.assertEqual(invoice_error, invoice_error_tlv) + invoice_error = Bolt12InvoiceError( + msg="amount too low", + erroneous_field=82, + suggested_value=b"deadbeef" + ) + self.assertEqual(invoice_error.to_tlv(), invoice_error_tlv) + invoice_error = Bolt12InvoiceError.from_tlv(b"deadbeef") + self.assertEqual(invoice_error.message, "malformed invoice error") + def test_fallback_address(self): # invoice without fallback address invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 7dbfa7275961..f3337f764329 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -13,9 +13,10 @@ from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum import bitcoin import electrum.trampoline +from electrum.gui.qt.wizard import wallet from electrum.onion_message import NoOnionMessagePeers from electrum import constants -from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload from electrum.crypto import sha256, hmac_oneshot from electrum.lnonion import BlindedPath, BlindedPathHop, BlindedPathInfo, BlindedPayInfo from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner @@ -27,6 +28,7 @@ from electrum.lnonion import OnionPacket, OnionRoutingFailure from electrum.crypto import sha256 from electrum.simple_config import SimpleConfig +from electrum.lnworker import LNWALLET_FEATURES from . import ElectrumTestCase, lnhelpers from .lnhelpers import create_test_channels @@ -78,6 +80,37 @@ def test_create_payment_info__amount_must_not_be_zero(self): exp_delay=exp_delay, ) + def test_save_blinded_payment_info(self): + amount_msat = 1000 + created_at = int(time.time()) + relative_expiry = created_at + 100 + payment_preimage = os.urandom(32) + min_final_cltv_expiry_delta = 322 + invoice_features = LNWALLET_FEATURES.for_bolt12_invoice() + invreq_payer_id = os.urandom(33) + path_id_payload = BOLT12InvoicePathIDPayload( + amount_msat=amount_msat, + created_at=created_at, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + invoice_features=invoice_features, + payer_id=invreq_payer_id, + ) + wallet = self.lnwallet_anchors + wallet.save_blinded_payment_info(path_id_payload) + payment_hash = sha256(payment_preimage) + payment_info = wallet.get_payment_info(payment_hash, direction=RECEIVED) + self.assertIsNotNone(payment_info) + self.assertEqual(wallet.get_preimage(payment_hash), payment_preimage) + self.assertEqual(payment_info.payment_hash, payment_hash) + self.assertEqual(payment_info.amount_msat, amount_msat) + self.assertEqual(payment_info.status, PR_UNPAID) + self.assertEqual(payment_info.min_final_cltv_delta, min_final_cltv_expiry_delta) + self.assertEqual(payment_info.expiry_delay, relative_expiry) + self.assertEqual(payment_info.creation_ts, created_at) + self.assertEqual(payment_info.invoice_features, invoice_features) + async def test_trampoline_invoice_features_and_routing_hints(self): """ When the invoice_features signal trampoline support, routing hints must only @@ -708,3 +741,19 @@ def test_create_offer(self): offer1 = wallet.create_offer(allow_unblinded=True).encode() offer2 = wallet.create_offer(allow_unblinded=True).encode() self.assertNotEqual(offer1, offer2) + + def test__verify_bolt12_offer_created_by_us(self): + wallet = self.lnwallet_anchors + + offer_created_by_us = wallet.create_offer( + allow_unblinded=True, + amount_msat=1000, + description="best pizza place", + ) + self.assertTrue(wallet._verify_bolt_12_offer_created_by_us(offer_created_by_us)) + + offer_modified = dataclasses.replace(offer_created_by_us, offer_description="pizza place stinks") + self.assertFalse(wallet._verify_bolt_12_offer_created_by_us(offer_modified)) + + offer_modified = dataclasses.replace(offer_created_by_us, offer_amount=1001) + self.assertFalse(wallet._verify_bolt_12_offer_created_by_us(offer_modified)) From 5ec7a2a0c6e0216447a5232ca31b6cddc3c5f049 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 15:36:21 +0200 Subject: [PATCH 34/45] test_lnwallet: test bolt12 invoice request handling --- electrum/lnutil.py | 3 + tests/lnhelpers.py | 33 +++- tests/test_lnwallet.py | 420 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 416 insertions(+), 40 deletions(-) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8a0a6cf07a2c..9ed7481abb0d 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1589,6 +1589,9 @@ def for_bolt12_invoice(self) -> 'LnFeatures': def for_blinded_path(self) -> 'LnFeatures': return self._for_context(LnFeatureContexts.BLINDED_PATH) + def for_blinded_payinfo(self) -> 'LnFeatures': + return self._for_context(LnFeatureContexts.BLINDED_PAYINFO) + def for_channel_announcement(self) -> 'LnFeatures': features = LnFeatures(0) for flag in list_enabled_ln_feature_bits(self): diff --git a/tests/lnhelpers.py b/tests/lnhelpers.py index 1202860d3700..aeab754fba5e 100644 --- a/tests/lnhelpers.py +++ b/tests/lnhelpers.py @@ -2,7 +2,9 @@ import copy import os from pprint import pformat -from typing import NamedTuple, Tuple, Dict, Mapping, TYPE_CHECKING, Sequence +from typing import NamedTuple, Tuple, Dict, Mapping, TYPE_CHECKING, Optional + +from electrum_ecc import ECPrivkey import electrum import electrum.trampoline @@ -20,10 +22,11 @@ from electrum.lnchannel import ChannelState, Channel from electrum.lnrouter import LNPathFinder from electrum.channel_db import ChannelDB -from electrum.lnworker import LNWallet, PaySession +from electrum.lnworker import LNWallet, PaySession, LNWALLET_FEATURES from electrum.simple_config import SimpleConfig from electrum.fee_policy import FeeTimeEstimates, FEE_ETA_TARGETS from electrum.wallet import Standard_Wallet +from electrum.lnonion import BlindedPathInfo, BlindedPayInfo, BlindedPath, BlindedPathHop from . import restore_wallet_from_text__for_unittest @@ -534,3 +537,29 @@ def create_test_channels( assert alice.channel_id == bob.channel_id return alice, bob + + +def get_dummy_paths(first_node_id: Optional[bytes] = None) -> list[BlindedPathInfo]: + ip_node_id = first_node_id or ECPrivkey.generate_random_key().get_public_key_bytes() + first_path_key = ECPrivkey.generate_random_key().get_public_key_bytes() + dummy_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=ip_node_id, + first_path_key=first_path_key, + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=ECPrivkey.generate_random_key().get_public_key_bytes(), # dummy + enclen=5, + encrypted_recipient_data=b'12345', + )], + ), + payinfo=BlindedPayInfo( + fee_base_msat=1000, + fee_proportional_millionths=1000, + cltv_expiry_delta=221, + htlc_minimum_msat=1, + htlc_maximum_msat=10000000000, + features=LNWALLET_FEATURES.for_blinded_payinfo(), + ), + )] + return dummy_paths diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index f3337f764329..622161790d3f 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -4,21 +4,20 @@ import os from unittest import mock from decimal import Decimal -from typing import Optional import time +from typing import Optional from unittest.mock import patch from electrum_ecc import ECPrivkey from electrum.address_synchronizer import TX_HEIGHT_LOCAL -from electrum import bitcoin import electrum.trampoline -from electrum.gui.qt.wizard import wallet from electrum.onion_message import NoOnionMessagePeers +from .test_wallet_vertical import UNICODE_HORROR + from electrum import constants -from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload -from electrum.crypto import sha256, hmac_oneshot -from electrum.lnonion import BlindedPath, BlindedPathHop, BlindedPathInfo, BlindedPayInfo +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload, Bolt12InvoiceError +from electrum.crypto import hmac_oneshot from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner from electrum.logging import console_stderr_handler from electrum.lntransport import LNPeerAddr @@ -31,7 +30,7 @@ from electrum.lnworker import LNWALLET_FEATURES from . import ElectrumTestCase, lnhelpers -from .lnhelpers import create_test_channels +from .lnhelpers import create_test_channels, get_dummy_paths class TestLNWallet(ElectrumTestCase): @@ -528,27 +527,12 @@ async def test_request_bolt12_invoice(self): offer_issuer_id=offer_issuer_id, ) - introduction_point = ECPrivkey.generate_random_key().get_public_key_bytes() - reply_paths = [BlindedPathInfo( - path=BlindedPath( - first_node_id=introduction_point, - first_path_key=ECPrivkey.generate_random_key().get_public_key_bytes(), - num_hops=(1).to_bytes(1, 'big'), - path=[BlindedPathHop( - blinded_node_id=ECPrivkey.generate_random_key().get_public_key_bytes(), - enclen=5, - encrypted_recipient_data=b'12345', - )], - ), - payinfo=None, - )] - submit_send_calls = [] def fake_submit_send(*, payload, node_id_or_blinded_paths, key=None): submit_send_calls.append((payload, node_id_or_blinded_paths)) return asyncio.Future() - with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths), \ + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()), \ patch.object(wallet.onion_message_manager, 'submit_send', side_effect=fake_submit_send): task = asyncio.create_task( wallet.request_bolt12_invoice(bolt12_offer=offer, amount_msat=21_000) @@ -636,21 +620,7 @@ def test_create_bolt12_invoice_request_with_offer(self): def test_create_bolt12_invoice_request_without_offer(self): wallet = self.lnwallet_anchors - fake_pubkey = ECPrivkey.generate_random_key().get_public_key_bytes() - reply_paths = [BlindedPathInfo( - path=BlindedPath( - first_node_id=fake_pubkey, - first_path_key=fake_pubkey, - num_hops=(1).to_bytes(1, 'big'), - path=[BlindedPathHop( - blinded_node_id=fake_pubkey, - enclen=5, - encrypted_recipient_data=b'12345', - )], - ), - payinfo=None, - )] - + reply_paths = get_dummy_paths() amount_msat = 42_000 with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths): unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( @@ -757,3 +727,377 @@ def test__verify_bolt12_offer_created_by_us(self): offer_modified = dataclasses.replace(offer_created_by_us, offer_amount=1001) self.assertFalse(wallet._verify_bolt_12_offer_created_by_us(offer_modified)) + + async def test_on_bolt12_invoice_request(self): + wallet = self.lnwallet_anchors + + reply_paths = get_dummy_paths() + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths) as mock: + offer = wallet.create_offer( + amount_msat=1000, + description="test", + relative_expiry=None, + issuer_name="tester", + allow_unblinded=False, + ) + # invoice needs to get signed with the offer_issuer_id's secret from offer paths path_id + offer_encrypted_recipient_data_path_id = mock.call_args.args[1] + + # create invoice request for our own offer + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=1000, + payer_note="pls send invoice", + ) + invreq_tlv = unsigned_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + + # create payload that we receive when someone (we as well) requests the invoice for our offer + incoming_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': offer.offer_paths[0].path[-1].encrypted_recipient_data}, + } + # decrypted encrypted recipient data + recipient_data = { + 'path_id': {'data': offer_encrypted_recipient_data_path_id} + } + + with patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=incoming_payload, recipient_data=recipient_data) + self.assertIn( + b'no active channels', + mock_submit_send.call_args.kwargs['payload']['invoice_error']['invoice_error'], + ) + + # add a channel so the active channel check passes + regular_peer = self.create_mock_lnwallet(name='regular_peer') + chan_r, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=regular_peer) + wallet._add_channel(chan_r) + invoice_paths = get_dummy_paths() + with patch('electrum.lnworker.get_blinded_paths_to_me', return_value=invoice_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=incoming_payload, recipient_data=recipient_data) + sent_invoice_tlv = mock_submit_send.call_args.kwargs['payload']['invoice']['invoice'] + sent_invoice = BOLT12Invoice.decode(sent_invoice_tlv) + + self.assertEqual(offer.offer_issuer_id, sent_invoice.invoice_node_id) + + # invreq for an offer that was not created by us (MAC verification fails) + foreign_offer = dataclasses.replace(offer, offer_description="tampered description") + foreign_invreq, foreign_signing_key = wallet.create_bolt12_invoice_request( + offer=foreign_offer, + amount_msat=1000, + payer_note="pls send invoice", + ) + foreign_invreq_tlv = foreign_invreq.encode( + signing_key=foreign_signing_key.get_secret_bytes(), as_bech32=False) + foreign_payload = { + 'invoice_request': {'invoice_request': foreign_invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': foreign_offer.offer_paths[0].path[-1].encrypted_recipient_data}, + } + with patch('electrum.lnworker.get_blinded_paths_to_me', return_value=invoice_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=foreign_payload, recipient_data=recipient_data) + self.assertIn( + b'no matching offer for this invoice request', + mock_submit_send.call_args.kwargs['payload']['invoice_error']['invoice_error'], + ) + + # invreq arrived with encrypted_recipient_data that doesn't belong to any of the offer paths + wrong_path_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': b'not_in_offer_paths'}, + } + with patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=wrong_path_payload, recipient_data=recipient_data) + self.assertFalse(mock_submit_send.called) + + # invreq arrives for an offer that is already expired + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()): + expiring_offer = wallet.create_offer( + amount_msat=1000, + description="expiring offer", + relative_expiry=3600, + allow_unblinded=False, + ) + expiring_invreq, expiring_signing_key = wallet.create_bolt12_invoice_request( + offer=expiring_offer, + amount_msat=1000, + payer_note="pls send invoice", + ) + expiring_invreq_tlv = expiring_invreq.encode( + signing_key=expiring_signing_key.get_secret_bytes(), as_bech32=False) + expiring_payload = { + 'invoice_request': {'invoice_request': expiring_invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': expiring_offer.offer_paths[0].path[-1].encrypted_recipient_data}, + } + with patch('electrum.lnworker.time.time', return_value=expiring_offer.offer_absolute_expiry + 1), \ + patch('electrum.lnworker.get_blinded_paths_to_me', return_value=invoice_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=expiring_payload, recipient_data=recipient_data) + self.assertIn( + b'offer already expired', + mock_submit_send.call_args.kwargs['payload']['invoice_error']['invoice_error'], + ) + + async def test_on_bolt12_offerless_invoice_request(self): + wallet = self.lnwallet_anchors + + # add a channel so for receive capacity + regular_peer = self.create_mock_lnwallet(name='regular_peer') + chan_r, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=regular_peer) + wallet._add_channel(chan_r) + + # offerless invoice_request, with invreq_paths (blinded) + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()): + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=1000, + payer_note="sats for you", + ) + + self.assertIsNone(unsigned_invreq.offer_issuer_id) + self.assertIsNone(unsigned_invreq.offer_paths) + self.assertIsNotNone(unsigned_invreq.invreq_paths) + self.assertEqual(unsigned_invreq.offer_description, "sats for you") + + invreq_tlv = unsigned_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + incoming_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': b'is_unused'}, + } + + with patch('electrum.lnworker.get_blinded_paths_to_me', return_value=get_dummy_paths()), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=incoming_payload, recipient_data={}) + + sent_kwargs = mock_submit_send.call_args.kwargs + self.assertEqual( + sent_kwargs['node_id_or_blinded_paths'], + unsigned_invreq.invreq_paths, + ) + sent_invoice = BOLT12Invoice.decode(sent_kwargs['payload']['invoice']['invoice']) + self.assertEqual(sent_invoice.invoice_amount, 1000) + self.assertEqual(sent_invoice.offer_description, "sats for you") + + async def test__verify_bolt12_invoice_requested_by_us(self): + wallet = self.lnwallet_anchors + + # pretend we're paying a peer's offer + peer_offer_key = ECPrivkey.generate_random_key() + peer_offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=1000, + offer_description="peer offer", + offer_issuer_id=peer_offer_key.get_public_key_bytes(), + ) + # invoice request for this offer + unsigned_invreq, _ = wallet.create_bolt12_invoice_request(offer=peer_offer, amount_msat=1000) + + def build_invoice(invreq: BOLT12InvoiceRequest) -> BOLT12Invoice: + dummy_path = get_dummy_paths() + return BOLT12Invoice( + **invreq.__dict__, + invoice_amount=invreq.invreq_amount, + invoice_created_at=int(time.time()), + invoice_payment_hash=os.urandom(32), + invoice_node_id=peer_offer_key.get_public_key_bytes(), + invoice_paths=tuple(p.path for p in dummy_path), + invoice_blindedpay=tuple(p.payinfo for p in dummy_path), + ) + + invoice = build_invoice(unsigned_invreq) + self.assertTrue(wallet._verify_bolt12_invoice_requested_by_us(invoice)) + + # tampering with any invreq field invalidates the stored metadata digest + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + build_invoice(dataclasses.replace(unsigned_invreq, invreq_amount=1001)) + )) + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, invreq_payer_note="tampered") + )) + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, offer_description="tampered") + )) + + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, invreq_metadata=b'\x00' * 10) + )) + + # metadata with wrong entropy yields a different derived key: verification fails + wrong_entropy_meta = os.urandom(16) + invoice.invreq_metadata[-32:] + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, invreq_metadata=wrong_entropy_meta) + )) + + # invoice carrying an invreq created by a different wallet: verification fails + other_wallet = self.create_mock_lnwallet(name='other_wallet') + other_invreq, _ = other_wallet.create_bolt12_invoice_request( + offer=peer_offer, + amount_msat=1000, + ) + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us(build_invoice(other_invreq))) + + # offerless unblinded invreq is signed with the node keypair + with patch('electrum.lnworker.get_blinded_reply_paths', side_effect=NoOnionMessagePeers("no peers")): + unblinded_invreq, node_signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=2000, + allow_unblinded=True, + ) + self.assertEqual(node_signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + self.assertEqual(unblinded_invreq.invreq_payer_id, wallet.node_keypair.pubkey) + + unblinded_invoice = build_invoice(unblinded_invreq) + self.assertTrue(wallet._verify_bolt12_invoice_requested_by_us(unblinded_invoice)) + + async def test_on_bolt12_invoice(self): + wallet = self.lnwallet_anchors + + offer_issuer_key = ECPrivkey.generate_random_key() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=1000, + offer_description=UNICODE_HORROR, + offer_issuer_id=offer_issuer_key.get_public_key_bytes(), + ) + unsigned_invreq, invreq_signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=1000, + payer_note=UNICODE_HORROR, + ) + path_id = invreq_signing_key.get_secret_bytes() + recipient_data = {'path_id': {'data': path_id}} + + def register_pending_invoice() -> asyncio.Future: + fut = asyncio.Future() + wallet._pending_bolt12_invoice_requests[path_id] = fut + return fut + + def build_invoice_tlv( + *, invreq: BOLT12InvoiceRequest = unsigned_invreq, + amount: int = unsigned_invreq.offer_amount, + created_at: Optional[int] = None, + relative_expiry: int = 3600, + ) -> bytes: + dummy_paths = get_dummy_paths() + invoice = BOLT12Invoice( + **invreq.__dict__, + invoice_amount=amount, + invoice_created_at=created_at if created_at is not None else int(time.time()), + invoice_relative_expiry=relative_expiry, + invoice_payment_hash=os.urandom(32), + invoice_node_id=offer_issuer_key.get_public_key_bytes(), + invoice_paths=tuple(p.path for p in dummy_paths), + invoice_blindedpay=tuple(p.payinfo for p in dummy_paths), + ) + return invoice.encode(signing_key=offer_issuer_key.get_secret_bytes(), as_bech32=False) + + # happy path: a valid invoice resolves the pending future + fut = register_pending_invoice() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': build_invoice_tlv()}}, + ) + received, _ = await fut + self.assertIsInstance(received, BOLT12Invoice) + self.assertEqual(received.invoice_amount, 1000) + self.assertEqual(received.offer_issuer_id, offer.offer_issuer_id) + + # invoice_error payload sets the exception on the pending future + fut = register_pending_invoice() + err_tlv = Bolt12InvoiceError("something went wrong").to_tlv() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice_error': {'invoice_error': err_tlv}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("something went wrong", str(ctx.exception)) + + # malformed invoice TLV: decode error is surfaced on the future + fut = register_pending_invoice() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': os.urandom(32)}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("received invalid invoice", str(ctx.exception)) + + # invoice with modified invreq content + fut = register_pending_invoice() + tampered_invreq = dataclasses.replace(unsigned_invreq, invreq_payer_note="hello") + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': build_invoice_tlv(invreq=tampered_invreq)}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("unable to verify invoice content", str(ctx.exception)) + + # expired invoice: exception on the future + fut = register_pending_invoice() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': build_invoice_tlv(created_at=1000, relative_expiry=1)}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("received expired invoice", str(ctx.exception)) + + # unknown path_id and not an offerless invreq: silently dropped + del wallet._pending_bolt12_invoice_requests[path_id] + wallet.on_bolt12_invoice( + recipient_data={'path_id': {'data': os.urandom(32)}}, + payload={'invoice': {'invoice': build_invoice_tlv()}}, + ) + self.assertEqual(wallet._pending_bolt12_invoice_requests, {}) + + # offerless invreq: invoice must arrive on a reply path we sent in invreq_paths + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()): + offerless_invreq, offerless_signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=2000, + ) + + def build_offerless_invoice_tlv() -> bytes: + invoice_node_key = ECPrivkey.generate_random_key() + dummy_paths = get_dummy_paths() + invoice = BOLT12Invoice( + **offerless_invreq.__dict__, + invoice_amount=offerless_invreq.invreq_amount, + invoice_created_at=int(time.time()), + invoice_payment_hash=os.urandom(32), + invoice_node_id=invoice_node_key.get_public_key_bytes(), + invoice_paths=tuple(p.path for p in dummy_paths), + invoice_blindedpay=tuple(p.payinfo for p in dummy_paths), + ) + return invoice.encode(signing_key=invoice_node_key.get_secret_bytes(), as_bech32=False) + + expected_encrypted_recipient_data = offerless_invreq.invreq_paths[0].path[-1].encrypted_recipient_data + + # offerless happy path: encrypted_recipient_data matches one of invreq_paths + with self.assertRaises(NotImplementedError): + wallet.on_bolt12_invoice( + recipient_data={}, + payload={ + 'invoice': {'invoice': build_offerless_invoice_tlv()}, + 'encrypted_recipient_data': {'encrypted_recipient_data': expected_encrypted_recipient_data}, + }, + ) + + # offerless on incorrect reply path: invoice is silently dropped, future stays pending + with self.assertLogs('electrum', level='WARN') as logs: + wallet.on_bolt12_invoice( + recipient_data={}, + payload={ + 'invoice': {'invoice': build_offerless_invoice_tlv()}, + 'encrypted_recipient_data': {'encrypted_recipient_data': os.urandom(32)}, + }, + ) + self.assertTrue(any('received invoice for offerless invreq on incorrect path' in msg for msg in logs.output)) From 152217438175d3a8ee851545ad497dfa95739305 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 13 Nov 2025 12:31:23 +0100 Subject: [PATCH 35/45] wallet: return bech32 encoded bolt12 invoice in export_invoice() --- electrum/wallet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/electrum/wallet.py b/electrum/wallet.py index ea55b697f7bd..696f35d85ee8 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -74,7 +74,9 @@ AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE, TX_TIMESTAMP_INF ) -from .invoices import BaseInvoice, Invoice, Request, PR_PAID, PR_UNPAID, PR_EXPIRED, PR_UNCONFIRMED +from .invoices import ( + BaseInvoice, Invoice, Request, PR_PAID, PR_UNPAID, PR_EXPIRED, PR_UNCONFIRMED +) from .contacts import Contacts from .mnemonic import Mnemonic from .lnworker import LNWallet From e801f99367d6a7d5b596c241de38c188ca3d1401 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sat, 6 Dec 2025 16:18:54 +0100 Subject: [PATCH 36/45] add regtest: tests.regtest.TestLightningABC.test_bolt12 --- electrum/commands.py | 36 ++++++++++++++++++++++++++++++++++++ tests/regtest.py | 3 +++ tests/regtest/regtest.sh | 19 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/electrum/commands.py b/electrum/commands.py index 86aa63b47069..1d110dd9872a 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1388,6 +1388,42 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force req = wallet.get_request(key) return wallet.export_request(req) + @command('wnl') + async def pay_bolt12_offer( + self, + offer: str, + amount: Optional[Decimal] = None, + payer_note: Optional[str] = None, + wallet: Abstract_Wallet = None + ): + """Retrieve an invoice from a bolt12 offer, and pay that invoice + + arg:str:offer:bolt-12 offer (bech32) + arg:decimal:amount:Amount to send + arg:str:payer_note:Text note for the recipient + """ + amount_msat = satoshis(amount) * 1000 if amount else None + bolt12_offer = bolt12.BOLT12Offer.decode(offer) + offer_amount_msat = bolt12_offer.offer_amount + if offer_amount_msat and amount_msat and offer_amount_msat != amount_msat: + raise ValueError(f"{amount_msat=} different than {offer_amount_msat=}") + send_amount = offer_amount_msat or amount_msat + if not send_amount: + raise ValueError(f"Missing amount to send: {amount_msat=}") + lnworker = wallet.lnworker + bolt12_invoice, invoice_tlv = await lnworker.request_bolt12_invoice( + bolt12_offer, + amount_msat=send_amount, + payer_note=payer_note, + ) + invoice_bech32 = bolt12.bolt12_tlv_bytes_to_bech32(invoice_tlv, bolt12.BOLT12Invoice) + invoice = Invoice.from_bech32(invoice_bech32) + success, log = await lnworker.pay_invoice(invoice) + return { + 'success': success, + 'log': [x.formatted_tuple() for x in log] + } + @command('wnl') async def add_lightning_offer( self, diff --git a/tests/regtest.py b/tests/regtest.py index 9c2de7b36243..a4b1405062c6 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -147,6 +147,9 @@ class TestLightningABC(TestLightning): } } + def test_bolt12(self): + self.run_shell(['bolt12']) + def test_fw_fail_htlc(self): self.run_shell(['fw_fail_htlc']) diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index 0102adb501a3..b8c22385c9e3 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -771,6 +771,25 @@ if [[ $1 == "watchtower" ]]; then wait_until_spent $ctx_id $output_index # alice's to_local gets punished fi +if [[ $1 == "bolt12" ]]; then +# $carol enable_htlc_settle false + bob_node=$($bob nodeid) + wait_for_balance carol 1 + echo "alice and carol open channels with bob" + chan_id1=$($alice open_channel $bob_node 0.15 --password='' --push_amount=0.075) + chan_id2=$($carol open_channel $bob_node 0.15 --password='' --push_amount=0.075) + new_blocks 3 + wait_until_channel_open alice + wait_until_channel_open carol + echo "alice pays carol" + offer=$($carol add_lightning_offer --amount=0.001| jq '.offer') + result=$($alice pay_bolt12_offer $offer) + echo $result + if [[ $(echo $result|jq '.success') == false ]]; then + exit 1 + fi +fi + if [[ $1 == "fw_fail_htlc" ]]; then $carol enable_htlc_settle false bob_node=$($bob nodeid) From d556d43698a7ede2f139d069d63c9efd8f8e5be7 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 17 Mar 2026 16:17:37 +0100 Subject: [PATCH 37/45] lnutil: add blinded path method to PaymentFeeBudget Add method to substract fees required for blinded path from a PaymentFeeBudget to calculate the remaining budget for the unblinded path. --- electrum/lnutil.py | 22 ++++++++++++++++++++++ tests/test_lnutil.py | 23 +++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 9ed7481abb0d..09720d8a3872 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -2166,6 +2166,28 @@ def reverse_from_total_amount(cls, *, total_amount_msat: int, config: 'SimpleCon fees_msat = min(fees_msat, total_amount_msat) # to handle (invalid?) inputs below cutoff_clamped return fees_msat + def subtract_blinded_path_fees(self, blinded_payinfo: 'BlindedPayInfo', amount_msat: int) -> 'PaymentFeeBudget': + """Subtract the blinded path's aggregate fees from this budget, returning the remaining budget + available for the non-blinded part of the route (e.g. trampoline fees). + + Raises FeeBudgetExceeded if the blinded path fees or cltv exceed the budget. + """ + from .lnrouter import fee_for_edge_msat + blinded_fee_msat = fee_for_edge_msat( + forwarded_amount_msat=amount_msat, + fee_base_msat=blinded_payinfo.fee_base_msat, + fee_proportional_millionths=blinded_payinfo.fee_proportional_millionths, + ) + remaining_fee_msat = self.fee_msat - blinded_fee_msat + remaining_cltv = self.cltv - blinded_payinfo.cltv_expiry_delta + if remaining_fee_msat < 0 or remaining_cltv < 0: + raise FeeBudgetExceeded( + f"blinded path fees exceed budget: " + f"{blinded_fee_msat=}, fee budget: {self.fee_msat}, " + f"{blinded_payinfo.cltv_expiry_delta=}, cltv budget: {self.cltv=}" + ) + return PaymentFeeBudget(fee_msat=remaining_fee_msat, cltv=remaining_cltv) + @dataclasses.dataclass(kw_only=True, frozen=True) class UnblindedRoutingInfo: diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py index 5df8755ce524..fe3439c08ad0 100644 --- a/tests/test_lnutil.py +++ b/tests/test_lnutil.py @@ -10,8 +10,9 @@ derive_privkey, derive_pubkey, make_htlc_tx, extract_ctn_from_tx, get_compressed_pubkey_from_bech32, ScriptHtlc, calc_fees_for_commitment_tx, UpdateAddHtlc, LnFeatures, ln_compare_features, IncompatibleLightningFeatures, ChannelType, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat, - ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget, LnFeatureContexts + ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget, LnFeatureContexts, FeeBudgetExceeded ) +from electrum.lnonion import BlindedPayInfo from electrum.util import bfh, MyEncoder from electrum.transaction import Transaction, PartialTransaction, Sighash from electrum.lnworker import LNWallet @@ -1190,4 +1191,22 @@ async def test_payment_fee_budget(self): self.assertEqual(budget.fee_msat, config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT) self.assertEqual(reversed_fee_msat, budget.fee_msat) - + # test subtract_blinded_path_fees + budget = PaymentFeeBudget(fee_msat=10_000, cltv=500) + payinfo = BlindedPayInfo( + fee_base_msat=1000, + fee_proportional_millionths=100, + cltv_expiry_delta=144, + htlc_minimum_msat=0, + htlc_maximum_msat=2**32, + features=LnFeatures(0), + ) + # blinded fee: 1000 + (100 * 100_000) // 1_000_000 = 1010 + remaining = budget.subtract_blinded_path_fees(payinfo, amount_msat=100_000) + self.assertEqual(remaining.fee_msat, 10_000 - 1010) + self.assertEqual(remaining.cltv, 500 - 144) + + # test that exceeding the budget raises + small_budget = PaymentFeeBudget(fee_msat=500, cltv=500) + with self.assertRaises(FeeBudgetExceeded): + small_budget.subtract_blinded_path_fees(payinfo, amount_msat=100_000) From 9317bf875199ba69359ea7a38c822d95bfc02c97 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 38/45] lnonion: also detect final trampoline forwarder by payload keys Don't decide if we are final based solely on the hmac, for legacy trampoline forwarding this won't work assuming the sender doesn't add a dummy onion layer. --- electrum/lnonion.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index a95562ee9109..db8a9647d448 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -703,11 +703,12 @@ def process_onion_packet( public_key=next_public_key, hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) - if hop_data.hmac == bytes(PER_HOP_HMAC_SIZE): - # we are the destination / exit node - are_we_final = True - else: - # we are an intermediate node; forwarding + + # decide if we are recipient or forwarder + are_we_final = hop_data.hmac == bytes(PER_HOP_HMAC_SIZE) + next_hop_keys = ('outgoing_node_id', 'outgoing_blinded_paths') + if is_trampoline and any(key in hop_data.payload for key in next_hop_keys): + # we are the final trampoline forwarder during a legacy trampoline payment are_we_final = False next_path_key = blinded_path_recipient_data.get('next_path_key_override', {}).get('path_key') From ee94703b6d92faaff7cbb946612718869b4ff684 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 18 Mar 2026 17:06:42 +0100 Subject: [PATCH 39/45] trampoline: change invoice_features tlv from u64 to bytes Eclair interprets the invoice_features tlv for legacy trampoline payments as bytes, potentially this u64 conversion is outdated? This changes the invoice_features tlv to bytes type. See eclair source (grep "invoiceFeatures"). https://github.com/ACINQ/eclair/blob/a4d66adce2ec180c94532d1d0014f86f6e74f607/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala#L638 --- electrum/lnwire/onion_wire.csv | 2 +- electrum/lnworker.py | 2 +- electrum/trampoline.py | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv index d63d8401d5b5..2d22f38e07b9 100644 --- a/electrum/lnwire/onion_wire.csv +++ b/electrum/lnwire/onion_wire.csv @@ -16,7 +16,7 @@ tlvdata,payload,payment_metadata,payment_metadata,byte,... tlvtype,payload,total_amount_msat,18 tlvdata,payload,total_amount_msat,total_msat,tu64, tlvtype,payload,invoice_features,66097 -tlvdata,payload,invoice_features,invoice_features,u64, +tlvdata,payload,invoice_features,invoice_features,byte,... tlvtype,payload,outgoing_node_id,66098 tlvdata,payload,outgoing_node_id,outgoing_node_id,byte,33 tlvtype,payload,invoice_routing_info,66099 diff --git a/electrum/lnworker.py b/electrum/lnworker.py index e3e1f16c5f61..3ec81c4485eb 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4216,7 +4216,7 @@ async def _maybe_forward_trampoline( if "invoice_features" in payload: self.logger.info('forward_trampoline: legacy') next_trampoline_onion = None - invoice_features = payload["invoice_features"]["invoice_features"] + invoice_features = int.from_bytes(payload["invoice_features"]["invoice_features"], byteorder="big") invoice_routing_info = payload["invoice_routing_info"]["invoice_routing_info"] r_tags = decode_routing_info(invoice_routing_info) self.logger.info(f'r_tags {BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)}') diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 92ae616b0972..0c926e59f12c 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -327,8 +327,6 @@ def create_trampoline_route( # Due to space constraints it is not guaranteed for all route hints to get included in the onion invoice_routing_info: List[bytes] = encode_routing_info(r_tags) assert invoice_routing_info == encode_routing_info(decode_routing_info(b''.join(invoice_routing_info))) - # lnwire invoice_features for trampoline is u64 - invoice_features = invoice_features & 0xffffffffffffffff route[-1].invoice_routing_info = invoice_routing_info route[-1].invoice_features = invoice_features route[-1].outgoing_node_id = invoice_pubkey @@ -405,7 +403,7 @@ def create_trampoline_onion( } # legacy if i == num_hops - 2 and route_edge.invoice_features: - payload["invoice_features"] = {"invoice_features": route_edge.invoice_features} + payload["invoice_features"] = {"invoice_features": LnFeatures(route_edge.invoice_features).to_tlv_bytes()} routing_info_payload_index = i payload["payment_data"] = { "payment_secret": payment_secret, From c618ebdbdc12f34b41582132db8f31a930a88bd5 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 1 Apr 2026 16:02:07 +0200 Subject: [PATCH 40/45] trampoline: add blinded payment support Update the trampoline payment logic in LNWallet to handle payments to/through blinded paths. This allows for bolt 12 trampoline payments. Also stops including a trampoline onion layer for the recipient of a legacy trampoline payment. A non-trampoline aware (legacy) receiver cannot read this onion and it is not required for Eclair compatibility anymore. --- electrum/lnonion.py | 18 +- electrum/lnrouter.py | 4 +- electrum/lnwire/onion_wire.csv | 5 + electrum/lnworker.py | 36 ++-- electrum/trampoline.py | 333 +++++++++++++++++++++++---------- tests/test_lnrouter.py | 81 ++++---- 6 files changed, 320 insertions(+), 157 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index db8a9647d448..523fd3fa0daa 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -45,7 +45,7 @@ from . import util if TYPE_CHECKING: - from .lnrouter import LNPaymentRoute + from .lnrouter import LNPaymentRoute, FinalForwardFees _logger = get_logger(__name__) @@ -427,7 +427,8 @@ def calc_hops_data_for_payment( *, final_cltv_abs: int, total_msat: int, - payment_secret: bytes, + payment_secret: Optional[bytes], + final_forward_fees: Optional['FinalForwardFees'] = None, ) -> Tuple[List[OnionHopsDataSingle], int, int]: """Returns the hops_data to be used for constructing an onion packet, and the amount_msat and cltv_abs to be used on our immediate channel. @@ -439,15 +440,20 @@ def calc_hops_data_for_payment( # payload that will be seen by the last hop: # for multipart payments we need to tell the receiver about the total and # partial amounts - hop_payload = { + final_hop_payload: dict = { "amt_to_forward": {"amt_to_forward": amt}, "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, - "payment_data": { + } + if payment_secret: # None if blinded legacy trampoline payment + final_hop_payload["payment_data"] = { "payment_secret": payment_secret, "total_msat": total_msat, "amount_msat": amt, - }} - hops_data = [OnionHopsDataSingle(payload=hop_payload)] + } + hops_data = [OnionHopsDataSingle(payload=final_hop_payload)] + if final_forward_fees is not None: + amt += final_forward_fees.fee_for_edge(amt) + cltv_abs += final_forward_fees.forwarder_cltv_delta + final_forward_fees.blinded_path_cltv_delta # payloads, backwards from last hop (but excluding the first edge): for route_edge in reversed(route[1:]): hop_payload = { diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 45ed2f8a32a9..05324fa7fa55 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -34,6 +34,7 @@ import attr +from .lnonion import BlindedPathInfo from .util import profiler, with_lock from .logging import Logger from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures, @@ -125,7 +126,8 @@ def is_trampoline(self) -> bool: @attr.s class TrampolineEdge(RouteEdge): - invoice_routing_info = attr.ib(type=Sequence[bytes], default=None) + # r-tags (non e2e bolt11) or a sequence of `payment_blinded_path` (non e2e bolt12) + invoice_routing_info = attr.ib(type=Sequence[bytes] | Sequence[BlindedPathInfo], default=None) invoice_features = attr.ib(type=int, default=None) # this is re-defined from parent just to specify a default value: short_channel_id = attr.ib(default=ShortChannelID(8), repr=lambda val: str(val)) diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv index 2d22f38e07b9..1dccc1df0276 100644 --- a/electrum/lnwire/onion_wire.csv +++ b/electrum/lnwire/onion_wire.csv @@ -23,6 +23,8 @@ tlvtype,payload,invoice_routing_info,66099 tlvdata,payload,invoice_routing_info,invoice_routing_info,byte,... tlvtype,payload,trampoline_onion_packet,66100 tlvdata,payload,trampoline_onion_packet,trampoline_onion_packet,byte,... +tlvtype,payload,outgoing_blinded_paths,66102 +tlvdata,payload,outgoing_blinded_paths,paths,payment_blinded_path,... tlvtype,encrypted_data_tlv,padding,1 tlvdata,encrypted_data_tlv,padding,padding,byte,... tlvtype,encrypted_data_tlv,short_channel_id,2 @@ -244,6 +246,9 @@ subtypedata,blinded_payinfo,htlc_minimum_msat,u64, subtypedata,blinded_payinfo,htlc_maximum_msat,u64, subtypedata,blinded_payinfo,flen,u16, subtypedata,blinded_payinfo,features,byte,flen +subtype,payment_blinded_path +subtypedata,payment_blinded_path,blinded_path,blinded_path, +subtypedata,payment_blinded_path,payment_info,blinded_payinfo, subtype,fallback_address subtypedata,fallback_address,version,byte, subtypedata,fallback_address,len,u16, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 3ec81c4485eb..7d35d0df9820 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -90,7 +90,7 @@ from .lnmsg import decode_msg, OnionWireSerializer from .lnrouter import ( RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, - LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, FinalForwardFees + LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, FinalForwardFees, ) from .lnwatcher import LNWatcher from .submarine_swaps import SwapManager @@ -2405,18 +2405,26 @@ def suggest_payment_splits( have_direct_channel = any(chan.node_id in recipient_pubkeys for chan in my_active_channels) self.logger.info(f"channels_with_funds: {channels_with_funds}, {have_direct_channel=}") exclude_single_part_payments = False + exclude_multinode_payments = False if self.uses_trampoline(): - # in the case of a legacy payment, we don't allow splitting via different - # trampoline nodes, because of https://github.com/ACINQ/eclair/issues/2127 - exclude_multinode_payments = False if isinstance(routing_info, UnblindedRoutingInfo): + # in the case of a legacy payment, we don't allow splitting via different + # trampoline nodes, because of https://github.com/ACINQ/eclair/issues/2127 is_legacy, _ = is_legacy_relay(invoice_features, routing_info.r_tags) - exclude_multinode_payments = is_legacy + else: + # In blinded payments the total_amount has to be included in the payment recipients onion. + # During blinded legacy payments the last trampoline forwarder gets the blinded path(s) in his trampoline onion + # and constructs the onion for the recipient. There is no specified mechanism to tell the + # last trampoline forwarder the total payment amount, and Eclair just uses the amount_to_forward + # as total_amount when forwarding the payment, so multinode cannot work. See: + # https://github.com/ACINQ/eclair/blob/2dda79468a8b69a2acf7962cdac63245f7cc3ee8/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala#L281 + is_legacy, _ = is_legacy_relay(invoice_features, None) + is_legacy = True # FIXME: blinded payments are always legacy, see create_trampoline_route(). + exclude_multinode_payments = is_legacy # we don't split within a channel when sending to a trampoline node, # the trampoline node will split for us exclude_single_channel_splits = not self.config.TEST_FORCE_MPP else: - exclude_multinode_payments = False exclude_single_channel_splits = False if invoice_features.supports(LnFeatures.BASIC_MPP_OPT) and not self.config.TEST_FORCE_DISABLE_MPP: # if amt is still large compared to total_msat, split it: @@ -2483,6 +2491,7 @@ async def create_routes_for_payment( routes = [] try: destination_pubkey = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey + # FIXME: determine is_direct_path per sc key is_direct_path = all(node_id == destination_pubkey for (chan_id, node_id) in sc.config.keys()) if self.uses_trampoline() and not is_direct_path: if fwd_trampoline_onion: @@ -2496,30 +2505,25 @@ async def create_routes_for_payment( # for each trampoline forwarder, construct mpp trampoline for trampoline_node_id, trampoline_parts in per_trampoline_channel_amounts.items(): per_trampoline_amount = sum([x[1] for x in trampoline_parts]) - if not isinstance(routing_info, UnblindedRoutingInfo): - raise NotImplementedError trampoline_route, trampoline_onion, per_trampoline_amount_with_fees, per_trampoline_cltv_delta = create_trampoline_route_and_onion( amount_msat=per_trampoline_amount, total_msat=paysession.amount_to_pay, my_pubkey=self.node_keypair.pubkey, - min_final_cltv_delta=routing_info.final_cltv_delta, - invoice_pubkey=routing_info.node_pubkey, - invoice_features=routing_info.invoice_features, - r_tags=routing_info.r_tags, - payment_secret=routing_info.payment_secret, - node_id=trampoline_node_id, + my_trampoline=trampoline_node_id, payment_hash=paysession.payment_hash, local_height=local_height, trampoline_fee_level=paysession.trampoline_fee_level, next_trampolines=paysession.next_trampolines.get(trampoline_node_id, {}), failed_routes=paysession.failed_trampoline_routes, budget=budget._replace(fee_msat=budget.fee_msat // len(per_trampoline_channel_amounts)), + routing_info=routing_info, + blinded_path=blinded_path, ) # node_features is only used to determine is_tlv per_trampoline_secret = os.urandom(32) per_trampoline_fees = per_trampoline_amount_with_fees - per_trampoline_amount self.logger.info(f'created route with trampoline fee level={paysession.trampoline_fee_level}') - self.logger.info(f'trampoline hops: {[hop.end_node.hex() for hop in trampoline_route]}') + self.logger.info(f'trampoline hops: {[hop.end_node.hex() for hop in trampoline_route.edges]}') self.logger.info(f'per trampoline fees: {per_trampoline_fees}') for chan_id, part_amount_msat in trampoline_parts: chan = self._channels[chan_id] @@ -2546,7 +2550,7 @@ async def create_routes_for_payment( bucket_msat=per_trampoline_amount_with_fees, amount_receiver_msat=part_amount_msat, trampoline_fee_level=paysession.trampoline_fee_level, - trampoline_route=trampoline_route, + trampoline_route=trampoline_route.edges, per_trampoline_payment_secret=per_trampoline_secret, # blinded path is embedded in the trampoline onion for last trampoline forwarder blinded_path=None, diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 0c926e59f12c..c0a39ab3c20b 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -4,15 +4,17 @@ import dataclasses from fractions import Fraction from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any, TYPE_CHECKING -from types import MappingProxyType import electrum_ecc as ecc -from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded +from .lnutil import ( + LnFeatures, PaymentFeeBudget, FeeBudgetExceeded, UnblindedRoutingInfo, BlindedRoutingInfo, + MIN_FINAL_CLTV_DELTA_ACCEPTED, +) from .lnonion import ( - calc_hops_data_for_payment, new_onion_packet, OnionPacket + new_onion_packet, OnionPacket, calc_hops_data_for_blinded_payment, calc_hops_data_for_payment, BlindedPathInfo ) -from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute +from .lnrouter import TrampolineEdge, is_route_within_budget, fee_for_edge_msat, FinalForwardFees from .lnutil import NoPathFound from .lntransport import LNPeerAddr from . import constants @@ -20,6 +22,7 @@ from .util import random_shuffled_copy if TYPE_CHECKING: + from .lnutil import RoutingInfo from .lnchannel import Channel @@ -147,17 +150,22 @@ def decode_routing_info(rinfo: bytes) -> Sequence[Sequence[Sequence[Any]]]: return r_tags -def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, Set[bytes]]: +def is_legacy_relay(invoice_features, r_tags: Optional[Iterable]) -> Tuple[bool, Set[bytes]]: """Returns if we deal with a legacy payment and the list of trampoline pubkeys in the invoice. """ invoice_features = LnFeatures(invoice_features) # trampoline-supporting wallets: if invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR)\ or invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM): + # Unblinded payment (BOLT11): # If there are no r_tags (routing hints) included, the wallet doesn't have # private channels and is probably directly connected to a trampoline node. # Any trampoline node should be able to figure out a path to the receiver and # we can use an e2e payment. + # Blinded payment (BOLT12): + # https://github.com/lightning/bolts/blob/bc7a1a0bc97b2293e7f43dd8a06529e5fdcf7cd2/proposals/trampoline.md#with-bolt-12-invoices + # r_tags are always None, if the invoice signals trampoline we assume every node id on the + # blinded path is a trampoline node. Otherwise, it is 'legacy', but without the risks of bolt11 legacy payments. if not r_tags: return False, set() else: @@ -179,6 +187,21 @@ def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, Set[bytes]]: PLACEHOLDER_FEE = None +DEFAULT_TRAMPOLINE_CLTV_DELTA = 576 + + +@dataclasses.dataclass(kw_only=True) +class _FinalForwardPlaceholder: + """Mutable placeholder for route fee allocation""" + fee_base_msat: Optional[int] = PLACEHOLDER_FEE + fee_proportional_millionths: Optional[int] = PLACEHOLDER_FEE + + +@dataclasses.dataclass(kw_only=True) +class TrampolineRoute: + edges: List[TrampolineEdge] + is_legacy: bool + final_forward_fees: Optional[FinalForwardFees] def _extend_trampoline_route( @@ -197,7 +220,7 @@ def _extend_trampoline_route( # note: trampoline nodes are supposed to advertise their fee and cltv in node_update message. # However, in the temporary spec, they do not. # They also don't send their fee policy in the error message if we lowball the fee... - fee_base, fee_proportional, cltv_delta = fee_info if fee_info else (PLACEHOLDER_FEE, PLACEHOLDER_FEE, 576) + fee_base, fee_proportional, cltv_delta = fee_info if fee_info else (PLACEHOLDER_FEE, PLACEHOLDER_FEE, DEFAULT_TRAMPOLINE_CLTV_DELTA) route.append( TrampolineEdge( start_node=start_node, @@ -221,7 +244,7 @@ def get_trampoline_budget(trampoline_fee_level: int, budget_msat: int) -> int: def _allocate_fee_budget_among_route( - route: Sequence[TrampolineEdge], + route: Sequence[TrampolineEdge | _FinalForwardPlaceholder], *, usable_budget_msat: int, amount_msat_for_dest: int, @@ -291,45 +314,68 @@ def _choose_next_trampoline( def create_trampoline_route( *, amount_msat: int, - min_final_cltv_delta: int, - invoice_pubkey: bytes, - invoice_features: int, + routing_info: 'RoutingInfo', my_pubkey: bytes, my_trampoline: bytes, # the first trampoline in the path; which we are directly connected to - r_tags, trampoline_fee_level: int, next_trampolines: dict, failed_routes: Iterable[Sequence[str]], budget: PaymentFeeBudget, -) -> LNPaymentTRoute: + blinded_path: Optional['BlindedPathInfo'], +) -> TrampolineRoute: # we decide whether to convert to a legacy payment - is_legacy, invoice_trampolines = is_legacy_relay(invoice_features, r_tags) + unblinded_payment_r_tags = routing_info.r_tags if isinstance(routing_info, UnblindedRoutingInfo) else None + is_legacy, invoice_trampolines = is_legacy_relay(routing_info.invoice_features, unblinded_payment_r_tags) # we can be in the invoice_trampolines e.g. if we have a direct channel with the recipient invoice_trampolines.discard(my_pubkey) - _logger.debug(f"Creating trampoline route for invoice_pubkey={invoice_pubkey.hex()}, {is_legacy=}") + if blinded_path: + assert isinstance(routing_info, BlindedRoutingInfo) and blinded_path.payinfo + # FIXME: eclair doesn't yet implement end-to-end blinded payments, so we have to fall back to legacy. + # see: https://github.com/ACINQ/eclair/pull/2819 + is_legacy = True + + _logger.debug(f"Creating trampoline route for blinded payment. " + f"IP={blinded_path.path.first_node_id.hex()} {blinded_path.path.hop_count=}, {is_legacy=}") + else: + assert isinstance(routing_info, UnblindedRoutingInfo) + _logger.debug(f"Creating trampoline route for invoice_pubkey={routing_info.node_pubkey.hex()}, {is_legacy=}") # we build a route of trampoline hops and extend the route list in place - route = [] + route: list[TrampolineEdge] = [] + final_node_pubkey = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey # our first trampoline hop is decided by the channel we use _extend_trampoline_route( route, start_node=my_pubkey, end_node=my_trampoline, fee_info=(0, 0, 0) ) + next_trampolines = next_trampolines.copy() # don't mutate PaySessions next_trampolines next_trampolines.pop(my_pubkey, None) + # prevent routing through the final node + next_trampolines.pop(final_node_pubkey, None) next_trampolines_ids = list(next_trampolines.keys()) + final_forward_placeholder: Optional[_FinalForwardPlaceholder] = None if is_legacy: if next_trampolines: trampoline_id = _choose_next_trampoline(my_trampoline, next_trampolines_ids, failed_routes) _extend_trampoline_route(route, end_node=trampoline_id, fee_info = next_trampolines[trampoline_id]) - # the last trampoline onion must contain routing hints for the last trampoline - # node to find the recipient - # Due to space constraints it is not guaranteed for all route hints to get included in the onion - invoice_routing_info: List[bytes] = encode_routing_info(r_tags) - assert invoice_routing_info == encode_routing_info(decode_routing_info(b''.join(invoice_routing_info))) - route[-1].invoice_routing_info = invoice_routing_info - route[-1].invoice_features = invoice_features - route[-1].outgoing_node_id = invoice_pubkey + # the last trampoline onion must contain routing information for the last trampoline node to find the recipient + route[-1].invoice_features = routing_info.invoice_features + if blinded_path: + # The last trampoline forwarder will use one of the provided paths and forward to its introduction point. + # We could include multiple blinded paths and let the trampoline node decide which one to use. + # However, considering that we ourselves are stuffing a lot of (recipient-) data into the blinded path + # space might get tight when putting multiple paths into the trampoline onion. Also, fee allocation + # for the last trampoline node seems simpler when we know which path will be used, otherwise we'd have + # to assume it uses the most expensive one to not under-allocate? + route[-1].invoice_routing_info = [blinded_path] + else: + # Due to space constraints it is not guaranteed for all route hints to get included in the onion + invoice_routing_info: List[bytes] = encode_routing_info(routing_info.r_tags) + assert invoice_routing_info == encode_routing_info(decode_routing_info(b''.join(invoice_routing_info))) + route[-1].invoice_routing_info = invoice_routing_info + # set fee placeholder for final trampoline so it can pay for the route to the recipient/IP + final_forward_placeholder = _FinalForwardPlaceholder() else: next_trampoline = my_trampoline # maybe add second trampoline @@ -340,96 +386,190 @@ def create_trampoline_route( if invoice_trampolines and next_trampoline not in invoice_trampolines: invoice_trampoline = _choose_next_trampoline(next_trampoline, invoice_trampolines, failed_routes) _extend_trampoline_route(route, end_node=invoice_trampoline) - - # Add final edge. note: eclair requires an encrypted t-onion blob even in legacy case. - # Also needed for fees for last TF! - if route[-1].end_node != invoice_pubkey: - _extend_trampoline_route(route, end_node=invoice_pubkey) - - # replace placeholder fees in route - usable_budget_msat = get_trampoline_budget(trampoline_fee_level, budget.fee_msat) + # the recipient is itself a trampoline node, so it is the last hop and gets its own trampoline onion + if route[-1].end_node != final_node_pubkey: + # fee_info is not passed for e2e blinded paths, even though we know from the blinded_payinfo, + # because `calc_hops_data_for_blinded_payment` itself takes the payinfo and allocates the fee + _extend_trampoline_route(route, end_node=final_node_pubkey) + + # trampoline fee allocation + # if there is a blinded path, the fees required for the blinded path are accounted into the available budget. + unblinded_path_budget = budget.subtract_blinded_path_fees(blinded_path.payinfo, amount_msat) if blinded_path else budget + usable_budget_msat = get_trampoline_budget(trampoline_fee_level, unblinded_path_budget.fee_msat) _logger.debug(f"create_trampoline_route: {trampoline_fee_level=}, {usable_budget_msat=}") + # the trampolines need to carry the fee for the blinded path, so add it to the recipient (IP) amount for the allocation + amount_for_dest = amount_msat + fee_for_edge_msat( + forwarded_amount_msat=amount_msat, + fee_base_msat=blinded_path.payinfo.fee_base_msat, + fee_proportional_millionths=blinded_path.payinfo.fee_proportional_millionths, + ) if blinded_path else amount_msat + fee_carrying_edges: list[TrampolineEdge | _FinalForwardPlaceholder] = list(route) + if final_forward_placeholder is not None: + fee_carrying_edges.append(final_forward_placeholder) _allocate_fee_budget_among_route( - route, + fee_carrying_edges, usable_budget_msat=usable_budget_msat, - amount_msat_for_dest=amount_msat, + amount_msat_for_dest=amount_for_dest, # dest is IP (blinded) or payment recipient (unblinded) ) + # Allocate additional blinded path fees to the budget for the last unblinded trampoline so it can pay for the blinded path. + blinded_payinfo = blinded_path.payinfo if blinded_path else None + final_forward_fees: Optional[FinalForwardFees] = None + budget_final_forward_fees: Optional[FinalForwardFees] = None # only used for the budget check below, excluding bp cltv + if final_forward_placeholder is not None: + # unblinded or blinded legacy payment + assert final_forward_placeholder.fee_base_msat is not None, "no base trampoline fee allocated?" + budget_final_forward_fees = final_forward_fees = FinalForwardFees( + fee_base_msat=final_forward_placeholder.fee_base_msat \ + + (blinded_payinfo.fee_base_msat if blinded_payinfo else 0), + fee_proportional_millionths=final_forward_placeholder.fee_proportional_millionths \ + + (blinded_payinfo.fee_proportional_millionths if blinded_payinfo else 0), + forwarder_cltv_delta=DEFAULT_TRAMPOLINE_CLTV_DELTA, + blinded_path_cltv_delta=blinded_payinfo.cltv_expiry_delta if blinded_payinfo else 0, + ) + elif blinded_payinfo is not None: + # e2e blinded payment, the recipient/IP is already made part of the route above and got its own allocation. + # But we still need to consider the blinded path fees in the budget check below. They have not been added to + # the route edge as 'calc_hops_data_for_blinded_payment' handles them internally. + budget_final_forward_fees = FinalForwardFees( + fee_base_msat=blinded_payinfo.fee_base_msat, + fee_proportional_millionths=blinded_payinfo.fee_proportional_millionths, + ) + + trampoline_route = TrampolineRoute(edges=route, is_legacy=is_legacy, final_forward_fees=final_forward_fees) + # check that we can pay amount and fees + min_final_cltv_delta = blinded_path.payinfo.cltv_expiry_delta + routing_info.final_cltv_delta \ + if blinded_path else routing_info.final_cltv_delta if not is_route_within_budget( - route=route, - budget=budget, + route=trampoline_route.edges, # unblinded route (forwarders only) + budget=budget, # whole budget amount_msat_for_dest=amount_msat, cltv_delta_for_dest=min_final_cltv_delta, + final_forward_fees=budget_final_forward_fees, ): - raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget}") - return route + raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget=} {unblinded_path_budget=}") + + return trampoline_route def create_trampoline_onion( *, - route: LNPaymentTRoute, + trampoline_route: TrampolineRoute, + routing_info: 'BlindedRoutingInfo | UnblindedRoutingInfo', + blinded_path: Optional['BlindedPathInfo'], amount_msat: int, - final_cltv_abs: int, + local_height: int, total_msat: int, payment_hash: bytes, - payment_secret: bytes, ) -> Tuple[OnionPacket, int, int]: - # all edges are trampoline - 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) - # detect trampoline hops. - payment_path_pubkeys = [x.node_id for x in route] - num_hops = len(payment_path_pubkeys) + route, is_legacy = trampoline_route.edges, trampoline_route.is_legacy + final_cltv_abs = local_height + routing_info.final_cltv_delta + if blinded_path and not is_legacy: # every node on the blinded path is a trampoline + assert blinded_path.payinfo + hops_data, blinded_path_pubkeys, amount_msat, cltv_abs = calc_hops_data_for_blinded_payment( + route_to_introduction_point=route, + recipient_amount_msat=amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path, + ) + else: + 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=routing_info.payment_secret if not blinded_path else None, + final_forward_fees=trampoline_route.final_forward_fees, + ) + + # detect trampoline hops. mutate hops_data payloads to make them trampoline payloads. + payment_path_pubkeys = [e.end_node for e in trampoline_route.edges] + num_unblinded_hops = len(payment_path_pubkeys) routing_info_payload_index: Optional[int] = None - for i in range(num_hops): + for i in range(num_unblinded_hops): route_edge = route[i] assert route_edge.is_trampoline() payload = dict(hops_data[i].payload) - if i < num_hops - 1: - payload.pop('short_channel_id') + + # replace `short_channel_id` with next `outgoing_node_id` + if i < num_unblinded_hops - 1: next_edge = route[i+1] assert next_edge.is_trampoline() + del payload["short_channel_id"] payload["outgoing_node_id"] = {"outgoing_node_id": next_edge.node_id} - # only for final - if i == num_hops - 1: - payload["payment_data"] = { - "payment_secret": payment_secret, - "total_msat": total_msat - } - # legacy - if i == num_hops - 2 and route_edge.invoice_features: - payload["invoice_features"] = {"invoice_features": LnFeatures(route_edge.invoice_features).to_tlv_bytes()} - routing_info_payload_index = i - payload["payment_data"] = { - "payment_secret": payment_secret, - "total_msat": total_msat - } + + # final (unblinded) hop + elif i == num_unblinded_hops - 1: + if blinded_path: + assert "payment_data" not in payload, hops_data + if is_legacy: + assert route_edge.invoice_routing_info is not None, route + # include blinded paths so the trampoline can route to the recipient + routing_info_payload_index = i + payload["invoice_features"] = {"invoice_features": LnFeatures(route_edge.invoice_features).to_tlv_bytes()} + else: + assert "short_channel_id" not in payload, hops_data + assert "current_path_key" in payload, payload + else: + payload["payment_data"] = { + "payment_secret": routing_info.payment_secret, + "total_msat": total_msat + } + if is_legacy: + # payload for the last trampoline forwarder to find the recipient + assert route_edge.invoice_features is not None, route + routing_info_payload_index = i + payload["outgoing_node_id"] = {"outgoing_node_id": routing_info.node_pubkey} + payload["invoice_features"] = { + "invoice_features": LnFeatures(route_edge.invoice_features).to_tlv_bytes() + } + hops_data[i] = dataclasses.replace(hops_data[i], payload=payload) + if blinded_path and not is_legacy: + payment_path_pubkeys = payment_path_pubkeys + blinded_path_pubkeys + assert len(hops_data) == len(payment_path_pubkeys), (hops_data, payment_path_pubkeys) + if (index := routing_info_payload_index) is not None: - # fill the remaining payload space with available routing hints (r_tags) payload = dict(hops_data[index].payload) - # try different r_tag order on each attempt - invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info) remaining_payload_space = TRAMPOLINE_HOPS_MAX_DATA_SIZE - sum(len(hop.to_bytes()) for hop in hops_data) - routing_info_to_use = [] - for encoded_r_tag in invoice_routing_info: - if remaining_payload_space < 50: - break # no r_tag will fit here anymore - r_tag_size = len(encoded_r_tag) - if r_tag_size > remaining_payload_space: - continue - routing_info_to_use.append(encoded_r_tag) - remaining_payload_space -= r_tag_size - # add the chosen r_tags to the payload - payload["invoice_routing_info"] = {"invoice_routing_info": b''.join(routing_info_to_use)} + if blinded_path: + assert route[index].invoice_routing_info, route + paths = [] + hop_base_size = len(hops_data[index].to_bytes()) + for bpi in route[index].invoice_routing_info: + assert isinstance(bpi, BlindedPathInfo) + candidate_paths = paths + [{ + 'blinded_path': dataclasses.asdict(bpi.path), + 'payment_info': bpi.payinfo.to_dict(), + }] + candidate_payload = {**payload, "outgoing_blinded_paths": {"paths": candidate_paths}} + paths_size = len(dataclasses.replace(hops_data[index], payload=candidate_payload).to_bytes()) - hop_base_size + if paths_size > remaining_payload_space: + if not paths: + raise NoPathFound(f"couldn't fit a single blinded path in trampoline onion. {paths_size=}") + break + paths = candidate_paths + payload["outgoing_blinded_paths"] = {"paths": paths} + else: + # fill the remaining payload space with available routing hints (r_tags) + # try different r_tag order on each attempt + invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info) + routing_info_to_use = [] + for encoded_r_tag in invoice_routing_info: + if remaining_payload_space < 50: + break # no r_tag will fit here anymore + r_tag_size = len(encoded_r_tag) + if r_tag_size > remaining_payload_space: + continue + routing_info_to_use.append(encoded_r_tag) + remaining_payload_space -= r_tag_size + # add the chosen r_tags to the payload + payload["invoice_routing_info"] = {"invoice_routing_info": b''.join(routing_info_to_use)} + _logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags") hops_data[index] = dataclasses.replace(hops_data[index], payload=payload) - _logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags") trampoline_session_key = os.urandom(32) trampoline_onion = new_onion_packet(payment_path_pubkeys, trampoline_session_key, hops_data, associated_data=payment_hash, trampoline=True) @@ -445,42 +585,39 @@ def create_trampoline_route_and_onion( *, amount_msat: int, # that final receiver gets total_msat: int, - min_final_cltv_delta: int, - invoice_pubkey: bytes, - invoice_features, + routing_info: 'UnblindedRoutingInfo | BlindedRoutingInfo', my_pubkey: bytes, - node_id: bytes, - r_tags, + my_trampoline: bytes, payment_hash: bytes, - payment_secret: bytes, local_height: int, trampoline_fee_level: int, next_trampolines: dict, failed_routes: Iterable[Sequence[str]], budget: PaymentFeeBudget, -) -> Tuple[LNPaymentTRoute, OnionPacket, int, int]: + blinded_path: Optional['BlindedPathInfo'], +) -> Tuple[TrampolineRoute, OnionPacket, int, int]: # create route for the trampoline_onion + # blinded path hops are not appended to this route trampoline_route = create_trampoline_route( amount_msat=amount_msat, - min_final_cltv_delta=min_final_cltv_delta, + routing_info=routing_info, my_pubkey=my_pubkey, - invoice_pubkey=invoice_pubkey, - invoice_features=invoice_features, - my_trampoline=node_id, - r_tags=r_tags, + my_trampoline=my_trampoline, trampoline_fee_level=trampoline_fee_level, next_trampolines=next_trampolines, failed_routes=failed_routes, budget=budget, + blinded_path=blinded_path, ) # compute onion and fees - final_cltv_abs = local_height + min_final_cltv_delta trampoline_onion, amount_with_fees, bucket_cltv_abs = create_trampoline_onion( - route=trampoline_route, + trampoline_route=trampoline_route, + routing_info=routing_info, + blinded_path=blinded_path, amount_msat=amount_msat, - final_cltv_abs=final_cltv_abs, + local_height=local_height, total_msat=total_msat, payment_hash=payment_hash, - payment_secret=payment_secret) + ) bucket_cltv_delta = bucket_cltv_abs - local_height return trampoline_route, trampoline_onion, amount_with_fees, bucket_cltv_delta diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py index 3ad775cf691d..7053900f59ee 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -7,10 +7,9 @@ from electrum import util from electrum.channel_db import NodeInfo from electrum.onion_message import is_onion_message_node -from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE, - get_trampoline_budget) +from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE, get_trampoline_budget, TrampolineRoute) from electrum.util import bfh -from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget +from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget, UnblindedRoutingInfo from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet, process_onion_packet, _decode_onion_error, decode_onion_error, OnionFailureCode) @@ -18,7 +17,8 @@ from electrum.constants import BitcoinTestnet from electrum.simple_config import SimpleConfig from electrum.lnrouter import (PathEdge, LiquidityHintMgr, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH, - DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge) + DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge, + FinalForwardFees) from . import ElectrumTestCase from .test_bitcoin import needs_test_with_all_chacha20_implementations @@ -459,43 +459,52 @@ def test_process_onion_packet(self): def test_create_legacy_trampoline_onion_multiple_rtags(self): """Test to verify we don't overfill the trampoline onion with r_tags if there are more tags than available space""" - dummy_route: LNPaymentTRoute = [ - TrampolineEdge( - invoice_routing_info=[ - bfh("010305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a03b62b00003a0002000003e8000000010050"), - bfh("01037414fe3dcfedc4a0a0e153205d9a973af5096d1cd1c8c53d07ed12d7dd966f19f424000000000020000003e8000008ca0050"), - bfh("01038550162fa86287884a6a052471934abb5cb261c5a2b15386df8104d3c7bcb85dddd92ee1898ee15c000003e8000000010090"), - bfh("010244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082f424000000000029000003e8000008ca0050") - ], - invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT, - short_channel_id=ShortChannelID.from_str("0x0x0"), - start_node=node('a'), - end_node=node('b'), - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_delta=0, - node_features=0 - ), - TrampolineEdge( - invoice_routing_info=[], - invoice_features=None, - short_channel_id=ShortChannelID.from_str("0x0x0"), - start_node=node('b'), - end_node=node('c'), - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_delta=0, - node_features=0 - ), - ] + troute = TrampolineRoute( + edges=[ + TrampolineEdge( + short_channel_id=ShortChannelID.from_str("0x0x0"), + start_node=node('a'), + end_node=node('b'), + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0, + ), + TrampolineEdge( + invoice_routing_info=[ + bfh("010305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a03b62b00003a0002000003e8000000010050"), + bfh("01037414fe3dcfedc4a0a0e153205d9a973af5096d1cd1c8c53d07ed12d7dd966f19f424000000000020000003e8000008ca0050"), + bfh("01038550162fa86287884a6a052471934abb5cb261c5a2b15386df8104d3c7bcb85dddd92ee1898ee15c000003e8000000010090"), + bfh("010244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082f424000000000029000003e8000008ca0050") + ], + invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT, + short_channel_id=ShortChannelID.from_str("0x0x0"), + start_node=node('b'), + end_node=node('c'), + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0 + ), + ], + is_legacy=True, + final_forward_fees=FinalForwardFees(fee_base_msat=0, fee_proportional_millionths=0, forwarder_cltv_delta=576), + ) # create a trampoline onion, this shouldn't raise InvalidPayloadSize create_trampoline_onion( - route=dummy_route, + trampoline_route=troute, + routing_info=UnblindedRoutingInfo( + node_pubkey=node('d'), + payment_secret=urandom(32), + final_cltv_delta=0, + r_tags=[], + invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT, + ), + blinded_path=None, amount_msat=0, - final_cltv_abs=0, + local_height=0, total_msat=0, payment_hash=urandom(32), - payment_secret=urandom(32), ) @needs_test_with_all_chacha20_implementations From 693384aee10813d395bb051c2731cfb3dc2eefe2 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 1 Jun 2026 11:56:16 +0200 Subject: [PATCH 41/45] tests: test_lnrouter: add trampoline route construction tests --- tests/test_lnrouter.py | 170 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py index 7053900f59ee..d24b35f5ad36 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -1,3 +1,5 @@ +import dataclasses +import os import random import unittest from math import inf @@ -7,12 +9,14 @@ from electrum import util from electrum.channel_db import NodeInfo from electrum.onion_message import is_onion_message_node -from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE, get_trampoline_budget, TrampolineRoute) +from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE, get_trampoline_budget, + TrampolineRoute, create_trampoline_route_and_onion, DEFAULT_TRAMPOLINE_CLTV_DELTA) from electrum.util import bfh -from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget, UnblindedRoutingInfo +from electrum.lnutil import (ShortChannelID, LnFeatures, UnblindedRoutingInfo, PaymentFeeBudget, BlindedRoutingInfo, + FeeBudgetExceeded, MIN_FINAL_CLTV_DELTA_ACCEPTED, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet, process_onion_packet, _decode_onion_error, decode_onion_error, - OnionFailureCode) + OnionFailureCode, BlindedPathInfo) from electrum import bitcoin, lnrouter from electrum.constants import BitcoinTestnet from electrum.simple_config import SimpleConfig @@ -20,7 +24,7 @@ DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge, FinalForwardFees) -from . import ElectrumTestCase +from . import ElectrumTestCase, lnhelpers from .test_bitcoin import needs_test_with_all_chacha20_implementations @@ -507,6 +511,164 @@ def test_create_legacy_trampoline_onion_multiple_rtags(self): payment_hash=urandom(32), ) + def test_create_trampoline_route_and_onion_unblinded_legacy(self): + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), + payment_secret=urandom(32), + final_cltv_delta=144, + r_tags=[], + invoice_features=LnFeatures(0), # no trampoline support + ) + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={}, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=None, + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(len(route.edges), 1) + + # recipient is in next_trampolines + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={ + node('c'): None, # recipient shouldn't be used + node('d'): None, + }, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=None, + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(route.edges[1].start_node, node('b')) + self.assertEqual(route.edges[1].end_node, node('d')) + self.assertEqual(len(route.edges), 2) + + def test_create_trampoline_route_and_onion_blinded_legacy(self): + # random IP + paths = lnhelpers.get_dummy_paths() + routing_info = BlindedRoutingInfo( + paths=paths, + invoice_features=LnFeatures(0), + final_cltv_delta=50, + ) + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={}, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=paths[0], + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(len(route.edges), 1) + + paths = lnhelpers.get_dummy_paths(first_node_id=node('c')) + routing_info = BlindedRoutingInfo( + paths=paths, + invoice_features=LnFeatures(0), + final_cltv_delta=50, + ) + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={ + node('c'): None, # IP is in next_trampolines + node('d'): None, + }, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=paths[0], + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(route.edges[1].start_node, node('b')) + self.assertEqual(route.edges[1].end_node, node('d')) + self.assertEqual(len(route.edges), 2) + + def test_create_trampoline_route_and_onion_blinded_budget(self): + """The destinations final CLTV delta is not deducted from the budget, see PaymentFeeBudget comment""" + paths = lnhelpers.get_dummy_paths() + blinded_cltv = paths[0].payinfo.cltv_expiry_delta + routing_info = BlindedRoutingInfo(paths=paths, invoice_features=LnFeatures(0), final_cltv_delta=50) + + def build(budget_cltv: int, routing_info, blinded_path = None): + return create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={}, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=budget_cltv), + blinded_path=blinded_path, + ) + + # check the blinded paths cltv cost is not accounted into the budget, similar to unblinded min_final_cltv_delta + route, onion, amount_with_fees, bucket_cltv_delta = build(DEFAULT_TRAMPOLINE_CLTV_DELTA, routing_info, paths[0]) + self.assertEqual(bucket_cltv_delta, DEFAULT_TRAMPOLINE_CLTV_DELTA + blinded_cltv + 50) + + # the check raises if there is not enough cltv budget for the hops excluding the IP (node b) + with self.assertRaises(FeeBudgetExceeded): + build(DEFAULT_TRAMPOLINE_CLTV_DELTA - 1, routing_info, paths[0]) + + # check unblinded behavior + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), + payment_secret=urandom(32), + final_cltv_delta=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE - DEFAULT_TRAMPOLINE_CLTV_DELTA, + r_tags=[], + invoice_features=LnFeatures(0), + ) + route, onion, amount_with_fees, bucket_cltv_delta = build(DEFAULT_TRAMPOLINE_CLTV_DELTA, routing_info) + self.assertEqual(bucket_cltv_delta, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) + + with self.assertRaises(FeeBudgetExceeded): + build(DEFAULT_TRAMPOLINE_CLTV_DELTA - 1, routing_info) + + # check the sanity check (cltv <= NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) + with self.assertRaises(FeeBudgetExceeded): + routing_info = dataclasses.replace(routing_info, final_cltv_delta=routing_info.final_cltv_delta + 1) + build(DEFAULT_TRAMPOLINE_CLTV_DELTA, routing_info) + @needs_test_with_all_chacha20_implementations def test_decode_onion_error(self): # test vector from bolt-04 From 4871ce1ea12aa033f8400c8b95b4ee97bbf4fb66 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Apr 2026 16:02:39 +0200 Subject: [PATCH 42/45] qt: initial support for bolt12 offers Co-Authored-By: f321x --- electrum/gui/icons/bolt12.png | Bin 0 -> 3624 bytes electrum/gui/qt/invoice_list.py | 5 ++++- electrum/gui/qt/main_window.py | 34 +++++++++++++++++--------------- electrum/gui/qt/send_tab.py | 8 +++++--- 4 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 electrum/gui/icons/bolt12.png diff --git a/electrum/gui/icons/bolt12.png b/electrum/gui/icons/bolt12.png new file mode 100644 index 0000000000000000000000000000000000000000..e56d45e27bd97045dd7aff079e8e0744b4e40358 GIT binary patch literal 3624 zcmZ{nc{tQv|Hr>$n6Zqaq)~`5$-Yx|X)Gg(n2_8J6Dix+cYPH#_UtpZ#7%C=63G%q z3Pmv_j5XV!kY=ovp6QS0x_-;^{P8*G`kX)B=Q`(lpX>cP@zxj2_<4`<0sz2|HOE|H z&)}cphOx&^1D9U*#6vW93;=)woqr0_?OT4Gy(kiB;t*)->mC^5;_n88goG&F^zjTJ zxDefxeEo0at?3mN>2eAgzLwoh-HWk_4yxgU}CRd~ilxe#(S ztW^9)ym(0{L1Qk$xW?w)T&bz zPaTQt{H*AHOCAj?h1lV;;>!i8BGMWoQ2a^3_xzk35tbg+MMyiCHEz=mb%k^07+>!1 zOp@@GJrN7e0>aggjB91z^zBX5FX7g#Y;Gk zoU|H>#$tRq8hI$!ex62Sb1P-u9l-?hs!#Jt4B7nF?)zC^cJsEEx3|8m{AaFMLIh}z z{vPG-!%0kusks>M(V@HklKCW#?`YR0Lu*_RhoB<-8v(i+1a%mmoYa3@QL(}Y_2arb zhxj&d?qI7RdWt+r&5e7IEljLMZ_%lYDr^VIpxDRPDI84oBUT-&dTfp|S?(Q({p zBxq%6sqq*!3b>#luqOA_Y4wT-c3jxVMQqul3T&1PHp5@}0J;v+YWNi)e@bM8>i`@Ap z67NPYLBtwlglgS>FXxTTFQRdC5rD_Pf{xr$b`u)spJDy*%%02C$JC>R6E$-+FT52V zN2riZM}rOinYWnYe-PSL)#FOpa+Ab(%LwhY;OPD*?>8kR8CsC4(&nW54W$zfbzA|h5Sxb+pUf~FDrkoFaBKV89L$WCs|K=(;( z#XpsmUu!I!+6IE0+1E&h;bp-?&_})kv_2z=Z?PtWfWvh#^TeJ3KtRN{B}@sew&yCn zbI;AoOGWAgE-9dz#bCdYkp|biO-$a5UtUt11ZWdg7q{8v!F!A;;EBTv|Ixlq^StNfvCplPZE#hopSmoUIVgHv9 z*5U`X8V~5uZmaBF9!}@fsr;6&^x^T7UX(NZMR`Cz|Jl|i{iLbik*c5ag7x_ZP)%j? z%G&$LhR+VrrSO*6r*7&UjmL`~l{HM(L?9ZntGRZbMYov(N{}6%*02#d@Ycf0p>A)5 zg&tLKYG(sGdTC1o*$a>(HgZ}cL?dg1iS0+*m6Mi%Pi(ch=!YqXD-CM`)c)T20$4s& z9@z|j^+3`a7|gi54gHBGq3}Op)JJ(1Y8T%uK6@~hTiIpv0{GQ&uuE$3haxB`dtam| z3q6kTtn?DQ< z9P(hb?iAn)Q6rfXKyg+6c$_7A>=1FDAztDGOG%2N=3vSbI9nHl3 zG9mNL8+k@#crgPKF0fHtH&6FlTo4vWvD8&DJCGkZYTrLk>25nH+o6z1%)G<@*isKyr^2e4-(Zh}d=4rsmhQqbI&0hz@KoUZXOGAW5q~cnt6L8@6An(p^(`ilrpo05w?4s|Kw% z8U8BO&;YvW#R>4c_ef8G^jBSURa1cZp6aQYWXPp~_1`QHp&b1?=e8AufcNR1U0T50 zUf0rUtm49#4n3SNeN!0ScD2oKBIRZL2f=E%d68ia(@rR)9XzMJ4^gSg@U%F#?>m$I zoeA034!3ZL+)rWh3NXd40qbuFeM{UiKf*WPS)BXjthgrZNIV&*vy9N~5HPP5h-F!jOFUjIx^tpI&Vm zG_RGhFJh-=iD_~;mywl0f8Kh0kt^_Mc6RoOR9X*5`}A*6p*JNgi_U9@)6XIaLy+Ha z8uB%s2$clNaa|o9EfbWW6qgj7Kh1pJ1Bc5Iph=GgZb0}D)V?n?rw#eT(rV1ZxnV{ zefE-gJt`_HZF;?0C5e>0?e*!~FUEqHeWti!zC)X7$zH+S8`>00Z+uig(E3=z@Q>-#ArI4qms`X%p8!!Zf0#xXIr3S0u9=D3i7 zniP@`oAz214v!qnEwh5uaDZvrN!vi5jgVWiW<`>X0L=VQvT6Te<4S;78&lKT_tKKv z@8TIz1AJD49h+d9;`e%{)dd=IC^^{ldb9wykmZ`oSrG~Btnw2caN20J9WwEc%)63o z5=V{u`&`=O5klFvL*VZU%r08XunKYXXQdMS0N^=NB^EtHjk)Ds2!-Y;AF_#X z-N$D*Ub)mI$Al!YQ7avdeV&z-)t^`T#*6n8fTh#vDeKG2O>n9Ps|}>^r~p)|ho5WXjRP*UPhNsog1*zhN zKD{xcQ3H3MmE~neI~;B^E{!=BM?e6g`7SNu)K- z4o8JZpCJ7kYg7aX?Dub!o~gYxp0?{R2Y9h|BcC5Ra>QxK(A=@E@i^5FMvpvVR8TCF zLPu%EOYuMWn{7}bY`Spg=rg9|6E8(Y#SAp|7ALVRCRo9_?ETRcEu%wLLrR22Qz@t> zP=Ki?Q`~^%IMiOracV-SUi3D;#|bF}1N>KBB@;)TNVXyxF!S_Ii7G=iag0v#*&86* z(V(j|W0UZ=E&C&2p8g(lz%t)lE)doV0SLBDn2#U&)K~vv?^LB?lnxONP`G9(hq>wo zqT}5drmV{+6not0k)JKtc6*%lSb&xod@@srqe>GrQ=Z!Y-&FE{Am#tJjN4BmqNHVc SpZl?Yc>vbr0;b9sf9GGEC9YHe literal 0 HcmV?d00001 diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index dd8bfb35d298..c618b819a10f 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -111,7 +111,10 @@ def update(self): for idx, item in enumerate(self.wallet.get_unpaid_invoices()): key = item.get_id() if item.is_lightning(): - icon_name = 'lightning.png' + if item.bolt12_invoice: + icon_name = 'bolt12.png' + else: + icon_name = 'lightning.png' else: icon_name = 'bitcoin.png' status = self.wallet.get_invoice_status(item) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 81b5ca9b3b79..04ea6c1720b9 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -50,7 +50,7 @@ import electrum from electrum.gui import messages from electrum import (keystore, constants, util, bitcoin, commands, - lnutil) + lnutil, bolt12) from electrum.bitcoin import COIN, is_address, DummyAddress from electrum.plugin import run_hook from electrum.i18n import _ @@ -1677,15 +1677,15 @@ def show_onchain_invoice(self, invoice: Invoice): d.exec() def show_lightning_invoice(self, invoice: Invoice): - from electrum.util import format_short_id - lnaddr = decode_bolt11_invoice(invoice.lightning_invoice) + assert invoice.is_lightning() # bolt11 or bolt12 d = WindowModalDialog(self, _("Lightning Invoice")) vbox = QVBoxLayout(d) grid = QGridLayout() - pubkey_e = ShowQRLineEdit(lnaddr.pubkey.serialize().hex(), self.config, title=_("Public Key")) - pubkey_e.setMinimumWidth(700) - grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0) - grid.addWidget(pubkey_e, 0, 1) + if pubkey := invoice.issuer_pubkey: + pubkey_e = ShowQRLineEdit(pubkey, self.config, title=_("Public Key")) + pubkey_e.setMinimumWidth(700) + grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0) + grid.addWidget(pubkey_e, 0, 1) grid.addWidget(QLabel(_("Amount") + ':'), 1, 0) amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit() grid.addWidget(QLabel(amount_str), 1, 1) @@ -1697,11 +1697,11 @@ def show_lightning_invoice(self, invoice: Invoice): grid.addWidget(QLabel(_("Expiration time") + ':'), 4, 0) grid.addWidget(QLabel(format_time(invoice.time + invoice.exp)), 4, 1) grid.addWidget(QLabel(_('Features') + ':'), 5, 0) - grid.addWidget(QLabel(', '.join(lnaddr.get_features().get_names())), 5, 1) - payhash_e = ShowQRLineEdit(lnaddr.paymenthash.hex(), self.config, title=_("Payment Hash")) + grid.addWidget(QLabel(', '.join(invoice.features.get_names())), 5, 1) + payhash_e = ShowQRLineEdit(invoice.rhash, self.config, title=_("Payment Hash")) grid.addWidget(QLabel(_("Payment Hash") + ':'), 6, 0) grid.addWidget(payhash_e, 6, 1) - fallback = lnaddr.get_fallback_address() + fallback = invoice.get_lightning_fallback_address() if fallback: fallback_e = ShowQRLineEdit(fallback, self.config, title=_("Fallback address")) grid.addWidget(QLabel(_("Fallback address") + ':'), 7, 0) @@ -1712,12 +1712,14 @@ def show_lightning_invoice(self, invoice: Invoice): invoice_e.setText(invoice.lightning_invoice) grid.addWidget(QLabel(_('Text') + ':'), 8, 0) grid.addWidget(invoice_e, 8, 1) - r_tags = lnaddr.get_routing_info('r') - r_tags = '\n'.join(repr(r) for r in BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)) - routing_e = QTextEdit(str(r_tags)) - routing_e.setReadOnly(True) - grid.addWidget(QLabel(_("Routing Hints") + ':'), 9, 0) - grid.addWidget(routing_e, 9, 1) + if b11 := invoice.bolt11_invoice: + # TODO: show path/introduction point for b12? + r_tags = b11.get_routing_info('r') + r_tags = '\n'.join(repr(r) for r in BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)) + routing_e = QTextEdit(str(r_tags)) + routing_e.setReadOnly(True) + grid.addWidget(QLabel(_("Routing Hints") + ':'), 9, 0) + grid.addWidget(routing_e, 9, 1) vbox.addLayout(grid) vbox.addLayout(Buttons(CloseButton(d),)) d.exec() diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 444fff601175..044e8a023115 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -432,7 +432,8 @@ def update_fields(self): lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, PaymentIdentifierType.OPENALIAS, - PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve() + PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11, + PaymentIdentifierType.BOLT12_OFFER] and not pi.need_resolve() lock_amount = pi.is_amount_locked() lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21] @@ -474,8 +475,9 @@ def update_fields(self): amount_valid = is_spk_script or bool(self.amount_e.get_amount()) self.send_button.setEnabled(not pi_unusable and amount_valid and not pi.has_expired()) - self.save_button.setEnabled(not pi_unusable and not is_spk_script and not pi.has_expired() and \ - pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]) + self.save_button.setEnabled(not pi_unusable and not is_spk_script and pi.type not in [ \ + PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, PaymentIdentifierType.BOLT12_OFFER + ]) self.invoice_error.setText(_('Expired') if pi.has_expired() else '') From 20fb0376ce9e0c30829ef63f74ced648a040cbe7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Apr 2026 16:02:55 +0200 Subject: [PATCH 43/45] qml: initial support for bolt12 offers Co-Authored-By: f321x --- .../gui/qml/components/Bolt12OfferDialog.qml | 176 ++++++++++++++++++ electrum/gui/qml/components/InvoiceDialog.qml | 58 +++++- .../gui/qml/components/WalletMainView.qml | 26 ++- electrum/gui/qml/qeinvoice.py | 149 ++++++++++++--- electrum/gui/qml/qeinvoicelistmodel.py | 9 +- 5 files changed, 386 insertions(+), 32 deletions(-) create mode 100644 electrum/gui/qml/components/Bolt12OfferDialog.qml diff --git a/electrum/gui/qml/components/Bolt12OfferDialog.qml b/electrum/gui/qml/components/Bolt12OfferDialog.qml new file mode 100644 index 000000000000..811d172fd174 --- /dev/null +++ b/electrum/gui/qml/components/Bolt12OfferDialog.qml @@ -0,0 +1,176 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('Lightning Offer') + iconSource: '../../../icons/lightning.png' + + property var invoiceParser // type: InvoiceParser + + padding: 0 + + property bool commentValid: note.text.length <= 64 + property bool amountValid: amountBtc.textAsSats.satsInt > 0 && amountBtc.textAsSats.satsInt <= Daemon.currentWallet.lightningCanSend.satsInt + property bool valid: commentValid && amountValid + + ColumnLayout { + width: parent.width + + spacing: 0 + + GridLayout { + id: rootLayout + columns: 2 + + Layout.fillWidth: true + Layout.leftMargin: constants.paddingLarge + Layout.rightMargin: constants.paddingLarge + Layout.bottomMargin: constants.paddingLarge + + // qml quirk; first cells cannot colspan without messing up the grid width + Item { Layout.fillWidth: true; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } + Item { Layout.fillWidth: true; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } + + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Issuer') + color: Material.accentColor + visible: 'issuer' in invoiceParser.offerData + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: 'issuer' in invoiceParser.offerData + leftPadding: constants.paddingMedium + Label { + width: parent.width + wrapMode: Text.Wrap + elide: Text.ElideRight + font.pixelSize: constants.fontSizeXLarge + text: 'issuer' in invoiceParser.offerData ? invoiceParser.offerData['issuer'] : '' + } + } + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.topMargin: constants.paddingSmall + text: qsTr('Description') + color: Material.accentColor + visible: 'description' in invoiceParser.offerData + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: 'description' in invoiceParser.offerData + leftPadding: constants.paddingMedium + Label { + width: parent.width + text: 'description' in invoiceParser.offerData ? invoiceParser.offerData['description'] : '' + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeXLarge + } + } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Amount') + color: Material.accentColor + } + + DialogHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + ColumnLayout { + width: parent.width + spacing: constants.paddingSmall + + RowLayout { + Layout.fillWidth: true + BtcField { + id: amountBtc + Layout.preferredWidth: rootLayout.width / 3 + text: 'amount_msat' in invoiceParser.offerData + ? Config.formatSatsForEditing(invoiceParser.offerData['amount_msat'] / 1000) + : '' + readOnly: 'amount_msat' in invoiceParser.offerData + // accent color for fixed-amount offers; also overrides gray-out on disabled + color: readOnly ? Material.accentColor : Material.foreground + fiatfield: amountFiat + onTextAsSatsChanged: { + if (textAsSats) + invoiceParser.amountOverride = textAsSats + } + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + RowLayout { + Layout.fillWidth: true + visible: Daemon.fx.enabled + FiatField { + id: amountFiat + Layout.preferredWidth: rootLayout.width / 3 + btcfield: amountBtc + readOnly: btcfield.readOnly + } + Label { + text: Daemon.fx.fiatCurrency + color: Material.accentColor + } + } + } + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.topMargin: constants.paddingSmall + Label { + text: qsTr('Note') + color: Material.accentColor + } + Item { Layout.fillWidth: true } + Label { + text: note.text.length + '/64' + font.pixelSize: constants.fontSizeSmall + color: commentValid ? constants.mutedForeground : constants.colorError + } + } + ElTextArea { + id: note + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.minimumHeight: 100 + wrapMode: TextEdit.Wrap + placeholderText: qsTr('Enter an (optional) message for the receiver') + color: commentValid ? Material.foreground : constants.colorError + } + } + + FlatButton { + Layout.topMargin: constants.paddingLarge + Layout.fillWidth: true + text: qsTr('Request') + icon.source: '../../icons/confirmed.png' + enabled: valid + onClicked: { + invoiceParser.requestInvoiceFromOffer(note.text) + dialog.close() + } + } + } + +} diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 51e40c2a8970..0a58a8c1be09 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -17,7 +17,9 @@ ElDialog { signal doPay signal invoiceAmountChanged - title: invoice.invoiceType == Invoice.OnchainInvoice ? qsTr('On-chain Invoice') : qsTr('Lightning Invoice') + title: invoice.invoiceType == Invoice.OnchainInvoice + ? qsTr('On-chain Invoice') + : qsTr('Lightning Invoice') iconSource: Qt.resolvedUrl('../../icons/tab_send.png') padding: 0 @@ -109,6 +111,30 @@ ElDialog { } } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Issuer') + visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != '' + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != '' + leftPadding: constants.paddingMedium + + Label { + text: 'issuer' in invoice.lnprops ? invoice.lnprops.issuer : '' + width: parent.width + font.pixelSize: constants.fontSizeXLarge + wrapMode: Text.Wrap + elide: Text.ElideRight + } + } + Label { Layout.columnSpan: 2 Layout.topMargin: constants.paddingSmall @@ -424,6 +450,36 @@ ElDialog { } } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + visible: 'blinded_paths' in invoice.lnprops && invoice.lnprops.blinded_paths.length + text: qsTr('Blinded paths') + color: Material.accentColor + } + + Repeater { + visible: 'blinded_paths' in invoice.lnprops && invoice.lnprops.blinded_paths.length + model: invoice.lnprops.blinded_paths + + DialogHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + RowLayout { + width: parent.width + + Label { + Layout.fillWidth: true + text: qsTr('via %1 (%2 hops)') + .arg(modelData.first_node) + .arg(modelData.path_length) + wrapMode: Text.Wrap + } + } + } + } + Label { Layout.columnSpan: 2 Layout.topMargin: constants.paddingSmall diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 55f73519db45..b2d4e59c8b6c 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -44,7 +44,7 @@ Item { // Android based send dialog if on android var scanner = app.scanDialog.createObject(mainView, { hint: Daemon.currentWallet.isLightning - ? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup') + ? qsTr('Scan an Invoice, an Address, an Offer, a LNURL, a PSBT or a Channel Backup') : qsTr('Scan an Invoice, an Address, an LNURL or a PSBT') }) scanner.onFoundText.connect(function(data) { @@ -438,6 +438,20 @@ Item { }) dialog.open() } + onBolt12Offer: { + closeSendDialog() + var dialog = bolt12OfferDialog.createObject(app, { + invoiceParser: invoiceParser + }) + dialog.open() + } + onBolt12Invoice: { + closeSendDialog() + var dialog = invoiceDialog.createObject(app, { + invoice: invoiceParser + }) + dialog.open() + } } Bitcoin { @@ -748,6 +762,16 @@ Item { } } + Component { + id: bolt12OfferDialog + Bolt12OfferDialog { + width: parent.width * 0.9 + anchors.centerIn: parent + + onClosed: destroy() + } + } + Component { id: otpDialog OtpDialog { diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 5e914c34dfc3..723e6868ddd6 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -6,27 +6,27 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer, QVariant +from electrum.bolt12 import BOLT12Offer from electrum.i18n import _ from electrum.logging import get_logger from electrum.invoices import ( Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, - PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER + PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER, ) from electrum.transaction import PartialTxOutput, TxOutput from electrum.lnutil import format_short_channel_id from electrum.lnurl import LNURL6Data from electrum.lnchannel import PeerState, ChannelState -from electrum.bitcoin import COIN, address_to_script -from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType -from electrum.network import Network -from electrum.util import event_listener, now - +from electrum.bitcoin import address_to_script +from electrum.payment_identifier import ( + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType, invoice_from_payment_identifier +) +from electrum.util import event_listener, now, InvoiceError from electrum.gui.common_qt.util import QtEventListener from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval -from ...util import InvoiceError class QEInvoice(QObject, QtEventListener): @@ -38,6 +38,7 @@ class Type(IntEnum): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 + Bolt12Offer = 3 @pyqtEnum class Status(IntEnum): @@ -154,11 +155,11 @@ def expiration(self): return self._effectiveInvoice.exp if self._effectiveInvoice else 0 @pyqtProperty(str, notify=invoiceChanged) - def address(self): + def address(self) -> str: return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' @pyqtProperty(QEAmount, notify=invoiceChanged) - def amount(self): + def amount(self) -> QEAmount: if not self._effectiveInvoice: self._amount.clear() return self._amount @@ -265,24 +266,41 @@ def set_lnprops(self): if not self.invoiceType == QEInvoice.Type.LightningInvoice: return - lnaddr = self._effectiveInvoice.bolt11_invoice - ln_routing_info = lnaddr.get_routing_info('r') - self._logger.debug(str(ln_routing_info)) - - self._lnprops = { - 'pubkey': lnaddr.pubkey.serialize().hex(), - 'payment_hash': lnaddr.paymenthash.hex(), - 'r': [{ - 'node': self.name_for_node_id(x[-1][0]), - 'scid': format_short_channel_id(x[-1][1]) - } for x in ln_routing_info] if ln_routing_info else [] + lnaddr = self._effectiveInvoice + + lnprops = { + 'is_bolt12': bool(lnaddr.bolt12_invoice), + 'payment_hash': lnaddr.rhash, } + if pubkey := lnaddr.issuer_pubkey: + lnprops['pubkey'] = pubkey + + if b12i := lnaddr.bolt12_invoice: + paths = b12i.invoice_paths + lnprops['blinded_paths'] = [{ + 'first_node': self.name_for_node_id(x.first_node_id), + 'path_length': x.hop_count, + } for x in paths] + if issuer := b12i.offer_issuer: + lnprops['issuer'] = issuer + else: + ln_routing_info = lnaddr.bolt11_invoice.get_routing_info('r') + self._logger.debug(str(ln_routing_info)) + lnprops.update({ + 'r': [{ + 'node': self.name_for_node_id(x[-1][0]), + 'scid': format_short_channel_id(x[-1][1]) + } for x in ln_routing_info] if ln_routing_info else [] + }) + + self._lnprops = lnprops + def name_for_node_id(self, node_id): lnworker = self._wallet.wallet.lnworker return (lnworker.lnpeermgr.get_node_alias(node_id) if lnworker else None) or node_id.hex() - def set_effective_invoice(self, invoice: Invoice): + def set_effective_invoice(self, invoice: Optional[Invoice]): self._paid_in_this_session = False self._effectiveInvoice = invoice @@ -294,6 +312,8 @@ def set_effective_invoice(self, invoice: Invoice): else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) self._isSaved = self._wallet.wallet.get_invoice(invoice.get_id()) is not None + if not self._key: # unset if invoice is not saved and just parsed. We need this for tracking status updates + self._key = invoice.get_id() self.set_lnprops() @@ -391,8 +411,8 @@ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[Optional[bool], Option assert self.status in [PR_UNPAID, PR_FAILED] if self.invoiceType == QEInvoice.Type.LightningInvoice: if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt: - lnaddr = self._effectiveInvoice.bolt11_invoice - if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000: + invoice_amount_msat = self._effectiveInvoice.amount_msat + if invoice_amount_msat and amount.msatsInt < invoice_amount_msat: return False, _('Cannot pay less than the amount specified in the invoice') else: return True, None @@ -487,6 +507,10 @@ class QEInvoiceParser(QEInvoice): lnurlRetrieved = pyqtSignal() lnurlError = pyqtSignal([str, str], arguments=['code', 'message']) + bolt12Offer = pyqtSignal() + bolt12InvReqError = pyqtSignal([str, str], arguments=['code', 'message']) + bolt12Invoice = pyqtSignal() + busyChanged = pyqtSignal() def __init__(self, parent=None): @@ -494,6 +518,7 @@ def __init__(self, parent=None): self._pi = None # type: Optional[PaymentIdentifier] self._lnurlData = None + self._offerData = None self._busy = False self.clear() @@ -504,6 +529,7 @@ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None: self.amountOverride = QEAmount() if resolved_pi: assert not resolved_pi.need_resolve() + self.clear() self.validateRecipient(resolved_pi) @pyqtProperty('QVariantMap', notify=lnurlRetrieved) @@ -514,6 +540,14 @@ def lnurlData(self): def isLnurlPay(self): return self._lnurlData is not None + @pyqtProperty('QVariantMap', notify=bolt12Offer) + def offerData(self): + return self._offerData + + @pyqtProperty(bool, notify=bolt12Offer) + def isBolt12Offer(self): + return self._offerData is not None + @pyqtProperty(bool, notify=busyChanged) def busy(self): return self._busy @@ -521,7 +555,9 @@ def busy(self): @pyqtSlot() def clear(self): self.setInvoiceType(QEInvoice.Type.Invalid) + self._key = None self._lnurlData = None + self._offerData = None self.canSave = False self.canPay = False self.set_userinfo('') @@ -546,6 +582,12 @@ def setValidLNURLPayRequest(self): self._effectiveInvoice = None self.invoiceChanged.emit() + def setValidBolt12Offer(self): + self._logger.debug('setValidBolt12Offer') + self.setInvoiceType(QEInvoice.Type.Bolt12Offer) + self._effectiveInvoice = None + self.invoiceChanged.emit() + def create_onchain_invoice(self, *, outputs, message, uri): return self._wallet.wallet.create_invoice( outputs=outputs, @@ -564,7 +606,7 @@ def validateRecipient(self, pi: PaymentIdentifier): PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP, PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE, - PaymentIdentifierType.OPENALIAS, + PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BOLT12_OFFER ]: self.validationError.emit('unknown', _('Unknown invoice')) return @@ -587,6 +629,10 @@ def _update_from_payment_identifier(self): self.on_lnurl_pay(self._pi.lnurl_data) return + if self._pi.type == PaymentIdentifierType.BOLT12_OFFER: + self.on_bolt12_offer(self._pi.bolt12_offer) + return + if self._pi.is_available(): if self._pi.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.OPENALIAS]: outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)] @@ -595,8 +641,8 @@ def _update_from_payment_identifier(self): self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() return - elif self._pi.type == PaymentIdentifierType.BOLT11: - lninvoice = self._pi.lightning_invoice + elif self._pi.type in [PaymentIdentifierType.BOLT11]: + lninvoice = invoice_from_payment_identifier(self._pi, self._wallet.wallet) if not self._wallet.wallet.has_lightning() and not lninvoice.get_address(): self.validationError.emit('no_lightning', _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) @@ -645,6 +691,26 @@ def on_lnurl_pay(self, lnurldata: LNURL6Data): self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() + def on_bolt12_offer(self, bolt12_offer: BOLT12Offer) -> None: + self._logger.debug(f'on_bolt12_offer: {bolt12_offer!r}') + # bolt 12 invoices can have fallback addresses, however we don't know until we have actually requested the invoice. + # so it is cleaner to not utilize them so we can validate the user input amount against num_sats_can_send in the GUI + if not self._wallet.wallet.has_lightning(): + self.validationError.emit( + 'no_lightning', + _('Found valid Lightning offer, but Lightning is not enabled for wallet.'), + ) + return + self._offerData = offer_data = {} + if description := bolt12_offer.offer_description: + offer_data['description'] = description + if amount := bolt12_offer.offer_amount: + offer_data['amount_msat'] = amount + if issuer_name := bolt12_offer.offer_issuer: + offer_data['issuer'] = issuer_name + self.setValidBolt12Offer() + self.bolt12Offer.emit() + @pyqtSlot() @pyqtSlot(str) def lnurlGetInvoice(self, comment=None): @@ -688,6 +754,37 @@ def on_lnurl_invoice(self, orig_amount, invoice): PaymentIdentifier(self._wallet.wallet, invoice.lightning_invoice) ) + @pyqtSlot() + @pyqtSlot(str) + def requestInvoiceFromOffer(self, note: str = None): + assert self._offerData is not None + assert self._pi.need_finalize() + self._logger.debug(f'{self._offerData!r}') + + amount = self.amountOverride.satsInt + + def on_finished(pi: PaymentIdentifier): + self._busy = False + self.busyChanged.emit() + + if pi.is_error(): + if pi.state == PaymentIdentifierState.INVALID_AMOUNT: + self.bolt12InvReqError.emit('amount', pi.get_error()) + else: + self.bolt12InvReqError.emit('generic', pi.get_error()) + else: + self.on_bolt12_invoice(pi.lightning_invoice) + + self._busy = True + self.busyChanged.emit() + + self._pi.finalize(amount_sat=amount, comment=note, on_finished=on_finished) + + def on_bolt12_invoice(self, bolt12_invoice: Invoice): + self._logger.debug(f'on_bolt12_invoice {bolt12_invoice!r}') + self.set_effective_invoice(bolt12_invoice) + self.bolt12Invoice.emit() + @pyqtSlot(result=bool) def saveInvoice(self) -> bool: if not self._effectiveInvoice: diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 551afb19fa84..6d2fce35a7b8 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -23,7 +23,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount', 'status', 'status_str', 'address', 'expiry', 'type', 'onchain_fallback', - 'lightning_invoice') + 'lightning_invoice', 'is_bolt12') _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -135,7 +135,7 @@ def invoice_to_model(self, invoice: BaseInvoice): item['date'] = format_time(item['timestamp']) item['amount'] = QEAmount(from_invoice=invoice) item['onchain_fallback'] = invoice.is_lightning() and bool(invoice.get_address()) - + item['is_bolt12'] = False return item def set_status_timer(self): @@ -195,9 +195,10 @@ def on_event_invoice_status(self, wallet, key, status): self._logger.debug(f'invoice status update for key {key} to {status}') self.updateInvoice(key, status) - def invoice_to_model(self, invoice: BaseInvoice): + def invoice_to_model(self, invoice: Invoice): item = super().invoice_to_model(invoice) item['type'] = 'invoice' + item['is_bolt12'] = bool(invoice.bolt12_invoice) return item @@ -230,7 +231,7 @@ def on_event_request_status(self, wallet, key, status): self._logger.debug(f'request status update for key {key} to {status}') self.updateRequest(key, status) - def invoice_to_model(self, invoice: BaseInvoice): + def invoice_to_model(self, invoice: Request): item = super().invoice_to_model(invoice) item['type'] = 'request' From 0f36e5626d83c0b01a5e317b2adecb728e83c527 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 8 Jun 2026 15:21:03 +0200 Subject: [PATCH 44/45] wip: qml: bolt12: implement GUI for creating offer --- .../qml/components/ReceiveDetailsDialog.qml | 8 +++++ electrum/gui/qml/components/ReceiveDialog.qml | 23 ++++++++++---- .../gui/qml/components/WalletMainView.qml | 30 ++++++++++++++++++- electrum/gui/qml/qerequestdetails.py | 24 +++++++++++++++ electrum/gui/qml/qewallet.py | 24 +++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/electrum/gui/qml/components/ReceiveDetailsDialog.qml b/electrum/gui/qml/components/ReceiveDetailsDialog.qml index 964bdd2d0e85..fb9615d0c478 100644 --- a/electrum/gui/qml/components/ReceiveDetailsDialog.qml +++ b/electrum/gui/qml/components/ReceiveDetailsDialog.qml @@ -18,6 +18,7 @@ ElDialog { property alias description: message.text property alias expiry: expires.currentValue property bool isLightning: false + property bool isOffer: false padding: 0 needsSystemBarPadding: false @@ -112,6 +113,13 @@ ElDialog { > amountBtc.textAsSats.satsInt || Daemon.currentWallet.canGetZeroconfChannel) text: qsTr('Lightning') icon.source: '../../icons/lightning.png' + pressAndHoldIndicator: true + onPressAndHold: { + // long-press creates a reusable Lightning offer instead of a single invoice + AppController.haptic() + dialog.isOffer = true + doAccept() + } onClicked: { if (Daemon.currentWallet.lightningCanReceive.satsInt > amountBtc.textAsSats.satsInt) { // can receive on existing channel diff --git a/electrum/gui/qml/components/ReceiveDialog.qml b/electrum/gui/qml/components/ReceiveDialog.qml index b7ebf3cc942c..3cdfe3043838 100644 --- a/electrum/gui/qml/components/ReceiveDialog.qml +++ b/electrum/gui/qml/components/ReceiveDialog.qml @@ -11,10 +11,15 @@ import "controls" ElDialog { id: dialog - title: qsTr('Receive Payment') + title: isOffer ? qsTr('Lightning Offer') : qsTr('Receive Payment') iconSource: Qt.resolvedUrl('../../icons/tab_receive.png') property string key + // when 'offer' is set, the dialog shows a reusable Lightning offer instead of a request + property string offer: '' + readonly property bool isOffer: offer !== '' + property var offerAmountSat: 0 + property string offerMessage: '' property bool isLightning: request.isLightning property string _bolt11: request.bolt11 @@ -94,10 +99,12 @@ ElDialog { Layout.alignment: Qt.AlignHCenter Label { + visible: !dialog.isOffer text: qsTr('Status') color: Material.accentColor } Label { + visible: !dialog.isOffer text: request.status_str } Label { @@ -178,9 +185,11 @@ ElDialog { : _bip21uri ? _bip21uri : _address, - _bolt11 || _bip21uri - ? qsTr('Payment Request') - : qsTr('Onchain address') + dialog.isOffer + ? qsTr('Lightning Offer') + : _bolt11 || _bip21uri + ? qsTr('Payment Request') + : qsTr('Onchain address') ) enabled = true } @@ -203,7 +212,11 @@ ElDialog { } Component.onCompleted: { - request.key = dialog.key + if (dialog.isOffer) { + request.setOffer(dialog.offer, dialog.offerAmountSat, dialog.offerMessage) + } else { + request.key = dialog.key + } } // hack. delay qr rendering until dialog is shown diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index b2d4e59c8b6c..918898a82b4e 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -136,6 +136,11 @@ Item { Daemon.currentWallet.createRequest(qamt, _request_description, _request_expiry, lightning, reuse_address) } + function createOffer() { + var qamt = Config.unitsToSats(_request_amount) + Daemon.currentWallet.createOffer(qamt, _request_description, _request_expiry) + } + function startSweep() { var dialog = sweepDialog.createObject(app) dialog.accepted.connect(function() { @@ -493,6 +498,25 @@ Item { }) dialog.open() } + function onOfferCreateSuccess(offer) { + // reuse the same receive view as bolt11 requests, in offer mode + var qamt = Config.unitsToSats(_request_amount) + var dialog = receiveDialog.createObject(app, { + offer: offer, + offerAmountSat: qamt.satsInt, + offerMessage: _request_description + }) + dialog.open() + } + function onOfferCreateError(error) { + console.log(error) + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Error'), + iconSource: Qt.resolvedUrl('../../icons/warning.png'), + text: error + }) + dialog.open() + } function onOtpRequested() { console.log('OTP requested') var dialog = otpDialog.createObject(mainView) @@ -642,7 +666,11 @@ Item { _request_amount = _receiveDetailsDialog.amount _request_description = _receiveDetailsDialog.description _request_expiry = _receiveDetailsDialog.expiry - createRequest(_receiveDetailsDialog.isLightning, false) + if (_receiveDetailsDialog.isOffer) { + createOffer() + } else { + createRequest(_receiveDetailsDialog.isLightning, false) + } } onRejected: { console.log('rejected') diff --git a/electrum/gui/qml/qerequestdetails.py b/electrum/gui/qml/qerequestdetails.py index 6a5b76730c89..b1e26099e793 100644 --- a/electrum/gui/qml/qerequestdetails.py +++ b/electrum/gui/qml/qerequestdetails.py @@ -50,6 +50,8 @@ def __init__(self, parent=None): self._req = None self._timer = None self._amount = None + self._offer = None # type: Optional[str] + self._offer_message = '' self._lnurlData = None # type: Optional[dict] self._busy = False @@ -101,6 +103,8 @@ def key(self, key): @pyqtProperty(int, notify=statusChanged) def status(self): + if not self._req: + return PR_UNPAID return self._wallet.wallet.get_invoice_status(self._req) @pyqtProperty(str, notify=statusChanged) @@ -109,6 +113,8 @@ def status_str(self): @pyqtProperty(bool, notify=detailsChanged) def isLightning(self): + if self._offer is not None: + return True return self._req.is_lightning() if self._req else False @pyqtProperty(str, notify=detailsChanged) @@ -118,6 +124,8 @@ def address(self): @pyqtProperty(str, notify=detailsChanged) def message(self): + if self._offer is not None: + return self._offer_message return self._req.get_message() if self._req else '' @pyqtProperty(QEAmount, notify=detailsChanged) @@ -144,6 +152,9 @@ def paidTxid(self): @pyqtProperty(str, notify=detailsChanged) def bolt11(self): + if self._offer is not None: + # in offer mode this field carries the offer string for the QR code / copy / share + return self._offer wallet = self._wallet.wallet if not wallet.lnworker: return '' @@ -188,6 +199,19 @@ def initRequest(self): self.statusChanged.emit() self.set_status_timer() + @pyqtSlot(str, 'qint64', str) + def setOffer(self, offer: str, amount_sat: int, message: str): + """Populate from a reusable Lightning offer instead of a persisted request, + so the same receive view used for bolt11 can also display offers. + Offers are not persisted (no key) and have no payment status.""" + self._offer = offer + self._offer_message = message or '' + if amount_sat: + self._amount = QEAmount(amount_sat=amount_sat, amount_msat=amount_sat * 1000) + else: + self._amount = QEAmount() + self.detailsChanged.emit() + def set_status_timer(self): if self.status == PR_UNPAID: if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER: diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index eeabf5261b43..37c30e3e82c8 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -17,6 +17,7 @@ InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates ) from electrum.lnutil import MIN_FUNDING_SAT +from electrum.onion_message import NoOnionMessagePeers from electrum.plugin import run_hook from electrum.wallet import Multisig_Wallet from electrum.crypto import pw_decode_with_version_and_mac @@ -63,6 +64,8 @@ def getInstanceFor(cls, wallet): requestStatusChanged = pyqtSignal([str, int], arguments=['key', 'status']) requestCreateSuccess = pyqtSignal([str], arguments=['key']) requestCreateError = pyqtSignal([str], arguments=['error']) + offerCreateSuccess = pyqtSignal([str], arguments=['offer']) + offerCreateError = pyqtSignal([str], arguments=['error']) invoiceStatusChanged = pyqtSignal([str, int], arguments=['key', 'status']) invoiceCreateSuccess = pyqtSignal() invoiceCreateError = pyqtSignal([str, str], arguments=['code', 'error']) @@ -717,6 +720,27 @@ def createRequest(self, amount: QEAmount, message: str, expiration: int, lightni self.requestModel.add_invoice(self.wallet.get_request(key)) self.requestCreateSuccess.emit(key) + @pyqtSlot(QEAmount, str, int) + def createOffer(self, amount: QEAmount, message: str, expiration: int): + lnworker = self.wallet.lnworker + try: + amount_msat = amount.satsInt * 1000 if amount.satsInt > 0 else None + offer = lnworker.create_offer( + amount_msat=amount_msat, + description=message or None, + relative_expiry=expiration or None, + ) + offer_str = offer.encode(as_bech32=True).upper() + except NoOnionMessagePeers: + msg = [ + _('Cannot create a Lightning offer right now.'), + _('None of your channel peers support the private paths an offer needs.'), + ] + self.offerCreateError.emit(' '.join(msg)) + return + self._logger.debug('created offer') + self.offerCreateSuccess.emit(offer_str) + @pyqtSlot(str) def deleteRequest(self, key: str): self._logger.debug('delete req %s' % key) From 01e5d62f0dd51b53707c13816cbfdc54c9468973 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 13 May 2026 09:37:34 +0200 Subject: [PATCH 45/45] wip: exchange rates --- electrum/util.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/electrum/util.py b/electrum/util.py index a7bad5cc9da9..f9f4885edbc8 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -2524,3 +2524,15 @@ class ChoiceItem: key: Any label: str # user facing string extra_data: Any = None + + +def convert_fiat_to_satoshi(currency: str, amount: Decimal) -> Optional[int]: + """Convert amount of given currency into satoshi if exchange rate is available""" + from .network import Network + network = Network.get_instance() + if not network or not network.daemon or not network.daemon.fx: + _logger.warning(f"cannot convert fiat to bitcoin: {network}") + return None + fx = network.daemon.fx + return 0 +