lnonion: immutable OnionPacket and OnionHopsDataSingle
What changed, and why it matters
This commit is a defensive code-hardening change, not a fix for an active security flaw. It makes two internal data structures used in Lightning Network onion routing immutable (unchangeable after creation) so they can be safely cached and shared without accidental or malicious modification. There is no direct evidence in the commit that this change fixes a known exploitable vulnerability.
No urgent action required. Treat as routine hardening. Review downstream consumers of OnionHopsDataSingle/OnionPacket to ensure they no longer rely on mutability, and verify test coverage for the new immutable constructors and serialization paths.
Security signals we found
Defensive immutability refactor of Lightning onion packet structures
Use of MappingProxyType to prevent post-construction mutation of nested payloads
Replacement of in-place field mutation with dataclasses.replace()
No mention of vulnerability, CVE, bug, or exploit in commit message or diff
Evidence from the diff
The patch converts OnionHopsDataSingle and OnionPacket from mutable classes to frozen dataclasses, wraps nested dict payloads with MappingProxyType, and introduces a recursive make_object_immutable helper. Call sites in lnonion.py, lnworker.py, onion_message.py, and trampoline.py are updated to use dataclasses.replace() instead of in-place mutation. The serializer in lnmsg.py is extended to accept MappingProxyType and tuple values. The stated goal is ‘safer caching and handling.’ No bug, crash, or vulnerability is described in the commit message or diff, and no CVE or advisory is referenced.
Changed components
electrum/lnonion.pyelectrum/lnmsg.pyelectrum/lnworker.pyelectrum/onion_message.pyelectrum/trampoline.pyelectrum/util.pyInspect captured patch +149 / −93
diff --git a/electrum/lnmsg.py b/electrum/lnmsg.py
index eb0b761..2a5688c 100644
--- a/electrum/lnmsg.py
+++ b/electrum/lnmsg.py
@@ -2,6 +2,7 @@ import os
import csv
import io
from typing import Callable, Tuple, Any, Dict, List, Sequence, Union, Optional
+from types import MappingProxyType
from collections import OrderedDict
from .lnutil import OnionFailureCodeMetaFlag
@@ -289,7 +290,7 @@ def _write_tlv_record(*, fd: io.BytesIO, tlv_type: int, tlv_val: bytes) -> None:
_write_primitive_field(fd=fd, field_type="byte", count=tlv_len, value=tlv_val)
-def _resolve_field_count(field_count_str: str, *, vars_dict: dict, allow_any=False) -> Union[int, str]:
+def _resolve_field_count(field_count_str: str, *, vars_dict: Union[dict, MappingProxyType], allow_any=False) -> Union[int, str]:
"""Returns an evaluated field count, typically an int.
If allow_any is True, the return value can be a str with value=="...".
"""
@@ -403,7 +404,12 @@ class LNSerializer:
fd: io.BytesIO,
field_type: str,
count: Union[int, str],
- value: Union[List[Dict[str, Any]], Dict[str, Any]]
+ value: Union[
+ List[Union[MappingProxyType[str, Any], Dict[str, Any]]],
+ Tuple[Union[MappingProxyType[str, Any], Dict[str, Any]], ...],
+ Dict[str, Any],
+ MappingProxyType[str, Any],
+ ],
) -> None:
assert fd
@@ -421,10 +427,10 @@ class LNSerializer:
return
if count == 1:
- assert isinstance(value, dict) or isinstance(value, list)
- values = [value] if isinstance(value, dict) else value
+ assert isinstance(value, (MappingProxyType, dict)) or isinstance(value, (list, tuple)), type(value)
+ values = [value] if isinstance(value, (MappingProxyType, dict)) else value
else:
- assert isinstance(value, list), f'{field_type=}, expected value of type list for {count=}'
+ assert isinstance(value, (tuple, list)), f'{field_type=}, expected value of type list/tuple for {count=}'
values = value
if count == '...':
diff --git a/electrum/lnonion.py b/electrum/lnonion.py
index 4867a8a..f9872e5 100644
--- a/electrum/lnonion.py
+++ b/electrum/lnonion.py
@@ -27,6 +27,8 @@ import io
import hashlib
from typing import Sequence, List, Tuple, NamedTuple, TYPE_CHECKING, Dict, Any, Optional, Union
from enum import IntEnum
+from dataclasses import dataclass, field, replace
+from types import MappingProxyType
import electrum_ecc as ecc
@@ -53,18 +55,22 @@ class InvalidOnionPubkey(Exception): pass
class InvalidPayloadSize(Exception): pass
-class OnionHopsDataSingle: # called HopData in lnd
+@dataclass(frozen=True, kw_only=True)
+class OnionHopsDataSingle:
+ payload: MappingProxyType = field(default_factory=lambda: MappingProxyType({}))
+ hmac: Optional[bytes] = None
+ tlv_stream_name: str = 'payload'
+ blind_fields: MappingProxyType = field(default_factory=lambda: MappingProxyType({}))
+ _raw_bytes_payload: Optional[bytes] = None
- def __init__(self, *, payload: dict = None, tlv_stream_name: str = 'payload', blind_fields: dict = None):
- if payload is None:
- payload = {}
- self.payload = payload
- self.hmac = None
- self.tlv_stream_name = tlv_stream_name
- if blind_fields is None:
- blind_fields = {}
- self.blind_fields = blind_fields
- self._raw_bytes_payload = None # used in unit tests
+ def __post_init__(self):
+ # make all fields immutable recursively
+ object.__setattr__(self, 'payload', util.make_object_immutable(self.payload))
+ object.__setattr__(self, 'blind_fields', util.make_object_immutable(self.blind_fields))
+ assert isinstance(self.payload, MappingProxyType)
+ assert isinstance(self.blind_fields, MappingProxyType)
+ assert isinstance(self.tlv_stream_name, str)
+ assert (isinstance(self.hmac, bytes) and len(self.hmac) == PER_HOP_HMAC_SIZE) or self.hmac is None
def to_bytes(self) -> bytes:
hmac_ = self.hmac if self.hmac is not None else bytes(PER_HOP_HMAC_SIZE)
@@ -101,32 +107,35 @@ class OnionHopsDataSingle: # called HopData in lnd
hop_payload = fd.read(hop_payload_length)
if hop_payload_length != len(hop_payload):
raise Exception(f"unexpected EOF")
- ret = OnionHopsDataSingle(tlv_stream_name=tlv_stream_name)
- ret.payload = OnionWireSerializer.read_tlv_stream(fd=io.BytesIO(hop_payload),
- tlv_stream_name=tlv_stream_name)
- ret.hmac = fd.read(PER_HOP_HMAC_SIZE)
- assert len(ret.hmac) == PER_HOP_HMAC_SIZE
+ payload = OnionWireSerializer.read_tlv_stream(fd=io.BytesIO(hop_payload),
+ tlv_stream_name=tlv_stream_name)
+ ret = OnionHopsDataSingle(
+ tlv_stream_name=tlv_stream_name,
+ payload=MappingProxyType(payload),
+ hmac=fd.read(PER_HOP_HMAC_SIZE)
+ )
return ret
def __repr__(self):
- return f"<OnionHopsDataSingle. payload={self.payload}. hmac={self.hmac}>"
+ return f"<OnionHopsDataSingle. {self.payload=}. {self.hmac=}>"
+@dataclass(frozen=True, kw_only=True)
class OnionPacket:
-
- def __init__(self, *, public_key: bytes, hops_data: bytes, hmac: bytes, version: int = 0):
- assert len(public_key) == 33
- assert len(hops_data) in [HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE, ONION_MESSAGE_LARGE_SIZE]
- assert len(hmac) == PER_HOP_HMAC_SIZE
- self.version = version
- self.public_key = public_key
- self.hops_data = hops_data # also called RoutingInfo in bolt-04
- self.hmac = hmac
- if not ecc.ECPubkey.is_pubkey_bytes(public_key):
+ public_key: bytes
+ hops_data: bytes # also called RoutingInfo in bolt-04
+ hmac: bytes
+ version: int = 0
+ # for debugging our own onions:
+ _debug_hops_data: Optional[Sequence[OnionHopsDataSingle]] = None
+ _debug_route: Optional['LNPaymentRoute'] = None
+
+ def __post_init__(self):
+ assert len(self.public_key) == 33
+ assert len(self.hops_data) in [HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE, ONION_MESSAGE_LARGE_SIZE]
+ assert len(self.hmac) == PER_HOP_HMAC_SIZE
+ if not ecc.ECPubkey.is_pubkey_bytes(self.public_key):
raise InvalidOnionPubkey()
- # for debugging our own onions:
- self._debug_hops_data = None # type: Optional[Sequence[OnionHopsDataSingle]]
- self._debug_route = None # type: Optional[LNPaymentRoute]
def to_bytes(self) -> bytes:
ret = bytes([self.version])
@@ -138,7 +147,7 @@ class OnionPacket:
return ret
@classmethod
- def from_bytes(cls, b: bytes):
+ def from_bytes(cls, b: bytes) -> 'OnionPacket':
if len(b) - 66 not in [HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE, ONION_MESSAGE_LARGE_SIZE]:
raise Exception('unexpected length {}'.format(len(b)))
return OnionPacket(
@@ -187,7 +196,7 @@ def get_blinded_node_id(node_id: bytes, shared_secret: bytes):
def new_onion_packet(
payment_path_pubkeys: Sequence[bytes],
session_key: bytes,
- hops_data: Sequence[OnionHopsDataSingle],
+ hops_data: List[OnionHopsDataSingle],
*,
associated_data: bytes = b'',
trampoline: bool = False,
@@ -226,7 +235,7 @@ def new_onion_packet(
for i in range(num_hops-1, -1, -1):
rho_key = get_bolt04_onion_key(b'rho', hop_shared_secrets[i])
mu_key = get_bolt04_onion_key(b'mu', hop_shared_secrets[i])
- hops_data[i].hmac = next_hmac
+ hops_data[i] = replace(hops_data[i], hmac=next_hmac)
stream_bytes = generate_cipher_stream(rho_key, data_size)
hop_data_bytes = hops_data[i].to_bytes()
mix_header = mix_header[:-len(hop_data_bytes)]
@@ -294,7 +303,7 @@ def calc_hops_data_for_payment(
"total_msat": total_msat,
"amount_msat": amt
}
- hops_data = [OnionHopsDataSingle(payload=hop_payload)]
+ hops_data = [OnionHopsDataSingle(payload=MappingProxyType(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]
@@ -304,7 +313,7 @@ def calc_hops_data_for_payment(
"short_channel_id": {"short_channel_id": route_edge.short_channel_id},
}
hops_data.append(
- OnionHopsDataSingle(payload=hop_payload))
+ OnionHopsDataSingle(payload=MappingProxyType(hop_payload)))
amt += route_edge.fee_for_edge(amt)
cltv_abs += route_edge.cltv_delta
hops_data.reverse()
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 80ccb2a..1a9aaba 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -12,6 +12,7 @@ from typing import (
Optional, Sequence, Tuple, List, Set, Dict, TYPE_CHECKING, NamedTuple, Mapping, Any, Iterable, AsyncGenerator,
Callable, Awaitable
)
+from types import MappingProxyType
import threading
import socket
from functools import partial
@@ -3723,13 +3724,14 @@ class LNWallet(LNWorker):
# if we are forwarding a trampoline payment, add trampoline onion
if trampoline_onion:
self.logger.info(f'adding trampoline onion to final payload')
- trampoline_payload = hops_data[-1].payload
+ trampoline_payload = dict(hops_data[-1].payload)
trampoline_payload["trampoline_onion_packet"] = {
"version": trampoline_onion.version,
"public_key": trampoline_onion.public_key,
"hops_data": trampoline_onion.hops_data,
"hmac": trampoline_onion.hmac
}
+ hops_data[-1] = dataclasses.replace(hops_data[-1], payload=MappingProxyType(trampoline_payload))
if t_hops_data := trampoline_onion._debug_hops_data: # None if trampoline-forwarding
t_route = trampoline_onion._debug_route
assert t_route is not None
diff --git a/electrum/onion_message.py b/electrum/onion_message.py
index 6c8a2fd..94537b8 100644
--- a/electrum/onion_message.py
+++ b/electrum/onion_message.py
@@ -27,9 +27,11 @@ import io
import os
import threading
import time
+import dataclasses
from random import random
+from types import MappingProxyType
-from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple
+from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, List
import electrum_ecc as ecc
@@ -139,7 +141,7 @@ def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bo
def encrypt_onionmsg_tlv_hops_data(
- hops_data: Sequence[OnionHopsDataSingle],
+ hops_data: List[OnionHopsDataSingle],
hop_shared_secrets: Sequence[bytes]
) -> None:
"""encrypt unencrypted onionmsg_tlv.encrypted_recipient_data for hops with blind_fields"""
@@ -148,7 +150,9 @@ def encrypt_onionmsg_tlv_hops_data(
if hops_data[i].tlv_stream_name == 'onionmsg_tlv' and 'encrypted_recipient_data' not in hops_data[i].payload:
# construct encrypted_recipient_data from blind_fields
encrypted_recipient_data = encrypt_onionmsg_data_tlv(shared_secret=hop_shared_secrets[i], **hops_data[i].blind_fields)
- hops_data[i].payload['encrypted_recipient_data'] = {'encrypted_recipient_data': encrypted_recipient_data}
+ new_payload = dict(hops_data[i].payload)
+ new_payload['encrypted_recipient_data'] = {'encrypted_recipient_data': encrypted_recipient_data}
+ hops_data[i] = dataclasses.replace(hops_data[i], payload=MappingProxyType(new_payload))
def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Sequence[PathEdge]:
@@ -280,17 +284,17 @@ def send_onion_message_to(
hops_data = [
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={'next_node_id': {'node_id': x.end_node}}
+ blind_fields=MappingProxyType({'next_node_id': {'node_id': x.end_node}})
) for x in path[:-1]
]
# final hop pre-ip, add next_path_key_override
final_hop_pre_ip = OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={
+ blind_fields=MappingProxyType({
'next_node_id': {'node_id': introduction_point},
'next_path_key_override': {'path_key': blinded_path['first_path_key']},
- }
+ })
)
hops_data.append(final_hop_pre_ip)
@@ -299,9 +303,11 @@ def send_onion_message_to(
encrypted_recipient_data = encrypt_onionmsg_data_tlv(
shared_secret=hop_shared_secrets[i],
**hops_data[i].blind_fields)
- hops_data[i].payload['encrypted_recipient_data'] = {
+ payload = dict(hops_data[i].payload)
+ payload['encrypted_recipient_data'] = {
'encrypted_recipient_data': encrypted_recipient_data
}
+ hops_data[i] = dataclasses.replace(hops_data[i], payload=MappingProxyType(payload))
path_key = ecc.ECPrivkey(session_key).get_public_key_bytes()
@@ -314,7 +320,7 @@ def send_onion_message_to(
}
if i == len(remaining_blinded_path) - 1: # final hop
payload.update(destination_payload)
- hop = OnionHopsDataSingle(tlv_stream_name='onionmsg_tlv', payload=payload)
+ hop = OnionHopsDataSingle(tlv_stream_name='onionmsg_tlv', payload=MappingProxyType(payload))
hops_data.append(hop)
payment_path_pubkeys = blinded_node_ids + blinded_path_blinded_ids
@@ -345,13 +351,13 @@ def send_onion_message_to(
hops_data = [
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={'next_node_id': {'node_id': x.end_node}}
+ blind_fields=MappingProxyType({'next_node_id': {'node_id': x.end_node}})
) for x in path[1:]
]
final_hop = OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- payload=destination_payload
+ payload=MappingProxyType(destination_payload),
)
hops_data.append(final_hop)
diff --git a/electrum/trampoline.py b/electrum/trampoline.py
index 32a5f4a..1316227 100644
--- a/electrum/trampoline.py
+++ b/electrum/trampoline.py
@@ -1,7 +1,9 @@
import io
import os
import random
+import dataclasses
from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any
+from types import MappingProxyType
from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded
from .lnonion import (
@@ -302,12 +304,12 @@ def create_trampoline_onion(
for i in range(num_hops):
route_edge = route[i]
assert route_edge.is_trampoline()
- payload = hops_data[i].payload
+ payload = dict(hops_data[i].payload)
if i < num_hops - 1:
payload.pop('short_channel_id')
next_edge = route[i+1]
assert next_edge.is_trampoline()
- hops_data[i].payload["outgoing_node_id"] = {"outgoing_node_id": next_edge.node_id}
+ payload["outgoing_node_id"] = {"outgoing_node_id": next_edge.node_id}
# only for final
if i == num_hops - 1:
payload["payment_data"] = {
@@ -322,10 +324,11 @@ def create_trampoline_onion(
"payment_secret": payment_secret,
"total_msat": total_msat
}
+ hops_data[i] = dataclasses.replace(hops_data[i], payload=MappingProxyType(payload))
if (index := routing_info_payload_index) is not None:
# fill the remaining payload space with available routing hints (r_tags)
- payload: dict = hops_data[index].payload
+ payload = dict(hops_data[index].payload)
# try different r_tag order on each attempt
invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info)
remaining_payload_space = TRAMPOLINE_HOPS_DATA_SIZE \
@@ -341,12 +344,16 @@ def create_trampoline_onion(
remaining_payload_space -= r_tag_size
# add the chosen r_tags to the payload
payload["invoice_routing_info"] = {"invoice_routing_info": b''.join(routing_info_to_use)}
+ hops_data[index] = dataclasses.replace(hops_data[index], payload=MappingProxyType(payload))
_logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags")
trampoline_session_key = os.urandom(32)
trampoline_onion = new_onion_packet(payment_path_pubkeys, trampoline_session_key, hops_data, associated_data=payment_hash, trampoline=True)
- trampoline_onion._debug_hops_data = hops_data
- trampoline_onion._debug_route = route
+ trampoline_onion = dataclasses.replace(
+ trampoline_onion,
+ _debug_hops_data=hops_data,
+ _debug_route=route,
+ )
return trampoline_onion, amount_msat, cltv_abs
diff --git a/electrum/util.py b/electrum/util.py
index 08e60e5..9cc1f33 100644
--- a/electrum/util.py
+++ b/electrum/util.py
@@ -32,6 +32,7 @@ from typing import (
NamedTuple, Union, TYPE_CHECKING, Tuple, Optional, Callable, Any, Sequence, Dict, Generic, TypeVar, List, Iterable,
Set, Awaitable
)
+from types import MappingProxyType
from datetime import datetime, timezone, timedelta
import decimal
from decimal import Decimal
@@ -1875,6 +1876,21 @@ class OrderedDictWithIndex(OrderedDict):
return ret
+def make_object_immutable(obj):
+ """Makes the passed object immutable recursively."""
+ allowed_types = (
+ dict, MappingProxyType, list, tuple, set, frozenset, str, int, float, bool, bytes, type(None)
+ )
+ assert isinstance(obj, allowed_types), f"{type(obj)=} cannot be made immutable"
+ if isinstance(obj, (dict, MappingProxyType)):
+ return MappingProxyType({k: make_object_immutable(v) for k, v in obj.items()})
+ elif isinstance(obj, (list, tuple)):
+ return tuple(make_object_immutable(item) for item in obj)
+ elif isinstance(obj, (set, frozenset)):
+ return frozenset(make_object_immutable(item) for item in obj)
+ return obj
+
+
def multisig_type(wallet_type):
"""If wallet_type is mofn multi-sig, return [m, n],
otherwise return None."""
diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py
index 2a7db47..287f981 100644
--- a/tests/test_lnrouter.py
+++ b/tests/test_lnrouter.py
@@ -5,6 +5,7 @@ import shutil
import asyncio
from typing import Optional
from os import urandom
+from types import MappingProxyType
from electrum import util
from electrum.channel_db import NodeInfo
@@ -387,17 +388,22 @@ class Test_LNRouter(ElectrumTestCase):
session_key = bfh('4141414141414141414141414141414141414141414141414141414141414141')
associated_data = bfh('4242424242424242424242424242424242424242424242424242424242424242')
hops_data = [
- OnionHopsDataSingle(),
- OnionHopsDataSingle(),
- OnionHopsDataSingle(),
- OnionHopsDataSingle(),
- OnionHopsDataSingle(),
+ OnionHopsDataSingle(
+ _raw_bytes_payload=bfh("1202023a98040205dc06080000000000000001"),
+ ),
+ OnionHopsDataSingle(
+ _raw_bytes_payload=bfh("52020236b00402057806080000000000000002fd02013c0102030405060708090a0b0c0d0e0f0102030405060708090a0b0c0d0e0f0102030405060708090a0b0c0d0e0f0102030405060708090a0b0c0d0e0f"),
+ ),
+ OnionHopsDataSingle(
+ _raw_bytes_payload=bfh("12020230d4040204e206080000000000000003"),
+ ),
+ OnionHopsDataSingle(
+ _raw_bytes_payload=bfh("1202022710040203e806080000000000000004"),
+ ),
+ OnionHopsDataSingle(
+ _raw_bytes_payload=bfh("fd011002022710040203e8082224a33562c54507a9334e79f0dc4f17d407e6d7c61f0e2f3d0d38599502f617042710fd012de02a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a"),
+ ),
]
- hops_data[0]._raw_bytes_payload = bfh("1202023a98040205dc06080000000000000001")
- hops_data[1]._raw_bytes_payload = bfh("52020236b00402057806080000000000000002fd02013c0102030405060708090a0b0c0d0e0f0102030405060708090a0b0c0d0e0f0102030405060708090a0b0c0d0e0f0102030405060708090a0b0c0d0e0f")
- hops_data[2]._raw_bytes_payload = bfh("12020230d4040204e206080000000000000003")
- hops_data[3]._raw_bytes_payload = bfh("1202022710040203e806080000000000000004")
- hops_data[4]._raw_bytes_payload = bfh("fd011002022710040203e8082224a33562c54507a9334e79f0dc4f17d407e6d7c61f0e2f3d0d38599502f617042710fd012de02a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a")
packet = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=associated_data)
self.assertEqual(bfh('0002eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619f7f3416a5aa36dc7eeb3ec6d421e9615471ab870a33ac07fa5d5a51df0a8823aabe3fea3f90d387529d4f72837f9e687230371ccd8d263072206dbed0234f6505e21e282abd8c0e4f5b9ff8042800bbab065036eadd0149b37f27dde664725a49866e052e809d2b0198ab9610faa656bbf4ec516763a59f8f42c171b179166ba38958d4f51b39b3e98706e2d14a2dafd6a5df808093abfca5aeaaca16eded5db7d21fb0294dd1a163edf0fb445d5c8d7d688d6dd9c541762bf5a5123bf9939d957fe648416e88f1b0928bfa034982b22548e1a4d922690eecf546275afb233acf4323974680779f1a964cfe687456035cc0fba8a5428430b390f0057b6d1fe9a8875bfa89693eeb838ce59f09d207a503ee6f6299c92d6361bc335fcbf9b5cd44747aadce2ce6069cfdc3d671daef9f8ae590cf93d957c9e873e9a1bc62d9640dc8fc39c14902d49a1c80239b6c5b7fd91d05878cbf5ffc7db2569f47c43d6c0d27c438abff276e87364deb8858a37e5a62c446af95d8b786eaf0b5fcf78d98b41496794f8dcaac4eef34b2acfb94c7e8c32a9e9866a8fa0b6f2a06f00a1ccde569f97eec05c803ba7500acc96691d8898d73d8e6a47b8f43c3d5de74458d20eda61474c426359677001fbd75a74d7d5db6cb4feb83122f133206203e4e2d293f838bf8c8b3a29acb321315100b87e80e0edb272ee80fda944e3fb6084ed4d7f7c7d21c69d9da43d31a90b70693f9b0cc3eac74c11ab8ff655905688916cfa4ef0bd04135f2e50b7c689a21d04e8e981e74c6058188b9b1f9dfc3eec6838e9ffbcf22ce738d8a177c19318dffef090cee67e12de1a3e2a39f61247547ba5257489cbc11d7d91ed34617fcc42f7a9da2e3cf31a94a210a1018143173913c38f60e62b24bf0d7518f38b5bab3e6a1f8aeb35e31d6442c8abb5178efc892d2e787d79c6ad9e2fc271792983fa9955ac4d1d84a36c024071bc6e431b625519d556af38185601f70e29035ea6a09c8b676c9d88cf7e05e0f17098b584c4168735940263f940033a220f40be4c85344128b14beb9e75696db37014107801a59b13e89cd9d2258c169d523be6d31552c44c82ff4bb18ec9f099f3bf0e5b1bb2ba9a87d7e26f98d294927b600b5529c47e04d98956677cbcee8fa2b60f49776d8b8c367465b7c626da53700684fb6c918ead0eab8360e4f60edd25b4f43816a75ecf70f909301825b512469f8389d79402311d8aecb7b3ef8599e79485a4388d87744d899f7c47ee644361e17040a7958c8911be6f463ab6a9b2afacd688ec55ef517b38f1339efc54487232798bb25522ff4572ff68567fe830f92f7b8113efce3e98c3fffbaedce4fd8b50e41da97c0c08e423a72689cc68e68f752a5e3a9003e64e35c957ca2e1c48bb6f64b05f56b70b575ad2f278d57850a7ad568c24a4d32a3d74b29f03dc125488bc7c637da582357f40b0a52d16b3b40bb2c2315d03360bc24209e20972c200566bcf3bbe5c5b0aedd83132a8a4d5b4242ba370b6d67d9b67eb01052d132c7866b9cb502e44796d9d356e4e3cb47cc527322cd24976fe7c9257a2864151a38e568ef7a79f10d6ef27cc04ce382347a2488b1f404fdbf407fe1ca1c9d0d5649e34800e25e18951c98cae9f43555eef65fee1ea8f15828807366c3b612cd5753bf9fb8fced08855f742cddd6f765f74254f03186683d646e6f09ac2805586c7cf11998357cafc5df3f285329366f475130c928b2dceba4aa383758e7a9d20705c4bb9db619e2992f608a1ba65db254bb389468741d0502e2588aeb54390ac600c19af5c8e61383fc1bebe0029e4474051e4ef908828db9cca13277ef65db3fd47ccc2179126aaefb627719f421e20'),
packet.to_bytes())
@@ -424,26 +430,26 @@ class Test_LNRouter(ElectrumTestCase):
session_key = bfh('4141414141414141414141414141414141414141414141414141414141414141')
associated_data = bfh('4242424242424242424242424242424242424242424242424242424242424242')
hops_data = [
- OnionHopsDataSingle(payload={
+ OnionHopsDataSingle(payload=MappingProxyType({
'amt_to_forward': {'amt_to_forward': 15000},
'outgoing_cltv_value': {'outgoing_cltv_value': 1500},
- 'short_channel_id': {'short_channel_id': bfh('0000000000000001')}}),
- OnionHopsDataSingle(payload={
+ 'short_channel_id': {'short_channel_id': bfh('0000000000000001')}})),
+ OnionHopsDataSingle(payload=MappingProxyType({
'amt_to_forward': {'amt_to_forward': 14000},
'outgoing_cltv_value': {'outgoing_cltv_value': 1400},
- 'short_channel_id': {'short_channel_id': bfh('0000000000000002')}}),
- OnionHopsDataSingle(payload={
+ 'short_channel_id': {'short_channel_id': bfh('0000000000000002')}})),
+ OnionHopsDataSingle(payload=MappingProxyType({
'amt_to_forward': {'amt_to_forward': 12500},
'outgoing_cltv_value': {'outgoing_cltv_value': 1250},
- 'short_channel_id': {'short_channel_id': bfh('0000000000000003')}}),
- OnionHopsDataSingle(payload={
+ 'short_channel_id': {'short_channel_id': bfh('0000000000000003')}})),
+ OnionHopsDataSingle(payload=MappingProxyType({
'amt_to_forward': {'amt_to_forward': 10000},
'outgoing_cltv_value': {'outgoing_cltv_value': 1000},
- 'short_channel_id': {'short_channel_id': bfh('0000000000000004')}}),
- OnionHopsDataSingle(payload={
+ 'short_channel_id': {'short_channel_id': bfh('0000000000000004')}})),
+ OnionHopsDataSingle(payload=MappingProxyType({
'amt_to_forward': {'amt_to_forward': 10000},
'outgoing_cltv_value': {'outgoing_cltv_value': 1000},
- 'payment_data': {'payment_secret': bfh('24a33562c54507a9334e79f0dc4f17d407e6d7c61f0e2f3d0d38599502f61704'), 'total_msat': 10000}}),
+ 'payment_data': {'payment_secret': bfh('24a33562c54507a9334e79f0dc4f17d407e6d7c61f0e2f3d0d38599502f61704'), 'total_msat': 10000}})),
]
packet = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=associated_data)
self.assertEqual(bfh('0002eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619f7f3416a5aa36dc7eeb3ec6d421e9615471ab858ba970cd3cceb768b44e692be2f390c0b7fe70122abae84d7801db070dfb1638cd8d263072206dbed0234f6505e21e282abd8587124c572aad8de04610a136d6c71a7648c0ef66f1b3655d8a9eea1f92349132c93befbd6c37dbfc55615814ae09e4cbef721c01b487007811bbbfdc1fc7bd869aeb70eb08b4140ff5f501394b3653ada2a3b36a263535ea421d26818afb278df46abcec093305b715cac22b0b03645f8f4797cf2987b1bf4bfdd9ed8648ed42ed1a831fc36ccd45416a132580281ddac4e7470e4d2afd675baad9282ec6335403a73e1391427e330996c834db93848b4ae29dd975f678b2f5155ad6865ca23190725d4b7238fb44f0e3762dd59091b45c97d45df8164a15d9ca0329ec76f957b0a0e49ae372154620708df5c0fa991f0dd12b6bff1ebaf9e2376bb64bc24713f7c57da569bcd9c43a50c088416564b786a87d1f40936a051a3dbfe023bd867a5e66148b61cdd24a79f8c18682150e55aa6969ce9becf51f7c69e72deafcd0659f6be4f78463eaef8716e56615c77b3fbea8190806359909dcbec13c1592523b3d2985ec3e83d42cb7286a66a22f58704ddf6979ceb6883ab4ad8ac99d30251035189ffd514e03ce1576844513d66965d4adfc2523f4eee0dede229ab96303e31348c72bc0c8c816c666a904e5ccbabadf5a919720438f4a14dbd4a802f8d4b942f0ca8572f59644c9ac1912c8c8efefc4afa7f19e27411d46b7541c55985e28ce5cd7620b335fea51de55fa00ef977e8522181ad19e5e04f93bcfc83a36edd7e96fe48e846f2e54fe7a7090fe8e46ba72123e1cdee0667777c38c4930e50401074d8ab31a9717457fcefaa46323003af553bee2b49ea7f907eb2ff3301463e64a8c53975c853bbdd2956b9001b5ce1562264963fce84201daaf752de6df7ca31291226969c9851d1fc4ea88ca67d38c38587c2cdd8bc4d3f7bdf705497a1e054246f684554b3b8dfac43194f1eadec7f83b711e663b5645bde6d7f8cefb59758303599fed25c3b4d2e4499d439c915910dd283b3e7118320f1c6e7385009fbcb9ae79bab72a85e644182b4dafc0a173241f2ae68ae6a504f17f102da1e91de4548c7f5bc1c107354519077a4e83407f0d6a8f0975b4ac0c2c7b30637a998dda27b56b56245371296b816876b859677bcf3473a07e0f300e788fdd60c51b1626b46050b182457c6d716994847aaef667ca45b2cede550c92d336ff29ce6effd933b875f81381cda6e59e9727e728a58c0b3e74035beeeb639ab7463744322bf40138b81895e9a8e8850c9513782dc7a79f04380c216cb177951d8940d576486b887a232fcd382adcbd639e70af0c1a08bcf1405496606fce4645aef10d769dc0c010a8a433d8cd24d5943843a89cdbc8d16531db027b312ab2c03a7f1fdb7f2bcb128639c49e86705c948137fd42d0080fda4be4e9ee812057c7974acbf0162730d3b647b355ac1a5adbb2993832eba443b7c9b5a0ae1fc00a6c0c2b0b65b9019690565739d6439bf602066a3a9bd9c67b83606de51792d25ae517cbbdf6e1827fa0e8b2b5c6023cbb1e9f0e10b786dc6fa154e282fd9c90b8d46ca685d0f4434760035073c92d131564b6845ef57457488add4f709073bbb41f5f31f8226904875a9fd9e1b7a2901e71426104d7a298a05af0d4ab549fbd69c539ebe64949a9b6088f16e2e4bc827c305cb8d64536b8364dc3d5f7519c3b431faa38b47a958cf0c6dcabf205280693abf747c262f44cd6ffa11b32fc38d4f9c3631d554d8b57389f1390ac65c06357843ee6d9f289bb054ef25de45c5149c090fe6ddcd4095696dcc9a5cfc09c8bdfd5b83a153'),
diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py
index 55a54d2..31636e2 100644
--- a/tests/test_onion_message.py
+++ b/tests/test_onion_message.py
@@ -2,8 +2,10 @@ import asyncio
import io
import os
import time
-from functools import partial
+import dataclasses
import logging
+from functools import partial
+from types import MappingProxyType
import electrum_ecc as ecc
from electrum_ecc import ECPrivkey
@@ -72,34 +74,34 @@ class TestOnionMessage(ElectrumTestCase):
hops_data = [
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={
+ blind_fields=MappingProxyType({
'next_node_id': {'node_id': bfh(ALICE_TLVS['next_node_id'])},
'next_path_key_override': {'path_key': bfh(ALICE_TLVS['next_path_key_override'])},
}
- ),
+ )),
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={
+ blind_fields=MappingProxyType({
'next_node_id': {'node_id': bfh(BOB_TLVS['next_node_id'])},
'unknown_tag_561': {'data': bfh(BOB_TLVS['unknown_tag_561'])},
}
- ),
+ )),
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={
+ blind_fields=MappingProxyType({
'padding': {'padding': bfh(CAROL_TLVS['padding'])},
'next_node_id': {'node_id': bfh(CAROL_TLVS['next_node_id'])},
}
- ),
+ )),
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- payload={'message': {'text': bfh(test_vectors['onionmessage']['unknown_tag_1'])}},
- blind_fields={
+ payload=MappingProxyType({'message': {'text': bfh(test_vectors['onionmessage']['unknown_tag_1'])}}),
+ blind_fields=MappingProxyType({
'padding': {'padding': bfh(DAVE_TLVS['padding'])},
'path_id': {'data': bfh(DAVE_TLVS['path_id'])},
'unknown_tag_65535': {'data': bfh(DAVE_TLVS['unknown_tag_65535'])},
}
- )
+ ))
]
encrypt_onionmsg_tlv_hops_data(hops_data, hop_shared_secrets)
@@ -117,11 +119,11 @@ class TestOnionMessage(ElectrumTestCase):
return [
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- payload={'message': {'text': message.encode('utf-8')}},
- blind_fields={
+ payload=MappingProxyType({'message': {'text': message.encode('utf-8')}}),
+ blind_fields=MappingProxyType({
'path_id': {'data': bfh('deadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0')},
}
- )
+ ))
]
hops_data = hops_data_for_message('short_message') # fit in HOPS_DATA_SIZE
encrypt_onionmsg_tlv_hops_data(hops_data, hop_shared_secrets)
@@ -232,16 +234,18 @@ class TestOnionMessage(ElectrumTestCase):
hops_data = [
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={
+ blind_fields=MappingProxyType({
'next_node_id': {'node_id': BOB_PUBKEY},
'next_path_key_override': {'path_key': bfh(ALICE_TLVS['next_path_key_override'])},
}
- ),
+ )),
]
# encrypt encrypted_data_tlv here
for i in range(len(hops_data)):
encrypted_recipient_data = encrypt_onionmsg_data_tlv(shared_secret=hop_shared_secrets[i], **hops_data[i].blind_fields)
- hops_data[i].payload['encrypted_recipient_data'] = {'encrypted_recipient_data': encrypted_recipient_data}
+ new_payload = dict(hops_data[i].payload)
+ new_payload['encrypted_recipient_data'] = {'encrypted_recipient_data': encrypted_recipient_data}
+ hops_data[i] = dataclasses.replace(hops_data[i], payload=MappingProxyType(new_payload))
blinded_path_blinded_ids = []
for i, x in enumerate(blinded_path_to_dave.get('path')):
@@ -253,7 +257,7 @@ class TestOnionMessage(ElectrumTestCase):
hops_data.append(
OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- payload=payload)
+ payload=MappingProxyType(payload))
)
payment_path_pubkeys = blinded_node_ids + blinded_path_blinded_ids
hop_shared_secrets, _ = get_shared_secrets_along_route(payment_path_pubkeys, SESSION_KEY)
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.