Skip to content

Commit 566e1ad

Browse files
authored
networking: implement enr (leanEthereum#253)
* networking: new types and stronger typing * networking: implement enr * fmt * mv enr to support/
1 parent 65a25bb commit 566e1ad

8 files changed

Lines changed: 695 additions & 1 deletion

File tree

src/lean_spec/subspecs/networking/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
BlocksByRootResponse,
1616
Status,
1717
)
18-
from .types import DomainType, ProtocolId
18+
from .types import DomainType, ForkDigest, ProtocolId
1919

2020
__all__ = [
2121
"MAX_REQUEST_BLOCKS",
@@ -31,4 +31,5 @@
3131
"Status",
3232
"DomainType",
3333
"ProtocolId",
34+
"ForkDigest",
3435
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Supporting implementation code for networking."""
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Ethereum Node Records (EIP-778)
3+
4+
References:
5+
----------
6+
- EIP-778: https://eips.ethereum.org/EIPS/eip-778
7+
"""
8+
9+
from . import keys
10+
from .enr import ENR
11+
from .eth2 import FAR_FUTURE_EPOCH, AttestationSubnets, Eth2Data, SyncCommitteeSubnets
12+
from .keys import EnrKey
13+
14+
__all__ = [
15+
"ENR",
16+
"EnrKey",
17+
"keys",
18+
"Eth2Data",
19+
"AttestationSubnets",
20+
"SyncCommitteeSubnets",
21+
"FAR_FUTURE_EPOCH",
22+
]
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""
2+
Ethereum Node Record (EIP-778)
3+
==============================
4+
5+
ENR is an open format for p2p connectivity information that improves upon
6+
the node discovery v4 protocol by providing:
7+
8+
1. **Flexibility**: Arbitrary key/value pairs for any transport protocol
9+
2. **Cryptographic Agility**: Support for multiple identity schemes
10+
3. **Authoritative Updates**: Sequence numbers to determine record freshness
11+
12+
Record Structure
13+
----------------
14+
15+
An ENR is an RLP-encoded list::
16+
17+
record = [signature, seq, k1, v1, k2, v2, ...]
18+
19+
Where:
20+
- `signature`: 64-byte secp256k1 signature (r || s, no recovery id)
21+
- `seq`: 64-bit sequence number (increases on each update)
22+
- `k, v`: Sorted key/value pairs (keys are lexicographically ordered)
23+
24+
The signature covers the content `[seq, k1, v1, k2, v2, ...]` (excluding itself).
25+
26+
Size Limit
27+
----------
28+
29+
Maximum encoded size is **300 bytes**. This ensures ENRs fit in a single
30+
UDP packet and can be included in size-constrained protocols like DNS.
31+
32+
Text Encoding
33+
-------------
34+
35+
Text form is URL-safe base64 with `enr:` prefix::
36+
37+
enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjz...
38+
39+
"v4" Identity Scheme
40+
--------------------
41+
42+
The default scheme uses secp256k1:
43+
- **Sign**: keccak256(content), then secp256k1 signature
44+
- **Verify**: Check signature against `secp256k1` key in record
45+
- **Node ID**: keccak256(uncompressed_public_key)
46+
47+
References:
48+
----------
49+
- EIP-778: https://eips.ethereum.org/EIPS/eip-778
50+
"""
51+
52+
from typing import ClassVar, Optional
53+
54+
from lean_spec.subspecs.networking.types import Multiaddr, NodeId, SeqNumber
55+
from lean_spec.types import StrictBaseModel
56+
57+
from . import keys
58+
from .eth2 import AttestationSubnets, Eth2Data
59+
from .keys import EnrKey
60+
61+
62+
class ENR(StrictBaseModel):
63+
r"""
64+
Ethereum Node Record (EIP-778).
65+
66+
Example from EIP-778 (IPv4 127.0.0.1, UDP 30303)::
67+
68+
enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04j...
69+
70+
Which decodes to RLP::
71+
72+
[
73+
7098ad865b00a582..., # signature (64 bytes)
74+
01, # seq = 1
75+
"id", "v4",
76+
"ip", 7f000001, # 127.0.0.1
77+
"secp256k1", 03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138,
78+
"udp", 765f, # 30303
79+
]
80+
"""
81+
82+
MAX_SIZE: ClassVar[int] = 300
83+
"""Maximum RLP-encoded size in bytes (EIP-778)."""
84+
85+
SCHEME: ClassVar[str] = "v4"
86+
"""Supported identity scheme."""
87+
88+
signature: bytes
89+
"""64-byte secp256k1 signature (r || s concatenated, no recovery id)."""
90+
91+
seq: SeqNumber
92+
"""Sequence number. MUST increase on any record change."""
93+
94+
pairs: dict[EnrKey, bytes]
95+
"""Key/value pairs. Keys must be unique and sorted lexicographically."""
96+
97+
node_id: Optional[NodeId] = None
98+
"""32-byte node ID derived from public key via keccak256."""
99+
100+
def get(self, key: EnrKey) -> Optional[bytes]:
101+
"""Get value by key, or None if absent."""
102+
return self.pairs.get(key)
103+
104+
def has(self, key: EnrKey) -> bool:
105+
"""Check if key is present."""
106+
return key in self.pairs
107+
108+
@property
109+
def identity_scheme(self) -> Optional[str]:
110+
"""Get identity scheme (should be "v4")."""
111+
id_bytes = self.get(keys.ID)
112+
return id_bytes.decode("utf-8") if id_bytes else None
113+
114+
@property
115+
def public_key(self) -> Optional[bytes]:
116+
"""Get compressed secp256k1 public key (33 bytes)."""
117+
return self.get(keys.SECP256K1)
118+
119+
@property
120+
def ip4(self) -> Optional[str]:
121+
"""IPv4 address as dotted string (e.g., "127.0.0.1")."""
122+
ip_bytes = self.get(keys.IP)
123+
return ".".join(str(b) for b in ip_bytes) if ip_bytes and len(ip_bytes) == 4 else None
124+
125+
@property
126+
def ip6(self) -> Optional[str]:
127+
"""IPv6 address as colon-separated hex."""
128+
ip_bytes = self.get(keys.IP6)
129+
if ip_bytes and len(ip_bytes) == 16:
130+
return ":".join(ip_bytes[i : i + 2].hex() for i in range(0, 16, 2))
131+
return None
132+
133+
@property
134+
def tcp_port(self) -> Optional[int]:
135+
"""TCP port (applies to both IPv4 and IPv6 unless tcp6 is set)."""
136+
port = self.get(keys.TCP)
137+
return int.from_bytes(port, "big") if port else None
138+
139+
@property
140+
def udp_port(self) -> Optional[int]:
141+
"""UDP port for discovery (applies to both unless udp6 is set)."""
142+
port = self.get(keys.UDP)
143+
return int.from_bytes(port, "big") if port else None
144+
145+
def multiaddr(self) -> Optional[Multiaddr]:
146+
"""Construct multiaddress from endpoint info."""
147+
if self.ip4 and self.tcp_port:
148+
return f"/ip4/{self.ip4}/tcp/{self.tcp_port}"
149+
if self.ip6 and self.tcp_port:
150+
return f"/ip6/{self.ip6}/tcp/{self.tcp_port}"
151+
return None
152+
153+
# =========================================================================
154+
# Ethereum Consensus Extensions
155+
# =========================================================================
156+
157+
@property
158+
def eth2_data(self) -> Optional[Eth2Data]:
159+
"""Parse eth2 key: fork_digest(4) + next_fork_version(4) + next_fork_epoch(8)."""
160+
eth2_bytes = self.get(keys.ETH2)
161+
if eth2_bytes and len(eth2_bytes) >= 16:
162+
from lean_spec.types import Uint64
163+
from lean_spec.types.byte_arrays import Bytes4
164+
165+
return Eth2Data(
166+
fork_digest=Bytes4(eth2_bytes[0:4]),
167+
next_fork_version=Bytes4(eth2_bytes[4:8]),
168+
next_fork_epoch=Uint64(int.from_bytes(eth2_bytes[8:16], "little")),
169+
)
170+
return None
171+
172+
@property
173+
def attestation_subnets(self) -> Optional[AttestationSubnets]:
174+
"""Parse attnets key (SSZ Bitvector[64])."""
175+
attnets = self.get(keys.ATTNETS)
176+
return AttestationSubnets.decode_bytes(attnets) if attnets and len(attnets) == 8 else None
177+
178+
# =========================================================================
179+
# Validation
180+
# =========================================================================
181+
182+
def is_valid(self) -> bool:
183+
"""
184+
Check structural validity (does NOT verify cryptographic signature).
185+
186+
A valid ENR has:
187+
- Identity scheme "v4"
188+
- 33-byte compressed secp256k1 public key
189+
- 64-byte signature
190+
"""
191+
return (
192+
self.identity_scheme == self.SCHEME
193+
and self.public_key is not None
194+
and len(self.public_key) == 33
195+
and len(self.signature) == 64
196+
)
197+
198+
def is_compatible_with(self, other: "ENR") -> bool:
199+
"""Check fork compatibility via eth2 fork digest."""
200+
self_eth2, other_eth2 = self.eth2_data, other.eth2_data
201+
if self_eth2 is None or other_eth2 is None:
202+
return False
203+
return self_eth2.fork_digest == other_eth2.fork_digest
204+
205+
# =========================================================================
206+
# Display
207+
# =========================================================================
208+
209+
def __str__(self) -> str:
210+
"""Human-readable summary."""
211+
parts = [f"ENR(seq={self.seq}"]
212+
if self.ip4:
213+
parts.append(f"ip={self.ip4}")
214+
if self.tcp_port:
215+
parts.append(f"tcp={self.tcp_port}")
216+
if self.udp_port:
217+
parts.append(f"udp={self.udp_port}")
218+
if eth2 := self.eth2_data:
219+
parts.append(f"fork={eth2.fork_digest.hex()}")
220+
return ", ".join(parts) + ")"
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
Ethereum Consensus ENR Extensions
3+
=================================
4+
5+
Ethereum consensus clients extend ENR with additional keys for fork
6+
compatibility and subnet discovery.
7+
8+
eth2 Key Structure
9+
------------------
10+
11+
The `eth2` key contains 16 bytes::
12+
13+
fork_digest (4 bytes) - Current fork identifier
14+
next_fork_version (4 bytes) - Version of next scheduled fork
15+
next_fork_epoch (8 bytes) - Epoch when next fork activates (little-endian)
16+
17+
attnets / syncnets
18+
------------------
19+
20+
SSZ Bitvectors indicating subnet subscriptions:
21+
- attnets: Bitvector[64] - attestation subnets (bit i = subscribed to subnet i)
22+
- syncnets: Bitvector[4] - sync committee subnets
23+
24+
See: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md
25+
"""
26+
27+
from typing import ClassVar
28+
29+
from lean_spec.subspecs.networking.types import ForkDigest
30+
from lean_spec.types import StrictBaseModel, Uint64
31+
from lean_spec.types.bitfields import BaseBitvector
32+
from lean_spec.types.boolean import Boolean
33+
34+
FAR_FUTURE_EPOCH = Uint64(2**64 - 1)
35+
"""Sentinel value indicating no scheduled fork."""
36+
37+
38+
class Eth2Data(StrictBaseModel):
39+
"""
40+
Ethereum consensus data stored in ENR `eth2` key (16 bytes).
41+
42+
SSZ: fork_digest (4) + next_fork_version (4) + next_fork_epoch (8)
43+
"""
44+
45+
fork_digest: ForkDigest
46+
"""Current active fork identifier (4 bytes)."""
47+
48+
next_fork_version: ForkDigest
49+
"""Fork version of next scheduled fork. Equals current if none scheduled."""
50+
51+
next_fork_epoch: Uint64
52+
"""Epoch when next fork activates. FAR_FUTURE_EPOCH if none scheduled."""
53+
54+
@classmethod
55+
def no_scheduled_fork(cls, current_digest: ForkDigest) -> "Eth2Data":
56+
"""Create Eth2Data with no scheduled fork."""
57+
return cls(
58+
fork_digest=current_digest,
59+
next_fork_version=current_digest,
60+
next_fork_epoch=FAR_FUTURE_EPOCH,
61+
)
62+
63+
64+
class AttestationSubnets(BaseBitvector):
65+
"""
66+
Attestation subnet subscriptions (ENR `attnets` key).
67+
68+
SSZ Bitvector[64] where bit i indicates subscription to subnet i.
69+
"""
70+
71+
LENGTH: ClassVar[int] = 64
72+
"""64 attestation subnets."""
73+
74+
@classmethod
75+
def none(cls) -> "AttestationSubnets":
76+
"""No subscriptions."""
77+
return cls(data=[Boolean(False)] * 64)
78+
79+
@classmethod
80+
def all(cls) -> "AttestationSubnets":
81+
"""Subscribe to all 64 subnets."""
82+
return cls(data=[Boolean(True)] * 64)
83+
84+
@classmethod
85+
def from_subnet_ids(cls, subnet_ids: list[int]) -> "AttestationSubnets":
86+
"""Subscribe to specific subnets."""
87+
bits = [Boolean(False)] * 64
88+
for sid in subnet_ids:
89+
if not 0 <= sid < 64:
90+
raise ValueError(f"Subnet ID must be 0-63, got {sid}")
91+
bits[sid] = Boolean(True)
92+
return cls(data=bits)
93+
94+
def is_subscribed(self, subnet_id: int) -> bool:
95+
"""Check if subscribed to a subnet."""
96+
if not 0 <= subnet_id < 64:
97+
raise ValueError(f"Subnet ID must be 0-63, got {subnet_id}")
98+
return bool(self.data[subnet_id])
99+
100+
def subscribed_subnets(self) -> list[int]:
101+
"""List of subscribed subnet IDs."""
102+
return [i for i in range(64) if self.data[i]]
103+
104+
def subscription_count(self) -> int:
105+
"""Number of subscribed subnets."""
106+
return sum(1 for b in self.data if b)
107+
108+
109+
class SyncCommitteeSubnets(BaseBitvector):
110+
"""
111+
Sync committee subnet subscriptions (ENR `syncnets` key).
112+
113+
SSZ Bitvector[4] where bit i indicates subscription to sync subnet i.
114+
"""
115+
116+
LENGTH: ClassVar[int] = 4
117+
"""4 sync committee subnets."""
118+
119+
@classmethod
120+
def none(cls) -> "SyncCommitteeSubnets":
121+
"""No subscriptions."""
122+
return cls(data=[Boolean(False)] * 4)
123+
124+
@classmethod
125+
def all(cls) -> "SyncCommitteeSubnets":
126+
"""Subscribe to all 4 subnets."""
127+
return cls(data=[Boolean(True)] * 4)
128+
129+
def is_subscribed(self, subnet_id: int) -> bool:
130+
"""Check if subscribed to a sync subnet."""
131+
if not 0 <= subnet_id < 4:
132+
raise ValueError(f"Sync subnet ID must be 0-3, got {subnet_id}")
133+
return bool(self.data[subnet_id])

0 commit comments

Comments
 (0)