diff --git a/Cargo.lock b/Cargo.lock index 67779029..bc4b3917 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,7 +419,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "thiserror 2.0.18", @@ -448,7 +448,7 @@ checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -463,7 +463,7 @@ dependencies = [ "alloy-transport-http", "futures", "pin-project", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -571,7 +571,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -589,7 +589,7 @@ dependencies = [ "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -607,7 +607,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -665,7 +665,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde_json", "tower", "tracing", @@ -697,7 +697,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -856,7 +856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -894,7 +894,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -996,7 +996,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -1008,7 +1008,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1092,7 +1092,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1103,7 +1103,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1145,14 +1145,14 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -1281,7 +1281,7 @@ checksum = "7b9a5040dce49a7642c97ccb1ae59567098967b5d52c29773f1299a42d23bb39" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1313,9 +1313,9 @@ checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +checksum = "4ed83caece3afc59919481b33b472e1432d1abc4641ed9100be142ef5110b406" dependencies = [ "bitcoin-io", "hex-conservative", @@ -1323,15 +1323,15 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1359,9 +1359,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1429,8 +1429,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" dependencies = [ - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "tonic", "tonic-prost", "ureq", @@ -1445,7 +1445,7 @@ dependencies = [ "base64", "bollard-buildkit-proto", "bytes", - "prost 0.14.3", + "prost 0.14.4", "serde", "serde_json", "serde_repr", @@ -1454,9 +1454,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -1464,9 +1464,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ "darling 0.23.0", "ident_case", @@ -1474,14 +1474,14 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "borsh" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", "bytes", @@ -1490,15 +1490,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1523,9 +1523,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byte-slice-cast" @@ -1541,9 +1541,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] @@ -1598,9 +1598,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -1657,9 +1657,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1738,7 +1738,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1789,9 +1789,9 @@ dependencies = [ [[package]] name = "const-hex" -version = "1.19.0" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -2051,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2098,7 +2098,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2132,7 +2132,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2146,7 +2146,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2157,7 +2157,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2168,7 +2168,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2208,7 +2208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2260,7 +2260,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2294,7 +2293,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -2325,19 +2324,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2423,7 +2422,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2483,7 +2482,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2503,7 +2502,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2580,7 +2579,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2844,7 +2843,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2954,16 +2953,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -3031,9 +3028,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -3125,9 +3122,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -3247,9 +3244,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3307,9 +3304,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3403,7 +3400,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3438,7 +3435,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -3532,12 +3529,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3636,7 +3627,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3677,7 +3668,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -3756,7 +3747,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3775,7 +3766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3790,13 +3781,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3836,9 +3826,9 @@ dependencies = [ [[package]] name = "keccak-asm" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1766b89733097006f3a1388a02849865d6bc98c89273cb622e29fdd209922183" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" dependencies = [ "digest 0.10.7", "sha3-asm", @@ -3865,12 +3855,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -3879,9 +3863,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" -version = "0.18.4+1.9.3" +version = "0.18.5+1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" dependencies = [ "cc", "libc", @@ -4130,9 +4114,9 @@ dependencies = [ [[package]] name = "libp2p-identity" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" dependencies = [ "asn1_der", "bs58", @@ -4141,7 +4125,7 @@ dependencies = [ "k256", "multihash", "p256", - "quick-protobuf", + "prost 0.14.4", "rand 0.8.6", "ring", "sec1", @@ -4360,7 +4344,7 @@ dependencies = [ "bimap", "futures", "futures-timer", - "hashlink 0.11.0", + "hashlink 0.11.1", "libp2p-core", "libp2p-identity", "libp2p-request-response", @@ -4425,7 +4409,7 @@ checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" dependencies = [ "heck", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4439,7 +4423,7 @@ dependencies = [ "if-watch", "libc", "libp2p-core", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tracing", ] @@ -4609,9 +4593,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -4642,9 +4626,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "loki-api" @@ -4679,7 +4663,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4690,7 +4674,7 @@ checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4710,9 +4694,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memory-stats" @@ -4758,9 +4742,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -5064,7 +5048,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5119,12 +5103,12 @@ dependencies = [ "quick-xml 0.40.1", "quote", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_path_to_error", "serde_with", - "syn 2.0.117", + "syn 2.0.118", "thiserror 2.0.18", "uuid", "validator", @@ -5169,9 +5153,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -5189,7 +5173,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5200,9 +5184,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -5257,7 +5241,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5311,7 +5295,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5404,7 +5388,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5481,10 +5465,10 @@ dependencies = [ "pluto-k1util", "pluto-ssz", "pluto-testutil", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "tar", @@ -5534,7 +5518,7 @@ dependencies = [ "pluto-tracing", "quick-xml 0.39.4", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -5567,11 +5551,11 @@ dependencies = [ "pluto-p2p", "pluto-ssz", "pluto-testutil", - "prost 0.14.3", + "prost 0.14.4", "prost-build", - "prost-types 0.14.3", + "prost-types 0.14.4", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -5609,8 +5593,8 @@ dependencies = [ "pluto-p2p", "pluto-ssz", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "serde_json", "test-case", "thiserror 2.0.18", @@ -5630,6 +5614,7 @@ dependencies = [ "axum", "backon", "base64", + "bon", "built", "cancellation", "chrono", @@ -5649,11 +5634,11 @@ dependencies = [ "pluto-ssz", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "rand 0.8.6", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "test-case", @@ -5710,8 +5695,8 @@ dependencies = [ "pluto-peerinfo", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "rand 0.8.6", "serde", "serde_json", @@ -5753,7 +5738,7 @@ dependencies = [ "oas3-gen-support", "pluto-ssz", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -5788,7 +5773,7 @@ dependencies = [ "pluto-testutil", "rand 0.8.6", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "scrypt", "serde", "serde_json", @@ -5865,9 +5850,9 @@ dependencies = [ "pluto-k1util", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", + "prost 0.14.4", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde_json", "tempfile", "thiserror 2.0.18", @@ -5898,7 +5883,7 @@ dependencies = [ "pluto-p2p", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", + "prost 0.14.4", "serde_json", "thiserror 2.0.18", "tokio", @@ -5925,8 +5910,8 @@ dependencies = [ "pluto-eth2util", "pluto-p2p", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "regex", "serde_json", "thiserror 2.0.18", @@ -5952,7 +5937,7 @@ dependencies = [ "pluto-p2p", "pluto-tracing", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde_json", "thiserror 2.0.18", "tokio", @@ -5993,7 +5978,7 @@ dependencies = [ "pluto-eth2util", "pluto-ssz", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -6094,7 +6079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6145,7 +6130,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6177,7 +6162,7 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6211,19 +6196,19 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive 0.14.3", + "prost-derive 0.14.4", ] [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools 0.14.0", @@ -6231,10 +6216,10 @@ dependencies = [ "multimap", "petgraph", "prettyplease", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "regex", - "syn 2.0.117", + "syn 2.0.118", "tempfile", ] @@ -6248,20 +6233,20 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6275,11 +6260,11 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost 0.14.3", + "prost 0.14.4", ] [[package]] @@ -6344,7 +6329,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -6382,7 +6367,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -6457,7 +6442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -6611,14 +6596,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6639,9 +6624,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -6681,9 +6666,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -6886,9 +6871,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -7192,14 +7177,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap 2.14.0", "itoa", @@ -7228,7 +7213,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7254,9 +7239,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -7274,14 +7259,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7351,9 +7336,9 @@ dependencies = [ [[package]] name = "sha3-asm" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3f15d4e239ebe08413eed880e0f9b5af4b40ee0472543320efa91d488e96a7" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" dependencies = [ "cc", "cfg-if", @@ -7370,9 +7355,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -7424,9 +7409,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -7466,9 +7451,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -7526,7 +7511,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7537,7 +7522,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7558,7 +7543,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7580,9 +7565,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -7598,7 +7583,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7618,7 +7603,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7686,7 +7671,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7710,7 +7695,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7721,7 +7706,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "test-case-core", ] @@ -7782,7 +7767,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7793,7 +7778,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7816,12 +7801,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -7831,15 +7815,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -7892,7 +7876,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -7905,7 +7889,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7988,9 +7972,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -8032,7 +8016,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.3", + "socket2 0.6.4", "sync_wrapper", "tokio", "tokio-stream", @@ -8049,7 +8033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost 0.14.3", + "prost 0.14.4", "tonic", ] @@ -8122,7 +8106,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8216,7 +8200,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8227,9 +8211,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -8290,9 +8274,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -8398,11 +8382,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -8435,7 +8419,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8490,7 +8474,7 @@ source = "git+https://github.com/matter-labs/vise?rev=73c654303d8190023cf30034d6 dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8535,27 +8519,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -8566,9 +8541,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -8576,9 +8551,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8586,48 +8561,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -8641,18 +8594,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.28", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -8669,9 +8610,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -8689,9 +8630,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -8702,14 +8643,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -8826,7 +8767,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8837,7 +8778,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8848,7 +8789,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8859,7 +8800,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9119,100 +9060,12 @@ dependencies = [ "url", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -9324,9 +9177,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9341,28 +9194,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9382,28 +9235,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9436,7 +9289,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b1590316..a58d4458 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -10,6 +10,7 @@ publish.workspace = true alloy.workspace = true backon.workspace = true async-trait.workspace = true +bon.workspace = true axum.workspace = true cancellation.workspace = true chrono.workspace = true diff --git a/crates/core/src/fetcher/graffiti.rs b/crates/core/src/fetcher/graffiti.rs new file mode 100644 index 00000000..28de3b3c --- /dev/null +++ b/crates/core/src/fetcher/graffiti.rs @@ -0,0 +1,375 @@ +//! Graffiti construction for block proposals. + +use std::collections::HashMap; + +use pluto_eth2api::{EthBeaconNodeApiClient, GetNodeVersionRequest, GetNodeVersionResponse}; + +use crate::{ + types::PubKey, + version::{VERSION, git_commit}, +}; + +/// Obol token appended to graffiti unless client-append is disabled. +const OBOL_TOKEN: &str = "OB"; + +/// Graffiti is a fixed 32-byte field in the beacon block body. +const GRAFFITI_LEN: usize = 32; + +/// Error returned while constructing a [`GraffitiBuilder`]. +#[derive(Debug, thiserror::Error)] +pub enum GraffitiError { + /// More than one graffiti value was provided but the count did not match + /// the number of validators. + #[error("graffiti length must match the number of validators or be a single value")] + LengthMismatch, +} + +/// Maps a beacon node product token (the first `/`-separated component of the +/// node version string) to its two-letter graffiti code, returning an empty +/// string for an unrecognized client. +pub fn client_graffiti_token(product_token: &str) -> &'static str { + match product_token { + "teku" => "TK", + "Lighthouse" => "LH", + "Lodestar" => "LS", + "Prysm" => "PY", + "Nimbus" => "NB", + "Grandine" => "GD", + _ => "", + } +} + +/// Builds per-validator graffiti used when proposing blocks. +#[derive(Debug, Clone, Default)] +pub struct GraffitiBuilder { + default_graffiti: [u8; GRAFFITI_LEN], + graffiti: HashMap, +} + +impl GraffitiBuilder { + /// Creates a new graffiti builder. + /// + /// `graffiti` may be `None` (every validator gets the default graffiti), a + /// single value (applied to every validator) or one value per validator. + pub async fn new( + pubkeys: &[PubKey], + graffiti: Option<&[String]>, + disable_client_append: bool, + eth2_cl: &EthBeaconNodeApiClient, + ) -> Result { + let default = default_graffiti(); + let mut builder = Self { + default_graffiti: default, + graffiti: HashMap::with_capacity(pubkeys.len()), + }; + + // Handle nil graffiti. + let Some(graffiti) = graffiti else { + for pubkey in pubkeys { + builder.graffiti.insert(*pubkey, default); + } + + return Ok(builder); + }; + + if graffiti.len() > 1 && graffiti.len() != pubkeys.len() { + return Err(GraffitiError::LengthMismatch); + } + + let token = fetch_beacon_node_token(eth2_cl).await; + + // Handle single graffiti case. + if graffiti.len() == 1 { + let single_graffiti = &graffiti[0]; + for pubkey in pubkeys { + builder.graffiti.insert( + *pubkey, + build_graffiti(single_graffiti, &token, disable_client_append), + ); + } + + return Ok(builder); + } + + // Handle multiple graffiti case. + for (idx, pubkey) in pubkeys.iter().enumerate() { + builder.graffiti.insert( + *pubkey, + build_graffiti(&graffiti[idx], &token, disable_client_append), + ); + } + + Ok(builder) + } + + /// Returns the graffiti for a given pubkey, or the default graffiti when + /// the pubkey is unknown. + pub fn get_graffiti(&self, pubkey: &PubKey) -> [u8; GRAFFITI_LEN] { + self.graffiti + .get(pubkey) + .copied() + .unwrap_or(self.default_graffiti) + } +} + +/// Copies `s` into a fixed 32-byte array, truncating or zero-padding to match +/// Go's `copy(graffiti[:], s)` semantics. +fn graffiti_bytes(s: &str) -> [u8; GRAFFITI_LEN] { + let mut out = [0u8; GRAFFITI_LEN]; + let bytes = s.as_bytes(); + let n = bytes.len().min(GRAFFITI_LEN); + out[..n].copy_from_slice(&bytes[..n]); + out +} + +/// Builds the graffiti with optional Obol and beacon node token. +fn build_graffiti(graffiti: &str, token: &str, disable_client_append: bool) -> [u8; GRAFFITI_LEN] { + if disable_client_append { + graffiti_bytes(graffiti) + } else { + graffiti_bytes(&format!("{graffiti}{OBOL_TOKEN}{token}")) + } +} + +/// Returns the default graffiti: `pluto/-`. +fn default_graffiti() -> [u8; GRAFFITI_LEN] { + let (commit_sha, _) = git_commit(); + graffiti_bytes(&format!("pluto/{}-{}", *VERSION, commit_sha)) +} + +/// Queries the beacon node for its product token, returning an empty string on +/// any error or unrecognized client. +async fn fetch_beacon_node_token(eth2_cl: &EthBeaconNodeApiClient) -> String { + let Some(version) = node_version(eth2_cl).await else { + return String::new(); + }; + + let product_token = version.split('/').next().unwrap_or_default(); + + client_graffiti_token(product_token).to_string() +} + +/// Fetches the beacon node version string (e.g. `Lighthouse/v0.1.5 (Linux +/// x86_64)`), or `None` on any error. +async fn node_version(eth2_cl: &EthBeaconNodeApiClient) -> Option { + match eth2_cl.get_node_version(GetNodeVersionRequest {}).await { + Ok(GetNodeVersionResponse::Ok(resp)) => Some(resp.data.version), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use pluto_testutil::BeaconMock; + use serde_json::json; + + use super::*; + + /// 48-byte BLS public key length used to build distinct test pubkeys. + const PK_LEN: usize = 48; + + /// Builds a beacon mock whose `/eth/v1/node/version` endpoint returns + /// `version`. + async fn mock_with_version(version: &str) -> BeaconMock { + BeaconMock::builder() + .endpoint_overrides(vec![( + "/eth/v1/node/version".to_string(), + json!({ "data": { "version": version } }), + )]) + .build() + .await + .expect("build mock") + } + + #[tokio::test] + async fn fetch_beacon_node_token() { + // fetch token error: unreachable beacon node yields an empty token. + let unreachable = + EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:1").expect("create client"); + assert_eq!(super::fetch_beacon_node_token(&unreachable).await, ""); + + // fetch token unexpected response: no `/`-separated product token. + let mock = mock_with_version("IncorrectUserAgent").await; + assert_eq!(super::fetch_beacon_node_token(mock.client()).await, ""); + + // fetch token not predicted in map. + let mock = mock_with_version("Dune/v1.3 (Windows)").await; + assert_eq!(super::fetch_beacon_node_token(mock.client()).await, ""); + + // fetch token: Lighthouse maps to "LH". + let mock = mock_with_version("Lighthouse/v0.1.5 (Linux x86_64)").await; + assert_eq!(super::fetch_beacon_node_token(mock.client()).await, "LH"); + } + + #[test] + fn build_graffiti() { + let graffiti = "abcdefghij"; // 10 bytes + let token = "BN"; + + // disable client append. + assert_eq!( + super::build_graffiti(graffiti, token, true), + graffiti_bytes(graffiti) + ); + + // enable client append. + assert_eq!( + super::build_graffiti(graffiti, token, false), + graffiti_bytes(&format!("{graffiti}{OBOL_TOKEN}{token}")) + ); + } + + #[test] + fn default_graffiti() { + let (commit_sha, _) = git_commit(); + let expected = graffiti_bytes(&format!("pluto/{}-{}", *VERSION, commit_sha)); + assert_eq!(super::default_graffiti(), expected); + } + + #[test] + fn get_graffiti() { + let pubkeys = [ + PubKey::new([1u8; PK_LEN]), + PubKey::new([2u8; PK_LEN]), + PubKey::new([3u8; PK_LEN]), + ]; + + let mut g0 = [0u8; GRAFFITI_LEN]; + g0[0] = 1; + let mut g1 = [0u8; GRAFFITI_LEN]; + g1[0] = 2; + + let builder = GraffitiBuilder { + default_graffiti: super::default_graffiti(), + graffiti: HashMap::from([(pubkeys[0], g0), (pubkeys[1], g1)]), + }; + + assert_eq!(builder.get_graffiti(&pubkeys[0]), g0); + assert_eq!(builder.get_graffiti(&pubkeys[1]), g1); + assert_eq!(builder.get_graffiti(&pubkeys[2]), super::default_graffiti()); + } + + /// Three distinct pubkeys used across the `GraffitiBuilder::new` tests. + fn test_pubkeys() -> [PubKey; 3] { + [ + PubKey::new([1u8; PK_LEN]), + PubKey::new([2u8; PK_LEN]), + PubKey::new([3u8; PK_LEN]), + ] + } + + #[tokio::test] + async fn new_rejects_mismatched_graffiti_length() { + let pubkeys = test_pubkeys(); + let mock = BeaconMock::builder().build().await.expect("build mock"); + + // graffiti length greater than pubkeys. + let graffiti = vec![ + "a".repeat(10), + "b".repeat(15), + "c".repeat(20), + "d".repeat(25), + ]; + let result = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()).await; + assert!(matches!(result, Err(GraffitiError::LengthMismatch))); + + // graffiti length lesser than pubkeys. + let graffiti = vec!["a".repeat(10), "b".repeat(15)]; + let result = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()).await; + assert!(matches!(result, Err(GraffitiError::LengthMismatch))); + } + + #[tokio::test] + async fn new_with_nil_graffiti_uses_default() { + let pubkeys = test_pubkeys(); + let mock = BeaconMock::builder().build().await.expect("build mock"); + + let builder = GraffitiBuilder::new(&pubkeys, None, false, mock.client()) + .await + .expect("build builder"); + for pubkey in &pubkeys { + assert_eq!(builder.get_graffiti(pubkey), super::default_graffiti()); + } + } + + #[tokio::test] + async fn new_single_graffiti_with_append() { + let pubkeys = test_pubkeys(); + + // single graffiti with append (Grandine -> GD). + let mock = mock_with_version("Grandine/v2.1.4 (Linux x86_64)").await; + let graffiti = "x".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() - 2); + let builder = GraffitiBuilder::new( + &pubkeys, + Some(std::slice::from_ref(&graffiti)), + false, + mock.client(), + ) + .await + .expect("build builder"); + let expected = graffiti_bytes(&format!("{graffiti}{OBOL_TOKEN}GD")); + for pubkey in &pubkeys { + assert_eq!(builder.get_graffiti(pubkey), expected); + } + } + + #[tokio::test] + async fn new_single_graffiti_without_append() { + let pubkeys = test_pubkeys(); + + let mock = mock_with_version("Teku/v4.2.1 (Linux x86_64)").await; + let graffiti = "y".repeat(GRAFFITI_LEN); + let builder = GraffitiBuilder::new( + &pubkeys, + Some(std::slice::from_ref(&graffiti)), + true, + mock.client(), + ) + .await + .expect("build builder"); + let expected = graffiti_bytes(&graffiti); + for pubkey in &pubkeys { + assert_eq!(builder.get_graffiti(pubkey), expected); + } + } + + #[tokio::test] + async fn new_multiple_graffiti_with_append() { + let pubkeys = test_pubkeys(); + + // multiple graffiti with append (Prysm -> PY). + let mock = mock_with_version("Prysm/v0.2.7 (Linux x86_64)").await; + let graffiti = vec![ + "a".repeat(10), + "b".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() - 3), + "c".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() - 4), + ]; + let builder = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()) + .await + .expect("build builder"); + for (idx, pubkey) in pubkeys.iter().enumerate() { + let expected = graffiti_bytes(&format!("{}{OBOL_TOKEN}PY", graffiti[idx])); + assert_eq!(builder.get_graffiti(pubkey), expected); + } + } + + #[tokio::test] + async fn new_multiple_graffiti_without_append() { + let pubkeys = test_pubkeys(); + + // multiple graffiti without append (empty version -> empty token). + let mock = mock_with_version("").await; + let graffiti = vec![ + "a".repeat(10), + "b".repeat(GRAFFITI_LEN - OBOL_TOKEN.len()), + "c".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() + 1), + ]; + let builder = GraffitiBuilder::new(&pubkeys, Some(&graffiti), true, mock.client()) + .await + .expect("build builder"); + for (idx, pubkey) in pubkeys.iter().enumerate() { + let expected = graffiti_bytes(&graffiti[idx]); + assert_eq!(builder.get_graffiti(pubkey), expected); + } + } +} diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs new file mode 100644 index 00000000..562d0f3c --- /dev/null +++ b/crates/core/src/fetcher/mod.rs @@ -0,0 +1,1679 @@ +//! Fetcher — fetches unsigned duty data from the beacon node. +//! +//! Ported from `charon/core/fetcher/fetcher.go`. + +mod graffiti; + +use graffiti::GraffitiBuilder; + +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; + +use pluto_eth2api::{ + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetAggregatedAttestationV2Request, + GetAggregatedAttestationV2Response, GetAggregatedAttestationV2ResponseResponseData, + ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, + ProduceBlockV3Response, ProduceSyncCommitteeContributionRequest, + ProduceSyncCommitteeContributionResponse, + spec::{ConversionError, altair, bellatrix::ExecutionAddress, phase0}, + versioned, +}; +use pluto_eth2util::eth2exp::{self, Eth2ExpError}; +use tree_hash::TreeHash; + +use crate::{ + signeddata::{ + AttestationData, BeaconCommitteeSelection, ProposalBlock, SignedDataError, + SignedSyncMessage, SyncCommitteeSelection, SyncContribution, + VersionedAggregatedAttestation, VersionedProposal, + }, + types::{Duty, DutyDefinition, DutyDefinitionSet, DutyType, PubKey, SignedData}, + unsigneddata::{UnsignedDataSet, UnsignedDutyData}, +}; + +/// Boxed error returned by injected callbacks (subscribers, AggSigDB, DutyDB). +type BoxError = Box; + +/// Future returned by an injected callback. +type CallbackFuture = Pin> + Send>>; + +/// Subscriber callback invoked for each fetched duty data set. +pub type Subscriber = Arc CallbackFuture<()> + Send + Sync>; + +/// AggSigDB callback: resolves aggregated signed data for a duty/pubkey. +pub type AggSigDbFunc = + Arc CallbackFuture> + Send + Sync>; + +/// DutyDB callback: resolves attestation data for a `(slot, committee index)`. +pub type AwaitAttDataFunc = + Arc CallbackFuture + Send + Sync>; + +/// Fee recipient resolver: returns the configured fee recipient for a pubkey. +pub type FeeRecipientFunc = Arc ExecutionAddress + Send + Sync>; + +/// Errors returned while fetching duty data. +#[derive(Debug, thiserror::Error)] +pub enum FetcherError { + /// Wraps an inner error with the duty-type context, matching Go's + /// `errors.Wrap(err, "fetch data")`. + #[error("{context}: {source}")] + Fetch { + /// Context prefix (e.g. `fetch attester data`). + context: &'static str, + /// Wrapped inner error. + source: Box, + }, + + /// `DutyBuilderProposer` is deprecated and no longer supported. + #[error("DutyBuilderProposer is deprecated and no longer supported")] + DeprecatedDutyBuilderProposer, + + /// The duty type is not supported by the fetcher. + #[error("unsupported duty type: {0}")] + UnsupportedDutyType(String), + + /// A duty definition was not an attester definition. + #[error("invalid attester definition")] + InvalidAttesterDefinition, + + /// AggSigDB returned a value that was not a beacon committee selection. + #[error("invalid beacon committee selection")] + InvalidBeaconCommitteeSelection, + + /// AggSigDB returned a value that was not a sync committee selection. + #[error("invalid sync committee selection")] + InvalidSyncCommitteeSelection, + + /// AggSigDB returned a value that was not a sync committee message. + #[error("invalid sync committee message")] + InvalidSyncCommitteeMessage, + + /// The beacon node returned a nil attestation data response. + #[error("attestation data cannot be nil")] + NilAttestationData, + + /// The beacon node could not find an aggregate attestation for the root. + #[error("aggregate attestation not found by root (retryable)")] + AggregateAttestationNotFound, + + /// The beacon node could not find a sync committee contribution. + #[error("sync committee contribution not found by root (retryable)")] + SyncContributionNotFound, + + /// The beacon node returned an unexpected (non-success) response. + #[error("unexpected beacon node response")] + UnexpectedResponse, + + /// AggSigDB / DutyDB callback (or a subscriber) returned an error. + #[error("{0}")] + Callback(BoxError), + + /// Error from the beacon node API client. + #[error(transparent)] + BeaconNode(#[from] EthBeaconNodeApiClientError), + + /// Error from aggregator selection. + #[error(transparent)] + Eth2Exp(#[from] Eth2ExpError), + + /// JSON (de)serialization error while decoding a beacon node response. + #[error("decode beacon node response: {0}")] + Json(#[from] serde_json::Error), + + /// Failed to convert a loosely-typed beacon node value into a spec type. + #[error("convert beacon node response: {0}")] + Conversion(#[from] ConversionError), + + /// Failed to decode a beacon node response into a signed-data type. + #[error("decode proposal: {0}")] + SignedData(#[from] SignedDataError), + + /// A signed data value could not produce a signature. + #[error("signature: {0}")] + Signature(#[source] SignedDataError), +} + +/// Result alias for fetcher operations. +type Result = std::result::Result; + +/// Fetches proposed duty data from the beacon node. +#[derive(bon::Builder)] +pub struct Fetcher { + /// Subscribers invoked for each fetched duty data set. Appended via the + /// builder's `subscribe` method (zero or more times). + #[builder(field)] + subs: Vec, + eth2_cl: EthBeaconNodeApiClient, + fee_recipient: FeeRecipientFunc, + agg_sig_db: AggSigDbFunc, + await_att_data: AwaitAttDataFunc, + builder_enabled: bool, + graffiti_builder: GraffitiBuilder, + electra_slot: phase0::Slot, + fetch_only_comm_idx0: bool, +} + +impl FetcherBuilder { + /// Registers a callback for fetched duties. May be called multiple times to + /// register several subscribers. + pub fn subscribe(mut self, sub: Subscriber) -> Self { + self.subs.push(sub); + self + } +} + +impl Fetcher { + /// Triggers fetching of a proposed duty data set. + pub async fn fetch(&self, duty: Duty, def_set: DutyDefinitionSet) -> Result<()> { + let slot = duty.slot.inner(); + + let unsigned_set = match duty.duty_type { + DutyType::Proposer => self + .fetch_proposer_data(slot, &def_set) + .await + .map_err(wrap("fetch proposer data"))?, + DutyType::Attester => self + .fetch_attester_data(slot, &def_set) + .await + .map_err(wrap("fetch attester data"))?, + DutyType::BuilderProposer => return Err(FetcherError::DeprecatedDutyBuilderProposer), + DutyType::Aggregator => { + let set = self + .fetch_aggregator_data(slot, &def_set) + .await + .map_err(wrap("fetch aggregator data"))?; + if set.is_empty() { + // No aggregators found in this slot. + return Ok(()); + } + set + } + DutyType::SyncContribution => { + let set = self + .fetch_contribution_data(slot, &def_set) + .await + .map_err(wrap("fetch contribution data"))?; + if set.is_empty() { + // No sync committee contributors found in this slot. + return Ok(()); + } + set + } + other => return Err(FetcherError::UnsupportedDutyType(other.to_string())), + }; + + for sub in &self.subs { + // Clone before calling each subscriber. + let clone = unsigned_set.clone(); + sub(duty.clone(), clone) + .await + .map_err(FetcherError::Callback)?; + } + + Ok(()) + } + + /// Returns the fetched attestation data set for committees and validators + /// in the arg set. + async fn fetch_attester_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + // We may have multiple validators in the same committee, use the same + // attestation data in that case. + let mut data_by_comm_idx: HashMap = HashMap::new(); + + let mut resp = UnsignedDataSet::new(); + for (pubkey, def) in def_set { + let DutyDefinition::Attester(att_def) = def else { + return Err(FetcherError::InvalidAttesterDefinition); + }; + + let mut comm_idx = att_def.duty.committee_index; + + // Attestation data for Electra is not bound by committee index; + // committee index is still persisted in the request but should be + // set to 0 once all VCs request committee index 0. + if slot >= self.electra_slot && self.fetch_only_comm_idx0 { + comm_idx = 0; + } + + let eth2_att_data = match data_by_comm_idx.get(&comm_idx) { + Some(data) => data.clone(), + None => { + let data = self.attestation_data(slot, comm_idx).await?; + data_by_comm_idx.insert(comm_idx, data.clone()); + data + } + }; + + resp.insert( + *pubkey, + UnsignedDutyData::Attestation(AttestationData { + data: eth2_att_data, + duty: att_def.duty.clone(), + }), + ); + } + + Ok(resp) + } + + /// Fetches the attestation aggregation data. + async fn fetch_aggregator_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + let mut tracker = PubkeysTracker::new("attester aggregation"); + + // We may have multiple aggregators in the same committee, use the same + // aggregated attestation in that case. + let mut agg_att_by_comm_idx: HashMap = HashMap::new(); + + let mut resp = UnsignedDataSet::new(); + for (pubkey, def) in def_set { + let DutyDefinition::Attester(att_def) = def else { + return Err(FetcherError::InvalidAttesterDefinition); + }; + + // Query AggSigDB for DutyPrepareAggregator to get beacon committee + // selections. + let prep_agg_data = self + .query_agg_sig_db(Duty::new_prepare_aggregator_duty(slot.into()), *pubkey) + .await?; + let selection = downcast::(prep_agg_data.as_ref()) + .ok_or(FetcherError::InvalidBeaconCommitteeSelection)?; + + let is_aggregator = eth2exp::is_att_aggregator( + &self.eth2_cl, + att_def.duty.committee_length, + selection.0.selection_proof, + ) + .await?; + if !is_aggregator { + tracker.add_not_selected(pubkey.to_string()); + continue; + } + + tracker.add_resolved(pubkey.to_string()); + + let comm_idx = att_def.duty.committee_index; + + if let Some(agg_att) = agg_att_by_comm_idx.get(&comm_idx) { + resp.insert( + *pubkey, + UnsignedDutyData::AggAttestation(VersionedAggregatedAttestation( + agg_att.clone(), + )), + ); + // Skip querying aggregate attestation for aggregators of the + // same committee. + continue; + } + + // Query DutyDB for attestation data to get the attestation data root. + let att_data = self.query_att_data(slot, comm_idx).await?; + let data_root = att_data.tree_hash_root().0; + + // Query BN for aggregate attestation. + let agg_att = self + .aggregate_attestation(slot, comm_idx, data_root) + .await?; + + agg_att_by_comm_idx.insert(comm_idx, agg_att.clone()); + resp.insert( + *pubkey, + UnsignedDutyData::AggAttestation(VersionedAggregatedAttestation(agg_att)), + ); + } + + Ok(resp) + } + + /// Fetches the block proposal data set. + async fn fetch_proposer_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + let mut resp = UnsignedDataSet::new(); + for pubkey in def_set.keys() { + // Fetch previously aggregated randao reveal from AggSigDB. + let randao_data = self + .query_agg_sig_db(Duty::new_randao_duty(slot.into()), *pubkey) + .await?; + let randao = randao_data.signature().map_err(FetcherError::Signature)?; + + // Maximum priority to builder blocks when the builder is enabled. + let builder_boost_factor: u64 = if self.builder_enabled { u64::MAX } else { 0 }; + + let graffiti = self.graffiti_builder.get_graffiti(pubkey); + + let request = ProduceBlockV3Request::builder() + .slot(slot.to_string()) + .randao_reveal(format!("0x{}", hex::encode(randao))) + .graffiti(format!("0x{}", hex::encode(graffiti))) + .builder_boost_factor(builder_boost_factor.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let response = match self + .eth2_cl + .produce_block_v3(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + ProduceBlockV3Response::Ok(resp) => resp, + _ => return Err(FetcherError::UnexpectedResponse), + }; + + let proposal = VersionedProposal::try_from(&response)?; + + // Builders set the fee recipient to themselves, so it always differs + // from the validator's; only verify when the builder is disabled. + if !self.builder_enabled { + let fee_recipient = (self.fee_recipient)(pubkey); + verify_fee_recipient(&proposal, &fee_recipient); + } + + resp.insert(*pubkey, UnsignedDutyData::Proposal(Box::new(proposal))); + } + + Ok(resp) + } + + /// Fetches the sync committee contribution data. + async fn fetch_contribution_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + let mut tracker = PubkeysTracker::new("sync committee contribution"); + + let mut resp = UnsignedDataSet::new(); + for pubkey in def_set.keys() { + // Query AggSigDB for DutyPrepareSyncContribution to get the sync + // committee selection. + let selection_data = self + .query_agg_sig_db( + Duty::new_prepare_sync_contribution_duty(slot.into()), + *pubkey, + ) + .await?; + let selection = downcast::(selection_data.as_ref()) + .ok_or(FetcherError::InvalidSyncCommitteeSelection)?; + + let subcomm_idx = selection.0.subcommittee_index; + + // Check if the validator is an aggregator for the sync committee. + let is_aggregator = + eth2exp::is_sync_comm_aggregator(&self.eth2_cl, selection.0.selection_proof) + .await?; + if !is_aggregator { + tracker.add_not_selected(pubkey.to_string()); + continue; + } + + // Query AggSigDB for DutySyncMessage to get the beacon block root. + let sync_msg_data = self + .query_agg_sig_db(Duty::new_sync_message_duty(slot.into()), *pubkey) + .await?; + let msg = downcast::(sync_msg_data.as_ref()) + .ok_or(FetcherError::InvalidSyncCommitteeMessage)?; + + let block_root = msg.0.beacon_block_root; + + // Query BN for sync committee contribution. + let contribution = self + .sync_committee_contribution(slot, subcomm_idx, block_root) + .await?; + + tracker.add_resolved(pubkey.to_string()); + + resp.insert( + *pubkey, + UnsignedDutyData::SyncContribution(SyncContribution(contribution)), + ); + } + + Ok(resp) + } + + // Beacon node helpers + + /// Queries the beacon node for attestation data. + async fn attestation_data(&self, slot: u64, comm_idx: u64) -> Result { + let request = ProduceAttestationDataRequest::builder() + .slot(slot.to_string()) + .committee_index(comm_idx.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + match self + .eth2_cl + .produce_attestation_data(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + ProduceAttestationDataResponse::Ok(ok) => { + Ok(phase0::AttestationData::try_from(&ok.data)?) + } + _ => Err(FetcherError::NilAttestationData), + } + } + + /// Queries the beacon node for an aggregate attestation by data root. + async fn aggregate_attestation( + &self, + slot: u64, + comm_idx: u64, + data_root: phase0::Root, + ) -> Result { + let request = GetAggregatedAttestationV2Request::builder() + .attestation_data_root(format!("0x{}", hex::encode(data_root))) + .slot(slot.to_string()) + .committee_index(comm_idx.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let ok = match self + .eth2_cl + .get_aggregated_attestation_v2(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + GetAggregatedAttestationV2Response::Ok(ok) => ok, + // Some beacon nodes return nil if the root is not found; surface a + // retryable error. + _ => return Err(FetcherError::AggregateAttestationNotFound), + }; + + let version = versioned::DataVersion::from(&ok.version); + Ok(versioned::VersionedAttestation { + version, + validator_index: None, + attestation: Some(attestation_payload(version, &ok.data)?), + }) + } + + /// Queries the beacon node for a sync committee contribution. + async fn sync_committee_contribution( + &self, + slot: u64, + subcomm_idx: u64, + block_root: phase0::Root, + ) -> Result { + let request = ProduceSyncCommitteeContributionRequest::builder() + .slot(slot.to_string()) + .subcommittee_index(subcomm_idx.to_string()) + .beacon_block_root(format!("0x{}", hex::encode(block_root))) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + match self + .eth2_cl + .produce_sync_committee_contribution(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + ProduceSyncCommitteeContributionResponse::Ok(payload) => { + Ok(altair::SyncCommitteeContribution::try_from(&payload.data)?) + } + _ => Err(FetcherError::SyncContributionNotFound), + } + } + + /// Invokes the AggSigDB resolver. + async fn query_agg_sig_db(&self, duty: Duty, pubkey: PubKey) -> Result> { + (self.agg_sig_db)(duty, pubkey) + .await + .map_err(FetcherError::Callback) + } + + /// Invokes the DutyDB attestation-data resolver. + async fn query_att_data(&self, slot: u64, comm_idx: u64) -> Result { + (self.await_att_data)(slot, comm_idx) + .await + .map_err(FetcherError::Callback) + } +} + +/// Builds a closure that wraps a [`FetcherError`] with the duty-type context, +/// matching Go's `errors.Wrap(err, context)`. +fn wrap(context: &'static str) -> impl Fn(FetcherError) -> FetcherError { + move |source| FetcherError::Fetch { + context, + source: Box::new(source), + } +} + +/// Downcasts a `&dyn SignedData` to a concrete signed-data type. +fn downcast(data: &dyn SignedData) -> Option<&T> { + (data as &dyn std::any::Any).downcast_ref::() +} + +/// Builds a versioned attestation payload from the beacon node's aggregate +/// attestation response. +/// +/// The response carries the attestation as an untagged union: `Object2` is the +/// phase0-style attestation returned up to Deneb, `Object` is the +/// committee-aware Electra shape returned from Electra onwards. +fn attestation_payload( + version: versioned::DataVersion, + data: &GetAggregatedAttestationV2ResponseResponseData, +) -> Result { + use GetAggregatedAttestationV2ResponseResponseData as GenData; + use versioned::{AttestationPayload as AP, DataVersion as DV}; + + Ok(match (version, data) { + (DV::Phase0, GenData::Object2(att)) => AP::Phase0(att.try_into()?), + (DV::Altair, GenData::Object2(att)) => AP::Altair(att.try_into()?), + (DV::Bellatrix, GenData::Object2(att)) => AP::Bellatrix(att.try_into()?), + (DV::Capella, GenData::Object2(att)) => AP::Capella(att.try_into()?), + (DV::Deneb, GenData::Object2(att)) => AP::Deneb(att.try_into()?), + (DV::Electra, GenData::Object(att)) => AP::Electra(att.try_into()?), + (DV::Fulu, GenData::Object(att)) => AP::Fulu(att.try_into()?), + // A spec-compliant beacon node never pairs a fork version with the + // other fork's attestation shape (e.g. an Electra version reporting a + // phase0-style body), and `version` is derived from a + // `ConsensusVersion`, so it is never `Unknown`. + _ => return Err(FetcherError::UnexpectedResponse), + }) +} + +/// Logs a warning when the fee recipient is not correctly populated in the +/// proposal. Fee recipient is unavailable in forks earlier than Bellatrix. +fn verify_fee_recipient(proposal: &VersionedProposal, fee_recipient_address: &ExecutionAddress) { + if let Some((expected, actual)) = fee_recipient_mismatch(proposal, fee_recipient_address) { + tracing::warn!( + expected = format!("0x{}", hex::encode(expected)), + actual = format!("0x{}", hex::encode(actual)), + "Proposal with unexpected fee recipient address" + ); + } +} + +/// Returns `Some((expected, actual))` when the proposal's fee recipient differs +/// from `fee_recipient_address`. Returns `None` for forks +/// without a fee recipient (pre-Bellatrix) or when the addresses match. +fn fee_recipient_mismatch( + proposal: &VersionedProposal, + fee_recipient_address: &ExecutionAddress, +) -> Option<(ExecutionAddress, ExecutionAddress)> { + let actual_addr = proposal_block_fee_recipient(&proposal.block)?; + + if actual_addr == *fee_recipient_address { + None + } else { + Some((*fee_recipient_address, actual_addr)) + } +} + +/// Extracts the fee recipient from a proposal block, if available. Returns +/// `None` for pre-Bellatrix blocks or if the fee recipient cannot be extracted. +fn proposal_block_fee_recipient(block: &ProposalBlock) -> Option<[u8; 20]> { + match block { + ProposalBlock::Bellatrix(b) => Some(b.body.execution_payload.fee_recipient), + ProposalBlock::BellatrixBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Capella(b) => Some(b.body.execution_payload.fee_recipient), + ProposalBlock::CapellaBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Deneb { block, .. } => Some(block.body.execution_payload.fee_recipient), + ProposalBlock::DenebBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Electra { block, .. } => Some(block.body.execution_payload.fee_recipient), + ProposalBlock::ElectraBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Fulu { block, .. } => Some(block.body.execution_payload.fee_recipient), + ProposalBlock::FuluBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + _ => None, + } +} + +/// Tracks which pubkeys were selected/resolved for aggregation duties so the +/// outcome can be logged once per fetch. +struct PubkeysTracker { + title: &'static str, + not_selected_pubkeys: Vec, + resolved_pubkeys: Vec, +} + +impl PubkeysTracker { + fn new(title: &'static str) -> Self { + Self { + title, + not_selected_pubkeys: Vec::new(), + resolved_pubkeys: Vec::new(), + } + } + + fn add_not_selected(&mut self, pubkey: String) { + self.not_selected_pubkeys.push(pubkey); + } + + fn add_resolved(&mut self, pubkey: String) { + self.resolved_pubkeys.push(pubkey); + } + + fn log(&self) { + if !self.not_selected_pubkeys.is_empty() { + tracing::debug!( + title = self.title, + pubkeys = self.not_selected_pubkeys.join(","), + "not selected pubkeys" + ); + } + + if !self.resolved_pubkeys.is_empty() { + tracing::info!( + title = self.title, + pubkeys = self.resolved_pubkeys.join(","), + "resolved pubkeys" + ); + } + } +} + +impl Drop for PubkeysTracker { + fn drop(&mut self) { + // Log at the end of scope + self.log(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use pluto_testutil::BeaconMock; + + use super::*; + use crate::{ + signeddata::AttesterDuty, + types::{ + AttesterDutyDefinition, ProposerDutyDefinition, SlotNumber, SyncCommitteeDutyDefinition, + }, + }; + + /// 48-byte BLS public key length used to build distinct test pubkeys. + const PK_LEN: usize = 48; + + /// Captures the `(duty, set)` passed to the last subscriber invocation. + type Captured = Arc>>; + + /// Builds a subscriber that records its argument into `captured`. + fn capturing_subscriber(captured: Captured) -> Subscriber { + Arc::new(move |duty, set| { + let captured = captured.clone(); + Box::pin(async move { + *captured.lock().unwrap() = Some((duty, set)); + Ok(()) + }) + }) + } + + /// Fee-recipient stub for tests that don't exercise fee-recipient + /// verification. + fn stub_fee_recipient() -> FeeRecipientFunc { + Arc::new(|_| ExecutionAddress::default()) + } + + /// AggSigDB stub for tests whose duty path never queries it. + fn stub_agg_sig_db() -> AggSigDbFunc { + Arc::new(|_, _| Box::pin(async { unreachable!("AggSigDB not expected in this test") })) + } + + /// DutyDB attestation-data stub for tests whose duty path never queries it; + fn stub_await_att_data() -> AwaitAttDataFunc { + Arc::new(|_, _| Box::pin(async { unreachable!("AwaitAttData not expected in this test") })) + } + + /// Spec fields required by `is_sync_comm_aggregator` / + /// `is_att_aggregator`, matching the values the prysm selection-proof test + /// vectors were generated against. + fn aggregator_spec() -> serde_json::Value { + serde_json::json!({ + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "SYNC_COMMITTEE_SIZE": "512", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", + }) + } + + /// Decodes a 96-byte BLS signature from hex. + fn bls_sig(hex_str: &str) -> phase0::BLSSignature { + hex::decode(hex_str) + .expect("valid hex") + .try_into() + .expect("96-byte signature") + } + + /// Electra block contents (`{block, kzg_proofs, blobs}`) reused as the + /// `produce_block_v3` response payload. + const BLOCK_CONTENTS_GOLDEN: &str = include_str!( + "../../testdata/signeddata/TestJSONSerialisation_VersionedProposal.json.golden" + ); + + /// Blinded proposal (`{slot, .., body}`) whose body carries an + /// `execution_payload_header` with the Deneb-era blob fields. Reused to + /// build blinded proposals across forks in [`verify_fee_recipient`]. + const BLINDED_BLOCK_GOLDEN: &str = include_str!( + "../../testdata/signeddata/TestJSONSerialisation_VersionedBlindedProposal.json.golden" + ); + + /// Mounts a `produce_block_v3` responder that returns the golden Electra + /// block contents with the request's slot, randao reveal and graffiti + /// echoed back and a zero fee recipient. + async fn mount_produce_block(server: &wiremock::MockServer) { + let golden: serde_json::Value = + serde_json::from_str(BLOCK_CONTENTS_GOLDEN).expect("parse golden"); + let base = golden["block"].clone(); + + struct Responder { + base: serde_json::Value, + } + impl wiremock::Respond for Responder { + fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate { + let query: HashMap = req.url.query_pairs().into_owned().collect(); + let randao = query.get("randao_reveal").cloned().unwrap_or_default(); + let graffiti = query.get("graffiti").cloned().unwrap_or_default(); + // Slot is the final path segment. + let slot = req + .url + .path_segments() + .and_then(|mut s| s.next_back()) + .unwrap_or("0") + .to_string(); + + let mut data = self.base.clone(); + data["block"]["slot"] = serde_json::json!(slot); + data["block"]["body"]["randao_reveal"] = serde_json::json!(randao); + data["block"]["body"]["graffiti"] = serde_json::json!(graffiti); + data["block"]["body"]["execution_payload"]["fee_recipient"] = + serde_json::json!(format!("0x{}", "00".repeat(20))); + + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "version": "electra", + "execution_payload_blinded": false, + "execution_payload_value": "0", + "consensus_block_value": "0", + "data": data, + })) + } + } + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path_regex( + r"^/eth/v3/validator/blocks/[0-9]+$", + )) + .respond_with(Responder { base }) + .mount(server) + .await; + } + + /// Empties the variable-shape list fields of a block body JSON so it + /// deserializes into any fork's block type, regardless of per-fork element + /// shapes (e.g. Electra's committee-aware attestations vs. phase0's). + fn empty_block_body_lists(body: &mut serde_json::Value) { + for field in [ + "proposer_slashings", + "attester_slashings", + "attestations", + "deposits", + "voluntary_exits", + ] { + body[field] = serde_json::json!([]); + } + } + + /// Mirrors Go's `TestVerifyFeeRecipient`: every fork/blinded combination + /// from Bellatrix onwards must extract a fee recipient. A matching address + /// (case-insensitively) yields no mismatch; a different one is flagged. + #[test] + fn verify_fee_recipient() { + // The unblinded golden is the richest fork shape (Electra), whose body + // is a field-superset of every earlier fork. Since the spec types + // ignore unknown fields it deserializes into each fork's `BeaconBlock`. + let unblinded_block = { + let golden: serde_json::Value = + serde_json::from_str(BLOCK_CONTENTS_GOLDEN).expect("parse unblinded golden"); + let mut block = golden["block"]["block"].clone(); + empty_block_body_lists(&mut block["body"]); + block + }; + + // The blinded golden's `execution_payload_header` already carries the + // Deneb-era blob fields, so it deserializes into Bellatrix..Deneb + // blinded blocks directly. + let blinded_block = { + let golden: serde_json::Value = + serde_json::from_str(BLINDED_BLOCK_GOLDEN).expect("parse blinded golden"); + let mut block = golden["block"].clone(); + empty_block_body_lists(&mut block["body"]); + block + }; + + // Electra+ blinded blocks additionally require `execution_requests`. + let blinded_block_electra = { + let mut block = blinded_block.clone(); + block["body"]["execution_requests"] = serde_json::json!({ + "deposits": [], + "withdrawals": [], + "consolidations": [], + }); + block + }; + + let kzg_proofs = Vec::new(); + let blobs = Vec::new(); + let cases: Vec<(&str, ProposalBlock)> = vec![ + ( + "bellatrix", + ProposalBlock::Bellatrix( + serde_json::from_value(unblinded_block.clone()).expect("bellatrix"), + ), + ), + ( + "bellatrix blinded", + ProposalBlock::BellatrixBlinded( + serde_json::from_value(blinded_block.clone()).expect("bellatrix b"), + ), + ), + ( + "capella", + ProposalBlock::Capella( + serde_json::from_value(unblinded_block.clone()).expect("capella"), + ), + ), + ( + "capella blinded", + ProposalBlock::CapellaBlinded( + serde_json::from_value(blinded_block.clone()).expect("capella b"), + ), + ), + ( + "deneb", + ProposalBlock::Deneb { + block: Box::new( + serde_json::from_value(unblinded_block.clone()).expect("deneb"), + ), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + }, + ), + ( + "deneb blinded", + ProposalBlock::DenebBlinded( + serde_json::from_value(blinded_block.clone()).expect("deneb b"), + ), + ), + ( + "electra", + ProposalBlock::Electra { + block: Box::new( + serde_json::from_value(unblinded_block.clone()).expect("electra"), + ), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + }, + ), + ( + "electra blinded", + ProposalBlock::ElectraBlinded( + serde_json::from_value(blinded_block_electra.clone()).expect("electra b"), + ), + ), + ( + "fulu", + ProposalBlock::Fulu { + block: Box::new(serde_json::from_value(unblinded_block.clone()).expect("fulu")), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + }, + ), + ( + "fulu blinded", + ProposalBlock::FuluBlinded( + serde_json::from_value(blinded_block_electra.clone()).expect("fulu b"), + ), + ), + ]; + + for (name, block) in cases { + let proposal = VersionedProposal { + block, + consensus_block_value: alloy::primitives::U256::ZERO, + execution_payload_value: alloy::primitives::U256::ZERO, + }; + + // A different address is reported as a mismatch. + // the proposal's own fee recipient matches itself. + let some_fee_recipient = [0xFF; 20]; + let (_, actual) = fee_recipient_mismatch(&proposal, &some_fee_recipient) + .unwrap_or_else(|| panic!("{name}: expected a mismatch")); + assert!( + fee_recipient_mismatch(&proposal, &actual).is_none(), + "{name}: should match its own fee recipient", + ); + } + } + + #[tokio::test] + async fn fetch_blocks() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let randao_a: phase0::BLSSignature = [7u8; 96]; + let randao_b: phase0::BLSSignature = [8u8; 96]; + let randao_by_pubkey: HashMap = + HashMap::from([(pk_a, randao_a), (pk_b, randao_b)]); + + // disable_client_append = true, so graffiti is the raw string padded to + // 32 bytes. + let mut graffiti_a = [0u8; 32]; + graffiti_a[..5].copy_from_slice(b"testA"); + let mut graffiti_b = [0u8; 32]; + graffiti_b[..5].copy_from_slice(b"testB"); + + let def_set = DutyDefinitionSet::from([ + ( + pk_a, + DutyDefinition::Proposer(ProposerDutyDefinition { + pubkey: pk_a, + v_idx: 2, + slot: SlotNumber::new(SLOT), + }), + ), + ( + pk_b, + DutyDefinition::Proposer(ProposerDutyDefinition { + pubkey: pk_b, + v_idx: 3, + slot: SlotNumber::new(SLOT), + }), + ), + ]); + + let mock = BeaconMock::builder().build().await.expect("build mock"); + mount_produce_block(mock.server()).await; + + let graffiti_builder = GraffitiBuilder::new( + &[pk_a, pk_b], + Some(&["testA".to_string(), "testB".to_string()]), + true, + mock.client(), + ) + .await + .expect("build graffiti"); + + let randaos = randao_by_pubkey.clone(); + let agg_sig_db: AggSigDbFunc = Arc::new(move |_duty: Duty, pubkey: PubKey| { + let sig = randaos[&pubkey]; + Box::pin(async move { + let data: Box = Box::new(sig); + Ok(data) + }) + }); + + let captured: Captured = Arc::new(Mutex::new(None)); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(graffiti_builder) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + let duty = Duty::new_proposer_duty(SlotNumber::new(SLOT)); + fetcher.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_randao, expected_graffiti) in + [(pk_a, randao_a, graffiti_a), (pk_b, randao_b, graffiti_b)] + { + let UnsignedDutyData::Proposal(proposal) = res_set.get(&pubkey).expect("entry") else { + panic!("expected proposal"); + }; + assert_eq!(proposal.slot(), SLOT); + + let ProposalBlock::Electra { block, .. } = &proposal.block else { + panic!("expected electra block"); + }; + assert_eq!(block.slot, SLOT); + assert_eq!(block.body.randao_reveal, expected_randao); + assert_eq!(block.body.graffiti, expected_graffiti); + assert_eq!(block.body.execution_payload.fee_recipient, [0u8; 20]); + } + } + + #[tokio::test] + async fn fetch_attester() { + const SLOT: u64 = 1; + const V_IDX_A: u64 = 2; + const V_IDX_B: u64 = 3; + const NOT_ZERO: u64 = 99; // Validation requires non-zero values. + + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let duty_a = AttesterDuty { + slot: SLOT, + validator_index: V_IDX_A, + committee_index: V_IDX_A, + committee_length: NOT_ZERO, + committees_at_slot: NOT_ZERO, + validator_committee_index: 0, + }; + let duty_b = AttesterDuty { + slot: SLOT, + validator_index: V_IDX_B, + committee_index: V_IDX_B, + committee_length: NOT_ZERO, + committees_at_slot: NOT_ZERO, + validator_committee_index: 0, + }; + + let def_set = DutyDefinitionSet::from([ + ( + pk_a, + DutyDefinition::Attester(attester_duty_def(pk_a, &duty_a)), + ), + ( + pk_b, + DutyDefinition::Attester(attester_duty_def(pk_b, &duty_b)), + ), + ]); + + let duty = Duty::new_attester_duty(SlotNumber::new(SLOT)); + let mock = BeaconMock::builder().build().await.expect("build mock"); + + let captured: Captured = Arc::new(Mutex::new(None)); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(stub_agg_sig_db()) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + fetcher.fetch(duty.clone(), def_set).await.expect("fetch"); + + let (res_duty, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_duty, duty); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_duty, v_idx) in [(pk_a, &duty_a, V_IDX_A), (pk_b, &duty_b, V_IDX_B)] { + let UnsignedDutyData::Attestation(att) = res_set.get(&pubkey).expect("entry") else { + panic!("expected attestation data"); + }; + assert_eq!(att.data.slot, SLOT); + assert_eq!(att.data.index, v_idx); + assert_eq!(&att.duty, expected_duty); + } + } + + // Aggregator selection proofs from prysm's + // validate_sync_contribution_proof_test.go. + const SYNC_AGG_SIG_A: &str = "a9dbd88a49a7269e91b8ef1296f1e07f87fed919d51a446b67122bfdfd61d23f3f929fc1cd5209bd6862fd60f739b27213fb0a8d339f7f081fc84281f554b190bb49cc97a6b3364e622af9e7ca96a97fe2b766f9e746dead0b33b58473d91562"; + const SYNC_AGG_SIG_B: &str = "99e60f20dde4d4872b048d703f1943071c20213d504012e7e520c229da87661803b9f139b9a0c5be31de3cef6821c080125aed38ebaf51ba9a2e9d21d7fbf2903577983109d097a8599610a92c0305408d97c1fd4b0b2d1743fb4eedf5443f99"; + const SYNC_NON_AGG_SIG: &str = "b9251a82040d4620b8c5665f328ee6c2eaa02d31d71d153f4abba31a7922a981e541e85283f0ced387d26e86aef9386d18c6982b9b5f8759882fe7f25a328180d86e146994ef19d28bc1432baf29751dec12b5f3d65dbbe224d72cf900c6831a"; + + /// Mounts a request-aware sync-committee-contribution responder that echoes + /// the request slot / subcommittee index / beacon block root. + async fn mount_sync_contribution(server: &wiremock::MockServer) { + struct Responder; + impl wiremock::Respond for Responder { + fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate { + let query: std::collections::HashMap = + req.url.query_pairs().into_owned().collect(); + let slot = query.get("slot").cloned().unwrap_or_default(); + let subcommittee_index = + query.get("subcommittee_index").cloned().unwrap_or_default(); + let beacon_block_root = query.get("beacon_block_root").cloned().unwrap_or_default(); + + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "slot": slot, + "beacon_block_root": beacon_block_root, + "subcommittee_index": subcommittee_index, + "aggregation_bits": format!("0x{}", "00".repeat(16)), + "signature": format!("0x{}", "00".repeat(96)), + } + })) + } + } + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/eth/v1/validator/sync_committee_contribution", + )) + .respond_with(Responder) + .mount(server) + .await; + } + + /// Builds a phase0 attestation with the given committee index. + fn build_attestation(index: u64) -> phase0::Attestation { + phase0::Attestation { + aggregation_bits: phase0::BitList::default(), + data: phase0::AttestationData { + slot: 1, + index, + beacon_block_root: [u8::try_from(index).unwrap_or(0); 32], + source: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + target: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + }, + signature: [0u8; 96], + } + } + + /// Mounts an aggregate-attestation responder that returns the Deneb + /// attestation whose data root matches the request, or 404 when unknown. + async fn mount_aggregate( + server: &wiremock::MockServer, + by_root: HashMap, + ) { + struct Responder { + by_root: HashMap, + } + impl wiremock::Respond for Responder { + fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate { + let query: HashMap = req.url.query_pairs().into_owned().collect(); + let root = query + .get("attestation_data_root") + .cloned() + .unwrap_or_default(); + match self.by_root.get(&root) { + Some(att) => wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "version": "deneb", "data": att })), + None => wiremock::ResponseTemplate::new(404) + .set_body_json(serde_json::json!({ "code": 404, "message": "not found" })), + } + } + } + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/eth/v2/validator/aggregate_attestation", + )) + .respond_with(Responder { by_root }) + .mount(server) + .await; + } + + /// Builds an attester duty definition from an eth2 [`AttesterDuty`], keyed + /// by the given public key. + fn attester_duty_def(pubkey: PubKey, duty: &AttesterDuty) -> AttesterDutyDefinition { + AttesterDutyDefinition { + pubkey, + duty: duty.clone(), + } + } + + /// Builds an attester definition with the given committee index/length. + fn attester_def(comm_idx: u64, comm_len: u64) -> DutyDefinition { + DutyDefinition::Attester(attester_duty_def( + PubKey::new([0u8; PK_LEN]), + &AttesterDuty { + slot: 1, + validator_index: 0, + committee_index: comm_idx, + committee_length: comm_len, + committees_at_slot: 1, + validator_committee_index: 0, + }, + )) + } + + /// Builds the AggSigDB (returns a beacon committee selection) and DutyDB + /// (returns the attestation data for each committee index) callbacks used + /// by the aggregator tests. + fn aggregator_funcs( + atts: impl AsRef<[phase0::Attestation]>, + ) -> (AggSigDbFunc, AwaitAttDataFunc) { + use pluto_eth2api::v1; + + let agg_sig_db: AggSigDbFunc = Arc::new(move |_duty: Duty, _pubkey: PubKey| { + Box::pin(async move { + let selection = BeaconCommitteeSelection::new(v1::BeaconCommitteeSelection { + slot: 1, + validator_index: 0, + selection_proof: [0u8; 96], + }); + let data: Box = Box::new(selection); + Ok(data) + }) + }); + + let by_idx: HashMap = atts + .as_ref() + .iter() + .map(|a| (a.data.index, a.data.clone())) + .collect(); + let await_att_data: AwaitAttDataFunc = Arc::new(move |_slot: u64, comm_idx: u64| { + let data = by_idx.get(&comm_idx).cloned(); + Box::pin(async move { data.ok_or_else(|| "missing attestation data".into()) }) + }); + + (agg_sig_db, await_att_data) + } + + #[tokio::test] + async fn fetch_aggregator_different_committee() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let att_a = build_attestation(2); + let att_b = build_attestation(3); + + let def_set = DutyDefinitionSet::from([ + (pk_a, attester_def(att_a.data.index, 0)), + (pk_b, attester_def(att_b.data.index, 0)), + ]); + + let by_root = HashMap::from([ + ( + format!("0x{}", hex::encode(att_a.data.tree_hash_root().0)), + att_a.clone(), + ), + ( + format!("0x{}", hex::encode(att_b.data.tree_hash_root().0)), + att_b.clone(), + ), + ]); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_aggregate(mock.server(), by_root).await; + + let (agg_sig_db, await_att_data) = aggregator_funcs(&[att_a.clone(), att_b.clone()]); + + let captured: Captured = Arc::new(Mutex::new(None)); + let fetch = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + fetch.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_idx) in [(pk_a, 2u64), (pk_b, 3u64)] { + let UnsignedDutyData::AggAttestation(agg) = res_set.get(&pubkey).expect("entry") else { + panic!("expected aggregated attestation"); + }; + assert_eq!(agg.data().expect("data").index, expected_idx); + } + } + + #[tokio::test] + async fn fetch_aggregator_same_committee() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + // Both validators belong to the same committee; the aggregate is fetched + // once and reused for the second validator. + let att = build_attestation(2); + let def_set = DutyDefinitionSet::from([ + (pk_a, attester_def(att.data.index, 0)), + (pk_b, attester_def(att.data.index, 0)), + ]); + + let by_root = HashMap::from([( + format!("0x{}", hex::encode(att.data.tree_hash_root().0)), + att.clone(), + )]); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_aggregate(mock.server(), by_root).await; + + let (agg_sig_db, await_att_data) = aggregator_funcs(std::slice::from_ref(&att)); + + let captured: Captured = Arc::new(Mutex::new(None)); + let fetch = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + fetch.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + for pubkey in [pk_a, pk_b] { + let UnsignedDutyData::AggAttestation(agg) = res_set.get(&pubkey).expect("entry") else { + panic!("expected aggregated attestation"); + }; + assert_eq!(agg.data().expect("data").index, 2); + } + } + + #[tokio::test] + async fn fetch_aggregator_no_aggregator() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + + let att_a = build_attestation(2); + let mut def_set = DutyDefinitionSet::new(); + // u64::MAX committee length makes the selection modulo enormous, so the + // validator is never selected as an aggregator. + def_set.insert(pk_a, attester_def(att_a.data.index, u64::MAX)); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_aggregate(mock.server(), HashMap::new()).await; + + let (agg_sig_db, await_att_data) = aggregator_funcs([att_a]); + + let captured: Captured = Arc::new(Mutex::new(None)); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + // No aggregators found -> empty set -> Ok and subscriber not invoked. + fetcher.fetch(duty, def_set).await.expect("fetch"); + assert!(captured.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn fetch_aggregator_nil_aggregate() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + + let att_a = build_attestation(2); + let mut def_set = DutyDefinitionSet::new(); + def_set.insert(pk_a, attester_def(att_a.data.index, 0)); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + // Empty map -> responder returns 404 for every root. + mount_aggregate(mock.server(), HashMap::new()).await; + + let (agg_sig_db, await_att_data) = aggregator_funcs(std::slice::from_ref(&att_a)); + + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .build(); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + let err = fetcher + .fetch(duty, def_set) + .await + .expect_err("expected error"); + assert!( + err.to_string() + .contains("aggregate attestation not found by root (retryable)"), + "got: {err}" + ); + } + + #[tokio::test] + async fn fetch_sync_contribution_aggregator() { + use pluto_eth2api::{spec::altair, v1}; + + const SLOT: u64 = 1; + const V_IDX_A: u64 = 2; + const V_IDX_B: u64 = 3; + const SUBCOMM_A: u64 = 4; + const SUBCOMM_B: u64 = 5; + + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let root_a = [10u8; 32]; + let root_b = [11u8; 32]; + + let selection = |v_idx, subcomm, sig| { + SyncCommitteeSelection::new(v1::SyncCommitteeSelection { + slot: SLOT, + validator_index: v_idx, + subcommittee_index: subcomm, + selection_proof: bls_sig(sig), + }) + }; + let message = |v_idx, root| { + SignedSyncMessage::new(altair::SyncCommitteeMessage { + slot: SLOT, + beacon_block_root: root, + validator_index: v_idx, + signature: [0u8; 96], + }) + }; + + let sel_a = selection(V_IDX_A, SUBCOMM_A, SYNC_AGG_SIG_A); + let sel_b = selection(V_IDX_B, SUBCOMM_B, SYNC_AGG_SIG_B); + let msg_a = message(V_IDX_A, root_a); + let msg_b = message(V_IDX_B, root_b); + + let selections: HashMap = + HashMap::from([(pk_a, sel_a), (pk_b, sel_b)]); + let messages: HashMap = + HashMap::from([(pk_a, msg_a), (pk_b, msg_b)]); + + let mut def_set = DutyDefinitionSet::new(); + for pk in [pk_a, pk_b] { + def_set.insert( + pk, + DutyDefinition::SyncCommittee(SyncCommitteeDutyDefinition { + pubkey: pk, + validator_index: 0, + validator_sync_committee_indices: vec![], + }), + ); + } + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_sync_contribution(mock.server()).await; + + let sels = selections.clone(); + let msgs = messages.clone(); + let agg_sig_db: AggSigDbFunc = Arc::new(move |duty: Duty, pubkey: PubKey| { + let sels = sels.clone(); + let msgs = msgs.clone(); + Box::pin(async move { + let data: Box = match duty.duty_type { + DutyType::PrepareSyncContribution => Box::new(sels[&pubkey].clone()), + DutyType::SyncMessage => Box::new(msgs[&pubkey].clone()), + _ => return Err("unsupported duty".into()), + }; + Ok(data) + }) + }); + + let captured: Captured = Arc::new(Mutex::new(None)); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); + fetcher.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_subcomm, expected_root) in + [(pk_a, SUBCOMM_A, root_a), (pk_b, SUBCOMM_B, root_b)] + { + let UnsignedDutyData::SyncContribution(contrib) = res_set.get(&pubkey).expect("entry") + else { + panic!("expected sync contribution"); + }; + assert_eq!(contrib.0.slot, SLOT); + assert_eq!(contrib.0.subcommittee_index, expected_subcomm); + assert_eq!(contrib.0.beacon_block_root, expected_root); + } + } + + #[tokio::test] + async fn fetch_sync_contribution_not_aggregator() { + use pluto_eth2api::v1; + + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let mut def_set = DutyDefinitionSet::new(); + for pk in [pk_a, pk_b] { + def_set.insert( + pk, + DutyDefinition::SyncCommittee(SyncCommitteeDutyDefinition { + pubkey: pk, + validator_index: 0, + validator_sync_committee_indices: vec![], + }), + ); + } + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + + let agg_sig_db: AggSigDbFunc = Arc::new(move |duty: Duty, _pubkey: PubKey| { + Box::pin(async move { + if duty.duty_type == DutyType::PrepareSyncContribution { + let selection = SyncCommitteeSelection::new(v1::SyncCommitteeSelection { + slot: 0, + validator_index: 0, + subcommittee_index: 0, + selection_proof: bls_sig(SYNC_NON_AGG_SIG), + }); + let data: Box = Box::new(selection); + return Ok(data); + } + Err("unsupported duty".into()) + }) + }); + + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .build(); + + let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); + // Non-aggregators are skipped, producing an empty set and no error. + fetcher.fetch(duty, def_set).await.expect("fetch"); + } + + #[tokio::test] + async fn fetch_sync_contribution_data_error() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + + let mut def_set = DutyDefinitionSet::new(); + def_set.insert( + pk_a, + DutyDefinition::SyncCommittee(SyncCommitteeDutyDefinition { + pubkey: pk_a, + validator_index: 0, + validator_sync_committee_indices: vec![], + }), + ); + + let mock = BeaconMock::builder().build().await.expect("build mock"); + let agg_sig_db: AggSigDbFunc = Arc::new(move |_duty: Duty, _pubkey: PubKey| { + Box::pin(async move { Err("error".into()) }) + }); + + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .build(); + + let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); + let err = fetcher + .fetch(duty, def_set) + .await + .expect_err("expected error"); + let msg = err.to_string(); + assert!(msg.contains("fetch contribution data"), "got: {msg}"); + assert!(msg.contains("error"), "got: {msg}"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 65978060..9f9e21ec 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -53,6 +53,9 @@ pub mod aggsigdb; /// Broadcaster for aggregate signed duty data. pub mod bcast; +/// Fetcher — fetches unsigned duty data from the beacon node. +pub mod fetcher; + mod parsigex_codec; // SSZ codec operates on compile-time-constant byte sizes and offsets. diff --git a/crates/core/src/scheduler.rs b/crates/core/src/scheduler.rs index 3893460b..8cac3df7 100644 --- a/crates/core/src/scheduler.rs +++ b/crates/core/src/scheduler.rs @@ -441,7 +441,7 @@ impl SchedulerActor { let att_duties = fetch_attester_duties(&slot, &vals, &self.client).await?; for att_duty in att_duties.into_iter() { if !self.set_duty_definition( - types::Duty::new_attester_duty(att_duty.slot), + types::Duty::new_attester_duty(att_duty.duty.slot.into()), slot.epoch(), att_duty.pubkey, types::DutyDefinition::Attester(att_duty.clone()), @@ -450,15 +450,15 @@ impl SchedulerActor { } tracing::info!( - slot = %att_duty.slot, - vidx = %att_duty.v_idx, + slot = %att_duty.duty.slot, + vidx = %att_duty.duty.validator_index, pubkey = %att_duty.pubkey, epoch = %slot.epoch(), "Resolved attester duty" ); // Schedule Aggregator duty as well - let agg_duty = types::Duty::new_aggregator_duty(att_duty.slot); + let agg_duty = types::Duty::new_aggregator_duty(att_duty.duty.slot.into()); self.set_duty_definition( agg_duty, slot.epoch(), @@ -870,20 +870,20 @@ async fn fetch_attester_duties( let mut result = vec![]; for att_duty in att_duties.into_iter() { - remaining.remove(&att_duty.v_idx); + remaining.remove(&att_duty.duty.validator_index); - if att_duty.slot < slot.slot { + if att_duty.duty.slot < slot.slot.inner() { // Skip duties for earlier slots in initial epoch. continue; } let Some(pubkey) = validators .iter() - .find(|v| v.v_idx == att_duty.v_idx) + .find(|v| v.v_idx == att_duty.duty.validator_index) .map(|v| v.pubkey) else { tracing::warn!( - vidx = att_duty.v_idx, + vidx = att_duty.duty.validator_index, slot = %slot.slot, "Ignoring unexpected attester duty" ); @@ -1161,7 +1161,10 @@ mod tests { pubkey: pubkey.to_string(), validator_index: v_idx.to_string(), slot: slot.to_string(), - ..Default::default() + committee_index: "0".to_string(), + committee_length: "0".to_string(), + committees_at_slot: "0".to_string(), + validator_committee_index: "0".to_string(), }; let def: types::AttesterDutyDefinition = datum.try_into().expect("valid attester datum"); types::DutyDefinition::Attester(def) diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 2fc4ed95..6eb84785 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use tree_hash::TreeHash; use pluto_eth2api::{ + ConsensusVersion, ProduceBlockV3ResponseResponse, spec::{ altair, bellatrix, capella, deneb, electra, phase0, serde_legacy_builder_version, serde_legacy_data_version, @@ -51,6 +52,12 @@ pub enum SignedDataError { /// Invalid attestation wrapper JSON. #[error("unmarshal attestation")] AttestationJson, + /// A proposal response carried an unparsable block reward value. + #[error("invalid proposal block value: {0}")] + InvalidBlockValue(&'static str), + /// A versioned proposal response was missing the `block` field. + #[error("proposal response missing block field")] + MissingBlockField, /// Custom error. #[error("{0}")] Custom(Box), @@ -1367,6 +1374,85 @@ impl VersionedProposal { } } +impl TryFrom<&ProduceBlockV3ResponseResponse> for VersionedProposal { + type Error = SignedDataError; + + /// Builds an unsigned proposal from a `produce_block_v3` response, + /// selecting the block variant by `(version, blinded)`. + fn try_from(resp: &ProduceBlockV3ResponseResponse) -> Result { + let data = serde_json::to_value(&resp.data)?; + let blinded = resp.execution_payload_blinded; + + let block = match (&resp.version, blinded) { + (ConsensusVersion::Phase0, _) => ProposalBlock::Phase0(json_from(&data)?), + (ConsensusVersion::Altair, _) => ProposalBlock::Altair(json_from(&data)?), + (ConsensusVersion::Bellatrix, false) => ProposalBlock::Bellatrix(json_from(&data)?), + (ConsensusVersion::Bellatrix, true) => { + ProposalBlock::BellatrixBlinded(json_from(&data)?) + } + (ConsensusVersion::Capella, false) => ProposalBlock::Capella(json_from(&data)?), + (ConsensusVersion::Capella, true) => ProposalBlock::CapellaBlinded(json_from(&data)?), + (ConsensusVersion::Deneb, false) => ProposalBlock::Deneb { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Deneb, true) => ProposalBlock::DenebBlinded(json_from(&data)?), + (ConsensusVersion::Electra, false) => ProposalBlock::Electra { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Electra, true) => ProposalBlock::ElectraBlinded(json_from(&data)?), + (ConsensusVersion::Fulu, false) => ProposalBlock::Fulu { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Fulu, true) => ProposalBlock::FuluBlinded(json_from(&data)?), + }; + + let consensus_block_value = resp + .consensus_block_value + .parse() + .map_err(|_| SignedDataError::InvalidBlockValue("consensus_block_value"))?; + let execution_payload_value = resp + .execution_payload_value + .parse() + .map_err(|_| SignedDataError::InvalidBlockValue("execution_payload_value"))?; + + Ok(VersionedProposal { + block, + consensus_block_value, + execution_payload_value, + }) + } +} + +/// Deserializes a JSON value into `T`. +fn json_from( + value: &serde_json::Value, +) -> Result { + Ok(serde_json::from_value(value.clone())?) +} + +/// Returns the `block` field of a Deneb+ versioned block contents object. +fn block_field(value: &serde_json::Value) -> Result<&serde_json::Value, SignedDataError> { + value.get("block").ok_or(SignedDataError::MissingBlockField) +} + +/// Deserializes the named field of `value` into `T`, defaulting to `T::default` +/// when absent. +fn json_from_field( + value: &serde_json::Value, + field: &str, +) -> Result { + match value.get(field) { + Some(v) => Ok(serde_json::from_value(v.clone())?), + None => Ok(T::default()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -2825,4 +2911,25 @@ mod tests { assert_eq!(Some(aggregation_bits.clone()), wrapped.aggregation_bits()); } } + + #[test] + fn versioned_proposal_from_produce_block_response() { + // Electra block contents `{block, kzg_proofs, blobs}` from the golden + // fixture, wrapped as a `produce_block_v3` response. + let golden = load_signeddata_fixture("TestJSONSerialisation_VersionedProposal.json.golden"); + let resp: ProduceBlockV3ResponseResponse = serde_json::from_value(serde_json::json!({ + "version": "electra", + "execution_payload_blinded": false, + "execution_payload_value": "11", + "consensus_block_value": "22", + "data": golden["block"], + })) + .expect("deserialize produce_block_v3 response"); + + let proposal = VersionedProposal::try_from(&resp).expect("convert"); + assert!(matches!(proposal.block, ProposalBlock::Electra { .. })); + assert_eq!(proposal.version(), versioned::DataVersion::Electra); + assert_eq!(proposal.execution_payload_value, U256::from(11)); + assert_eq!(proposal.consensus_block_value, U256::from(22)); + } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 2d1a1bfd..b7af6f3c 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -12,7 +12,7 @@ use crate::{ ParSigExCodecError, corepb::v1::core as pbcore, parsigex_codec::{deserialize_signed_data, serialize_signed_data}, - signeddata::SignedDataError, + signeddata::{AttesterDuty, SignedDataError}, }; /// The type of duty. @@ -427,36 +427,64 @@ impl AsRef<[u8]> for PubKey { } /// Attestation duties to be performed by validators for a particular epoch. +/// +/// Mirrors Charon's `core.AttesterDefinition`, which embeds the eth2 +/// `v1.AttesterDuty`. Pluto's [`AttesterDuty`] omits the validator public key, +/// so it is carried alongside the embedded duty. #[derive(Debug, Clone, PartialEq)] pub struct AttesterDutyDefinition { - /// The validator's BLS public key + /// The validator's BLS public key. pub pubkey: PubKey, - /// Index of validator in validator registry - pub v_idx: u64, - /// The slot at which the validator must attest. - pub slot: SlotNumber, + /// The attester duty to perform. + pub duty: AttesterDuty, } -impl TryInto - for pluto_eth2api::types::GetAttesterDutiesResponseResponseDatum +impl TryFrom + for AttesterDutyDefinition { type Error = pluto_eth2api::EthBeaconNodeApiClientError; - fn try_into(self) -> Result { - let pubkey = PubKey::try_from(self.pubkey.as_str()) + fn try_from( + value: pluto_eth2api::types::GetAttesterDutiesResponseResponseDatum, + ) -> Result { + let pubkey = PubKey::try_from(value.pubkey.as_str()) .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("pubkey".into()))?; - let v_idx = self.validator_index.parse::().map_err(|_| { + let validator_index = value.validator_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("validator_index".into()) })?; - let slot = - SlotNumber::from(self.slot.parse::().map_err(|_| { - pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()) - })?); + let slot = value + .slot + .parse::() + .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()))?; + let committee_index = value.committee_index.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_index".into()) + })?; + let committee_length = value.committee_length.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_length".into()) + })?; + let committees_at_slot = value.committees_at_slot.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committees_at_slot".into()) + })?; + let validator_committee_index = + value + .validator_committee_index + .parse::() + .map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError( + "validator_committee_index".into(), + ) + })?; Ok(AttesterDutyDefinition { pubkey, - v_idx, - slot, + duty: AttesterDuty { + slot, + validator_index, + committee_index, + committee_length, + committees_at_slot, + validator_committee_index, + }, }) } } @@ -509,18 +537,20 @@ pub struct SyncCommitteeDutyDefinition { pub validator_sync_committee_indices: Vec, } -impl TryInto - for pluto_eth2api::types::GetSyncCommitteeDutiesResponseResponseDatum +impl TryFrom + for SyncCommitteeDutyDefinition { type Error = pluto_eth2api::EthBeaconNodeApiClientError; - fn try_into(self) -> Result { - let pubkey = PubKey::try_from(self.pubkey.as_str()) + fn try_from( + value: pluto_eth2api::types::GetSyncCommitteeDutiesResponseResponseDatum, + ) -> Result { + let pubkey = PubKey::try_from(value.pubkey.as_str()) .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("pubkey".into()))?; - let validator_index = self.validator_index.parse::().map_err(|_| { + let validator_index = value.validator_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("validator_index".into()) })?; - let validator_sync_committee_indices = self + let validator_sync_committee_indices = value .validator_sync_committee_indices .iter() .map(|idx| { @@ -555,69 +585,6 @@ pub enum DutyDefinition { /// public key. pub type DutyDefinitionSet = HashMap; -/// Unsigned data type -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsignedData(T); - -impl UnsignedData -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new unsigned data. - pub fn new(unsigned_data: T) -> Self { - Self(unsigned_data) - } -} -/// Unsigned data set -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsignedDataSet(HashMap>) -where - T: Clone + Serialize + StdDebug; - -impl Default for UnsignedDataSet -where - T: Clone + Serialize + StdDebug, -{ - fn default() -> Self { - Self(HashMap::default()) - } -} - -impl UnsignedDataSet -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new unsigned data set. - pub fn new() -> Self { - Self::default() - } - - /// Get an unsigned data by duty type. - pub fn get(&self, duty_type: &DutyType) -> Option<&UnsignedData> { - self.0.get(duty_type) - } - - /// Insert an unsigned data. - pub fn insert(&mut self, duty_type: DutyType, unsigned_data: UnsignedData) { - self.0.insert(duty_type, unsigned_data); - } - - /// Remove an unsigned data by duty type. - pub fn remove(&mut self, duty_type: &DutyType) -> Option> { - self.0.remove(duty_type) - } - - /// Inner unsigned data set. - pub fn inner(&self) -> &HashMap> { - &self.0 - } - - /// Inner unsigned data set. - pub fn inner_mut(&mut self) -> &mut HashMap> { - &mut self.0 - } -} - /// Signed data type pub trait SignedData: Any + DynClone + DynEq + StdDebug + Send + Sync { /// signature returns the signed duty data's signature. @@ -1039,16 +1006,6 @@ mod tests { assert_eq!(pk.abbreviated(), "2a2_a2a"); } - #[test] - fn unsigned_data_set() { - let mut unsigned_data_set = UnsignedDataSet::new(); - unsigned_data_set.insert(DutyType::Proposer, UnsignedData::new(DutyType::Proposer)); - assert_eq!( - unsigned_data_set.get(&DutyType::Proposer), - Some(&UnsignedData::new(DutyType::Proposer)) - ); - } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct MockSignedData; diff --git a/crates/eth2api/src/spec/altair.rs b/crates/eth2api/src/spec/altair.rs index e30321dc..755d7b4f 100644 --- a/crates/eth2api/src/spec/altair.rs +++ b/crates/eth2api/src/spec/altair.rs @@ -8,7 +8,10 @@ use tree_hash_derive::TreeHash; use pluto_ssz::BitVector; -use crate::spec::phase0; +use crate::spec::{ + phase0, + serde_utils::{ConversionError, decode_hex_fixed, decode_hex_var, parse_u64}, +}; /// Sync aggregate included in Altair+ block bodies. /// @@ -131,6 +134,33 @@ pub struct SyncCommitteeContribution { pub signature: phase0::BLSSignature, } +impl TryFrom<&crate::Contribution> for SyncCommitteeContribution { + type Error = ConversionError; + + fn try_from(value: &crate::Contribution) -> Result { + const BITS_FIELD: &str = "sync_committee_contribution.aggregation_bits"; + let aggregation_bits = as ssz::Decode>::from_ssz_bytes(&decode_hex_var( + &value.aggregation_bits, + BITS_FIELD, + )?) + .map_err(|_| ConversionError::DecodeHex { field: BITS_FIELD })?; + + Ok(Self { + slot: parse_u64(&value.slot, "sync_committee_contribution.slot")?, + beacon_block_root: decode_hex_fixed( + &value.beacon_block_root, + "sync_committee_contribution.beacon_block_root", + )?, + subcommittee_index: parse_u64( + &value.subcommittee_index, + "sync_committee_contribution.subcommittee_index", + )?, + aggregation_bits, + signature: decode_hex_fixed(&value.signature, "sync_committee_contribution.signature")?, + }) + } +} + /// Contribution-and-proof payload. /// /// Spec: @@ -277,4 +307,48 @@ mod tests { fn tree_hash_matches_vector(actual: String, expected: &'static str) { assert_eq!(actual, expected); } + + #[test] + fn sync_committee_contribution_try_from_matches_json_roundtrip() { + let wire = serde_json::json!({ + "slot": "9", + "beacon_block_root": format!("0x{}", "66".repeat(32)), + "subcommittee_index": "3", + "aggregation_bits": format!("0x{}", "00".repeat(16)), + "signature": format!("0x{}", "77".repeat(96)), + }); + let generated: crate::Contribution = + serde_json::from_value(wire.clone()).expect("deserialize generated Contribution"); + + // Direct conversion must equal the loosely-typed JSON round-trip it + // replaces. + let direct = SyncCommitteeContribution::try_from(&generated).expect("convert"); + let via_json: SyncCommitteeContribution = + serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + + assert_eq!(direct.slot, 9); + assert_eq!(direct.subcommittee_index, 3); + assert_eq!(direct.beacon_block_root, [0x66; 32]); + assert_eq!(direct.signature, [0x77; 96]); + } + + #[test] + fn sync_committee_contribution_try_from_rejects_bad_bits_length() { + let wire = serde_json::json!({ + "slot": "9", + "beacon_block_root": format!("0x{}", "66".repeat(32)), + "subcommittee_index": "3", + // BitVector<128> requires exactly 16 bytes. + "aggregation_bits": "0x0102", + "signature": format!("0x{}", "77".repeat(96)), + }); + let generated: crate::Contribution = serde_json::from_value(wire).expect("deserialize"); + assert!(matches!( + SyncCommitteeContribution::try_from(&generated), + Err(ConversionError::DecodeHex { + field: "sync_committee_contribution.aggregation_bits" + }) + )); + } } diff --git a/crates/eth2api/src/spec/electra.rs b/crates/eth2api/src/spec/electra.rs index ff98d337..7ceeab82 100644 --- a/crates/eth2api/src/spec/electra.rs +++ b/crates/eth2api/src/spec/electra.rs @@ -7,7 +7,10 @@ use tree_hash_derive::TreeHash; use pluto_ssz::{BitList, BitVector}; -use crate::spec::{altair, bellatrix, capella, deneb, phase0}; +use crate::spec::{ + altair, bellatrix, capella, deneb, phase0, + serde_utils::{ConversionError, decode_hex_fixed, decode_hex_var}, +}; /// Maximum number of attester slashings per block (Electra). pub const MAX_ATTESTER_SLASHINGS_ELECTRA: usize = 1; @@ -64,6 +67,33 @@ pub struct Attestation { pub committee_bits: BitVector<64>, } +impl TryFrom<&crate::GetBlockAttestationsV2ResponseResponseDataArray> for Attestation { + type Error = ConversionError; + + fn try_from( + value: &crate::GetBlockAttestationsV2ResponseResponseDataArray, + ) -> Result { + const COMMITTEE_BITS_FIELD: &str = "attestation.committee_bits"; + let committee_bits = as ssz::Decode>::from_ssz_bytes(&decode_hex_var( + &value.committee_bits, + COMMITTEE_BITS_FIELD, + )?) + .map_err(|_| ConversionError::DecodeHex { + field: COMMITTEE_BITS_FIELD, + })?; + + Ok(Self { + aggregation_bits: BitList::from_ssz_bytes(decode_hex_var( + &value.aggregation_bits, + "attestation.aggregation_bits", + )?), + data: phase0::AttestationData::try_from(&value.data)?, + signature: decode_hex_fixed(&value.signature, "attestation.signature")?, + committee_bits, + }) + } +} + /// Execution-layer deposit request. /// /// Spec: @@ -370,4 +400,30 @@ mod tests { serde_json::from_value(json).expect("deserialize indexed attestation"); assert_eq!(roundtrip.attesting_indices.0, vec![21, 22]); } + + #[test] + fn attestation_try_from_matches_json_roundtrip() { + let wire = serde_json::json!({ + "aggregation_bits": "0x0102", + "committee_bits": format!("0x{}", "00".repeat(8)), + "data": { + "slot": "42", + "index": "3", + "beacon_block_root": format!("0x{}", "11".repeat(32)), + "source": { "epoch": "5", "root": format!("0x{}", "22".repeat(32)) }, + "target": { "epoch": "6", "root": format!("0x{}", "33".repeat(32)) }, + }, + "signature": format!("0x{}", "44".repeat(96)), + }); + let generated: crate::GetBlockAttestationsV2ResponseResponseDataArray = + serde_json::from_value(wire.clone()).expect("deserialize generated attestation"); + + // Direct conversion must equal the loosely-typed JSON round-trip it + // replaces. + let direct = super::Attestation::try_from(&generated).expect("convert"); + let via_json: super::Attestation = serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + assert_eq!(direct.data.slot, 42); + assert_eq!(direct.signature, [0x44; 96]); + } } diff --git a/crates/eth2api/src/spec/mod.rs b/crates/eth2api/src/spec/mod.rs index a7924b78..c8ee25fc 100644 --- a/crates/eth2api/src/spec/mod.rs +++ b/crates/eth2api/src/spec/mod.rs @@ -3,6 +3,7 @@ /// Shared serde helpers for spec-compatible JSON. pub mod serde_utils; +pub use serde_utils::ConversionError; /// Spec-level version enums. pub mod version; diff --git a/crates/eth2api/src/spec/phase0.rs b/crates/eth2api/src/spec/phase0.rs index 4b67a76f..0580b0a7 100644 --- a/crates/eth2api/src/spec/phase0.rs +++ b/crates/eth2api/src/spec/phase0.rs @@ -9,6 +9,8 @@ use tree_hash_derive::TreeHash; pub use pluto_ssz::{BitList, SszList, SszVector}; +use crate::spec::serde_utils::{ConversionError, decode_hex_fixed, decode_hex_var, parse_u64}; + /// Fork version length in bytes. pub const VERSION_LEN: usize = 4; /// Signature domain length in bytes. @@ -336,6 +338,19 @@ pub struct Checkpoint { pub root: Root, } +impl TryFrom<&crate::AltairBeaconStateCurrentJustifiedCheckpoint> for Checkpoint { + type Error = ConversionError; + + fn try_from( + value: &crate::AltairBeaconStateCurrentJustifiedCheckpoint, + ) -> Result { + Ok(Self { + epoch: parse_u64(&value.epoch, "checkpoint.epoch")?, + root: decode_hex_fixed(&value.root, "checkpoint.root")?, + }) + } +} + /// Attestation data. /// /// Spec: @@ -357,6 +372,23 @@ pub struct AttestationData { pub target: Checkpoint, } +impl TryFrom<&crate::Data> for AttestationData { + type Error = ConversionError; + + fn try_from(value: &crate::Data) -> Result { + Ok(Self { + slot: parse_u64(&value.slot, "attestation_data.slot")?, + index: parse_u64(&value.index, "attestation_data.index")?, + beacon_block_root: decode_hex_fixed( + &value.beacon_block_root, + "attestation_data.beacon_block_root", + )?, + source: Checkpoint::try_from(&value.source)?, + target: Checkpoint::try_from(&value.target)?, + }) + } +} + /// Attestation object. /// /// Spec: @@ -372,6 +404,23 @@ pub struct Attestation { pub signature: BLSSignature, } +impl TryFrom<&crate::GetBlockAttestationsV2ResponseResponseDataArray2> for Attestation { + type Error = ConversionError; + + fn try_from( + value: &crate::GetBlockAttestationsV2ResponseResponseDataArray2, + ) -> Result { + Ok(Self { + aggregation_bits: BitList::from_ssz_bytes(decode_hex_var( + &value.aggregation_bits, + "attestation.aggregation_bits", + )?), + data: AttestationData::try_from(&value.data)?, + signature: decode_hex_fixed(&value.signature, "attestation.signature")?, + }) + } +} + /// Aggregate-and-proof payload. /// /// Spec: @@ -685,4 +734,86 @@ mod tests { serde_json::from_value(json).expect("deserialize indexed attestation"); assert_eq!(roundtrip.attesting_indices.0, vec![11, 12]); } + + /// Wire-format JSON for an attestation-data object, as returned by the + /// beacon node. + fn attestation_data_wire() -> serde_json::Value { + serde_json::json!({ + "slot": "42", + "index": "3", + "beacon_block_root": format!("0x{}", "11".repeat(32)), + "source": { "epoch": "5", "root": format!("0x{}", "22".repeat(32)) }, + "target": { "epoch": "6", "root": format!("0x{}", "33".repeat(32)) }, + }) + } + + #[test] + fn attestation_data_try_from_matches_json_roundtrip() { + let wire = attestation_data_wire(); + let generated: crate::Data = + serde_json::from_value(wire.clone()).expect("deserialize generated Data"); + + // Direct conversion must equal the loosely-typed JSON round-trip it + // replaces. + let direct = AttestationData::try_from(&generated).expect("convert"); + let via_json: AttestationData = serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + + assert_eq!(direct.slot, 42); + assert_eq!(direct.index, 3); + assert_eq!(direct.beacon_block_root, [0x11; 32]); + assert_eq!( + direct.source, + Checkpoint { + epoch: 5, + root: [0x22; 32] + } + ); + assert_eq!( + direct.target, + Checkpoint { + epoch: 6, + root: [0x33; 32] + } + ); + } + + #[test] + fn attestation_data_try_from_rejects_bad_fields() { + let mut wire = attestation_data_wire(); + wire["slot"] = serde_json::json!("not-a-number"); + let generated: crate::Data = serde_json::from_value(wire).expect("deserialize"); + assert!(matches!( + AttestationData::try_from(&generated), + Err(ConversionError::ParseInt { + field: "attestation_data.slot" + }) + )); + + let mut wire = attestation_data_wire(); + wire["beacon_block_root"] = serde_json::json!("0xZZ"); + let generated: crate::Data = serde_json::from_value(wire).expect("deserialize"); + assert!(matches!( + AttestationData::try_from(&generated), + Err(ConversionError::DecodeHex { + field: "attestation_data.beacon_block_root" + }) + )); + } + + #[test] + fn attestation_try_from_matches_json_roundtrip() { + let wire = serde_json::json!({ + "aggregation_bits": "0x0102", + "data": attestation_data_wire(), + "signature": format!("0x{}", "44".repeat(96)), + }); + let generated: crate::GetBlockAttestationsV2ResponseResponseDataArray2 = + serde_json::from_value(wire.clone()).expect("deserialize generated attestation"); + + let direct = Attestation::try_from(&generated).expect("convert"); + let via_json: Attestation = serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + assert_eq!(direct.signature, [0x44; 96]); + } } diff --git a/crates/eth2api/src/spec/serde_utils.rs b/crates/eth2api/src/spec/serde_utils.rs index 0cf98850..781f4d14 100644 --- a/crates/eth2api/src/spec/serde_utils.rs +++ b/crates/eth2api/src/spec/serde_utils.rs @@ -1,5 +1,49 @@ //! Shared serde helpers for consensus-spec JSON encoding. +use pluto_ssz::serde_utils::trim_0x_prefix; + +/// Error raised while converting a loosely-typed beacon-API value (whose +/// numeric and byte fields are carried as decimal / `0x`-hex strings) into a +/// strongly-typed spec value. +#[derive(Debug, thiserror::Error)] +pub enum ConversionError { + /// A decimal-encoded integer field could not be parsed. + #[error("parse integer field `{field}`")] + ParseInt { + /// Name of the offending field. + field: &'static str, + }, + /// A `0x`-hex field could not be decoded or had an unexpected length. + #[error("decode hex field `{field}`")] + DecodeHex { + /// Name of the offending field. + field: &'static str, + }, +} + +/// Parses a decimal-encoded unsigned integer field. +pub(crate) fn parse_u64(value: &str, field: &'static str) -> Result { + value + .parse() + .map_err(|_| ConversionError::ParseInt { field }) +} + +/// Decodes a `0x`-prefixed (or bare) hex string into a byte vector. +pub(crate) fn decode_hex_var(value: &str, field: &'static str) -> Result, ConversionError> { + hex::decode(trim_0x_prefix(value)).map_err(|_| ConversionError::DecodeHex { field }) +} + +/// Decodes a `0x`-prefixed (or bare) hex string into a fixed-size byte array, +/// erroring when the decoded length does not match `N`. +pub(crate) fn decode_hex_fixed( + value: &str, + field: &'static str, +) -> Result<[u8; N], ConversionError> { + decode_hex_var(value, field)? + .try_into() + .map_err(|_| ConversionError::DecodeHex { field }) +} + /// JSON helpers for decimal-encoded `U256` values with optional `0x` input /// support. pub(crate) mod u256_dec_serde { diff --git a/crates/eth2api/src/spec/version.rs b/crates/eth2api/src/spec/version.rs index d69bc9c2..eb873485 100644 --- a/crates/eth2api/src/spec/version.rs +++ b/crates/eth2api/src/spec/version.rs @@ -2,6 +2,8 @@ use core::fmt; use serde::{Deserialize, Serialize}; +use crate::ConsensusVersion; + /// Error returned when converting unknown data or builder versions. #[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] pub enum VersionError { @@ -103,6 +105,22 @@ impl fmt::Display for DataVersion { } } +impl From<&ConsensusVersion> for DataVersion { + /// Maps a beacon-node `ConsensusVersion` onto the corresponding data + /// version. Total: `ConsensusVersion` has no `Unknown` variant. + fn from(version: &ConsensusVersion) -> Self { + match version { + ConsensusVersion::Phase0 => DataVersion::Phase0, + ConsensusVersion::Altair => DataVersion::Altair, + ConsensusVersion::Bellatrix => DataVersion::Bellatrix, + ConsensusVersion::Capella => DataVersion::Capella, + ConsensusVersion::Deneb => DataVersion::Deneb, + ConsensusVersion::Electra => DataVersion::Electra, + ConsensusVersion::Fulu => DataVersion::Fulu, + } + } +} + /// Builder API version. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -227,6 +245,17 @@ mod tests { use super::*; use test_case::test_case; + #[test_case(ConsensusVersion::Phase0, DataVersion::Phase0 ; "phase0")] + #[test_case(ConsensusVersion::Altair, DataVersion::Altair ; "altair")] + #[test_case(ConsensusVersion::Bellatrix, DataVersion::Bellatrix ; "bellatrix")] + #[test_case(ConsensusVersion::Capella, DataVersion::Capella ; "capella")] + #[test_case(ConsensusVersion::Deneb, DataVersion::Deneb ; "deneb")] + #[test_case(ConsensusVersion::Electra, DataVersion::Electra ; "electra")] + #[test_case(ConsensusVersion::Fulu, DataVersion::Fulu ; "fulu")] + fn data_version_from_consensus_version(consensus: ConsensusVersion, expected: DataVersion) { + assert_eq!(DataVersion::from(&consensus), expected); + } + #[test_case(DataVersion::Phase0, "\"phase0\"" ; "phase0")] #[test_case(DataVersion::Deneb, "\"deneb\"" ; "deneb")] #[test_case(DataVersion::Fulu, "\"fulu\"" ; "fulu")]