From dbaf6a0d180ce3d8ee6fcd0ae495547d982fd270 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 May 2024 10:45:04 +0200 Subject: [PATCH 01/45] lnmsg: add new primitive type `bip340sig`, add tlv merkle root calculation and schnorr-sign over tlvs, implicit en/decode utf8 fields, schnorr signature verification. Co-Authored-By: f321x --- electrum/lnmsg.py | 170 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 11 deletions(-) diff --git a/electrum/lnmsg.py b/electrum/lnmsg.py index e8ad41d24fa9..ea02db7bc36f 100644 --- a/electrum/lnmsg.py +++ b/electrum/lnmsg.py @@ -5,17 +5,21 @@ from types import MappingProxyType from collections import OrderedDict +import electrum_ecc as ecc + +from . import bitcoin from .lnutil import OnionFailureCodeMetaFlag +from .util import chunks class FailedToParseMsg(Exception): msg_type_int: Optional[int] = None msg_type_name: Optional[str] = None + class UnknownMsgType(FailedToParseMsg): pass class UnknownOptionalMsgType(UnknownMsgType): pass class UnknownMandatoryMsgType(UnknownMsgType): pass - class MalformedMsg(FailedToParseMsg): pass class UnknownMsgFieldType(MalformedMsg): pass class UnexpectedEndOfStream(MalformedMsg): pass @@ -24,6 +28,7 @@ class UnknownMandatoryTLVRecordType(MalformedMsg): pass class MsgTrailingGarbage(MalformedMsg): pass class MsgInvalidFieldOrder(MalformedMsg): pass class UnexpectedFieldSizeForEncoder(MalformedMsg): pass +class MsgInvalidSignature(MalformedMsg): pass def _num_remaining_bytes_to_read(fd: io.BytesIO) -> int: @@ -94,7 +99,7 @@ def _read_primitive_field( fd: io.BytesIO, field_type: str, count: Union[int, str] -) -> Union[bytes, int]: +) -> Union[bytes, int, str]: if not fd: raise Exception() if isinstance(count, int): @@ -150,6 +155,8 @@ def _read_primitive_field( type_len = 32 elif field_type == 'signature': type_len = 64 + elif field_type == 'bip340sig': + type_len = 64 elif field_type == 'point': type_len = 33 elif field_type == 'short_channel_id': @@ -166,6 +173,9 @@ def _read_primitive_field( if len(buf) != type_len: raise UnexpectedEndOfStream() return buf + elif field_type == 'utf8': + if count != '...': + raise Exception(f"utf8 fields can only have unbounded count") if count == "...": total_len = -1 # read all @@ -177,6 +187,13 @@ def _read_primitive_field( buf = fd.read(total_len) if total_len >= 0 and len(buf) != total_len: raise UnexpectedEndOfStream() + + if field_type == 'utf8': + try: + return buf.decode('utf-8') + except UnicodeDecodeError as e: + raise MalformedMsg(f'invalid utf-8: {buf.hex()}') from e + return buf @@ -186,7 +203,7 @@ def _write_primitive_field( fd: io.BytesIO, field_type: str, count: Union[int, str], - value: Union[bytes, int] + value: Union[bytes, int, str] ) -> None: if not fd: raise Exception() @@ -246,6 +263,8 @@ def _write_primitive_field( type_len = 32 elif field_type == 'signature': type_len = 64 + elif field_type == 'bip340sig': + type_len = 64 elif field_type == 'point': type_len = 33 elif field_type == 'short_channel_id': @@ -258,6 +277,10 @@ def _write_primitive_field( type_len = 33 # point else: raise Exception(f"invalid sciddir_or_pubkey, prefix byte not in range 0-3") + elif field_type == 'utf8': + if count != '...': + raise Exception(f"utf8 fields can only have unbounded count") + value = value.encode('utf-8') total_len = -1 if count != "...": if type_len is None: @@ -274,12 +297,16 @@ def _write_primitive_field( raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?") -def _read_tlv_record(*, fd: io.BytesIO) -> Tuple[int, bytes]: +def _read_tlv_record(*, fd: io.BytesIO) -> Tuple[int, bytes, bytes]: if not fd: raise Exception() + pos_start = fd.tell() tlv_type = _read_primitive_field(fd=fd, field_type="bigsize", count=1) tlv_len = _read_primitive_field(fd=fd, field_type="bigsize", count=1) tlv_val = _read_primitive_field(fd=fd, field_type="byte", count=tlv_len) - return tlv_type, tlv_val + pos_end = fd.tell() + fd.seek(pos_start) + rawbytes = fd.read(pos_end - pos_start) + return tlv_type, tlv_val, rawbytes def _write_tlv_record(*, fd: io.BytesIO, tlv_type: int, tlv_val: bytes) -> None: @@ -321,6 +348,46 @@ def _parse_msgtype_intvalue_for_onion_wire(value: str) -> int: return msg_type_int +def _tlv_merkle_root(tlvs: List[Sequence[bytes]]) -> bytes: + first_tlv = None + tlv_merkle_nodes = [] + + for tlvt, tlv in tlvs: + if first_tlv is None: + first_tlv = tlv + tlv_val = tlv + tlv_record_type = write_bigsize_int(tlvt) + merkle_leaf_hash = bitcoin.bip340_tagged_hash(b'LnLeaf', tlv_val) + merkle_nonce = bitcoin.bip340_tagged_hash(b'LnNonce' + first_tlv, tlv_record_type) + + # ascending order + msg = merkle_leaf_hash + merkle_nonce if merkle_leaf_hash < merkle_nonce else merkle_nonce + merkle_leaf_hash + merkle_node_hash = bitcoin.bip340_tagged_hash(b'LnBranch', msg) + + tlv_merkle_nodes.append(merkle_node_hash) + + while len(tlv_merkle_nodes) > 1: + target = [] + for chunk in chunks(tlv_merkle_nodes, 2): + if len(chunk) == 1: + target.append(chunk[0]) + else: + msg = chunk[0] + chunk[1] if chunk[0] < chunk[1] else chunk[1] + chunk[0] + merkle_node_hash = bitcoin.bip340_tagged_hash(b'LnBranch', msg) + target.append(merkle_node_hash) + tlv_merkle_nodes = target + + return tlv_merkle_nodes[0] + + +def _is_bolt12_signature_tlv_type(tlv_type: int) -> bool: + """ + Each form is signed using one or more *signature TLV elements*: TLV + types 240 through 1000 (inclusive) + """ + return tlv_type in range(240, 1001) + + class LNSerializer: def __init__(self, *, name: str = 'peer_wire'): @@ -331,6 +398,7 @@ def __init__(self, *, name: str = 'peer_wire'): self.in_tlv_stream_get_tlv_record_scheme_from_type = {} # type: Dict[str, Dict[int, List[Sequence[str]]]] self.in_tlv_stream_get_record_type_from_name = {} # type: Dict[str, Dict[str, int]] self.in_tlv_stream_get_record_name_from_type = {} # type: Dict[str, Dict[int, str]] + self.in_tlv_stream_signature_tlv_records = {} # type: Dict[str, Dict[int, str]] self.subtypes = {} # type: Dict[str, Dict[str, Sequence[str]]] @@ -393,7 +461,17 @@ def __init__(self, *, name: str = 'peer_wire'): assert fieldname not in self.subtypes[subtypename], f"duplicate field definition for {fieldname} for subtype {subtypename}" self.subtypes[subtypename][fieldname] = tuple(row) else: - pass # TODO + pass # TODO: raise? + + for stream_name, scheme_map in self.in_tlv_stream_get_tlv_record_scheme_from_type.items(): + sig_records = {} + for tlv_type, scheme in scheme_map.items(): + for row in scheme: + if row[0] == 'tlvdata' and row[4] == 'bip340sig': + assert _is_bolt12_signature_tlv_type(tlv_type), f"bip340sig field outside bolt 12 range: {stream_name=} {tlv_type=}" + sig_records[tlv_type] = row[3] + break + self.in_tlv_stream_signature_tlv_records[stream_name] = sig_records def write_field( self, @@ -497,12 +575,35 @@ def read_field( return parsedlist if count == '...' or count > 1 else parsedlist[0] - def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, **kwargs) -> None: + def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, signing_key: Optional[bytes] = None, **kwargs) -> None: + sign_over_tlvs = [] + sig_tlv_type, sig_tlv_record_name = None, None + sig_records = self.in_tlv_stream_signature_tlv_records.get(tlv_stream_name, {}) + if signing_key is not None: + sig_tlv_types = list(sig_records.keys()) + if len(sig_tlv_types) != 1: + raise NotImplementedError + sig_tlv_type = sig_tlv_types[0] + sig_tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][sig_tlv_type] + assert sig_tlv_record_name not in kwargs, f"pass either existing {sig_tlv_record_name} or signing_key" + scheme_map = self.in_tlv_stream_get_tlv_record_scheme_from_type[tlv_stream_name] for tlv_record_type, scheme in scheme_map.items(): # note: tlv_record_type is monotonically increasing tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][tlv_record_type] + + is_signature_record = tlv_record_type in sig_records if tlv_record_name not in kwargs: - continue + # skip record_name if not in kwargs, unless we need to generate it + if not is_signature_record or signing_key is None: + continue + # calculate signature over previously serialized tlv records + # and store in kwargs for inclusion in tlv stream + merkle_root = _tlv_merkle_root(sign_over_tlvs) + priv = ecc.ECPrivkey(signing_key) + tag = b'lightning' + tlv_stream_name.encode('ascii') + sig_tlv_record_name.encode('ascii') + signature = priv.schnorr_sign(bitcoin.bip340_tagged_hash(tag, merkle_root)) + kwargs[tlv_record_name] = {sig_records[tlv_record_type]: signature} + with io.BytesIO() as tlv_record_fd: for row in scheme: if row[0] == "tlvtype": @@ -525,14 +626,30 @@ def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, **kwargs) -> value=field_value) else: raise Exception(f"unexpected row in scheme: {row!r}") - _write_tlv_record(fd=fd, tlv_type=tlv_record_type, tlv_val=tlv_record_fd.getvalue()) - def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, Dict[str, Any]]: + tlv_val = tlv_record_fd.getvalue() + + with io.BytesIO() as tlvfd: + _write_tlv_record(fd=tlvfd, tlv_type=tlv_record_type, tlv_val=tlv_val) + raw_tlv = tlvfd.getvalue() + fd.write(raw_tlv) + + # signature TLVs are excluded from the bolt 12 merkle root + if signing_key is not None and not is_signature_record: + sign_over_tlvs.append((tlv_record_type, raw_tlv)) + + def read_tlv_stream(self, *, + fd: io.BytesIO, + tlv_stream_name: str, + signing_key_path: Optional[Sequence[str]] = None) -> Dict[str, Dict[str, Any]]: + sign_over_tlvs = [] + signature_record = None # type: Optional[tuple[int, str]] # (tlv_type, tlv_record_name) parsed = {} # type: Dict[str, Dict[str, Any]] scheme_map = self.in_tlv_stream_get_tlv_record_scheme_from_type[tlv_stream_name] + sig_records = self.in_tlv_stream_signature_tlv_records.get(tlv_stream_name, {}) last_seen_tlv_record_type = -1 # type: int while _num_remaining_bytes_to_read(fd) > 0: - tlv_record_type, tlv_record_val = _read_tlv_record(fd=fd) + tlv_record_type, tlv_record_val, rawbytes = _read_tlv_record(fd=fd) if not (tlv_record_type > last_seen_tlv_record_type): raise MsgInvalidFieldOrder(f"TLV records must be monotonically increasing by type. " f"cur: {tlv_record_type}. prev: {last_seen_tlv_record_type}") @@ -545,8 +662,20 @@ def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, raise UnknownMandatoryTLVRecordType(f"{tlv_stream_name}/{tlv_record_type}") from None else: # unknown "odd" type: skip it + if signing_key_path and not _is_bolt12_signature_tlv_type(tlv_record_type): + sign_over_tlvs.append((tlv_record_type, rawbytes)) continue tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][tlv_record_type] + + # collect tlvs for deferred signature check + if signing_key_path: + if tlv_record_type in sig_records: + if signature_record is not None: + raise MalformedMsg(f"multiple signatures in {tlv_stream_name=} not supported") + signature_record = (tlv_record_type, tlv_record_name) + else: + sign_over_tlvs.append((tlv_record_type, rawbytes)) + parsed[tlv_record_name] = {} with io.BytesIO(tlv_record_val) as tlv_record_fd: for row in scheme: @@ -573,6 +702,25 @@ def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, raise Exception(f"unexpected row in scheme: {row!r}") if _num_remaining_bytes_to_read(tlv_record_fd) > 0: raise MsgTrailingGarbage(f"TLV record ({tlv_stream_name}/{tlv_record_name}) has extra trailing garbage") + + if signing_key_path: + if signature_record is None: + raise MalformedMsg(f"expected signature in {tlv_stream_name}") + sig_tlv_type, sig_tlv_record_name = signature_record + merkle_root = _tlv_merkle_root(sign_over_tlvs) + signing_pubkey = parsed + for key in signing_key_path: # walk signing_key_path + signing_pubkey = signing_pubkey[key] + assert isinstance(signing_pubkey, bytes) + sig_field_name = sig_records[sig_tlv_type] + sig_bytes = parsed[sig_tlv_record_name][sig_field_name] + if not isinstance(sig_bytes, bytes) or len(sig_bytes) != 64: + raise MsgInvalidSignature(f"invalid signature data in {tlv_stream_name}/{sig_tlv_record_name}: {sig_bytes=}") + tag = b'lightning' + tlv_stream_name.encode('ascii') + sig_tlv_record_name.encode('ascii') + tagh = bitcoin.bip340_tagged_hash(tag, merkle_root) + if not ecc.ECPubkey(signing_pubkey).schnorr_verify(sig_bytes, tagh): + raise MsgInvalidSignature(f"invalid signature in {'.'.join(signing_key_path)}") + return parsed def encode_msg(self, msg_type: str, **kwargs) -> bytes: From 378faa537f68b242827b49bffa4fe225c3ac02f7 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 10 Feb 2026 15:33:59 +0100 Subject: [PATCH 02/45] lnmsg: validate point primitive field Validate points to be valid ECPubkeys in lnmsg._read_primitive_field. There are failing bolt12 test vectors that contain offers with invalid points. --- electrum/lnmsg.py | 7 +++++++ tests/test_lnmsg.py | 5 ++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/electrum/lnmsg.py b/electrum/lnmsg.py index ea02db7bc36f..7d1dbc7c8e87 100644 --- a/electrum/lnmsg.py +++ b/electrum/lnmsg.py @@ -194,6 +194,13 @@ def _read_primitive_field( except UnicodeDecodeError as e: raise MalformedMsg(f'invalid utf-8: {buf.hex()}') from e + if field_type == 'point': + for point in chunks(buf, type_len): + try: + ecc.ECPubkey(b=point) + except Exception as e: + raise MalformedMsg(f"invalid point: {point.hex()}") from e + return buf diff --git a/tests/test_lnmsg.py b/tests/test_lnmsg.py index b7ca81798444..d542c44c2267 100644 --- a/tests/test_lnmsg.py +++ b/tests/test_lnmsg.py @@ -116,9 +116,8 @@ def test_read_tlv_stream_tests1(self): lnser.read_tlv_stream(fd=io.BytesIO(bfh("0329023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb0000000000000001")), tlv_stream_name="n1") with self.assertRaises(UnexpectedEndOfStream): lnser.read_tlv_stream(fd=io.BytesIO(bfh("0330023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb000000000000000100000000000001")), tlv_stream_name="n1") - # check if ECC point is valid?... skip for now. - #with self.assertRaises(Exception): - # lnser.read_tlv_stream(fd=io.BytesIO(bfh("0331043da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb00000000000000010000000000000002")), tlv_stream_name="n1") + with self.assertRaises(MalformedMsg): # check if ECC point is valid + lnser.read_tlv_stream(fd=io.BytesIO(bfh("0331043da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb00000000000000010000000000000002")), tlv_stream_name="n1") with self.assertRaises(MsgTrailingGarbage): lnser.read_tlv_stream(fd=io.BytesIO(bfh("0332023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb0000000000000001000000000000000001")), tlv_stream_name="n1") with self.assertRaises(UnexpectedEndOfStream): From 74a843b4e72570811b06f31f928af388e5428881 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 20 Apr 2026 11:02:57 +0200 Subject: [PATCH 03/45] lnmsg: fix parsing of nested complex types where the leaf objects were consuming too much from the fd. --- electrum/lnmsg.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/electrum/lnmsg.py b/electrum/lnmsg.py index 7d1dbc7c8e87..02a33e45e4d3 100644 --- a/electrum/lnmsg.py +++ b/electrum/lnmsg.py @@ -580,6 +580,11 @@ def read_field( count=subtype_field_count) parsedlist.append(parsed) + # fd might contain more bytes, but we got passed a count. break when we have 'count' items. + # (e.g. nested complex types) + if isinstance(count, int) and len(parsedlist) == count: + break + return parsedlist if count == '...' or count > 1 else parsedlist[0] def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, signing_key: Optional[bytes] = None, **kwargs) -> None: From c2aca2e20e301fc03de29d3790438b8e2fb9debc Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 21 Apr 2026 16:34:42 +0200 Subject: [PATCH 04/45] test_lnmsg: add bolt 12 merkle tree test vector --- tests/bolt12-signature-test.json | 137 +++++++++++++++++++++++++++++++ tests/test_lnmsg.py | 27 +++++- 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/bolt12-signature-test.json diff --git a/tests/bolt12-signature-test.json b/tests/bolt12-signature-test.json new file mode 100644 index 000000000000..00d327071e66 --- /dev/null +++ b/tests/bolt12-signature-test.json @@ -0,0 +1,137 @@ +[ + { + "comment": "Simple n1 test, tlv1 = 1000", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + } + ], + "branches": [], + "merkle": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,tlv2-type)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + } + ], + "merkle": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3, tlv3 = 0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518, 1, 2", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,1)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,2)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + }, + { + "H(`LnLeaf`,03310266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c0351800000000000000010000000000000002)": "47da319b36d61a006e0dbcf6642fe4c822c33a6131af67dfa9293b089c5cbd27", + "H(`LnNonce`|first-tlv,3)": "068cf6e9d2db9258a6c1d3304a8f2e9d4d046ea711664c9a96960234f707a084", + "H(`LnBranch`,leaf+nonce)": "7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "desc": "1 and tlv3+nonce", + "H(`LnBranch`,7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1)": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + } + ], + "merkle": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + }, + { + "comment": "invoice_request test: offer_issuer_id = Alice (privkey 0x414141...), offer_description = 'A Mathematical Treatise', offer_amount = 100, offer_currency = 'USD', invreq_payer_id = Bob (privkey 0x424242...), invreq_metadata = 0x0000000000000000", + "bolt12": "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss", + "tlv": "invoice_request", + "first-tlv": "00080000000000000000", + "leaves": [ + { + "H(`LnLeaf`,00080000000000000000)": "cd45d50b8dbb73ba995f92aa48be7c2909331998cb070572f5499bae338a03c6", + "H(`LnNonce`|first-tlv,0)": "edc13c82e89b213a5641b27f0c06c5f31ea948a0cc2fd6495120cc8590cac3f5", + "H(`LnBranch`,leaf+nonce)": "5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94ab" + }, + { + "H(`LnLeaf`,0603555344)": "ae61bfe63f8fc81b7a02a962182a5b5e01501365806481d52fbdfbca915266fa", + "H(`LnNonce`|first-tlv,6)": "cc9fc57ce5e82252b6cc8908a93f012b13294a82132768e36dd767b3c3c289e8", + "H(`LnBranch`,leaf+nonce)": "a2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e" + }, + { + "H(`LnLeaf`,080164)": "b4f3adb8ca4f4a4c0e7cd9e0b1cafe8634cf8a864e1a730868bdda39fbd3e336", + "H(`LnNonce`|first-tlv,8)": "376180f1ef3b7973ba4989f9391502bd78a1a8a54929fe9adcaec1dd2bfec648", + "H(`LnBranch`,leaf+nonce)": "fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0" + }, + { + "H(`LnLeaf`,0a1741204d617468656d61746963616c205472656174697365)": "7007775409456c33c47bddd7ce946ecd5a82035f1d5a529cc90e84d146f75a6e", + "H(`LnNonce`|first-tlv,10)": "01926a0c38b4ec71d76b116eeb81ea7999706fdce24a7f5b9d67bf867fd0c4d8", + "H(`LnBranch`,leaf+nonce)": "349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230" + }, + { + "H(`LnLeaf`,162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619)": "bdde38b7b58fa74acee1e943bbc32c04306368cb2aa513856f53f45be461051b", + "H(`LnNonce`|first-tlv,22)": "2e571571c7dd0739dbc4180bb96b7652b055f9e97f80d37337c96689990fdbaa", + "H(`LnBranch`,leaf+nonce)": "384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e47" + }, + { + "H(`LnLeaf`,58210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c)": "f3b92382531e261e16a0f35d65f314ae622306bbb1b206fee00d80153b76eea3", + "H(`LnNonce`|first-tlv,88)": "c31a695332d176217470b705cde5c8cd71cdb611e1f26c5a98f14c0d935c97bd", + "H(`LnBranch`,leaf+nonce)": "73e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600" + } + ], + "branches": [ + { + "desc": "1: metadata+nonce and currency+nonce", + "H(`LnBranch`,5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94aba2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e)": "f0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9" + }, + { + "desc": "2: amount+nonce and descripton+nonce", + "H(`LnBranch`,349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0)": "92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400e" + }, + { + "desc": "3: 1 and 2", + "H(`LnBranch`,92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400ef0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9)": "432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a" + }, + { + "desc": "4: node_id+nonce and payer_id+nonce", + "H(`LnBranch`,384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e4773e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600)": "2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d" + }, + { + "desc": "5: 3 and 4", + "H(`LnBranch`,2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a)": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624" + } + ], + "merkle": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624", + "signature_tag": "lightninginvoice_requestsignature", + "H(signature_tag,merkle)": "aefe3aa88a69772c246dcaef75ed3e7566c08ecc4e9f995233526a5651fc34cd", + "signature": "b8f83ea3288cfd6ea510cdb481472575141e8d8744157f98562d162cc1c472526fdb24befefbdebab4dbb726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" + } +] diff --git a/tests/test_lnmsg.py b/tests/test_lnmsg.py index d542c44c2267..c01b54c2340f 100644 --- a/tests/test_lnmsg.py +++ b/tests/test_lnmsg.py @@ -1,12 +1,13 @@ import io +import os from electrum.lnmsg import (read_bigsize_int, write_bigsize_int, FieldEncodingNotMinimal, UnexpectedEndOfStream, LNSerializer, UnknownMandatoryTLVRecordType, MalformedMsg, MsgTrailingGarbage, MsgInvalidFieldOrder, encode_msg, decode_msg, UnexpectedFieldSizeForEncoder, OnionWireSerializer, - UnknownMsgType) + UnknownMsgType, _tlv_merkle_root, _read_tlv_record) from electrum.lnonion import OnionRoutingFailure -from electrum.util import bfh +from electrum.util import bfh, read_json_file from electrum.lnutil import ShortChannelID, LnFeatures from electrum.channel_db import NodeInfo from electrum import constants @@ -412,3 +413,25 @@ def test_address_parsing_and_serialization(self): self.assertEqual(paf(taf(host, port)), [(host, port)]) for input_, output in valid_inputs_with_defined_output: self.assertEqual(paf(taf(*input_)), output) + + def test_bolt12_signature_test_vectors(self): + test_vector_file = os.path.join(os.path.dirname(__file__), "bolt12-signature-test.json") + vectors = read_json_file(test_vector_file) + leaf_key_prefix = "H(`LnLeaf`," + + def _extract_tlvs(vector): + tlvs = [] + for leaf in vector["leaves"]: + leaf_tlv_hex = next( # "H(`LnLeaf`,010203e8)" <- extract the tlv hex + k[len(leaf_key_prefix):-1] for k in leaf if k.startswith(leaf_key_prefix) + ) + tlv_bytes = bfh(leaf_tlv_hex) + tlv_type, _, _ = _read_tlv_record(fd=io.BytesIO(tlv_bytes)) + tlvs.append((tlv_type, tlv_bytes)) + return tlvs + + for vector in vectors: + with self.subTest(comment=vector["comment"]): + tlvs = _extract_tlvs(vector) + merkle_root = _tlv_merkle_root(tlvs) + self.assertEqual(vector["merkle"], merkle_root.hex()) From f9437e895f88ff33442303e259b4841019d164f8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 8 Jul 2024 11:35:11 +0200 Subject: [PATCH 05/45] lnwire: add bolt12 types to onion_wire --- electrum/lnwire/onion_wire.csv | 138 +++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv index 135312017059..d63d8401d5b5 100644 --- a/electrum/lnwire/onion_wire.csv +++ b/electrum/lnwire/onion_wire.csv @@ -116,3 +116,141 @@ subtype,blinded_path_hop subtypedata,blinded_path_hop,blinded_node_id,point, subtypedata,blinded_path_hop,enclen,u16, subtypedata,blinded_path_hop,encrypted_recipient_data,byte,enclen +tlvtype,offer,offer_chains,2 +tlvdata,offer,offer_chains,chains,chain_hash,... +tlvtype,offer,offer_metadata,4 +tlvdata,offer,offer_metadata,data,byte,... +tlvtype,offer,offer_currency,6 +tlvdata,offer,offer_currency,iso4217,utf8,... +tlvtype,offer,offer_amount,8 +tlvdata,offer,offer_amount,amount,tu64, +tlvtype,offer,offer_description,10 +tlvdata,offer,offer_description,description,utf8,... +tlvtype,offer,offer_features,12 +tlvdata,offer,offer_features,features,byte,... +tlvtype,offer,offer_absolute_expiry,14 +tlvdata,offer,offer_absolute_expiry,seconds_from_epoch,tu64, +tlvtype,offer,offer_paths,16 +tlvdata,offer,offer_paths,paths,blinded_path,... +tlvtype,offer,offer_issuer,18 +tlvdata,offer,offer_issuer,issuer,utf8,... +tlvtype,offer,offer_quantity_max,20 +tlvdata,offer,offer_quantity_max,max,tu64, +tlvtype,offer,offer_issuer_id,22 +tlvdata,offer,offer_issuer_id,id,point, +tlvtype,invoice_request,invreq_metadata,0 +tlvdata,invoice_request,invreq_metadata,blob,byte,... +tlvtype,invoice_request,offer_chains,2 +tlvdata,invoice_request,offer_chains,chains,chain_hash,... +tlvtype,invoice_request,offer_metadata,4 +tlvdata,invoice_request,offer_metadata,data,byte,... +tlvtype,invoice_request,offer_currency,6 +tlvdata,invoice_request,offer_currency,iso4217,utf8,... +tlvtype,invoice_request,offer_amount,8 +tlvdata,invoice_request,offer_amount,amount,tu64, +tlvtype,invoice_request,offer_description,10 +tlvdata,invoice_request,offer_description,description,utf8,... +tlvtype,invoice_request,offer_features,12 +tlvdata,invoice_request,offer_features,features,byte,... +tlvtype,invoice_request,offer_absolute_expiry,14 +tlvdata,invoice_request,offer_absolute_expiry,seconds_from_epoch,tu64, +tlvtype,invoice_request,offer_paths,16 +tlvdata,invoice_request,offer_paths,paths,blinded_path,... +tlvtype,invoice_request,offer_issuer,18 +tlvdata,invoice_request,offer_issuer,issuer,utf8,... +tlvtype,invoice_request,offer_quantity_max,20 +tlvdata,invoice_request,offer_quantity_max,max,tu64, +tlvtype,invoice_request,offer_issuer_id,22 +tlvdata,invoice_request,offer_issuer_id,id,point, +tlvtype,invoice_request,invreq_chain,80 +tlvdata,invoice_request,invreq_chain,chain,chain_hash, +tlvtype,invoice_request,invreq_amount,82 +tlvdata,invoice_request,invreq_amount,msat,tu64, +tlvtype,invoice_request,invreq_features,84 +tlvdata,invoice_request,invreq_features,features,byte,... +tlvtype,invoice_request,invreq_quantity,86 +tlvdata,invoice_request,invreq_quantity,quantity,tu64, +tlvtype,invoice_request,invreq_payer_id,88 +tlvdata,invoice_request,invreq_payer_id,key,point, +tlvtype,invoice_request,invreq_payer_note,89 +tlvdata,invoice_request,invreq_payer_note,note,utf8,... +tlvtype,invoice_request,invreq_paths,90 +tlvdata,invoice_request,invreq_paths,paths,blinded_path,... +tlvtype,invoice_request,signature,240 +tlvdata,invoice_request,signature,sig,bip340sig, +tlvtype,invoice,invreq_metadata,0 +tlvdata,invoice,invreq_metadata,blob,byte,... +tlvtype,invoice,offer_chains,2 +tlvdata,invoice,offer_chains,chains,chain_hash,... +tlvtype,invoice,offer_metadata,4 +tlvdata,invoice,offer_metadata,data,byte,... +tlvtype,invoice,offer_currency,6 +tlvdata,invoice,offer_currency,iso4217,utf8,... +tlvtype,invoice,offer_amount,8 +tlvdata,invoice,offer_amount,amount,tu64, +tlvtype,invoice,offer_description,10 +tlvdata,invoice,offer_description,description,utf8,... +tlvtype,invoice,offer_features,12 +tlvdata,invoice,offer_features,features,byte,... +tlvtype,invoice,offer_absolute_expiry,14 +tlvdata,invoice,offer_absolute_expiry,seconds_from_epoch,tu64, +tlvtype,invoice,offer_paths,16 +tlvdata,invoice,offer_paths,paths,blinded_path,... +tlvtype,invoice,offer_issuer,18 +tlvdata,invoice,offer_issuer,issuer,utf8,... +tlvtype,invoice,offer_quantity_max,20 +tlvdata,invoice,offer_quantity_max,max,tu64, +tlvtype,invoice,offer_issuer_id,22 +tlvdata,invoice,offer_issuer_id,id,point, +tlvtype,invoice,invreq_chain,80 +tlvdata,invoice,invreq_chain,chain,chain_hash, +tlvtype,invoice,invreq_amount,82 +tlvdata,invoice,invreq_amount,msat,tu64, +tlvtype,invoice,invreq_features,84 +tlvdata,invoice,invreq_features,features,byte,... +tlvtype,invoice,invreq_quantity,86 +tlvdata,invoice,invreq_quantity,quantity,tu64, +tlvtype,invoice,invreq_payer_id,88 +tlvdata,invoice,invreq_payer_id,key,point, +tlvtype,invoice,invreq_payer_note,89 +tlvdata,invoice,invreq_payer_note,note,utf8,... +tlvtype,invoice,invreq_paths,90 +tlvdata,invoice,invreq_paths,paths,blinded_path,... +tlvtype,invoice,invoice_paths,160 +tlvdata,invoice,invoice_paths,paths,blinded_path,... +tlvtype,invoice,invoice_blindedpay,162 +tlvdata,invoice,invoice_blindedpay,payinfo,blinded_payinfo,... +tlvtype,invoice,invoice_created_at,164 +tlvdata,invoice,invoice_created_at,timestamp,tu64, +tlvtype,invoice,invoice_relative_expiry,166 +tlvdata,invoice,invoice_relative_expiry,seconds_from_creation,tu32, +tlvtype,invoice,invoice_payment_hash,168 +tlvdata,invoice,invoice_payment_hash,payment_hash,sha256, +tlvtype,invoice,invoice_amount,170 +tlvdata,invoice,invoice_amount,msat,tu64, +tlvtype,invoice,invoice_fallbacks,172 +tlvdata,invoice,invoice_fallbacks,fallbacks,fallback_address,... +tlvtype,invoice,invoice_features,174 +tlvdata,invoice,invoice_features,features,byte,... +tlvtype,invoice,invoice_node_id,176 +tlvdata,invoice,invoice_node_id,node_id,point, +tlvtype,invoice,signature,240 +tlvdata,invoice,signature,sig,bip340sig, +subtype,blinded_payinfo +subtypedata,blinded_payinfo,fee_base_msat,u32, +subtypedata,blinded_payinfo,fee_proportional_millionths,u32, +subtypedata,blinded_payinfo,cltv_expiry_delta,u16, +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,fallback_address +subtypedata,fallback_address,version,byte, +subtypedata,fallback_address,len,u16, +subtypedata,fallback_address,address,byte,len +tlvtype,invoice_error,erroneous_field,1 +tlvdata,invoice_error,erroneous_field,tlv_fieldnum,tu64, +tlvtype,invoice_error,suggested_value,3 +tlvdata,invoice_error,suggested_value,value,byte,... +tlvtype,invoice_error,error,5 +tlvdata,invoice_error,error,msg,utf8,... From 735a3e23630ee4b62534008ffea01e1b0388c8ea Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 6 May 2026 17:37:45 +0200 Subject: [PATCH 06/45] onion_message: factor out payinfo creation from get_blinded_paths_to_me Separates the payinfo creation from get_blinded_paths_to_me into a separate function. --- electrum/onion_message.py | 107 ++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 167b8976a4f3..0b30a6a2e54f 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -414,61 +414,22 @@ def get_blinded_paths_to_me( lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] result = [] - payinfo = [] + payinfos = [] mynodeid = lnwallet.node_keypair.pubkey - local_height = lnwallet.network.get_local_height() - if len(my_channels): rchans = random_shuffled_copy(my_channels) for chan in rchans[:max_paths]: hop_extras = None if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) - # get policy - cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) - - dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED - - # TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop - # and determine the cltv delta sums and fee sums of the hops for the payinfo struct. - # current assumption is len(blinded_path) == 2 (us and peer) - sum_cltv_expiry_delta = cp.cltv_delta - sum_fee_base_msat = cp.fee_base_msat - sum_fee_proportional_millionths = cp.fee_proportional_millionths - # path htlc limits - blinded_path_min_htlc_msat = cp.htlc_minimum_msat - blinded_path_max_htlc_msat = cp.htlc_maximum_msat - - hop_extras = [{ - # spec: MUST include encrypted_data_tlv.payment_relay for each non-final node. - 'payment_relay': { - 'cltv_expiry_delta': cp.cltv_delta, - 'fee_base_msat': cp.fee_base_msat, - 'fee_proportional_millionths': cp.fee_proportional_millionths, - }, - # spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node: - # - # max_cltv_expiry to the largest block height at which the route is allowed to be used, starting - # from the final node's chosen max_cltv_expiry height at which the route should expire, adding - # the final node's min_final_cltv_expiry_delta and then adding - # encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop. - # - # htlc_minimum_msat to the largest minimum HTLC value the nodes will allow. - 'payment_constraints': { - 'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta, - 'htlc_minimum_msat': blinded_path_min_htlc_msat - } - }] - payinfo.append({ - 'fee_base_msat': sum_fee_base_msat, - 'fee_proportional_millionths': sum_fee_proportional_millionths, - 'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED, - 'htlc_minimum_msat': blinded_path_min_htlc_msat, - 'htlc_maximum_msat': blinded_path_max_htlc_msat, - 'flen': 0, - 'features': bytes(0) - }) - blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data, - hop_extras=hop_extras, channels=[chan] if not onion_message else None) + payinfo, hop_extras = _get_payinfo_for_blinded_path(chan, lnwallet) + payinfos.append(payinfo) + blinded_path = create_blinded_path( + session_key=os.urandom(32), + path=[chan.node_id, mynodeid], + final_recipient_data=final_recipient_data, + hop_extras=hop_extras, + channels=[chan] if not onion_message else None, + ) result.append(blinded_path) elif onion_message: # we can use peers even without channels for onion messages @@ -480,7 +441,53 @@ def get_blinded_paths_to_me( blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) result.append(blinded_path) - return result, payinfo + return result, payinfos + + +def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet'): + cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) + sum_cltv_expiry_delta = cp.cltv_delta + sum_fee_base_msat = cp.fee_base_msat + sum_fee_proportional_millionths = cp.fee_proportional_millionths + # path htlc limits + blinded_path_min_htlc_msat = cp.htlc_minimum_msat + blinded_path_max_htlc_msat = cp.htlc_maximum_msat + + # TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop + # and determine the cltv delta sums and fee sums of the hops for the payinfo struct. + # current assumption is len(blinded_path) == 2 (us and peer) + local_height = lnwallet.network.get_local_height() + dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED + hop_extras = [{ + # spec: MUST include encrypted_data_tlv.payment_relay for each non-final node. + 'payment_relay': { + 'cltv_expiry_delta': cp.cltv_delta, + 'fee_base_msat': cp.fee_base_msat, + 'fee_proportional_millionths': cp.fee_proportional_millionths, + }, + # spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node: + # + # max_cltv_expiry to the largest block height at which the route is allowed to be used, starting + # from the final node's chosen max_cltv_expiry height at which the route should expire, adding + # the final node's min_final_cltv_expiry_delta and then adding + # encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop. + # + # htlc_minimum_msat to the largest minimum HTLC value the nodes will allow. + 'payment_constraints': { + 'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta, + 'htlc_minimum_msat': blinded_path_min_htlc_msat + } + }] + payinfo = { + 'fee_base_msat': sum_fee_base_msat, + 'fee_proportional_millionths': sum_fee_proportional_millionths, + 'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED, + 'htlc_minimum_msat': blinded_path_min_htlc_msat, + 'htlc_maximum_msat': blinded_path_max_htlc_msat, + 'flen': 0, + 'features': b'', + } + return payinfo, hop_extras class Timeout(Exception): pass From 17f6e8621836262757408a3700b7aea0c530cf5b Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 7 May 2026 11:15:57 +0200 Subject: [PATCH 07/45] onion_message: handle missing remote update Handle missing remote channel_update when creating payinfo for blinded path by skipping the affected channel. --- electrum/onion_message.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 0b30a6a2e54f..e5cf5a8190c3 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -33,7 +33,7 @@ import electrum_ecc as ecc from electrum.channel_db import get_mychannel_policy -from electrum.lnrouter import PathEdge +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 @@ -421,7 +421,11 @@ def get_blinded_paths_to_me( for chan in rchans[:max_paths]: hop_extras = None if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) - payinfo, hop_extras = _get_payinfo_for_blinded_path(chan, lnwallet) + 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), @@ -446,6 +450,8 @@ def get_blinded_paths_to_me( def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet'): 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) sum_cltv_expiry_delta = cp.cltv_delta sum_fee_base_msat = cp.fee_base_msat sum_fee_proportional_millionths = cp.fee_proportional_millionths From a6a2e5d99f2f1e82797ff681c0076cd18920945a Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 7 May 2026 11:52:38 +0200 Subject: [PATCH 08/45] onion_message: use trampoline nodes for onion messages --- electrum/onion_message.py | 55 ++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index e5cf5a8190c3..a4ff8be7a2b7 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -27,6 +27,7 @@ import os import threading import time +import random from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union @@ -150,18 +151,27 @@ def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bo def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Sequence[PathEdge]: - """Constructs a route to the destination node_id, first by starting with peers with existing channels, - and if no route found, opening a direct peer connection if node_id is found with an address in - channel_db.""" - # TODO: is this the proper way to set up my_sending_channels? - my_active_channels = [ - chan for chan in lnwallet.channels.values() if - chan.is_active() and not chan.is_frozen_for_sending()] - my_sending_channels = {chan.short_channel_id: chan for chan in my_active_channels - if chan.short_channel_id is not None} - # find route to introduction point over existing channel mesh - # NOTE: nodes that are in channel_db but are offline are not removed from the set - if lnwallet.network.path_finder: + """ + Constructs a route for sending an onion message to node_id. + With Gossip: Tries to find a route through the graph. If no route can be found, raise NoRouteFound. + If a connection address is known for node_id it is added to NoRouteFound for potential direct connection. + With Trampoline: Construct a route: us -> random trampoline peer -> node_id. + We hope the trampoline peer will open a direct connection to node_id and forward the message. + NOTE: Eclair only does this with the relay-policy config set to relay-all! + """ + # destination is existing peer, use direct route + if lnwallet.lnpeermgr.get_peer_by_pubkey(node_id): + return [PathEdge(short_channel_id=None, start_node=None, end_node=node_id)] + + if not lnwallet.uses_trampoline(): + # find route to node over existing channel mesh + # NOTE: nodes that are in channel_db but are offline are not removed from the set + my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] + my_sending_channels = { + chan.short_channel_id: chan for chan in my_active_channels + if chan.short_channel_id is not None + } + if path := lnwallet.network.path_finder.find_path_for_payment( nodeA=lnwallet.node_keypair.pubkey, nodeB=node_id, @@ -173,15 +183,18 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node) assert peer, 'first hop not a peer' return path - - # alt: dest is existing peer? - if lnwallet.lnpeermgr.get_peer_by_pubkey(node_id): - return [PathEdge(short_channel_id=None, start_node=None, end_node=node_id)] - - # if we have an address, pass it. - if lnwallet.channel_db: - if peer_addr := lnwallet.channel_db.get_last_good_address(node_id): + elif peer_addr := lnwallet.channel_db.get_last_good_address(node_id): + # if we have an address, pass it. raise NoRouteFound('no path found, peer_addr available', peer_address=peer_addr) + else: + # if we use trampoline just assume the trampoline will open a direct connection to the next_node_id + trampoline_peers = [p for p in lnwallet.lnpeermgr.peers.values() if lnwallet.is_trampoline_peer(p.pubkey)] + if trampoline_peers: + random_trampoline_peer = random.choice(trampoline_peers) + return [ + PathEdge(short_channel_id=None, start_node=None, end_node=random_trampoline_peer.pubkey), + PathEdge(short_channel_id=None, start_node=random_trampoline_peer.pubkey, end_node=node_id), + ] raise NoRouteFound('no path found') @@ -538,7 +551,7 @@ def get_next_destination(self) -> bytes: def __init__(self, lnwallet: 'LNWallet'): Logger.__init__(self) - self.network = None # type: Optional['Network'] + self.network = None # type: Optional[Network] self.taskgroup = None # type: OldTaskGroup self.lnwallet = lnwallet self.pending = {} # type: dict[bytes, OnionMessageManager.Request] From fe1278a87295537d2b3714c3aa348b5424cf0eea Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 7 May 2026 14:02:23 +0200 Subject: [PATCH 09/45] onion_message: pathfinding: ignore amount contraints Ignore channel amount constraints when doing pathfinding for an onion message. Onion messages don't need to move funds so pathfinding shouldn't penalize channels based on fake payment amounts. --- electrum/lnrouter.py | 59 ++++++++++++++++++++++++++++----------- electrum/onion_message.py | 5 ++-- tests/test_lnrouter.py | 37 ++++++++++++++---------- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 4322dc692b6b..790303e77c28 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -452,6 +452,7 @@ def _edge_cost( end_node: bytes, payment_amt_msat: int, ignore_costs=False, + ignore_amount_constraints: bool = False, is_mine=False, my_channels: Dict[ShortChannelID, 'Channel'] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None, @@ -481,14 +482,15 @@ def _edge_cost( return float('inf'), 0 if channel_policy.is_disabled(): return float('inf'), 0 - if payment_amt_msat < channel_policy.htlc_minimum_msat: - return float('inf'), 0 # payment amount too little - if channel_info.capacity_sat is not None and \ - payment_amt_msat // 1000 > channel_info.capacity_sat: - return float('inf'), 0 # payment amount too large - if channel_policy.htlc_maximum_msat is not None and \ - payment_amt_msat > channel_policy.htlc_maximum_msat: - return float('inf'), 0 # payment amount too large + if not ignore_amount_constraints: + if payment_amt_msat < channel_policy.htlc_minimum_msat: + return float('inf'), 0 # payment amount too little + if channel_info.capacity_sat is not None and \ + payment_amt_msat // 1000 > channel_info.capacity_sat: + return float('inf'), 0 # payment amount too large + if channel_policy.htlc_maximum_msat is not None and \ + payment_amt_msat > channel_policy.htlc_maximum_msat: + return float('inf'), 0 # payment amount too large route_edge = private_route_edges.get(short_channel_id, None) if route_edge is None: node_info = self.channel_db.get_node_info_for_node_id(node_id=end_node) @@ -513,7 +515,7 @@ def _edge_cost( # - The larger the payment amount, and the longer the CLTV, # the more irritating it is if the HTLC gets stuck. # - Paying lower fees is better. :) - if ignore_costs: + if ignore_costs or ignore_amount_constraints: return DEFAULT_PENALTY_BASE_MSAT, 0 fee_msat = route_edge.fee_for_edge(payment_amt_msat) cltv_cost = route_edge.cltv_delta * payment_amt_msat * 15 / 1_000_000_000 @@ -528,10 +530,10 @@ def get_shortest_path_hops( *, nodeA: bytes, # nodeA is expected to be our node id if channels are passed in my_sending_channels nodeB: bytes, - invoice_amount_msat: int, + invoice_amount_msat: Optional[int], my_sending_channels: Dict[ShortChannelID, 'Channel'] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None, - node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None + node_filter: Optional[Callable[[bytes, Optional[NodeInfo]], bool]] = None, ) -> Dict[bytes, PathEdge]: # note: we don't lock self.channel_db, so while the path finding runs, # the underlying graph could potentially change... (not good but maybe ~OK?) @@ -545,11 +547,12 @@ def get_shortest_path_hops( # run Dijkstra # The search is run in the REVERSE direction, from nodeB to nodeA, # to properly calculate compound routing fees. + ignore_amount_constraints = invoice_amount_msat is None # e.g. onion messages distance_from_start = defaultdict(lambda: float('inf')) distance_from_start[nodeB] = 0 previous_hops = {} # type: Dict[bytes, PathEdge] nodes_to_explore = queue.PriorityQueue() - nodes_to_explore.put((0, invoice_amount_msat, nodeB)) # order of fields (in tuple) matters! + nodes_to_explore.put((0, invoice_amount_msat or 0, nodeB)) # order of fields (in tuple) matters! now = int(time.time()) # main loop of search @@ -592,7 +595,8 @@ def get_shortest_path_hops( if edge_startnode == nodeA and my_sending_channels: # payment outgoing, on our channel if edge_channel_id not in my_sending_channels: continue - if not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True): + if not ignore_amount_constraints \ + and not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True): continue edge_cost, fee_for_edge_msat = self._edge_cost( short_channel_id=edge_channel_id, @@ -600,6 +604,7 @@ def get_shortest_path_hops( end_node=edge_endnode, payment_amt_msat=amount_msat, ignore_costs=(edge_startnode == nodeA), + ignore_amount_constraints=ignore_amount_constraints, is_mine=is_mine, my_channels=my_sending_channels, private_route_edges=private_route_edges, @@ -626,15 +631,15 @@ def find_path_for_payment( *, nodeA: bytes, nodeB: bytes, - invoice_amount_msat: int, + invoice_amount_msat: Optional[int], my_sending_channels: Dict[ShortChannelID, 'Channel'] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None, - node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None + node_filter: Optional[Callable[[bytes, Optional[NodeInfo]], bool]] = None ) -> Optional[LNPaymentPath]: """Return a path from nodeA to nodeB.""" assert type(nodeA) is bytes assert type(nodeB) is bytes - assert type(invoice_amount_msat) is int + assert type(invoice_amount_msat) is int or invoice_amount_msat is None if my_sending_channels is None: my_sending_channels = {} @@ -659,6 +664,28 @@ def find_path_for_payment( edge_startnode = edge.node_id return path + def find_path_for_onion_message( + self, + *, + nodeA: bytes, + nodeB: bytes, + my_sending_channels: Dict[ShortChannelID, 'Channel'] = None, + private_route_edges: Dict[ShortChannelID, RouteEdge] = None, + ) -> Optional[LNPaymentPath]: + from .onion_message import is_onion_message_node + def _node_filter(edge_startnode, node_info): + if edge_startnode == nodeA: + return True # assume the sending node does support onion messages + return is_onion_message_node(edge_startnode, node_info) + return self.find_path_for_payment( + nodeA=nodeA, + nodeB=nodeB, + my_sending_channels=my_sending_channels, + private_route_edges=private_route_edges, + node_filter=_node_filter, + invoice_amount_msat=None, + ) + def create_route_from_path( self, path: Optional[LNPaymentPath], diff --git a/electrum/onion_message.py b/electrum/onion_message.py index a4ff8be7a2b7..a6164bd3298d 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -172,11 +172,10 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque if chan.short_channel_id is not None } - if path := lnwallet.network.path_finder.find_path_for_payment( + # TODO: if this blocks the event loop too long it might needs to go on a thread + if path := lnwallet.network.path_finder.find_path_for_onion_message( nodeA=lnwallet.node_keypair.pubkey, nodeB=node_id, - invoice_amount_msat=10000, # TODO: do this without amount constraints - node_filter=lambda x, y: True if x == lnwallet.node_keypair.pubkey else is_onion_message_node(x, y), my_sending_channels=my_sending_channels ): # first edge must be to our peer diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py index acc8f40b6db5..3ad775cf691d 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -523,33 +523,40 @@ def test_decode_onion_error(self): async def test_find_path_for_onion_message(self): self.prepare_graph() - amount_to_send = 1000 # we route along channels, and we use find_path_for_payment, so dummy this. - path = self.path_finder.find_path_for_payment( - nodeA=node('a'), - nodeB=node('c'), - invoice_amount_msat=amount_to_send, - node_filter=is_onion_message_node) + path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('c')) self.assertEqual([ PathEdge(start_node=node('a'), end_node=node('d'), short_channel_id=channel(6)), PathEdge(start_node=node('d'), end_node=node('c'), short_channel_id=channel(4)), ], path) - # impossible routes - path = self.path_finder.find_path_for_payment( - nodeA=node('e'), - nodeB=node('a'), - invoice_amount_msat=amount_to_send, - node_filter=is_onion_message_node) + # node e doesn't support onion messages + path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('e')) self.assertIsNone(path) + async def test_find_path_for_onion_message_ignores_amount_constraints(self): + self.prepare_graph() + + # bump htlc_minimum_msat on channel(4) d->c direction + key = (node('d'), channel(4)) + self.cdb._policies[key] = self.cdb._policies[key]._replace(htlc_minimum_msat=10_000_000) + + # a small payment can no longer be routed path = self.path_finder.find_path_for_payment( nodeA=node('a'), - nodeB=node('e'), - invoice_amount_msat=amount_to_send, - node_filter=is_onion_message_node) + nodeB=node('c'), + invoice_amount_msat=1000, + node_filter=is_onion_message_node, + ) self.assertIsNone(path) + # but an onion message still routes through the same hop + path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('c')) + self.assertEqual([ + PathEdge(start_node=node('a'), end_node=node('d'), short_channel_id=channel(6)), + PathEdge(start_node=node('d'), end_node=node('c'), short_channel_id=channel(4)), + ], path) + def _tramp_edge(start: str, end: str, *, fee_base=PLACEHOLDER_FEE, fee_prop=PLACEHOLDER_FEE, cltv=576) -> TrampolineEdge: return TrampolineEdge( From 6cad3c740600738a31a6e96bdbdcc21cef518d86 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 13 May 2026 12:39:00 +0200 Subject: [PATCH 10/45] onion_messages: filter correct feature when creating paths Filter channels/peers in `get_blinded_paths_to_me` depending on the context (onion message or blinded payment path). Raise according exceptions when no channel/peer is available. --- electrum/onion_message.py | 45 ++++++++++++++++++++++--------------- tests/test_onion_message.py | 10 +++++---- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index a6164bd3298d..cd53c2844f08 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -65,6 +65,10 @@ def now() -> float: PAYMENT_PATHS_MAX = 3 +class NoOnionMessagePeers(Exception): pass +class NoRouteBlindingChannelPeers(Exception): pass + + class NoRouteFound(Exception): def __init__(self, *args, peer_address: 'LNPeerAddr' = None): Exception.__init__(self, *args) @@ -412,23 +416,25 @@ def get_blinded_paths_to_me( ) -> Tuple[Sequence[dict], Sequence[dict]]: """construct a list of blinded paths. current logic: - - uses channels peers if not onion_message - - uses current onion_message capable channel peers if exist and if onion_message - - otherwise, uses current onion_message capable peers if onion_message - - reply_path introduction points are direct peers only (TODO: longer paths)""" + - uses active channel peers if my_channels not provided + - if onion_message, filters channels for onion_message feature + - if not onion_message, filters channels for route_blinding feature + - if onion_message and no suitable channel peers, tries onion_message capable peers + - raises if no blinded path could be generated + - reply_path introduction points are direct peers only (TODO: longer paths) + """ # TODO: build longer paths and/or add dummy hops to increase privacy if not my_channels: - my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - my_channels = my_active_channels + my_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - if onion_message: - my_channels = [chan for chan in my_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and - lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + required_features = LnFeatures.OPTION_ONION_MESSAGE_OPT if onion_message else LnFeatures.OPTION_ROUTE_BLINDING_OPT + my_channels = [chan for chan in my_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and + lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(required_features)] result = [] payinfos = [] mynodeid = lnwallet.node_keypair.pubkey - if len(my_channels): + if my_channels: rchans = random_shuffled_copy(my_channels) for chan in rchans[:max_paths]: hop_extras = None @@ -447,16 +453,22 @@ def get_blinded_paths_to_me( channels=[chan] if not onion_message else None, ) result.append(blinded_path) - elif onion_message: - # we can use peers even without channels for onion messages - my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if - peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] - if len(my_onionmsg_peers): + + if not result: + if not onion_message: + raise NoRouteBlindingChannelPeers('no OPTION_ROUTE_BLINDING capable channel peers') + else: + # fall back to peers without channels for onion messages + my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if + peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + if not my_onionmsg_peers: + raise NoOnionMessagePeers('no ONION_MESSAGE capable peers') 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) + assert result return result, payinfos @@ -708,9 +720,6 @@ 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) - if not reply_paths: - raise Exception(f'Could not create a reply_path for {key=}. No active peers?') - final_payload['reply_path'] = {'path': reply_paths} # 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 8b02d239c939..978199fa974d 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -22,7 +22,7 @@ from electrum.lnutil import LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE from electrum.onion_message import ( create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, - create_route_to_introduction_point, get_blinded_paths_to_me + create_route_to_introduction_point, get_blinded_paths_to_me, NoOnionMessagePeers ) from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop from electrum.logging import console_stderr_handler @@ -271,12 +271,13 @@ def __init__(self): self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True -class MockPeer: - their_features = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT) +ONION_MESSAGE_CAPABLE_PEER_FEATURES = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT) - def __init__(self, pubkey, on_send_message=None): +class MockPeer: + def __init__(self, pubkey, on_send_message=None, their_features=ONION_MESSAGE_CAPABLE_PEER_FEATURES): self.pubkey = pubkey self.on_send_message = on_send_message + self.their_features = their_features async def wait_one_htlc_switch_iteration(self, *args): pass @@ -501,6 +502,7 @@ async def test_get_blinded_paths_to_me_payment(self): bob_update = decode_msg(bob_update_raw)[1] bob_update['raw'] = bob_update_raw alice_chan.set_remote_update(bob_update) + 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) From 943f8baca29b9f9840ec8e26bf3587d414536d97 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 11/45] lnonion: support payment path blinding move decryption of recipient_data to process_onion_packet, add handling of blinding in peer msgs, handle properties in ProcessedOnionPacket in case of path blinding, move repeated next blinding key derivation to func lnonion.next_blinding_from_shared_secret() Co-Authored-By: f321x --- electrum/lnonion.py | 53 +++++++++++++++++++++++++++++++---- electrum/lnpeer.py | 50 +++++++++++++++++++++++++++------ 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, 119 insertions(+), 37 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index e3deb45ba175..a923b7dd145a 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -390,6 +390,8 @@ class ProcessedOnionPacket(NamedTuple): hop_data: OnionHopsDataSingle next_packet: OnionPacket trampoline_onion_packet: OnionPacket + blinded_path_recipient_data: Optional[Mapping] + 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, + 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,11 @@ def process_onion_packet( if not ecc.ECPubkey.is_pubkey_bytes(onion_packet.public_key): raise InvalidOnionPubkey() is_onion_message = tlv_stream_name == 'onionmsg_tlv' + recipient_data_shared_secret = None + blinded_path_recipient_data = {} + if path_key: + recipient_data_shared_secret = get_ecdh(our_onion_private_key, path_key) + our_onion_private_key = blinding_privkey(our_onion_private_key, 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 +475,19 @@ 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) + + 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 path_key: + # we are the introduction point + path_key = hop_data.payload.get('current_path_key', {}).get('path_key') + recipient_data_shared_secret = get_ecdh(our_onion_private_key, path_key) + 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 +501,20 @@ 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 path_key: + next_path_key = next_blinding_from_shared_secret(path_key, recipient_data_shared_secret) + + return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet, + blinded_path_recipient_data, next_path_key) def compare_trampoline_onions( diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index d4551cc5cc7d..bd3df2e2519e 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, @@ -1920,6 +1920,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())) @@ -1927,6 +1928,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, @@ -1934,7 +1939,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 @@ -2041,12 +2047,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}") @@ -2062,8 +2070,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, @@ -2090,7 +2098,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 @@ -2110,7 +2137,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 @@ -2309,6 +2336,7 @@ def _fail_htlc_set( processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=payment_hash, + path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) if raw_error: @@ -2830,6 +2858,7 @@ def _run_htlc_switch_iteration(self): processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, + path_key=htlc.path_key, is_trampoline=False, ) payment_key: str = self._check_unfulfilled_htlc( @@ -2937,6 +2966,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, + path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) onion_payload = processed_onion.hop_data.payload @@ -2989,6 +3019,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, + path_key=mpp_htlc.htlc.path_key, is_trampoline=False, # this is always the outer onion ) processed_onions[mpp_htlc] = (processed_onion, None) @@ -3245,17 +3276,20 @@ def _process_incoming_onion_packet( self, onion_packet: OnionPacket, *, payment_hash: bytes, + 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 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, + path_key=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 9287744b8f63..5015e517f249 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('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 c93a644a9f43..449cdee28217 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -3957,10 +3957,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) @@ -4034,6 +4045,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 cd53c2844f08..3dfa14a6e3da 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -864,9 +864,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, 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}') @@ -877,7 +877,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'] @@ -888,4 +888,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 978199fa974d..bb7477013b84 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -163,17 +163,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', path_key=path_key) self.assertEqual(p.hop_data.blind_fields, {}) self.assertEqual(p.hop_data.hmac, bfh('a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2')) From 3f94381b118c68ab31b06d84fe38e50c1de99262 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Dec 2025 17:58:35 +0100 Subject: [PATCH 12/45] 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 5015e517f249..9fa87734b6b9 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 449cdee28217..89920fdbd8c8 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -209,6 +209,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 cd9e908dcfb13aac6861ee926dec214a5e5f9453 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 24 Nov 2025 15:16:20 +0100 Subject: [PATCH 13/45] onion_message: perform a direct peer connection for sending request when we have tried all blinded paths and for none of them we could find a route and we have a network address available for the destination (either node_id or blinded path introduction point). --- 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 3dfa14a6e3da..f667cfefa797 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -549,16 +549,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) @@ -570,6 +571,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" @@ -653,13 +656,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)) @@ -706,11 +707,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=}') @@ -722,10 +723,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 4258cbe125a8..f4000fc6e7b1 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 bb7477013b84..3f5afb184bf4 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 from electrum.onion_message import ( create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, @@ -302,6 +302,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( @@ -338,13 +348,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): @@ -365,7 +400,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 @@ -379,21 +414,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 163cae5b8782a97892cfa3534ee1ce5675c7aa0a Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 4 May 2026 16:11:37 +0200 Subject: [PATCH 14/45] lnpeer: add send_onion_message method Adds a separate method to `Peer` specifically for sending onion messages. This allows to 'enqueue' a (onion) message send before the peer is initialized without having to externalize waiting for init from `Peer`. This way we can establish a peer connection in `OnionMessageManager` and immediately fire an onion message on the `Peer`. Using the Queue also prevents the timing/retry logic in `OnionMessageManager` from breaking down if a single peer (of multiple available ones) takes a long time to send for whatever reason. Also creates a simpler API for callers (they don't have to pass a message_name and packet len). --- electrum/lnpeer.py | 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 bd3df2e2519e..62318f574a3f 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -65,6 +65,7 @@ LN_P2P_NETWORK_TIMEOUT = 20 +ONION_MESSAGE_SEND_DELAY = 0.25 class Peer(Logger, EventListener): @@ -141,6 +142,9 @@ def __init__( self._processed_onion_cache = LRUCache(maxsize=100) # type: LRUCache[bytes, ProcessedOnionPacket] self._last_commitsig_sent_time = 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 @@ -152,6 +156,42 @@ def send_message(self, message_name: str, **kwargs): self._store_raw_msg_if_local_update(raw_msg, message_name=message_name, channel_id=kwargs.get("channel_id")) self.transport.send_bytes(raw_msg) + 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): @@ -552,6 +592,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 f667cfefa797..dcd41d14bb16 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -385,11 +385,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, ) @@ -612,11 +610,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 3f5afb184bf4..3ffa66711620 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -277,7 +277,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 f4b49abfb76e5a364438eedabaa24c8b900ac8e1 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 23 Mar 2026 16:19:41 +0100 Subject: [PATCH 15/45] PaymentIdentifier: _do_finalize: don't catch all exc Only catch UserFacingException. The scope of Exception is way too large, this caught all kinds of exceptions from LNWallet etc. It is not useful to show the user an error popup with e.g. "KeyError". --- electrum/payment_identifier.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 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 e6cd28e4fe09961b0d694584c748f1f8cb599744 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 10:56:54 +0200 Subject: [PATCH 16/45] lnonion: introduce BlindedPath dataclasses Introduce dataclasses for blinded path related structures. This allows safer and cleaner handling. - BlindedPathHop - BlindedPath - BlindedPayInfo - BlindedPathInfo --- electrum/commands.py | 3 + electrum/lnonion.py | 114 +++++++++++++++++- electrum/onion_message.py | 224 ++++++++++++++++-------------------- tests/test_onion_message.py | 51 ++++---- 4 files changed, 243 insertions(+), 149 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index ea57c7808ff9..8f4f9c318aa0 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, @@ -2229,6 +2230,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 a923b7dd145a..1352a2aeae96 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,115 @@ 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_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 dcd41d14bb16..757913a60348 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,12 +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) -from electrum.lnutil import LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED + 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, + validate_features, IncompatibleOrInsaneFeatures) from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy @@ -83,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: @@ -94,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 @@ -121,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: @@ -204,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 ): @@ -215,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) @@ -249,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) @@ -262,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: @@ -380,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() @@ -396,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 @@ -411,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 @@ -430,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], @@ -450,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: @@ -464,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) @@ -506,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, - '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, + htlc_minimum_msat=blinded_path_min_htlc_msat, + htlc_maximum_msat=blinded_path_max_htlc_msat, + features=LnFeatures(0), + ) return payinfo, hop_extras @@ -541,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 @@ -669,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 @@ -717,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 3ffa66711620..85b9d3d5885a 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 @@ -192,14 +192,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: @@ -207,7 +207,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')) @@ -239,10 +239,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( @@ -538,12 +538,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, @@ -553,13 +552,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 @@ -571,9 +570,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 f5f59594c30167fa5aabbd53203e898da4ecf436 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:09:23 +0200 Subject: [PATCH 17/45] bolt12: add BOLT12 classes, encode/decode Introduce typed dataclass hierarchy for BOLT12 objects: - BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice - validation in __post_init__() methods - helper functions - unittests (test_bolt12.py) Co-Authored-By: Sander van Grieken --- electrum/bolt12.py | 528 ++++++++++++++++++++++++++++++++++++++++ electrum/commands.py | 3 +- electrum/segwit_addr.py | 6 +- tests/test_bolt12.py | 439 +++++++++++++++++++++++++++++++++ 4 files changed, 972 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..4bee5c828578 --- /dev/null +++ b/electrum/bolt12.py @@ -0,0 +1,528 @@ +# -*- 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 not 'invreq_chain' 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("offer_quantity_max is given but no invreq_quantity") + # if offer_quantity_max is non-zero + if self.offer_quantity_max and not self.invreq_quantity or self.invreq_quantity > self.offer_quantity_max: + # MUST reject the invoice request if invreq_quantity is zero, OR greater than offer_quantity_max + raise ValueError("invreq_quantity > 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 8f4f9c318aa0..14f67980921b 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 @@ -2275,7 +2276,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..61c7ee717f1c --- /dev/null +++ b/tests/test_bolt12.py @@ -0,0 +1,439 @@ +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_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': int.to_bytes(1, byteorder="big", signed=False), + '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': int.to_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': int.to_bytes(1), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': int.to_bytes(1), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': int.to_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': int.to_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': int.to_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 101356e339f78eae424c8bd35fd5d2b8e2b9a4ea Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 10 Feb 2026 14:27:44 +0100 Subject: [PATCH 18/45] tests: add bolt12 string formatting test vector Adds unittest to check if we handle malformed bolt12 strings according to the specification using the format-string-test.json test vector. https://github.com/lightning/bolts/blob/5f31faa0b6e2cdbe32171d79464305f90bda9585/bolt12/format-string-test.json --- tests/bolt12_format_string_test.json | 62 ++++++++++++++++++++++++++++ tests/test_bolt12.py | 21 ++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tests/bolt12_format_string_test.json diff --git a/tests/bolt12_format_string_test.json b/tests/bolt12_format_string_test.json new file mode 100644 index 000000000000..46e543b8b29b --- /dev/null +++ b/tests/bolt12_format_string_test.json @@ -0,0 +1,62 @@ +[ + { + "comment": "A complete string is valid", + "valid": true, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Uppercase is valid", + "valid": true, + "string": "LNO1PQPS7SJQPGTYZM3QV4UXZMTSD3JJQER9WD3HY6TSW35K7MSJZFPY7NZ5YQCNYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD5XVXG" + }, + { + "comment": "+ can join anywhere", + "valid": true, + "string": "l+no1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Multiple + can join", + "valid": true, + "string": "lno1pqps7sjqpgt+yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+5k7msjzfpy7nz5yqcn+ygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+5xvxg" + }, + { + "comment": "+ can be followed by whitespace", + "valid": true, + "string": "lno1pqps7sjqpgt+ yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+ 5k7msjzfpy7nz5yqcn+\nygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+\r\n 5xvxg" + }, + { + "comment": "+ can be followed by whitespace, UPPERCASE", + "valid": true, + "string": "LNO1PQPS7SJQPGT+ YZM3QV4UXZMTSD3JJQER9WD3HY6TSW3+ 5K7MSJZFPY7NZ5YQCN+\nYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD+\r\n 5XVXG" + }, + { + "comment": "Mixed case is invalid", + "valid": false, + "string": "LnO1PqPs7sJqPgTyZm3qV4UxZmTsD3JjQeR9Wd3hY6TsW35k7mSjZfPy7nZ5YqCnYgRfDeJ82uM5Wf5k2uCkYyPwA3EyT44h6tXtXqUqH7Lz5dJgE4AfGfJn7k4rGrKuAg0jSd5xVxG" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+ " + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+ lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "ln++o1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + } +] diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py index 61c7ee717f1c..112de66376ab 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 fb52cae7667269bbf1aaeac81ddce14dc7cce445 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 12 Feb 2026 14:46:09 +0100 Subject: [PATCH 19/45] tests: add bolt12 offer test vector Tests BOLT12Offer validation against the test vector json from the bolts repository. --- tests/bolt12_offers_test.json | 596 ++++++++++++++++++++++++++++++++++ tests/test_bolt12.py | 21 +- 2 files changed, 616 insertions(+), 1 deletion(-) 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 112de66376ab..9c3cf65e1fd2 100644 --- a/tests/test_bolt12.py +++ b/tests/test_bolt12.py @@ -8,7 +8,7 @@ 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 @@ -139,6 +139,25 @@ 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: + with self.assertRaises(Exception, 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 48263825c12b5ce3a14dba5abb2bf4fe78088028 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 5 Nov 2025 14:44:51 +0100 Subject: [PATCH 20/45] commands: add decode_bolt12 command Co-Authored-By: f321x --- electrum/commands.py | 22 +++++++++++++++-- tests/test_commands.py | 56 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 14f67980921b..a906718b6491 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 @@ -2281,6 +2283,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 Exception('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 f294c6da5b58..f3929b910d42 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 from electrum.lnworker import RecvMPPResolution from electrum.wallet import Abstract_Wallet @@ -784,3 +786,55 @@ async def lnpeermgr_add_peer(*args, **kwargs): result = await cmds.add_peer(connection_string=connection_string, wallet=w) assert called == 2 self.assertTrue(result) + + 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(Exception): + await cmds.decode_bolt12(bech32='invalid_string') + + # set back to Testnet + constants.net = prev_net From a51ffbb5312288cd9d0ce964527d98cbdc805263 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 15 Apr 2026 11:44:11 +0200 Subject: [PATCH 21/45] bolt12/lnwallet: implement requesting and handling invoices Implements methods in LNWallet to create and send invoice_requests, handles incoming invoices. Supports both offerless and offer based invreqs --- electrum/bolt12.py | 15 +++ electrum/lnutil.py | 1 + electrum/lnworker.py | 209 +++++++++++++++++++++++++++++++++++++- electrum/onion_message.py | 10 +- tests/test_bolt12.py | 21 +++- 5 files changed, 251 insertions(+), 5 deletions(-) diff --git a/electrum/bolt12.py b/electrum/bolt12.py index 4bee5c828578..2bafe3311cde 100644 --- a/electrum/bolt12.py +++ b/electrum/bolt12.py @@ -459,6 +459,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) @@ -499,6 +510,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 9fa87734b6b9..9b0e1ab3ebb1 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 89920fdbd8c8..30090f3fa172 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, @@ -1017,6 +1022,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: @@ -1089,6 +1095,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 @@ -4277,3 +4286,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 757913a60348..7586606d70b5 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 9c3cf65e1fd2..38f91939f476 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 @@ -371,6 +372,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 35c3cd6d3631a53bcfa4c663112eb724597b8455 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 11:33:30 +0200 Subject: [PATCH 22/45] test_lnwallet: add unittests for bolt12 invoice flow Add unittests for the invoice_request creation and invoice handling. --- tests/test_lnwallet.py | 190 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 4c3919f07740..3321becd5e45 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -1,13 +1,22 @@ +import asyncio +import dataclasses import logging import os -import asyncio from unittest import mock from decimal import Decimal from typing import Optional +import time +from unittest.mock import patch + +from electrum_ecc import ECPrivkey from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum import bitcoin import electrum.trampoline +from electrum import constants +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice +from electrum.crypto import sha256, hmac_oneshot +from electrum.lnonion import BlindedPath, BlindedPathHop, BlindedPathInfo, BlindedPayInfo from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner from electrum.logging import console_stderr_handler from electrum.lntransport import LNPeerAddr @@ -474,3 +483,182 @@ async def test_rebalance_channels(self): chan1.set_frozen_for_sending(True) # shouldn't matter, this channel will receive success, log = await alice.rebalance_channels(chan0, chan1, amount_msat=150_000_000) self.assertTrue(success, msg=log) + + async def test_request_bolt12_invoice(self): + wallet = self.lnwallet_anchors + + offer_issuer_id = ECPrivkey.generate_random_key().get_public_key_bytes() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_description="test", + offer_issuer_id=offer_issuer_id, + ) + + introduction_point = ECPrivkey.generate_random_key().get_public_key_bytes() + reply_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=introduction_point, + first_path_key=ECPrivkey.generate_random_key().get_public_key_bytes(), + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=ECPrivkey.generate_random_key().get_public_key_bytes(), + enclen=5, + encrypted_recipient_data=b'12345', + )], + ), + payinfo=None, + )] + + submit_send_calls = [] + def fake_submit_send(*, payload, node_id_or_blinded_paths, key=None): + submit_send_calls.append((payload, node_id_or_blinded_paths)) + return asyncio.Future() + + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send', side_effect=fake_submit_send): + task = asyncio.create_task( + wallet.request_bolt12_invoice(bolt12_offer=offer, amount_msat=21_000) + ) + + start = time.time() + while not wallet._pending_bolt12_invoice_requests: + await asyncio.sleep(0.05) + if time.time() - start > 2: + task.cancel() + self.fail(f"invreq future wasn't registered") + + self.assertEqual(len(submit_send_calls), 1) + payload, destination = submit_send_calls[0] + self.assertEqual(destination, offer_issuer_id) + self.assertIn('invoice_request', payload) + self.assertIn('reply_path', payload) + + self.assertEqual(len(wallet._pending_bolt12_invoice_requests), 1) + path_id, fut = next(iter(wallet._pending_bolt12_invoice_requests.items())) + fut.set_result("invoice") + + self.assertIs(await task, "invoice") + + self.assertNotIn(path_id, wallet._pending_bolt12_invoice_requests) + + def test_create_bolt12_invoice_request_with_offer(self): + wallet = self.lnwallet_anchors + + amount_msat = 10_000 + offer_issuer_id = ECPrivkey.generate_random_key().get_public_key_bytes() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=amount_msat, + offer_description="test offer", + offer_issuer_id=offer_issuer_id, + ) + + # raises if amount is much higher than offer_amount + with self.assertRaises(ValueError): + _ = wallet.create_bolt12_invoice_request(offer=offer, amount_msat=40000) + + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=amount_msat, + payer_note="pls send invoice", + ) + + self.assertIsInstance(unsigned_invreq, BOLT12InvoiceRequest) + self.assertIsInstance(signing_key, ECPrivkey) + + # offer fields propagated into the invreq + self.assertEqual(unsigned_invreq.offer_issuer_id, offer_issuer_id) + self.assertEqual(unsigned_invreq.offer_amount, 10_000) + self.assertEqual(unsigned_invreq.offer_description, "test offer") + self.assertEqual(unsigned_invreq.offer_chains, [constants.net.rev_genesis_bytes()]) + + # invreq fields set from our parameters + self.assertEqual(unsigned_invreq.invreq_amount, amount_msat) + self.assertEqual(unsigned_invreq.invreq_payer_note, "pls send invoice") + self.assertEqual(unsigned_invreq.invreq_payer_id, signing_key.get_public_key_bytes()) + self.assertEqual(unsigned_invreq.invreq_chain, constants.net.rev_genesis_bytes()) + # invreq_metadata is the 16-byte entropy concatenated with sha256(signed_invreq_tlv) + self.assertEqual(len(unsigned_invreq.invreq_metadata), 16 + 32) + + # standalone-only fields are absent + self.assertIsNone(unsigned_invreq.invreq_paths) + + # derived signing key is not the node key + self.assertNotEqual(signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + + # test the stateless authenticity scheme + entropy, invreq_sig_digest = unsigned_invreq.invreq_metadata[:16], unsigned_invreq.invreq_metadata[-32:] + derived_privkey = hmac_oneshot( + key=wallet.bolt12_secret_key, + msg=b'invreq_key' + entropy, + digest='sha-256', + ) + self.assertEqual(derived_privkey, signing_key.get_secret_bytes()) + + signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) + resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + self.assertEqual(sha256(resigned), invreq_sig_digest) + + def test_create_bolt12_invoice_request_without_offer(self): + wallet = self.lnwallet_anchors + + fake_pubkey = ECPrivkey.generate_random_key().get_public_key_bytes() + reply_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=fake_pubkey, + first_path_key=fake_pubkey, + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=fake_pubkey, + enclen=5, + encrypted_recipient_data=b'12345', + )], + ), + payinfo=None, + )] + + amount_msat = 42_000 + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths): + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=amount_msat, + payer_note="standalone invreq", + allow_unblinded=False, + ) + + self.assertIsInstance(unsigned_invreq, BOLT12InvoiceRequest) + self.assertIsInstance(signing_key, ECPrivkey) + + # not a response to an offer: offer identity fields must be empty + self.assertIsNone(unsigned_invreq.offer_issuer_id) + self.assertIsNone(unsigned_invreq.offer_paths) + self.assertIsNone(unsigned_invreq.offer_amount) + self.assertIsNone(unsigned_invreq.offer_chains) + # payer_note is stored in offer_description for standalone invreqs + self.assertEqual(unsigned_invreq.offer_description, "standalone invreq") + + # invreq fields set from our parameters + self.assertEqual(unsigned_invreq.invreq_amount, amount_msat) + self.assertEqual(unsigned_invreq.invreq_payer_id, signing_key.get_public_key_bytes()) + self.assertEqual(unsigned_invreq.invreq_chain, constants.net.rev_genesis_bytes()) + self.assertEqual(len(unsigned_invreq.invreq_metadata), 16 + 32) + + # blinded reply paths are attached so the payee can respond + self.assertEqual(len(unsigned_invreq.invreq_paths), 1) + self.assertEqual(unsigned_invreq.invreq_paths[0], reply_paths[0].path) + + # with reply paths available, the derived signing key is used (not node key) + self.assertNotEqual(signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + + # authenticity scheme (same as offer-based invreqs) + entropy, stored_digest = unsigned_invreq.invreq_metadata[:16], unsigned_invreq.invreq_metadata[-32:] + derived_privkey = hmac_oneshot( + key=wallet.bolt12_secret_key, + msg=b'invreq_key' + entropy, + digest='sha-256', + ) + self.assertEqual(derived_privkey, signing_key.get_secret_bytes()) + + signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) + resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + self.assertEqual(sha256(resigned), stored_digest) From 28aba5cf230e38fffd13df0b723c8fc55a33c9ef Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:24:32 +0200 Subject: [PATCH 23/45] invoices: make `Invoice` handle bolt 12 invoices Store BOLT12 invoices as bech32-encoded strings (lni...) in the lightning_invoice field of the persisted Invoice class. Decode on demand and cache instance via cached_property. The `Invoice` can act as sort of abstraction over bolt11 and bolt12 invoices for the GUI. Co-Authored-By: Sander van Grieken --- electrum/gui/qml/qeinvoice.py | 4 +- electrum/invoices.py | 94 ++++++++++++++++++++++++++-------- electrum/lnworker.py | 11 ++-- electrum/payment_identifier.py | 18 ++----- tests/test_lnpeer.py | 4 +- 5 files changed, 85 insertions(+), 46 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 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 7d6a8bbbbef5..87ab10706181 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 30090f3fa172..37392ecb47b3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1890,13 +1890,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")) @@ -1911,7 +1910,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()) @@ -1930,7 +1929,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 1c006dd7ba437e491bf3cd49d9e05062231bf2ef Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 May 2024 12:16:28 +0200 Subject: [PATCH 24/45] payment_identifier: initial support for bolt12 offers --- electrum/gui/qml/qeinvoice.py | 8 +- electrum/gui/qt/send_tab.py | 2 +- electrum/payment_identifier.py | 155 ++++++++++++++++++++----------- tests/test_payment_identifier.py | 6 +- 4 files changed, 110 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..de0e70e7a65d 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,57 @@ 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')) + try: + invoice, invoice_tlv = await self.wallet.lnworker.request_bolt12_invoice( + self.bolt12_offer, + amount_msat=amount_sat * 1000, + 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) + 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 +584,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 +643,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 +656,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 2d3e545aa98915b913863351f1ae9b21e6d16475 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 14:08:42 +0100 Subject: [PATCH 25/45] tests: test_payment_identifier: test bolt12 pi Add unnittests for bolt12 payment identifier. --- tests/test_payment_identifier.py | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index a3169fd1860f..6cc4e52010d5 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -37,15 +37,21 @@ def setUp(self): def test_maybe_extract_bech32_lightning_payment_identifier(self): bolt11 = "lnbc1ps9zprzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsdqq9qypqszpyrpe4tym8d3q87d43cgdhhlsrt78epu7u99mkzttmt2wtsx0304rrw50addkryfrd3vn3zy467vxwlmf4uz7yvntuwjr2hqjl9lw5cqwtp2dy" + bolt12 = "lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3uprukkghkdufdz6adxl0ejhy0lmzfykj08u6df9v4v2c93qknz8eggzq2jyyszrrt35mmkyl7efrv5x8a3wspk07pghey4a5kcm4ef76p0ksqpnh7fqgmq9eaf7ntqspcksqkqk8ngvjtjp585mqw3qata3xe8aycgkpprk87yqcxhh705dxauxkghsc9xywqpez7lt5gw67kqwejl83unmuc7r44h32durffs4rmpcgrhxa8x8y9gqxgy9w2tqgpxqk0tl487a9ssuchyh5p9t3le3n5ylhevggk6ly8wzxvds0jawct4spe2tzqfp5d34kah9ss" lnurl = "lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9" self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"{bolt11}".upper())) self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt11}")) self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt11} ".upper())) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f"{bolt12}".upper())) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt12}")) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt12} ".upper())) self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(lnurl)) self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{lnurl} ".upper())) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt12}")) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt12}")) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"garbage text")) def test_remove_uri_prefix(self): @@ -109,6 +115,38 @@ def test_bolt11(self): self.assertFalse(pi.need_finalize()) self.assertFalse(pi.is_multiline()) + def test_bolt12(self): + offers = [ + ('lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3up5nwlwchur9zukwx743mvm0rvftrhskna22pcvtkyhufn5rc97j3gzqffs859lkadpfasgwxj47xvml7jgekez0lpfuwzhegyxsn2lzdx86qpny7xrmgwj6lphxcfauu22kenqnty4tqdlgnh8tyg87lamqe84nmh2vn0a2n908l7z7cfjghjsuusv7k079upfw0x7dpzavqpwj8swx9ee9q9cumg07fk4gvlajyhy6lfjv0cfe9gqxg0gykehtgjkxwzz24rqdssj4fjcm8xhv2rwel04ed4up2h5sf8n6y7scr0q5rt65k06s6u3mvefzer7qq', True), + ('lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878s9h02lqjy3hxc0x67pvwmu3evl6nsnvyy6adl2vn0dym4m2hdtrlnqgprp7jxwvnz5zj7xhz0fwel78cj0u90zgzpwr6we8j0nwzuv5tx9egqxdn72n27tdyers8ffdc2n75cydcl4tkd5lee0trwaekj9luzz5ydqh6cz07448ldts3yzkdk09ekl9t53ryq9lvvpuq90cmylys5saumem93wtvfd77z4alynefyj7ua7kr69dnfqqet7nsydwqa9ghdfy8udkc7x86ydl5l4nrsctfl8d3w4ejcceh9zqh0acy4cc4rcv6wv7zr6gh7fwsjzu8q', False), + ] + for bolt12, amount_locked in offers: + for valid_pi_str in [ + f'{bolt12}', + f' {bolt12}', + f'{bolt12} ', + f'lightning:{bolt12}', + f' lightning:{bolt12}', + f'lightning:{bolt12} ', + f'lightning:{bolt12.upper()}', + f'lightning:{bolt12}'.upper(), + ]: + pi = PaymentIdentifier(None, valid_pi_str) + self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.BOLT12_OFFER, pi.type) + self.assertEqual(pi.is_amount_locked(), amount_locked) + self.assertFalse(pi.is_error()) + self.assertIsNotNone(pi.bolt12_offer) + self.assertTrue(pi.need_finalize()) + self.assertFalse(pi.is_multiline()) + + for invalid_pi_str in [ + f'lightning: {bolt12}', + f'bitcoin:{bolt12}' + ]: + pi = PaymentIdentifier(None, invalid_pi_str) + self.assertFalse(pi.is_valid()) + def test_bip21(self): bip21 = 'bitcoin:bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293?message=unit_test' for pi_str in [ From ea304b1f0464a419e01ad5bcb5516c1ae03fc702 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sun, 9 Mar 2025 14:04:14 +0100 Subject: [PATCH 26/45] bolt12: route blinding test --- tests/route-blinding-test.json | 179 +++++++++++++++++++++++++++++++++ tests/test_route_blinding.py | 117 +++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 tests/route-blinding-test.json create mode 100644 tests/test_route_blinding.py diff --git a/tests/route-blinding-test.json b/tests/route-blinding-test.json new file mode 100644 index 000000000000..fdd195b3253d --- /dev/null +++ b/tests/route-blinding-test.json @@ -0,0 +1,179 @@ +{ + "comment": "test vector for using blinded routes", + "generate": { + "comment": "This section contains test data for creating a blinded route. This route is the concatenation of two blinded routes: one from Dave to Eve and one from Bob to Carol.", + "hops": [ + { + "comment": "Bob creates a Bob -> Carol route with the following session_key and concatenates it with the Dave -> Eve route.", + "session_key": "0202020202020202020202020202020202020202020202020202020202020202", + "alias": "Bob", + "node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x1729", + "payment_relay": { + "cltv_expiry_delta": 36, + "fee_proportional_millionths": 150, + "fee_base_msat": 10000 + }, + "payment_constraints": { + "max_cltv_expiry": 748005, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + }, + "unknown_tag_561": "123456" + }, + "encoded_tlvs": "011a0000000000000000000000000000000000000000000000000000020800000000000006c10a0800240000009627100c06000b69e505dc0e00fd023103123456", + "path_privkey": "0202020202020202020202020202020202020202020202020202020202020202", + "path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "shared_secret": "76771bab0cc3d0de6e6f60147fd7c9c7249a5ced3d0612bdfaeec3b15452229d", + "rho": "ba217b23c0978d84c4a19be8a9ff64bc1b40ed0d7ecf59521567a5b3a9a1dd48", + "encrypted_data": "cd4100ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c49982088b49f2e70b99287fdee0aa58aa39913ab405813b999f66783aa2fe637b3cda91ffc0913c30324e2c6ce327e045183e4bffecb", + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25" + }, + { + "comment": "Notice the next_path_key_override tlv in Carol's payload, indicating that Bob concatenated his route with another blinded route starting at Dave.", + "alias": "Carol", + "node_id": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", + "tlvs": { + "short_channel_id": "0x0x1105", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "payment_relay": { + "cltv_expiry_delta": 48, + "fee_proportional_millionths": 100, + "fee_base_msat": 500 + }, + "payment_constraints": { + "max_cltv_expiry": 747969, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + } + }, + "encoded_tlvs": "020800000000000004510821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0a0800300000006401f40c06000b69c105dc0e00", + "path_privkey": "0a2aa791ac81265c139237b2b84564f6000b1d4d0e68d4b9cc97c5536c9b61c1", + "path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0", + "shared_secret": "dc91516ec6b530a3d641c01f29b36ed4dc29a74e063258278c0eeed50313d9b8", + "rho": "d1e62bae1a8e169da08e6204997b60b1a7971e0f246814c648125c35660f5416", + "encrypted_data": "cc0f16524fd7f8bb0b1d8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f57ff62da5aaec5d7b10d59b04d8a9d77e472b9b3ecc2179334e411be22fa4c02b467c7e", + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7" + }, + { + "comment": "Eve creates a Dave -> Eve blinded route using the following session_key.", + "session_key": "0101010101010101010101010101010101010101010101010101010101010101", + "alias": "Dave", + "node_id": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x561", + "payment_relay": { + "cltv_expiry_delta": 144, + "fee_proportional_millionths": 250 + }, + "payment_constraints": { + "max_cltv_expiry": 747921, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + } + }, + "encoded_tlvs": "01230000000000000000000000000000000000000000000000000000000000000000000000020800000000000002310a060090000000fa0c06000b699105dc0e00", + "path_privkey": "0101010101010101010101010101010101010101010101010101010101010101", + "path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "shared_secret": "dc46f3d1d99a536300f17bc0512376cc24b9502c5d30144674bfaa4b923d9057", + "rho": "393aa55d35c9e207a8f28180b81628a31dff558c84959cdc73130f8c321d6a06", + "encrypted_data": "0fa0a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9c43815fd4bcebf6f58c546da0cd8a9bf5cebd0d554802f6c0255e28e4a27343f761fe518cd897463187991105", + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf" + }, + { + "comment": "Eve is the final recipient, so she included a path_id in her own payload to verify that the route is used when she expects it.", + "alias": "Eve", + "node_id": "02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000", + "path_id": "deadbeef", + "payment_constraints": { + "max_cltv_expiry": 747777, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [113] + }, + "unknown_tag_65535": "06c1" + }, + "encoded_tlvs": "011a00000000000000000000000000000000000000000000000000000604deadbeef0c06000b690105dc0e0f020000000000000000000000000000fdffff0206c1", + "path_privkey": "62e8bcd6b5f7affe29bec4f0515aab2eebd1ce848f4746a9638aa14e3024fb1b", + "path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a", + "shared_secret": "352a706b194c2b6d0a04ba1f617383fb816dc5f8f9ac0b60dd19c9ae3b517289", + "rho": "719d0307340b1c68b79865111f0de6e97b093a30bc603cebd1beb9eef116f2d8", + "encrypted_data": "da1a7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60724a2e4d3f0489ad884f7f3f77149209f0df51efd6b276294a02e3949c7254fbc8b5cab58212d9a78983e1cf86fe218b30c4ca8f6d8", + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae" + } + ] + }, + "route": { + "comment": "This section contains the resulting blinded route, which can then be used inside onion messages or payments.", + "first_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "first_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "hops": [ + { + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25", + "encrypted_data": "cd4100ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c49982088b49f2e70b99287fdee0aa58aa39913ab405813b999f66783aa2fe637b3cda91ffc0913c30324e2c6ce327e045183e4bffecb" + }, + { + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "encrypted_data": "cc0f16524fd7f8bb0b1d8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f57ff62da5aaec5d7b10d59b04d8a9d77e472b9b3ecc2179334e411be22fa4c02b467c7e" + }, + { + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "encrypted_data": "0fa0a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9c43815fd4bcebf6f58c546da0cd8a9bf5cebd0d554802f6c0255e28e4a27343f761fe518cd897463187991105" + }, + { + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "encrypted_data": "da1a7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60724a2e4d3f0489ad884f7f3f77149209f0df51efd6b276294a02e3949c7254fbc8b5cab58212d9a78983e1cf86fe218b30c4ca8f6d8" + } + ] + }, + "unblind": { + "comment": "This section contains test data for unblinding the route at each intermediate hop.", + "hops": [ + { + "alias": "Bob", + "node_privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "blinded_privkey": "d12fec0332c3e9d224789a17ebd93595f37d37bd8ef8bd3d2e6ce50acb9e554f", + "decrypted_data": "011a0000000000000000000000000000000000000000000000000000020800000000000006c10a0800240000009627100c06000b69e505dc0e00fd023103123456", + "next_path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0" + }, + { + "alias": "Carol", + "node_privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0", + "blinded_privkey": "bfa697fbbc8bbc43ca076e6dd60d306038a32af216b9dc6fc4e59e5ae28823c1", + "decrypted_data": "020800000000000004510821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0a0800300000006401f40c06000b69c105dc0e00", + "next_path_key": "03af5ccc91851cb294e3a364ce63347709a08cdffa58c672e9a5c587ddd1bbca60", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + }, + { + "alias": "Dave", + "node_privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "blinded_privkey": "cebc115c7fce4c295dc396dea6c79115b289b8ceeceea2ed61cf31428d88fc4e", + "decrypted_data": "01230000000000000000000000000000000000000000000000000000000000000000000000020800000000000002310a060090000000fa0c06000b699105dc0e00", + "next_path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a" + }, + { + "alias": "Eve", + "node_privkey": "4545454545454545454545454545454545454545454545454545454545454545", + "path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a", + "blinded_privkey": "ff4e07da8d92838bedd019ce532eb990ed73b574e54a67862a1df81b40c0d2af", + "decrypted_data": "011a00000000000000000000000000000000000000000000000000000604deadbeef0c06000b690105dc0e0f020000000000000000000000000000fdffff0206c1", + "next_path_key": "038fc6859a402b96ce4998c537c823d6ab94d1598fca02c788ba5dd79fbae83589" + } + ] + } +} diff --git a/tests/test_route_blinding.py b/tests/test_route_blinding.py new file mode 100644 index 000000000000..008b86f4becc --- /dev/null +++ b/tests/test_route_blinding.py @@ -0,0 +1,117 @@ +import os + +from electrum.lnonion import get_shared_secrets_along_route, OnionHopsDataSingle, encrypt_hops_recipient_data +from electrum.lnutil import LnFeatures +from electrum.util import read_json_file, bfh + +from tests import ElectrumTestCase + +# test vectors https://github.com/lightning/bolts/pull/765/files +path = os.path.join(os.path.dirname(__file__), 'route-blinding-test.json') +test_vectors = read_json_file(path) +HOPS = test_vectors['generate']['hops'] +BOB = HOPS[0] +CAROL = HOPS[1] +DAVE = HOPS[2] +EVE = HOPS[3] + +BOB_TLVS = BOB['tlvs'] +CAROL_TLVS = CAROL['tlvs'] +DAVE_TLVS = DAVE['tlvs'] +EVE_TLVS = EVE['tlvs'] + +BOB_PUBKEY = bfh(test_vectors['route']['first_node_id']) +CAROL_PUBKEY = bfh(CAROL['node_id']) +DAVE_PUBKEY = bfh(DAVE['node_id']) +EVE_PUBKEY = bfh(EVE['node_id']) + + +class TestPaymentRouteBlinding(ElectrumTestCase): + + def test_blinded_path_payload_tlv_concat(self): + + hop_shared_secrets1, blinded_node_ids1 = get_shared_secrets_along_route([BOB_PUBKEY, CAROL_PUBKEY], bfh(BOB['session_key'])) + hop_shared_secrets2, blinded_node_ids2 = get_shared_secrets_along_route([DAVE_PUBKEY, EVE_PUBKEY], bfh(DAVE['session_key'])) + hop_shared_secrets = hop_shared_secrets1 + hop_shared_secrets2 + blinded_node_ids = blinded_node_ids1 + blinded_node_ids2 + + for i, ss in enumerate(hop_shared_secrets): + self.assertEqual(ss, bfh(HOPS[i]['shared_secret'])) + for i, ss in enumerate(blinded_node_ids): + self.assertEqual(ss, bfh(HOPS[i]['blinded_node_id'])) + + hops_data = [ + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(BOB_TLVS['padding'])}, + 'short_channel_id': {'short_channel_id': 1729}, # FIXME scid from "0x0x1729" testvector repr + 'payment_relay': { + 'cltv_expiry_delta': BOB_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': BOB_TLVS['payment_relay']['fee_proportional_millionths'], + 'fee_base_msat': BOB_TLVS['payment_relay']['fee_base_msat'], + }, + 'payment_constraints': { + 'max_cltv_expiry': BOB_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': BOB_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + 'unknown_tag_561': {'data': bfh(BOB_TLVS['unknown_tag_561'])}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'short_channel_id': {'short_channel_id': 1105}, + 'next_path_key_override': {'path_key': bfh(CAROL_TLVS['next_path_key_override'])}, + 'payment_relay': { + 'cltv_expiry_delta': CAROL_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': CAROL_TLVS['payment_relay']['fee_proportional_millionths'], + 'fee_base_msat': CAROL_TLVS['payment_relay']['fee_base_msat'], + }, + 'payment_constraints': { + 'max_cltv_expiry': CAROL_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': CAROL_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(DAVE_TLVS['padding'])}, + 'short_channel_id': {'short_channel_id': 561}, + 'payment_relay': { + 'cltv_expiry_delta': DAVE_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': DAVE_TLVS['payment_relay']['fee_proportional_millionths'], + # 'fee_base_msat': DAVE_TLVS['payment_relay']['fee_base_msat'], + # FIXME: mandatory but not in test vectors ? + 'fee_base_msat': 0 + }, + 'payment_constraints': { + 'max_cltv_expiry': DAVE_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': DAVE_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(EVE_TLVS['padding'])}, + 'path_id': {'data': bfh(EVE_TLVS['path_id'])}, + 'payment_constraints': { + 'max_cltv_expiry': EVE_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': EVE_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': LnFeatures(1 << EVE_TLVS['allowed_features']['features'][0]).to_tlv_bytes()}, + 'unknown_tag_65535': {'data': bfh(EVE_TLVS['unknown_tag_65535'])}, + } + ), + ] + + encrypt_hops_recipient_data(hops_data, hop_shared_secrets) + + for i, hop in enumerate(hops_data): + self.assertEqual(hop.payload['encrypted_recipient_data']['encrypted_recipient_data'], + bfh(HOPS[i]['encrypted_data']), f'hop {i} not matching') From 87df62293390f26d16ede94b59a8ba126b7477a0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 May 2026 16:02:18 +0200 Subject: [PATCH 27/45] lnonion: implement hops data assembly for blinded payments Implement `calc_hops_data_for_blinded_payment`, which takes a route to the introduction point of a blinded payment as well as the blinded path and assembles the hop payloads for the whole payment (unblinded path + blinded path). Co-Authored-By: f321x --- electrum/lnonion.py | 81 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 1352a2aeae96..13053a628788 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 @@ -332,7 +334,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 @@ -446,8 +448,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}, @@ -461,6 +462,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, + *, + local_height: 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': local_height}, + '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 = local_height + 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 15acf230995213d2540119ef9d156a4d5218725d Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:14:22 +0200 Subject: [PATCH 28/45] lnutil, lnonion: add RoutingInfo abstractions Introduce RoutingInfo class to make the lightning payment flow (LNWallet.pay_invoice and following) less specific to bolt11 but act on generic `RoutingInfo`. --- electrum/invoices.py | 23 ++++++++++++++++++++++- electrum/lnutil.py | 21 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/electrum/invoices.py b/electrum/invoices.py index 87ab10706181..6282a79b84fd 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, + min_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 9b0e1ab3ebb1..69bfc1fa62eb 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 + min_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 9e4e6f831bafc468d83aae5904541abebe63fb34 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 14 Apr 2026 11:27:41 +0200 Subject: [PATCH 29/45] LNWallet/bolt12: implement sending blinded payments Adapt the LNWallet payment flow to handle blinded/bolt12 payments. Paying via Trampoline is added in a future commit. Co-Authored-By: Sander van Grieken --- electrum/lnpeer.py | 9 +- electrum/lnrouter.py | 17 ++- electrum/lnutil.py | 14 ++ electrum/lnworker.py | 350 ++++++++++++++++++++++++++----------------- tests/test_lnpeer.py | 21 +-- 5 files changed, 262 insertions(+), 149 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 62318f574a3f..5beaf7d22993 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 @@ -1991,8 +1992,9 @@ def pay(self, *, amount_msat: int, total_msat: int, payment_hash: bytes, - min_final_cltv_delta: int, - payment_secret: bytes, + min_final_cltv_delta: Optional[int], + payment_secret: Optional[bytes], + blinded_path_info: Optional['BlindedPathInfo'] = None, trampoline_onion: Optional[OnionPacket] = None, ) -> UpdateAddHtlc: @@ -2007,7 +2009,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..c5a04b823eeb 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -33,6 +33,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, @@ -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 @@ -115,7 +117,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[dict], 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)) @@ -134,7 +137,8 @@ def is_route_within_budget( *, budget: PaymentFeeBudget, amount_msat_for_dest: int, # that final receiver gets - cltv_delta_for_dest: int, # that final receiver gets + cltv_delta_for_dest: Optional[int], # that final receiver gets, not given for blinded paths + additional_blinded_path_fees: Optional['BlindedPayInfo'] = 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 @@ -143,6 +147,13 @@ def is_route_within_budget( return False amt = amount_msat_for_dest cltv_cost_of_route = 0 # excluding cltv_delta_for_dest + if additional_blinded_path_fees is not None: + amt += fee_for_edge_msat( + forwarded_amount_msat=amt, + fee_base_msat=additional_blinded_path_fees.fee_base_msat, + fee_proportional_millionths=additional_blinded_path_fees.fee_proportional_millionths, + ) + cltv_cost_of_route += additional_blinded_path_fees.cltv_expiry_delta for route_edge in reversed(route[1:]): amt += route_edge.fee_for_edge(amt) cltv_cost_of_route += route_edge.cltv_delta @@ -153,7 +164,7 @@ def is_route_within_budget( if fee_cost > budget.fee_msat: return False # sanity check - total_cltv_delta = cltv_cost_of_route + cltv_delta_for_dest + total_cltv_delta = cltv_cost_of_route + (cltv_delta_for_dest or 0) if total_cltv_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: return False return True diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 69bfc1fa62eb..b6378b61b5af 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -2152,9 +2152,23 @@ 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', ...] 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 37392ecb47b3..acd00893d364 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,11 +77,12 @@ 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 ( @@ -136,7 +136,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 @@ -152,7 +152,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) @@ -174,13 +179,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 @@ -862,28 +868,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() @@ -966,9 +965,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] @@ -978,8 +978,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 @@ -994,6 +995,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: @@ -1887,15 +1896,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")) @@ -1908,12 +1920,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) @@ -1923,12 +1935,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, @@ -1956,12 +1965,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, @@ -1979,17 +1985,14 @@ 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) @@ -1998,7 +2001,8 @@ async def pay_to_node( 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={"r_tags:" + str(BOLT11Addr.format_bolt11_routing_info_as_human_readable(routing_info.r_tags)) \ + if not isinstance(routing_info, BlindedRoutingInfo) else str(len(routing_info.paths)) + " blinded path(s)"}. " f"{amount_to_pay=} msat. {budget=}") if not self.uses_trampoline(): self.logger.info( @@ -2127,7 +2131,7 @@ async def pay_to_route( self, *, paysession: PaySession, sent_htlc_info: SentHtlcInfo, - min_final_cltv_delta: int, + min_final_cltv_delta: Optional[int], trampoline_onion: Optional[OnionPacket] = None, fw_payment_key: str = None, ) -> None: @@ -2141,15 +2145,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 @@ -2313,6 +2329,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): @@ -2336,8 +2363,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)) @@ -2345,14 +2371,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 @@ -2386,7 +2418,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. @@ -2394,6 +2425,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 @@ -2408,8 +2441,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 @@ -2423,7 +2455,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() @@ -2436,17 +2469,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.min_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, {}), @@ -2479,13 +2514,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: @@ -2498,39 +2535,40 @@ 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, )) 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=None if blinded_path else routing_info.min_final_cltv_delta, + additional_blinded_path_fees=blinded_path.payinfo if blinded_path else None + ): 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)) + min_final_cltv_delta = None if blinded_path else routing_info.min_final_cltv_delta + routes.append((shi, min_final_cltv_delta, fwd_trampoline_onion)) except NoPathFound: continue except FeeBudgetExceeded as e: @@ -2545,7 +2583,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()}') @@ -2572,23 +2609,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 @@ -2615,34 +2688,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) \ @@ -3159,8 +3215,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, @@ -3169,7 +3224,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 @@ -3205,8 +3260,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 @@ -3241,7 +3295,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 @@ -4073,7 +4127,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): @@ -4155,6 +4209,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, @@ -4170,14 +4225,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, + min_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, @@ -4227,25 +4286,48 @@ def create_onion_for_route( amount_msat: int, total_msat: int, payment_hash: bytes, - min_final_cltv_delta: int, - payment_secret: bytes, + min_final_cltv_delta: Optional[int], # for blinded payments we don't know the final cltv delta + 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" + assert bool(min_final_cltv_delta) ^ bool(blinded_path_info) + # 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 + 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) + assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) + 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, + local_height=local_height, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path_info, + ) + assert cltv_abs > local_height, (cltv_abs, local_height) + payment_path_pubkeys = [x.node_id for x in route] + blinded_node_ids + 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') @@ -4260,8 +4342,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/test_lnpeer.py b/tests/test_lnpeer.py index 6be6809af2b2..7e5ec7206b50 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 640d8722668e3e9ddcbdb1a15b51ee61511e878b Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 7 Oct 2025 13:30:46 +0200 Subject: [PATCH 30/45] add unit test: blinded payment onion --- tests/blinded-payment-onion-test.json | 183 ++++++++++++++++++++++++++ tests/test_blinded_payment_onion.py | 104 +++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 tests/blinded-payment-onion-test.json create mode 100644 tests/test_blinded_payment_onion.py diff --git a/tests/blinded-payment-onion-test.json b/tests/blinded-payment-onion-test.json new file mode 100644 index 000000000000..2d14e1788dbd --- /dev/null +++ b/tests/blinded-payment-onion-test.json @@ -0,0 +1,183 @@ +{ + "comment": "test vector for a payment onion sent to a partially blinded route", + "generate": { + "comment": "This section contains test data for creating a payment onion that sends to the provided blinded route.", + "session_key": "0303030303030303030303030303030303030303030303030303030303030303", + "associated_data": "4242424242424242424242424242424242424242424242424242424242424242", + "final_amount_msat": 100000, + "final_cltv": 749000, + "blinded_payinfo": { + "comment": "total costs for using the blinded path", + "fee_base_msat": 10100, + "fee_proportional_millionths": 251, + "cltv_expiry_delta": 150 + }, + "blinded_route": { + "comment": "This section contains a blinded route that the sender will use for his payment, usually obtained from a Bolt 12 invoice.", + "first_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "first_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "hops": [ + { + "alias": "Bob", + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25", + "encrypted_data": "cd7b00ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c499a2888b49f2e72b19446f7e60a818aa2938d8c625415b992b8928a7321edb8f7cea40de362bed082ad51acc6156dca5532fb68" + }, + { + "alias": "Carol", + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "encrypted_data": "cc0f16524fd7f8bb0f4e8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f570f656a5aaecaf1ee8dc9d0fa1d424759be1932a8f29fac08bc2d2a1ed7159f28b" + }, + { + "alias": "Dave", + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "encrypted_data": "0fa1a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9e49895fd4bcebf6f58d6f61a6d41a9bf5aa4b0453437856632e8255c351873143ddf2bb2b0832b091e1b4" + }, + { + "alias": "Eve", + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "encrypted_data": "da1c7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60722a63c688768042ade22f2c22f5724767d171fd221d3e579e43b354cc72e3ef146ada91a892d95fc48662f5b158add0af457da" + } + ] + }, + "full_route": { + "comment": "The sender adds one normal hop through Alice, who doesn't support blinded payments (and doesn't charge a fee). The sender provides the initial blinding point in Bob's onion payload, and encrypted_data for each node in the blinded route.", + "hops": [ + { + "alias": "Alice", + "pubkey": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619", + "payload": "14020301ae2d04030b6e5e0608000000000000000a", + "tlvs": { + "outgoing_channel_id": "0x0x10", + "amt_to_forward": 110125, + "outgoing_cltv_value": 749150 + } + }, + { + "alias": "Bob", + "pubkey": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "payload": "740a4fcd7b00ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c499a2888b49f2e72b19446f7e60a818aa2938d8c625415b992b8928a7321edb8f7cea40de362bed082ad51acc6156dca5532fb680c21024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "tlvs": { + "current_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "encrypted_recipient_data": { + "padding": "0000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x1", + "payment_relay": { + "cltv_expiry_delta": 50, + "fee_proportional_millionths": 0, + "fee_base_msat": 10000 + }, + "payment_constraints": { + "max_cltv_expiry": 750150, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Carol", + "pubkey": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "payload": "510a4fcc0f16524fd7f8bb0f4e8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f570f656a5aaecaf1ee8dc9d0fa1d424759be1932a8f29fac08bc2d2a1ed7159f28b", + "tlvs": { + "encrypted_recipient_data": { + "short_channel_id": "0x0x2", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "payment_relay": { + "cltv_expiry_delta": 75, + "fee_proportional_millionths": 150, + "fee_base_msat": 100 + }, + "payment_constraints": { + "max_cltv_expiry": 750100, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Dave", + "pubkey": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "payload": "510a4f0fa1a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9e49895fd4bcebf6f58d6f61a6d41a9bf5aa4b0453437856632e8255c351873143ddf2bb2b0832b091e1b4", + "tlvs": { + "encrypted_recipient_data": { + "padding": "00000000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x3", + "payment_relay": { + "cltv_expiry_delta": 25, + "fee_proportional_millionths": 100 + }, + "payment_constraints": { + "max_cltv_expiry": 750025, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Eve", + "pubkey": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "payload": "6002030186a004030b6dc80a4fda1c7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60722a63c688768042ade22f2c22f5724767d171fd221d3e579e43b354cc72e3ef146ada91a892d95fc48662f5b158add0af457da12030249f0", + "tlvs": { + "amt_to_forward": 100000, + "total_amount_msat": 150000, + "outgoing_cltv_value": 749000, + "encrypted_recipient_data": { + "padding": "00000000000000000000000000000000000000000000000000000000", + "path_id": "c9cf92f45ade68345bc20ae672e2012f4af487ed4415", + "payment_constraints": { + "max_cltv_expiry": 750000, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + } + ] + }, + "onion": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337dadf610256c6ab518495dce9cdedf9391e21a71dada75be905267ba82f326c0513dda706908cfee834996700f881b2aed106585d61a2690de4ebe5d56ad2013b520af2a3c49316bc590ee83e8c31b1eb11ff766dad27ca993326b1ed582fb451a2ad87fbf6601134c6341c4a2deb6850e25a355be68dbb6923dc89444fdd74a0f700433b667bda345926099f5547b07e97ad903e8a01566a78ae177366239e793dac719de805565b6d0a1d290e273f705cfc56873f8b5e28225f7ded7a1d4ceffae63f91e477be8c917c786435976102a924ba4ba3de6150c829ce01c25428f2f5d05ef023be7d590ecdf6603730db3948f80ca1ed3d85227e64ef77200b9b557f427b6e1073cfa0e63e4485441768b98ab11ba8104a6cee1d7af7bb5ee9c05cf9cf4718901e92e09dfe5cb3af336a953072391c1e91fc2f4b92e124b38e0c6d17ef6ba7bbe93f02046975bb01b7f766fcfc5a755af11a90cc7eb3505986b56e07a7855534d03b79f0dfbfe645b0d6d4185c038771fd25b800aa26b2ed2e30b1e713659468618a2fea04fcd0473284598f76b11b0d159d343bc9711d3bea8d561547bcc8fff12317c0e7b1ee75bcb8082d762b6417f99d0f71ff7c060f6b564ad6827edaffa72eefcc4ce633a8da8d41c19d8f6aebd8878869eb518ccc16dccae6a94c690957598ce0295c1c46af5d7a2f0955b5400526bfd1430f554562614b5d00feff3946427be520dee629b76b6a9c2b1da6701c8ca628a69d6d40e20dd69d6e879d7a052d9c16f544b49738c7ff3cdd0613e9ed00ead7707702d1a6a0b88de1927a50c36beb78f4ff81e3dd97b706307596eebb363d418a891e1cb4589ce86ce81cdc0e1473d7a7dd5f6bb6e147c1f7c46fa879b4512c25704da6cdbb3c123a72e3585dc07b3e5cbe7fecf3a08426eee8c70ddc46ebf98b0bcb14a08c469cb5cfb6702acc0befd17640fa60244eca491280a95fbbc5833d26e4be70fcf798b55e06eb9fcb156942dcf108236f32a5a6c605687ba4f037eddbb1834dcbcd5293a0b66c621346ca5d893d239c26619b24c71f25cecc275e1ab24436ac01c80c0006fab2d95e82e3a0c3ea02d08ec5b24eb39205c49f4b549dcab7a88962336c4624716902f4e08f2b23cfd324f18405d66e9da3627ac34a6873ba2238386313af20d5a13bbd507fdc73015a17e3bd38fae1145f7f70d7cb8c5e1cdf9cf06d1246592a25d56ec2ae44cd7f75aa7f5f4a2b2ee49a41a26be4fab3f3f2ceb7b08510c5e2b7255326e4c417325b333cafe96dde1314a15dd6779a7d5a8a40622260041e936247eec8ec39ca29a1e18161db37497bdd4447a7d5ef3b8d22a2acd7f486b152bb66d3a15afc41dc9245a8d75e1d33704d4471e417ccc8d31645fdd647a2c191692675cf97664951d6ce98237d78b0962ad1433b5a3e49ddddbf57a391b14dcce00b4d7efe5cbb1e78f30d5ef53d66c381a45e275d2dcf6be559acb3c42494a9a2156eb8dcf03dd92b2ebaa697ea628fa0f75f125e4a7daa10f8dcf56ebaf7814557708c75580fad2bbb33e66ad7a4788a7aaac792aaae76138d7ff09df6a1a1920ddcf22e5e7007b15171b51ff81799355232ce39f7d5ceeaf704255d790041d6390a69f42816cba641ec81faa3d7c0fdec59dfe4ca41f31a692eaffc66b083995d86c575aea4514a3e09e8b3a1fa4d1591a2505f253ad0b6bfd9d87f063d2be414d3a427c0506a88ac5bdbef9b50d73bce876f85c196dca435e210e1d6713695b529ddda3350fb5065a6a8288abd265380917bac8ebbc7d5ced564587471dddf90c22ce6dbadea7e7a6723438d4cf6ac6dae27d033a8cadd77ab262e8defb33445ddb2056ec364c7629c33745e2338" + }, + "decrypt": { + "comment": "This section contains the internal values generated by intermediate nodes when decrypting their payload.", + "hops": [ + { + "alias": "Alice", + "onion": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337dadf610256c6ab518495dce9cdedf9391e21a71dada75be905267ba82f326c0513dda706908cfee834996700f881b2aed106585d61a2690de4ebe5d56ad2013b520af2a3c49316bc590ee83e8c31b1eb11ff766dad27ca993326b1ed582fb451a2ad87fbf6601134c6341c4a2deb6850e25a355be68dbb6923dc89444fdd74a0f700433b667bda345926099f5547b07e97ad903e8a01566a78ae177366239e793dac719de805565b6d0a1d290e273f705cfc56873f8b5e28225f7ded7a1d4ceffae63f91e477be8c917c786435976102a924ba4ba3de6150c829ce01c25428f2f5d05ef023be7d590ecdf6603730db3948f80ca1ed3d85227e64ef77200b9b557f427b6e1073cfa0e63e4485441768b98ab11ba8104a6cee1d7af7bb5ee9c05cf9cf4718901e92e09dfe5cb3af336a953072391c1e91fc2f4b92e124b38e0c6d17ef6ba7bbe93f02046975bb01b7f766fcfc5a755af11a90cc7eb3505986b56e07a7855534d03b79f0dfbfe645b0d6d4185c038771fd25b800aa26b2ed2e30b1e713659468618a2fea04fcd0473284598f76b11b0d159d343bc9711d3bea8d561547bcc8fff12317c0e7b1ee75bcb8082d762b6417f99d0f71ff7c060f6b564ad6827edaffa72eefcc4ce633a8da8d41c19d8f6aebd8878869eb518ccc16dccae6a94c690957598ce0295c1c46af5d7a2f0955b5400526bfd1430f554562614b5d00feff3946427be520dee629b76b6a9c2b1da6701c8ca628a69d6d40e20dd69d6e879d7a052d9c16f544b49738c7ff3cdd0613e9ed00ead7707702d1a6a0b88de1927a50c36beb78f4ff81e3dd97b706307596eebb363d418a891e1cb4589ce86ce81cdc0e1473d7a7dd5f6bb6e147c1f7c46fa879b4512c25704da6cdbb3c123a72e3585dc07b3e5cbe7fecf3a08426eee8c70ddc46ebf98b0bcb14a08c469cb5cfb6702acc0befd17640fa60244eca491280a95fbbc5833d26e4be70fcf798b55e06eb9fcb156942dcf108236f32a5a6c605687ba4f037eddbb1834dcbcd5293a0b66c621346ca5d893d239c26619b24c71f25cecc275e1ab24436ac01c80c0006fab2d95e82e3a0c3ea02d08ec5b24eb39205c49f4b549dcab7a88962336c4624716902f4e08f2b23cfd324f18405d66e9da3627ac34a6873ba2238386313af20d5a13bbd507fdc73015a17e3bd38fae1145f7f70d7cb8c5e1cdf9cf06d1246592a25d56ec2ae44cd7f75aa7f5f4a2b2ee49a41a26be4fab3f3f2ceb7b08510c5e2b7255326e4c417325b333cafe96dde1314a15dd6779a7d5a8a40622260041e936247eec8ec39ca29a1e18161db37497bdd4447a7d5ef3b8d22a2acd7f486b152bb66d3a15afc41dc9245a8d75e1d33704d4471e417ccc8d31645fdd647a2c191692675cf97664951d6ce98237d78b0962ad1433b5a3e49ddddbf57a391b14dcce00b4d7efe5cbb1e78f30d5ef53d66c381a45e275d2dcf6be559acb3c42494a9a2156eb8dcf03dd92b2ebaa697ea628fa0f75f125e4a7daa10f8dcf56ebaf7814557708c75580fad2bbb33e66ad7a4788a7aaac792aaae76138d7ff09df6a1a1920ddcf22e5e7007b15171b51ff81799355232ce39f7d5ceeaf704255d790041d6390a69f42816cba641ec81faa3d7c0fdec59dfe4ca41f31a692eaffc66b083995d86c575aea4514a3e09e8b3a1fa4d1591a2505f253ad0b6bfd9d87f063d2be414d3a427c0506a88ac5bdbef9b50d73bce876f85c196dca435e210e1d6713695b529ddda3350fb5065a6a8288abd265380917bac8ebbc7d5ced564587471dddf90c22ce6dbadea7e7a6723438d4cf6ac6dae27d033a8cadd77ab262e8defb33445ddb2056ec364c7629c33745e2338", + "node_privkey": "4141414141414141414141414141414141414141414141414141414141414141" + }, + { + "alias": "Bob", + "onion": "000280caa47c2a0ea677f6a77529e46caa04212153a8d5f829bee1e7339b17e2e2a9a3461d10472364a4ff12344beb6df96fb0c38ec47d1e956ddff5a665190fcca5ed02c3a3903fd8bbd4a4b95b197867c378b67b08f0624cfe80734ba512869c0fa22099beb1f6f1ea325b07ce7449736d7ffad79178b428d8ea2d7bc6578f12dbd788ef933f3b5ba352797c41f6786c3820c96726acf8bddf2cfa5d9c617d2b0bd5ab7b93f7964c98f44cf47db8422f47d11100236a29579f1cafcd38bd979814e1d2bf6d625edf50e1e21bfaf6268e3180dd7aafd3892da281c6dd53c1c366d0fdaf670b6ad84a38d6e8a3f4a80d132d686fd3b7443bc2250023bdb9303190f74c9220481cf99da30b5ec2bdb5a49028f5014e3eaeaa48429a0c78ebd3bb7c7d582c22b7d547cd269f0c4490373a81bf92687e73dac2075b4bda189ce0be225f5f510655e37a6e724a1415bede0a076b92a882cc2a82878ba67aaedf71454eb42b7f8638df8e21d5f708006e5112e2dc0a4afbcfed9f2c7959be812853ca8e313fbc99a0f38f1ee4479c96ccb836632b0808401db159bd2637f7a664013241e4664e994a0a9a3940115a702c60381e66d291e1ade1be2802e1226e311e3201a7c9682b6bc4354caff3d439adb1dfee53ad3fb3dd5e169d64796853bb323129f41213b166a7cac00f728c3e33bd7e59aa2ac0d1341cdb1532b507a0f446e51022a882ac16405442347b70f78c9b6e122f8e70096a4fae4c0405db5b869e0b7b59b09519c4dbf4d4980483906e837da0bee93f668ffaad37d6a4764211a02f95ad2dc2d942c198796741c20a3baf8efb5a53bd9c1a0148318d60a97d0013ab63269097ea295d62c1426d064f0b31c02e74a348ee0509998e701069f5a1e0c1086aed38d2ec87da69fb57a992d88ace3b4a16b0960f5a94936e2e684a9926cf4f911969a2a5d31fed0c7616d30197848253170e51274278873b11f3f5cc1b04b14aa5812524e4d86cbf08306c2aa671288324d7a009b2be533b1d7d0ce6defeeb630b86a9655f1e6424fcb559ed67457c115fba0d0719374802ea68fab299fd3f273be86fa3d2e7456020db2f47c6ec16c21ce6ec65de495e20af1941a5dcd65d910c1cb93f22e1318c173c645c81aed681c9704a8a541ac3d6ff604f46d0260468acbfec1b771b9eb8cd49a2124468dae786571895a569aae18438eaee6343ab2634823119fa2439634645d12e3b4a748b9cc0398b8416a834eb5d9e5cf619bbfaba4894d1c574c738caf530d0862f4cc75eb52bd3921d2d9edb09940edb1e3776423b0046d870ccdcc5d61f72e0440b97a93eeef21fb246a779d339be301a5971400749d6cc9911dfbf9de8ae86fac83c860fdd0e2bfa40af37c99d50e50fd6e5ae86597a201112ed404042b55e132f243dec481a2adc1d5e4b71e1efdea806ea900b2907ce877742d5ecf700ff3640f737863d0dd7207e462ee8d0e17d52047a88ae7446f419560d23968bf64957949e36953155b0ac2511c66be2890b4036329a21e132efb635297a64431899e0c351e50c6682c9b4d79b5d122466d02cd84f206369417d9c194a9349d3c631d72eb7857a9cd542906fc02ad6cdcf9bcf25ace3d826b6623fa5164351e14d3f0de5c8445a2ba3aae26595d0e31c3e307c1d56d4274f61f056145c1b8d6880872b9b10a8bfa4a923cad2edbcf5c50eba48936ed2bcc0be60eb721a74b46704aaae5ad24e2797852195dfacbb30a777d33b63d4dc4f35cfbe5e88fd1944c55a54fd53581446ea061ad29f4671da819ad7488c5dfc700f5f7a1b2af0d6a6e9d9ffc570a6d3209614ab4dc43728f3f0cd7eb4ce36ccd98936bbcbd32627384434bd01e9c0f93b2a5173fba184685e19b9af78afe876aa4e4b4242382b293133771d95a2bd83fa9c62", + "node_privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "next_path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0" + }, + { + "alias": "Carol", + "onion": "000288b48876fb0dc0d7375233ccaf2910dc0dc81ba52e5a7906f00d75e0d58dbd4bb7c2714870529410735f0951e72cbe981e2e167c0d8f3de33a36e39e78465aea2acad1e23c78b6fd342d63e37d214c912b4a0be344618f779138edc1b42a5ca3218ca2fea4be427f6cd0d387160db2bf6c2ba8e82941c8cf3626bd6bed7187f633012ef49df38f6b12963cb639e9eed1b9d269dcebcbd0b25287aa536ec85e7320b02e193122199a745ccbaaebd37f5d4b71f52f9b50feeb793eeef56924a046bc5e7003f6253e0284a8d3fe2e42c3564050f1e753cd32cc258ac0ffa6e05eecad5ba1286f78252e60dd884a65405ab673a85ba52adfa65c1086d4bb37ba2e0848adb2b04379775ad798492b14e8997f30ffa9cf5d432bdf5b246fce008fd876399beed827db58195f4f6192f6ff4ec63cb17fdcb497cb7aec26846a71dd8dca02fc3bb14dd7231a4d62a981bec54b71eb20331096dfa214a0ff4489ee96db663826ae8c850e9f06baa52a47b8eb576363f97e742aab2dc616acc6e74588e1d2ac16694febc90abaf5b1c684163c0e615a68d32633f01934adc8c6bf91fa3fd7aad033b7596d60402494e45e2c1632c40f7bfbd88a81a896a1d28ed6338c83e1eeaa467945d59998eb456c95f94bf1892e8f326ec2d5e0196b7073f106febc6ab8ca5bcc23f77ffc819bc1b5debce418ccc7d8391bbf33bceee6110beba170121bd99f54c956e64970bdab31227b03ee0ea3f01fbd9bd74015f6f82d04fab072e8f85f4370d09f41ee3e48eb959767bd989abb4eea42c4daa0437a7f747d7f9b70eb87b9f9b0b6f283b8205912601a432999b8869fd9fe5bad3572edac24da7184f9298f21ff60923db277264d29c846dd2f228f6fc53b6b60364237de64773f803f174ed10229c374f603ccc5fd3a62cb413ffe6f5630dc646bb33f231b2350537ec39e5d3f2fe1a1cb019ed0b18ad14019cad27afcca8ad70387ca110394c0432774f1aa1fa404b2e086c84a55388d3bd102501c78ef925cce89d76fa04c3f20f2d1f0ce507ac8b37b7913e3949ba12bbc5a4f6bac37c2415622d365bc8b83709a28e3d46f3850c89a3ff4d027fef6e3e4ce5c6c85f663c7eaec3c9730106fb82f53249a905533cfabee812aae51965b24b42f7ab471967bc8e73354e69141ee26a1f03684d5fb9c256a34de8257210e0390dd3962db521ae0a3bdab28300610ab2a634b699e5f092da5a061609ef6414bd805c8171f54ad6f285fb64ce0becca0b61188badcf8ef21190dad629e3fb3e89f55ebba829919540ebf5f8ae4283836d3c9133c1ca3365f6b9394916730411650686e0c2ab9c53b6cda9efdd5cfcb53ba9b6962bb6aa49d0a83a87460b60a9c7d2643ee99afe652883795f14014ec5df61b1e30c041c1fa6487f3c82f1ded5f83ffbef5017e197b7fb77be3b36e284a15e57d45bf9316dcaf97eb78ee4642b731ba05c5063bce1333fab4af6da97c80a96ee599b4df823efbedc250c0abba9783da7ddf2414b2a4774ff2880a7dc6791103e18b8631e39743cf9e87aed71700daa5dc72fdae520324741f92ea3d510ff555dea5e45f15cda87272d4559a12d4777680acb06993840e3c748da82c16cae556015fb2acd0335da11a3388575394048ab71199793ab706abc9d68add2075d79a5cc0f779845ee8b98951be61fd293d6c15b9d4653935bf17cf50bd31f8b79e60dba0e7fd6864754fd94262485a4f65e7eb3e1922f51b1a4dd2b4fd2c20d94d1213fbe90bd603dfc7e15176382e3ce0f43f980d44d23bf3c57f54a15f42c171a8f2511e28ac178c6f01396e50397a57ffb09c5e6c315bd3ae7983577c1a0386c6d5d9a2223438e321b0fedfdee58fa452d57dc11a256834bb49ac9deeec88e4bf563c7340f44a240caec941c7e50f09cf", + "node_privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "next_path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + }, + { + "alias": "Dave", + "onion": "0003f25471c0f2ff549a7fd7859100306bb6c294c209f34c77f782897f184b967c498efc246bdb8e060a6d1cf8dd0d4d732e33311fb96c9e9f1274005fa3d08b41704a1b7224c6300a7caead7baa0a8263eba2e0de6956ee8e4a1958264f47e4cf20d194eb576f5bd249ee4fece563f80fd76dc3eaca8f956188406d83195752b5c90c4b2a5e7ac3a8d5c62b17b551aff48ef6842a7e9326832c9a4a2fd415011150a9e71beb901fd9747bac8add1c694b612730dc86b5b19a0bbbc675947a953316e3303d7b30c182f94def9206671edac9a3ec3e52d28fc28247a1c73ab751bf61c82c3950f617e758f79bd0ba294defb20466eaf1e801462046baad3aec3e5b8868a7b037f23d73a47a7e74c77107334f37388cff863e452820c61d89728fa75c84bc7cdfc06dcdd1911f5f803353926d073efd65251380e174913aae03318ea5b6f0ec83998c55ab99bef62803ea2da9f6d1ea892b90efc4f8ffb685a5201a781da2e6ac5923645638c9709ae32171a00c0cd3d8c7eedfb06b4eedc7d3e566987e2e3805a038f21d78ded5d6c7137a5e8e592f3180ee4d5f4e1289176f67fc38690d0958bc82e240b72b10577f340f1e14b8633f0b6d9729ff4618be2a972400a015a871ba33be70335f652a8d70f2bd32421d6ac2af781d667dad787d6aef4505a15d046579e46eebe757444cffca6d0610f0dd36a7ce57af969bd0c3f7006298ef406a25f689daf58f875d44d2423ebf195b503f11c37c506ea6abe50a463f7bb5e9b964604d832724de768513f6b38bf71715e1feea8a6e86797788d487146891564919da1372016ed8f08c7fcbff66a4a65a3d0fcd8e3daac6eba41f5d65ef2d8075364a9e78b3a273549f6eac4abb72e912e237990367e0d43e89945f8ac3907be5a6c662139485a50cb5ce3f0ba08586c39f6c515368ec3f91b72295f1b7a73a9df322ae9a45d363d6c616be3300083764cbdee31221f25a318f095feacb09f957c96db30fccca47a0215b576c3ed925a0bad05d6400abe318c11f36628c387a4ee38832182cd44b3cd48e5422c1f1e3b57218dfe72c611f5415127720e60f6e2400607e61841b76de1704bcbeb0daf1377ccb2253916de2b6d490bb71ba0a44fea2e94f2423d723934557d5905e01b2b80232a884e258d46dc92ea11e0818d0ece5b914f02049866e151801ab8c9aea155479b354dc91151fb9ba43277458f9760dd859faaa139e3b9ab36a1dbc36a93ef2c90598b20cb30ef3c4f23a2d6178b4d1da668fb328a25d84d30a132d9f2a6a988cbe2e5c2be01cb6db4b4725a50d6cdacf5fb083e7d650a25bec1407fbc047d26076c7596429a29606ad527e97ef0824ad6c1b05831a3e5b71c63a528918a3301cdd4061fc1fcce3da601961f2602a2b002ac8404125c2d52666263858a923e197efcda873c32d86897352e4f2264ad6a1b48acc0fe78ff55cb442cb2bb5fa2880810e1d00aa0247057fb80b7ed36cf9647af41b44ee4a63ee2d6f652526404572520a7d2d9dcde4e62df0c3be89f8471550594cdd16a51a9cacc58729c092c68506162fe65edc2314055d389f724ced189d826a546b5c4d08a43d977b3cf033de5760b71a7cc38ee5851592031aafb467a89b3b6c7ed67b15d44c48d6baedce3e95e08ec7c55038f3eba90ccb900895734f0fb7efe54961ce493369cc56416898a9bed7c2482871c15a7f1eb5ed17c33657fc31333539c2dfb59461af09e7049228113b5c9feea5a6e9959c18c51b19c90995afb9c76f2c0c820964cd7989c993a73925818a656c6a18dcd1a1e3782b2eae06dd5a41250ec2d1c203626ab9920c1673339eff04b1eb0cab85ef5909f571f9b83cdf21697c9f5cfa1c76e7bca955510e2126b3bb989a4ac21cf948f965e48bc363d2997437797b4f770e8b65", + "node_privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "next_path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a" + }, + { + "alias": "Eve", + "onion": "0002ef43c4dfe9aee14248c445406ac7980474ce106c504d9025f57963739130adfd06eb26201baee8866af2d1b7a7ab26595349dad002af0590193aaa8f400ab394f5994ec831aeeecb64421c566e3556cbdd7e7e50deb1fc49fd5e007308ab6494415514abff978899623f9b6065ca1e243bb78170118e8b8c8b53b750b59cc1ec017d167adbb3aabab7c2d84fbf94f5d827239f4c2b9d2c3cfe68fe5641f25e386202a4b6edff2a71e700229df7230c8ca31bd5588f04799e9640c9c20a47cba713f3cc5ad3202e14bb520880f2a8409d8e7835cae21b48a651c2d47fe6af785889ab98f1416f6e4ad67a66ae681e9a8828bad3f9b6890221c4a7ec80531d6b63eb30843f613ce644795bc8bcee60e8f7b36f3fd04de762f103c52efaf36a2f3bbbaac482d6271dc4180c10bcc076c04d06ea7fd8fb6a647e0e10523b05da2d89e4139fb55c2315cd01bdcbd57587fef8442d7ff5620630fd2d2e79739d90be811bf2cba60415d6cba2cea14ba1859f3122cd905c4e12e3e2a1ab6fab54b2ec40e434626e2d3c3195c02c82a8bd64d226c2328ac72ca12197d9908eaf54333717448ce6ed73adc0ac05e2ee1d735131d87918beb8995993dc8f63fe10f2c8eba2be7ab8bb44d9f78f59ef3e4c180bd75e4eef2381450c6f0480d543997305f1d07815993b5aca8d88d474966d9abec93bb069a16aa2da75b87f94576e01d08a17d3e0e3d0370f010733a7d7affb12cdf94c259a62607fce71003535c4727305de5ff7bba3840922844b3a45f62c29715fccf440517ef121450f6962396fba9b07036d085582405dcae6ee95964b66bc7c85b8d02d90091500db3cebf6de584f86b7b55335a8c9aa26381b00747f055cc458a2cadfccf9c29702bf941447beaca6583cca09492a57d4b03b2ca00dbaf41dfd6a9b249381626a7debe475735a7e39e77a363eccf14669046f656cc09ad448da8d8b545e6a604f46dc481786d09a94c63cf23f49ba367d2929466364dbce2a8ffce3dadf8f4cef8a56e1fefa1a3304a953fe83018e57d8a95694b02d994fea2630a9a3d5f1e2f6d6142d503ec4152871f7122d7e566a03261f554639e7a759e0e73846f71d5cace37d91336fc9ca9396bf64ca2cf45fa2db779b3b5c63b04f1c0c1fb79fdfcf5a82b0202df934ae1720a7ce1e047cbec3f82737b50168c974f4623cacce87e3f5bd5232caca7956d28ffedcf11ac5998662c5f6b13c6126584ca2e894d3fcbad4d130bbe22e88a135e0020cdd43853e0b3af3800e9544854d211e873cf68ab683578d501d69ec5dc7fce42ac436d58243880c1b88227b0681c6c9dd8a8ad0793202b15ab63b787b748e258da3e68d0e649fc4ac081a71de8adbc891c113d5f722686b6ac4ed9e3cc247bc4a4643416f480627e9de20f7307f434a499f5c6951c2e8b3ff51d455bf65ceb5ee3dee47b968ac2642e13d8a68f903b73627c2e75788fecca5836371a908eea4f1ea44db2315bc185f77e478efeaaa4da2da13fe7aeaa79ed1d04876a8b2b7b333c5de8c4c9a50274c2eb7b9bd2a3630c57173174781fc9785235f830cefa1c82080eaffdef257f18eedc9ddfd25a696a11a3dc56cd836be72f5f4a2cbb6316d5d3b1ad91a7ec7d877f28d2c29a5525b0b24362699281b0e3b48f38caf1085045fe9089f9e6fb29e4b47aa4cecf68c9bf72073469bd9beeea5e88bfe554cb6a81231149ba7fe7784c154fd8b0f9179ecdf1e9fd5c2939ec1ab16df9cbe9359101ebce933d4f65d3f66f87afaecfe9c046b52f4878b6c430329df7bd879fba8864fcbd9b782bf545734699b9b5a66b466dcedc0c9368803b5b0f1232950cef398ad3e057a5db964bd3e5c8a5717b30b41601a4f11ad63afe404cb6f1e8ea5fd7a8e085b65ca5136146febf4d47928dcc9a9e0", + "node_privkey": "4545454545454545454545454545454545454545454545454545454545454545", + "next_path_key": "038fc6859a402b96ce4998c537c823d6ab94d1598fca02c788ba5dd79fbae83589" + } + ] + } +} diff --git a/tests/test_blinded_payment_onion.py b/tests/test_blinded_payment_onion.py new file mode 100644 index 000000000000..6226e85ee8db --- /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=int.to_bytes(len(blinded_path_hops), signed=False, byteorder='big') +) + +# 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, + local_height=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 3989cc28ddb446dc9c391dedd18601fbbab933af Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 8 Oct 2025 14:34:34 +0200 Subject: [PATCH 31/45] bolt12/lnworker: implement creating offer Implement initial LNWallet method `create_offer` to create bolt12 offer for sharing with external parties. Add CLI command `add_offer` to use this method. --- electrum/commands.py | 31 +++++++++++++++++++++++ electrum/lnworker.py | 53 +++++++++++++++++++++++++++++++++++++++ electrum/onion_message.py | 1 + tests/test_commands.py | 26 +++++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/electrum/commands.py b/electrum/commands.py index a906718b6491..e17fcebd91d1 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 acd00893d364..1bb31a61a1ee 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4561,3 +4561,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 7586606d70b5..49d583b78a86 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 f3929b910d42..801c4f21e7e4 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 @@ -838,3 +839,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 10a1c5e72a101b766917ec509de7344613f79ee8 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 13:24:43 +0200 Subject: [PATCH 32/45] test_lnwallet: unittest offer creation --- tests/test_lnwallet.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 3321becd5e45..7dbfa7275961 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -13,6 +13,7 @@ from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum import bitcoin import electrum.trampoline +from electrum.onion_message import NoOnionMessagePeers from electrum import constants from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice from electrum.crypto import sha256, hmac_oneshot @@ -662,3 +663,48 @@ def test_create_bolt12_invoice_request_without_offer(self): signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) self.assertEqual(sha256(resigned), stored_digest) + + def test_create_offer(self): + wallet = self.lnwallet_anchors + + amount_msat = 10_000 + memo = "test" + expiry = 4000 + issuer = "pizza italy" + with self.assertRaises(NoOnionMessagePeers): + wallet.create_offer( + amount_msat=amount_msat, + description=memo, + relative_expiry=expiry, + issuer_name=issuer, + allow_unblinded=False, + ) + + offer = wallet.create_offer( + amount_msat=amount_msat, + description=memo, + relative_expiry=expiry, + issuer_name=issuer, + allow_unblinded=True, + ) + + self.assertEqual(offer.offer_amount, amount_msat) + self.assertEqual(offer.offer_description, memo) + self.assertEqual(offer.offer_issuer, issuer) + self.assertEqual(offer.offer_issuer_id, wallet.node_keypair.pubkey) + self.assertIsNone(offer.offer_paths) + absolute_expiry = int(time.time()) + expiry + self.assertLess(offer.offer_absolute_expiry - absolute_expiry, 2) + + # test that offer metadata is a mac of encoded offer + mac, remaining_metadata = offer.offer_metadata[-32:], offer.offer_metadata[:-32] + offer = dataclasses.replace(offer, offer_metadata=remaining_metadata) + encoded_offer = offer.encode(as_bech32=False) + our_mac = hmac_oneshot(key=wallet.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + self.assertEqual(our_mac, mac) + + # assert two offers signed with node keypair are not equal + # this allows for example to blacklist/revoke certain offers + offer1 = wallet.create_offer(allow_unblinded=True).encode() + offer2 = wallet.create_offer(allow_unblinded=True).encode() + self.assertNotEqual(offer1, offer2) From d45a9dc9bf73d2c191d523e11cb684d4bf57d383 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 8 Oct 2025 15:47:36 +0100 Subject: [PATCH 33/45] bolt12: handle 'invoice_request' onion message payloads Co-Authored-By: Sander van Grieken --- electrum/bolt12.py | 210 +++++++++++++++++++++++++++++++++++++- electrum/lnpeer.py | 43 +++++--- electrum/lnworker.py | 201 ++++++++++++++++++++++++++++++++++-- electrum/onion_message.py | 10 +- tests/test_bolt12.py | 43 +++++++- tests/test_lnwallet.py | 51 ++++++++- 6 files changed, 530 insertions(+), 28 deletions(-) diff --git a/electrum/bolt12.py b/electrum/bolt12.py index 2bafe3311cde..cdbb10915b64 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, @@ -258,6 +258,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: @@ -291,6 +292,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) @@ -506,12 +521,197 @@ 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': + with io.BytesIO(tlv) as fd: + invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') + return cls( + msg=invoice_error.get('error', {}).get('msg'), + 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 5beaf7d22993..064740fdaa3f 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, @@ -2121,7 +2120,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). @@ -2144,6 +2143,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'] @@ -2157,8 +2158,19 @@ 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 + + if sha256(path_id_payload.payment_preimage) != htlc.payment_hash: + log_fail_reason(f"sender misused our blinded path for unrelated payment? {path_id_payload}") + raise exc_incorrect_or_unknown_pd + + # we could even go as far and only persist the information once we have received all htlcs + self.lnworker.save_blinded_payment_info(path_id_payload) + path_id_digest = sha256(path_id) else: payment_secret_from_onion = processed_onion.payment_secret @@ -2181,11 +2193,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, *, @@ -2222,7 +2234,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, @@ -2230,11 +2242,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: @@ -2265,6 +2278,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, @@ -2293,10 +2307,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 1bb31a61a1ee..01f3fc6263b2 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 if TYPE_CHECKING: from .network import Network @@ -1106,6 +1110,7 @@ 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, bytes] # payment_hash -> callback: self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]] @@ -4489,9 +4494,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: @@ -4557,7 +4560,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 @@ -4614,3 +4617,189 @@ 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): + 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) + 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) + + if invreq.offer_issuer_id and (cached_invoice := self._bolt12_invoice_cache.get(invreq_metadata_digest)) is not None: + # 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 = cached_invoice + else: + serialized_invoice = self._create_bolt12_invoice_for_invoice_request( + invreq=invreq, + invoice_signing_key=invoice_signing_key, + ) + self._bolt12_invoice_cache[invreq_metadata_digest] = serialized_invoice + 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.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, + ) -> bytes: + """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 + 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=sha256(path_id_payload.payment_preimage), + 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 + + 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) + # TODO: persist description(s) for GUI? diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 49d583b78a86..f35aada70ec0 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -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 38f91939f476..165d9ffdf858 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 ) from electrum.crypto import privkey_to_pubkey from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer @@ -17,6 +18,7 @@ from electrum.lnutil import LnFeatures 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 @@ -390,6 +392,45 @@ 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_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 0f2eae071cda41acdf57ee54938b627a544dd2cd Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Apr 2026 15:36:21 +0200 Subject: [PATCH 34/45] test_lnwallet: test bolt12 invoice request handling --- tests/test_lnwallet.py | 439 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 407 insertions(+), 32 deletions(-) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index f3337f764329..43f4ae46812d 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -6,6 +6,7 @@ from decimal import Decimal from typing import Optional import time +from typing import Optional from unittest.mock import patch from electrum_ecc import ECPrivkey @@ -15,8 +16,12 @@ import electrum.trampoline from electrum.gui.qt.wizard import wallet from electrum.onion_message import NoOnionMessagePeers +from . import ElectrumTestCase +from .test_lnchannel import create_test_channels +from .test_wallet_vertical import UNICODE_HORROR + from electrum import constants -from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload, Bolt12InvoiceError 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 @@ -34,6 +39,31 @@ from .lnhelpers import create_test_channels +def get_dummy_paths(): + 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=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 reply_paths + + class TestLNWallet(ElectrumTestCase): TESTNET = True TEST_ANCHOR_CHANNELS = True @@ -528,27 +558,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 +651,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 +758,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 312a5607856ee610d2177f19e8e6ff5e1c82e609 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 17 Apr 2026 14:41:25 +0200 Subject: [PATCH 35/45] test_bolt12: unittest Bolt12InvoiceError --- tests/test_bolt12.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py index 165d9ffdf858..a8084b10a227 100644 --- a/tests/test_bolt12.py +++ b/tests/test_bolt12.py @@ -10,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, BOLT12InvoicePathIDPayload + extract_shared_fields, BOLT12InvoicePathIDPayload, Bolt12InvoiceError ) from electrum.crypto import privkey_to_pubkey from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer @@ -431,6 +431,18 @@ def test_bolt12_invoice_path_id_payload(self): 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) + def test_fallback_address(self): # invoice without fallback address invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') From 735afb774af78e47cd2e375aa6e9ce512046fc07 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 13 Nov 2025 12:31:23 +0100 Subject: [PATCH 36/45] wallet: return bech32 encoded bolt12 invoice in export_invoice() --- electrum/wallet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/electrum/wallet.py b/electrum/wallet.py index 0de4221029a8..40585af67e99 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -73,7 +73,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 aab052d516a0e2f0bbc5a9c802614eae79e05f72 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sat, 6 Dec 2025 16:18:54 +0100 Subject: [PATCH 37/45] add regtest: tests.regtest.TestLightningABC.test_bolt12 --- electrum/commands.py | 36 ++++++++++++++++++++++++++++++++++++ tests/regtest.py | 3 +++ tests/regtest/regtest.sh | 19 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/electrum/commands.py b/electrum/commands.py index e17fcebd91d1..6797f34ab809 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 0dcadfed9b1d..b76bda8cc240 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 0159be8bd7984cc68e073ab017f43ee82c679d1f Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 17 Mar 2026 16:17:37 +0100 Subject: [PATCH 38/45] lnutil: add blinded path method to PaymentFeeBudget Add method to substract fees required for blinded path from a PaymentFeeBudget to calculate the remaining budget for the unblinded path. --- electrum/lnutil.py | 20 ++++++++++++++++++++ tests/test_lnutil.py | 23 +++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index b6378b61b5af..7a670d4c003a 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -2143,6 +2143,26 @@ 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 substract_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. + """ + blinded_fee_msat = ( + blinded_payinfo.fee_base_msat + + (blinded_payinfo.fee_proportional_millionths * amount_msat) // 1_000_000 + ) + 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..8d74f47a075f 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 substract_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.substract_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.substract_blinded_path_fees(payinfo, amount_msat=100_000) From 5c9aa41813fffee15eeca2de3194b1921580e6e3 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 18 Mar 2026 17:06:42 +0100 Subject: [PATCH 39/45] trampoline: change invoice_features tlv from u64 to bytes Eclair interprets the invoice_features tlv for legacy trampoline payments as bytes, potentially this u64 conversion is outdated? This changes the invoice_features tlv to bytes type. See eclair source (grep "invoiceFeatures"). https://github.com/ACINQ/eclair/blob/a4d66adce2ec180c94532d1d0014f86f6e74f607/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala#L638 --- electrum/lnwire/onion_wire.csv | 2 +- electrum/lnworker.py | 2 +- electrum/trampoline.py | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv index d63d8401d5b5..2d22f38e07b9 100644 --- a/electrum/lnwire/onion_wire.csv +++ b/electrum/lnwire/onion_wire.csv @@ -16,7 +16,7 @@ tlvdata,payload,payment_metadata,payment_metadata,byte,... tlvtype,payload,total_amount_msat,18 tlvdata,payload,total_amount_msat,total_msat,tu64, tlvtype,payload,invoice_features,66097 -tlvdata,payload,invoice_features,invoice_features,u64, +tlvdata,payload,invoice_features,invoice_features,byte,... tlvtype,payload,outgoing_node_id,66098 tlvdata,payload,outgoing_node_id,outgoing_node_id,byte,33 tlvtype,payload,invoice_routing_info,66099 diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 01f3fc6263b2..14644addc8d3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4148,7 +4148,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 08d0cf28ce00..011a39c414df 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 6ddd51f1105c05a21e5fb99cb35aa570071f03f8 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 1 Apr 2026 16:02:07 +0200 Subject: [PATCH 40/45] trampoline: add blinded payment support Update the trampoline payment logic in LNWallet to handle payments to/through blinded paths. This allows for bolt 12 trampoline payments. Also stops including a trampoline onion layer for the recipient of a legacy trampoline payment. A non-trampoline aware (legacy) receiver cannot read this onion and it is not required for Eclair compatibility anymore. --- electrum/lnonion.py | 12 +- electrum/lnrouter.py | 12 +- electrum/lnwire/onion_wire.csv | 5 + electrum/lnworker.py | 34 +++-- electrum/trampoline.py | 250 ++++++++++++++++++++++----------- tests/test_lnrouter.py | 68 ++++++++- 6 files changed, 273 insertions(+), 108 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 13053a628788..233b4ca7a410 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -426,7 +426,7 @@ def calc_hops_data_for_payment( *, final_cltv_abs: int, total_msat: int, - payment_secret: bytes, + payment_secret: Optional[bytes], ) -> 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. @@ -438,15 +438,17 @@ 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)] # payloads, backwards from last hop (but excluding the first edge): for route_edge in reversed(route[1:]): hop_payload = { diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index c5a04b823eeb..ca5930703fcd 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -136,16 +136,16 @@ def is_route_within_budget( route: LNPaymentRoute, *, budget: PaymentFeeBudget, - amount_msat_for_dest: int, # that final receiver gets - cltv_delta_for_dest: Optional[int], # that final receiver gets, not given for blinded paths + amount_msat_for_recipient: int, # that final receiver gets + cltv_delta_for_recipient: Optional[int], # that final receiver gets, not given for blinded paths additional_blinded_path_fees: Optional['BlindedPayInfo'] = 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 + amt = amount_msat_for_recipient cltv_cost_of_route = 0 # excluding cltv_delta_for_dest if additional_blinded_path_fees is not None: amt += fee_for_edge_msat( @@ -157,14 +157,14 @@ def is_route_within_budget( for route_edge in reversed(route[1:]): amt += route_edge.fee_for_edge(amt) cltv_cost_of_route += route_edge.cltv_delta - fee_cost = amt - amount_msat_for_dest + fee_cost = amt - amount_msat_for_recipient # check against budget if cltv_cost_of_route > budget.cltv: return False if fee_cost > budget.fee_msat: return False # sanity check - total_cltv_delta = cltv_cost_of_route + (cltv_delta_for_dest or 0) + total_cltv_delta = cltv_cost_of_route + (cltv_delta_for_recipient or 0) if total_cltv_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: return False return True 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 14644addc8d3..2a65f7c921e3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -2383,18 +2383,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: @@ -2461,6 +2469,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 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: @@ -2474,24 +2483,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, my_pubkey=self.node_keypair.pubkey, - min_final_cltv_delta=routing_info.min_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) @@ -2555,8 +2559,8 @@ async def create_routes_for_payment( if not is_route_within_budget( route, budget=budget, - amount_msat_for_dest=amount_msat, - cltv_delta_for_dest=None if blinded_path else routing_info.min_final_cltv_delta, + amount_msat_for_recipient=amount_msat, + cltv_delta_for_recipient=None if blinded_path else routing_info.min_final_cltv_delta, additional_blinded_path_fees=blinded_path.payinfo if blinded_path else None ): self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}") diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 011a39c414df..c6c5f3d4ff0e 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -4,15 +4,15 @@ 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 from .lnonion import ( - calc_hops_data_for_payment, new_onion_packet, OnionPacket, PER_HOP_HMAC_SIZE + calc_hops_data_for_payment, new_onion_packet, OnionPacket, PER_HOP_HMAC_SIZE, + calc_hops_data_for_blinded_payment, BlindedPathInfo ) -from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute +from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute, fee_for_edge_msat from .lnutil import NoPathFound from .lntransport import LNPeerAddr from . import constants @@ -20,6 +20,7 @@ from .util import random_shuffled_copy if TYPE_CHECKING: + from .lnutil import RoutingInfo from .lnchannel import Channel @@ -46,6 +47,7 @@ _TRAMPOLINE_NODES_UNITTESTS = {} # used in unit tests + TRAMPOLINE_HOPS_MAX_DATA_SIZE = 500 LAZY_TRAMPOLINE_MAGIC = b'lazy' LAZY_TRAMPOLINE_VERSION = b'\x00' @@ -88,6 +90,7 @@ def decode_next_trampolines(data: bytes) -> dict: return next_trampolines + def hardcoded_trampoline_nodes() -> Mapping[str, LNPeerAddr]: if _TRAMPOLINE_NODES_UNITTESTS: return _TRAMPOLINE_NODES_UNITTESTS @@ -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: @@ -291,25 +299,36 @@ 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'], +) -> tuple[LNPaymentTRoute, bool]: # 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 + assert final_node_pubkey != my_trampoline, "is_direct_path should be true?" # our first trampoline hop is decided by the channel we use _extend_trampoline_route( @@ -317,19 +336,31 @@ def create_trampoline_route( ) 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()) 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": dataclasses.asdict(blinded_path.path), + "payment_info": blinded_path.payinfo.to_dict(), + }] + 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 else: next_trampoline = my_trampoline # maybe add second trampoline @@ -341,77 +372,140 @@ def create_trampoline_route( 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) + # Add final edge. Needed for fees for last TF as fees are allocated for the start_node. + # Last edge end_node is recipient or IP. + if route[-1].end_node != final_node_pubkey: + _extend_trampoline_route(route, end_node=final_node_pubkey) - # replace placeholder fees in route - usable_budget_msat = get_trampoline_budget(trampoline_fee_level, budget.fee_msat) + # 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.substract_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=}") - _allocate_fee_budget_among_route( + # 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 isinstance(routing_info, BlindedRoutingInfo) else amount_msat + _allocate_fee_budget_among_route( # replaces the placeholder fee values of the route edges route, 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) ) # check that we can pay amount and fees + min_final_cltv_delta = routing_info.min_final_cltv_delta if isinstance(routing_info, UnblindedRoutingInfo) else 0 if not is_route_within_budget( - route=route, - budget=budget, - amount_msat_for_dest=amount_msat, - cltv_delta_for_dest=min_final_cltv_delta, + route=route, # unblinded route + budget=budget, # whole budget + amount_msat_for_recipient=amount_msat, + cltv_delta_for_recipient=min_final_cltv_delta, + additional_blinded_path_fees=blinded_path.payinfo if blinded_path else None, ): - raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget}") - return route + raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget=} {unblinded_path_budget=}") + + # extend the last trampolines budget so it can pay for the blinded path + if blinded_path and is_legacy: + last_trampoline, payinfo = route[-1], blinded_path.payinfo + last_trampoline.fee_base_msat += payinfo.fee_base_msat + last_trampoline.fee_proportional_millionths += payinfo.fee_proportional_millionths + last_trampoline.cltv_delta += payinfo.cltv_expiry_delta + + return route, is_legacy def create_trampoline_onion( *, route: LNPaymentTRoute, + routing_info: 'BlindedRoutingInfo | UnblindedRoutingInfo', + blinded_path: Optional['BlindedPathInfo'], amount_msat: int, - final_cltv_abs: int, + local_height: int, + is_legacy: bool, 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. + 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, + local_height=local_height, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path, + ) + else: + # either non-blinded payment or blinded legacy trampoline payment (last trampoline forwards to IP) + final_cltv_abs = local_height + if isinstance(routing_info, UnblindedRoutingInfo): + final_cltv_abs += routing_info.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=routing_info.payment_secret if not blinded_path else None, + ) + if is_legacy: + # The last TrampolineEdge was only added to the route to allocate fees for the last trampoline forwarder. + # No onion layer is needed for the recipient/IP as they don't understand Trampoline. + del hops_data[-1]; del route[-1] + assert hops_data and route, (hops_data, route) + + # detect trampoline hops. mutate hops_data payloads to make them trampoline payloads. payment_path_pubkeys = [x.node_id for x in route] - num_hops = len(payment_path_pubkeys) + 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: + # include blinded paths so the trampoline can route to the recipient + # invoice_routing_info is set on route[-1] by create_trampoline_route + assert route[-1].invoice_routing_info is not None, route + del payload["short_channel_id"] + payload["outgoing_blinded_paths"] = {"paths": route[-1].invoice_routing_info} + payload["invoice_features"] = {"invoice_features": LnFeatures(route[-1].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 + del payload["short_channel_id"] + 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: + assert not blinded_path # 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 @@ -446,42 +540,40 @@ 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, + blinded_path: Optional['BlindedPathInfo'], ) -> Tuple[LNPaymentTRoute, OnionPacket, int, int]: # create route for the trampoline_onion - trampoline_route = create_trampoline_route( + # blinded path hops are not appended to this route + trampoline_route, is_legacy = 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, + route=trampoline_route, # last edge might get truncated + routing_info=routing_info, + blinded_path=blinded_path, amount_msat=amount_msat, - final_cltv_abs=final_cltv_abs, + local_height=local_height, + is_legacy=is_legacy, 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..08adcda1bce9 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -1,3 +1,4 @@ +import os import random import unittest from math import inf @@ -8,9 +9,9 @@ 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) + get_trampoline_budget, create_trampoline_route_and_onion) from electrum.util import bfh -from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget +from electrum.lnutil import ShortChannelID, LnFeatures, UnblindedRoutingInfo, PaymentFeeBudget from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet, process_onion_packet, _decode_onion_error, decode_onion_error, OnionFailureCode) @@ -488,15 +489,76 @@ def test_create_legacy_trampoline_onion_multiple_rtags(self): node_features=0 ), ] + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), + payment_secret=urandom(32), + min_final_cltv_delta=0, + r_tags=[], # create_trampoline_onion uses r_tags from route + invoice_features=LnFeatures(0), + ) # create a trampoline onion, this shouldn't raise InvalidPayloadSize create_trampoline_onion( route=dummy_route, + routing_info=routing_info, amount_msat=0, - final_cltv_abs=0, + local_height=0, total_msat=0, payment_hash=urandom(32), + is_legacy=True, + blinded_path=None, + ) + + def test_create_trampoline_route_and_onion_unblinded_legacy(self): + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), payment_secret=urandom(32), + min_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[0].start_node, node('a')) + self.assertEqual(route[0].end_node, node('b')) + self.assertEqual(len(route), 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[0].start_node, node('a')) + self.assertEqual(route[0].end_node, node('b')) + self.assertEqual(route[1].start_node, node('b')) + self.assertEqual(route[1].end_node, node('d')) + self.assertEqual(len(route), 2) @needs_test_with_all_chacha20_implementations def test_decode_onion_error(self): From 923ec98aae1436d5892a7583d84d84679429382c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Apr 2026 16:02:39 +0200 Subject: [PATCH 41/45] qt: initial support for bolt12 offers Co-Authored-By: f321x --- electrum/gui/icons/bolt12.png | Bin 0 -> 3624 bytes electrum/gui/qt/invoice_list.py | 5 ++++- electrum/gui/qt/main_window.py | 34 +++++++++++++++++--------------- electrum/gui/qt/send_tab.py | 8 +++++--- 4 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 electrum/gui/icons/bolt12.png diff --git a/electrum/gui/icons/bolt12.png b/electrum/gui/icons/bolt12.png new file mode 100644 index 0000000000000000000000000000000000000000..e56d45e27bd97045dd7aff079e8e0744b4e40358 GIT binary patch literal 3624 zcmZ{nc{tQv|Hr>$n6Zqaq)~`5$-Yx|X)Gg(n2_8J6Dix+cYPH#_UtpZ#7%C=63G%q z3Pmv_j5XV!kY=ovp6QS0x_-;^{P8*G`kX)B=Q`(lpX>cP@zxj2_<4`<0sz2|HOE|H z&)}cphOx&^1D9U*#6vW93;=)woqr0_?OT4Gy(kiB;t*)->mC^5;_n88goG&F^zjTJ zxDefxeEo0at?3mN>2eAgzLwoh-HWk_4yxgU}CRd~ilxe#(S ztW^9)ym(0{L1Qk$xW?w)T&bz zPaTQt{H*AHOCAj?h1lV;;>!i8BGMWoQ2a^3_xzk35tbg+MMyiCHEz=mb%k^07+>!1 zOp@@GJrN7e0>aggjB91z^zBX5FX7g#Y;Gk zoU|H>#$tRq8hI$!ex62Sb1P-u9l-?hs!#Jt4B7nF?)zC^cJsEEx3|8m{AaFMLIh}z z{vPG-!%0kusks>M(V@HklKCW#?`YR0Lu*_RhoB<-8v(i+1a%mmoYa3@QL(}Y_2arb zhxj&d?qI7RdWt+r&5e7IEljLMZ_%lYDr^VIpxDRPDI84oBUT-&dTfp|S?(Q({p zBxq%6sqq*!3b>#luqOA_Y4wT-c3jxVMQqul3T&1PHp5@}0J;v+YWNi)e@bM8>i`@Ap z67NPYLBtwlglgS>FXxTTFQRdC5rD_Pf{xr$b`u)spJDy*%%02C$JC>R6E$-+FT52V zN2riZM}rOinYWnYe-PSL)#FOpa+Ab(%LwhY;OPD*?>8kR8CsC4(&nW54W$zfbzA|h5Sxb+pUf~FDrkoFaBKV89L$WCs|K=(;( z#XpsmUu!I!+6IE0+1E&h;bp-?&_})kv_2z=Z?PtWfWvh#^TeJ3KtRN{B}@sew&yCn zbI;AoOGWAgE-9dz#bCdYkp|biO-$a5UtUt11ZWdg7q{8v!F!A;;EBTv|Ixlq^StNfvCplPZE#hopSmoUIVgHv9 z*5U`X8V~5uZmaBF9!}@fsr;6&^x^T7UX(NZMR`Cz|Jl|i{iLbik*c5ag7x_ZP)%j? z%G&$LhR+VrrSO*6r*7&UjmL`~l{HM(L?9ZntGRZbMYov(N{}6%*02#d@Ycf0p>A)5 zg&tLKYG(sGdTC1o*$a>(HgZ}cL?dg1iS0+*m6Mi%Pi(ch=!YqXD-CM`)c)T20$4s& z9@z|j^+3`a7|gi54gHBGq3}Op)JJ(1Y8T%uK6@~hTiIpv0{GQ&uuE$3haxB`dtam| z3q6kTtn?DQ< z9P(hb?iAn)Q6rfXKyg+6c$_7A>=1FDAztDGOG%2N=3vSbI9nHl3 zG9mNL8+k@#crgPKF0fHtH&6FlTo4vWvD8&DJCGkZYTrLk>25nH+o6z1%)G<@*isKyr^2e4-(Zh}d=4rsmhQqbI&0hz@KoUZXOGAW5q~cnt6L8@6An(p^(`ilrpo05w?4s|Kw% z8U8BO&;YvW#R>4c_ef8G^jBSURa1cZp6aQYWXPp~_1`QHp&b1?=e8AufcNR1U0T50 zUf0rUtm49#4n3SNeN!0ScD2oKBIRZL2f=E%d68ia(@rR)9XzMJ4^gSg@U%F#?>m$I zoeA034!3ZL+)rWh3NXd40qbuFeM{UiKf*WPS)BXjthgrZNIV&*vy9N~5HPP5h-F!jOFUjIx^tpI&Vm zG_RGhFJh-=iD_~;mywl0f8Kh0kt^_Mc6RoOR9X*5`}A*6p*JNgi_U9@)6XIaLy+Ha z8uB%s2$clNaa|o9EfbWW6qgj7Kh1pJ1Bc5Iph=GgZb0}D)V?n?rw#eT(rV1ZxnV{ zefE-gJt`_HZF;?0C5e>0?e*!~FUEqHeWti!zC)X7$zH+S8`>00Z+uig(E3=z@Q>-#ArI4qms`X%p8!!Zf0#xXIr3S0u9=D3i7 zniP@`oAz214v!qnEwh5uaDZvrN!vi5jgVWiW<`>X0L=VQvT6Te<4S;78&lKT_tKKv z@8TIz1AJD49h+d9;`e%{)dd=IC^^{ldb9wykmZ`oSrG~Btnw2caN20J9WwEc%)63o z5=V{u`&`=O5klFvL*VZU%r08XunKYXXQdMS0N^=NB^EtHjk)Ds2!-Y;AF_#X z-N$D*Ub)mI$Al!YQ7avdeV&z-)t^`T#*6n8fTh#vDeKG2O>n9Ps|}>^r~p)|ho5WXjRP*UPhNsog1*zhN zKD{xcQ3H3MmE~neI~;B^E{!=BM?e6g`7SNu)K- z4o8JZpCJ7kYg7aX?Dub!o~gYxp0?{R2Y9h|BcC5Ra>QxK(A=@E@i^5FMvpvVR8TCF zLPu%EOYuMWn{7}bY`Spg=rg9|6E8(Y#SAp|7ALVRCRo9_?ETRcEu%wLLrR22Qz@t> zP=Ki?Q`~^%IMiOracV-SUi3D;#|bF}1N>KBB@;)TNVXyxF!S_Ii7G=iag0v#*&86* z(V(j|W0UZ=E&C&2p8g(lz%t)lE)doV0SLBDn2#U&)K~vv?^LB?lnxONP`G9(hq>wo zqT}5drmV{+6not0k)JKtc6*%lSb&xod@@srqe>GrQ=Z!Y-&FE{Am#tJjN4BmqNHVc SpZl?Yc>vbr0;b9sf9GGEC9YHe literal 0 HcmV?d00001 diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index dd8bfb35d298..c618b819a10f 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -111,7 +111,10 @@ def update(self): for idx, item in enumerate(self.wallet.get_unpaid_invoices()): key = item.get_id() if item.is_lightning(): - icon_name = 'lightning.png' + if item.bolt12_invoice: + icon_name = 'bolt12.png' + else: + icon_name = 'lightning.png' else: icon_name = 'bitcoin.png' status = self.wallet.get_invoice_status(item) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 6e1c35d3e797..7238ede2f800 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 ec7c65e6fc336d0ca14ebaf8e66b054dbe2f0ef7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Apr 2026 16:02:55 +0200 Subject: [PATCH 42/45] qml: initial support for bolt12 offers Co-Authored-By: f321x --- .../gui/qml/components/Bolt12OfferDialog.qml | 146 ++++++++++++++++++ electrum/gui/qml/components/InvoiceDialog.qml | 60 ++++++- .../gui/qml/components/WalletMainView.qml | 26 +++- .../components/controls/InvoiceDelegate.qml | 4 +- electrum/gui/qml/qeinvoice.py | 139 ++++++++++++++--- electrum/gui/qml/qeinvoicelistmodel.py | 9 +- 6 files changed, 355 insertions(+), 29 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..ca8ba641f590 --- /dev/null +++ b/electrum/gui/qml/components/Bolt12OfferDialog.qml @@ -0,0 +1,146 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('BOLT12 Offer') + iconSource: '../../../icons/bolt12.png' + + property InvoiceParser invoiceParser + + padding: 0 + + property bool commentValid: true // TODO? + property bool amountValid: amountBtc.textAsSats.satsInt > 0 + 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 + text: qsTr('Issuer') + color: Material.accentColor + visible: 'issuer' in invoiceParser.offerData + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: 'issuer' in invoiceParser.offerData + Label { + width: parent.width + wrapMode: Text.Wrap + text: invoiceParser.offerData['issuer'] + } + } + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + text: qsTr('Description') + color: Material.accentColor + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + Label { + width: parent.width + text: invoiceParser.offerData['description'] + wrapMode: Text.Wrap + } + } + Label { + Layout.columnSpan: 2 + text: qsTr('Amount') + color: Material.accentColor + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + BtcField { + id: amountBtc + Layout.preferredWidth: rootLayout.width /3 + text: 'amount' in invoiceParser.offerData + ? Config.formatSatsForEditing(invoiceParser.offerData['amount']/1000) + : '' + readOnly: 'amount' in invoiceParser.offerData + color: Material.foreground // override gray-out on disabled + fiatfield: amountFiat + onTextAsSatsChanged: { + invoiceParser.amountOverride = textAsSats + } + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + RowLayout { + Layout.columnSpan: 2 + 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 + } + } + + Label { + Layout.columnSpan: 2 + text: qsTr('Note') + color: Material.accentColor + } + ElTextArea { + id: note + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.minimumHeight: 100 + wrapMode: TextEdit.Wrap + placeholderText: qsTr('Enter an (optional) message for the receiver') + // TODO: max 100 chars is arbitrary, not sure what the max size is + color: text.length > 100 ? constants.colorError : Material.foreground + } + } + + FlatButton { + Layout.topMargin: constants.paddingLarge + Layout.fillWidth: true + text: qsTr('Pay') + 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..2230220e9f70 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -17,7 +17,11 @@ 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') + : invoice.lnprops.is_bolt12 + ? qsTr('BOLT12 Invoice') + : qsTr('Lightning Invoice') iconSource: Qt.resolvedUrl('../../icons/tab_send.png') padding: 0 @@ -107,6 +111,30 @@ ElDialog { } } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Issuer') + visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != '' + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != '' + leftPadding: constants.paddingMedium + + Label { + text: 'issuer' in invoice.lnprops ? invoice.lnprops.issuer : '' + width: parent.width + font.pixelSize: constants.fontSizeXLarge + wrapMode: Text.Wrap + elide: Text.ElideRight + } + } + Label { Layout.columnSpan: 2 Layout.topMargin: constants.paddingSmall @@ -423,6 +451,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 + + TextHighlightPane { + 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/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml index bc053b07ba8c..e4fc082e2b3a 100644 --- a/electrum/gui/qml/components/controls/InvoiceDelegate.qml +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -40,7 +40,9 @@ ItemDelegate { Layout.preferredWidth: constants.iconSizeLarge Layout.preferredHeight: constants.iconSizeLarge source: model.is_lightning - ? "../../../icons/lightning.png" + ? model.is_bolt12 + ? "../../../icons/bolt12.png" + : "../../../icons/lightning.png" : "../../../icons/bitcoin.png" Image { diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 1bc2b016f2f6..e139421ac27a 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -8,24 +8,26 @@ from electrum.i18n import _ from electrum.logging import get_logger +from electrum.util import InvoiceError, bfh 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.payment_identifier import ( + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType, invoice_from_payment_identifier +) from electrum.network import Network from electrum.util import event_listener - from electrum.gui.common_qt.util import QtEventListener +from electrum.bolt12 import BOLT12Invoice 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 +37,7 @@ class Type(IntEnum): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 + Bolt12Offer = 3 @pyqtEnum class Status(IntEnum): @@ -144,11 +147,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,19 +251,38 @@ 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 + issuer = b12i.offer_issuer + lnprops.update({ + 'blinded_paths': [{ + 'first_node': self.name_for_node_id(x.first_node_id), + 'path_length': x.hop_count, + } for x in paths], + '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() @@ -277,6 +299,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 +390,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 +471,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 +482,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 +493,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 +504,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 +519,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 +546,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 +570,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 +593,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 +605,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, PaymentIdentifierType.BOLT12_OFFER]: + 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 +655,15 @@ def on_lnurl_pay(self, lnurldata: LNURL6Data): self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() + def on_bolt12_offer(self, bolt12_offer): + self._logger.debug(f'on_bolt12_offer: {bolt12_offer!r}') + self._offerData = {} + self._offerData.update(bolt12_offer.get('offer_description', {})) + self._offerData.update(bolt12_offer.get('offer_amount', {})) + self._offerData.update(bolt12_offer.get('offer_issuer', {})) + self.setValidBolt12Offer() + self.bolt12Offer.emit() + @pyqtSlot() @pyqtSlot(str) def lnurlGetInvoice(self, comment=None): @@ -648,6 +707,42 @@ 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 + assert self._pi.need_finalize() + self._logger.debug(f'{self._offerData!r}') + + amount = self.amountOverride.satsInt + + def on_finished(pi): + 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(self.amountOverride.satsInt, pi.bolt12_invoice) + + self._busy = True + self.busyChanged.emit() + + self._pi.finalize(amount_sat=amount, comment=note, on_finished=on_finished) + + def on_bolt12_invoice(self, orig_amount, bolt12_invoice: Invoice): + self._logger.debug(f'on_bolt12_invoice {bolt12_invoice!r}') + + # # assure no shenanigans with the invoice we get back + if orig_amount * 1000 != bolt12_invoice.amount_msat: # TODO msat precision can cause trouble here + raise Exception('Unexpected amount in invoice, differs from invoice_request specified amount') + + 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 fde12bf4b18c9a9cef88d0da4bea25d1287a1360 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 13 May 2026 09:37:34 +0200 Subject: [PATCH 43/45] wip: exchange rates --- electrum/util.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/electrum/util.py b/electrum/util.py index afbf95df1a1b..aedcc82a0017 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -2503,3 +2503,15 @@ class ChoiceItem: key: Any label: str # user facing string extra_data: Any = None + + +def convert_fiat_to_satoshi(currency: str, amount: Decimal) -> Optional[int]: + """Convert amount of given currency into satoshi if exchange rate is available""" + from .network import Network + network = Network.get_instance() + if not network or not network.daemon or not network.daemon.fx: + _logger.warning(f"cannot convert fiat to bitcoin: {network}") + return None + fx = network.daemon.fx + return 0 + From 17ef4532a4625e9c9913ff47b830ea5dedc17efc Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 27 May 2026 12:10:44 +0200 Subject: [PATCH 44/45] fix(lnpeer): include path_key in processed-onion cache key The LRU cache for processed onions keyed entries on onion_hash + payment_hash + is_trampoline only. With route blinding a receiver may advertise multiple blinded paths for the same payment; two incoming HTLCs sharing onion_hash and payment_hash but using different path_key bytes would otherwise be served the same cached result. Including path_key in the key removes the ambiguity. Also drops a commented-out debug line from the bolt12 regtest case. --- electrum/lnpeer.py | 2 +- tests/regtest/regtest.sh | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 064740fdaa3f..bbdb5ffb3827 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -3338,7 +3338,7 @@ def _process_incoming_onion_packet( 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]) + (path_key or b'')) # type: ignore if cached_onion := self._processed_onion_cache.get(cache_key): return cached_onion diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index b76bda8cc240..202b32df0ace 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -771,7 +771,6 @@ if [[ $1 == "watchtower" ]]; then 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" From e50836b9c38f3fe6a91e094eadf5c32a43067880 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 27 May 2026 12:10:50 +0200 Subject: [PATCH 45/45] feat(wallet-db): bump seed_version to 72 for bolt12 blinded htlcs UpdateAddHtlc.to_json now produces a 6-tuple (with the trailing 'path_key' field) instead of the previous 5-tuple. The new code reads either shape, but older electrum versions call from_tuple(*tuple) positionally and would raise TypeError on a 6-tuple entry. Bumping FINAL_SEED_VERSION ensures older clients refuse to open wallets that may now contain blinded HTLC entries, rather than failing mid-deserialization with an unclear error. --- electrum/wallet_db.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index c53c355b0753..10773f599768 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -71,7 +71,7 @@ def __init__(self, wallet_db: 'WalletDB'): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 71 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 72 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -250,6 +250,7 @@ def upgrade(self): self._convert_version_69() self._convert_version_70() self._convert_version_71() + self._convert_version_72() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1426,6 +1427,17 @@ def _convert_version_71(self): self.data['genesis_blockhash'] = constants.net.GENESIS self.data['seed_version'] = 71 + def _convert_version_72(self): + """UpdateAddHtlc storage tuple gained a trailing 'path_key' field for BOLT12 route blinding. + Existing 5-tuples remain readable thanks to the default arg in UpdateAddHtlc.from_tuple, but + older clients call from_tuple(*tuple) positionally and would raise TypeError on a 6-tuple + entry. The version bump ensures they refuse to open wallets that may now contain such + entries instead of failing mid-deserialization. + """ + if not self._is_upgrade_method_needed(71, 71): + return + self.data['seed_version'] = 72 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return