diff --git a/CLAUDE.md b/CLAUDE.md index 16d047f4..a4b14c91 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` +- 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..18985d0b 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" @@ -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", @@ -2054,7 +1961,7 @@ dependencies = [ "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]] @@ -2116,9 +2023,11 @@ dependencies = [ "libssz-merkle", "libssz-types", "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", @@ -2153,7 +2062,7 @@ dependencies = [ name = "ethlambda-state-transition" version = "0.1.0" dependencies = [ - "datatest-stable 0.3.3", + "datatest-stable", "ethlambda-metrics", "ethlambda-test-fixtures", "ethlambda-types", @@ -2200,7 +2109,7 @@ dependencies = [ name = "ethlambda-types" version = "0.1.0" dependencies = [ - "datatest-stable 0.3.3", + "datatest-stable", "ethlambda-test-fixtures", "hex", "libssz", @@ -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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" dependencies = [ "bytes", + "crossbeam", "ethrex-common", "ethrex-crypto", + "ethrex-guest-program", "ethrex-metrics", "ethrex-rlp", "ethrex-storage", "ethrex-trie", "ethrex-vm", - "hex", + "libssz", + "libssz-merkle", + "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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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", + "libssz-derive", + "libssz-merkle", + "libssz-types", + "lru", "once_cell", "rayon", "rkyv", @@ -2260,60 +2178,95 @@ 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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" +dependencies = [ + "bytes", + "ethereum-types", + "ethrex-common", + "ethrex-crypto", + "ethrex-l2-common", + "ethrex-rlp", + "ethrex-vm", + "hex", + "libssz", + "libssz-derive", + "libssz-merkle", + "libssz-types", + "rkyv", + "serde", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "ethrex-l2-common" +version = "24.0.0" +source = "git+https://github.com/lambdaclass/ethrex?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" +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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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", "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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" dependencies = [ "ethrex-common", "serde", @@ -2324,14 +2277,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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" dependencies = [ "aes", - "async-trait", + "aes-gcm", "bytes", "concat-kdf", - "crossbeam 0.8.4", + "crossbeam", "ctr", "ethereum-types", "ethrex-blockchain", @@ -2339,24 +2292,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 +2319,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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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 +2349,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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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?rev=275da9269765af83abe49f29ad96eb641ec49b55#275da9269765af83abe49f29ad96eb641ec49b55" 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 +2429,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 +2475,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 +2675,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 +2692,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 +2703,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 +2716,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 +2730,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 +2808,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2906,6 +2824,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 +2924,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 +2947,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 +2972,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 +3114,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 +3385,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 +3416,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 +3473,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 +3530,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 +3542,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 +3565,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 +3595,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 +3615,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 +3704,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 +3724,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 +3746,7 @@ dependencies = [ "ethereum_ssz", "leansig", "leansig_fast_keygen", - "p3-field 0.5.1", + "p3-field", "rand 0.10.1", ] @@ -3821,7 +3780,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", ] @@ -4732,27 +4691,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 +4746,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 +4787,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 +4803,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 +5007,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 +5019,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 +5064,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 +5072,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 +5133,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 +5232,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 +5255,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 +5284,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 +5359,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 +5381,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 +5418,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 +5476,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 +5605,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 +5630,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 +5761,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 +5829,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 +5875,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 +5925,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6184,7 +5962,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -6378,8 +6156,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 +6206,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 +6334,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 +6359,7 @@ dependencies = [ "bytecheck", "bytes", "hashbrown 0.17.1", - "indexmap", + "indexmap 2.14.0", "munge", "ptr_meta", "rancor", @@ -6643,7 +6441,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 +6616,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 +6743,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 +6799,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 +6850,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 +6861,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 +6893,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 +6944,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 +7019,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 +7028,7 @@ dependencies = [ "futures", "pin-project-lite", "spawned-macros", - "spawned-rt 0.5.0", + "spawned-rt", "thiserror 2.0.18", "tracing", ] @@ -7337,20 +7044,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 +7176,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 +7269,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 +7321,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 +7520,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 +7776,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -8166,6 +7869,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 +7939,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 +8005,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 +8018,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver 1.0.28", ] @@ -8771,7 +8480,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -8802,7 +8511,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -8821,7 +8530,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver 1.0.28", "serde", @@ -8890,12 +8599,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/cli.rs b/bin/ethlambda/src/cli.rs index 30382a0e..121c4079 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -1,5 +1,6 @@ //! Command-line interface for the ethlambda binary. +use ethlambda_p2p::discovery::DEFAULT_DISCOVERY_TARGET_PEERS; use std::net::IpAddr; use std::path::PathBuf; @@ -146,6 +147,14 @@ pub(crate) struct DiscoveryConfig { /// discv5's PONG-based IP voting may still replace it at runtime. #[arg(long = "discovery.advertise-ip")] pub(crate) advertise_ip: Option, + /// Connected-peer count above which discovery stops dialing. + /// + /// Governs the dial loop only, not discv5's own lookup pacing. The loop + /// keeps ticking either way and resumes dialing as soon as the connected + /// count drops back below this, so 0 means "discover and serve, never + /// dial". + #[arg(long = "discovery.target-peers", default_value_t = DEFAULT_DISCOVERY_TARGET_PEERS)] + pub(crate) target_peers: usize, } impl CliOptions { diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index b1d84a40..c0e83b15 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -39,7 +39,8 @@ use ethlambda_blockchain::key_manager::ValidatorKeyPair; use ethlambda_crypto::signature::ValidatorSecretKey; use ethlambda_network_api::{InitBlockChain, InitP2P, ToBlockChainToP2PRef, ToP2PToBlockChainRef}; use ethlambda_p2p::{ - Bootnode, P2P, PeerId, SwarmConfig, attestation_subscription_subnets, build_swarm, parse_enrs, + Bootnode, P2P, PeerId, SwarmConfig, attestation_subscription_subnets, build_swarm, + discovery::DiscoverySpawnConfig, parse_enrs, }; use ethlambda_types::primitives::{H256, HashTreeRoot as _}; use ethlambda_types::{ @@ -262,12 +263,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 +276,23 @@ 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); + // `None` when discovery is disabled; `P2P::spawn` starts the discv5 server + // from it and owns the resulting handle. + let discovery = options.discovery.enable.then(|| 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, + target_peers: options.discovery.target_peers, + }); + + let p2p = P2P::spawn(built, store.clone(), node_names, discovery) + .await + .wrap_err("failed to start discv5 discovery")?; // Wire actors together via protocol refs blockchain diff --git a/crates/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index d766b6a8..e33fe9fa 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -29,13 +29,23 @@ 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. +# +# Pinned to an ethrex `main` commit, for the unified discovery module, the peer +# table, and the `PeerFilter` seam this crate builds on. ethrex publishes no +# release we can name, so `rev` rather than `branch`: a `main` that moves under +# us would change what this crate builds against with nothing in the manifest +# saying so. +ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", rev = "275da9269765af83abe49f29ad96eb641ec49b55" } +ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", rev = "275da9269765af83abe49f29ad96eb641ec49b55" } +ethrex-common = { git = "https://github.com/lambdaclass/ethrex", rev = "275da9269765af83abe49f29ad96eb641ec49b55" } + +# 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 +57,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..51524ec6 --- /dev/null +++ b/crates/net/p2p/src/discovery/admission.rs @@ -0,0 +1,503 @@ +//! 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 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_ip, read_public_key, 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(crate) struct DiscoveredPeer { + pub(crate) peer_id: PeerId, + pub(crate) addr: Multiaddr, + /// Attestation subnets the peer advertises in `attnets`. + pub(crate) 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(crate) 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(crate) 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(crate) 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 { + admit(record, &self.fork_id, self.attestation_committee_count) + // The only place a rejection is visible: the peer table records + // that the record failed the filter but says nothing about why. + .inspect_err(|reason| { + debug!( + ip = ?record.pairs().ip, + udp_port = ?record.pairs().udp_port, + seq = record.seq, + ?reason, + "Rejecting discovered peer" + ); + }) + .is_ok() + } +} + +/// 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 = read_public_key(pairs).ok_or(RejectReason::BadPublicKey)?; + let peer_id = PeerId::from_public_key(&libp2p::identity::PublicKey::from(public_key)); + + let ip = read_ip(pairs).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(crate) 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)] +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::{IpAddr, 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) + } + + 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, + } + } + } + + #[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..93565da8 --- /dev/null +++ b/crates/net/p2p/src/discovery/dial.rs @@ -0,0 +1,172 @@ +//! 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 [`DiscoveryState::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, 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, + /// Connected-peer count above which the loop stops dialing. + target_peers: usize, +} + +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, + target_peers: handle.target_peers, + } + } +} + +/// 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(server: &mut P2PServer, peer_id: &PeerId) { + if let Some(discovery) = server.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) { + // 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, taken only when a refill is + // actually due rather than on every tick that just drains the queue. + let Some(discovery) = server.discovery.as_ref() else { + return; + }; + if server.connected_peers.len() >= discovery.target_peers { + return; + } + let refill = discovery + .candidates + .is_empty() + .then(|| (discovery.peer_table.clone(), discovery.filter.clone())); + let mut admitted = match refill { + Some((peer_table, filter)) => draw_candidates(&peer_table, &filter).await, + None => 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..f7f0a55d --- /dev/null +++ b/crates/net/p2p/src/discovery/enr.rs @@ -0,0 +1,346 @@ +//! 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(crate) struct EnrForkId { + pub(crate) fork_digest: [u8; 4], + pub(crate) next_fork_version: [u8; 4], + pub(crate) next_fork_epoch: u64, +} + +impl EnrForkId { + /// This node's fork id. Constant for the lifetime of the process. + pub(crate) 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 address a record advertises, preferring IPv4 when it carries both. +/// +/// `None` for a record with neither `ip` nor `ip6`, which names no host to +/// reach. Shared with the bootnode parser so both readers agree on which family +/// wins. +pub(crate) fn read_ip(pairs: &NodeRecordPairs) -> Option { + pairs + .ip + .map(IpAddr::from) + .or_else(|| pairs.ip6.map(IpAddr::from)) +} + +/// The `secp256k1` entry as a libp2p key, or `None` when absent or not a valid +/// compressed point. +/// +/// libp2p derives the peer id from this key, so the bootnode parser and the +/// admission filter must decode it the same way or they would disagree about who +/// a record belongs to. +pub(crate) fn read_public_key( + pairs: &NodeRecordPairs, +) -> Option { + let bytes = pairs.secp256k1?; + libp2p::identity::secp256k1::PublicKey::try_from_bytes(bytes.as_bytes()).ok() +} + +/// 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 first two +/// come straight from `extra_int`, which looks the key up before it decodes +/// anything and so reports a missing entry rather than a zero; the explicit `0` +/// is what the filter here is for. None of the three names a port worth dialing, +/// which is why they collapse into one answer. +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..ecaaf63d --- /dev/null +++ b/crates/net/p2p/src/discovery/mod.rs @@ -0,0 +1,296 @@ +//! 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); + +/// Default connected-peer count above which the dial loop stops dialing. +/// Overridable per node via [`DiscoverySpawnConfig::target_peers`]. +pub const DEFAULT_DISCOVERY_TARGET_PEERS: usize = 200; + +/// The target we hand ethrex's peer table, which is not +/// [`DiscoverySpawnConfig::target_peers`] and deliberately so. +/// +/// ethrex's table counts only peers registered through `NewConnectedPeer`, which +/// carries an RLPx `PeerConnection`. ethlambda connects over libp2p and never +/// registers anything, so `peers.len()` is permanently 0 and the table's target +/// cannot mean "how many peers we have". Its one live consumer here is the discv5 +/// lookup pacing, which divides by it: passing 0 would yield `0/0 = NaN`, and +/// `NaN as u64` saturates to zero, turning the lookup timer into an unthrottled +/// re-fire loop. Any non-zero value gives the same pacing, so this is 1 with the +/// reason attached rather than a number pretending to be a peer budget. +const PEER_TABLE_TARGET_PEERS: usize = 1; + +/// 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, + /// Connected-peer count above which the dial loop stops dialing. Defaults to + /// [`DEFAULT_DISCOVERY_TARGET_PEERS`]; a target of 0 leaves the dial loop + /// ticking without ever dialing. + /// + /// Governs the dial loop only. ethrex's peer table is handed a fixed value + /// instead, because it counts only peers registered over RLPx and so can + /// never see ours; see `PEER_TABLE_TARGET_PEERS`. + pub target_peers: usize, +} + +/// 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. `spawn_discovery` already + /// logs it; this copy is what the tests assert the published record against, + /// and what a future RPC identity endpoint would read. Reflects startup + /// state; discv5 may bump the sequence number later if PONG voting changes + /// our external IP. + pub local_enr: String, + /// The admission policy the peer table judges records with, kept so the dial + /// loop can apply the same rules when it turns a contact into a dial target. + /// See [`LeanFilter`]. + pub filter: LeanFilter, + /// The configured [`DiscoverySpawnConfig::target_peers`], carried through to + /// the dial loop, which is the only thing it governs. + pub target_peers: usize, +} + +/// 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(), + PEER_TABLE_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, + }, + ) + .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, + target_peers: config.target_peers, + }) +} + +#[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, + target_peers: DEFAULT_DISCOVERY_TARGET_PEERS, + } + } + + 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..c2967bb1 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,10 +13,9 @@ 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; +use futures::{StreamExt, future::OptionFuture}; use libp2p::{ Multiaddr, StreamProtocol, gossipsub::{MessageAuthenticity, ValidationMode}, @@ -33,9 +32,15 @@ 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, DiscoveryError, DiscoverySpawnConfig, + dial::{DiscoveryState, dial_tick, forget_discovered_peer}, + enr::{read_ip, read_public_key, read_quic_port}, + spawn_discovery, + }, gossipsub::{ aggregation_topic, attestation_subnet_topic, block_topic, publish_aggregated_attestation, publish_attestation, publish_block, @@ -48,6 +53,7 @@ use crate::{ swarm_adapter::SwarmHandle, }; +pub mod discovery; mod gossipsub; pub mod metrics; mod req_resp; @@ -297,20 +303,34 @@ pub fn build_swarm( .build(); let local_peer_id = *swarm.local_peer_id(); let mut bootnode_addrs = HashMap::new(); + let mut quic_less_bootnodes = 0usize; for bootnode in config.bootnodes { let peer_id = PeerId::from_public_key(&bootnode.public_key); 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 { + quic_less_bootnodes += 1; + 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(); } + // Every skip above is individually unremarkable and logged at `debug`, but a + // list that produces no dial target at all leaves the node isolated unless + // discovery is on, which is worth one line at `warn`. A beacon-chain + // bootstrap list is exactly this shape: `tcp` and `udp`, never `quic`. + if bootnode_addrs.is_empty() && quic_less_bootnodes > 0 { + warn!( + quic_less_bootnodes, + "No bootnode advertises a quic port, so nothing will be dialed statically; \ + peering depends entirely on discv5 discovery" + ); + } let addr = Multiaddr::empty() .with(config.listening_socket.ip().into()) .with(Protocol::Udp(config.listening_socket.port())) @@ -374,11 +394,35 @@ 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 { + /// Start discovery, start the I/O adapter, spawn the actor, and wire the + /// swarm event stream. + /// + /// `discovery` is `Some` when discv5 discovery is enabled: the discv5 + /// server is started here, and its handle 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`. + /// + /// Discovery is started before the swarm adapter so a fatal discovery + /// failure (a busy UDP port, say) surfaces before any actor is running. + pub async fn spawn( + built: BuiltSwarm, + store: Store, + node_names: HashMap, + discovery: Option, + ) -> Result { + if discovery.is_none() { + info!("discv5 discovery disabled; peering from the static bootnode list only"); + } + // `OptionFuture` awaits the spawn only when there is one to await, so the + // disabled case stays a plain `None` without a branch of its own. + let discovery = OptionFuture::from(discovery.map(spawn_discovery)) + .await + .transpose()?; 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,10 +437,18 @@ 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 } + Ok(P2P { handle }) } pub fn actor_ref(&self) -> &ActorRef { @@ -430,6 +482,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 +504,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 +550,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 +694,7 @@ async fn handle_swarm_event( }; if num_established == 0 { server.connected_peers.remove(&peer_id); + forget_discovered_peer(server, &peer_id); let peer_count = server.connected_peers.len(); metrics::notify_peer_disconnected( server.resolve_node_name(Some(&peer_id)), @@ -663,17 +736,29 @@ async fn handle_swarm_event( ); warn!(?peer_id, %error, "Outgoing connection error"); - // Schedule redial if this was a bootnode - if let Some(pid) = peer_id - && server.bootnode_addrs.contains_key(&pid) - && !server.connected_peers.contains(&pid) - { - send_after( - Duration::from_secs(PEER_REDIAL_INTERVAL_SECS), - ctx.clone(), - p2p_protocol::RetryPeerRedial { peer_id: pid }, - ); - info!(%pid, "Scheduled bootnode redial after connection error"); + if let Some(pid) = peer_id { + // 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. Gated on the peer + // being gone: a *second* dial to an already-connected peer can + // fail here (the bootnode redial path is one way), and dropping + // a live peer's `attnets` would make `covered_subnets` + // under-count subnets we do in fact cover. + if !server.connected_peers.contains(&pid) { + forget_discovered_peer(server, &pid); + } + + // Schedule redial if this was a bootnode + if server.bootnode_addrs.contains_key(&pid) + && !server.connected_peers.contains(&pid) + { + send_after( + Duration::from_secs(PEER_REDIAL_INTERVAL_SECS), + ctx.clone(), + p2p_protocol::RetryPeerRedial { peer_id: pid }, + ); + info!(%pid, "Scheduled bootnode redial after connection error"); + } } } SwarmEvent::IncomingConnectionError { peer_id, error, .. } => { @@ -715,67 +800,142 @@ 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")) - }); - - // Prefer IPv4 if both are present - let ip = ipv4.or(ipv6).expect("node record missing IP 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..]), + )) + } +} - bootnodes.push(Bootnode { - ip, - quic_port, - public_key: public_key.into(), - }); +/// Decode `enr:`-prefixed records into usable bootnodes. +/// +/// Records that cannot be decoded, or that lack an IP, a public key or any +/// dialable port at all, are skipped with a warning rather than aborting +/// startup: one malformed entry in the bootnode file should not stop the node +/// from booting. A record carrying only one of `quic` and `udp` is kept, since +/// each is useful on its own. +pub fn parse_enrs(enrs: Vec) -> Vec { + let configured = enrs.len(); + let bootnodes: Vec = enrs + .into_iter() + .filter_map(|enr_str| { + parse_enr(&enr_str) + .inspect_err( + |reason| warn!(%reason, enr = %enr_str, "Skipping unusable bootnode ENR"), + ) + .ok() + }) + .collect(); + // Each rejection above already warned, but a file where *every* entry is + // unusable boots a node with an empty bootnode list, which otherwise looks + // identical to having configured none at all. + if bootnodes.is_empty() && configured > 0 { + warn!( + configured, + "No bootnode ENR could be used; the node starts with no bootnodes" + ); } bootnodes } +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); + + // An explicit `udp: 0` is no more reachable than a `quic: 0`, which + // `read_quic_port` already rejects. Reading it verbatim would seed discv5 + // with a contact on a port nothing listens on. + let udp_port = pairs.udp_port.filter(|port| *port != 0); + + let public_key = read_public_key(pairs) + .ok_or_else(|| "node record missing or malformed public key".to_string())?; + + let ip = read_ip(pairs).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() && udp_port.is_none() { + return Err("node advertises neither a quic nor a udp port".to_string()); + } + + Ok(Bootnode { + ip, + quic_port, + 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 +968,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 +1038,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 +1068,132 @@ 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_treats_a_zero_udp_port_as_absent() { + use ::secp256k1 as raw_secp256k1; + + // `udp: 0` names no listener, exactly as `quic: 0` does not. Reading it + // verbatim would seed discv5 with an undialable contact, so the record + // survives only as a static dial target. + let signer = raw_secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let mut pairs = ethrex_p2p::types::NodeRecordPairs { + ip: Some(Ipv4Addr::LOCALHOST), + udp_port: Some(0), + 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].quic_port, Some(9001)); + assert_eq!(bootnodes[0].udp_port, None); + assert!( + bootnodes[0].as_discovery_node().is_none(), + "a zero udp port must not become a discv5 seed" + ); + } + + #[test] + fn parse_enrs_drops_a_record_whose_only_ports_are_zero() { + use ::secp256k1 as raw_secp256k1; + + // Neither port is dialable, so nothing downstream can ever use this + // record: the same outcome as a record carrying no ports at all. + let signer = raw_secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let mut pairs = ethrex_p2p::types::NodeRecordPairs { + ip: Some(Ipv4Addr::LOCALHOST), + udp_port: Some(0), + tcp_port: None, + ..Default::default() + }; + pairs.set_extra_int(b"quic", 0); + let record = NodeRecord::from_pairs(1, &signer, pairs).unwrap(); + + assert!(parse_enrs(vec![record.enr_url().unwrap()]).is_empty()); + } + + #[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/docs/SUMMARY.md b/docs/SUMMARY.md index deadcedb..46e8041d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -19,6 +19,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..91fcef41 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,153 @@ +# 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 | +| `--discovery.target-peers` | `200` | Connected-peer count above which dialing stops | + +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 | + +The local ENR is 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 `--discovery.target-peers` peers are connected, and resumes +if that count drops. That is all the flag does: it is the dial loop's cutoff, and +nothing in ethrex's peer table or discv5's own pacing enforces it (see +[below](#discv5-lookups-run-at-the-startup-rate)). + +## 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: a `udp` port but 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 a node logs at startup is only useful to a peer if that node 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. + +## 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. + +### discv5 lookups run at the startup rate + +ethrex paces its discv5 iterative lookups by how full its own peer table is, +easing from one lookup every 500ms at startup to one every 10s once the table +reaches its target. That table only counts peers registered through +`NewConnectedPeer`, which carries an RLPx connection; ethlambda connects over +libp2p and registers nothing, so the count is permanently zero and the pacing +never eases off the startup rate. A lean node therefore keeps looking up every +500ms rather than settling at 10s, roughly 20x the intended steady-state +`FindNode` traffic, for the life of the process. + +`--discovery.target-peers` deliberately does *not* feed that computation, since a +target of `0` would make it divide by zero and re-fire the lookup timer with no +delay at all. Closing the gap properly means ethrex learning about non-RLPx +connections, which is an upstream change. + +### `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. diff --git a/docs/metrics.md b/docs/metrics.md index 10f274a3..2b3fdfe9 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -126,6 +126,18 @@ The metrics below are not part of the [leanMetrics specification](https://github | `lean_reqresp_request_size_bytes` | Histogram | Bytes size of a req/resp request (raw SSZ or snappy on-wire) | On req/resp request send/receive | protocol=status,blocks_by_root
compression=raw,snappy | 64, 128, 256, 512, 1024, 4096, 16384, 65536 | | `lean_reqresp_response_chunk_size_bytes` | Histogram | Bytes size of a single req/resp response chunk (raw SSZ or snappy on-wire) | On req/resp response chunk send/receive | protocol=status,blocks_by_root
compression=raw,snappy | 128, 1024, 10000, 100000, 500000, 1000000, 5000000, 10000000 | +### Peer Discovery + +Only emitted when discv5 discovery is enabled (`--discovery.enable`); see +[Peer discovery](./discovery.md). Counts dials discovery initiated, as opposed to +the static bootnode dials every node makes. Connection outcomes are not repeated +here: a discovery dial that succeeds or fails shows up in +`lean_peer_connection_events_total` like any other. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_discovered_peers_dialed_total` | Counter | Peers dialed as a result of discv5 discovery | On dialing a discovered peer | | + ### Gossip Arrival Timing These histograms record the absolute distance between a gossip message's arrival and the start of the interval it was due in, so an arrival that is early by some amount and one that is late by the same amount land in the same bucket; the counters' `position` label is what tells them apart. `inside` means the message arrived within the interval it was due in, not merely somewhere in the right slot: an attestation for slot 10 that lands during slot 10's interval 2 is `after`, not `inside`, since it missed the AttestationProduction interval it was actually due in.