From 818f54ae47a90ab67cb8c71bd9dc30a1b5b7c772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:02:51 -0300 Subject: [PATCH 1/6] feat(p2p): add opt-in discv5 peer discovery Lean nodes could only meet through a static bootnode list, so every new node needed an operator to hand it peers. This wires ethrex's discv5 stack in behind `--discovery.enable`: the node builds and signs its own ENR, joins the DHT on its own UDP socket, and dials what it finds over libp2p QUIC. Static bootnode dialing is untouched and discovery is off by default, so nothing changes for an operator who does not ask for it. Admission follows the beacon phase0 p2p spec, mirroring lighthouse's `eth2_fork_predicate`: the `eth2` fork digest must match, a differing `next_fork_version`/`next_fork_epoch` is explicitly tolerated, and the peer must advertise a `quic` port. The checks live in a `LeanFilter` that ethrex's peer table runs as each ENR arrives, so a record is judged where it lands rather than at dial time, and is judged afresh whenever the peer publishes a higher-`seq` record. Survivors are ranked by how many attestation subnets they cover that no connected peer does, so discovery fills subnet gaps first. A peer's `attnets` is self-reported, so subnet ids at or beyond the local committee count are dropped before ranking sees them. `ethrex-p2p` is pinned to the unmerged `feat/discovery-peer-requirements` branch, which carries the unified `DiscoveryServer`, the peer table, and the `PeerFilter` seam. Repoint it at a main revision once that merges. Known gap: `DiscoveryServer::spawn` builds its own local record and offers no way to seed the consensus entries, so the ENR ethrex answers queries with carries `ip`/`udp`/`secp256k1` but not `eth2`, `attnets` or `quic`. Discovery is one-sided until `spawn` can take a prepared record: we find and admit lean peers, but a lean peer applying these same rules to what ethrex serves would refuse us. See `docs/discovery.md`. --- CLAUDE.md | 7 + Cargo.lock | 1352 +++++++++------------ bin/ethlambda/Cargo.toml | 4 + bin/ethlambda/src/cli.rs | 46 + bin/ethlambda/src/main.rs | 59 +- crates/common/types/src/enr.rs | 154 +++ crates/common/types/src/lib.rs | 1 + crates/net/p2p/Cargo.toml | 21 +- crates/net/p2p/src/discovery/admission.rs | 540 ++++++++ crates/net/p2p/src/discovery/enr.rs | 178 +++ crates/net/p2p/src/discovery/mod.rs | 278 +++++ crates/net/p2p/src/lib.rs | 442 ++++++- crates/net/p2p/src/metrics.rs | 14 + crates/net/p2p/src/req_resp/mod.rs | 9 +- crates/net/rpc/src/lib.rs | 25 +- crates/net/rpc/src/node.rs | 72 +- docs/SUMMARY.md | 1 + docs/discovery.md | 177 +++ 18 files changed, 2486 insertions(+), 894 deletions(-) create mode 100644 crates/common/types/src/enr.rs create mode 100644 crates/net/p2p/src/discovery/admission.rs create mode 100644 crates/net/p2p/src/discovery/enr.rs create mode 100644 crates/net/p2p/src/discovery/mod.rs create mode 100644 docs/discovery.md diff --git a/CLAUDE.md b/CLAUDE.md index c593c5e7..8e8b5c76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,6 +280,13 @@ actual_slot = finalized_slot + 1 + relative_index - Mesh size: 8 (6-12 bounds), heartbeat: 700ms - **Req/Resp**: Status, BlocksByRoot, BlocksByRange (snappy frame compression + varint length) +### Peer Discovery (discv5, opt-in) +- Off by default; `--discovery.enable` plus `--discovery.port` (own UDP socket, must differ from `--gossipsub-port`) +- Reuses ethrex's `DiscoveryServer` + `PeerTable` with discv4 disabled and an empty in-memory ethrex `Store` (`spawn` requires one; lean has no execution chain) +- ENR follows the beacon phase0 spec: `ip`/`udp`/`quic`/`secp256k1`/`eth2`/`attnets`, deliberately **no** `tcp` +- Admission mirrors lighthouse: `eth2.fork_digest` must match, `next_fork_*` may differ, `quic` entry required. Handed to the peer table as `LeanFilter: PeerFilter`, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-`seq` ENR +- Candidates ranked by uncovered attestation subnets. See [`docs/discovery.md`](docs/discovery.md) + ### Retry Strategy on Block Requests - Exponential backoff: doubling from `INITIAL_BACKOFF_MS` (5ms → 2560ms) - Max `MAX_FETCH_RETRIES` (10) attempts, random peer selection on retry diff --git a/Cargo.lock b/Cargo.lock index 00958308..da15d789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,17 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addchain" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e33f6a175ec6a9e0aca777567f9ff7c3deefc255660df887e7fa3585e9801d8" -dependencies = [ - "num-bigint 0.3.3", - "num-integer", - "num-traits", -] - [[package]] name = "addr2line" version = "0.25.1" @@ -44,7 +33,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cipher", "cpufeatures 0.2.17", ] @@ -69,7 +58,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.3.4", "once_cell", "version_check", @@ -99,12 +88,12 @@ checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "alloy-rlp", "bytes", - "cfg-if 1.0.4", + "cfg-if", "const-hex", "derive_more 2.1.1", "foldhash 0.2.0", "hashbrown 0.17.1", - "indexmap", + "indexmap 2.14.0", "itoa", "k256", "keccak-asm", @@ -129,6 +118,15 @@ dependencies = [ "bytes", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -220,7 +218,7 @@ dependencies = [ "fnv", "hashbrown 0.15.5", "itertools 0.13.0", - "num-bigint 0.4.6", + "num-bigint", "num-integer", "num-traits", "zeroize", @@ -237,7 +235,7 @@ dependencies = [ "ark-serialize 0.3.0", "ark-std 0.3.0", "derivative", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "paste", "rustc_version 0.3.3", @@ -257,7 +255,7 @@ dependencies = [ "derivative", "digest 0.10.7", "itertools 0.10.5", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "paste", "rustc_version 0.4.1", @@ -278,7 +276,7 @@ dependencies = [ "digest 0.10.7", "educe", "itertools 0.13.0", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "paste", "zeroize", @@ -320,7 +318,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "num-bigint 0.4.6", + "num-bigint", "num-traits", "quote", "syn 1.0.109", @@ -332,7 +330,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "num-bigint 0.4.6", + "num-bigint", "num-traits", "proc-macro2", "quote", @@ -345,7 +343,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "num-bigint 0.4.6", + "num-bigint", "num-traits", "proc-macro2", "quote", @@ -385,7 +383,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-std 0.4.0", "digest 0.10.7", - "num-bigint 0.4.6", + "num-bigint", ] [[package]] @@ -398,7 +396,7 @@ dependencies = [ "ark-std 0.5.0", "arrayvec", "digest 0.10.7", - "num-bigint 0.4.6", + "num-bigint", ] [[package]] @@ -518,7 +516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ "autocfg", - "cfg-if 1.0.4", + "cfg-if", "concurrent-queue", "futures-io", "futures-lite", @@ -675,7 +673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if 1.0.4", + "cfg-if", "libc", "miniz_oxide", "object", @@ -723,15 +721,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -741,7 +730,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -808,20 +797,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if 1.0.4", - "constant_time_eq", - "cpufeatures 0.3.0", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -849,19 +824,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "bls12_381" -version = "0.8.0" -source = "git+https://github.com/lambdaclass/bls12_381?branch=expose-fp-struct#219174187bd78154cec35b0809799fc2c991a579" -dependencies = [ - "digest 0.10.7", - "ff", - "group", - "pairing", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "blst" version = "0.3.16" @@ -951,9 +913,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.7" +version = "2.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" dependencies = [ "blst", "cc", @@ -1000,12 +962,6 @@ dependencies = [ "nom", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.4" @@ -1024,7 +980,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cipher", "cpufeatures 0.2.17", ] @@ -1035,7 +991,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1053,6 +1009,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1145,7 +1113,7 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "crossbeam-utils 0.8.21", + "crossbeam-utils", ] [[package]] @@ -1154,7 +1122,7 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "proptest", "serde_core", @@ -1199,12 +1167,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "convert_case" version = "0.6.0" @@ -1263,7 +1225,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -1272,41 +1234,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69323bff1fb41c635347b8ead484a5ca6c3f11914d784170b158d8449ab07f8e" -dependencies = [ - "cfg-if 0.1.10", - "crossbeam-channel 0.4.4", - "crossbeam-deque 0.7.4", - "crossbeam-epoch 0.8.2", - "crossbeam-queue 0.2.3", - "crossbeam-utils 0.7.2", -] - [[package]] name = "crossbeam" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" dependencies = [ - "crossbeam-channel 0.5.15", - "crossbeam-deque 0.8.6", - "crossbeam-epoch 0.9.18", - "crossbeam-queue 0.3.12", - "crossbeam-utils 0.8.21", -] - -[[package]] -name = "crossbeam-channel" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b153fe7cbef478c567df0f972e02e6d736db11affe43dfc9c56a9374d1adfb87" -dependencies = [ - "crossbeam-utils 0.7.2", - "maybe-uninit", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", ] [[package]] @@ -1315,18 +1253,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "crossbeam-utils 0.8.21", -] - -[[package]] -name = "crossbeam-deque" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20ff29ded3204c5106278a81a38f4b482636ed4fa1e6cfbeef193291beb29ed" -dependencies = [ - "crossbeam-epoch 0.8.2", - "crossbeam-utils 0.7.2", - "maybe-uninit", + "crossbeam-utils", ] [[package]] @@ -1335,23 +1262,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ - "crossbeam-epoch 0.9.18", - "crossbeam-utils 0.8.21", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" -dependencies = [ - "autocfg", - "cfg-if 0.1.10", - "crossbeam-utils 0.7.2", - "lazy_static", - "maybe-uninit", - "memoffset", - "scopeguard", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] @@ -1360,18 +1272,7 @@ version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "crossbeam-utils 0.8.21", -] - -[[package]] -name = "crossbeam-queue" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570" -dependencies = [ - "cfg-if 0.1.10", - "crossbeam-utils 0.7.2", - "maybe-uninit", + "crossbeam-utils", ] [[package]] @@ -1380,18 +1281,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ - "crossbeam-utils 0.8.21", -] - -[[package]] -name = "crossbeam-utils" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" -dependencies = [ - "autocfg", - "cfg-if 0.1.10", - "lazy_static", + "crossbeam-utils", ] [[package]] @@ -1475,7 +1365,7 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", @@ -1502,8 +1392,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1520,13 +1420,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1537,8 +1461,8 @@ version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if 1.0.4", - "crossbeam-utils 0.8.21", + "cfg-if", + "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -1571,18 +1495,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "datatest-stable" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833306ca7eec4d95844e65f0d7502db43888c5c1006c6c517e8cf51a27d15431" -dependencies = [ - "camino", - "fancy-regex", - "libtest-mimic", - "walkdir", -] - [[package]] name = "datatest-stable" version = "0.3.3" @@ -1615,7 +1527,7 @@ dependencies = [ "asn1-rs", "displaydoc", "nom", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "rusticata-macros", ] @@ -1627,6 +1539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1655,7 +1568,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.117", @@ -1839,12 +1752,6 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" -[[package]] -name = "elf" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" - [[package]] name = "elliptic-curve" version = "0.13.8" @@ -2020,9 +1927,10 @@ dependencies = [ "eyre", "hex", "libc", - "libssz", - "libssz-types", + "libssz 0.3.0", + "libssz-types 0.3.0", "reqwest", + "secp256k1 0.30.0", "serde", "serde_yaml_ng", "thiserror 2.0.18", @@ -2038,7 +1946,7 @@ dependencies = [ name = "ethlambda-blockchain" version = "0.1.0" dependencies = [ - "datatest-stable 0.3.3", + "datatest-stable", "ethlambda-crypto", "ethlambda-fork-choice", "ethlambda-metrics", @@ -2049,12 +1957,12 @@ dependencies = [ "ethlambda-types", "hex", "leansig", - "libssz", - "libssz-types", + "libssz 0.3.0", + "libssz-types 0.3.0", "rand 0.10.1", "rayon", "serde", - "spawned-concurrency 0.5.0", + "spawned-concurrency", "thiserror 2.0.18", "tokio", "tokio-util", @@ -2094,13 +2002,14 @@ name = "ethlambda-network-api" version = "0.1.0" dependencies = [ "ethlambda-types", - "spawned-concurrency 0.5.0", + "spawned-concurrency", ] [[package]] name = "ethlambda-p2p" version = "0.1.0" dependencies = [ + "bytes", "ethlambda-metrics", "ethlambda-network-api", "ethlambda-storage", @@ -2108,17 +2017,19 @@ dependencies = [ "ethrex-common", "ethrex-p2p", "ethrex-rlp", + "ethrex-storage", "futures", "hex", "libp2p", - "libssz", - "libssz-derive", - "libssz-merkle", - "libssz-types", + "libssz 0.3.0", + "libssz-derive 0.3.0", + "libssz-merkle 0.3.0", + "libssz-types 0.3.0", "rand 0.8.6", + "secp256k1 0.30.0", "sha2", "snap", - "spawned-concurrency 0.5.0", + "spawned-concurrency", "tokio", "tokio-stream", "tracing", @@ -2140,7 +2051,7 @@ dependencies = [ "hex", "http-body-util", "jemalloc_pprof", - "libssz", + "libssz 0.3.0", "serde", "serde_json", "tokio", @@ -2153,12 +2064,12 @@ dependencies = [ name = "ethlambda-state-transition" version = "0.1.0" dependencies = [ - "datatest-stable 0.3.3", + "datatest-stable", "ethlambda-metrics", "ethlambda-test-fixtures", "ethlambda-types", "hex", - "libssz-types", + "libssz-types 0.3.0", "serde", "serde_json", "thiserror 2.0.18", @@ -2172,9 +2083,9 @@ dependencies = [ "ethlambda-crypto", "ethlambda-types", "leansig", - "libssz", - "libssz-derive", - "libssz-types", + "libssz 0.3.0", + "libssz-derive 0.3.0", + "libssz-types 0.3.0", "lru", "rand 0.10.1", "rocksdb", @@ -2190,8 +2101,8 @@ dependencies = [ "ethlambda-state-transition", "ethlambda-types", "hex", - "libssz", - "libssz-types", + "libssz 0.3.0", + "libssz-types 0.3.0", "serde", "serde_json", ] @@ -2200,13 +2111,13 @@ dependencies = [ name = "ethlambda-types" version = "0.1.0" dependencies = [ - "datatest-stable 0.3.3", + "datatest-stable", "ethlambda-test-fixtures", "hex", - "libssz", - "libssz-derive", - "libssz-merkle", - "libssz-types", + "libssz 0.3.0", + "libssz-derive 0.3.0", + "libssz-merkle 0.3.0", + "libssz-types 0.3.0", "rand 0.10.1", "serde", "serde_json", @@ -2216,18 +2127,22 @@ dependencies = [ [[package]] name = "ethrex-blockchain" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "bytes", + "crossbeam", "ethrex-common", "ethrex-crypto", + "ethrex-guest-program", "ethrex-metrics", "ethrex-rlp", "ethrex-storage", "ethrex-trie", "ethrex-vm", - "hex", + "libssz 0.2.2", + "libssz-merkle 0.2.2", + "rayon", "rustc-hash", "thiserror 2.0.18", "tokio", @@ -2237,8 +2152,8 @@ dependencies = [ [[package]] name = "ethrex-common" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "bytes", "crc32fast", @@ -2248,10 +2163,15 @@ dependencies = [ "ethrex-trie", "hex", "hex-literal", - "k256", - "kzg-rs", + "hex-simd", + "indexmap 2.14.0", "lazy_static", "libc", + "libssz 0.2.2", + "libssz-derive 0.2.2", + "libssz-merkle 0.2.2", + "libssz-types 0.2.2", + "lru", "once_cell", "rayon", "rkyv", @@ -2260,60 +2180,91 @@ dependencies = [ "serde", "serde_json", "sha2", - "sha3 0.10.9", "thiserror 2.0.18", - "tinyvec", "tracing", - "url", ] [[package]] name = "ethrex-crypto" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ + "ark-bn254", + "ark-ec", + "ark-ff 0.5.0", "c-kzg", - "kzg-rs", + "ethereum-types", + "hex-literal", + "k256", + "malachite", + "num-bigint", + "p256", + "ripemd", + "secp256k1 0.30.0", + "sha2", "thiserror 2.0.18", "tiny-keccak", ] +[[package]] +name = "ethrex-guest-program" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +dependencies = [ + "bytes", + "ethereum-types", + "ethrex-common", + "ethrex-crypto", + "ethrex-l2-common", + "ethrex-rlp", + "ethrex-vm", + "hex", + "rkyv", + "serde", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "ethrex-l2-common" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +dependencies = [ + "bytes", + "ethereum-types", + "ethrex-common", + "ethrex-crypto", + "k256", + "lambdaworks-crypto", + "rkyv", + "serde", + "serde_with", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "ethrex-levm" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff 0.5.0", - "bitvec", - "bls12_381", "bytes", - "datatest-stable 0.2.10", "derive_more 1.0.0", "ethrex-common", "ethrex-crypto", "ethrex-rlp", - "k256", - "lambdaworks-math", - "lazy_static", + "libssz 0.2.2", "malachite", - "p256", - "ripemd", "rustc-hash", "serde", - "serde_json", - "sha2", - "sha3 0.10.9", "strum", "thiserror 2.0.18", - "walkdir", ] [[package]] name = "ethrex-metrics" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "ethrex-common", "serde", @@ -2324,14 +2275,14 @@ dependencies = [ [[package]] name = "ethrex-p2p" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "aes", - "async-trait", + "aes-gcm", "bytes", "concat-kdf", - "crossbeam 0.8.4", + "crossbeam", "ctr", "ethereum-types", "ethrex-blockchain", @@ -2339,24 +2290,24 @@ dependencies = [ "ethrex-crypto", "ethrex-rlp", "ethrex-storage", - "ethrex-threadpool", "ethrex-trie", "futures", "hex", + "hkdf", "hmac", - "indexmap", + "indexmap 2.14.0", "lazy_static", + "lru", "prometheus", "rand 0.8.6", "rayon", "rustc-hash", "secp256k1 0.30.0", "serde", - "serde_json", "sha2", "snap", - "spawned-concurrency 0.4.5", - "spawned-rt 0.4.5", + "spawned-concurrency", + "spawned-rt", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -2366,34 +2317,27 @@ dependencies = [ [[package]] name = "ethrex-rlp" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "bytes", "ethereum-types", - "hex", - "lazy_static", - "snap", "thiserror 2.0.18", - "tinyvec", ] [[package]] name = "ethrex-storage" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "anyhow", - "async-trait", "bytes", - "ethereum-types", "ethrex-common", "ethrex-crypto", "ethrex-rlp", "ethrex-trie", - "hex", + "fastbloom", "lru", - "qfilter", "rayon", "rustc-hash", "serde", @@ -2403,55 +2347,39 @@ dependencies = [ "tracing", ] -[[package]] -name = "ethrex-threadpool" -version = "0.1.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" -dependencies = [ - "crossbeam 0.8.4", -] - [[package]] name = "ethrex-trie" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ "anyhow", "bytes", - "crossbeam 0.8.4", - "digest 0.10.7", + "crossbeam", "ethereum-types", "ethrex-crypto", "ethrex-rlp", - "ethrex-threadpool", - "hex", - "lazy_static", + "hashbrown 0.15.5", + "rayon", "rkyv", "rustc-hash", "serde", - "serde_json", - "smallvec", + "spin 0.9.8", "thiserror 2.0.18", - "tracing", ] [[package]] name = "ethrex-vm" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "23.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" dependencies = [ - "bincode", "bytes", "derive_more 1.0.0", "dyn-clone", - "ethereum-types", "ethrex-common", "ethrex-crypto", "ethrex-levm", "ethrex-rlp", - "ethrex-trie", - "lazy_static", - "rkyv", + "rustc-hash", "serde", "thiserror 2.0.18", "tracing", @@ -2499,6 +2427,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.4", + "siphasher", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2533,28 +2473,10 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "bitvec", - "byteorder", - "ff_derive", "rand_core 0.6.4", "subtle", ] -[[package]] -name = "ff_derive" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f10d12652036b0e99197587c6ba87a8fc3031986499973c030d8b44fcc151b60" -dependencies = [ - "addchain", - "num-bigint 0.3.3", - "num-integer", - "num-traits", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2751,12 +2673,6 @@ dependencies = [ "slab", ] -[[package]] -name = "gcd" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" - [[package]] name = "generic-array" version = "0.14.7" @@ -2774,7 +2690,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "wasi 0.9.0+wasi-snapshot-preview1", ] @@ -2785,7 +2701,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2798,7 +2714,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "r-efi 5.3.0", @@ -2812,7 +2728,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", @@ -2890,7 +2806,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2906,6 +2822,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -3000,6 +2922,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hex-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7685beb53fc20efc2605f32f5d51e9ba18b8ef237961d1760169d2290d3bee" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "hex_fmt" version = "0.3.0" @@ -3013,7 +2945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", - "cfg-if 1.0.4", + "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", @@ -3038,7 +2970,7 @@ version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "futures-util", "hickory-proto", "ipconfig", @@ -3180,12 +3112,36 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -3427,6 +3383,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -3447,11 +3414,11 @@ checksum = "90807d610575744524d9bdc69f3885d96f0e6c3354565b0828354a7ff2a262b8" dependencies = [ "ahash", "clap", - "crossbeam-channel 0.5.15", - "crossbeam-utils 0.8.21", + "crossbeam-channel", + "crossbeam-utils", "dashmap", "env_logger", - "indexmap", + "indexmap 2.14.0", "itoa", "log", "num-format", @@ -3504,15 +3471,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -3570,7 +3528,7 @@ version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "futures-util", "once_cell", "wasm-bindgen", @@ -3582,7 +3540,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "ecdsa", "elliptic-curve", "once_cell", @@ -3605,7 +3563,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", ] @@ -3635,17 +3593,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] -name = "kzg-rs" -version = "0.2.8" +name = "lambdaworks-crypto" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8b4f55c3dedcfaa8668de1dfc8469e7a32d441c28edf225ed1f566fb32977d" +checksum = "58b1a1c1102a5a7fbbda117b79fb3a01e033459c738a3c1642269603484fd1c1" dependencies = [ - "ff", - "hex", - "serde_arrays", + "lambdaworks-math", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", "sha2", - "sp1_bls12_381", - "spin 0.9.8", + "sha3 0.10.9", ] [[package]] @@ -3655,10 +3613,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "018a95aa873eb49896a858dee0d925c33f3978d073c64b08dd4f2c9b35a017c6" dependencies = [ "getrandom 0.2.17", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "rand 0.8.6", - "rayon", "serde", "serde_json", ] @@ -3745,12 +3702,12 @@ source = "git+https://github.com/leanEthereum/leanSig?branch=devnet4#15cbdd43ec8 dependencies = [ "dashmap", "ethereum_ssz", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "p3-baby-bear", - "p3-field 0.5.1", - "p3-koala-bear 0.5.1", - "p3-symmetric 0.5.1", + "p3-field", + "p3-koala-bear", + "p3-symmetric", "rand 0.10.1", "rayon", "serde", @@ -3765,12 +3722,12 @@ source = "git+https://github.com/TomWambsgans/leanSig?branch=devnet4-fast-keygen dependencies = [ "dashmap", "ethereum_ssz", - "num-bigint 0.4.6", + "num-bigint", "num-traits", "p3-baby-bear", - "p3-field 0.5.1", - "p3-koala-bear 0.5.1", - "p3-symmetric 0.5.1", + "p3-field", + "p3-koala-bear", + "p3-symmetric", "rand 0.10.1", "rayon", "serde", @@ -3787,7 +3744,7 @@ dependencies = [ "ethereum_ssz", "leansig", "leansig_fast_keygen", - "p3-field 0.5.1", + "p3-field", "rand 0.10.1", ] @@ -3821,7 +3778,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "windows-link", ] @@ -4521,6 +4478,15 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "libssz" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d498c0482bba87d2647ea4601ea76cf2b498065e3958798a88f49274f3ced5e9" +dependencies = [ + "smallvec", +] + [[package]] name = "libssz" version = "0.3.0" @@ -4530,6 +4496,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "libssz-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ddfb5c969c28a4a54043e630f80c723352637bd1020f256ee3ac7a8814922b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "libssz-derive" version = "0.3.0" @@ -4541,24 +4518,45 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "libssz-merkle" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63c6d6d5ce5d79bba66bc98c99869eedffedf7f14f0aa0915f1a62802650bdf6" +dependencies = [ + "libssz 0.2.2", + "sha2", +] + [[package]] name = "libssz-merkle" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "863eca32d1a43e5ec41106a515552efa8307768d37c68d26b7f21ff13cfee1a7" dependencies = [ - "libssz", + "libssz 0.3.0", "sha2", ] +[[package]] +name = "libssz-types" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747273ab2d923e82ed147091fe0fb3e602dd2012c872cdad5efe69e27c3b4099" +dependencies = [ + "libssz 0.2.2", + "libssz-merkle 0.2.2", + "smallvec", +] + [[package]] name = "libssz-types" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4231ac301726840a3fe111f11bd4619d3c97ed155cb94c88dbf92b70e04e017" dependencies = [ - "libssz", - "libssz-merkle", + "libssz 0.3.0", + "libssz-merkle 0.3.0", "smallvec", ] @@ -4732,27 +4730,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "maybe-uninit" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memoffset" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa" -dependencies = [ - "autocfg", -] - [[package]] name = "memory-stats" version = "1.2.0" @@ -4802,9 +4785,9 @@ version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ - "crossbeam-channel 0.5.15", - "crossbeam-epoch 0.9.18", - "crossbeam-utils 0.8.21", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", "equivalent", "parking_lot", "portable-atomic", @@ -4843,7 +4826,7 @@ source = "git+https://github.com/leanEthereum/leanVM.git?rev=e2592df#e2592df4e30 dependencies = [ "itertools 0.14.0", "mt-utils", - "num-bigint 0.3.3", + "num-bigint", "parallel", "paste", "rand 0.10.1", @@ -4859,7 +4842,7 @@ dependencies = [ "itertools 0.14.0", "mt-field", "mt-utils", - "num-bigint 0.3.3", + "num-bigint", "paste", "rand 0.10.1", "serde", @@ -5063,7 +5046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags", - "cfg-if 1.0.4", + "cfg-if", "cfg_aliases", "libc", ] @@ -5075,7 +5058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags", - "cfg-if 1.0.4", + "cfg-if", "cfg_aliases", "libc", ] @@ -5120,7 +5103,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint 0.4.6", + "num-bigint", "num-complex", "num-integer", "num-iter", @@ -5128,17 +5111,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -5200,7 +5172,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint 0.4.6", + "num-bigint", "num-integer", "num-traits", ] @@ -5299,6 +5271,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -5316,68 +5294,26 @@ name = "p3-baby-bear" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ - "p3-challenger 0.5.1", - "p3-field 0.5.1", - "p3-mds 0.5.1", + "p3-challenger", + "p3-field", + "p3-mds", "p3-monty-31", "p3-poseidon1", - "p3-poseidon2 0.5.1", - "p3-symmetric 0.5.1", + "p3-poseidon2", + "p3-symmetric", "rand 0.10.1", ] -[[package]] -name = "p3-bn254-fr" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577200e3fa7e49e2b21e940a6dc7399dc63acb8581da088558cdf7c455adafc0" -dependencies = [ - "ff", - "num-bigint 0.4.6", - "p3-field 0.3.3-succinct", - "p3-poseidon2 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "rand 0.8.6", - "serde", -] - -[[package]] -name = "p3-challenger" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75358edd6e2562752c01f5064a66d88144a3e75ace0407166dbdf8a727597f52" -dependencies = [ - "p3-field 0.3.3-succinct", - "p3-maybe-rayon 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "p3-util 0.3.3-succinct", - "serde", - "tracing", -] - [[package]] name = "p3-challenger" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ - "p3-field 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-monty-31", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", - "tracing", -] - -[[package]] -name = "p3-dft" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761f1e1b014f2b1b69bd0309124e233d64aa3590e6a41ee786000dd849506d51" -dependencies = [ - "p3-field 0.3.3-succinct", - "p3-matrix 0.3.3-succinct", - "p3-maybe-rayon 0.3.3-succinct", - "p3-util 0.3.3-succinct", + "p3-field", + "p3-maybe-rayon", + "p3-monty-31", + "p3-symmetric", + "p3-util", "tracing", ] @@ -5387,139 +5323,72 @@ version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ "itertools 0.14.0", - "p3-field 0.5.1", - "p3-matrix 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-util 0.5.1", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", "spin 0.10.0", "tracing", ] -[[package]] -name = "p3-field" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df7cebaa4079b24e0dd7e3aad59eebcbb99a67c1271f79ad884a7c032f5f183" -dependencies = [ - "itertools 0.12.1", - "num-bigint 0.4.6", - "num-traits", - "p3-util 0.3.3-succinct", - "rand 0.8.6", - "serde", -] - [[package]] name = "p3-field" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ "itertools 0.14.0", - "num-bigint 0.4.6", - "p3-maybe-rayon 0.5.1", - "p3-util 0.5.1", + "num-bigint", + "p3-maybe-rayon", + "p3-util", "paste", "rand 0.10.1", "serde", "tracing", ] -[[package]] -name = "p3-koala-bear" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cea0ba3389b034b6088d566aea8b57aa29dd2e180966e0c8056f61331c92b4e" -dependencies = [ - "cfg-if 1.0.4", - "num-bigint 0.4.6", - "p3-field 0.3.3-succinct", - "p3-mds 0.3.3-succinct", - "p3-poseidon2 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "rand 0.8.6", - "rustc_version 0.4.1", - "serde", -] - [[package]] name = "p3-koala-bear" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ - "p3-challenger 0.5.1", - "p3-field 0.5.1", - "p3-mds 0.5.1", + "p3-challenger", + "p3-field", + "p3-mds", "p3-monty-31", "p3-poseidon1", - "p3-poseidon2 0.5.1", - "p3-symmetric 0.5.1", + "p3-poseidon2", + "p3-symmetric", "rand 0.10.1", ] -[[package]] -name = "p3-matrix" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae5cc6ce726cc265cc687c1214e3f1ac1f5c6e973442286ba00d1e75da1c3cb" -dependencies = [ - "itertools 0.12.1", - "p3-field 0.3.3-succinct", - "p3-maybe-rayon 0.3.3-succinct", - "p3-util 0.3.3-succinct", - "rand 0.8.6", - "serde", - "tracing", -] - [[package]] name = "p3-matrix" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ "itertools 0.14.0", - "p3-field 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-util 0.5.1", + "p3-field", + "p3-maybe-rayon", + "p3-util", "rand 0.10.1", "serde", "tracing", ] -[[package]] -name = "p3-maybe-rayon" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ac1d2f102cf8c71dba1b449575c99697781fcc028831e83d2245787bd7a650" - [[package]] name = "p3-maybe-rayon" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" -[[package]] -name = "p3-mds" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f072643e385d65fb9eb089ee6824b320417f78671a0db748566e057e28b250e" -dependencies = [ - "itertools 0.12.1", - "p3-dft 0.3.3-succinct", - "p3-field 0.3.3-succinct", - "p3-matrix 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "p3-util 0.3.3-succinct", - "rand 0.8.6", -] - [[package]] name = "p3-mds" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ - "p3-dft 0.5.1", - "p3-field 0.5.1", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", + "p3-dft", + "p3-field", + "p3-symmetric", + "p3-util", "rand 0.10.1", ] @@ -5529,16 +5398,16 @@ version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ "itertools 0.14.0", - "num-bigint 0.4.6", - "p3-dft 0.5.1", - "p3-field 0.5.1", - "p3-matrix 0.5.1", - "p3-maybe-rayon 0.5.1", - "p3-mds 0.5.1", + "num-bigint", + "p3-dft", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-mds", "p3-poseidon1", - "p3-poseidon2 0.5.1", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", + "p3-poseidon2", + "p3-symmetric", + "p3-util", "paste", "rand 0.10.1", "serde", @@ -5551,65 +5420,31 @@ name = "p3-poseidon1" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ - "p3-field 0.5.1", - "p3-symmetric 0.5.1", + "p3-field", + "p3-symmetric", "rand 0.10.1", ] -[[package]] -name = "p3-poseidon2" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00cc4b6e8a439f79541b0910a016da9e6e12a05a24309bbb713e1db0db396952" -dependencies = [ - "gcd", - "p3-field 0.3.3-succinct", - "p3-mds 0.3.3-succinct", - "p3-symmetric 0.3.3-succinct", - "rand 0.8.6", - "serde", -] - [[package]] name = "p3-poseidon2" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ - "p3-field 0.5.1", - "p3-mds 0.5.1", - "p3-symmetric 0.5.1", - "p3-util 0.5.1", + "p3-field", + "p3-mds", + "p3-symmetric", + "p3-util", "rand 0.10.1", ] -[[package]] -name = "p3-symmetric" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebff7fea7deb08a57ccf731a0ed39df25cc66a0e0c2d92c4472c4dee02ee21" -dependencies = [ - "itertools 0.12.1", - "p3-field 0.3.3-succinct", - "serde", -] - [[package]] name = "p3-symmetric" version = "0.5.1" source = "git+https://github.com/Plonky3/Plonky3.git#3f67d136c71bec40f180c85d0bb2b654acddef22" dependencies = [ "itertools 0.14.0", - "p3-field 0.5.1", - "p3-util 0.5.1", - "serde", -] - -[[package]] -name = "p3-util" -version = "0.3.3-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8164df89bbc92e29938f916cc5f1ccbfe6a36fb5040f21ba93c1f21985b9868" -dependencies = [ + "p3-field", + "p3-util", "serde", ] @@ -5622,15 +5457,6 @@ dependencies = [ "transpose", ] -[[package]] -name = "pairing" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" -dependencies = [ - "group", -] - [[package]] name = "parallel" version = "0.1.0" @@ -5689,7 +5515,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "redox_syscall", "smallvec", @@ -5818,7 +5644,7 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", @@ -5843,7 +5669,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "opaque-debug", "universal-hash", @@ -5974,7 +5800,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "fnv", "lazy_static", "memchr", @@ -6042,7 +5868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -6088,15 +5914,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "qfilter" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "746341cd2357c9a4df2d951522b4a8dd1ef553e543119899ad7bf87e938c8fbe" -dependencies = [ - "xxhash-rust", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -6147,7 +5964,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6184,7 +6001,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -6378,8 +6195,8 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "crossbeam-deque 0.8.6", - "crossbeam-utils 0.8.21", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -6428,6 +6245,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.12.3" @@ -6536,7 +6373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.2.17", "libc", "untrusted", @@ -6561,7 +6398,7 @@ dependencies = [ "bytecheck", "bytes", "hashbrown 0.17.1", - "indexmap", + "indexmap 2.14.0", "munge", "ptr_meta", "rancor", @@ -6643,7 +6480,7 @@ dependencies = [ "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", - "num-bigint 0.4.6", + "num-bigint", "num-integer", "num-traits", "parity-scale-codec", @@ -6818,6 +6655,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -6921,15 +6782,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_arrays" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a16b99c5ea4fe3daccd14853ad260ec00ea043b2708d1fd1da3106dcd8d9df" -dependencies = [ - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -6986,13 +6838,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml_ng" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -7005,7 +6889,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -7016,7 +6900,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -7048,7 +6932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f3f15d4e239ebe08413eed880e0f9b5af4b40ee0472543320efa91d488e96a7" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -7099,91 +6983,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slop-algebra" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a473c3a06b466dd0708829415a8a9fab451740da066e07862c8c098904aaad6" -dependencies = [ - "itertools 0.14.0", - "p3-field 0.3.3-succinct", - "serde", -] - -[[package]] -name = "slop-bn254" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7fbae5dd16a3d1e87c9e99cfd557338171710be01458bd5b12dded3878d3fd8" -dependencies = [ - "ff", - "p3-bn254-fr", - "serde", - "slop-algebra", - "slop-challenger", - "slop-poseidon2", - "slop-symmetric", -] - -[[package]] -name = "slop-challenger" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e80df718cef7d3100658dc8b46fafcc994b814421ec9a7d0763a6ee1e5070c" -dependencies = [ - "futures", - "p3-challenger 0.3.3-succinct", - "serde", - "slop-algebra", - "slop-symmetric", -] - -[[package]] -name = "slop-koala-bear" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6586b1c0e66c503e4026a8cb007349fa99c2466957c5b09d18fe658d1391ed8" -dependencies = [ - "lazy_static", - "p3-koala-bear 0.3.3-succinct", - "serde", - "slop-algebra", - "slop-challenger", - "slop-poseidon2", - "slop-symmetric", -] - -[[package]] -name = "slop-poseidon2" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c956b11fff1b8a071fa4ba982dc35e458cff1620dc7b33d9cf22d8df30895f79" -dependencies = [ - "p3-poseidon2 0.3.3-succinct", -] - -[[package]] -name = "slop-primitives" -version = "6.2.1" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de169e0ca381847f9efa0db5a54533371c10558d7aaed4cb3b2a9bae24a0fe83" -dependencies = [ - "slop-algebra", -] +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "slop-symmetric" -version = "6.2.1" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955145ad6e3a1d083a428f9274071cfbb44c3b29013aae9d6c4c29fb7328cfc0" -dependencies = [ - "p3-symmetric 0.3.3-succinct", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -7249,69 +7058,6 @@ dependencies = [ "sha1", ] -[[package]] -name = "sp1-lib" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cd166e010c80e542585bf74585ea80eff117c361656cae43f2968cf0af12d4" -dependencies = [ - "bincode", - "serde", - "sp1-primitives", -] - -[[package]] -name = "sp1-primitives" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4df14efe799ebd675cf530c853153a4787327a2385067716dfad4ede79ff31ad" -dependencies = [ - "bincode", - "blake3", - "elf", - "hex", - "itertools 0.14.0", - "lazy_static", - "num-bigint 0.4.6", - "serde", - "sha2", - "slop-algebra", - "slop-bn254", - "slop-challenger", - "slop-koala-bear", - "slop-poseidon2", - "slop-primitives", - "slop-symmetric", -] - -[[package]] -name = "sp1_bls12_381" -version = "0.8.0-sp1-6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23e41cd36168cc2e51e5d3e35ff0c34b204d945769a65591a76286d04b51e43" -dependencies = [ - "cfg-if 1.0.4", - "ff", - "group", - "pairing", - "rand_core 0.6.4", - "sp1-lib", - "subtle", -] - -[[package]] -name = "spawned-concurrency" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3ec6b3c003075f7d1c4c6475308243e853c9a78149b84b1f8b64d5bed49d49" -dependencies = [ - "futures", - "pin-project-lite", - "spawned-rt 0.4.5", - "thiserror 2.0.18", - "tracing", -] - [[package]] name = "spawned-concurrency" version = "0.5.0" @@ -7321,7 +7067,7 @@ dependencies = [ "futures", "pin-project-lite", "spawned-macros", - "spawned-rt 0.5.0", + "spawned-rt", "thiserror 2.0.18", "tracing", ] @@ -7337,20 +7083,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "spawned-rt" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca60c56b1c60b94dd314edce5ea1a98b6037cca3b44d73828e647bad4dae46c" -dependencies = [ - "crossbeam 0.7.3", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "tracing-subscriber", -] - [[package]] name = "spawned-rt" version = "0.5.0" @@ -7483,6 +7215,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -7565,7 +7308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7617,7 +7360,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -7816,7 +7559,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -8072,7 +7815,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -8166,6 +7908,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -8230,7 +7978,7 @@ version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -8296,7 +8044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -8309,7 +8057,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver 1.0.28", ] @@ -8771,7 +8519,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -8802,7 +8550,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -8821,7 +8569,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver 1.0.28", "serde", @@ -8890,12 +8638,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - [[package]] name = "yamux" version = "0.12.1" diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 94913342..29717624 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -45,6 +45,10 @@ reqwest.workspace = true thiserror.workspace = true eyre.workspace = true +# Pinned to ethlambda-p2p's discovery module, which takes a raw secp256k1 +# `SecretKey` in `DiscoverySpawnConfig`. +secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context"] } + tikv-jemallocator = { workspace = true, optional = true } libc.workspace = true diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index 81208b67..30382a0e 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -113,6 +113,8 @@ pub(crate) struct CliOptions { /// `on_block`. #[arg(long, default_value = "3")] pub(crate) max_attestations_per_block: usize, + #[command(flatten)] + pub(crate) discovery: DiscoveryConfig, /// Shadow-simulator sim-cost + fake-XMSS flags (only under the /// `shadow-integration` feature). #[cfg(feature = "shadow-integration")] @@ -120,6 +122,50 @@ pub(crate) struct CliOptions { pub(crate) shadow: ShadowOptions, } +/// discv5 peer discovery. Off by default: nothing else on the lean network +/// speaks discv5 yet, so enabling it only finds other ethlambda nodes. +#[derive(Debug, clap::Args)] +pub(crate) struct DiscoveryConfig { + /// Enable discv5 peer discovery. + /// + /// Requires `--discovery.port` to differ from `--gossipsub-port`: both are + /// UDP sockets and they cannot share one port. + #[arg(long = "discovery.enable", default_value = "false")] + pub(crate) enable: bool, + /// UDP port for the discv5 socket. + /// + /// Independent of `--gossipsub-port`, which carries libp2p QUIC. Both + /// default to 9000, so enabling discovery means changing one of them. + #[arg(long = "discovery.port", default_value = "9000")] + pub(crate) port: u16, + /// IP address to advertise in the ENR. + /// + /// Defaults to the bind address, which is the wildcard `0.0.0.0` and is not + /// dialable as published. Set this to the address peers should reach this + /// node on: `127.0.0.1` for a local devnet, or the host's public address. + /// discv5's PONG-based IP voting may still replace it at runtime. + #[arg(long = "discovery.advertise-ip")] + pub(crate) advertise_ip: Option, +} + +impl CliOptions { + /// Reject a discovery port that collides with the QUIC port. + /// + /// Both are UDP. Without this the collision surfaces at bind time as an + /// opaque `EADDRINUSE` on whichever socket loses the race. + pub(crate) fn validate_discovery(&self) -> eyre::Result<()> { + if self.discovery.enable && self.discovery.port == self.gossipsub_port { + eyre::bail!( + "--discovery.port ({}) must differ from --gossipsub-port ({}): \ + both bind UDP and cannot share a port", + self.discovery.port, + self.gossipsub_port + ); + } + Ok(()) + } +} + /// Shadow-simulator sim-cost + fake-XMSS flags. Compiled only under the /// `shadow-integration` feature. #[cfg(feature = "shadow-integration")] diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 5b40ad37..8bca207d 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -81,6 +81,7 @@ async fn main() -> eyre::Result<()> { .wrap_err("failed to set global tracing subscriber")?; let options = CliOptions::parse(); + options.validate_discovery()?; #[cfg(feature = "shadow-integration")] init_shadow_cost(&options.shadow); @@ -127,6 +128,8 @@ async fn main() -> eyre::Result<()> { ) })?; let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), options.gossipsub_port); + let discovery_enabled = options.discovery.enable; + let discovery_port = options.discovery.port; #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] info!("Using jemalloc allocator with heap profiling enabled"); @@ -182,6 +185,9 @@ async fn main() -> eyre::Result<()> { ethlambda_blockchain::metrics::set_attestation_committee_count(attestation_committee_count); let bootnodes = read_bootnodes(&bootnodes_path)?; + // `build_swarm` consumes `bootnodes` below; discovery needs its own list, + // so the file is read twice rather than cloning the non-`Clone` `Bootnode`. + let discovery_bootnodes = read_bootnodes(&bootnodes_path)?; let validator_keys = read_validator_keys(&validators_path, &validator_keys_dir, &options.node_id) @@ -261,12 +267,12 @@ async fn main() -> eyre::Result<()> { ); let built = build_swarm(SwarmConfig { - node_key: node_p2p_key, + node_key: node_p2p_key.clone(), bootnodes, listening_socket: p2p_socket, validator_ids, attestation_committee_count, - subscription_subnets: subscribed_subnets, + subscription_subnets: subscribed_subnets.clone(), }) .wrap_err("failed to build swarm")?; @@ -274,7 +280,49 @@ async fn main() -> eyre::Result<()> { // RPC `/lean/v0/node/identity` endpoint reports it. let local_peer_id = built.local_peer_id.to_string(); - let p2p = P2P::spawn(built, store.clone(), node_names); + let bind_ip = IpAddr::from([0, 0, 0, 0]); + let advertise_ip = options.discovery.advertise_ip; + let discovery = if discovery_enabled { + let node_key = secp256k1::SecretKey::from_slice(&node_p2p_key) + .wrap_err("node key is not a valid secp256k1 secret key")?; + let handle = ethlambda_p2p::discovery::spawn_discovery( + ethlambda_p2p::discovery::DiscoverySpawnConfig { + node_key, + bind_ip, + discovery_port, + quic_port: p2p_socket.port(), + subscription_subnets: subscribed_subnets, + attestation_committee_count, + bootnodes: discovery_bootnodes, + advertise_ip, + }, + ) + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_err("failed to start discv5 discovery")?; + // Without `--discovery.advertise-ip` the ENR advertises whatever `ip` + // it was built with, which falls back to the unspecified `bind_ip` + // and is not dialable as published. discv5's PONG-based IP voting may + // still learn and substitute our real external address at runtime; + // only the ENR as published right now is affected. + let advertised = advertise_ip.unwrap_or(bind_ip); + if advertised.is_unspecified() { + warn!( + enr = %handle.local_enr, + "Local ENR advertises an unspecified IP; peers can still reach us once \ + discv5 IP voting resolves our external address, but this ENR is not \ + directly dialable as published. Set --discovery.advertise-ip to the \ + address peers should reach this node on" + ); + } + Some(handle) + } else { + info!("discv5 discovery disabled; peering from the static bootnode list only"); + None + }; + let local_enr = discovery.as_ref().map(|handle| handle.local_enr.clone()); + + let p2p = P2P::spawn(built, store.clone(), node_names, discovery); // Wire actors together via protocol refs blockchain @@ -300,7 +348,10 @@ async fn main() -> eyre::Result<()> { store, aggregator, sync_status, - local_peer_id, + ethlambda_rpc::NodeIdentity { + peer_id: local_peer_id, + enr: local_enr, + }, events, rpc_shutdown, ) diff --git a/crates/common/types/src/enr.rs b/crates/common/types/src/enr.rs new file mode 100644 index 00000000..cee767c1 --- /dev/null +++ b/crates/common/types/src/enr.rs @@ -0,0 +1,154 @@ +//! ENR entries ethlambda advertises for discv5 peer discovery. +//! +//! Layout follows the beacon-chain phase0 p2p interface spec's discovery +//! domain, so that whatever lean standardizes on later has the best chance of +//! already matching. +//! +//! Note that lean defines no fork schedule and its fork digest is a +//! compile-time constant rather than a genesis-derived value, so every field of +//! [`EnrForkId`] is currently fixed. The `eth2` check therefore separates lean +//! from non-lean, but not one lean devnet from another. + +use std::collections::HashSet; + +use libssz_derive::{SszDecode, SszEncode}; + +use crate::constants::FORK_DIGEST; + +/// Fork version of the next planned hard fork. The spec says to set this to the +/// current fork version when no fork is planned; lean has neither. +pub const NEXT_FORK_VERSION: [u8; 4] = [0; 4]; + +/// Sentinel for "no fork is scheduled", per the beacon spec. +pub const FAR_FUTURE_EPOCH: u64 = u64::MAX; + +/// The `eth2` ENR entry: SSZ, 16 bytes, byte-identical to the beacon-chain +/// `ENRForkID` container. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct EnrForkId { + pub fork_digest: [u8; 4], + pub next_fork_version: [u8; 4], + pub next_fork_epoch: u64, +} + +impl EnrForkId { + /// This node's fork id. Constant for the lifetime of the process. + pub fn local() -> Self { + Self { + fork_digest: fork_digest(), + next_fork_version: NEXT_FORK_VERSION, + next_fork_epoch: FAR_FUTURE_EPOCH, + } + } +} + +/// [`FORK_DIGEST`] as raw bytes. The constant is the same hex string embedded in +/// every gossipsub topic name, so the ENR and the topics cannot disagree. +pub fn fork_digest() -> [u8; 4] { + u32::from_str_radix(FORK_DIGEST, 16) + .expect("FORK_DIGEST must be 8 hex digits") + .to_be_bytes() +} + +/// Encode subscribed attestation subnets as the `attnets` bitfield: bit `i` set +/// means subnet `i` is subscribed. +/// +/// Lighthouse uses a fixed-width SSZ `BitVector` because the beacon +/// `ATTESTATION_SUBNET_COUNT` is a spec constant. ethlambda's +/// `attestation_committee_count` is runtime configuration, so the length is +/// derived from it and readers must tolerate a length other than their own. +/// Subnet ids at or beyond `committee_count` are dropped. +pub fn encode_attnets(subnets: &HashSet, committee_count: u64) -> Vec { + let mut bits = vec![0u8; committee_count.div_ceil(8) as usize]; + for &subnet in subnets { + if subnet < committee_count { + bits[(subnet / 8) as usize] |= 1 << (subnet % 8); + } + } + bits +} + +/// Whether `bits` advertises `subnet`. A subnet past the end of the bitfield +/// reads as unsubscribed rather than as an error. +pub fn attnets_contains(bits: &[u8], subnet: u64) -> bool { + bits.get((subnet / 8) as usize) + .is_some_and(|byte| byte & (1 << (subnet % 8)) != 0) +} + +/// Every subnet set in `bits`, ascending. +pub fn decode_attnets(bits: &[u8]) -> Vec { + (0..(bits.len() as u64) * 8) + .filter(|&subnet| attnets_contains(bits, subnet)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use libssz::{SszDecode, SszEncode}; + use std::collections::HashSet; + + #[test] + fn fork_digest_parses_the_constant() { + assert_eq!(fork_digest(), [0x12, 0x34, 0x56, 0x78]); + } + + #[test] + fn enr_fork_id_is_sixteen_bytes_and_round_trips() { + let id = EnrForkId::local(); + let bytes = id.to_ssz(); + assert_eq!(bytes.len(), 16, "ENRForkID is 4 + 4 + 8 bytes"); + assert_eq!(EnrForkId::from_ssz_bytes(&bytes).unwrap(), id); + } + + #[test] + fn local_fork_id_has_no_planned_fork() { + let id = EnrForkId::local(); + assert_eq!(id.fork_digest, fork_digest()); + assert_eq!(id.next_fork_version, NEXT_FORK_VERSION); + assert_eq!(id.next_fork_epoch, FAR_FUTURE_EPOCH); + } + + #[test] + fn attnets_sets_exactly_the_subscribed_bits() { + let subnets = HashSet::from([0u64, 3, 9]); + let bits = encode_attnets(&subnets, 16); + + assert_eq!(bits.len(), 2, "16 subnets need ceil(16/8) = 2 bytes"); + for subnet in 0..16u64 { + assert_eq!( + attnets_contains(&bits, subnet), + subnets.contains(&subnet), + "subnet {subnet}" + ); + } + } + + #[test] + fn attnets_rounds_the_byte_length_up() { + let bits = encode_attnets(&HashSet::from([0u64]), 1); + assert_eq!(bits.len(), 1); + assert!(attnets_contains(&bits, 0)); + } + + #[test] + fn attnets_ignores_out_of_range_subnets() { + // A misconfigured subnet id must not panic or corrupt neighbouring bits. + let bits = encode_attnets(&HashSet::from([0u64, 99]), 8); + assert_eq!(bits, vec![0b0000_0001]); + } + + #[test] + fn attnets_reads_past_the_end_as_unset() { + // A peer advertising a shorter bitfield than our committee count is not + // an error; the missing subnets simply read as unsubscribed. + let bits = encode_attnets(&HashSet::from([0u64]), 8); + assert!(!attnets_contains(&bits, 64)); + } + + #[test] + fn decode_attnets_lists_set_subnets_in_order() { + let bits = encode_attnets(&HashSet::from([9u64, 0, 3]), 16); + assert_eq!(decode_attnets(&bits), vec![0, 3, 9]); + } +} diff --git a/crates/common/types/src/lib.rs b/crates/common/types/src/lib.rs index 88ba98b9..ccbabc42 100644 --- a/crates/common/types/src/lib.rs +++ b/crates/common/types/src/lib.rs @@ -3,6 +3,7 @@ pub mod attestation; pub mod block; pub mod checkpoint; pub mod constants; +pub mod enr; pub mod genesis; pub mod primitives; pub mod state; diff --git a/crates/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index d766b6a8..01402b9c 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -32,10 +32,23 @@ tracing.workspace = true rand = "0.8" -# Required for NodeEnr parsing -ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", rev = "1af63a4de7c93eb7413b9b003df1be82e1484c69" } -ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", rev = "1af63a4de7c93eb7413b9b003df1be82e1484c69" } -ethrex-common = { git = "https://github.com/lambdaclass/ethrex", rev = "1af63a4de7c93eb7413b9b003df1be82e1484c69" } +# Required for NodeEnr parsing. +# +# Points at an unmerged ethrex branch: feat/discovery-peer-requirements carries +# the unified discovery module, the peer table, and the `PeerFilter` seam this +# crate needs, none of which are on ethrex's main yet. `branch` rather than +# `rev` so the two move together while that branch is still being reviewed; +# Cargo.lock pins the exact commit, so builds stay reproducible until someone +# runs `cargo update`. Repoint at a main revision once the branch merges. +ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } +ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } +ethrex-common = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } +# Only for the empty in-memory Store that DiscoveryServer::spawn requires. +ethrex-storage = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } + +# Versions pinned to ethrex's workspace: these types cross the API boundary. +secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context", "recovery", "rand"] } +bytes = "1.6.0" # SSZ libssz.workspace = true diff --git a/crates/net/p2p/src/discovery/admission.rs b/crates/net/p2p/src/discovery/admission.rs new file mode 100644 index 00000000..b460331a --- /dev/null +++ b/crates/net/p2p/src/discovery/admission.rs @@ -0,0 +1,540 @@ +//! Whether a discovered peer may be dialed, and in what order. +//! +//! These are the beacon-chain phase0 p2p spec's discovery checks, mirroring +//! lighthouse's `eth2_fork_predicate`: the `fork_digest` must match, a +//! differing `next_fork_version`/`next_fork_epoch` is explicitly tolerated, and +//! the peer must advertise a port on a transport we actually speak. +//! +//! Lighthouse applies these inside the discovery query itself, via +//! `discv5.find_node_predicate`. ethlambda hands them to ethrex as a +//! [`LeanFilter`], which the peer table consults the moment each ENR arrives. A +//! peer that does not belong is judged where the record lands, not at dial time, +//! and is not offered for dialing again until it publishes a higher-`seq` +//! record, which the peer table runs through the filter afresh. +//! +//! So the dial loop filters nothing: every contact it draws has already passed, +//! and all it does is turn the record into something dialable +//! ([`LeanFilter::dial_target`]) and rank what it got +//! ([`rank_by_uncovered_subnets`]). + +use std::collections::HashSet; +use std::net::IpAddr; + +use ethlambda_types::enr::{EnrForkId, decode_attnets}; +use ethrex_p2p::peer_filter::PeerFilter; +use ethrex_p2p::types::NodeRecord; +use libp2p::multiaddr::Protocol; +use libp2p::{Multiaddr, PeerId}; +use libssz::SszDecode; +use tracing::debug; + +use super::enr::{ATTNETS_ENR_KEY, ETH2_ENR_KEY, read_extra, read_quic_port}; +// `QUIC_ENR_KEY` itself is only needed by the test module below (which reaches +// it through this `use` via `super::*`); `admit` reads the quic port through +// `read_quic_port` instead of the raw key. +#[cfg(test)] +use super::enr::QUIC_ENR_KEY; + +/// A peer that passed admission and is ready to dial. +#[derive(Debug, Clone, PartialEq)] +pub struct DiscoveredPeer { + pub peer_id: PeerId, + pub addr: Multiaddr, + /// Attestation subnets the peer advertises in `attnets`. + pub subnets: Vec, + /// Human-readable tag used in tests and logs. + pub label: String, +} + +/// Why a discovered peer was turned away. +/// +/// No reason is final: the peer table re-runs the filter on every higher-`seq` +/// record, so a peer that adds a `quic` entry or gains an address through +/// discv5's IP voting is reconsidered without restarting the process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectReason { + /// No `eth2` entry, or one that does not decode. Cannot be our network. + MissingForkId, + /// On a different network. + ForkDigestMismatch, + /// Discoverable over discv5, but advertises no libp2p QUIC port (or one + /// that is `0`, which is undialable and RLP-decodes the same way an + /// absent entry does). + NoQuicPort, + /// No `secp256k1` entry, or one that is not a valid key. + BadPublicKey, + /// Neither `ip` nor `ip6`. + MissingAddress, +} + +impl RejectReason { + pub fn as_str(self) -> &'static str { + match self { + Self::MissingForkId => "missing or undecodable eth2 entry", + Self::ForkDigestMismatch => "fork digest mismatch", + Self::NoQuicPort => "no quic port advertised", + Self::BadPublicKey => "missing or invalid secp256k1 key", + Self::MissingAddress => "no ip or ip6 entry", + } + } +} + +/// The spec's admission checks, in the shape ethrex's peer table wants them. +/// +/// Holds what [`admit`] needs to judge a record, so the dial loop no longer +/// carries the local fork id and committee count around: the value handed to +/// [`PeerTableServer::spawn_with_filter`](ethrex_p2p::peer_table::PeerTableServer::spawn_with_filter) +/// judges by the same rules the dial loop later asks for dial targets. It is +/// [`Clone`] because the peer table takes ownership of the filter it runs, and +/// both fields are plain data. +/// +/// `accepts` runs inside the peer table's message loop, so it must stay cheap; +/// [`admit`] only parses entries out of a record it was handed, and the fork id +/// and committee count are captured here at construction. +#[derive(Clone)] +pub struct LeanFilter { + fork_id: EnrForkId, + attestation_committee_count: u64, +} + +impl LeanFilter { + pub fn new(fork_id: EnrForkId, attestation_committee_count: u64) -> Self { + Self { + fork_id, + attestation_committee_count, + } + } + + /// What to dial for a record that has already been admitted, or `None` if it + /// would not be. + /// + /// The `None` arm is unreachable for a contact drawn from the peer table, + /// since the same policy already judged the same record. It is not an + /// `expect` because the two are only guaranteed to agree while the record is + /// unchanged, and the peer table hands out clones: a caller that reaches + /// this with an arbitrary record should get nothing to dial, not a panic. + pub fn dial_target(&self, record: &NodeRecord) -> Option { + admit(record, &self.fork_id, self.attestation_committee_count).ok() + } +} + +impl PeerFilter for LeanFilter { + fn accepts(&self, record: &NodeRecord) -> bool { + match admit(record, &self.fork_id, self.attestation_committee_count) { + Ok(_) => true, + Err(reason) => { + // The only place a rejection is visible: the peer table records + // that the record failed the filter but says nothing about why. + debug!( + ip = ?record.pairs().ip, + udp_port = ?record.pairs().udp_port, + seq = record.seq, + reason = reason.as_str(), + "Rejecting discovered peer" + ); + false + } + } + } +} + +/// Apply the spec's admission checks to a discovered ENR. +/// +/// `attestation_committee_count` bounds [`DiscoveredPeer::subnets`]: a +/// decoded subnet id `>= attestation_committee_count` is dropped before it +/// ever reaches the result. The peer's `attnets` is self-reported and +/// unauthenticated, so trusting it verbatim lets a hostile ENR pack an +/// oversized bitfield (e.g. ~290 bytes of `0xFF`) that decodes to thousands +/// of subnets, dominating `rank_by_uncovered_subnets` forever. A subnet we +/// have no committee for cannot be useful to us regardless, so the clamp is +/// correct on the merits, not just a mitigation. +pub fn admit( + record: &NodeRecord, + local: &EnrForkId, + attestation_committee_count: u64, +) -> Result { + let raw = read_extra(record, ETH2_ENR_KEY).ok_or(RejectReason::MissingForkId)?; + let remote = EnrForkId::from_ssz_bytes(&raw).map_err(|_| RejectReason::MissingForkId)?; + + if remote.fork_digest != local.fork_digest { + return Err(RejectReason::ForkDigestMismatch); + } + if remote.next_fork_version != local.next_fork_version + || remote.next_fork_epoch != local.next_fork_epoch + { + // Explicitly permitted: the spec's MAY covers peers that are not + // compatible with an upcoming fork but are compatible right now. + debug!( + remote_next_fork_version = ?remote.next_fork_version, + remote_next_fork_epoch = remote.next_fork_epoch, + "Peer advertises a different upcoming fork; connecting anyway" + ); + } + + // `None` and `Some(0)` are the same failure: no dialable quic port. They + // also coincide by construction, since an absent entry RLP-decodes to + // `0u16` via left-padding. + let quic_port = match read_quic_port(record) { + None | Some(0) => return Err(RejectReason::NoQuicPort), + Some(port) => port, + }; + + let pairs = record.pairs(); + let public_key_bytes = pairs.secp256k1.ok_or(RejectReason::BadPublicKey)?; + let public_key = + libp2p::identity::secp256k1::PublicKey::try_from_bytes(public_key_bytes.as_bytes()) + .map_err(|_| RejectReason::BadPublicKey)?; + let peer_id = PeerId::from_public_key(&libp2p::identity::PublicKey::from(public_key)); + + let ip = pairs + .ip + .map(IpAddr::from) + .or_else(|| pairs.ip6.map(IpAddr::from)) + .ok_or(RejectReason::MissingAddress)?; + + let addr = Multiaddr::empty() + .with(ip.into()) + .with(Protocol::Udp(quic_port)) + .with(Protocol::QuicV1) + .with_p2p(peer_id) + .map_err(|_| RejectReason::BadPublicKey)?; + + let subnets = read_extra(record, ATTNETS_ENR_KEY) + .map(|bits| decode_attnets(&bits)) + .unwrap_or_default() + .into_iter() + .filter(|subnet| *subnet < attestation_committee_count) + .collect(); + + Ok(DiscoveredPeer { + peer_id, + addr, + subnets, + label: peer_id.to_string(), + }) +} + +/// Order candidates so those covering the most currently-uncovered attestation +/// subnets are dialed first. +/// +/// A candidate advertising no subnets scores zero and sorts last, but is never +/// dropped: with few peers, any peer is better than none. +pub fn rank_by_uncovered_subnets(candidates: &mut [DiscoveredPeer], covered: &HashSet) { + candidates.sort_by_key(|candidate| { + std::cmp::Reverse( + candidate + .subnets + .iter() + .filter(|subnet| !covered.contains(subnet)) + .count(), + ) + }); +} + +#[cfg(test)] +impl DiscoveredPeer { + /// A candidate carrying only what ranking looks at. + fn for_test(label: &str, subnets: Vec) -> Self { + Self { + peer_id: PeerId::random(), + addr: Multiaddr::empty(), + subnets, + label: label.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use ethlambda_types::enr::{FAR_FUTURE_EPOCH, encode_attnets}; + use ethrex_p2p::types::Node; + use ethrex_rlp::encode::RLPEncode; + use libssz::SszEncode; + use std::collections::HashSet; + use std::net::Ipv4Addr; + + /// Build an ENR with an arbitrary set of extra pairs, so each test can omit + /// or corrupt exactly one entry. + fn record_with(extra: Vec<(Bytes, Bytes)>) -> NodeRecord { + let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let public_key = ethrex_common::H512::from_slice( + &secp256k1::PublicKey::from_secret_key(secp256k1::SECP256K1, &signer) + .serialize_uncompressed()[1..], + ); + let node = Node::new(IpAddr::from(Ipv4Addr::LOCALHOST), 9010, 0, public_key); + let mut record = NodeRecord::from_node(&node, 1, &signer).unwrap(); + // `from_node` writes `tcp: 0`; the real builder leaves it unset. Drop it + // so these records match what `build_local_enr` publishes, and re-sign + // once with the extras applied. + record + .edit(&signer, |pairs| { + pairs.tcp_port = None; + pairs.extra_fields = extra; + }) + .unwrap(); + record + } + + fn pair(key: &'static [u8], value: Vec) -> (Bytes, Bytes) { + (Bytes::from_static(key), Bytes::from(value)) + } + + // Byte payloads go through `Bytes` so RLP encodes them as a byte string. A + // bare `Vec` would hit the generic `Vec` impl and emit a list, which + // `read_extra` cannot decode. `u16` is a scalar and needs no wrapping. + fn eth2_pair(fork_id: EnrForkId) -> (Bytes, Bytes) { + pair(ETH2_ENR_KEY, Bytes::from(fork_id.to_ssz()).encode_to_vec()) + } + + fn quic_pair(port: u16) -> (Bytes, Bytes) { + pair(QUIC_ENR_KEY, port.encode_to_vec()) + } + + fn attnets_pair(subnets: &[u64]) -> (Bytes, Bytes) { + let bits = encode_attnets(&subnets.iter().copied().collect::>(), 8); + pair(ATTNETS_ENR_KEY, Bytes::from(bits).encode_to_vec()) + } + + /// `attnets_pair` above encodes against a committee count of 8, so this + /// matches it: subnets in the tests below are all meant to be in-range. + const TEST_COMMITTEE_COUNT: u64 = 8; + + fn admit_record(record: &NodeRecord) -> Result { + admit(record, &EnrForkId::local(), TEST_COMMITTEE_COUNT) + } + + /// Build a record whose pairs are fully controlled, bypassing + /// `from_pairs`'s automatic `secp256k1` population. + /// `admit` never checks the signature, so an all-zero one is fine; this + /// is the only way to reach a record with a missing/invalid public key or + /// with neither `ip` nor `ip6`, both of which `record_with` always fills + /// in from the `Node` it wraps. + fn raw_record(pairs: ethrex_p2p::types::NodeRecordPairs) -> NodeRecord { + NodeRecord::new(ethrex_common::H512::zero(), 1, pairs) + } + + #[test] + fn accepts_a_well_formed_peer() { + let record = record_with(vec![ + attnets_pair(&[2, 5]), + eth2_pair(EnrForkId::local()), + quic_pair(9001), + ]); + let peer = admit_record(&record).expect("accepted"); + assert_eq!(peer.subnets, vec![2, 5]); + assert_eq!( + peer.addr.to_string(), + format!("/ip4/127.0.0.1/udp/9001/quic-v1/p2p/{}", peer.peer_id) + ); + } + + #[test] + fn rejects_a_peer_with_no_eth2_entry() { + let record = record_with(vec![quic_pair(9001)]); + assert_eq!(admit_record(&record), Err(RejectReason::MissingForkId)); + } + + #[test] + fn rejects_a_peer_on_another_network() { + let mut foreign = EnrForkId::local(); + foreign.fork_digest = [0xde, 0xad, 0xbe, 0xef]; + let record = record_with(vec![eth2_pair(foreign), quic_pair(9001)]); + assert_eq!(admit_record(&record), Err(RejectReason::ForkDigestMismatch)); + } + + #[test] + fn accepts_a_peer_with_a_different_upcoming_fork() { + // Per the spec's MAY, and lighthouse: "next_fork_epoch and + // next_fork_version can be different so that we can connect to peers who + // aren't compatible with an upcoming fork. fork_digest **must** be same." + let mut upcoming = EnrForkId::local(); + upcoming.next_fork_version = [9, 9, 9, 9]; + upcoming.next_fork_epoch = FAR_FUTURE_EPOCH - 1; + let record = record_with(vec![eth2_pair(upcoming), quic_pair(9001)]); + assert!(admit_record(&record).is_ok()); + } + + #[test] + fn rejects_a_peer_with_no_quic_port() { + // Reachable by discv5 but not over our only transport. + let record = record_with(vec![eth2_pair(EnrForkId::local())]); + assert_eq!(admit_record(&record), Err(RejectReason::NoQuicPort)); + } + + #[test] + fn rejects_a_peer_with_a_quic_port_of_zero() { + // A port of 0 is undialable, and this is also how an absent entry + // decodes (left-padded to 0u16), so it must hit the same reason as + // `rejects_a_peer_with_no_quic_port` rather than sail through as + // "accepted" with an unusable `/udp/0/quic-v1` multiaddr. + let record = record_with(vec![eth2_pair(EnrForkId::local()), quic_pair(0)]); + assert_eq!(admit_record(&record), Err(RejectReason::NoQuicPort)); + } + + #[test] + fn rejects_a_peer_with_an_invalid_public_key() { + let pairs = ethrex_p2p::types::NodeRecordPairs { + // `0xff` is not a valid compressed secp256k1 point tag (`02`/`03`). + secp256k1: Some(ethrex_common::H264([0xff; 33])), + ip: Some(Ipv4Addr::LOCALHOST), + udp_port: Some(9010), + extra_fields: vec![eth2_pair(EnrForkId::local()), quic_pair(9001)], + ..Default::default() + }; + assert_eq!( + admit_record(&raw_record(pairs)), + Err(RejectReason::BadPublicKey) + ); + } + + #[test] + fn rejects_a_peer_with_neither_ip_nor_ip6() { + let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let public_key_bytes = + secp256k1::PublicKey::from_secret_key(secp256k1::SECP256K1, &signer).serialize(); + let pairs = ethrex_p2p::types::NodeRecordPairs { + secp256k1: Some(ethrex_common::H264(public_key_bytes)), + udp_port: Some(9010), + extra_fields: vec![eth2_pair(EnrForkId::local()), quic_pair(9001)], + ..Default::default() + }; + assert_eq!( + admit_record(&raw_record(pairs)), + Err(RejectReason::MissingAddress) + ); + } + + #[test] + fn accepts_a_peer_with_no_attnets() { + // subnet_predicate treats a missing bitfield as covering no subnets, but + // that never excludes a peer from general discovery. + let record = record_with(vec![eth2_pair(EnrForkId::local()), quic_pair(9001)]); + let peer = admit_record(&record).expect("accepted"); + assert!(peer.subnets.is_empty()); + } + + #[test] + fn drops_subnets_at_or_beyond_the_local_committee_count() { + // A peer (hostile or just differently configured) can advertise + // subnet ids our own committee count has no room for. `admit` must + // silently drop those rather than surface them: a subnet we do not + // have is never useful to us, and (per the ranking exploit this + // guards against) letting them through would let a Sybil ENR packing + // a huge `attnets` bitfield dominate `rank_by_uncovered_subnets`. + let bits = encode_attnets(&HashSet::from([2u64, 8, 40]), 64); + let record = record_with(vec![ + pair(ATTNETS_ENR_KEY, Bytes::from(bits).encode_to_vec()), + eth2_pair(EnrForkId::local()), + quic_pair(9001), + ]); + let peer = admit_record(&record).expect("accepted"); + assert_eq!(peer.subnets, vec![2]); + } + + // --- what the peer table sees, and what it hands back --- + + fn filter() -> LeanFilter { + LeanFilter::new(EnrForkId::local(), TEST_COMMITTEE_COUNT) + } + + #[test] + fn a_well_formed_record_is_accepted_and_dialable() { + let record = record_with(vec![ + attnets_pair(&[2, 5]), + eth2_pair(EnrForkId::local()), + quic_pair(9001), + ]); + + assert!(filter().accepts(&record)); + let peer = filter().dial_target(&record).expect("dialable"); + assert_eq!(peer.subnets, vec![2, 5]); + } + + #[test] + fn another_network_is_rejected() { + let mut foreign = EnrForkId::local(); + foreign.fork_digest = [0xde, 0xad, 0xbe, 0xef]; + let record = record_with(vec![eth2_pair(foreign), quic_pair(9001)]); + + assert!(!filter().accepts(&record)); + assert!(filter().dial_target(&record).is_none()); + } + + #[test] + fn a_missing_quic_port_is_rejected() { + // Discoverable, but not over the only transport we speak. The peer can + // add a `quic` entry and republish: the peer table runs the filter + // again on a higher-`seq` record. This is what the dial-time + // `set_unwanted` this replaced could not express, since ethrex never + // clears that flag. + let record = record_with(vec![eth2_pair(EnrForkId::local())]); + + assert!(!filter().accepts(&record)); + assert!(filter().dial_target(&record).is_none()); + } + + #[test] + fn a_hostile_oversized_attnets_cannot_dominate_the_ranking() { + // The honest peer claims one real subnet. The hostile peer claims none + // of them but pads its `attnets` with ~290 bytes of 0xFF, decoding to + // thousands of subnet ids no 8-subnet committee has. Unclamped, that + // raw count would outrank every honest peer forever. + let mut hostile_bits = vec![0u8; TEST_COMMITTEE_COUNT.div_ceil(8) as usize]; + hostile_bits.extend(vec![0xffu8; 290]); + + let honest = record_with(vec![ + attnets_pair(&[3]), + eth2_pair(EnrForkId::local()), + quic_pair(9001), + ]); + let hostile = record_with(vec![ + pair(ATTNETS_ENR_KEY, Bytes::from(hostile_bits).encode_to_vec()), + eth2_pair(EnrForkId::local()), + quic_pair(9002), + ]); + + let policy = filter(); + let mut admitted: Vec<_> = [honest, hostile] + .iter() + .map(|record| policy.dial_target(record).expect("both are admitted")) + .collect(); + assert!( + admitted + .iter() + .all(|peer| peer.subnets.iter().all(|&s| s < TEST_COMMITTEE_COUNT)), + "no admitted peer may advertise a subnet outside the local committee" + ); + + rank_by_uncovered_subnets(&mut admitted, &HashSet::new()); + assert_eq!( + admitted[0].subnets, + vec![3], + "the honest peer's real subnet must outrank the hostile peer's fabricated ones" + ); + } + + #[test] + fn ranks_candidates_by_uncovered_subnets() { + let mut candidates = vec![ + DiscoveredPeer::for_test("a", vec![0]), + DiscoveredPeer::for_test("b", vec![2]), + DiscoveredPeer::for_test("c", vec![2, 3]), + ]; + rank_by_uncovered_subnets(&mut candidates, &HashSet::from([0u64, 1])); + let order: Vec<_> = candidates.iter().map(|c| c.label.clone()).collect(); + assert_eq!(order, vec!["c", "b", "a"]); + } + + #[test] + fn ranking_keeps_subnet_less_candidates_last_but_present() { + let mut candidates = vec![ + DiscoveredPeer::for_test("none", vec![]), + DiscoveredPeer::for_test("some", vec![7]), + ]; + rank_by_uncovered_subnets(&mut candidates, &HashSet::new()); + let order: Vec<_> = candidates.iter().map(|c| c.label.clone()).collect(); + assert_eq!(order, vec!["some", "none"]); + } +} diff --git a/crates/net/p2p/src/discovery/enr.rs b/crates/net/p2p/src/discovery/enr.rs new file mode 100644 index 00000000..8ab08071 --- /dev/null +++ b/crates/net/p2p/src/discovery/enr.rs @@ -0,0 +1,178 @@ +//! Construction and reading of the ENR ethlambda publishes over discv5. +//! +//! The entry set follows the beacon-chain phase0 p2p spec's discovery domain: +//! +//! ```text +//! id, ip, udp=, quic=, secp256k1, +//! eth2 = SSZ(ENRForkID) +//! attnets = subscribed attestation subnet bitfield +//! ``` +//! +//! There is deliberately no `tcp` entry. The spec defines it as the libp2p TCP +//! listening port and makes it optional; ethlambda speaks QUIC only, so +//! advertising one would invite a dial that cannot succeed. + +use std::collections::HashSet; +use std::net::IpAddr; + +use ethlambda_types::enr::{EnrForkId, encode_attnets}; +use ethrex_common::H512; +use ethrex_p2p::types::{INITIAL_ENR_SEQ, Node, NodeRecord, NodeRecordPairs}; +use libssz::SszEncode; +use secp256k1::{PublicKey, SecretKey}; + +pub const QUIC_ENR_KEY: &[u8] = b"quic"; +pub const ETH2_ENR_KEY: &[u8] = b"eth2"; +pub const ATTNETS_ENR_KEY: &[u8] = b"attnets"; + +/// Everything needed to build this node's ENR. +pub struct LocalEnrParams { + pub signer: SecretKey, + /// Address to advertise. discv5's PONG-based IP voting may replace it later. + pub ip: IpAddr, + /// UDP port the discv5 socket is bound to. + pub discovery_port: u16, + /// UDP port the libp2p QUIC transport is bound to. + pub quic_port: u16, + pub subscription_subnets: HashSet, + pub attestation_committee_count: u64, +} + +impl LocalEnrParams { + /// The `Node` ethrex's discovery server takes as its local identity. + /// + /// `tcp_port` is 0, which ethrex reads as "no TCP listener" and omits from + /// the record. + pub fn local_node(&self) -> Node { + let public_key = PublicKey::from_secret_key(secp256k1::SECP256K1, &self.signer); + Node::new( + self.ip, + self.discovery_port, + 0, + H512::from_slice(&public_key.serialize_uncompressed()[1..]), + ) + } + + /// The full entry set this node advertises. + /// + /// `tcp_port` is left unset rather than zero: `from_pairs` takes the entry + /// set verbatim, so "no TCP listener" is spelled by the entry's absence. + /// + /// The three consensus entries go through `set_extra`/`set_extra_int`, + /// which pick the RLP codec once. Encoding them by hand is the trap that + /// helper exists for: a bare `Vec` hits the generic `Vec` impl and + /// encodes as a *list* of per-byte scalars rather than a byte string, which + /// is well-formed but unreadable by every other client, and nothing local + /// ever complains. + pub fn local_pairs(&self) -> NodeRecordPairs { + let mut pairs = NodeRecordPairs { + udp_port: Some(self.discovery_port), + tcp_port: None, + ..Default::default() + }; + match self.ip.to_canonical() { + IpAddr::V4(ip) => pairs.ip = Some(ip), + IpAddr::V6(ip) => pairs.ip6 = Some(ip), + } + + let attnets = encode_attnets(&self.subscription_subnets, self.attestation_committee_count); + pairs.set_extra(ATTNETS_ENR_KEY, attnets); + pairs.set_extra(ETH2_ENR_KEY, EnrForkId::local().to_ssz()); + pairs.set_extra_int(QUIC_ENR_KEY, self.quic_port); + pairs + } +} + +/// Build and sign this node's ENR. +pub fn build_local_enr(params: &LocalEnrParams) -> Result { + NodeRecord::from_pairs(INITIAL_ENR_SEQ, ¶ms.signer, params.local_pairs()) + .map_err(|err| format!("failed to build local ENR: {err}")) +} + +/// Read a non-dictionary entry's RLP-decoded byte payload. +pub fn read_extra(record: &NodeRecord, key: &[u8]) -> Option> { + record.pairs().extra(key).map(|value| value.to_vec()) +} + +/// The advertised libp2p QUIC port, if any. +pub fn read_quic_port(record: &NodeRecord) -> Option { + record.pairs().extra_int::(QUIC_ENR_KEY) +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::enr::{EnrForkId, decode_attnets}; + use ethrex_p2p::types::Node; + use ethrex_rlp::decode::RLPDecode as _; + use libssz::SszDecode; + use std::collections::HashSet; + use std::net::Ipv4Addr; + + fn test_signer() -> secp256k1::SecretKey { + secp256k1::SecretKey::new(&mut rand::rngs::OsRng) + } + + fn build() -> NodeRecord { + build_local_enr(&LocalEnrParams { + signer: test_signer(), + ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 9010, + quic_port: 9001, + subscription_subnets: HashSet::from([1u64, 4]), + attestation_committee_count: 8, + }) + .expect("ENR builds") + } + + #[test] + fn local_enr_advertises_udp_and_quic_but_no_tcp() { + let record = build(); + let pairs = record.pairs(); + assert_eq!(pairs.udp_port, Some(9010)); + assert_eq!( + pairs.tcp_port, None, + "ethlambda has no TCP listener, so it must not advertise one" + ); + assert_eq!(read_quic_port(&record), Some(9001)); + } + + #[test] + fn local_enr_carries_the_fork_id() { + let record = build(); + let raw = read_extra(&record, b"eth2").expect("eth2 entry present"); + assert_eq!(EnrForkId::from_ssz_bytes(&raw).unwrap(), EnrForkId::local()); + } + + #[test] + fn local_enr_carries_the_subscribed_subnets() { + let record = build(); + let raw = read_extra(&record, b"attnets").expect("attnets entry present"); + assert_eq!(decode_attnets(&raw), vec![1, 4]); + } + + #[test] + fn local_enr_is_signed_and_survives_a_round_trip() { + let record = build(); + assert!(record.verify_signature()); + + let url = record.enr_url().unwrap(); + assert!(url.starts_with("enr:")); + + let decoded = NodeRecord::decode(ðrex_common::base64::decode( + url.strip_prefix("enr:").unwrap().as_bytes(), + )) + .unwrap(); + assert_eq!(decoded, record); + assert_eq!(read_quic_port(&decoded), Some(9001)); + } + + #[test] + fn local_enr_is_a_valid_discv5_node() { + // ethrex's discovery stack turns records into Nodes; if that fails the + // record can never be seeded or gossiped. + let record = build(); + let node = Node::from_enr(&record).expect("Node::from_enr accepts our ENR"); + assert_eq!(node.udp_port, 9010); + } +} diff --git a/crates/net/p2p/src/discovery/mod.rs b/crates/net/p2p/src/discovery/mod.rs new file mode 100644 index 00000000..c151c0ec --- /dev/null +++ b/crates/net/p2p/src/discovery/mod.rs @@ -0,0 +1,278 @@ +//! discv5 peer discovery, built on ethrex's discovery stack. +//! +//! ethrex's `DiscoveryServer` runs discv5-only on its own UDP socket and writes +//! what it finds into an ethrex `PeerTable`. ethlambda's `P2PServer` polls that +//! table, applies the spec checks in [`admission`], and dials the survivors over +//! libp2p QUIC. Static bootnode dialing is untouched. +//! +//! See `docs/discovery.md` for the operator-facing description. + +pub mod admission; +pub mod enr; + +use std::collections::HashSet; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use ethlambda_types::enr::EnrForkId; +use ethrex_p2p::discovery::{DiscoveryConfig, DiscoveryServer}; +use ethrex_p2p::peer_table::{PeerTable, PeerTableServer}; +use ethrex_p2p::types::Node; +use ethrex_storage::{EngineType, Store}; +use secp256k1::SecretKey; +use tokio::net::UdpSocket; +use tracing::info; + +use crate::Bootnode; +use admission::LeanFilter; +use enr::{LocalEnrParams, build_local_enr}; + +/// How often the dial loop looks for a new peer. +pub const DISCOVERY_DIAL_INTERVAL: Duration = Duration::from_secs(5); + +/// Connected-peer count above which discovery stops dialing. Also the peer +/// table's own target. +pub const DISCOVERY_TARGET_PEERS: usize = 16; + +/// Candidates drawn from the peer table per refill. +pub const DISCOVERY_CANDIDATE_BATCH: usize = 8; + +pub struct DiscoverySpawnConfig { + pub node_key: SecretKey, + pub bind_ip: IpAddr, + pub discovery_port: u16, + pub quic_port: u16, + pub subscription_subnets: HashSet, + pub attestation_committee_count: u64, + pub bootnodes: Vec, + /// IP address to advertise in the ENR. Defaults to `bind_ip` when unset, + /// which is undialable if `bind_ip` is the wildcard `0.0.0.0`. + pub advertise_ip: Option, +} + +/// What the P2P actor needs from a running discovery server. +pub struct DiscoveryHandle { + pub peer_table: PeerTable, + /// This node's ENR as an `enr:`-prefixed string, for logs and the RPC + /// identity endpoint. Reflects startup state; discv5 may bump the sequence + /// number later if PONG voting changes our external IP. + pub local_enr: String, + /// The admission policy the peer table judges records with, kept so the dial + /// loop can apply the same rules when it turns a contact into a dial target. + /// See [`LeanFilter`]. + pub filter: LeanFilter, + /// The discv5 socket's actual bound address. Equal to the requested + /// `discovery_port` unless that was 0, in which case this carries the + /// port the OS assigned — the same one baked into `local_enr`'s `udp` + /// entry. + pub bound_addr: SocketAddr, +} + +/// Bind the discv5 socket, build the local ENR, and start ethrex's discovery +/// server with discv4 disabled. +/// +/// The socket is bound before the ENR is built so that a `discovery_port: 0` +/// (ask the OS for a free port) still produces an ENR advertising the real +/// bound port rather than the literal 0, which would be undialable. +/// +/// Only bootnodes whose ENR advertises a `udp` port can seed discv5; the rest +/// are still dialed statically by `build_swarm`. +pub async fn spawn_discovery(config: DiscoverySpawnConfig) -> Result { + let bind_addr = SocketAddr::new(config.bind_ip, config.discovery_port); + let socket = UdpSocket::bind(bind_addr) + .await + .map_err(|err| format!("failed to bind discovery socket on {bind_addr}: {err}"))?; + let bound = socket + .local_addr() + .map_err(|err| format!("failed to read discovery socket address: {err}"))?; + + let params = LocalEnrParams { + signer: config.node_key, + ip: config.advertise_ip.unwrap_or(config.bind_ip), + discovery_port: bound.port(), + quic_port: config.quic_port, + subscription_subnets: config.subscription_subnets, + attestation_committee_count: config.attestation_committee_count, + }; + let local_node = params.local_node(); + let local_record = build_local_enr(¶ms)?; + let local_enr = local_record + .enr_url() + .map_err(|err| format!("failed to encode local ENR: {err}"))?; + + // `spawn` rather than `spawn_with_filter` would install ethrex's own filter, + // which wants an EIP-2124 `eth` entry compatible with an execution chain lean + // does not have and rejects any record without one, so every lean contact + // would be stamped rejected and never dialed. + // + // The peer table owns the filter it runs, so the dial loop keeps a clone + // rather than sharing one: the two carry the same fork id and committee + // count, which is what makes their judgments agree. + let filter = LeanFilter::new(EnrForkId::local(), config.attestation_committee_count); + let peer_table = PeerTableServer::spawn_with_filter( + local_node.node_id(), + DISCOVERY_TARGET_PEERS, + Box::new(filter.clone()), + ); + + let seeds: Vec = config + .bootnodes + .iter() + .filter_map(|bootnode| bootnode.as_discovery_node()) + .collect(); + info!( + discovery_addr = %bound, + seeds = seeds.len(), + total_bootnodes = config.bootnodes.len(), + "Starting discv5 discovery" + ); + + // An empty in-memory store, because `spawn` requires one and lean has no + // execution chain. The discv5 path reads it for exactly one thing, + // `get_fork_id`, to stamp an `eth` entry into the record ethrex serves; on a + // store with no genesis block that call errors and `spawn` skips the entry, + // which is what we want. (The other reader is discv4's ENR handler, and + // discv4 is disabled below.) + // + // KNOWN GAP: `spawn` builds its own local record from `local_node` alone and + // offers no way to seed the consensus entries, so the record ethrex serves + // over the wire carries `ip`/`udp`/`secp256k1` but not `eth2`, `attnets` or + // `quic`. The ENR this node reports (and logs below) is `local_record`, + // built by `build_local_enr`, and is complete. The consequence is one-sided + // discovery: we can find and admit lean peers, but a lean peer applying the + // same admission rules to what ethrex serves would reject us for a missing + // `quic` entry. Closing this needs a way to hand `spawn` a prepared record. + let store = Store::new("", EngineType::InMemory) + .map_err(|err| format!("failed to create the discovery store: {err}"))?; + + DiscoveryServer::spawn( + store, + local_node, + params.signer, + Arc::new(socket), + peer_table.clone(), + seeds, + DiscoveryConfig { + discv4_enabled: false, + discv5_enabled: true, + ..Default::default() + }, + ) + .await + .map_err(|err| format!("failed to start discovery server: {err}"))?; + + info!(enr = %local_enr, "Local ENR"); + + Ok(DiscoveryHandle { + peer_table, + local_enr, + filter, + bound_addr: bound, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ethrex_p2p::peer_filter::PeerFilter; + use ethrex_p2p::types::NodeRecord; + use ethrex_rlp::decode::RLPDecode; + use std::collections::HashSet; + use std::net::Ipv4Addr; + + #[tokio::test] + async fn spawn_binds_the_socket_and_returns_the_local_enr() { + // Port 0 asks the OS for a free port, so the test cannot collide with a + // running node or a sibling test. + let handle = spawn_discovery(DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 0, + quic_port: 9001, + subscription_subnets: HashSet::from([0u64]), + attestation_committee_count: 4, + bootnodes: Vec::new(), + advertise_ip: None, + }) + .await + .expect("discovery spawns"); + + assert!(handle.local_enr.starts_with("enr:")); + + // With discovery_port: 0 the OS picks the real port. The bound + // address must reflect that real port, and the published ENR's `udp` + // entry must match it: were the two out of sync (e.g. the ENR built + // from the requested port 0 instead of the bound one), the record + // would advertise an undialable node. + assert_ne!(handle.bound_addr.port(), 0); + let record = NodeRecord::decode(ðrex_common::base64::decode( + handle + .local_enr + .strip_prefix("enr:") + .expect("enr: prefix") + .as_bytes(), + )) + .expect("local ENR decodes"); + assert_eq!(record.pairs().udp_port, Some(handle.bound_addr.port())); + + // The policy handed to the peer table must admit our own record. A peer + // running this code applies exactly these rules to what we publish, so a + // record we would reject ourselves is one nobody dials. + assert!(handle.filter.accepts(&record)); + } + + #[tokio::test] + async fn spawn_advertises_a_different_ip_than_it_binds() { + // Bind the loopback address but advertise a distinct, non-dialable-from- + // here address, mimicking an operator setting `--discovery.advertise-ip` + // to their host's public IP while still binding the wildcard/loopback + // locally. The ENR must reflect the advertised address, not the bind + // address. + let advertised = Ipv4Addr::new(203, 0, 113, 7); + let handle = spawn_discovery(DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 0, + quic_port: 9001, + subscription_subnets: HashSet::from([0u64]), + attestation_committee_count: 4, + bootnodes: Vec::new(), + advertise_ip: Some(IpAddr::from(advertised)), + }) + .await + .expect("discovery spawns"); + + assert_eq!(handle.bound_addr.ip(), IpAddr::from(Ipv4Addr::LOCALHOST)); + + let record = NodeRecord::decode(ðrex_common::base64::decode( + handle + .local_enr + .strip_prefix("enr:") + .expect("enr: prefix") + .as_bytes(), + )) + .expect("local ENR decodes"); + assert_eq!(record.pairs().ip, Some(advertised)); + } + + #[tokio::test] + async fn spawn_fails_loudly_on_a_busy_port() { + let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let busy = socket.local_addr().unwrap().port(); + + let result = spawn_discovery(DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: busy, + quic_port: 9001, + subscription_subnets: HashSet::new(), + attestation_committee_count: 4, + bootnodes: Vec::new(), + advertise_ip: None, + }) + .await; + + assert!(result.is_err(), "a busy discovery port must not be silent"); + } +} diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index 4726ce25..9d7244fb 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -1,6 +1,6 @@ use std::{ - collections::{HashMap, HashSet}, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + collections::{HashMap, HashSet, VecDeque}, + net::{IpAddr, SocketAddr}, ops::Range, time::Duration, }; @@ -13,7 +13,7 @@ use ethlambda_network_api::{ }; use ethlambda_storage::Store; use ethlambda_types::primitives::H256; -use ethrex_common::H264; +use ethrex_p2p::peer_table::{PeerTable, PeerTableServerProtocol as _}; use ethrex_p2p::types::NodeRecord; use ethrex_rlp::decode::RLPDecode; use futures::StreamExt; @@ -33,9 +33,14 @@ use spawned_concurrency::protocol; use spawned_concurrency::tasks::{ Actor, ActorRef, ActorStart, Context, Handler, send_after, spawn_listener, }; -use tracing::{info, trace, warn}; +use tracing::{debug, info, trace, warn}; use crate::{ + discovery::{ + DISCOVERY_CANDIDATE_BATCH, DISCOVERY_DIAL_INTERVAL, DISCOVERY_TARGET_PEERS, + DiscoveryHandle, + admission::{DiscoveredPeer, LeanFilter, rank_by_uncovered_subnets}, + }, gossipsub::{ aggregation_topic, attestation_subnet_topic, block_topic, publish_aggregated_attestation, publish_attestation, publish_block, @@ -48,9 +53,10 @@ use crate::{ swarm_adapter::SwarmHandle, }; +pub mod discovery; mod gossipsub; pub mod metrics; -mod req_resp; +pub mod req_resp; pub(crate) mod swarm_adapter; pub use libp2p::PeerId; @@ -135,6 +141,19 @@ impl RangeSyncState { } } +/// Everything the dial loop needs from a running discovery server. +pub(crate) struct DiscoveryState { + pub(crate) peer_table: PeerTable, + /// The same policy the peer table judges records with, asked here for the + /// dial target behind an already-admitted record. + pub(crate) filter: LeanFilter, + /// Admitted candidates, best first, drained one per tick. Refilled from the + /// peer table when empty. + pub(crate) candidates: VecDeque, + /// Subnets advertised by peers we dialed from discovery. + pub(crate) peer_attnets: HashMap>, +} + // --- Swarm construction --- /// [libp2p Behaviour](libp2p::swarm::NetworkBehaviour) combining identify, Gossipsub @@ -302,9 +321,15 @@ pub fn build_swarm( if peer_id == local_peer_id { continue; } + // Discovery-only seed: reachable over discv5, but with no QUIC port + // there is nothing for the swarm to dial. + let Some(quic_port) = bootnode.quic_port else { + debug!(%peer_id, ip = %bootnode.ip, "Bootnode advertises no quic port, discv5 seed only"); + continue; + }; let addr = Multiaddr::empty() .with(bootnode.ip.into()) - .with(Protocol::Udp(bootnode.quic_port)) + .with(Protocol::Udp(quic_port)) .with(Protocol::QuicV1) .with_p2p(peer_id) .expect("failed to add peer ID to multiaddr"); @@ -375,7 +400,17 @@ pub struct P2P { impl P2P { /// Build swarm, start I/O adapter, spawn actor, and wire the swarm event stream. - pub fn spawn(built: BuiltSwarm, store: Store, node_names: HashMap) -> P2P { + /// + /// `discovery` is `Some` when discv5 discovery is enabled; it seeds the + /// dial loop's state and schedules its first tick. `None` leaves the dial + /// loop permanently dormant, so peering relies solely on the static + /// bootnode list dialed by `build_swarm`. + pub fn spawn( + built: BuiltSwarm, + store: Store, + node_names: HashMap, + discovery: Option, + ) -> P2P { let (swarm_stream, swarm_handle) = swarm_adapter::start_swarm_adapter(built.swarm, node_names.clone()); @@ -393,8 +428,24 @@ impl P2P { range_sync_state: None, bootnode_addrs: built.bootnode_addrs, node_names, + local_peer_id: built.local_peer_id, + discovery: discovery.map(|handle| DiscoveryState { + peer_table: handle.peer_table, + filter: handle.filter, + candidates: VecDeque::new(), + peer_attnets: HashMap::new(), + }), }; + // Read the flag before `server` is moved into `start()`. + let discovery_enabled = server.discovery.is_some(); let handle = server.start(); + if discovery_enabled { + send_after( + DISCOVERY_DIAL_INTERVAL, + handle.context(), + p2p_protocol::DiscoverPeers, + ); + } spawn_listener(handle.context(), swarm_stream.map(WrappedSwarmEvent)); P2P { handle } } @@ -430,6 +481,11 @@ pub struct P2PServer { pub(crate) range_sync_state: Option, bootnode_addrs: HashMap, node_names: HashMap, + + /// Set when discovery is enabled. `None` disables the dial loop entirely. + pub(crate) discovery: Option, + /// Our own peer ID, so the dial loop never dials itself. + pub(crate) local_peer_id: PeerId, } impl P2PServer { @@ -449,6 +505,8 @@ pub(crate) trait P2PProtocol: Send + Sync { fn retry_block_fetch(&self, root: H256) -> Result<(), ActorError>; #[allow(dead_code)] // invoked via send_after, not called directly fn retry_peer_redial(&self, peer_id: PeerId) -> Result<(), ActorError>; + #[allow(dead_code)] // invoked via send_after, not called directly + fn discover_peers(&self) -> Result<(), ActorError>; } #[actor(protocol = P2PProtocol)] @@ -493,6 +551,93 @@ impl P2PServer { self.swarm_handle.dial(addr.clone()); } } + + #[send_handler] + async fn handle_discover_peers( + &mut self, + _msg: p2p_protocol::DiscoverPeers, + ctx: &Context, + ) { + // Reschedule first, so an early return never stops the loop. + send_after( + DISCOVERY_DIAL_INTERVAL, + ctx.clone(), + p2p_protocol::DiscoverPeers, + ); + + if self.connected_peers.len() >= DISCOVERY_TARGET_PEERS { + return; + } + + // Snapshot what the refill needs before any `.await`, so no borrow of + // `self.discovery` has to live across the async boundary. + let Some((peer_table, filter, needs_refill)) = self.discovery.as_ref().map(|discovery| { + ( + discovery.peer_table.clone(), + discovery.filter.clone(), + discovery.candidates.is_empty(), + ) + }) else { + return; + }; + + if needs_refill { + // ethrex serves one contact per call, skipping anything its + // `PeerFilter` (ours: [`LeanFilter`]) already rejected, and records + // each as tried before returning it. So successive calls never + // repeat, an early `None` means the pool is exhausted, and + // everything that arrives here has already passed admission. + let mut admitted = Vec::with_capacity(DISCOVERY_CANDIDATE_BATCH); + for _ in 0..DISCOVERY_CANDIDATE_BATCH { + let Ok(Some(contact)) = peer_table.get_contact_to_initiate().await else { + break; + }; + // A contact whose ENR has not arrived is unjudged, so the peer + // table still offers it, but it carries no address or peer id to + // dial. Skipping it costs nothing: it was marked tried on the way + // out either way, and that set is cleared once a full scan finds + // nothing eligible. + let Some(peer) = contact + .record + .as_ref() + .and_then(|record| filter.dial_target(record)) + else { + continue; + }; + admitted.push(peer); + } + + let Some(discovery) = self.discovery.as_mut() else { + return; + }; + let covered = covered_subnets(&discovery.peer_attnets, &self.connected_peers); + rank_by_uncovered_subnets(&mut admitted, &covered); + discovery.candidates.extend(admitted); + } + + let local_peer_id = self.local_peer_id; + let Some(discovery) = self.discovery.as_mut() else { + return; + }; + while let Some(candidate) = discovery.candidates.pop_front() { + if candidate.peer_id == local_peer_id + || self.connected_peers.contains(&candidate.peer_id) + { + continue; + } + info!( + peer_id = %candidate.peer_id, + subnets = ?candidate.subnets, + "Dialing discovered peer" + ); + discovery + .peer_attnets + .insert(candidate.peer_id, candidate.subnets.clone()); + metrics::inc_discovered_peers_dialed(); + self.swarm_handle.dial(candidate.addr); + break; + } + } } // --- Manual Handler impls for network-api messages --- @@ -622,6 +767,9 @@ async fn handle_swarm_event( }; if num_established == 0 { server.connected_peers.remove(&peer_id); + if let Some(discovery) = server.discovery.as_mut() { + discovery.peer_attnets.remove(&peer_id); + } let peer_count = server.connected_peers.len(); metrics::notify_peer_disconnected( server.resolve_node_name(Some(&peer_id)), @@ -663,6 +811,18 @@ async fn handle_swarm_event( ); warn!(?peer_id, %error, "Outgoing connection error"); + // A dial that never establishes ends up here rather than in + // `ConnectionClosed`, so this is the only place a discovery-fed + // `peer_attnets` entry can be removed for a peer we dialed but + // never actually connected to. Leaving it would grow the map + // without bound and make `covered_subnets` credit a subnet to a + // peer that isn't connected. + if let Some(pid) = peer_id + && let Some(discovery) = server.discovery.as_mut() + { + discovery.peer_attnets.remove(&pid); + } + // Schedule redial if this was a bootnode if let Some(pid) = peer_id && server.bootnode_addrs.contains_key(&pid) @@ -717,65 +877,132 @@ pub fn derive_peer_ids(names_and_privkeys: HashMap) -> HashMap, + /// The discv5 UDP port, when the ENR advertises one. + /// + /// `None` for the ENRs lean-quickstart generates today, which carry only + /// `ip`/`quic`/`secp256k1`. Such a bootnode is still dialed statically over + /// QUIC; it just cannot seed the discv5 routing table. + pub(crate) udp_port: Option, pub(crate) public_key: PublicKey, } -pub fn parse_enrs(enrs: Vec) -> Vec { - let mut bootnodes = vec![]; - - // File is YAML, but we try to avoid pulling a full YAML parser just for this - for enr_str in enrs { - let base64_decoded = ethrex_common::base64::decode(&enr_str.as_bytes()[4..]); - let record = NodeRecord::decode(&base64_decoded).unwrap(); - let (_, quic_port_bytes) = record - .pairs - .iter() - .find(|(key, _)| key.as_ref() == b"quic") - .expect("node doesn't support QUIC"); - - let (_, public_key_rlp) = record - .pairs - .iter() - .find(|(key, _)| key.as_ref() == b"secp256k1") - .expect("node record missing public key"); - - let public_key_bytes = H264::decode(public_key_rlp).unwrap(); - let public_key = - libp2p::identity::secp256k1::PublicKey::try_from_bytes(public_key_bytes.as_bytes()) - .unwrap(); - - let quic_port = u16::decode(quic_port_bytes.as_ref()).unwrap(); - - let ipv4 = record - .pairs - .iter() - .find(|(key, _)| key.as_ref() == b"ip") - .map(|(_, bytes)| { - IpAddr::from(Ipv4Addr::decode(bytes.as_ref()).expect("invalid IPv4 address")) - }); - let ipv6 = record - .pairs - .iter() - .find(|(key, _)| key.as_ref() == b"ip6") - .map(|(_, bytes)| { - IpAddr::from(Ipv6Addr::decode(bytes.as_ref()).expect("invalid IPv6 address")) - }); +impl Bootnode { + /// This bootnode as a discv5 seed, or `None` when its ENR advertises no + /// `udp` port and it therefore cannot be reached by discovery. + /// + /// `tcp_port` is 0: ethrex reads that as "no TCP listener". + pub(crate) fn as_discovery_node(&self) -> Option { + let udp_port = self.udp_port?; + // libp2p and ethrex hold the same key in different representations: + // ethrex wants the 65-byte uncompressed SEC1 form with its leading 0x04 + // tag stripped. + let uncompressed = self + .public_key + .clone() + .try_into_secp256k1() + .ok()? + .to_bytes_uncompressed(); + Some(ethrex_p2p::types::Node::new( + self.ip, + udp_port, + 0, + ethrex_common::H512::from_slice(&uncompressed[1..]), + )) + } +} - // Prefer IPv4 if both are present - let ip = ipv4.or(ipv6).expect("node record missing IP address"); +/// Decode `enr:`-prefixed records into dialable bootnodes. +/// +/// Records that cannot be decoded, or that lack a QUIC port, an IP or a public +/// key, are skipped with a warning rather than aborting startup: one malformed +/// entry in the bootnode file should not stop the node from booting. +pub fn parse_enrs(enrs: Vec) -> Vec { + enrs.into_iter() + .filter_map(|enr_str| match parse_enr(&enr_str) { + Ok(bootnode) => Some(bootnode), + Err(reason) => { + warn!(%reason, enr = %enr_str, "Skipping unusable bootnode ENR"); + None + } + }) + .collect() +} - bootnodes.push(Bootnode { - ip, - quic_port, - public_key: public_key.into(), - }); +fn parse_enr(enr_str: &str) -> Result { + let stripped = enr_str + .strip_prefix("enr:") + .ok_or_else(|| "missing enr: prefix".to_string())?; + let decoded = ethrex_common::base64::decode(stripped.as_bytes()); + let record = NodeRecord::decode(&decoded).map_err(|err| format!("RLP decode failed: {err}"))?; + let pairs = record.pairs(); + + // A record with no `quic` entry is not an error: it is discv5-reachable but + // speaks no transport we have, which is exactly what every beacon-chain + // bootnode looks like. Keep it as a discovery seed and let `build_swarm` + // skip it when it picks static dial targets. + // `extra_int` answers `None` both for an absent entry and for one whose + // encoding it cannot read, which includes the non-minimal forms some + // clients emit. Either way there is no port we can dial. + // + // A zero is filtered out too: an absent entry RLP-decodes to 0 via + // left-padding, and 0 is undialable regardless, so both collapse to "no + // quic port". + let quic_port = pairs.extra_int::(b"quic").filter(|port| *port != 0); + + let public_key_bytes = pairs + .secp256k1 + .ok_or_else(|| "node record missing public key".to_string())?; + let public_key = + libp2p::identity::secp256k1::PublicKey::try_from_bytes(public_key_bytes.as_bytes()) + .map_err(|err| format!("bad secp256k1 key: {err}"))?; + + // Prefer IPv4 if both are present. + let ip = pairs + .ip + .map(IpAddr::from) + .or_else(|| pairs.ip6.map(IpAddr::from)) + .ok_or_else(|| "node record missing IP address".to_string())?; + + // `quic` and `udp` are independently optional, but a record with neither is + // reachable by nothing we speak: it can be neither dialed nor seeded. Drop + // it here rather than carry a contact that no code path can ever use. + if quic_port.is_none() && pairs.udp_port.is_none() { + return Err("node advertises neither a quic nor a udp port".to_string()); } - bootnodes + + Ok(Bootnode { + ip, + quic_port, + udp_port: pairs.udp_port, + public_key: public_key.into(), + }) } // --- Utility functions --- +/// Attestation subnets covered by peers we are currently connected to. +/// +/// Only peers dialed from discovery contribute, since an inbound peer never +/// tells us its `attnets`. Treating an unknown peer as covering nothing makes +/// the ranking more eager, never wrong. +fn covered_subnets( + peer_attnets: &HashMap>, + connected_peers: &HashSet, +) -> HashSet { + peer_attnets + .iter() + .filter(|(peer, _)| connected_peers.contains(peer)) + .flat_map(|(_, subnets)| subnets.iter().copied()) + .collect() +} + fn connection_direction(endpoint: &libp2p::core::ConnectedPoint) -> &'static str { if endpoint.is_dialer() { "outbound" @@ -808,6 +1035,7 @@ fn compute_message_id(message: &libp2p::gossipsub::Message) -> libp2p::gossipsub #[cfg(test)] mod tests { use super::*; + use std::net::Ipv4Addr; fn random_peer() -> PeerId { PeerId::from_public_key(&Keypair::generate_ed25519().public()) @@ -877,10 +1105,10 @@ mod tests { } // Each ENR encodes a distinct QUIC port - assert_eq!(bootnodes[0].quic_port, 9001); - assert_eq!(bootnodes[1].quic_port, 9002); - assert_eq!(bootnodes[2].quic_port, 9003); - assert_eq!(bootnodes[3].quic_port, 9007); + assert_eq!(bootnodes[0].quic_port, Some(9001)); + assert_eq!(bootnodes[1].quic_port, Some(9002)); + assert_eq!(bootnodes[2].quic_port, Some(9003)); + assert_eq!(bootnodes[3].quic_port, Some(9007)); // Verify the secp256k1 public keys (33-byte compressed format) let expected_pubkeys: Vec<[u8; 33]> = vec![ @@ -907,5 +1135,99 @@ mod tests { let expected_key: PublicKey = secp_key.into(); assert_eq!(bootnode.public_key, expected_key); } + + // Devnet ENRs from lean-quickstart carry no `udp` entry, so they cannot + // seed discv5 even though they remain dialable over QUIC. + for bootnode in &bootnodes { + assert_eq!(bootnode.udp_port, None); + } + } + + #[test] + fn covered_subnets_unions_only_connected_peers() { + let connected = random_peer(); + let gone = random_peer(); + let mut server_subnets = HashMap::new(); + server_subnets.insert(connected, vec![1u64, 2]); + server_subnets.insert(gone, vec![7u64]); + + let connected_peers = HashSet::from([connected]); + let covered = covered_subnets(&server_subnets, &connected_peers); + + assert_eq!(covered, HashSet::from([1, 2])); + } + + #[test] + fn parse_enrs_extracts_the_udp_port_when_present() { + // `secp256k1` is already bound in this module to `libp2p::identity::secp256k1` + // (see the top-of-file `use`), so reach the raw `secp256k1` crate that + // `ethrex_p2p::types::NodeRecord::from_pairs` expects via an explicit + // crate-root path instead of the shadowed name. + use ::secp256k1 as raw_secp256k1; + + // Build an ENR the way ethlambda does once discovery is enabled: udp for + // discv5, quic for libp2p, no tcp. + let signer = raw_secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let mut pairs = ethrex_p2p::types::NodeRecordPairs { + ip: Some(Ipv4Addr::LOCALHOST), + udp_port: Some(9010), + tcp_port: None, + ..Default::default() + }; + pairs.set_extra_int(b"quic", 9001u16); + let record = NodeRecord::from_pairs(1, &signer, pairs).unwrap(); + + let bootnodes = parse_enrs(vec![record.enr_url().unwrap()]); + + assert_eq!(bootnodes.len(), 1); + assert_eq!(bootnodes[0].ip, IpAddr::from(Ipv4Addr::LOCALHOST)); + assert_eq!(bootnodes[0].quic_port, Some(9001)); + assert_eq!(bootnodes[0].udp_port, Some(9010)); + } + + #[test] + fn parse_enrs_keeps_a_quic_less_record_as_a_discovery_seed() { + // Some nodes advertise `tcp` and `udp` but no `quic`, so requiring + // `quic` here would drop the entire mainnet bootstrap list and leave + // discv5 with nothing to seed from. Such a record is kept, with + // `quic_port: None` telling `build_swarm` not to dial it. + // + // The two ENRs are from eth-clients/mainnet's `bootstrap_nodes.yaml`. + let enrs = vec![ + "enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo".to_string(), + "enr:-Le4QPUXJS2BTORXxyx2Ia-9ae4YqA_JWX3ssj4E_J-3z1A-HmFGrU8BpvpqhNabayXeOZ2Nq_sbeDgtzMJpLLnXFgAChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISsaa0Zg2lwNpAkAIkHAAAAAPA8kv_-awoTiXNlY3AyNTZrMaEDHAD2JKYevx89W0CcFJFiskdcEzkH_Wdv9iW42qLK79ODdWRwgiMohHVkcDaCI4I".to_string(), + ]; + + let bootnodes = parse_enrs(enrs); + + assert_eq!(bootnodes.len(), 2, "a quic-less ENR is still a valid seed"); + for bootnode in &bootnodes { + assert_eq!(bootnode.quic_port, None); + // The whole point of keeping them: a `udp` port means discv5 can + // use them, which is what `as_discovery_node` reports. + assert_eq!(bootnode.udp_port, Some(9000)); + assert!(bootnode.as_discovery_node().is_some()); + } + } + + #[test] + fn parse_enrs_skips_malformed_records_but_keeps_the_valid_one() { + // The rewrite's whole point is that one bad line in the bootnode file + // must not take the others down with it. Feed it a mix of the ways an + // entry can be malformed, plus one genuinely valid ENR (reused from + // `parse_enrs_extracts_ip_port_and_public_key`), and check the valid + // one survives and nothing panics along the way. + let enrs = vec![ + "not-an-enr-at-all".to_string(), // missing "enr:" prefix + "enr:not valid base64!!!".to_string(), // non-base64 garbage + "enr:AAAAAAAAAAAAAAAA".to_string(), // valid base64, not valid RLP + "enr:-IW4QGGifTt9ypyMtChDISUNX3z4z5iPdiEPOmBoILvnDuWIKbWVmKXxZERPnw0piQyaBNCENFEPoIi-vxsnsrBig9MBgmlkgnY0gmlwhH8AAAGEcXVpY4IjKYlzZWNwMjU2azGhAhMMnGF1rmIPQ9tWgqfkNmvsG-aIyc9EJU5JFo3Tegys".to_string(), + ]; + + let bootnodes = parse_enrs(enrs); + + assert_eq!(bootnodes.len(), 1, "exactly the one valid ENR must survive"); + assert_eq!(bootnodes[0].ip, IpAddr::from(Ipv4Addr::LOCALHOST)); + assert_eq!(bootnodes[0].quic_port, Some(9001)); } } diff --git a/crates/net/p2p/src/metrics.rs b/crates/net/p2p/src/metrics.rs index a18af7c5..7159ffc7 100644 --- a/crates/net/p2p/src/metrics.rs +++ b/crates/net/p2p/src/metrics.rs @@ -223,6 +223,20 @@ pub fn notify_peer_disconnected(node_name: &str, direction: &str, reason: &str) LEAN_CONNECTED_PEERS.with_label_values(&[node_name]).dec(); } +/// Counts dials initiated from discv5 discovery, as opposed to static bootnode +/// dials. Connection outcomes are already covered by the peer connect and +/// disconnect metrics. +pub fn inc_discovered_peers_dialed() { + static LEAN_DISCOVERED_PEERS_DIALED: LazyLock = LazyLock::new(|| { + register_int_counter!( + "lean_discovered_peers_dialed_total", + "Peers dialed as a result of discv5 discovery" + ) + .unwrap() + }); + LEAN_DISCOVERED_PEERS_DIALED.inc(); +} + /// Refresh the gossipsub mesh peers gauge from the current mesh peer set. pub fn update_gossip_mesh_peers<'a>( peers: impl Iterator, diff --git a/crates/net/p2p/src/req_resp/mod.rs b/crates/net/p2p/src/req_resp/mod.rs index 11acb79f..2242ea62 100644 --- a/crates/net/p2p/src/req_resp/mod.rs +++ b/crates/net/p2p/src/req_resp/mod.rs @@ -1,5 +1,12 @@ mod codec; -mod encoding; +/// The beacon-chain `ssz_snappy` request/response framing: a varint +/// uncompressed-length prefix followed by a snappy *frame*-compressed payload. +/// +/// Public because it is the spec's wire format rather than anything specific to +/// lean's message set, so a client speaking a different set of req/resp +/// protocols over the same framing can reuse it. `examples/mainnet_gossip.rs` +/// does exactly that. +pub mod encoding; pub mod handlers; mod messages; diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 6674b0b7..e5e72d95 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -22,6 +22,7 @@ mod spec; pub mod test_driver; pub(crate) use base::json_response; +pub use node::NodeIdentity; #[derive(Debug, Clone)] pub struct RpcConfig { @@ -64,11 +65,11 @@ pub async fn start_rpc_server( store: Store, aggregator: AggregatorController, sync_status: SyncStatusController, - peer_id: String, + identity: NodeIdentity, events: EventBus, shutdown: CancellationToken, ) -> Result<(), std::io::Error> { - let api_router = build_api_router(store, config.version, peer_id) + let api_router = build_api_router(store, config.version, identity) .layer(Extension(aggregator)) .layer(Extension(sync_status)) .layer(Extension(events)); @@ -107,21 +108,22 @@ pub async fn start_rpc_server( Ok(()) } -/// Build the API router with the given store, client version, and peer ID. +/// Build the API router with the given store, client version, and node identity. /// -/// `version` (`RpcConfig::version`) and `peer_id` (the node's libp2p peer ID) -/// are captured by the `/lean/v0/node/identity` route so it can report them. +/// `version` (`RpcConfig::version`) and `identity` (the node's libp2p peer ID +/// plus its discv5 ENR, the latter `None` when discovery is disabled) are +/// captured by the `/lean/v0/node/identity` route so it can report them. /// The aggregator controller is threaded in separately via `Extension` by the /// caller (see `start_rpc_server`) so existing store-backed handlers don't need /// to know about it and admin handlers extract it independently. -fn build_api_router(store: Store, version: &'static str, peer_id: String) -> Router { +fn build_api_router(store: Store, version: &'static str, identity: NodeIdentity) -> Router { Router::new() .merge(base::routes()) .merge(blocks::routes()) .merge(events::routes()) .merge(fork_choice::routes()) .merge(admin::routes()) - .merge(node::routes(version, peer_id)) + .merge(node::routes(version, identity)) .merge(genesis::routes()) .merge(spec::routes()) .with_state(store) @@ -154,7 +156,14 @@ pub(crate) mod test_utils { /// and peer ID. Tests that assert on those identity values (e.g. the /// `/lean/v0/node/identity` test) call `crate::build_api_router` directly. pub(crate) fn test_api_router(store: Store) -> Router { - crate::build_api_router(store, "ethlambda/test", "test-peer".to_string()) + crate::build_api_router( + store, + "ethlambda/test", + crate::node::NodeIdentity { + peer_id: "test-peer".to_string(), + enr: None, + }, + ) } /// Create a minimal test state for testing. diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 25f67258..d648bff0 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -14,11 +14,25 @@ struct SyncingResponse { finalized_slot: u64, } +/// How this node identifies itself on the network. Fixed at startup and +/// reported verbatim by `/lean/v0/node/identity`. +#[derive(Debug, Clone, Serialize)] +pub struct NodeIdentity { + /// This node's libp2p peer ID (base58), as it appears to peers on the wire. + pub peer_id: String, + /// This node's discv5 ENR, or `null` when discovery is disabled. + /// + /// Captured at startup. discv5 may bump the record's sequence number later + /// if PONG-based IP voting changes our external address, which this field + /// does not track. + pub enr: Option, +} + #[derive(Serialize)] struct IdentityResponse { version: &'static str, - /// This node's libp2p peer ID (base58), as it appears to peers on the wire. - peer_id: String, + #[serde(flatten)] + identity: NodeIdentity, } /// Sync status for `/lean/v0/node/syncing`. @@ -60,18 +74,19 @@ async fn get_syncing( /// Reports node identity: the full client version string (identical to /// `ethlambda --version`: semver, git branch and short SHA, target triple, and -/// rustc version) and the node's libp2p peer ID. Both are fixed at startup and -/// captured by the route in `routes`. -async fn get_identity(version: &'static str, peer_id: String) -> impl IntoResponse { - json_response(IdentityResponse { version, peer_id }) +/// rustc version), the node's libp2p peer ID, and its discv5 ENR (`null` when +/// discovery is disabled). All three are fixed at startup and captured by the +/// route in `routes`. +async fn get_identity(version: &'static str, identity: NodeIdentity) -> impl IntoResponse { + json_response(IdentityResponse { version, identity }) } -pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { +pub(crate) fn routes(version: &'static str, identity: NodeIdentity) -> Router { Router::new() .route("/lean/v0/node/syncing", get(get_syncing)) .route( "/lean/v0/node/identity", - get(move || get_identity(version, peer_id.clone())), + get(move || get_identity(version, identity.clone())), ) } @@ -90,6 +105,7 @@ mod tests { use std::sync::Arc; use tower::ServiceExt; + use super::NodeIdentity; use crate::test_utils::create_test_state; /// Helper: GET /lean/v0/node/syncing (with the given sync controller) and @@ -168,7 +184,41 @@ mod tests { "ethlambda/v9.9.9-test-deadbeef/x86_64-unknown-linux-gnu/rustc-v1.92.0"; const PEER_ID: &str = "16Uiu2HAmTestPeerIdSentinel"; let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); - let app = crate::build_api_router(store, VERSION, PEER_ID.to_string()); + let app = crate::build_api_router( + store, + VERSION, + NodeIdentity { + peer_id: PEER_ID.to_string(), + enr: None, + }, + ); + let json = identity_json(app).await; + assert_eq!(json["version"], VERSION); + assert_eq!(json["peer_id"], PEER_ID); + assert!( + json["enr"].is_null(), + "enr must be null when discovery is disabled" + ); + } + + #[tokio::test] + async fn node_identity_reports_the_enr_when_discovery_is_enabled() { + const ENR: &str = "enr:-TestSentinelRecord"; + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let app = crate::build_api_router( + store, + "ethlambda/test", + NodeIdentity { + peer_id: "test-peer".to_string(), + enr: Some(ENR.to_string()), + }, + ); + let json = identity_json(app).await; + assert_eq!(json["enr"], ENR); + } + + /// Helper: GET /lean/v0/node/identity and parse the JSON body. + async fn identity_json(app: axum::Router) -> serde_json::Value { let resp = app .oneshot( Request::builder() @@ -180,8 +230,6 @@ mod tests { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["version"], VERSION); - assert_eq!(json["peer_id"], PEER_ID); + serde_json::from_slice(&body).unwrap() } } diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index a7ac16fa..2609018b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,3 +15,4 @@ - [Checkpoint Sync](./checkpoint_sync.md) - [Fork Choice Visualization](./fork_choice_visualization.md) - [Data Storage](./data_storage.md) +- [Peer discovery](./discovery.md) diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..e8c8ed28 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,177 @@ +# Peer discovery (discv5) + +ethlambda can find peers over +[discv5](https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md) +instead of relying only on the static bootnode list. The implementation reuses +[ethrex](https://github.com/lambdaclass/ethrex)'s discovery stack, with discv4 +disabled. + +Discovery is **off by default**. Nothing else on the lean network speaks discv5 +today: not leanSpec, not ream's lean network, not zeam. Enabling it currently +only finds other ethlambda nodes. + +## Enabling it + +```bash +ethlambda \ + --gossipsub-port 9000 \ # libp2p QUIC (UDP) + --discovery.enable \ + --discovery.port 9010 # discv5 (UDP) +``` + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--discovery.enable` | `false` | Run the discv5 server and the dial loop | +| `--discovery.port` | `9000` | UDP port for the discv5 socket | +| `--discovery.advertise-ip` | bind address (`0.0.0.0`) | IP address to advertise in the ENR | + +Both `--discovery.port` and `--gossipsub-port` default to 9000 and both bind +UDP, so they cannot share a port. Enabling discovery without changing one of +them is rejected at startup. + +The discv5 socket always binds the wildcard `0.0.0.0`, since that is where we +listen, not where peers should dial us. Without `--discovery.advertise-ip` the +published ENR inherits that same `0.0.0.0`, which is not a dialable address: +set the flag to `127.0.0.1` for a local devnet or to the host's public address +so the ENR is usable as soon as it is published. discv5's PONG-based IP voting +may still replace the advertised address later, once a peer's response tells +the node what its external address looks like. + +## The ENR + +The layout follows the discovery domain of the beacon-chain +[phase0 p2p interface spec](https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md). + +| Entry | Value | +| --- | --- | +| `id` | `v4` | +| `ip` | `--discovery.advertise-ip`, or the bind address (`0.0.0.0`) if unset | +| `udp` | `--discovery.port` | +| `quic` | `--gossipsub-port`, the libp2p QUIC listener | +| `secp256k1` | compressed public key from `--node-key` | +| `eth2` | SSZ `ENRForkID`, 16 bytes | +| `attnets` | subscribed attestation subnet bitfield | + +There is no `tcp` entry: the spec defines it as the libp2p TCP listening port +and ethlambda speaks QUIC only. + +Read the local ENR from `GET /lean/v0/node/identity`, which reports it as `enr` +(`null` when discovery is disabled). It is also logged once at startup. + +## Which peers get dialed + +A discovered peer is admitted only if: + +- its ENR carries a decodable `eth2` entry, **and** +- that entry's `fork_digest` equals ours, **and** +- it advertises a `quic` port. + +A differing `next_fork_version` or `next_fork_epoch` is *not* grounds for +rejection: the spec permits connecting to a peer that is incompatible with an +upcoming fork but compatible now. + +These checks are handed to ethrex's peer table as a `PeerFilter`, so each record +is judged the moment it arrives and a peer that fails is not offered for dialing. +No rejection is final: the peer table runs the filter again as soon as the peer +publishes a higher-`seq` ENR, so a node that adds a `quic` entry, or gains an +address through discv5's IP voting, is reconsidered without a restart. + +Admitted peers are ranked by how many attestation subnets they advertise that no +currently connected peer covers, so discovery preferentially fills gaps in subnet +coverage. A peer advertising no `attnets` is ranked last but never dropped. + +Dialing stops once 16 peers are connected, and resumes if that drops. + +## Bootnodes + +The two entries a bootnode ENR can carry are read independently, because they +answer different questions: + +| Entry | Absent means | +| --- | --- | +| `quic` | Not dialed statically by `build_swarm`; discv5 seed only | +| `udp` | Not seeded into the discv5 routing table; static dial target only | + +Neither absence is an error, and a record carrying just one of them is still +kept. The ENRs `lean-quickstart` generates today carry `ip`/`quic`/`secp256k1` +and no `udp`, so they stay reachable but contribute nothing to discovery. Every +beacon-chain bootnode published today is the mirror image: `ip`/`udp`/`tcp` and +no `quic`, usable as a discv5 seed but never dialed. A record with neither is +dropped with a warning, as is one missing an `ip` or a `secp256k1` key. + +The ENR reported by `GET /lean/v0/node/identity` is only useful to a peer if +the node that published it was started with a real `--discovery.advertise-ip`. +Copying an ENR built from the default `0.0.0.0` into another node's bootnode +list produces a `udp`/`quic` target that cannot be dialed, since `0.0.0.0` +names no reachable host. Set `--discovery.advertise-ip` before pointing other +nodes at this one's ENR: `127.0.0.1` on a local devnet, or the host's public +address otherwise. + +## If lean ever meets a real network + +Two details are worth copying the day lean peers with a network carrying a live +fork schedule: + +- Learn the fork digest by plurality vote over discovered ENRs rather than + hardcoding it, so a fork does not strand the node. Bootnode records are poor + witnesses here: mainnet's still advertise the phase0 digest. +- Gossip message ids follow Altair's function, which inserts the topic length + and topic bytes between the domain and the payload. Phase0's shorter form + produces ids no peer agrees with, which breaks IWANT/IHAVE silently. + +## Known limitations + +### One lean devnet is not separated from another + +The spec's `fork_digest` is derived from genesis, so it separates one chain from +another. ethlambda's is the hardcoded cross-client dummy `0x12345678`, and lean +defines no fork schedule, so every `ENRForkID` field is a constant. The `eth2` +check therefore separates lean from non-lean but **not one lean devnet from +another**: two devnets running this code will peer with each other. Closing that +gap requires lean adopting a genesis-derived fork digest, which is a +cross-client change to gossip topic names. + +### The record ethrex serves is not the record we report + +`GET /lean/v0/node/identity` reports the ENR built by `build_local_enr`, which +carries every entry in the table above. ethrex's `DiscoveryServer` builds its +own copy from the local `Node` and offers no way to seed the consensus entries, +so the record it answers discv5 queries with carries `ip`, `udp` and +`secp256k1` but **not** `eth2`, `attnets` or `quic`. + +Discovery is therefore one-sided: we find lean peers and admit them, but a lean +peer applying [the same admission rules](#which-peers-get-dialed) to what ethrex +serves rejects us for a missing `quic` entry. Copying our reported ENR into +another node's bootnode list still works, since that is the complete record. + +Closing this needs a way to hand ethrex's `DiscoveryServer::spawn` a prepared +record instead of having it build one. Until then, discovery finds peers but +cannot be found by them. + +### A beacon-chain client cannot discover us, and `tcp` is why + +Beyond the fork digest never matching a real beacon network, there is a second, +independent blocker. Lighthouse's discovery predicate is stricter than the spec +text: alongside the `fork_digest` comparison it requires +`enr.tcp4().is_some() || enr.tcp6().is_some()`, and it applies that as a +discv5 query filter, so a `tcp`-less record is dropped before lighthouse's dial +logic ever sees it. Our records deliberately carry no `tcp`, so they would be +filtered out even if the digests did match. That is the right trade for a +QUIC-only client, but it means the omission is a real interop cost and not a +free simplification. + +### `attnets` is not a fixed-width SSZ `Bitvector` + +The spec's `attnets` is `Bitvector[ATTESTATION_SUBNET_COUNT]`, a constant every +conformant client shares, which is what makes an undelimited bitfield decodable. +ethlambda derives the width from `attestation_committee_count`, which is runtime +configuration, so two nodes can legitimately exchange bitfields of different +lengths. The bit-packing convention is identical to the spec's; only the width +is negotiable. Readers tolerate a foreign length by treating bits past the end +as unset, and a peer's advertised subnets are clamped to the local committee +count before they influence anything. + +## Metrics + +`lean_discovered_peers_dialed_total` counts dials initiated by discovery. +Connection outcomes are covered by the existing peer connect/disconnect metrics. From 52dae8b01b849a665f3ac7567ecd65a0cbceadbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:58:01 -0300 Subject: [PATCH 2/6] refactor(p2p): tighten the discv5 discovery surface Quality pass over the discovery feature. No behaviour change; the one observable difference is that a malformed bootnode ENR now warns once instead of twice, because the file is parsed once. Reuse and layering: - Merge `ethlambda-types::enr` into `p2p::discovery::enr`. The shared types crate grew an SSZ container and a `libssz_derive` use for a single consumer crate, and split `encode_attnets` from the `ATTNETS_ENR_KEY` that gives it meaning. `FORK_DIGEST` stays in `types::constants`, where a second crate does use it. - Move the dial loop out of `lib.rs` into `discovery::dial`, matching how `gossipsub::handler` and `req_resp::handlers` already keep their bodies out of the shared actor file. `DiscoveryState`, `covered_subnets` and `local_peer_id` go with it, so dial policy is editable without touching shared actor state. - Add `P2PServer::forget_discovered_peer` so the two teardown paths (`ConnectionClosed` and `OutgoingConnectionError`) share a seam instead of both reaching into `peer_attnets`. - One `quic_multiaddr()` for the two dial paths that were building the same `ip / udp / quic-v1 / p2p` chain, and `ethrex_p2p::utils::public_key_from_signing_key` in place of the hand-rolled uncompressed-SEC1 conversion (which had three copies). - Fold the `!= 0` filter into `read_quic_port` and have `parse_enr` call it. The two spellings of "no dialable quic port" had already drifted. - Drop `read_extra`: ethrex's `pairs.extra()` already returns `Bytes`, so the wrapper only added a copy on a path that runs per arriving ENR. Simplification: - `subnets_from_attnets(bits, committee_count)` replaces decode-everything-then-clamp. Iterating our own committee makes the clamp unforgettable rather than documented in three places, and stops a padded hostile bitfield allocating ~18 KB before being discarded. - `DiscoveryError` via `thiserror` replaces 12 hand-rolled `String` errors; p2p was the only crate in the workspace without it. `main.rs` loses its `map_err(|err| eyre::eyre!(err))` bridge. - Delete `DiscoveredPeer::label` and `DiscoveryHandle::bound_addr`: both were read only by tests, and `label` allocated a base58 string on every admission while the one log line uses `%peer_id`. The ENR-vs-bound-port test now asserts on the record's `udp` entry, which is the invariant. - Delete `RejectReason::as_str`, whose five strings restated the five variant docs for one `debug!`. - Read the bootnode file once (`#[derive(Clone)] Bootnode`) and inline the locals copied out of `options.discovery`. Move the unspecified-IP warning into `spawn_discovery`, next to the code that picks the value. Dependencies: - `DiscoverySpawnConfig::node_key` takes `Vec` like its sibling `SwarmConfig::node_key`, which removes the binary's direct `secp256k1` dependency and its version-coupling to ethrex's workspace. - p2p: drop the unused `recovery` feature, move `bytes` and `rand` to dev-dependencies (both are test-only). Revert `pub mod req_resp` / `pub mod encoding` to private: they were widened for an `examples/mainnet_gossip.rs` that is not in the tree, and making `req_resp` public also exposed the actor-facing `handlers` module. `NodeIdentity` reaches the identity route behind an `Arc`, so a polled endpoint stops cloning two startup-fixed strings per request. --- Cargo.lock | 2 +- bin/ethlambda/Cargo.toml | 4 - bin/ethlambda/src/main.rs | 39 +--- crates/common/types/src/enr.rs | 154 ---------------- crates/common/types/src/lib.rs | 1 - crates/net/p2p/Cargo.toml | 10 +- crates/net/p2p/src/discovery/admission.rs | 139 +++++--------- crates/net/p2p/src/discovery/dial.rs | 170 +++++++++++++++++ crates/net/p2p/src/discovery/enr.rs | 213 ++++++++++++++++++---- crates/net/p2p/src/discovery/mod.rs | 192 ++++++++++--------- crates/net/p2p/src/lib.rs | 197 ++++---------------- crates/net/p2p/src/req_resp/mod.rs | 9 +- crates/net/rpc/src/node.rs | 15 +- 13 files changed, 552 insertions(+), 593 deletions(-) delete mode 100644 crates/common/types/src/enr.rs create mode 100644 crates/net/p2p/src/discovery/dial.rs diff --git a/Cargo.lock b/Cargo.lock index da15d789..da3ddff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1930,7 +1930,6 @@ dependencies = [ "libssz 0.3.0", "libssz-types 0.3.0", "reqwest", - "secp256k1 0.30.0", "serde", "serde_yaml_ng", "thiserror 2.0.18", @@ -2030,6 +2029,7 @@ dependencies = [ "sha2", "snap", "spawned-concurrency", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 29717624..94913342 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -45,10 +45,6 @@ reqwest.workspace = true thiserror.workspace = true eyre.workspace = true -# Pinned to ethlambda-p2p's discovery module, which takes a raw secp256k1 -# `SecretKey` in `DiscoverySpawnConfig`. -secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context"] } - tikv-jemallocator = { workspace = true, optional = true } libc.workspace = true diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 8bca207d..9764f762 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -128,8 +128,6 @@ async fn main() -> eyre::Result<()> { ) })?; let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), options.gossipsub_port); - let discovery_enabled = options.discovery.enable; - let discovery_port = options.discovery.port; #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] info!("Using jemalloc allocator with heap profiling enabled"); @@ -185,9 +183,6 @@ async fn main() -> eyre::Result<()> { ethlambda_blockchain::metrics::set_attestation_committee_count(attestation_committee_count); let bootnodes = read_bootnodes(&bootnodes_path)?; - // `build_swarm` consumes `bootnodes` below; discovery needs its own list, - // so the file is read twice rather than cloning the non-`Clone` `Bootnode`. - let discovery_bootnodes = read_bootnodes(&bootnodes_path)?; let validator_keys = read_validator_keys(&validators_path, &validator_keys_dir, &options.node_id) @@ -268,7 +263,7 @@ async fn main() -> eyre::Result<()> { let built = build_swarm(SwarmConfig { node_key: node_p2p_key.clone(), - bootnodes, + bootnodes: bootnodes.clone(), listening_socket: p2p_socket, validator_ids, attestation_committee_count, @@ -280,41 +275,21 @@ async fn main() -> eyre::Result<()> { // RPC `/lean/v0/node/identity` endpoint reports it. let local_peer_id = built.local_peer_id.to_string(); - let bind_ip = IpAddr::from([0, 0, 0, 0]); - let advertise_ip = options.discovery.advertise_ip; - let discovery = if discovery_enabled { - let node_key = secp256k1::SecretKey::from_slice(&node_p2p_key) - .wrap_err("node key is not a valid secp256k1 secret key")?; + let discovery = if options.discovery.enable { let handle = ethlambda_p2p::discovery::spawn_discovery( ethlambda_p2p::discovery::DiscoverySpawnConfig { - node_key, - bind_ip, - discovery_port, + node_key: node_p2p_key, + bind_ip: p2p_socket.ip(), + discovery_port: options.discovery.port, quic_port: p2p_socket.port(), subscription_subnets: subscribed_subnets, attestation_committee_count, - bootnodes: discovery_bootnodes, - advertise_ip, + bootnodes, + advertise_ip: options.discovery.advertise_ip, }, ) .await - .map_err(|err| eyre::eyre!(err)) .wrap_err("failed to start discv5 discovery")?; - // Without `--discovery.advertise-ip` the ENR advertises whatever `ip` - // it was built with, which falls back to the unspecified `bind_ip` - // and is not dialable as published. discv5's PONG-based IP voting may - // still learn and substitute our real external address at runtime; - // only the ENR as published right now is affected. - let advertised = advertise_ip.unwrap_or(bind_ip); - if advertised.is_unspecified() { - warn!( - enr = %handle.local_enr, - "Local ENR advertises an unspecified IP; peers can still reach us once \ - discv5 IP voting resolves our external address, but this ENR is not \ - directly dialable as published. Set --discovery.advertise-ip to the \ - address peers should reach this node on" - ); - } Some(handle) } else { info!("discv5 discovery disabled; peering from the static bootnode list only"); diff --git a/crates/common/types/src/enr.rs b/crates/common/types/src/enr.rs deleted file mode 100644 index cee767c1..00000000 --- a/crates/common/types/src/enr.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! ENR entries ethlambda advertises for discv5 peer discovery. -//! -//! Layout follows the beacon-chain phase0 p2p interface spec's discovery -//! domain, so that whatever lean standardizes on later has the best chance of -//! already matching. -//! -//! Note that lean defines no fork schedule and its fork digest is a -//! compile-time constant rather than a genesis-derived value, so every field of -//! [`EnrForkId`] is currently fixed. The `eth2` check therefore separates lean -//! from non-lean, but not one lean devnet from another. - -use std::collections::HashSet; - -use libssz_derive::{SszDecode, SszEncode}; - -use crate::constants::FORK_DIGEST; - -/// Fork version of the next planned hard fork. The spec says to set this to the -/// current fork version when no fork is planned; lean has neither. -pub const NEXT_FORK_VERSION: [u8; 4] = [0; 4]; - -/// Sentinel for "no fork is scheduled", per the beacon spec. -pub const FAR_FUTURE_EPOCH: u64 = u64::MAX; - -/// The `eth2` ENR entry: SSZ, 16 bytes, byte-identical to the beacon-chain -/// `ENRForkID` container. -#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] -pub struct EnrForkId { - pub fork_digest: [u8; 4], - pub next_fork_version: [u8; 4], - pub next_fork_epoch: u64, -} - -impl EnrForkId { - /// This node's fork id. Constant for the lifetime of the process. - pub fn local() -> Self { - Self { - fork_digest: fork_digest(), - next_fork_version: NEXT_FORK_VERSION, - next_fork_epoch: FAR_FUTURE_EPOCH, - } - } -} - -/// [`FORK_DIGEST`] as raw bytes. The constant is the same hex string embedded in -/// every gossipsub topic name, so the ENR and the topics cannot disagree. -pub fn fork_digest() -> [u8; 4] { - u32::from_str_radix(FORK_DIGEST, 16) - .expect("FORK_DIGEST must be 8 hex digits") - .to_be_bytes() -} - -/// Encode subscribed attestation subnets as the `attnets` bitfield: bit `i` set -/// means subnet `i` is subscribed. -/// -/// Lighthouse uses a fixed-width SSZ `BitVector` because the beacon -/// `ATTESTATION_SUBNET_COUNT` is a spec constant. ethlambda's -/// `attestation_committee_count` is runtime configuration, so the length is -/// derived from it and readers must tolerate a length other than their own. -/// Subnet ids at or beyond `committee_count` are dropped. -pub fn encode_attnets(subnets: &HashSet, committee_count: u64) -> Vec { - let mut bits = vec![0u8; committee_count.div_ceil(8) as usize]; - for &subnet in subnets { - if subnet < committee_count { - bits[(subnet / 8) as usize] |= 1 << (subnet % 8); - } - } - bits -} - -/// Whether `bits` advertises `subnet`. A subnet past the end of the bitfield -/// reads as unsubscribed rather than as an error. -pub fn attnets_contains(bits: &[u8], subnet: u64) -> bool { - bits.get((subnet / 8) as usize) - .is_some_and(|byte| byte & (1 << (subnet % 8)) != 0) -} - -/// Every subnet set in `bits`, ascending. -pub fn decode_attnets(bits: &[u8]) -> Vec { - (0..(bits.len() as u64) * 8) - .filter(|&subnet| attnets_contains(bits, subnet)) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use libssz::{SszDecode, SszEncode}; - use std::collections::HashSet; - - #[test] - fn fork_digest_parses_the_constant() { - assert_eq!(fork_digest(), [0x12, 0x34, 0x56, 0x78]); - } - - #[test] - fn enr_fork_id_is_sixteen_bytes_and_round_trips() { - let id = EnrForkId::local(); - let bytes = id.to_ssz(); - assert_eq!(bytes.len(), 16, "ENRForkID is 4 + 4 + 8 bytes"); - assert_eq!(EnrForkId::from_ssz_bytes(&bytes).unwrap(), id); - } - - #[test] - fn local_fork_id_has_no_planned_fork() { - let id = EnrForkId::local(); - assert_eq!(id.fork_digest, fork_digest()); - assert_eq!(id.next_fork_version, NEXT_FORK_VERSION); - assert_eq!(id.next_fork_epoch, FAR_FUTURE_EPOCH); - } - - #[test] - fn attnets_sets_exactly_the_subscribed_bits() { - let subnets = HashSet::from([0u64, 3, 9]); - let bits = encode_attnets(&subnets, 16); - - assert_eq!(bits.len(), 2, "16 subnets need ceil(16/8) = 2 bytes"); - for subnet in 0..16u64 { - assert_eq!( - attnets_contains(&bits, subnet), - subnets.contains(&subnet), - "subnet {subnet}" - ); - } - } - - #[test] - fn attnets_rounds_the_byte_length_up() { - let bits = encode_attnets(&HashSet::from([0u64]), 1); - assert_eq!(bits.len(), 1); - assert!(attnets_contains(&bits, 0)); - } - - #[test] - fn attnets_ignores_out_of_range_subnets() { - // A misconfigured subnet id must not panic or corrupt neighbouring bits. - let bits = encode_attnets(&HashSet::from([0u64, 99]), 8); - assert_eq!(bits, vec![0b0000_0001]); - } - - #[test] - fn attnets_reads_past_the_end_as_unset() { - // A peer advertising a shorter bitfield than our committee count is not - // an error; the missing subnets simply read as unsubscribed. - let bits = encode_attnets(&HashSet::from([0u64]), 8); - assert!(!attnets_contains(&bits, 64)); - } - - #[test] - fn decode_attnets_lists_set_subnets_in_order() { - let bits = encode_attnets(&HashSet::from([9u64, 0, 3]), 16); - assert_eq!(decode_attnets(&bits), vec![0, 3, 9]); - } -} diff --git a/crates/common/types/src/lib.rs b/crates/common/types/src/lib.rs index ccbabc42..88ba98b9 100644 --- a/crates/common/types/src/lib.rs +++ b/crates/common/types/src/lib.rs @@ -3,7 +3,6 @@ pub mod attestation; pub mod block; pub mod checkpoint; pub mod constants; -pub mod enr; pub mod genesis; pub mod primitives; pub mod state; diff --git a/crates/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index 01402b9c..55a8df7a 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -29,6 +29,7 @@ snap = "1.1" tokio.workspace = true tracing.workspace = true +thiserror.workspace = true rand = "0.8" @@ -46,9 +47,8 @@ ethrex-common = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/ # Only for the empty in-memory Store that DiscoveryServer::spawn requires. ethrex-storage = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } -# Versions pinned to ethrex's workspace: these types cross the API boundary. -secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context", "recovery", "rand"] } -bytes = "1.6.0" +# Version pinned to ethrex's workspace: `SecretKey` crosses the API boundary. +secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context"] } # SSZ libssz.workspace = true @@ -60,3 +60,7 @@ sha2 = "0.10" [dev-dependencies] hex.workspace = true +# `Bytes` builds the raw ENR entries the admission tests feed to `NodeRecord`, +# and `rand` seeds their throwaway signing keys. Neither is used outside tests. +bytes = "1.6.0" +secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context", "rand"] } diff --git a/crates/net/p2p/src/discovery/admission.rs b/crates/net/p2p/src/discovery/admission.rs index b460331a..2c8b77cc 100644 --- a/crates/net/p2p/src/discovery/admission.rs +++ b/crates/net/p2p/src/discovery/admission.rs @@ -20,20 +20,14 @@ use std::collections::HashSet; use std::net::IpAddr; -use ethlambda_types::enr::{EnrForkId, decode_attnets}; use ethrex_p2p::peer_filter::PeerFilter; use ethrex_p2p::types::NodeRecord; -use libp2p::multiaddr::Protocol; use libp2p::{Multiaddr, PeerId}; use libssz::SszDecode; use tracing::debug; -use super::enr::{ATTNETS_ENR_KEY, ETH2_ENR_KEY, read_extra, read_quic_port}; -// `QUIC_ENR_KEY` itself is only needed by the test module below (which reaches -// it through this `use` via `super::*`); `admit` reads the quic port through -// `read_quic_port` instead of the raw key. -#[cfg(test)] -use super::enr::QUIC_ENR_KEY; +use super::enr::{ATTNETS_ENR_KEY, ETH2_ENR_KEY, EnrForkId, read_quic_port, subnets_from_attnets}; +use crate::quic_multiaddr; /// A peer that passed admission and is ready to dial. #[derive(Debug, Clone, PartialEq)] @@ -42,8 +36,6 @@ pub struct DiscoveredPeer { pub addr: Multiaddr, /// Attestation subnets the peer advertises in `attnets`. pub subnets: Vec, - /// Human-readable tag used in tests and logs. - pub label: String, } /// Why a discovered peer was turned away. @@ -57,9 +49,8 @@ pub enum RejectReason { MissingForkId, /// On a different network. ForkDigestMismatch, - /// Discoverable over discv5, but advertises no libp2p QUIC port (or one - /// that is `0`, which is undialable and RLP-decodes the same way an - /// absent entry does). + /// Discoverable over discv5, but advertises no dialable libp2p QUIC port + /// (see [`read_quic_port`] for what that folds together). NoQuicPort, /// No `secp256k1` entry, or one that is not a valid key. BadPublicKey, @@ -67,18 +58,6 @@ pub enum RejectReason { MissingAddress, } -impl RejectReason { - pub fn as_str(self) -> &'static str { - match self { - Self::MissingForkId => "missing or undecodable eth2 entry", - Self::ForkDigestMismatch => "fork digest mismatch", - Self::NoQuicPort => "no quic port advertised", - Self::BadPublicKey => "missing or invalid secp256k1 key", - Self::MissingAddress => "no ip or ip6 entry", - } - } -} - /// The spec's admission checks, in the shape ethrex's peer table wants them. /// /// Holds what [`admit`] needs to judge a record, so the dial loop no longer @@ -87,10 +66,6 @@ impl RejectReason { /// judges by the same rules the dial loop later asks for dial targets. It is /// [`Clone`] because the peer table takes ownership of the filter it runs, and /// both fields are plain data. -/// -/// `accepts` runs inside the peer table's message loop, so it must stay cheap; -/// [`admit`] only parses entries out of a record it was handed, and the fork id -/// and committee count are captured here at construction. #[derive(Clone)] pub struct LeanFilter { fork_id: EnrForkId, @@ -129,7 +104,7 @@ impl PeerFilter for LeanFilter { ip = ?record.pairs().ip, udp_port = ?record.pairs().udp_port, seq = record.seq, - reason = reason.as_str(), + ?reason, "Rejecting discovered peer" ); false @@ -140,20 +115,18 @@ impl PeerFilter for LeanFilter { /// Apply the spec's admission checks to a discovered ENR. /// -/// `attestation_committee_count` bounds [`DiscoveredPeer::subnets`]: a -/// decoded subnet id `>= attestation_committee_count` is dropped before it -/// ever reaches the result. The peer's `attnets` is self-reported and -/// unauthenticated, so trusting it verbatim lets a hostile ENR pack an -/// oversized bitfield (e.g. ~290 bytes of `0xFF`) that decodes to thousands -/// of subnets, dominating `rank_by_uncovered_subnets` forever. A subnet we -/// have no committee for cannot be useful to us regardless, so the clamp is -/// correct on the merits, not just a mitigation. -pub fn admit( +/// `attestation_committee_count` bounds [`DiscoveredPeer::subnets`]; see +/// [`subnets_from_attnets`] for why a peer's self-reported bitfield cannot be +/// trusted past our own committee. +fn admit( record: &NodeRecord, local: &EnrForkId, attestation_committee_count: u64, ) -> Result { - let raw = read_extra(record, ETH2_ENR_KEY).ok_or(RejectReason::MissingForkId)?; + let pairs = record.pairs(); + let raw = pairs + .extra(ETH2_ENR_KEY) + .ok_or(RejectReason::MissingForkId)?; let remote = EnrForkId::from_ssz_bytes(&raw).map_err(|_| RejectReason::MissingForkId)?; if remote.fork_digest != local.fork_digest { @@ -171,15 +144,8 @@ pub fn admit( ); } - // `None` and `Some(0)` are the same failure: no dialable quic port. They - // also coincide by construction, since an absent entry RLP-decodes to - // `0u16` via left-padding. - let quic_port = match read_quic_port(record) { - None | Some(0) => return Err(RejectReason::NoQuicPort), - Some(port) => port, - }; + let quic_port = read_quic_port(record).ok_or(RejectReason::NoQuicPort)?; - let pairs = record.pairs(); let public_key_bytes = pairs.secp256k1.ok_or(RejectReason::BadPublicKey)?; let public_key = libp2p::identity::secp256k1::PublicKey::try_from_bytes(public_key_bytes.as_bytes()) @@ -192,25 +158,15 @@ pub fn admit( .or_else(|| pairs.ip6.map(IpAddr::from)) .ok_or(RejectReason::MissingAddress)?; - let addr = Multiaddr::empty() - .with(ip.into()) - .with(Protocol::Udp(quic_port)) - .with(Protocol::QuicV1) - .with_p2p(peer_id) - .map_err(|_| RejectReason::BadPublicKey)?; - - let subnets = read_extra(record, ATTNETS_ENR_KEY) - .map(|bits| decode_attnets(&bits)) - .unwrap_or_default() - .into_iter() - .filter(|subnet| *subnet < attestation_committee_count) - .collect(); + let subnets = pairs + .extra(ATTNETS_ENR_KEY) + .map(|bits| subnets_from_attnets(&bits, attestation_committee_count)) + .unwrap_or_default(); Ok(DiscoveredPeer { peer_id, - addr, + addr: quic_multiaddr(ip, quic_port, peer_id), subnets, - label: peer_id.to_string(), }) } @@ -234,12 +190,11 @@ pub fn rank_by_uncovered_subnets(candidates: &mut [DiscoveredPeer], covered: &Ha #[cfg(test)] impl DiscoveredPeer { /// A candidate carrying only what ranking looks at. - fn for_test(label: &str, subnets: Vec) -> Self { + fn for_test(subnets: Vec) -> Self { Self { peer_id: PeerId::random(), addr: Multiaddr::empty(), subnets, - label: label.to_string(), } } } @@ -248,21 +203,20 @@ impl DiscoveredPeer { mod tests { use super::*; use bytes::Bytes; - use ethlambda_types::enr::{FAR_FUTURE_EPOCH, encode_attnets}; use ethrex_p2p::types::Node; + use ethrex_p2p::utils::public_key_from_signing_key; use ethrex_rlp::encode::RLPEncode; use libssz::SszEncode; use std::collections::HashSet; use std::net::Ipv4Addr; + use super::super::enr::{FAR_FUTURE_EPOCH, QUIC_ENR_KEY, encode_attnets}; + /// Build an ENR with an arbitrary set of extra pairs, so each test can omit /// or corrupt exactly one entry. fn record_with(extra: Vec<(Bytes, Bytes)>) -> NodeRecord { let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); - let public_key = ethrex_common::H512::from_slice( - &secp256k1::PublicKey::from_secret_key(secp256k1::SECP256K1, &signer) - .serialize_uncompressed()[1..], - ); + let public_key = public_key_from_signing_key(&signer); let node = Node::new(IpAddr::from(Ipv4Addr::LOCALHOST), 9010, 0, public_key); let mut record = NodeRecord::from_node(&node, 1, &signer).unwrap(); // `from_node` writes `tcp: 0`; the real builder leaves it unset. Drop it @@ -281,9 +235,9 @@ mod tests { (Bytes::from_static(key), Bytes::from(value)) } - // Byte payloads go through `Bytes` so RLP encodes them as a byte string. A - // bare `Vec` would hit the generic `Vec` impl and emit a list, which - // `read_extra` cannot decode. `u16` is a scalar and needs no wrapping. + // Byte payloads go through `Bytes` so RLP encodes them as a byte string; see + // `LocalEnrParams::local_pairs` for why a bare `Vec` would not. `u16` is + // a scalar and needs no wrapping. fn eth2_pair(fork_id: EnrForkId) -> (Bytes, Bytes) { pair(ETH2_ENR_KEY, Bytes::from(fork_id.to_ssz()).encode_to_vec()) } @@ -392,10 +346,9 @@ mod tests { #[test] fn rejects_a_peer_with_neither_ip_nor_ip6() { let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); - let public_key_bytes = - secp256k1::PublicKey::from_secret_key(secp256k1::SECP256K1, &signer).serialize(); + let compressed = signer.public_key(secp256k1::SECP256K1).serialize(); let pairs = ethrex_p2p::types::NodeRecordPairs { - secp256k1: Some(ethrex_common::H264(public_key_bytes)), + secp256k1: Some(ethrex_common::H264(compressed)), udp_port: Some(9010), extra_fields: vec![eth2_pair(EnrForkId::local()), quic_pair(9001)], ..Default::default() @@ -417,12 +370,10 @@ mod tests { #[test] fn drops_subnets_at_or_beyond_the_local_committee_count() { - // A peer (hostile or just differently configured) can advertise - // subnet ids our own committee count has no room for. `admit` must - // silently drop those rather than surface them: a subnet we do not - // have is never useful to us, and (per the ranking exploit this - // guards against) letting them through would let a Sybil ENR packing - // a huge `attnets` bitfield dominate `rank_by_uncovered_subnets`. + // A peer (hostile or just differently configured) can advertise subnet + // ids our own committee count has no room for; `admit` must not surface + // them. `subnets_from_attnets` is what enforces that (and is tested + // directly in `enr`); this checks `admit` actually routes through it. let bits = encode_attnets(&HashSet::from([2u64, 8, 40]), 64); let record = record_with(vec![ pair(ATTNETS_ENR_KEY, Bytes::from(bits).encode_to_vec()), @@ -517,24 +468,30 @@ mod tests { #[test] fn ranks_candidates_by_uncovered_subnets() { + // Subnet 0 is already covered, so `[0]` scores zero, `[2]` scores one + // and `[2, 3]` scores two. let mut candidates = vec![ - DiscoveredPeer::for_test("a", vec![0]), - DiscoveredPeer::for_test("b", vec![2]), - DiscoveredPeer::for_test("c", vec![2, 3]), + DiscoveredPeer::for_test(vec![0]), + DiscoveredPeer::for_test(vec![2]), + DiscoveredPeer::for_test(vec![2, 3]), ]; rank_by_uncovered_subnets(&mut candidates, &HashSet::from([0u64, 1])); - let order: Vec<_> = candidates.iter().map(|c| c.label.clone()).collect(); - assert_eq!(order, vec!["c", "b", "a"]); + let order: Vec<_> = candidates.iter().map(|c| c.subnets.clone()).collect(); + assert_eq!(order, vec![vec![2, 3], vec![2], vec![0]]); } #[test] fn ranking_keeps_subnet_less_candidates_last_but_present() { let mut candidates = vec![ - DiscoveredPeer::for_test("none", vec![]), - DiscoveredPeer::for_test("some", vec![7]), + DiscoveredPeer::for_test(vec![]), + DiscoveredPeer::for_test(vec![7]), ]; rank_by_uncovered_subnets(&mut candidates, &HashSet::new()); - let order: Vec<_> = candidates.iter().map(|c| c.label.clone()).collect(); - assert_eq!(order, vec!["some", "none"]); + let order: Vec<_> = candidates.iter().map(|c| c.subnets.clone()).collect(); + assert_eq!( + order, + vec![vec![7], vec![]], + "a subnet-less candidate sorts last but is never dropped" + ); } } diff --git a/crates/net/p2p/src/discovery/dial.rs b/crates/net/p2p/src/discovery/dial.rs new file mode 100644 index 00000000..3e270e34 --- /dev/null +++ b/crates/net/p2p/src/discovery/dial.rs @@ -0,0 +1,170 @@ +//! The dial loop: turn what discv5 found into libp2p QUIC connections. +//! +//! Runs as a `P2PServer` tick every [`DISCOVERY_DIAL_INTERVAL`], drawing +//! candidates from the ethrex peer table, ranking them by subnet coverage, and +//! dialing one per tick until [`DISCOVERY_TARGET_PEERS`] are connected. + +use std::collections::{HashMap, HashSet, VecDeque}; + +use ethrex_p2p::peer_table::{PeerTable, PeerTableServerProtocol as _}; +use libp2p::PeerId; +use tracing::info; + +use super::admission::{DiscoveredPeer, LeanFilter, rank_by_uncovered_subnets}; +use super::{DISCOVERY_CANDIDATE_BATCH, DISCOVERY_TARGET_PEERS, DiscoveryHandle}; +use crate::{P2PServer, metrics}; + +/// Everything the dial loop needs from a running discovery server. +pub(crate) struct DiscoveryState { + peer_table: PeerTable, + /// The same policy the peer table judges records with, asked here for the + /// dial target behind an already-admitted record. + filter: LeanFilter, + /// Admitted candidates, best first, drained one per tick. Refilled from the + /// peer table when empty. + candidates: VecDeque, + /// Subnets advertised by peers we dialed from discovery. + peer_attnets: HashMap>, + /// Our own peer ID, so the loop never dials itself. + local_peer_id: PeerId, +} + +impl DiscoveryState { + pub(crate) fn new(handle: DiscoveryHandle, local_peer_id: PeerId) -> Self { + Self { + peer_table: handle.peer_table, + filter: handle.filter, + candidates: VecDeque::new(), + peer_attnets: HashMap::new(), + local_peer_id, + } + } +} + +impl P2PServer { + /// Drop a peer's discovery bookkeeping. + /// + /// Called from both teardown paths — a connection that closed and a dial + /// that never established — so the map cannot outlive the peers in it and + /// [`covered_subnets`] cannot credit a subnet to someone who left. A no-op + /// when discovery is off. + pub(crate) fn forget_discovered_peer(&mut self, peer_id: &PeerId) { + if let Some(discovery) = self.discovery.as_mut() { + discovery.peer_attnets.remove(peer_id); + } + } +} + +/// One tick of the dial loop. A no-op when discovery is disabled. +pub(crate) async fn dial_tick(server: &mut P2PServer) { + if server.connected_peers.len() >= DISCOVERY_TARGET_PEERS { + return; + } + + // Snapshot what the refill needs before any `.await`, so no borrow of + // `server.discovery` has to live across the async boundary. Both are handle + // clones: an actor ref and two `Copy` fields. + let Some(discovery) = server.discovery.as_ref() else { + return; + }; + let peer_table = discovery.peer_table.clone(); + let filter = discovery.filter.clone(); + let mut admitted = if discovery.candidates.is_empty() { + draw_candidates(&peer_table, &filter).await + } else { + Vec::new() + }; + + let Some(discovery) = server.discovery.as_mut() else { + return; + }; + if !admitted.is_empty() { + let covered = covered_subnets(&discovery.peer_attnets, &server.connected_peers); + rank_by_uncovered_subnets(&mut admitted, &covered); + discovery.candidates.extend(admitted); + } + + while let Some(candidate) = discovery.candidates.pop_front() { + if candidate.peer_id == discovery.local_peer_id + || server.connected_peers.contains(&candidate.peer_id) + { + continue; + } + info!( + peer_id = %candidate.peer_id, + subnets = ?candidate.subnets, + "Dialing discovered peer" + ); + discovery + .peer_attnets + .insert(candidate.peer_id, candidate.subnets); + metrics::inc_discovered_peers_dialed(); + server.swarm_handle.dial(candidate.addr); + break; + } +} + +/// Draw up to [`DISCOVERY_CANDIDATE_BATCH`] dialable peers from the peer table. +/// +/// ethrex serves one contact per call, skipping anything its `PeerFilter` (ours: +/// [`LeanFilter`]) already rejected, and records each as tried before returning +/// it. So successive calls never repeat, an early `None` means the pool is +/// exhausted, and everything that arrives here has already passed admission. +async fn draw_candidates(peer_table: &PeerTable, filter: &LeanFilter) -> Vec { + let mut admitted = Vec::with_capacity(DISCOVERY_CANDIDATE_BATCH); + for _ in 0..DISCOVERY_CANDIDATE_BATCH { + let Ok(Some(contact)) = peer_table.get_contact_to_initiate().await else { + break; + }; + // A contact whose ENR has not arrived is unjudged, so the peer table + // still offers it, but it carries no address or peer id to dial. + // Skipping it costs nothing: it was marked tried on the way out either + // way, and that set is cleared once a full scan finds nothing eligible. + let Some(peer) = contact + .record + .as_ref() + .and_then(|record| filter.dial_target(record)) + else { + continue; + }; + admitted.push(peer); + } + admitted +} + +/// Attestation subnets covered by peers we are currently connected to. +/// +/// Only peers dialed from discovery contribute, since an inbound peer never +/// tells us its `attnets`. Treating an unknown peer as covering nothing makes +/// the ranking more eager, never wrong. +fn covered_subnets( + peer_attnets: &HashMap>, + connected_peers: &HashSet, +) -> HashSet { + peer_attnets + .iter() + .filter(|(peer, _)| connected_peers.contains(peer)) + .flat_map(|(_, subnets)| subnets.iter().copied()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use libp2p::identity::Keypair; + + fn random_peer() -> PeerId { + PeerId::from_public_key(&Keypair::generate_ed25519().public()) + } + + #[test] + fn covered_subnets_unions_only_connected_peers() { + let connected = random_peer(); + let gone = random_peer(); + let peer_attnets = HashMap::from([(connected, vec![1u64, 2]), (gone, vec![7u64])]); + + let covered = covered_subnets(&peer_attnets, &HashSet::from([connected])); + + assert_eq!(covered, HashSet::from([1, 2])); + } +} diff --git a/crates/net/p2p/src/discovery/enr.rs b/crates/net/p2p/src/discovery/enr.rs index 8ab08071..f63ac430 100644 --- a/crates/net/p2p/src/discovery/enr.rs +++ b/crates/net/p2p/src/discovery/enr.rs @@ -11,31 +11,110 @@ //! There is deliberately no `tcp` entry. The spec defines it as the libp2p TCP //! listening port and makes it optional; ethlambda speaks QUIC only, so //! advertising one would invite a dial that cannot succeed. +//! +//! Lean defines no fork schedule and its fork digest is a compile-time constant +//! rather than a genesis-derived value, so every field of [`EnrForkId`] is +//! fixed. The `eth2` check therefore separates lean from non-lean, but not one +//! lean devnet from another. use std::collections::HashSet; use std::net::IpAddr; -use ethlambda_types::enr::{EnrForkId, encode_attnets}; -use ethrex_common::H512; +use ethlambda_types::constants::FORK_DIGEST; use ethrex_p2p::types::{INITIAL_ENR_SEQ, Node, NodeRecord, NodeRecordPairs}; +use ethrex_p2p::utils::public_key_from_signing_key; use libssz::SszEncode; -use secp256k1::{PublicKey, SecretKey}; +use libssz_derive::{SszDecode, SszEncode}; +use secp256k1::SecretKey; + +use super::DiscoveryError; + +pub(crate) const QUIC_ENR_KEY: &[u8] = b"quic"; +pub(crate) const ETH2_ENR_KEY: &[u8] = b"eth2"; +pub(crate) const ATTNETS_ENR_KEY: &[u8] = b"attnets"; + +/// Fork version of the next planned hard fork. The spec says to set this to the +/// current fork version when no fork is planned; lean has neither. +pub(crate) const NEXT_FORK_VERSION: [u8; 4] = [0; 4]; + +/// Sentinel for "no fork is scheduled", per the beacon spec. +pub(crate) const FAR_FUTURE_EPOCH: u64 = u64::MAX; + +/// The `eth2` ENR entry: SSZ, 16 bytes, byte-identical to the beacon-chain +/// `ENRForkID` container. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct EnrForkId { + pub fork_digest: [u8; 4], + pub next_fork_version: [u8; 4], + pub next_fork_epoch: u64, +} + +impl EnrForkId { + /// This node's fork id. Constant for the lifetime of the process. + pub fn local() -> Self { + Self { + fork_digest: fork_digest(), + next_fork_version: NEXT_FORK_VERSION, + next_fork_epoch: FAR_FUTURE_EPOCH, + } + } +} + +/// [`FORK_DIGEST`] as raw bytes. The constant is the same hex string embedded in +/// every gossipsub topic name, so the ENR and the topics cannot disagree. +pub(crate) fn fork_digest() -> [u8; 4] { + u32::from_str_radix(FORK_DIGEST, 16) + .expect("FORK_DIGEST must be 8 hex digits") + .to_be_bytes() +} + +/// Encode subscribed attestation subnets as the `attnets` bitfield: bit `i` set +/// means subnet `i` is subscribed. +/// +/// Lighthouse uses a fixed-width SSZ `BitVector` because the beacon +/// `ATTESTATION_SUBNET_COUNT` is a spec constant. ethlambda's +/// `attestation_committee_count` is runtime configuration, so the length is +/// derived from it and readers must tolerate a length other than their own. +/// Subnet ids at or beyond `committee_count` are dropped. +pub(crate) fn encode_attnets(subnets: &HashSet, committee_count: u64) -> Vec { + let mut bits = vec![0u8; committee_count.div_ceil(8) as usize]; + for &subnet in subnets { + if subnet < committee_count { + bits[(subnet / 8) as usize] |= 1 << (subnet % 8); + } + } + bits +} -pub const QUIC_ENR_KEY: &[u8] = b"quic"; -pub const ETH2_ENR_KEY: &[u8] = b"eth2"; -pub const ATTNETS_ENR_KEY: &[u8] = b"attnets"; +/// The subnets `bits` advertises, ascending, bounded by `committee_count`. +/// +/// Iterating our own committee rather than the peer's bitfield does two jobs at +/// once. A bitfield shorter than ours is not an error: its missing subnets read +/// as unsubscribed. A longer one cannot be believed either, because `attnets` is +/// self-reported and unauthenticated, so a hostile ENR could otherwise pack an +/// oversized field that decodes to thousands of subnets and dominate +/// [`rank_by_uncovered_subnets`](super::admission::rank_by_uncovered_subnets) +/// forever. A subnet we have no committee for cannot be useful to us regardless. +pub(crate) fn subnets_from_attnets(bits: &[u8], committee_count: u64) -> Vec { + (0..committee_count) + .filter(|subnet| { + bits.get((subnet / 8) as usize) + .is_some_and(|byte| byte & (1 << (subnet % 8)) != 0) + }) + .collect() +} /// Everything needed to build this node's ENR. -pub struct LocalEnrParams { - pub signer: SecretKey, +pub(crate) struct LocalEnrParams { + pub(crate) signer: SecretKey, /// Address to advertise. discv5's PONG-based IP voting may replace it later. - pub ip: IpAddr, + pub(crate) ip: IpAddr, /// UDP port the discv5 socket is bound to. - pub discovery_port: u16, + pub(crate) discovery_port: u16, /// UDP port the libp2p QUIC transport is bound to. - pub quic_port: u16, - pub subscription_subnets: HashSet, - pub attestation_committee_count: u64, + pub(crate) quic_port: u16, + pub(crate) subscription_subnets: HashSet, + pub(crate) attestation_committee_count: u64, } impl LocalEnrParams { @@ -43,13 +122,12 @@ impl LocalEnrParams { /// /// `tcp_port` is 0, which ethrex reads as "no TCP listener" and omits from /// the record. - pub fn local_node(&self) -> Node { - let public_key = PublicKey::from_secret_key(secp256k1::SECP256K1, &self.signer); + pub(crate) fn local_node(&self) -> Node { Node::new( self.ip, self.discovery_port, 0, - H512::from_slice(&public_key.serialize_uncompressed()[1..]), + public_key_from_signing_key(&self.signer), ) } @@ -64,7 +142,7 @@ impl LocalEnrParams { /// encodes as a *list* of per-byte scalars rather than a byte string, which /// is well-formed but unreadable by every other client, and nothing local /// ever complains. - pub fn local_pairs(&self) -> NodeRecordPairs { + fn local_pairs(&self) -> NodeRecordPairs { let mut pairs = NodeRecordPairs { udp_port: Some(self.discovery_port), tcp_port: None, @@ -84,38 +162,34 @@ impl LocalEnrParams { } /// Build and sign this node's ENR. -pub fn build_local_enr(params: &LocalEnrParams) -> Result { +pub(crate) fn build_local_enr(params: &LocalEnrParams) -> Result { NodeRecord::from_pairs(INITIAL_ENR_SEQ, ¶ms.signer, params.local_pairs()) - .map_err(|err| format!("failed to build local ENR: {err}")) -} - -/// Read a non-dictionary entry's RLP-decoded byte payload. -pub fn read_extra(record: &NodeRecord, key: &[u8]) -> Option> { - record.pairs().extra(key).map(|value| value.to_vec()) + .map_err(DiscoveryError::BuildEnr) } -/// The advertised libp2p QUIC port, if any. -pub fn read_quic_port(record: &NodeRecord) -> Option { - record.pairs().extra_int::(QUIC_ENR_KEY) +/// The advertised libp2p QUIC port, if it is one we could dial. +/// +/// `None` covers an absent entry, an encoding `extra_int` cannot read (including +/// the non-minimal forms some clients emit), and a literal `0`. The three +/// coincide by construction, since an absent entry RLP-decodes to `0u16` via +/// left-padding, and none of them names a port worth dialing. +pub(crate) fn read_quic_port(record: &NodeRecord) -> Option { + record + .pairs() + .extra_int::(QUIC_ENR_KEY) + .filter(|port| *port != 0) } #[cfg(test)] mod tests { use super::*; - use ethlambda_types::enr::{EnrForkId, decode_attnets}; - use ethrex_p2p::types::Node; use ethrex_rlp::decode::RLPDecode as _; use libssz::SszDecode; - use std::collections::HashSet; use std::net::Ipv4Addr; - fn test_signer() -> secp256k1::SecretKey { - secp256k1::SecretKey::new(&mut rand::rngs::OsRng) - } - fn build() -> NodeRecord { build_local_enr(&LocalEnrParams { - signer: test_signer(), + signer: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), ip: IpAddr::from(Ipv4Addr::LOCALHOST), discovery_port: 9010, quic_port: 9001, @@ -125,6 +199,66 @@ mod tests { .expect("ENR builds") } + #[test] + fn fork_digest_parses_the_constant() { + assert_eq!(fork_digest(), [0x12, 0x34, 0x56, 0x78]); + } + + #[test] + fn enr_fork_id_is_sixteen_bytes_and_round_trips() { + let id = EnrForkId::local(); + let bytes = id.to_ssz(); + assert_eq!(bytes.len(), 16, "ENRForkID is 4 + 4 + 8 bytes"); + assert_eq!(EnrForkId::from_ssz_bytes(&bytes).unwrap(), id); + } + + #[test] + fn local_fork_id_has_no_planned_fork() { + let id = EnrForkId::local(); + assert_eq!(id.fork_digest, fork_digest()); + assert_eq!(id.next_fork_version, NEXT_FORK_VERSION); + assert_eq!(id.next_fork_epoch, FAR_FUTURE_EPOCH); + } + + #[test] + fn attnets_sets_exactly_the_subscribed_bits() { + let subnets = HashSet::from([0u64, 3, 9]); + let bits = encode_attnets(&subnets, 16); + + assert_eq!(bits.len(), 2, "16 subnets need ceil(16/8) = 2 bytes"); + assert_eq!(subnets_from_attnets(&bits, 16), vec![0, 3, 9]); + } + + #[test] + fn attnets_rounds_the_byte_length_up() { + let bits = encode_attnets(&HashSet::from([0u64]), 1); + assert_eq!(bits.len(), 1); + assert_eq!(subnets_from_attnets(&bits, 1), vec![0]); + } + + #[test] + fn attnets_ignores_out_of_range_subnets() { + // A misconfigured subnet id must not panic or corrupt neighbouring bits. + let bits = encode_attnets(&HashSet::from([0u64, 99]), 8); + assert_eq!(bits, vec![0b0000_0001]); + } + + #[test] + fn attnets_reads_past_the_end_as_unset() { + // A peer advertising a shorter bitfield than our committee count is not + // an error; the missing subnets simply read as unsubscribed. + let bits = encode_attnets(&HashSet::from([0u64]), 8); + assert_eq!(subnets_from_attnets(&bits, 64), vec![0]); + } + + #[test] + fn attnets_ignores_bits_beyond_our_committee() { + // The hostile case: a peer padding its bitfield cannot manufacture + // subnets we have no committee for. + let bits = encode_attnets(&HashSet::from([2u64, 8, 40]), 64); + assert_eq!(subnets_from_attnets(&bits, 8), vec![2]); + } + #[test] fn local_enr_advertises_udp_and_quic_but_no_tcp() { let record = build(); @@ -140,15 +274,18 @@ mod tests { #[test] fn local_enr_carries_the_fork_id() { let record = build(); - let raw = read_extra(&record, b"eth2").expect("eth2 entry present"); + let raw = record.pairs().extra(ETH2_ENR_KEY).expect("eth2 entry"); assert_eq!(EnrForkId::from_ssz_bytes(&raw).unwrap(), EnrForkId::local()); } #[test] fn local_enr_carries_the_subscribed_subnets() { let record = build(); - let raw = read_extra(&record, b"attnets").expect("attnets entry present"); - assert_eq!(decode_attnets(&raw), vec![1, 4]); + let raw = record + .pairs() + .extra(ATTNETS_ENR_KEY) + .expect("attnets entry"); + assert_eq!(subnets_from_attnets(&raw, 8), vec![1, 4]); } #[test] diff --git a/crates/net/p2p/src/discovery/mod.rs b/crates/net/p2p/src/discovery/mod.rs index c151c0ec..7c48d39b 100644 --- a/crates/net/p2p/src/discovery/mod.rs +++ b/crates/net/p2p/src/discovery/mod.rs @@ -8,6 +8,7 @@ //! See `docs/discovery.md` for the operator-facing description. pub mod admission; +pub(crate) mod dial; pub mod enr; use std::collections::HashSet; @@ -15,18 +16,16 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::Duration; -use ethlambda_types::enr::EnrForkId; use ethrex_p2p::discovery::{DiscoveryConfig, DiscoveryServer}; use ethrex_p2p::peer_table::{PeerTable, PeerTableServer}; use ethrex_p2p::types::Node; use ethrex_storage::{EngineType, Store}; -use secp256k1::SecretKey; use tokio::net::UdpSocket; -use tracing::info; +use tracing::{info, warn}; use crate::Bootnode; use admission::LeanFilter; -use enr::{LocalEnrParams, build_local_enr}; +use enr::{EnrForkId, LocalEnrParams, build_local_enr}; /// How often the dial loop looks for a new peer. pub const DISCOVERY_DIAL_INTERVAL: Duration = Duration::from_secs(5); @@ -38,8 +37,32 @@ pub const DISCOVERY_TARGET_PEERS: usize = 16; /// Candidates drawn from the peer table per refill. pub const DISCOVERY_CANDIDATE_BATCH: usize = 8; +/// Why discovery could not be started. Every variant is fatal at startup. +#[derive(Debug, thiserror::Error)] +pub enum DiscoveryError { + #[error("failed to bind discovery socket on {addr}: {source}")] + BindSocket { + addr: SocketAddr, + source: std::io::Error, + }, + #[error("failed to read discovery socket address: {0}")] + SocketAddr(std::io::Error), + #[error("failed to build local ENR: {0}")] + BuildEnr(ethrex_p2p::types::NodeError), + #[error("failed to encode local ENR: {0}")] + EncodeEnr(ethrex_p2p::types::NodeError), + #[error("failed to create the discovery store: {0}")] + Store(String), + #[error("failed to start discovery server: {0}")] + Server(String), + #[error("node key is not a valid secp256k1 secret key: {0}")] + NodeKey(secp256k1::Error), +} + pub struct DiscoverySpawnConfig { - pub node_key: SecretKey, + /// Raw 32-byte secp256k1 secret key, the same bytes `SwarmConfig::node_key` + /// takes, so the binary never needs to name a crypto type. + pub node_key: Vec, pub bind_ip: IpAddr, pub discovery_port: u16, pub quic_port: u16, @@ -62,11 +85,6 @@ pub struct DiscoveryHandle { /// loop can apply the same rules when it turns a contact into a dial target. /// See [`LeanFilter`]. pub filter: LeanFilter, - /// The discv5 socket's actual bound address. Equal to the requested - /// `discovery_port` unless that was 0, in which case this carries the - /// port the OS assigned — the same one baked into `local_enr`'s `udp` - /// entry. - pub bound_addr: SocketAddr, } /// Bind the discv5 socket, build the local ENR, and start ethrex's discovery @@ -78,18 +96,25 @@ pub struct DiscoveryHandle { /// /// Only bootnodes whose ENR advertises a `udp` port can seed discv5; the rest /// are still dialed statically by `build_swarm`. -pub async fn spawn_discovery(config: DiscoverySpawnConfig) -> Result { +pub async fn spawn_discovery( + config: DiscoverySpawnConfig, +) -> Result { + let signer = + secp256k1::SecretKey::from_slice(&config.node_key).map_err(DiscoveryError::NodeKey)?; + let bind_addr = SocketAddr::new(config.bind_ip, config.discovery_port); let socket = UdpSocket::bind(bind_addr) .await - .map_err(|err| format!("failed to bind discovery socket on {bind_addr}: {err}"))?; - let bound = socket - .local_addr() - .map_err(|err| format!("failed to read discovery socket address: {err}"))?; + .map_err(|source| DiscoveryError::BindSocket { + addr: bind_addr, + source, + })?; + let bound = socket.local_addr().map_err(DiscoveryError::SocketAddr)?; + let advertise_ip = config.advertise_ip.unwrap_or(config.bind_ip); let params = LocalEnrParams { - signer: config.node_key, - ip: config.advertise_ip.unwrap_or(config.bind_ip), + signer, + ip: advertise_ip, discovery_port: bound.port(), quic_port: config.quic_port, subscription_subnets: config.subscription_subnets, @@ -97,9 +122,7 @@ pub async fn spawn_discovery(config: DiscoverySpawnConfig) -> Result Result Result) -> DiscoverySpawnConfig { + DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng) + .secret_bytes() + .to_vec(), bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), - discovery_port: 0, + discovery_port, quic_port: 9001, subscription_subnets: HashSet::from([0u64]), attestation_committee_count: 4, bootnodes: Vec::new(), - advertise_ip: None, - }) - .await - .expect("discovery spawns"); - - assert!(handle.local_enr.starts_with("enr:")); + advertise_ip, + } + } - // With discovery_port: 0 the OS picks the real port. The bound - // address must reflect that real port, and the published ENR's `udp` - // entry must match it: were the two out of sync (e.g. the ENR built - // from the requested port 0 instead of the bound one), the record - // would advertise an undialable node. - assert_ne!(handle.bound_addr.port(), 0); - let record = NodeRecord::decode(ðrex_common::base64::decode( - handle - .local_enr + fn decode_enr(local_enr: &str) -> NodeRecord { + NodeRecord::decode(ðrex_common::base64::decode( + local_enr .strip_prefix("enr:") .expect("enr: prefix") .as_bytes(), )) - .expect("local ENR decodes"); - assert_eq!(record.pairs().udp_port, Some(handle.bound_addr.port())); + .expect("local ENR decodes") + } + + #[tokio::test] + async fn spawn_binds_the_socket_and_returns_the_local_enr() { + // Port 0 asks the OS for a free port, so the test cannot collide with a + // running node or a sibling test. + let handle = spawn_discovery(config(0, None)) + .await + .expect("discovery spawns"); + + assert!(handle.local_enr.starts_with("enr:")); + let record = decode_enr(&handle.local_enr); + + // With discovery_port: 0 the OS picks the real port, and the published + // ENR must advertise that one: were it built from the requested 0 + // instead of the bound port, the record would name an undialable node. + let advertised_port = record.pairs().udp_port.expect("udp entry"); + assert_ne!(advertised_port, 0); // The policy handed to the peer table must admit our own record. A peer // running this code applies exactly these rules to what we publish, so a @@ -230,30 +257,11 @@ mod tests { // locally. The ENR must reflect the advertised address, not the bind // address. let advertised = Ipv4Addr::new(203, 0, 113, 7); - let handle = spawn_discovery(DiscoverySpawnConfig { - node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), - bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), - discovery_port: 0, - quic_port: 9001, - subscription_subnets: HashSet::from([0u64]), - attestation_committee_count: 4, - bootnodes: Vec::new(), - advertise_ip: Some(IpAddr::from(advertised)), - }) - .await - .expect("discovery spawns"); + let handle = spawn_discovery(config(0, Some(IpAddr::from(advertised)))) + .await + .expect("discovery spawns"); - assert_eq!(handle.bound_addr.ip(), IpAddr::from(Ipv4Addr::LOCALHOST)); - - let record = NodeRecord::decode(ðrex_common::base64::decode( - handle - .local_enr - .strip_prefix("enr:") - .expect("enr: prefix") - .as_bytes(), - )) - .expect("local ENR decodes"); - assert_eq!(record.pairs().ip, Some(advertised)); + assert_eq!(decode_enr(&handle.local_enr).pairs().ip, Some(advertised)); } #[tokio::test] @@ -261,17 +269,7 @@ mod tests { let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let busy = socket.local_addr().unwrap().port(); - let result = spawn_discovery(DiscoverySpawnConfig { - node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), - bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), - discovery_port: busy, - quic_port: 9001, - subscription_subnets: HashSet::new(), - attestation_committee_count: 4, - bootnodes: Vec::new(), - advertise_ip: None, - }) - .await; + let result = spawn_discovery(config(busy, None)).await; assert!(result.is_err(), "a busy discovery port must not be silent"); } diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index 9d7244fb..acbc59c5 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{HashMap, HashSet}, net::{IpAddr, SocketAddr}, ops::Range, time::Duration, @@ -13,7 +13,6 @@ use ethlambda_network_api::{ }; use ethlambda_storage::Store; use ethlambda_types::primitives::H256; -use ethrex_p2p::peer_table::{PeerTable, PeerTableServerProtocol as _}; use ethrex_p2p::types::NodeRecord; use ethrex_rlp::decode::RLPDecode; use futures::StreamExt; @@ -37,9 +36,9 @@ use tracing::{debug, info, trace, warn}; use crate::{ discovery::{ - DISCOVERY_CANDIDATE_BATCH, DISCOVERY_DIAL_INTERVAL, DISCOVERY_TARGET_PEERS, - DiscoveryHandle, - admission::{DiscoveredPeer, LeanFilter, rank_by_uncovered_subnets}, + DISCOVERY_DIAL_INTERVAL, DiscoveryHandle, + dial::{DiscoveryState, dial_tick}, + enr::read_quic_port, }, gossipsub::{ aggregation_topic, attestation_subnet_topic, block_topic, publish_aggregated_attestation, @@ -56,7 +55,7 @@ use crate::{ pub mod discovery; mod gossipsub; pub mod metrics; -pub mod req_resp; +mod req_resp; pub(crate) mod swarm_adapter; pub use libp2p::PeerId; @@ -141,19 +140,6 @@ impl RangeSyncState { } } -/// Everything the dial loop needs from a running discovery server. -pub(crate) struct DiscoveryState { - pub(crate) peer_table: PeerTable, - /// The same policy the peer table judges records with, asked here for the - /// dial target behind an already-admitted record. - pub(crate) filter: LeanFilter, - /// Admitted candidates, best first, drained one per tick. Refilled from the - /// peer table when empty. - pub(crate) candidates: VecDeque, - /// Subnets advertised by peers we dialed from discovery. - pub(crate) peer_attnets: HashMap>, -} - // --- Swarm construction --- /// [libp2p Behaviour](libp2p::swarm::NetworkBehaviour) combining identify, Gossipsub @@ -327,12 +313,7 @@ pub fn build_swarm( debug!(%peer_id, ip = %bootnode.ip, "Bootnode advertises no quic port, discv5 seed only"); continue; }; - let addr = Multiaddr::empty() - .with(bootnode.ip.into()) - .with(Protocol::Udp(quic_port)) - .with(Protocol::QuicV1) - .with_p2p(peer_id) - .expect("failed to add peer ID to multiaddr"); + let addr = quic_multiaddr(bootnode.ip, quic_port, peer_id); bootnode_addrs.insert(peer_id, addr.clone()); swarm.dial(addr).unwrap(); } @@ -414,6 +395,7 @@ impl P2P { let (swarm_stream, swarm_handle) = swarm_adapter::start_swarm_adapter(built.swarm, node_names.clone()); + let discovery_enabled = discovery.is_some(); let server = P2PServer { swarm_handle, store, @@ -428,16 +410,8 @@ impl P2P { range_sync_state: None, bootnode_addrs: built.bootnode_addrs, node_names, - local_peer_id: built.local_peer_id, - discovery: discovery.map(|handle| DiscoveryState { - peer_table: handle.peer_table, - filter: handle.filter, - candidates: VecDeque::new(), - peer_attnets: HashMap::new(), - }), + discovery: discovery.map(|handle| DiscoveryState::new(handle, built.local_peer_id)), }; - // Read the flag before `server` is moved into `start()`. - let discovery_enabled = server.discovery.is_some(); let handle = server.start(); if discovery_enabled { send_after( @@ -484,8 +458,6 @@ pub struct P2PServer { /// Set when discovery is enabled. `None` disables the dial loop entirely. pub(crate) discovery: Option, - /// Our own peer ID, so the dial loop never dials itself. - pub(crate) local_peer_id: PeerId, } impl P2PServer { @@ -564,79 +536,7 @@ impl P2PServer { ctx.clone(), p2p_protocol::DiscoverPeers, ); - - if self.connected_peers.len() >= DISCOVERY_TARGET_PEERS { - return; - } - - // Snapshot what the refill needs before any `.await`, so no borrow of - // `self.discovery` has to live across the async boundary. - let Some((peer_table, filter, needs_refill)) = self.discovery.as_ref().map(|discovery| { - ( - discovery.peer_table.clone(), - discovery.filter.clone(), - discovery.candidates.is_empty(), - ) - }) else { - return; - }; - - if needs_refill { - // ethrex serves one contact per call, skipping anything its - // `PeerFilter` (ours: [`LeanFilter`]) already rejected, and records - // each as tried before returning it. So successive calls never - // repeat, an early `None` means the pool is exhausted, and - // everything that arrives here has already passed admission. - let mut admitted = Vec::with_capacity(DISCOVERY_CANDIDATE_BATCH); - for _ in 0..DISCOVERY_CANDIDATE_BATCH { - let Ok(Some(contact)) = peer_table.get_contact_to_initiate().await else { - break; - }; - // A contact whose ENR has not arrived is unjudged, so the peer - // table still offers it, but it carries no address or peer id to - // dial. Skipping it costs nothing: it was marked tried on the way - // out either way, and that set is cleared once a full scan finds - // nothing eligible. - let Some(peer) = contact - .record - .as_ref() - .and_then(|record| filter.dial_target(record)) - else { - continue; - }; - admitted.push(peer); - } - - let Some(discovery) = self.discovery.as_mut() else { - return; - }; - let covered = covered_subnets(&discovery.peer_attnets, &self.connected_peers); - rank_by_uncovered_subnets(&mut admitted, &covered); - discovery.candidates.extend(admitted); - } - - let local_peer_id = self.local_peer_id; - let Some(discovery) = self.discovery.as_mut() else { - return; - }; - while let Some(candidate) = discovery.candidates.pop_front() { - if candidate.peer_id == local_peer_id - || self.connected_peers.contains(&candidate.peer_id) - { - continue; - } - info!( - peer_id = %candidate.peer_id, - subnets = ?candidate.subnets, - "Dialing discovered peer" - ); - discovery - .peer_attnets - .insert(candidate.peer_id, candidate.subnets.clone()); - metrics::inc_discovered_peers_dialed(); - self.swarm_handle.dial(candidate.addr); - break; - } + dial_tick(self).await; } } @@ -767,9 +667,7 @@ async fn handle_swarm_event( }; if num_established == 0 { server.connected_peers.remove(&peer_id); - if let Some(discovery) = server.discovery.as_mut() { - discovery.peer_attnets.remove(&peer_id); - } + server.forget_discovered_peer(&peer_id); let peer_count = server.connected_peers.len(); metrics::notify_peer_disconnected( server.resolve_node_name(Some(&peer_id)), @@ -812,15 +710,10 @@ async fn handle_swarm_event( warn!(?peer_id, %error, "Outgoing connection error"); // A dial that never establishes ends up here rather than in - // `ConnectionClosed`, so this is the only place a discovery-fed - // `peer_attnets` entry can be removed for a peer we dialed but - // never actually connected to. Leaving it would grow the map - // without bound and make `covered_subnets` credit a subnet to a - // peer that isn't connected. - if let Some(pid) = peer_id - && let Some(discovery) = server.discovery.as_mut() - { - discovery.peer_attnets.remove(&pid); + // `ConnectionClosed`, so this is the only place a peer we dialed but + // never connected to can be forgotten. + if let Some(pid) = peer_id { + server.forget_discovered_peer(&pid); } // Schedule redial if this was a bootnode @@ -875,6 +768,9 @@ pub fn derive_peer_ids(names_and_privkeys: HashMap) -> HashMap Result { let record = NodeRecord::decode(&decoded).map_err(|err| format!("RLP decode failed: {err}"))?; let pairs = record.pairs(); - // A record with no `quic` entry is not an error: it is discv5-reachable but - // speaks no transport we have, which is exactly what every beacon-chain - // bootnode looks like. Keep it as a discovery seed and let `build_swarm` - // skip it when it picks static dial targets. - // `extra_int` answers `None` both for an absent entry and for one whose - // encoding it cannot read, which includes the non-minimal forms some - // clients emit. Either way there is no port we can dial. - // - // A zero is filtered out too: an absent entry RLP-decodes to 0 via - // left-padding, and 0 is undialable regardless, so both collapse to "no - // quic port". - let quic_port = pairs.extra_int::(b"quic").filter(|port| *port != 0); + // A record with no dialable `quic` entry is not an error: it is + // discv5-reachable but speaks no transport we have, which is exactly what + // every beacon-chain bootnode looks like. Keep it as a discovery seed and let + // `build_swarm` skip it when it picks static dial targets. + let quic_port = read_quic_port(&record); let public_key_bytes = pairs .secp256k1 @@ -987,20 +879,19 @@ fn parse_enr(enr_str: &str) -> Result { // --- Utility functions --- -/// Attestation subnets covered by peers we are currently connected to. +/// The address of a libp2p QUIC listener, as both dial paths spell it: static +/// bootnodes in [`build_swarm`] and discovered peers in +/// [`admission::admit`](discovery::admission). /// -/// Only peers dialed from discovery contribute, since an inbound peer never -/// tells us its `attnets`. Treating an unknown peer as covering nothing makes -/// the ranking more eager, never wrong. -fn covered_subnets( - peer_attnets: &HashMap>, - connected_peers: &HashSet, -) -> HashSet { - peer_attnets - .iter() - .filter(|(peer, _)| connected_peers.contains(peer)) - .flat_map(|(_, subnets)| subnets.iter().copied()) - .collect() +/// Infallible: `with_p2p` only rejects a multiaddr that already carries a `p2p` +/// component, and this one is built fresh. +pub(crate) fn quic_multiaddr(ip: IpAddr, quic_port: u16, peer_id: PeerId) -> Multiaddr { + Multiaddr::empty() + .with(ip.into()) + .with(Protocol::Udp(quic_port)) + .with(Protocol::QuicV1) + .with_p2p(peer_id) + .expect("a freshly built multiaddr carries no p2p component") } fn connection_direction(endpoint: &libp2p::core::ConnectedPoint) -> &'static str { @@ -1143,20 +1034,6 @@ mod tests { } } - #[test] - fn covered_subnets_unions_only_connected_peers() { - let connected = random_peer(); - let gone = random_peer(); - let mut server_subnets = HashMap::new(); - server_subnets.insert(connected, vec![1u64, 2]); - server_subnets.insert(gone, vec![7u64]); - - let connected_peers = HashSet::from([connected]); - let covered = covered_subnets(&server_subnets, &connected_peers); - - assert_eq!(covered, HashSet::from([1, 2])); - } - #[test] fn parse_enrs_extracts_the_udp_port_when_present() { // `secp256k1` is already bound in this module to `libp2p::identity::secp256k1` diff --git a/crates/net/p2p/src/req_resp/mod.rs b/crates/net/p2p/src/req_resp/mod.rs index 2242ea62..11acb79f 100644 --- a/crates/net/p2p/src/req_resp/mod.rs +++ b/crates/net/p2p/src/req_resp/mod.rs @@ -1,12 +1,5 @@ mod codec; -/// The beacon-chain `ssz_snappy` request/response framing: a varint -/// uncompressed-length prefix followed by a snappy *frame*-compressed payload. -/// -/// Public because it is the spec's wire format rather than anything specific to -/// lean's message set, so a client speaking a different set of req/resp -/// protocols over the same framing can reuse it. `examples/mainnet_gossip.rs` -/// does exactly that. -pub mod encoding; +mod encoding; pub mod handlers; mod messages; diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index d648bff0..0a636d45 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -3,6 +3,7 @@ use ethlambda_blockchain::metrics::SyncStatus; use ethlambda_blockchain::{MILLISECONDS_PER_SLOT, SyncStatusController}; use ethlambda_storage::Store; use serde::Serialize; +use std::sync::Arc; use crate::json_response; @@ -29,10 +30,10 @@ pub struct NodeIdentity { } #[derive(Serialize)] -struct IdentityResponse { +struct IdentityResponse<'a> { version: &'static str, #[serde(flatten)] - identity: NodeIdentity, + identity: &'a NodeIdentity, } /// Sync status for `/lean/v0/node/syncing`. @@ -77,11 +78,17 @@ async fn get_syncing( /// rustc version), the node's libp2p peer ID, and its discv5 ENR (`null` when /// discovery is disabled). All three are fixed at startup and captured by the /// route in `routes`. -async fn get_identity(version: &'static str, identity: NodeIdentity) -> impl IntoResponse { - json_response(IdentityResponse { version, identity }) +async fn get_identity(version: &'static str, identity: Arc) -> impl IntoResponse { + json_response(IdentityResponse { + version, + identity: &identity, + }) } pub(crate) fn routes(version: &'static str, identity: NodeIdentity) -> Router { + // `Arc` so the route closure hands each request a refcount bump rather than + // re-cloning two startup-fixed strings. + let identity = Arc::new(identity); Router::new() .route("/lean/v0/node/syncing", get(get_syncing)) .route( From 2425c2dd504d06bc6b15c716502551b872324d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:01:11 -0300 Subject: [PATCH 3/6] docs: keep the discovery entry inside the Operations section The merge with main placed it after a blank line, outside the list it belongs to and flush against the new Development heading. --- docs/SUMMARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 387fbac2..f343d34b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,8 +15,8 @@ - [Checkpoint Sync](./checkpoint_sync.md) - [Fork Choice Visualization](./fork_choice_visualization.md) - [Data Storage](./data_storage.md) - - [Peer discovery](./discovery.md) + # Development - [Spec Deviations](./spec_deviations.md) From 084caaf7c8944eac33c141a0d6606a9ba1526bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:25:17 -0300 Subject: [PATCH 4/6] chore(deps): bump ethrex to the feat/discovery-peer-requirements tip Our lock pinned 669de531, which is no longer on the branch: it was rebased away, so the build only kept working because the old commit was still in the local Cargo cache. A fresh clone would not have resolved it. Three API changes come with f30b16d5: - `PeerTableServer::spawn_with_filter` takes `impl PeerFilter + 'static` instead of `Box`, so the call site drops its `Box::new`. - `NodeRecordPairs::set_extra_int` takes a `u64` rather than any `RLPEncode`, which is deliberate upstream: a generic bound under a method named for integers would re-open the encode-a-`Vec`-as-a-list footgun that `set_extra` exists to close. - Both setters now answer whether the entry was stored, `false` for a key the record already has a typed field for. `attnets`, `eth2` and `quic` are all outside that dictionary and the tests assert each one lands in the built record, so `local_pairs` does not check the answers. `PeerFilter::accepts` is unchanged, so `LeanFilter` needed no edit. --- Cargo.lock | 42 ++++++++++++++--------------- crates/net/p2p/src/discovery/enr.rs | 6 ++++- crates/net/p2p/src/discovery/mod.rs | 2 +- crates/net/p2p/src/lib.rs | 2 +- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da3ddff6..a6614f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -183,7 +183,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1848,7 +1848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2128,7 +2128,7 @@ dependencies = [ [[package]] name = "ethrex-blockchain" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "crossbeam", @@ -2153,7 +2153,7 @@ dependencies = [ [[package]] name = "ethrex-common" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "crc32fast", @@ -2187,7 +2187,7 @@ dependencies = [ [[package]] name = "ethrex-crypto" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "ark-bn254", "ark-ec", @@ -2209,7 +2209,7 @@ dependencies = [ [[package]] name = "ethrex-guest-program" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "ethereum-types", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "ethrex-l2-common" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "ethereum-types", @@ -2246,7 +2246,7 @@ dependencies = [ [[package]] name = "ethrex-levm" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "derive_more 1.0.0", @@ -2264,7 +2264,7 @@ dependencies = [ [[package]] name = "ethrex-metrics" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "ethrex-common", "serde", @@ -2276,7 +2276,7 @@ dependencies = [ [[package]] name = "ethrex-p2p" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "aes", "aes-gcm", @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "ethrex-rlp" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "ethereum-types", @@ -2328,7 +2328,7 @@ dependencies = [ [[package]] name = "ethrex-storage" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "anyhow", "bytes", @@ -2350,7 +2350,7 @@ dependencies = [ [[package]] name = "ethrex-trie" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "anyhow", "bytes", @@ -2370,7 +2370,7 @@ dependencies = [ [[package]] name = "ethrex-vm" version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#669de531243902dd68fb4c6512b3a4f8ea9cc089" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" dependencies = [ "bytes", "derive_more 1.0.0", @@ -3130,7 +3130,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -5094,7 +5094,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6556,7 +6556,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7040,7 +7040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7311,7 +7311,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8137,7 +8137,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/crates/net/p2p/src/discovery/enr.rs b/crates/net/p2p/src/discovery/enr.rs index f63ac430..42b5c0a2 100644 --- a/crates/net/p2p/src/discovery/enr.rs +++ b/crates/net/p2p/src/discovery/enr.rs @@ -153,10 +153,14 @@ impl LocalEnrParams { IpAddr::V6(ip) => pairs.ip6 = Some(ip), } + // Each setter answers whether the entry was stored, which is `false` + // only for a key the record already has a typed field for. All three + // below are outside that dictionary, and the tests assert each one lands + // in the built record, so the answers are not checked here. let attnets = encode_attnets(&self.subscription_subnets, self.attestation_committee_count); pairs.set_extra(ATTNETS_ENR_KEY, attnets); pairs.set_extra(ETH2_ENR_KEY, EnrForkId::local().to_ssz()); - pairs.set_extra_int(QUIC_ENR_KEY, self.quic_port); + pairs.set_extra_int(QUIC_ENR_KEY, self.quic_port.into()); pairs } } diff --git a/crates/net/p2p/src/discovery/mod.rs b/crates/net/p2p/src/discovery/mod.rs index 7c48d39b..6bb89c89 100644 --- a/crates/net/p2p/src/discovery/mod.rs +++ b/crates/net/p2p/src/discovery/mod.rs @@ -136,7 +136,7 @@ pub async fn spawn_discovery( let peer_table = PeerTableServer::spawn_with_filter( local_node.node_id(), DISCOVERY_TARGET_PEERS, - Box::new(filter.clone()), + filter.clone(), ); let seeds: Vec = config diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index acbc59c5..ef5a1581 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -1051,7 +1051,7 @@ mod tests { tcp_port: None, ..Default::default() }; - pairs.set_extra_int(b"quic", 9001u16); + pairs.set_extra_int(b"quic", 9001); let record = NodeRecord::from_pairs(1, &signer, pairs).unwrap(); let bootnodes = parse_enrs(vec![record.enr_url().unwrap()]); From 0bf322c592c862fe5f4cfbc5d5e24c232a58b38f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:51:31 -0300 Subject: [PATCH 5/6] test(p2p): build admission-test ENRs through the extras accessors The helpers predate `f7fddb9dc` upstream, which added the `set_extra*` accessors so callers stop writing `extra_fields` directly. They still assigned the whole bag and hand-rolled the RLP for each entry, which made these tests the one place an ENR was assembled differently from the way `build_local_enr` assembles one: a `pair()` returning `(Bytes, Bytes)`, `Bytes::from(..).encode_to_vec()` per payload, and a comment explaining which of the two encodings that produced. `record_with` now takes a closure over `NodeRecordPairs` and the entries go through `set_extra`/`set_extra_int`, so a record these tests accept is one built the way production builds it, encoding included. Assertions are unchanged. Since nothing names `Bytes` any more, the `bytes` dev-dependency and the `ethrex_rlp::encode::RLPEncode` import go with it. `set_extra_encoded` stays unused: it exists for values the typed setters cannot express, such as a deliberately malformed RLP list, and no test wants one yet. --- Cargo.lock | 1 - crates/net/p2p/Cargo.toml | 4 +- crates/net/p2p/src/discovery/admission.rs | 136 ++++++++++++---------- 3 files changed, 76 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6614f95..97610658 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2008,7 +2008,6 @@ dependencies = [ name = "ethlambda-p2p" version = "0.1.0" dependencies = [ - "bytes", "ethlambda-metrics", "ethlambda-network-api", "ethlambda-storage", diff --git a/crates/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index 55a8df7a..7788ba0a 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -60,7 +60,5 @@ sha2 = "0.10" [dev-dependencies] hex.workspace = true -# `Bytes` builds the raw ENR entries the admission tests feed to `NodeRecord`, -# and `rand` seeds their throwaway signing keys. Neither is used outside tests. -bytes = "1.6.0" +# `rand` seeds the throwaway signing keys the ENR tests build records with. secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context", "rand"] } diff --git a/crates/net/p2p/src/discovery/admission.rs b/crates/net/p2p/src/discovery/admission.rs index 2c8b77cc..6dbae62c 100644 --- a/crates/net/p2p/src/discovery/admission.rs +++ b/crates/net/p2p/src/discovery/admission.rs @@ -202,19 +202,25 @@ impl DiscoveredPeer { #[cfg(test)] mod tests { use super::*; - use bytes::Bytes; - use ethrex_p2p::types::Node; + use ethrex_p2p::types::{Node, NodeRecordPairs}; use ethrex_p2p::utils::public_key_from_signing_key; - use ethrex_rlp::encode::RLPEncode; use libssz::SszEncode; use std::collections::HashSet; use std::net::Ipv4Addr; use super::super::enr::{FAR_FUTURE_EPOCH, QUIC_ENR_KEY, encode_attnets}; - /// Build an ENR with an arbitrary set of extra pairs, so each test can omit - /// or corrupt exactly one entry. - fn record_with(extra: Vec<(Bytes, Bytes)>) -> NodeRecord { + /// The committee count these tests admit against. `set_attnets` encodes to + /// the same width, so the subnets below are all meant to be in range. + const TEST_COMMITTEE_COUNT: u64 = 8; + + /// Build an ENR, applying `set_entries` to its extras so each test can omit + /// or corrupt exactly one of them. + /// + /// Entries go through the same `set_extra*` accessors `build_local_enr` uses, + /// rather than assigning `extra_fields` directly: a record these tests accept + /// is then one built the way production builds it, encoding included. + fn record_with(set_entries: impl FnOnce(&mut NodeRecordPairs)) -> NodeRecord { let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); let public_key = public_key_from_signing_key(&signer); let node = Node::new(IpAddr::from(Ipv4Addr::LOCALHOST), 9010, 0, public_key); @@ -225,35 +231,37 @@ mod tests { record .edit(&signer, |pairs| { pairs.tcp_port = None; - pairs.extra_fields = extra; + set_entries(pairs); }) .unwrap(); record } - fn pair(key: &'static [u8], value: Vec) -> (Bytes, Bytes) { - (Bytes::from_static(key), Bytes::from(value)) + fn set_eth2(pairs: &mut NodeRecordPairs, fork_id: EnrForkId) { + pairs.set_extra(ETH2_ENR_KEY, fork_id.to_ssz()); } - // Byte payloads go through `Bytes` so RLP encodes them as a byte string; see - // `LocalEnrParams::local_pairs` for why a bare `Vec` would not. `u16` is - // a scalar and needs no wrapping. - fn eth2_pair(fork_id: EnrForkId) -> (Bytes, Bytes) { - pair(ETH2_ENR_KEY, Bytes::from(fork_id.to_ssz()).encode_to_vec()) + fn set_quic(pairs: &mut NodeRecordPairs, port: u16) { + pairs.set_extra_int(QUIC_ENR_KEY, port.into()); } - fn quic_pair(port: u16) -> (Bytes, Bytes) { - pair(QUIC_ENR_KEY, port.encode_to_vec()) + fn set_attnets(pairs: &mut NodeRecordPairs, subnets: &[u64]) { + let subnets = subnets.iter().copied().collect::>(); + set_attnets_bits(pairs, encode_attnets(&subnets, TEST_COMMITTEE_COUNT)); } - fn attnets_pair(subnets: &[u64]) -> (Bytes, Bytes) { - let bits = encode_attnets(&subnets.iter().copied().collect::>(), 8); - pair(ATTNETS_ENR_KEY, Bytes::from(bits).encode_to_vec()) + /// `attnets` from raw bytes, for the widths `encode_attnets` would not + /// produce for us: a foreign committee count, or a hostile pad. + fn set_attnets_bits(pairs: &mut NodeRecordPairs, bits: Vec) { + pairs.set_extra(ATTNETS_ENR_KEY, bits); } - /// `attnets_pair` above encodes against a committee count of 8, so this - /// matches it: subnets in the tests below are all meant to be in-range. - const TEST_COMMITTEE_COUNT: u64 = 8; + /// The `eth2` and `quic` entries that get a record past every check except + /// the one under test. + fn set_admissible_entries(pairs: &mut NodeRecordPairs) { + set_eth2(pairs, EnrForkId::local()); + set_quic(pairs, 9001); + } fn admit_record(record: &NodeRecord) -> Result { admit(record, &EnrForkId::local(), TEST_COMMITTEE_COUNT) @@ -265,17 +273,17 @@ mod tests { /// is the only way to reach a record with a missing/invalid public key or /// with neither `ip` nor `ip6`, both of which `record_with` always fills /// in from the `Node` it wraps. - fn raw_record(pairs: ethrex_p2p::types::NodeRecordPairs) -> NodeRecord { + fn raw_record(mut pairs: NodeRecordPairs) -> NodeRecord { + set_admissible_entries(&mut pairs); NodeRecord::new(ethrex_common::H512::zero(), 1, pairs) } #[test] fn accepts_a_well_formed_peer() { - let record = record_with(vec![ - attnets_pair(&[2, 5]), - eth2_pair(EnrForkId::local()), - quic_pair(9001), - ]); + let record = record_with(|pairs| { + set_attnets(pairs, &[2, 5]); + set_admissible_entries(pairs); + }); let peer = admit_record(&record).expect("accepted"); assert_eq!(peer.subnets, vec![2, 5]); assert_eq!( @@ -286,7 +294,7 @@ mod tests { #[test] fn rejects_a_peer_with_no_eth2_entry() { - let record = record_with(vec![quic_pair(9001)]); + let record = record_with(|pairs| set_quic(pairs, 9001)); assert_eq!(admit_record(&record), Err(RejectReason::MissingForkId)); } @@ -294,7 +302,10 @@ mod tests { fn rejects_a_peer_on_another_network() { let mut foreign = EnrForkId::local(); foreign.fork_digest = [0xde, 0xad, 0xbe, 0xef]; - let record = record_with(vec![eth2_pair(foreign), quic_pair(9001)]); + let record = record_with(|pairs| { + set_eth2(pairs, foreign); + set_quic(pairs, 9001); + }); assert_eq!(admit_record(&record), Err(RejectReason::ForkDigestMismatch)); } @@ -306,14 +317,17 @@ mod tests { let mut upcoming = EnrForkId::local(); upcoming.next_fork_version = [9, 9, 9, 9]; upcoming.next_fork_epoch = FAR_FUTURE_EPOCH - 1; - let record = record_with(vec![eth2_pair(upcoming), quic_pair(9001)]); + let record = record_with(|pairs| { + set_eth2(pairs, upcoming); + set_quic(pairs, 9001); + }); assert!(admit_record(&record).is_ok()); } #[test] fn rejects_a_peer_with_no_quic_port() { // Reachable by discv5 but not over our only transport. - let record = record_with(vec![eth2_pair(EnrForkId::local())]); + let record = record_with(|pairs| set_eth2(pairs, EnrForkId::local())); assert_eq!(admit_record(&record), Err(RejectReason::NoQuicPort)); } @@ -323,18 +337,20 @@ mod tests { // decodes (left-padded to 0u16), so it must hit the same reason as // `rejects_a_peer_with_no_quic_port` rather than sail through as // "accepted" with an unusable `/udp/0/quic-v1` multiaddr. - let record = record_with(vec![eth2_pair(EnrForkId::local()), quic_pair(0)]); + let record = record_with(|pairs| { + set_eth2(pairs, EnrForkId::local()); + set_quic(pairs, 0); + }); assert_eq!(admit_record(&record), Err(RejectReason::NoQuicPort)); } #[test] fn rejects_a_peer_with_an_invalid_public_key() { - let pairs = ethrex_p2p::types::NodeRecordPairs { + let pairs = NodeRecordPairs { // `0xff` is not a valid compressed secp256k1 point tag (`02`/`03`). secp256k1: Some(ethrex_common::H264([0xff; 33])), ip: Some(Ipv4Addr::LOCALHOST), udp_port: Some(9010), - extra_fields: vec![eth2_pair(EnrForkId::local()), quic_pair(9001)], ..Default::default() }; assert_eq!( @@ -347,10 +363,9 @@ mod tests { fn rejects_a_peer_with_neither_ip_nor_ip6() { let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); let compressed = signer.public_key(secp256k1::SECP256K1).serialize(); - let pairs = ethrex_p2p::types::NodeRecordPairs { + let pairs = NodeRecordPairs { secp256k1: Some(ethrex_common::H264(compressed)), udp_port: Some(9010), - extra_fields: vec![eth2_pair(EnrForkId::local()), quic_pair(9001)], ..Default::default() }; assert_eq!( @@ -363,7 +378,7 @@ mod tests { fn accepts_a_peer_with_no_attnets() { // subnet_predicate treats a missing bitfield as covering no subnets, but // that never excludes a peer from general discovery. - let record = record_with(vec![eth2_pair(EnrForkId::local()), quic_pair(9001)]); + let record = record_with(set_admissible_entries); let peer = admit_record(&record).expect("accepted"); assert!(peer.subnets.is_empty()); } @@ -374,12 +389,10 @@ mod tests { // ids our own committee count has no room for; `admit` must not surface // them. `subnets_from_attnets` is what enforces that (and is tested // directly in `enr`); this checks `admit` actually routes through it. - let bits = encode_attnets(&HashSet::from([2u64, 8, 40]), 64); - let record = record_with(vec![ - pair(ATTNETS_ENR_KEY, Bytes::from(bits).encode_to_vec()), - eth2_pair(EnrForkId::local()), - quic_pair(9001), - ]); + let record = record_with(|pairs| { + set_attnets_bits(pairs, encode_attnets(&HashSet::from([2u64, 8, 40]), 64)); + set_admissible_entries(pairs); + }); let peer = admit_record(&record).expect("accepted"); assert_eq!(peer.subnets, vec![2]); } @@ -392,11 +405,10 @@ mod tests { #[test] fn a_well_formed_record_is_accepted_and_dialable() { - let record = record_with(vec![ - attnets_pair(&[2, 5]), - eth2_pair(EnrForkId::local()), - quic_pair(9001), - ]); + let record = record_with(|pairs| { + set_attnets(pairs, &[2, 5]); + set_admissible_entries(pairs); + }); assert!(filter().accepts(&record)); let peer = filter().dial_target(&record).expect("dialable"); @@ -407,7 +419,10 @@ mod tests { fn another_network_is_rejected() { let mut foreign = EnrForkId::local(); foreign.fork_digest = [0xde, 0xad, 0xbe, 0xef]; - let record = record_with(vec![eth2_pair(foreign), quic_pair(9001)]); + let record = record_with(|pairs| { + set_eth2(pairs, foreign); + set_quic(pairs, 9001); + }); assert!(!filter().accepts(&record)); assert!(filter().dial_target(&record).is_none()); @@ -420,7 +435,7 @@ mod tests { // again on a higher-`seq` record. This is what the dial-time // `set_unwanted` this replaced could not express, since ethrex never // clears that flag. - let record = record_with(vec![eth2_pair(EnrForkId::local())]); + let record = record_with(|pairs| set_eth2(pairs, EnrForkId::local())); assert!(!filter().accepts(&record)); assert!(filter().dial_target(&record).is_none()); @@ -435,16 +450,15 @@ mod tests { let mut hostile_bits = vec![0u8; TEST_COMMITTEE_COUNT.div_ceil(8) as usize]; hostile_bits.extend(vec![0xffu8; 290]); - let honest = record_with(vec![ - attnets_pair(&[3]), - eth2_pair(EnrForkId::local()), - quic_pair(9001), - ]); - let hostile = record_with(vec![ - pair(ATTNETS_ENR_KEY, Bytes::from(hostile_bits).encode_to_vec()), - eth2_pair(EnrForkId::local()), - quic_pair(9002), - ]); + let honest = record_with(|pairs| { + set_attnets(pairs, &[3]); + set_admissible_entries(pairs); + }); + let hostile = record_with(|pairs| { + set_attnets_bits(pairs, hostile_bits); + set_eth2(pairs, EnrForkId::local()); + set_quic(pairs, 9002); + }); let policy = filter(); let mut admitted: Vec<_> = [honest, hostile] From 337e4ba06f7556cdcf3b400eeae559875b809407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:02:09 -0300 Subject: [PATCH 6/6] chore(deps): serve the ENR we report, on the ethrex branch tip Bumps ethrex to the feat/discovery-peer-requirements tip (f30b16d5 -> bf401280, rebased onto main 24.0.0), which reworks `DiscoveryServer::spawn` to take a prepared `NodeRecord` instead of a `Store` it derived one from. That closes the gap docs/discovery.md called "the record ethrex serves is not the record we report": ethrex built its own copy from the local `Node`, so what it answered discv5 queries with carried `ip`, `udp` and `secp256k1` but none of `eth2`, `attnets` or `quic`. A lean peer applying our own admission rules to that record rejected us for the missing `quic` entry, so discovery found peers but could not be found by them. We now hand `spawn` the same record `enr_url` reports, and ethrex edits and re-signs it on IP voting rather than rebuilding, so the consensus entries survive a sequence bump. The empty in-memory ethrex `Store` existed only to satisfy the old signature, so both it and the `ethrex-storage` dependency go, along with the `DiscoveryError::Store` variant that could no longer be constructed. --- CLAUDE.md | 2 +- Cargo.lock | 67 ++++++++++++++--------------- crates/net/p2p/Cargo.toml | 2 - crates/net/p2p/src/discovery/mod.rs | 19 +++----- docs/discovery.md | 23 +++------- 5 files changed, 46 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 66109be0..9707fb5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,7 +282,7 @@ actual_slot = finalized_slot + 1 + relative_index ### Peer Discovery (discv5, opt-in) - Off by default; `--discovery.enable` plus `--discovery.port` (own UDP socket, must differ from `--gossipsub-port`) -- Reuses ethrex's `DiscoveryServer` + `PeerTable` with discv4 disabled and an empty in-memory ethrex `Store` (`spawn` requires one; lean has no execution chain) +- Reuses ethrex's `DiscoveryServer` + `PeerTable` with discv4 disabled; `spawn` takes the prepared lean ENR, so the record ethrex serves is the one we report - ENR follows the beacon phase0 spec: `ip`/`udp`/`quic`/`secp256k1`/`eth2`/`attnets`, deliberately **no** `tcp` - Admission mirrors lighthouse: `eth2.fork_digest` must match, `next_fork_*` may differ, `quic` entry required. Handed to the peer table as `LeanFilter: PeerFilter`, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-`seq` ENR - Candidates ranked by uncovered attestation subnets. See [`docs/discovery.md`](docs/discovery.md) diff --git a/Cargo.lock b/Cargo.lock index 97610658..8c63d2b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -183,7 +183,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1848,7 +1848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2015,7 +2015,6 @@ dependencies = [ "ethrex-common", "ethrex-p2p", "ethrex-rlp", - "ethrex-storage", "futures", "hex", "libp2p", @@ -2126,8 +2125,8 @@ dependencies = [ [[package]] name = "ethrex-blockchain" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "crossbeam", @@ -2151,8 +2150,8 @@ dependencies = [ [[package]] name = "ethrex-common" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "crc32fast", @@ -2185,8 +2184,8 @@ dependencies = [ [[package]] name = "ethrex-crypto" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "ark-bn254", "ark-ec", @@ -2207,8 +2206,8 @@ dependencies = [ [[package]] name = "ethrex-guest-program" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "ethereum-types", @@ -2226,8 +2225,8 @@ dependencies = [ [[package]] name = "ethrex-l2-common" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "ethereum-types", @@ -2244,8 +2243,8 @@ dependencies = [ [[package]] name = "ethrex-levm" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "derive_more 1.0.0", @@ -2262,8 +2261,8 @@ dependencies = [ [[package]] name = "ethrex-metrics" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "ethrex-common", "serde", @@ -2274,8 +2273,8 @@ dependencies = [ [[package]] name = "ethrex-p2p" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "aes", "aes-gcm", @@ -2316,8 +2315,8 @@ dependencies = [ [[package]] name = "ethrex-rlp" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "ethereum-types", @@ -2326,8 +2325,8 @@ dependencies = [ [[package]] name = "ethrex-storage" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "anyhow", "bytes", @@ -2348,8 +2347,8 @@ dependencies = [ [[package]] name = "ethrex-trie" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "anyhow", "bytes", @@ -2368,8 +2367,8 @@ dependencies = [ [[package]] name = "ethrex-vm" -version = "23.0.0" -source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#f30b16d5aa1fea2762c4baade0c1f45e69f3cfc3" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "derive_more 1.0.0", @@ -3129,7 +3128,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -5093,7 +5092,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6555,7 +6554,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7039,7 +7038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7310,7 +7309,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8136,7 +8135,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index 7788ba0a..d64de18e 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -44,8 +44,6 @@ rand = "0.8" ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } ethrex-common = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } -# Only for the empty in-memory Store that DiscoveryServer::spawn requires. -ethrex-storage = { git = "https://github.com/lambdaclass/ethrex", branch = "feat/discovery-peer-requirements" } # Version pinned to ethrex's workspace: `SecretKey` crosses the API boundary. secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context"] } diff --git a/crates/net/p2p/src/discovery/mod.rs b/crates/net/p2p/src/discovery/mod.rs index 6bb89c89..b04e96eb 100644 --- a/crates/net/p2p/src/discovery/mod.rs +++ b/crates/net/p2p/src/discovery/mod.rs @@ -19,7 +19,6 @@ use std::time::Duration; use ethrex_p2p::discovery::{DiscoveryConfig, DiscoveryServer}; use ethrex_p2p::peer_table::{PeerTable, PeerTableServer}; use ethrex_p2p::types::Node; -use ethrex_storage::{EngineType, Store}; use tokio::net::UdpSocket; use tracing::{info, warn}; @@ -51,8 +50,6 @@ pub enum DiscoveryError { BuildEnr(ethrex_p2p::types::NodeError), #[error("failed to encode local ENR: {0}")] EncodeEnr(ethrex_p2p::types::NodeError), - #[error("failed to create the discovery store: {0}")] - Store(String), #[error("failed to start discovery server: {0}")] Server(String), #[error("node key is not a valid secp256k1 secret key: {0}")] @@ -151,18 +148,14 @@ pub async fn spawn_discovery( "Starting discv5 discovery" ); - // `spawn` requires a store but the discv5 path reads it only for - // `get_fork_id`, which errors on a store with no genesis block and makes - // ethrex skip the `eth` entry — what lean wants, having no execution chain. - // That the record ethrex then serves is *not* the complete one we report is - // a known gap; see docs/discovery.md, "The record ethrex serves is not the - // record we report". - let store = Store::new("", EngineType::InMemory) - .map_err(|err| DiscoveryError::Store(err.to_string()))?; - + // The record we hand over is the same one `enr_url` above reported, so what + // ethrex answers discv5 queries with carries the consensus entries (`eth2`, + // `attnets`, `quic`) and a lean peer applying our own admission rules to it + // admits us. ethrex re-signs it under `params.signer` whenever IP voting + // bumps the sequence number, keeping the extra entries. DiscoveryServer::spawn( - store, local_node, + local_record, params.signer, Arc::new(socket), peer_table.clone(), diff --git a/docs/discovery.md b/docs/discovery.md index e8c8ed28..9d99b0e4 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -58,6 +58,12 @@ and ethlambda speaks QUIC only. Read the local ENR from `GET /lean/v0/node/identity`, which reports it as `enr` (`null` when discovery is disabled). It is also logged once at startup. +This same record is handed to ethrex's `DiscoveryServer`, so it is what answers +discv5 queries: what we report and what peers see are the same bytes. If IP +voting later changes our external address, ethrex edits and re-signs that record +rather than rebuilding one, so the consensus entries survive the bump; only the +sequence number and `ip` move, which the reported ENR then lags. + ## Which peers get dialed A discovered peer is admitted only if: @@ -131,23 +137,6 @@ another**: two devnets running this code will peer with each other. Closing that gap requires lean adopting a genesis-derived fork digest, which is a cross-client change to gossip topic names. -### The record ethrex serves is not the record we report - -`GET /lean/v0/node/identity` reports the ENR built by `build_local_enr`, which -carries every entry in the table above. ethrex's `DiscoveryServer` builds its -own copy from the local `Node` and offers no way to seed the consensus entries, -so the record it answers discv5 queries with carries `ip`, `udp` and -`secp256k1` but **not** `eth2`, `attnets` or `quic`. - -Discovery is therefore one-sided: we find lean peers and admit them, but a lean -peer applying [the same admission rules](#which-peers-get-dialed) to what ethrex -serves rejects us for a missing `quic` entry. Copying our reported ENR into -another node's bootnode list still works, since that is the complete record. - -Closing this needs a way to hand ethrex's `DiscoveryServer::spawn` a prepared -record instead of having it build one. Until then, discovery finds peers but -cannot be found by them. - ### A beacon-chain client cannot discover us, and `tcp` is why Beyond the fork digest never matching a real beacon network, there is a second,