|
| 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) + ")" |
0 commit comments