From 03bebd8f7cff40a4c8ae2801661cff97f1c0d3f9 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 01/34] 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 | 63 +++++++++++++++++++++++++++++++---- electrum/lnpeer.py | 52 ++++++++++++++++++++++++----- electrum/lnutil.py | 6 ++-- electrum/lnwire/peer_wire.csv | 5 +-- electrum/lnworker.py | 20 ++++++++--- electrum/onion_message.py | 10 +++--- tests/test_onion_message.py | 12 ++----- 7 files changed, 129 insertions(+), 39 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 043213a82d21..83b8c8a3ecf5 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -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] + next_path_key: Optional[bytes] = None @property def amt_to_forward(self) -> Optional[int]: @@ -404,19 +406,32 @@ def outgoing_cltv_value(self) -> Optional[int]: @property def next_chan_scid(self) -> Optional[ShortChannelID]: k1 = k2 = 'short_channel_id' - return self._get_from_payload(k1, k2, ShortChannelID) + if not self.blinded_path_recipient_data: + return self._get_from_payload(k1, k2, ShortChannelID) + return self._get_from_recipient_data(k1, k2, ShortChannelID) @property def total_msat(self) -> Optional[int]: - return self._get_from_payload('payment_data', 'total_msat', int) + if not self.blinded_path_recipient_data: + return self._get_from_payload('payment_data', 'total_msat', int) + return self._get_from_payload('total_amount_msat', 'total_msat', int) @property def payment_secret(self) -> Optional[bytes]: - return self._get_from_payload('payment_data', 'payment_secret', bytes) + if not self.blinded_path_recipient_data: + return self._get_from_payload('payment_data', 'payment_secret', bytes) + return None - def _get_from_payload(self, k1: str, k2: str, res_type: type): + 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): + 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 +444,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 +452,10 @@ 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) + our_onion_private_key = blinding_privkey(our_onion_private_key, current_path_key) 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 +474,21 @@ 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 = {} + encrypted_recipient_data = hop_data.payload.get('encrypted_recipient_data', {}).get('encrypted_recipient_data') + if encrypted_recipient_data: + # we are part of a blinded path + if not current_path_key: # we are the introduction point + current_path_key = hop_data.payload['current_path_key']['path_key'] + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + assert isinstance(encrypted_recipient_data, bytes) + assert isinstance(current_path_key, bytes) and isinstance(recipient_data_shared_secret, bytes) + blinded_path_recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=recipient_data_shared_secret, + encrypted_recipient_data=encrypted_recipient_data, + ) + # trampoline trampoline_onion_packet = hop_data.payload.get('trampoline_onion_packet') if trampoline_onion_packet: @@ -467,13 +502,27 @@ def process_onion_packet( public_key=next_public_key, hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) + + next_path_key = None 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 are_we_final = False - return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet) + + if current_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), + next_path_key, + ) def compare_trampoline_onions( diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 4b88f87d6e7d..e51bbe864ad5 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -21,7 +21,7 @@ from aiorpcx import ignore_after from .lrucache import LRUCache -from .crypto import sha256, sha256d, privkey_to_pubkey +from .crypto import sha256, sha256d, privkey_to_pubkey, get_ecdh from . import bitcoin, util from . import constants from .util import (log_exceptions, ignore_exceptions, chunks, OldTaskGroup, @@ -35,7 +35,7 @@ from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, - OnionParsingError) + OnionParsingError, decrypt_onionmsg_data_tlv) from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL from . import lnutil from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, LnFeatureContexts, @@ -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,8 @@ 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 +2086,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}") @@ -2101,8 +2109,8 @@ 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_accepted_final_htlc( + self, *, chan: Channel, htlc: UpdateAddHtlc, processed_onion: ProcessedOnionPacket, @@ -2129,7 +2137,26 @@ def _check_accepted_final_htlc( 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 htlc.path_key: # payment over blinded path + # spec: MUST return an error if the payload contains other tlv fields than allowed_payload_keys + allowed_payload_keys = ['encrypted_recipient_data', 'current_path_key', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat'] + if any(x not in allowed_payload_keys for x in processed_onion.hop_data.payload.keys()): + log_fail_reason(f"unknown key in blinded payload: {processed_onion.hop_data.payload.keys()=}") + raise exc_incorrect_or_unknown_pd + + recipient_data = processed_onion.blinded_path_recipient_data or {} + path_id = recipient_data.get('path_id', {}).get('data') + if not path_id: + log_fail_reason(f"'path_id' missing in recipient_data") + raise exc_incorrect_or_unknown_pd + + log_fail_reason('we cannot receive blinded payments yet.') + raise exc_incorrect_or_unknown_pd + 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") raise exc_incorrect_or_unknown_pd @@ -2149,7 +2176,7 @@ 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: log_fail_reason(f"'payment_secret' missing from onion") raise exc_incorrect_or_unknown_pd @@ -2348,6 +2375,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 +2897,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 +3005,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 +3058,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,17 +3315,20 @@ 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: processed_onion = lnonion.process_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) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8baff6182aed..a547c610a44f 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1931,16 +1931,18 @@ 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() 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 6e65cf626854..3ae28b632bd6 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -3987,10 +3987,21 @@ def log_fail_reason(reason: str): 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: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + if not processed_onion.blinded_path_recipient_data: + 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, take from recipient_data + payment_relay = processed_onion.blinded_path_recipient_data.get('payment_relay') + if not payment_relay: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + next_amount_msat_htlc = htlc.amount_msat + next_amount_msat_htlc -= int(next_amount_msat_htlc * payment_relay.get('fee_proportional_millionths') / 1_000_000) + next_amount_msat_htlc -= payment_relay.get('fee_base_msat') + + next_cltv_abs = htlc.cltv_abs - payment_relay.get('cltv_expiry_delta') next_chan = self.get_channel_by_short_id(next_chan_scid) @@ -4064,6 +4075,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..c0acc38aaee6 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -865,9 +865,9 @@ 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}') @@ -878,7 +878,7 @@ def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacke return # decrypt - shared_secret = get_ecdh(self.lnwallet.node_keypair.privkey, blinding) + shared_secret = get_ecdh(self.lnwallet.node_keypair.privkey, path_key) recipient_data = decrypt_onionmsg_data_tlv( shared_secret=shared_secret, encrypted_recipient_data=payload['encrypted_recipient_data']['encrypted_recipient_data'] @@ -889,4 +889,4 @@ def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacke 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) + self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, path_key, shared_secret) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 8595fae36a3c..cee958319aab 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')) From 57539921caf8c96170d2e15eb56e2b5b0cdbbee8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Dec 2025 17:58:35 +0100 Subject: [PATCH 02/34] lnworker: add ONION_MESSAGE and ROUTE_BLINDING features to LNWALLET_FEATURES ... and lnutil.LN_FEATURES_IMPLEMENTED --- electrum/lnutil.py | 2 ++ electrum/lnworker.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index a547c610a44f..b3b216ffabb6 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1713,6 +1713,8 @@ 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_ONION_MESSAGE_OPT | LnFeatures.OPTION_ONION_MESSAGE_REQ + | LnFeatures.OPTION_ROUTE_BLINDING_OPT | LnFeatures.OPTION_ROUTE_BLINDING_REQ ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 3ae28b632bd6..d4fb8e99cbd9 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -210,6 +210,8 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ + | LnFeatures.OPTION_ONION_MESSAGE_OPT + | LnFeatures.OPTION_ROUTE_BLINDING_OPT ) LNGOSSIP_FEATURES = ( From a0273fec7c911918093950252fab06185dd24cad Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 24 Nov 2025 15:16:20 +0100 Subject: [PATCH 03/34] 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). --- electrum/onion_message.py | 37 ++++++++++++------- electrum/simple_config.py | 7 ++++ tests/test_onion_message.py | 71 +++++++++++++++++++++++++++++-------- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index c0acc38aaee6..89f284b330c5 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -550,16 +550,17 @@ def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequ self.node_id_or_blinded_paths = node_id_or_blinded_paths self.current_index: int = 0 - # ensure node_id_or_blinded_paths is list + # ensure node_id_or_blinded_paths is list, and route_not_found_for matches list length if isinstance(self.node_id_or_blinded_paths, bytes): self.node_id_or_blinded_paths = [self.node_id_or_blinded_paths] + self.route_not_found_for: list = [None] * len(self.node_id_or_blinded_paths) - def get_next_destination(self) -> bytes: + def get_next_destination(self) -> tuple[bytes, int]: """get next path (round-robin)""" dests = self.node_id_or_blinded_paths - dest = dests[self.current_index] + dest, i = dests[self.current_index], self.current_index self.current_index = (self.current_index + 1) % len(dests) - return dest + return dest, i def __init__(self, lnwallet: 'LNWallet'): Logger.__init__(self) @@ -571,6 +572,8 @@ def __init__(self, lnwallet: 'LNWallet'): self.send_queue = asyncio.PriorityQueue() self.forward_queue = asyncio.PriorityQueue() + self.send_direct_connect_fallback = lnwallet.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS + def start_network(self, *, network: 'Network') -> None: assert network assert self.network is None, "already started" @@ -654,13 +657,11 @@ async def process_send_queue(self) -> None: await asyncio.sleep(self.SLEEP_DELAY) # sleep here, as the first queue item wasn't due yet continue try: - self._send_pending_message(key) + await self._send_pending_message(key) except BaseException as e: self.logger.debug(f'error while sending {key=}: ', exc_info=True) 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)) @@ -707,11 +708,11 @@ async def _wait_task(self, key: bytes, future: asyncio.Future): finally: self._remove_pending_message(key) - def _send_pending_message(self, key: bytes) -> None: + async def _send_pending_message(self, key: bytes) -> None: """adds reply_path to payload""" req = self.pending[key] payload = req.payload - dest = req.get_next_destination() + dest, dest_index = req.get_next_destination() self.logger.debug(f'send_pending_message {key=} {payload=} {dest=}') @@ -723,10 +724,20 @@ 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, final_payload) + except NoRouteFound as e: + req.route_not_found_for[dest_index] = True + if all(req.route_not_found_for) and self.send_direct_connect_fallback and e.peer_address: + # we have a peer address hint and we've tried all blinded paths: try direct peer connection + # TODO: this will only attempt direct connection to the last blinded path ip node, we could try the others too. + self.logger.info(f'No route to destination, attempting direct peer connection to {str(e.peer_address)}') + await self.lnwallet.lnpeermgr.add_peer(str(e.peer_address)) + else: + raise def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes: # TODO: use payload to determine prefix? diff --git a/electrum/simple_config.py b/electrum/simple_config.py index cf76ffa3f409..e8be3d6ca245 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -726,6 +726,13 @@ 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 cee958319aab..59df4a487c16 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -18,7 +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.lntransport import LNPeerAddr +from electrum.lntransport import LNPeerAddr, extract_nodeid from electrum.lnutil import (LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) from electrum.onion_message import ( @@ -303,6 +303,16 @@ 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)) + + 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 +349,38 @@ 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) + + t.send_direct_connect_fallback = 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) + + t.send_direct_connect_fallback = False + t5_3 = 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_3 + self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey)) async def run_test6(self, t, rkey): @@ -366,7 +401,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 +415,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) From 26acdf890a1f01c6fb2c0449da1057b54821aeef Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 4 May 2026 16:11:37 +0200 Subject: [PATCH 04/34] 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 | 41 +++++++++++++++++++++++++++++++++++++ electrum/onion_message.py | 12 ++++------- tests/test_onion_message.py | 2 +- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index e51bbe864ad5..de725b6d58cf 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -64,6 +64,7 @@ LN_P2P_NETWORK_TIMEOUT = 20 +ONION_MESSAGE_SEND_DELAY = 0.25 class Peer(Logger, EventListener): @@ -141,6 +142,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=1000) # 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 @@ -153,6 +157,42 @@ 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 predecesssor 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: + 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): @@ -565,6 +605,7 @@ async def main_loop(self): await group.spawn(self._process_gossip()) await group.spawn(self._send_own_gossip()) await group.spawn(self._forward_gossip()) + await group.spawn(self._send_onion_messages()) if self.network.lngossip != self.lnworker: await group.spawn(self.htlc_switch()) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 89f284b330c5..617bdaf265e6 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, ) @@ -613,11 +611,9 @@ async def process_forward_queue(self) -> None: 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 59df4a487c16..f5e89b172b3f 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 2cc7ff23520e212d8ec14e4210b071bf6f88d2b3 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 23 Mar 2026 16:19:41 +0100 Subject: [PATCH 05/34] 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 7fe8a2f1ed7a..2e9de1abd265 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, @@ -386,7 +386,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( @@ -418,6 +418,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 730f689648670004a36087e1891de79c72552fd0 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 10:56:54 +0200 Subject: [PATCH 06/34] 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/onion_message.py | 223 ++++++++++++++++-------------------- tests/test_onion_message.py | 51 +++++---- 4 files changed, 243 insertions(+), 149 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 37686f9c9a49..c4dfb46ec20f 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 83b8c8a3ecf5..2f76fb2a22d6 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 @@ -158,6 +159,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_PATH) + 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/onion_message.py b/electrum/onion_message.py index 617bdaf265e6..e5cdc2e241dd 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -27,9 +27,9 @@ import os import threading import time +import dataclasses 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 @@ -37,13 +37,13 @@ from electrum.lnrouter import PathEdge, NoChannelPolicy from electrum.logging import get_logger, Logger from electrum.crypto import sha256, get_ecdh -from electrum.lnmsg import OnionWireSerializer from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, blinding_privkey, 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 @@ -84,7 +84,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: @@ -95,7 +95,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 @@ -122,31 +122,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: @@ -205,7 +193,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 ): @@ -216,7 +204,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) @@ -250,7 +238,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) @@ -263,88 +251,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: @@ -381,7 +355,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() @@ -397,11 +371,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 @@ -412,7 +386,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 @@ -431,19 +405,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], @@ -451,7 +423,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: @@ -465,13 +440,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) @@ -507,15 +485,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 @@ -542,7 +519,7 @@ class OnionMessageManager(Logger): FORWARD_MAX_QUEUE = 3 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.node_id_or_blinded_paths = node_id_or_blinded_paths @@ -670,7 +647,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 @@ -718,7 +695,7 @@ async 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 f5e89b172b3f..53ef5f735cf3 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -16,7 +16,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.lntransport import LNPeerAddr, extract_nodeid from electrum.lnutil import (LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE, @@ -193,14 +193,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: @@ -208,7 +208,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')) @@ -240,10 +240,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( @@ -539,12 +539,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, @@ -554,13 +553,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 @@ -572,9 +571,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 1a90310146698144a27d541f357160dfa69e1574 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:09:23 +0200 Subject: [PATCH 07/34] 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 c4dfb46ec20f..26eb5adda259 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 @@ -2311,7 +2312,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 3074664bc2ac140e7d4c36d359cd538433097057 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 10 Feb 2026 14:27:44 +0100 Subject: [PATCH 08/34] 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 4174a71a36fb693bd7619358bd2b4bd93ccd1821 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 12 Feb 2026 14:46:09 +0100 Subject: [PATCH 09/34] 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 56289209c6ed213f8b597aad44c7b83d2ca130be Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 5 Nov 2025 14:44:51 +0100 Subject: [PATCH 10/34] 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 26eb5adda259..4d577b10efaf 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 @@ -2317,6 +2319,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 f05a16e47cda0814668cf00d483aa60a14c3ecf0 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 15 Apr 2026 11:44:11 +0200 Subject: [PATCH 11/34] 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 b3b216ffabb6..581915e874fe 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1906,6 +1906,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 d4fb8e99cbd9..7920046b87a2 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, @@ -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: @@ -1090,6 +1096,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 @@ -4307,3 +4316,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: dict, payload: dict): + # 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 e5cdc2e241dd..2aca4f345739 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -767,10 +767,18 @@ def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: d # 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 d6b97e60fdab4a619ae1815be32e82ecac8c7245 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 11:33:30 +0200 Subject: [PATCH 12/34] 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 eeca23e0399f741ac862fff3115c55c2ffde1c96 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:24:32 +0200 Subject: [PATCH 13/34] 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 7069663f3441..ac648c45ebe0 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -248,7 +248,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)) @@ -366,7 +366,7 @@ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]: 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 7920046b87a2..0acefc6271a0 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 2e9de1abd265..62fdb0c5d2c1 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -544,7 +544,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=}" @@ -577,18 +579,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]): @@ -626,7 +616,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 19e023115521..6be6809af2b2 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -831,7 +831,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() @@ -960,7 +960,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 e5e6e8664f9900ba823363d9dca6a977a124ebfa Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 May 2024 12:16:28 +0200 Subject: [PATCH 14/34] 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 ac648c45ebe0..1bc2b016f2f6 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -556,7 +556,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.')) @@ -567,8 +567,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: @@ -628,7 +628,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 cdb97cceb76d..535932b41a13 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -562,7 +562,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 62fdb0c5d2c1..179180e721e1 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()) @@ -371,7 +392,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()) @@ -385,37 +406,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): @@ -543,10 +586,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=}" @@ -595,8 +645,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() @@ -606,17 +658,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 d181a8c7b62f164367ed67d2cf637035d87b4d4f Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 14:08:42 +0100 Subject: [PATCH 15/34] 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 60b7a3c1e63c3a033c205d13614fd821e74ecc81 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sun, 9 Mar 2025 14:04:14 +0100 Subject: [PATCH 16/34] 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 cf0a8d52e63f6edc61fdb901de60ab0b19b51c79 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 May 2026 16:02:18 +0200 Subject: [PATCH 17/34] 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 2f76fb2a22d6..348151ddccf1 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 f3bb35e4ed5975292daa1b49783abf3d396d6667 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:14:22 +0200 Subject: [PATCH 18/34] 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 581915e874fe..4f46a752b5ba 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 @@ -2139,3 +2142,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 2c9cd4471877e6fa697abc1d1ce850d35ed063b9 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 4 Jun 2026 17:38:16 +0200 Subject: [PATCH 19/34] 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 | 10 +++++----- tests/test_lnpeer.py | 9 +++++---- 4 files changed, 29 insertions(+), 13 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 4f46a752b5ba..74aa162a2cdb 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 @@ -2148,13 +2160,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 47ee7e437e1d..8209197f1233 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 @@ -603,7 +603,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 @@ -707,8 +707,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 @@ -793,7 +793,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 diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 6be6809af2b2..a9879b82d140 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -1928,13 +1928,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 f228a14c56bc6d938baa18af034182c4a60478d8 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:27:41 +0200 Subject: [PATCH 20/34] 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 de725b6d58cf..4dd05e028b29 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -20,6 +20,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, get_ecdh from . import bitcoin, util @@ -2031,7 +2032,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: @@ -2046,7 +2048,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 790303e77c28..777bd903b71e 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 @@ -129,20 +131,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 74aa162a2cdb..71aaaf8b7e09 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -2164,10 +2164,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 0acefc6271a0..4e0a2c16e3ee 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, + LNPathInconsistent, fee_for_edge_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 @@ -4103,7 +4165,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): @@ -4185,6 +4247,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, @@ -4200,14 +4263,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, @@ -4258,24 +4325,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') @@ -4290,8 +4378,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 a9879b82d140..ba387cdca0f5 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -1011,11 +1011,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(), @@ -1031,11 +1032,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(), @@ -1635,17 +1637,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 38708ff6edd85f2d8b7309159baeaca32879edb9 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 7 Oct 2025 13:30:46 +0200 Subject: [PATCH 21/34] 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 ff070912c9301a6e1dbc427321ef92f7acbbb118 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 8 Oct 2025 14:34:34 +0200 Subject: [PATCH 22/34] 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 4d577b10efaf..9a5da88d5e9b 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 4e0a2c16e3ee..41fc1b0c113e 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4597,3 +4597,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 2aca4f345739..88c8684dd542 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -397,6 +397,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 e7e7c328b293a7754bf7197862720c763ead7ba8 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 13:24:43 +0200 Subject: [PATCH 23/34] 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 4db33a102c93c013043806cc968bcbdb5f15f886 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 8 Oct 2025 15:47:36 +0100 Subject: [PATCH 24/34] bolt12: handle 'invoice_request' onion message payloads Co-Authored-By: Sander van Grieken --- electrum/bolt12.py | 213 +++++++++++++++++++++++++++++++++- electrum/lnpeer.py | 45 +++++--- electrum/lnworker.py | 235 +++++++++++++++++++++++++++++++++++++- electrum/onion_message.py | 14 ++- tests/test_bolt12.py | 57 ++++++++- tests/test_lnwallet.py | 51 ++++++++- 6 files changed, 585 insertions(+), 30 deletions(-) diff --git a/electrum/bolt12.py b/electrum/bolt12.py index ee092be4506e..bd8a53fcbb2e 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,200 @@ 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': + """Might raise ValueError on invalid data""" + 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__(self, 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 4dd05e028b29..219cb4397c28 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -20,10 +20,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, get_ecdh -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, @@ -36,7 +35,7 @@ from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, - OnionParsingError, decrypt_onionmsg_data_tlv) + OnionParsingError, decrypt_onionmsg_data_tlv, BlindedPathInfo) from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL from . import lnutil from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, LnFeatureContexts, @@ -2160,7 +2159,7 @@ def _check_accepted_final_htlc( 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). @@ -2183,6 +2182,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 htlc.path_key: # payment over blinded path # spec: MUST return an error if the payload contains other tlv fields than allowed_payload_keys allowed_payload_keys = ['encrypted_recipient_data', 'current_path_key', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat'] @@ -2196,8 +2197,21 @@ def _check_accepted_final_htlc( log_fail_reason(f"'path_id' missing in recipient_data") raise exc_incorrect_or_unknown_pd - log_fail_reason('we cannot receive blinded payments yet.') - raise exc_incorrect_or_unknown_pd + try: + path_id_payload = bolt12.BOLT12InvoicePathIDPayload.decode(path_id) + except ValueError 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 @@ -2220,11 +2234,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 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, *, @@ -2261,7 +2275,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, @@ -2269,11 +2283,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: @@ -2304,6 +2319,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, @@ -2332,10 +2348,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 41fc1b0c113e..7b91106ca512 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 @@ -1107,6 +1112,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]]] @@ -4525,9 +4532,7 @@ def on_bolt12_invoice(self, recipient_data: dict, payload: dict): 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: @@ -4593,7 +4598,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 @@ -4650,3 +4655,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: dict, payload: dict): + # 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 88c8684dd542..c3d86ae705d1 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -640,7 +640,7 @@ async def process_send_queue(self) -> None: self.logger.debug(f'resubmit {key=}') self.send_queue.put_nowait((now() + self.REQUEST_REPLY_RETRY_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] @@ -680,7 +680,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) async def _send_pending_message(self, key: bytes) -> None: """adds reply_path to payload""" @@ -768,7 +768,7 @@ def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: d # 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 @@ -780,6 +780,14 @@ def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: d 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 34498b6d4366232d582fff45c0275fe3c6910b05 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 15:36:21 +0200 Subject: [PATCH 25/34] test_lnwallet: test bolt12 invoice request handling --- tests/lnhelpers.py | 33 +++- tests/test_lnwallet.py | 420 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 413 insertions(+), 40 deletions(-) diff --git a/tests/lnhelpers.py b/tests/lnhelpers.py index 1202860d3700..5b7178cdd39c 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_path(), + ), + )] + 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 9b6712a5500bd4391ae984504a09d9bc9b789828 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 13 Nov 2025 12:31:23 +0100 Subject: [PATCH 26/34] 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 70573de8c238..e952f49786ab 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 053f3c2b531312494ff63c1763b5c3f160828e20 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sat, 6 Dec 2025 16:18:54 +0100 Subject: [PATCH 27/34] 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 9a5da88d5e9b..89b458231a15 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 8209197f1233..64f39d4460d3 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -770,6 +770,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 c997c7fc5efbadcda6a80f1605170b65367b7aba Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 17 Mar 2026 16:17:37 +0100 Subject: [PATCH 28/34] 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 71aaaf8b7e09..401866866111 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -2155,6 +2155,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 9a2799a781e5a0ef1f551abf5381bf18aad28e43 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 18 Mar 2026 17:06:42 +0100 Subject: [PATCH 29/34] 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 7b91106ca512..259c7bbbe441 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4188,7 +4188,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 d6994ee622dd6eaff82b42d32ee873c6b2e705a3 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 1 Apr 2026 16:02:07 +0200 Subject: [PATCH 30/34] 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 | 36 ++-- 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, 329 insertions(+), 166 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 348151ddccf1..c7809ee1177e 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 = { @@ -689,17 +695,17 @@ def process_onion_packet( hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) - next_path_key = None - 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 - if current_path_key: - assert recipient_data_shared_secret - next_path_key = next_blinding_from_shared_secret(current_path_key, recipient_data_shared_secret) + next_path_key = None + if not are_we_final and current_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, diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 777bd903b71e..4e6348be6550 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, @@ -117,7 +118,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 259c7bbbe441..7686b6299a37 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, FinalForwardFees + LNPathInconsistent, fee_for_edge_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 124fec33ddb87ecbdbba6bb45a6d258800bd04ba Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 1 Jun 2026 11:56:16 +0200 Subject: [PATCH 31/34] 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 31d6987cacdc43b59032d2e690de71cc15692dcf Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Apr 2026 16:02:39 +0200 Subject: [PATCH 32/34] 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 535932b41a13..a746b937404c 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 b924ee1f2491186b1d4eb59641fafac1012f9c73 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Apr 2026 16:02:55 +0200 Subject: [PATCH 33/34] 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 3b036a7cda66..dea2b26fbd33 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 @@ -107,6 +109,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 @@ -423,6 +449,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 1bc2b016f2f6..9ab26fe65524 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -6,26 +6,26 @@ 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.util import InvoiceError, event_listener 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.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 - +from electrum.bitcoin import address_to_script +from electrum.payment_identifier import ( + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType, invoice_from_payment_identifier +) 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): @@ -35,6 +35,7 @@ class Type(IntEnum): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 + Bolt12Offer = 3 @pyqtEnum class Status(IntEnum): @@ -144,11 +145,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 @@ -248,24 +249,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 @@ -277,6 +295,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() @@ -366,8 +386,8 @@ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]: 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 @@ -447,6 +467,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): @@ -454,6 +478,7 @@ def __init__(self, parent=None): self._pi = None # type: Optional[PaymentIdentifier] self._lnurlData = None + self._offerData = None self._busy = False self.clear() @@ -464,6 +489,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) @@ -474,6 +500,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 @@ -481,7 +515,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.userinfo = '' @@ -506,6 +542,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, @@ -524,7 +566,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 @@ -547,6 +589,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)] @@ -555,8 +601,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.')) @@ -605,6 +651,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): @@ -648,6 +714,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 5419870f825b4eea9d8492cc32c64cb41628f165 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 8 Jun 2026 15:21:03 +0200 Subject: [PATCH 34/34] 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)