diff --git a/CLAUDE.md b/CLAUDE.md index 16d047f4..9707fb5d 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; `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) + ### 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..8c63d2b8 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,8 +1927,8 @@ dependencies = [ "eyre", "hex", "libc", - "libssz", - "libssz-types", + "libssz 0.3.0", + "libssz-types 0.3.0", "reqwest", "serde", "serde_yaml_ng", @@ -2038,7 +1945,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 +1956,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,7 +2001,7 @@ name = "ethlambda-network-api" version = "0.1.0" dependencies = [ "ethlambda-types", - "spawned-concurrency 0.5.0", + "spawned-concurrency", ] [[package]] @@ -2111,14 +2018,16 @@ dependencies = [ "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", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2140,7 +2049,7 @@ dependencies = [ "hex", "http-body-util", "jemalloc_pprof", - "libssz", + "libssz 0.3.0", "serde", "serde_json", "tokio", @@ -2153,12 +2062,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 +2081,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 +2099,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 +2109,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 +2125,22 @@ dependencies = [ [[package]] name = "ethrex-blockchain" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 +2150,8 @@ dependencies = [ [[package]] name = "ethrex-common" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "bytes", "crc32fast", @@ -2248,10 +2161,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 +2178,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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" +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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" +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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "ethrex-common", "serde", @@ -2324,14 +2273,14 @@ dependencies = [ [[package]] name = "ethrex-p2p" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" dependencies = [ "aes", - "async-trait", + "aes-gcm", "bytes", "concat-kdf", - "crossbeam 0.8.4", + "crossbeam", "ctr", "ethereum-types", "ethrex-blockchain", @@ -2339,24 +2288,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 +2315,27 @@ dependencies = [ [[package]] name = "ethrex-rlp" -version = "8.0.0" -source = "git+https://github.com/lambdaclass/ethrex?rev=1af63a4de7c93eb7413b9b003df1be82e1484c69#1af63a4de7c93eb7413b9b003df1be82e1484c69" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 +2345,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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?branch=feat%2Fdiscovery-peer-requirements#bf40128061531317366bd0f5718250de0cb83ad7" 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 +2425,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 +2471,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 +2671,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 +2688,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 +2699,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 +2712,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 +2726,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 +2804,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2906,6 +2820,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 +2920,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 +2943,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 +2968,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 +3110,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 +3381,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 +3412,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 +3469,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 +3526,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 +3538,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 +3561,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 +3591,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 +3611,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 +3700,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 +3720,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 +3742,7 @@ dependencies = [ "ethereum_ssz", "leansig", "leansig_fast_keygen", - "p3-field 0.5.1", + "p3-field", "rand 0.10.1", ] @@ -3821,7 +3776,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 +4476,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 +4494,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 +4516,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 +4728,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 +4783,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 +4824,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 +4840,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 +5044,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 +5056,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 +5101,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 +5109,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 +5170,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 +5269,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 +5292,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 +5321,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 +5396,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 +5418,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 +5455,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 +5513,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 +5642,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 +5667,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 +5798,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 +5866,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 +5912,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 +5962,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6184,7 +5999,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -6378,8 +6193,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 +6243,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 +6371,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 +6396,7 @@ dependencies = [ "bytecheck", "bytes", "hashbrown 0.17.1", - "indexmap", + "indexmap 2.14.0", "munge", "ptr_meta", "rancor", @@ -6643,7 +6478,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 +6653,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 +6780,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 +6836,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 +6887,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 +6898,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 +6930,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 +6981,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 +7056,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 +7065,7 @@ dependencies = [ "futures", "pin-project-lite", "spawned-macros", - "spawned-rt 0.5.0", + "spawned-rt", "thiserror 2.0.18", "tracing", ] @@ -7337,20 +7081,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 +7213,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 +7306,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 +7358,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 +7557,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 +7813,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -8166,6 +7906,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 +7976,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 +8042,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 +8055,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver 1.0.28", ] @@ -8771,7 +8517,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -8802,7 +8548,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -8821,7 +8567,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver 1.0.28", "serde", @@ -8890,12 +8636,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/src/main.rs b/bin/ethlambda/src/main.rs index b1d84a40..9764f762 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -262,12 +262,12 @@ async fn main() -> eyre::Result<()> { ); let built = build_swarm(SwarmConfig { - node_key: node_p2p_key, - bootnodes, + node_key: node_p2p_key.clone(), + bootnodes: bootnodes.clone(), listening_socket: p2p_socket, validator_ids, attestation_committee_count, - subscription_subnets: subscribed_subnets, + subscription_subnets: subscribed_subnets.clone(), }) .wrap_err("failed to build swarm")?; @@ -275,7 +275,29 @@ 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 discovery = if options.discovery.enable { + let handle = ethlambda_p2p::discovery::spawn_discovery( + ethlambda_p2p::discovery::DiscoverySpawnConfig { + 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, + advertise_ip: options.discovery.advertise_ip, + }, + ) + .await + .wrap_err("failed to start discv5 discovery")?; + 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 @@ -301,7 +323,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/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index d766b6a8..d64de18e 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -29,13 +29,24 @@ snap = "1.1" tokio.workspace = true tracing.workspace = true +thiserror.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" } + +# 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 @@ -47,3 +58,5 @@ sha2 = "0.10" [dev-dependencies] hex.workspace = true +# `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 new file mode 100644 index 00000000..6dbae62c --- /dev/null +++ b/crates/net/p2p/src/discovery/admission.rs @@ -0,0 +1,511 @@ +//! 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 ethrex_p2p::peer_filter::PeerFilter; +use ethrex_p2p::types::NodeRecord; +use libp2p::{Multiaddr, PeerId}; +use libssz::SszDecode; +use tracing::debug; + +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)] +pub struct DiscoveredPeer { + pub peer_id: PeerId, + pub addr: Multiaddr, + /// Attestation subnets the peer advertises in `attnets`. + pub subnets: Vec, +} + +/// 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 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, + /// Neither `ip` nor `ip6`. + MissingAddress, +} + +/// 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. +#[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, + "Rejecting discovered peer" + ); + false + } + } + } +} + +/// Apply the spec's admission checks to a discovered ENR. +/// +/// `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 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 { + 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" + ); + } + + let quic_port = read_quic_port(record).ok_or(RejectReason::NoQuicPort)?; + + 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 subnets = pairs + .extra(ATTNETS_ENR_KEY) + .map(|bits| subnets_from_attnets(&bits, attestation_committee_count)) + .unwrap_or_default(); + + Ok(DiscoveredPeer { + peer_id, + addr: quic_multiaddr(ip, quic_port, peer_id), + subnets, + }) +} + +/// 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(subnets: Vec) -> Self { + Self { + peer_id: PeerId::random(), + addr: Multiaddr::empty(), + subnets, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethrex_p2p::types::{Node, NodeRecordPairs}; + use ethrex_p2p::utils::public_key_from_signing_key; + use libssz::SszEncode; + use std::collections::HashSet; + use std::net::Ipv4Addr; + + use super::super::enr::{FAR_FUTURE_EPOCH, QUIC_ENR_KEY, encode_attnets}; + + /// 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); + 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; + set_entries(pairs); + }) + .unwrap(); + record + } + + fn set_eth2(pairs: &mut NodeRecordPairs, fork_id: EnrForkId) { + pairs.set_extra(ETH2_ENR_KEY, fork_id.to_ssz()); + } + + fn set_quic(pairs: &mut NodeRecordPairs, port: u16) { + pairs.set_extra_int(QUIC_ENR_KEY, port.into()); + } + + fn set_attnets(pairs: &mut NodeRecordPairs, subnets: &[u64]) { + let subnets = subnets.iter().copied().collect::>(); + set_attnets_bits(pairs, encode_attnets(&subnets, TEST_COMMITTEE_COUNT)); + } + + /// `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); + } + + /// 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) + } + + /// 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(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(|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!( + 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(|pairs| set_quic(pairs, 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(|pairs| { + set_eth2(pairs, foreign); + set_quic(pairs, 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(|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(|pairs| set_eth2(pairs, 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(|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 = 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), + ..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 compressed = signer.public_key(secp256k1::SECP256K1).serialize(); + let pairs = NodeRecordPairs { + secp256k1: Some(ethrex_common::H264(compressed)), + udp_port: Some(9010), + ..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(set_admissible_entries); + 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 not surface + // them. `subnets_from_attnets` is what enforces that (and is tested + // directly in `enr`); this checks `admit` actually routes through it. + 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]); + } + + // --- 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(|pairs| { + set_attnets(pairs, &[2, 5]); + set_admissible_entries(pairs); + }); + + 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(|pairs| { + set_eth2(pairs, foreign); + set_quic(pairs, 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(|pairs| set_eth2(pairs, 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(|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] + .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() { + // Subnet 0 is already covered, so `[0]` scores zero, `[2]` scores one + // and `[2, 3]` scores two. + let mut candidates = vec![ + 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.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(vec![]), + DiscoveredPeer::for_test(vec![7]), + ]; + rank_by_uncovered_subnets(&mut candidates, &HashSet::new()); + 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 new file mode 100644 index 00000000..42b5c0a2 --- /dev/null +++ b/crates/net/p2p/src/discovery/enr.rs @@ -0,0 +1,319 @@ +//! 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. +//! +//! 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::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 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 +} + +/// 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(crate) struct LocalEnrParams { + pub(crate) signer: SecretKey, + /// Address to advertise. discv5's PONG-based IP voting may replace it later. + pub(crate) ip: IpAddr, + /// UDP port the discv5 socket is bound to. + pub(crate) discovery_port: u16, + /// UDP port the libp2p QUIC transport is bound to. + pub(crate) quic_port: u16, + pub(crate) subscription_subnets: HashSet, + pub(crate) 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(crate) fn local_node(&self) -> Node { + Node::new( + self.ip, + self.discovery_port, + 0, + public_key_from_signing_key(&self.signer), + ) + } + + /// 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. + 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), + } + + // 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.into()); + pairs + } +} + +/// Build and sign this node's ENR. +pub(crate) fn build_local_enr(params: &LocalEnrParams) -> Result { + NodeRecord::from_pairs(INITIAL_ENR_SEQ, ¶ms.signer, params.local_pairs()) + .map_err(DiscoveryError::BuildEnr) +} + +/// 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 ethrex_rlp::decode::RLPDecode as _; + use libssz::SszDecode; + use std::net::Ipv4Addr; + + fn build() -> NodeRecord { + build_local_enr(&LocalEnrParams { + signer: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + 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 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(); + 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 = 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 = record + .pairs() + .extra(ATTNETS_ENR_KEY) + .expect("attnets entry"); + assert_eq!(subnets_from_attnets(&raw, 8), 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..b04e96eb --- /dev/null +++ b/crates/net/p2p/src/discovery/mod.rs @@ -0,0 +1,269 @@ +//! 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(crate) mod dial; +pub mod enr; + +use std::collections::HashSet; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use ethrex_p2p::discovery::{DiscoveryConfig, DiscoveryServer}; +use ethrex_p2p::peer_table::{PeerTable, PeerTableServer}; +use ethrex_p2p::types::Node; +use tokio::net::UdpSocket; +use tracing::{info, warn}; + +use crate::Bootnode; +use admission::LeanFilter; +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); + +/// 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; + +/// 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 start discovery server: {0}")] + Server(String), + #[error("node key is not a valid secp256k1 secret key: {0}")] + NodeKey(secp256k1::Error), +} + +pub struct DiscoverySpawnConfig { + /// 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, + 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, +} + +/// 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 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(|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, + ip: advertise_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(DiscoveryError::EncodeEnr)?; + + // `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, + 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" + ); + + // 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( + local_node, + local_record, + params.signer, + Arc::new(socket), + peer_table.clone(), + seeds, + DiscoveryConfig { + discv4_enabled: false, + discv5_enabled: true, + ..Default::default() + }, + ) + .await + .map_err(|err| DiscoveryError::Server(err.to_string()))?; + + info!(enr = %local_enr, "Local ENR"); + if advertise_ip.is_unspecified() { + warn!( + "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" + ); + } + + Ok(DiscoveryHandle { + peer_table, + local_enr, + filter, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ethrex_p2p::peer_filter::PeerFilter; + use ethrex_p2p::types::NodeRecord; + use ethrex_rlp::decode::RLPDecode; + use std::net::Ipv4Addr; + + fn config(discovery_port: u16, advertise_ip: Option) -> DiscoverySpawnConfig { + DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng) + .secret_bytes() + .to_vec(), + bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port, + quic_port: 9001, + subscription_subnets: HashSet::from([0u64]), + attestation_committee_count: 4, + bootnodes: Vec::new(), + advertise_ip, + } + } + + 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") + } + + #[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 + // 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(config(0, Some(IpAddr::from(advertised)))) + .await + .expect("discovery spawns"); + + assert_eq!(decode_enr(&handle.local_enr).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(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 4726ce25..ef5a1581 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}, + 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_common::H264; use ethrex_p2p::types::NodeRecord; use ethrex_rlp::decode::RLPDecode; use futures::StreamExt; @@ -33,9 +32,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_DIAL_INTERVAL, DiscoveryHandle, + dial::{DiscoveryState, dial_tick}, + enr::read_quic_port, + }, gossipsub::{ aggregation_topic, attestation_subnet_topic, block_topic, publish_aggregated_attestation, publish_attestation, publish_block, @@ -48,6 +52,7 @@ use crate::{ swarm_adapter::SwarmHandle, }; +pub mod discovery; mod gossipsub; pub mod metrics; mod req_resp; @@ -302,12 +307,13 @@ pub fn build_swarm( if peer_id == local_peer_id { continue; } - let addr = Multiaddr::empty() - .with(bootnode.ip.into()) - .with(Protocol::Udp(bootnode.quic_port)) - .with(Protocol::QuicV1) - .with_p2p(peer_id) - .expect("failed to add peer ID to multiaddr"); + // 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 = quic_multiaddr(bootnode.ip, quic_port, peer_id); bootnode_addrs.insert(peer_id, addr.clone()); swarm.dial(addr).unwrap(); } @@ -375,10 +381,21 @@ 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()); + let discovery_enabled = discovery.is_some(); let server = P2PServer { swarm_handle, store, @@ -393,8 +410,16 @@ impl P2P { range_sync_state: None, bootnode_addrs: built.bootnode_addrs, node_names, + discovery: discovery.map(|handle| DiscoveryState::new(handle, built.local_peer_id)), }; 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 +455,9 @@ 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, } impl P2PServer { @@ -449,6 +477,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 +523,21 @@ 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, + ); + dial_tick(self).await; + } } // --- Manual Handler impls for network-api messages --- @@ -622,6 +667,7 @@ async fn handle_swarm_event( }; if num_established == 0 { server.connected_peers.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)), @@ -663,6 +709,13 @@ 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 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 if let Some(pid) = peer_id && server.bootnode_addrs.contains_key(&pid) @@ -715,67 +768,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(); + // `Node::from_enr` would compute the same identity from the record, but + // it falls back to `tcp` when `udp` is absent, which would seed a + // tcp-only bootnode under a port discv5 does not listen on. + 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 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 + .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 --- +/// 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). +/// +/// 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 { if endpoint.is_dialer() { "outbound" @@ -808,6 +926,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 +996,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 +1026,85 @@ 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 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", 9001); + 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/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..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; @@ -14,11 +15,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 { +struct IdentityResponse<'a> { version: &'static str, - /// This node's libp2p peer ID (base58), as it appears to peers on the wire. - peer_id: String, + #[serde(flatten)] + identity: &'a NodeIdentity, } /// Sync status for `/lean/v0/node/syncing`. @@ -60,18 +75,25 @@ 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: Arc) -> impl IntoResponse { + json_response(IdentityResponse { + version, + identity: &identity, + }) } -pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { +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( "/lean/v0/node/identity", - get(move || get_identity(version, peer_id.clone())), + get(move || get_identity(version, identity.clone())), ) } @@ -90,6 +112,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 +191,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 +237,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 c51a2bc0..f343d34b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,6 +15,7 @@ - [Checkpoint Sync](./checkpoint_sync.md) - [Fork Choice Visualization](./fork_choice_visualization.md) - [Data Storage](./data_storage.md) +- [Peer discovery](./discovery.md) # Development diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..9d99b0e4 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,166 @@ +# 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. + +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: + +- 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. + +### 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.