Skip to content

Commit 01babc8

Browse files
syjn99tcoratger
andauthored
fix(networking): update message-id calculation (leanEthereum#17)
* fix(networking): update message-id calculation * Update gossipsub.py Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> * Update gossipsub.py Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> * Make ruff happy --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com>
1 parent 0ce74b9 commit 01babc8

2 files changed

Lines changed: 118 additions & 14 deletions

File tree

docs/client/networking.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,24 +101,20 @@ The optional `from` (1), `seqno` (3), `signature` (5) and `key` (6) protobuf
101101
fields are omitted from the message, since messages are identified by content,
102102
anonymous, and signed where necessary in the application layer.
103103

104-
The `message-id` of a gossipsub message MUST be the following 20 byte value
105-
computed from the message data:
104+
The `message-id` MUST be the following 20 byte value computed from the message:
106105

107-
- If `message.data` has a valid snappy decompression, set `message-id` to the
108-
first 20 bytes of the `SHA256` hash of the concatenation of
109-
`MESSAGE_DOMAIN_VALID_SNAPPY` with the snappy decompressed message data, i.e.
110-
`SHA256(MESSAGE_DOMAIN_VALID_SNAPPY + snappy_decompress(message.data))[:20]`.
111-
- Otherwise, set `message-id` to the first 20 bytes of the `SHA256` hash of the
112-
concatenation of `MESSAGE_DOMAIN_INVALID_SNAPPY` with the raw message data,
113-
i.e. `SHA256(MESSAGE_DOMAIN_INVALID_SNAPPY + message.data)[:20]`.
106+
- If `message.data` has a valid snappy decompression, set `message-id` to the first 20 bytes of the `SHA256` hash of
107+
the concatenation of the following data: `MESSAGE_DOMAIN_VALID_SNAPPY`, the length of the topic byte string (encoded as little-endian `uint64`),
108+
the topic byte string, and the snappy decompressed message data:
109+
i.e. `SHA256(MESSAGE_DOMAIN_VALID_SNAPPY + uint_to_bytes(uint64(len(message.topic))) + message.topic + snappy_decompress(message.data))[:20]`.
110+
- Otherwise, set `message-id` to the first 20 bytes of the `SHA256` hash of
111+
the concatenation of the following data: `MESSAGE_DOMAIN_INVALID_SNAPPY`, the length of the topic byte string (encoded as little-endian `uint64`),
112+
the topic byte string, and the raw message data:
113+
i.e. `SHA256(MESSAGE_DOMAIN_INVALID_SNAPPY + uint_to_bytes(uint64(len(message.topic))) + message.topic + message.data)[:20]`.
114114

115115
Where relevant, clients MUST reject messages with `message-id` sizes other than
116116
20 bytes.
117117

118-
*Note*: The above logic handles two exceptional cases: (1) multiple snappy
119-
`data` can decompress to the same value, and (2) some message `data` can fail to
120-
snappy decompress altogether.
121-
122118
The payload is carried in the `data` field of a gossipsub message, and varies
123119
depending on the topic:
124120

src/lean_spec/subspecs/networking/gossipsub.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
1-
"""Gossipsub protocol parameters."""
1+
"""
2+
Gossipsub protocol
3+
4+
- Parameters for gossipsub operation.
5+
- Message ID computation based on topic and message data, with snappy decompression handling.
6+
"""
7+
8+
import hashlib
9+
from typing import Annotated, Callable, Optional
10+
11+
from pydantic import Field
212

313
from lean_spec.subspecs.chain.config import DEVNET_CONFIG
14+
from lean_spec.subspecs.networking.config import (
15+
MESSAGE_DOMAIN_INVALID_SNAPPY,
16+
MESSAGE_DOMAIN_VALID_SNAPPY,
17+
)
418
from lean_spec.types import StrictBaseModel
519

620

@@ -46,3 +60,97 @@ class GossipsubParameters(StrictBaseModel):
4660
4761
This is calculated as SECONDS_PER_SLOT * JUSTIFICATION_LOOKBACK_SLOTS * 2.
4862
"""
63+
64+
65+
MessageId = Annotated[bytes, Field(min_length=20, max_length=20)]
66+
"""A 20-byte ID for gossipsub messages."""
67+
68+
69+
class GossipsubMessage:
70+
"""
71+
Represents a gossipsub message and manages its ID computation.
72+
73+
This class encapsulates the topic, data, and the logic to generate a
74+
message ID, correctly handling snappy decompression. The generated ID is
75+
cached for efficiency.
76+
"""
77+
78+
def __init__(
79+
self,
80+
topic: bytes,
81+
data: bytes,
82+
snappy_decompress: Optional[Callable[[bytes], bytes]] = None,
83+
):
84+
"""
85+
Initializes the message.
86+
87+
Args:
88+
topic: The topic byte string.
89+
data: The raw message data.
90+
snappy_decompress: Optional snappy decompression function.
91+
"""
92+
self.topic: bytes = topic
93+
self.raw_data: bytes = data
94+
self._snappy_decompress = snappy_decompress
95+
# Cache for the computed ID
96+
self._id: Optional[MessageId] = None
97+
98+
@property
99+
def id(self) -> MessageId:
100+
"""
101+
Computes and returns the 20-byte message ID.
102+
103+
The ID is computed on first access and then cached. The computation
104+
logic depends on whether the message data can be successfully
105+
decompressed with snappy.
106+
"""
107+
# Return the cached ID if it's already been computed
108+
if self._id is not None:
109+
return self._id
110+
111+
domain: bytes
112+
data_for_hash: bytes
113+
114+
if self._snappy_decompress:
115+
try:
116+
# Try to decompress the data with snappy
117+
decompressed_data = self._snappy_decompress(self.raw_data)
118+
# Valid snappy decompression - use valid domain
119+
domain = MESSAGE_DOMAIN_VALID_SNAPPY
120+
data_for_hash = decompressed_data
121+
except Exception:
122+
# Invalid snappy decompression - use invalid domain
123+
domain = MESSAGE_DOMAIN_INVALID_SNAPPY
124+
data_for_hash = self.raw_data
125+
else:
126+
# No decompressor provided - use invalid domain
127+
domain = MESSAGE_DOMAIN_INVALID_SNAPPY
128+
data_for_hash = self.raw_data
129+
130+
# The internal computation returns the raw bytes...
131+
computed_id_bytes = self._compute_raw_id(domain, data_for_hash)
132+
133+
# We then cast to our strict NewType before caching and returning.
134+
self._id = MessageId(computed_id_bytes)
135+
return self._id
136+
137+
def _compute_raw_id(self, domain: bytes, message_data: bytes) -> bytes:
138+
"""
139+
Computes SHA256(domain + uint64_le(len(topic)) + topic + message_data)[:20].
140+
141+
Args:
142+
domain: The 4-byte domain for message-id isolation.
143+
message_data: The message data (either decompressed or raw).
144+
145+
Returns:
146+
A 20-byte raw bytes digest.
147+
"""
148+
# Encode the topic length as little-endian bytes
149+
topic_len_bytes = len(self.topic).to_bytes(8, "little")
150+
151+
# Concatenate all components for hashing
152+
data_to_hash = domain + topic_len_bytes + self.topic + message_data
153+
154+
# Compute SHA256 and take the first 20 bytes
155+
digest = hashlib.sha256(data_to_hash).digest()
156+
return digest[:20]

0 commit comments

Comments
 (0)