diff --git a/.claude/skills/rust-style/SKILL.md b/.claude/skills/rust-style/SKILL.md index 8f14ad36..165eccab 100644 --- a/.claude/skills/rust-style/SKILL.md +++ b/.claude/skills/rust-style/SKILL.md @@ -61,16 +61,25 @@ let x = a.checked_add(b).ok_or(Error::Overflow)?; ## Casts -No lossy or unchecked casts — use fallible conversions: +**Never use `as` for numeric type conversions** — use fallible conversions with `try_from`: ```rust -// Bad +// Bad - will cause clippy errors let x = value as u32; +let y = some_usize as u64; -// Good +// Good - use try_from with proper error handling let x = u32::try_from(value)?; +let y = u64::try_from(some_usize).expect("message explaining why this is safe"); ``` +Rules: + +- Always use `TryFrom`/`try_from` for numeric conversions between different types +- Handle conversion failures explicitly (either with `?` or `expect` with justification) +- The only acceptable use of `expect` is when the conversion is guaranteed to succeed (e.g., `usize` to `u64` on 64-bit platforms) +- Clippy will error on unchecked `as` casts: `cast_possible_truncation`, `cast_possible_wrap`, `cast_sign_loss` + --- ## Async / Tokio diff --git a/Cargo.lock b/Cargo.lock index cdb0cddb..68136556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,7 +373,7 @@ dependencies = [ "derive_more", "foldhash 0.2.0", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.13.1", "itoa", "k256", "keccak-asm", @@ -428,9 +428,9 @@ dependencies = [ [[package]] name = "alloy-rlp" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" +checksum = "dc90b1e703d3c03f4ff7f48e82dd0bc1c8211ab7d079cd836a06fcfeb06651cb" dependencies = [ "alloy-rlp-derive", "arrayvec", @@ -439,9 +439,9 @@ dependencies = [ [[package]] name = "alloy-rlp-derive" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8849c74c9ca0f5a03da1c865e3eb6f768df816e67dd3721a398a8a7e398011" +checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" dependencies = [ "proc-macro2", "quote", @@ -581,7 +581,7 @@ dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.13.0", + "indexmap 2.13.1", "proc-macro-error2", "proc-macro2", "quote", @@ -1569,7 +1569,7 @@ version = "10.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06acb4f71407ba205a07cb453211e0e6a67b21904e47f6ba1f9589e38f2e454" dependencies = [ - "semver 1.0.27", + "semver 1.0.28", "serde", "toml", "url", @@ -1592,9 +1592,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -2574,9 +2574,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fastrlp" @@ -2999,7 +2999,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.13.1", "slab", "tokio", "tokio-util", @@ -3072,6 +3072,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -3238,9 +3247,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -3253,7 +3262,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3385,12 +3393,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3398,9 +3407,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -3411,9 +3420,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3425,15 +3434,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3445,15 +3454,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -3584,9 +3593,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3727,9 +3736,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", @@ -3785,9 +3794,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libgit2-sys" @@ -4263,15 +4272,16 @@ dependencies = [ [[package]] name = "libp2p-rendezvous" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15285d828c2b4a34cb660c2e74cd6938116daceab1f4357bae933d5b08cca933" +checksum = "31114bab295403e9934ae2e4415c45d681353829ea218390eed8f5bcc82dd1fb" dependencies = [ "async-trait", "asynchronous-codec", "bimap", "futures", "futures-timer", + "hashlink 0.11.0", "libp2p-core", "libp2p-identity", "libp2p-request-response", @@ -4532,9 +4542,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.25" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "libc", @@ -4550,9 +4560,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -5016,7 +5026,7 @@ dependencies = [ "log", "once_cell", "regex", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_json", "serde_yaml", @@ -5036,7 +5046,7 @@ dependencies = [ "eventsource-stream", "futures-core", "http", - "indexmap 2.13.0", + "indexmap 2.13.1", "oas3", "prettyplease", "proc-macro2", @@ -5309,7 +5319,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.13.1", ] [[package]] @@ -5443,6 +5453,7 @@ name = "pluto-cli" version = "1.7.1" dependencies = [ "backon", + "chrono", "clap", "hex", "humantime", @@ -5451,6 +5462,9 @@ dependencies = [ "pluto-app", "pluto-cluster", "pluto-core", + "pluto-crypto", + "pluto-eth1wrap", + "pluto-eth2api", "pluto-eth2util", "pluto-k1util", "pluto-p2p", @@ -5468,6 +5482,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "url", "wiremock", ] @@ -5855,9 +5870,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -5913,7 +5928,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -6615,7 +6630,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.27", + "semver 1.0.28", ] [[package]] @@ -6891,9 +6906,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -6959,7 +6974,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.13.1", "itoa", "memchr", "serde", @@ -7020,7 +7035,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.13.1", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -7047,7 +7062,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.13.1", "itoa", "ryu", "serde", @@ -7578,9 +7593,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -7613,9 +7628,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -7630,9 +7645,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -7707,9 +7722,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -7720,7 +7735,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.13.1", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -7730,21 +7745,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", + "indexmap 2.13.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.1", ] @@ -7803,7 +7818,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.13.1", "pin-project-lite", "slab", "sync_wrapper", @@ -8296,9 +8311,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -8309,9 +8324,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.66" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -8319,9 +8334,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8329,9 +8344,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -8342,9 +8357,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -8366,7 +8381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -8392,8 +8407,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver 1.0.27", + "indexmap 2.13.1", + "semver 1.0.28", ] [[package]] @@ -8412,9 +8427,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.93" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -8959,7 +8974,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.13.1", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -8990,7 +9005,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -9009,9 +9024,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", - "semver 1.0.27", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -9021,9 +9036,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -9130,9 +9145,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9141,9 +9156,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -9173,18 +9188,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -9214,9 +9229,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -9225,9 +9240,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -9236,9 +9251,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5a53e5d4..0c715593 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,10 +19,13 @@ humantime.workspace = true tokio.workspace = true pluto-app.workspace = true pluto-cluster.workspace = true +pluto-crypto.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true pluto-core.workspace = true pluto-p2p.workspace = true +pluto-eth1wrap.workspace = true +pluto-eth2api.workspace = true pluto-eth2util.workspace = true pluto-k1util.workspace = true pluto-ssz.workspace = true @@ -35,6 +38,8 @@ serde_with = { workspace = true, features = ["base64"] } rand.workspace = true tempfile.workspace = true reqwest.workspace = true +url.workspace = true +chrono.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 5c4956f0..fd7694f7 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand}; use crate::commands::{ + create_cluster::CreateClusterArgs, create_enr::CreateEnrArgs, enr::EnrArgs, relay::RelayArgs, @@ -40,7 +41,7 @@ pub enum Commands { about = "Create artifacts for a distributed validator cluster", long_about = "Create artifacts for a distributed validator cluster. These commands can be used to facilitate the creation of a distributed validator cluster between a group of operators by performing a distributed key generation ceremony, or they can be used to create a local cluster for single operator use cases." )] - Create(CreateArgs), + Create(Box), #[command(about = "Print version and exit", long_about = "Output version info")] Version(VersionArgs), @@ -135,4 +136,10 @@ pub enum CreateCommands { /// Create an Ethereum Node Record (ENR) private key to identify this charon /// client Enr(CreateEnrArgs), + + #[command( + about = "Create private keys and configuration files needed to run a distributed validator cluster locally", + long_about = "Creates a local charon cluster configuration including validator keys, charon p2p keys, cluster-lock.json and deposit-data.json file(s). See flags for supported features." + )] + Cluster(Box), } diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs new file mode 100644 index 00000000..68cbe001 --- /dev/null +++ b/crates/cli/src/commands/create_cluster.rs @@ -0,0 +1,2847 @@ +//! Create cluster command implementation. +//! +//! This module implements the `pluto create cluster` command, which creates a +//! local distributed validator cluster configuration including validator keys, +//! threshold BLS key shares, p2p private keys, cluster-lock files, and deposit +//! data files. + +use std::{ + collections::HashMap, + io::Write, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, +}; + +use chrono::Utc; +use k256::SecretKey; +use pluto_cluster::{ + definition::Definition, + deposit::DepositData, + distvalidator::DistValidator, + helpers, + lock::Lock, + operator::Operator, + registration::{BuilderRegistration, Registration}, +}; +use pluto_core::consensus::protocols; +use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + types::{PrivateKey, PublicKey}, +}; +use pluto_eth1wrap as eth1wrap; + +use pluto_app::{obolapi, utils as app_utils}; +use pluto_eth2util::{ + self as eth2util, + deposit::{self, Gwei}, + enr::Record, + keymanager, + keystore::{self, CONFIRM_INSECURE_KEYS, Keystore}, + network, registration as eth2util_registration, +}; +use pluto_p2p::k1 as p2p_k1; +use pluto_ssz::to_0x_hex; +use rand::rngs::OsRng; +use tracing::{debug, info, warn}; + +use crate::{ + commands::create_dkg, + error::{ + CliError, CreateClusterError, InvalidNetworkConfigError, Result as CliResult, + ThresholdError, + }, +}; + +/// Minimum number of nodes required in a cluster. +pub const MIN_NODES: u64 = 3; +/// Minimum threshold value. +pub const MIN_THRESHOLD: u64 = 2; +/// Zero ethereum address (not allowed on mainnet/gnosis). +pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; +/// HTTP scheme. +const HTTP_SCHEME: &str = "http"; +/// HTTPS scheme. +const HTTPS_SCHEME: &str = "https"; + +type Result = std::result::Result; + +/// Ethereum network options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +#[value(rename_all = "lowercase")] +pub enum Network { + /// Ethereum mainnet + #[default] + Mainnet, + /// Prater testnet (alias for Goerli) + Prater, + /// Goerli testnet + Goerli, + /// Sepolia testnet + Sepolia, + /// Hoodi testnet + Hoodi, + /// Holesky testnet + Holesky, + /// Gnosis chain + Gnosis, + /// Chiado testnet + Chiado, +} + +impl Network { + /// Returns the canonical network name. + pub fn as_str(&self) -> &'static str { + match self { + Network::Mainnet => "mainnet", + Network::Goerli | Network::Prater => "goerli", + Network::Sepolia => "sepolia", + Network::Hoodi => "hoodi", + Network::Holesky => "holesky", + Network::Gnosis => "gnosis", + Network::Chiado => "chiado", + } + } +} + +impl TryFrom<&str> for Network { + type Error = InvalidNetworkConfigError; + + fn try_from(value: &str) -> std::result::Result { + match value { + "mainnet" => Ok(Network::Mainnet), + "prater" => Ok(Network::Prater), + "goerli" => Ok(Network::Goerli), + "sepolia" => Ok(Network::Sepolia), + "hoodi" => Ok(Network::Hoodi), + "holesky" => Ok(Network::Holesky), + "gnosis" => Ok(Network::Gnosis), + "chiado" => Ok(Network::Chiado), + _ => Err(InvalidNetworkConfigError::InvalidNetworkSpecified { + network: value.to_string(), + }), + } + } +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Custom testnet configuration. +#[derive(Debug, Clone, Default, clap::Args)] +pub struct TestnetConfig { + /// Chain ID of the custom test network + #[arg( + long = "testnet-chain-id", + help = "Chain ID of the custom test network." + )] + pub chain_id: Option, + + /// Genesis fork version of the custom test network (in hex) + #[arg( + long = "testnet-fork-version", + help = "Genesis fork version of the custom test network (in hex)." + )] + pub fork_version: Option, + + /// Genesis timestamp of the custom test network + #[arg( + long = "testnet-genesis-timestamp", + help = "Genesis timestamp of the custom test network." + )] + pub genesis_timestamp: Option, + + /// Name of the custom test network + #[arg(long = "testnet-name", help = "Name of the custom test network.")] + pub testnet_name: Option, +} + +impl TestnetConfig { + pub fn is_empty(&self) -> bool { + self.testnet_name.is_none() + && self.fork_version.is_none() + && self.chain_id.is_none() + && self.genesis_timestamp.is_none() + } +} + +/// Arguments for the create cluster command +#[derive(clap::Args)] +pub struct CreateClusterArgs { + /// The target folder to create the cluster in. + #[arg( + long = "cluster-dir", + default_value = "./", + help = "The target folder to create the cluster in." + )] + pub cluster_dir: PathBuf, + + /// Enable compounding rewards for validators + #[arg( + long = "compounding", + help = "Enable compounding rewards for validators by using 0x02 withdrawal credentials." + )] + pub compounding: bool, + + /// Preferred consensus protocol name for the cluster + #[arg( + long = "consensus-protocol", + help = "Preferred consensus protocol name for the cluster. Selected automatically when not specified." + )] + pub consensus_protocol: Option, + + /// Path to a cluster definition file or HTTP URL + #[arg( + long = "definition-file", + help = "Optional path to a cluster definition file or an HTTP URL. This overrides all other configuration flags." + )] + pub definition_file: Option, + + /// List of partial deposit amounts (integers) in ETH + #[arg( + long = "deposit-amounts", + value_delimiter = ',', + help = "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH." + )] + pub deposit_amounts: Vec, + + /// The address of the execution engine JSON-RPC API + #[arg( + long = "execution-client-rpc-endpoint", + help = "The address of the execution engine JSON-RPC API." + )] + pub execution_engine_addr: Option, + + /// Comma separated list of fee recipient addresses + #[arg( + long = "fee-recipient-addresses", + value_delimiter = ',', + help = "Comma separated list of Ethereum addresses of the fee recipient for each validator. Either provide a single fee recipient address or fee recipient addresses for each validator." + )] + pub fee_recipient_addrs: Vec, + + /// Generates insecure keystore files (testing only) + #[arg( + long = "insecure-keys", + help = "Generates insecure keystore files. This should never be used. It is not supported on mainnet." + )] + pub insecure_keys: bool, + + /// Comma separated list of keymanager URLs + #[arg( + long = "keymanager-addresses", + value_delimiter = ',', + help = "Comma separated list of keymanager URLs to import validator key shares to. Note that multiple addresses are required, one for each node in the cluster." + )] + pub keymanager_addrs: Vec, + + /// Authentication bearer tokens for keymanager URLs + #[arg( + long = "keymanager-auth-tokens", + value_delimiter = ',', + help = "Authentication bearer tokens to interact with the keymanager URLs. Don't include the \"Bearer\" symbol, only include the api-token." + )] + pub keymanager_auth_tokens: Vec, + + /// The cluster name + #[arg(long = "name")] + pub name: Option, + + /// Ethereum network to create validators for + #[arg(long = "network", help = "Ethereum network to create validators for.")] + pub network: Option, + + /// The number of charon nodes in the cluster + #[arg( + long = "nodes", + default_value = "0", + help = "The number of charon nodes in the cluster. Minimum is 3." + )] + pub nodes: u64, + + /// The number of distributed validators needed in the cluster + #[arg( + long = "num-validators", + default_value = "0", + help = "The number of distributed validators needed in the cluster." + )] + pub num_validators: u64, + + /// Publish lock file to obol-api + #[arg(long = "publish", help = "Publish lock file to obol-api.")] + pub publish: bool, + + /// The URL to publish the lock file to + #[arg( + long = "publish-address", + default_value = "https://api.obol.tech/v1", + help = "The URL to publish the lock file to." + )] + pub publish_address: String, + + /// Split an existing validator's private key + #[arg( + long = "split-existing-keys", + help = "Split an existing validator's private key into a set of distributed validator private key shares. Does not re-create deposit data for this key." + )] + pub split_keys: bool, + + /// Directory containing keys to split + #[arg( + long = "split-keys-dir", + help = "Directory containing keys to split. Expects keys in keystore-*.json and passwords in keystore-*.txt. Requires --split-existing-keys." + )] + pub split_keys_dir: Option, + + /// Preferred target gas limit for transactions + #[arg( + long = "target-gas-limit", + default_value = "60000000", + help = "Preferred target gas limit for transactions." + )] + pub target_gas_limit: u64, + + /// Custom testnet configuration + #[command(flatten)] + pub testnet_config: TestnetConfig, + + /// Optional override of threshold + #[arg( + long = "threshold", + help = "Optional override of threshold required for signature reconstruction. Defaults to ceil(n*2/3) if zero. Warning, non-default values decrease security." + )] + pub threshold: Option, + + /// Comma separated list of withdrawal addresses + #[arg( + long = "withdrawal-addresses", + value_delimiter = ',', + help = "Comma separated list of Ethereum addresses to receive the returned stake and accrued rewards for each validator. Either provide a single withdrawal address or withdrawal addresses for each validator." + )] + pub withdrawal_addrs: Vec, + + /// Create a tar archive compressed with gzip + #[arg( + long = "zipped", + help = "Create a tar archive compressed with gzip of the cluster directory after creation." + )] + pub zipped: bool, +} + +impl From for network::Network { + fn from(config: TestnetConfig) -> Self { + network::Network { + chain_id: config.chain_id.unwrap_or(0), + name: Box::leak( + config + .testnet_name + .as_ref() + .unwrap_or(&String::new()) + .clone() + .into_boxed_str(), + ), + genesis_fork_version_hex: Box::leak( + config + .fork_version + .as_ref() + .unwrap_or(&String::new()) + .clone() + .into_boxed_str(), + ), + genesis_timestamp: config.genesis_timestamp.unwrap_or(0), + capella_hard_fork: "", + } + } +} + +fn init_tracing() -> CliResult<()> { + match pluto_tracing::init(&pluto_tracing::TracingConfig::default()) { + Ok(_) | Err(pluto_tracing::init::Error::InitError(_)) => Ok(()), + Err(err) => Err(CliError::from(err)), + } +} + +fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { + let Some(threshold) = args.threshold else { + return Ok(()); + }; + + if threshold < MIN_THRESHOLD { + return Err(ThresholdError::ThresholdTooLow { threshold }.into()); + } + + let number_of_nodes = args.nodes; + if threshold > number_of_nodes { + return Err(ThresholdError::ThresholdTooHigh { + threshold, + number_of_nodes, + } + .into()); + } + + Ok(()) +} + +/// Runs the create cluster command +pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<()> { + init_tracing()?; + + let mut definition_input = None; + + if let Some(definition_file) = args.definition_file.as_ref() { + let Some(addr) = args.execution_engine_addr.as_ref() else { + return Err(CreateClusterError::MissingExecutionEngineAddress.into()); + }; + + let eth1cl = eth1wrap::EthClient::new(addr.clone()).await?; + let def = load_definition(definition_file, ð1cl).await?; + + args.nodes = u64::try_from(def.operators.len()).expect("operators length is too large"); + args.threshold = Some(def.threshold); + + let network_name = eth2util::network::fork_version_to_network(&def.fork_version)?; + args.network = Some( + Network::try_from(network_name.as_str()) + .map_err(CreateClusterError::InvalidNetworkConfig)?, + ); + + definition_input = Some((def, eth1cl)); + } + + validate_threshold(&args)?; + + validate_create_config(&args)?; + + let mut secrets: Vec = Vec::new(); + + // If we're splitting keys, read them from `split_keys_dir` and set + // args.num_validators to the amount of secrets we read. + // If `split_keys` wasn't set, we wouldn't have reached this part of code + // because `validate_create_config()` would've already errored. + if args.split_keys { + let use_sequence_keys = args.withdrawal_addrs.len() > 1; + + let Some(split_keys_dir) = &args.split_keys_dir else { + return Err(CreateClusterError::MissingSplitKeysDir.into()); + }; + + secrets = get_keys(&split_keys_dir, use_sequence_keys).await?; + + debug!( + "Read {} secrets from {}", + secrets.len(), + split_keys_dir.display() + ); + + // Needed if --split-existing-keys is called without a definition file. + // It's safe to unwrap here because we know the length is less than u64::MAX. + args.num_validators = u64::try_from(secrets.len()).expect("secrets length is too large"); + } + + // Get a cluster definition, either from a definition file or from the config. + let (mut def, mut deposit_amounts) = if let Some((def, eth1cl)) = definition_input { + validate_definition(&def, args.insecure_keys, &args.keymanager_addrs, ð1cl).await?; + + let deposit_amounts = def.deposit_amounts.clone(); + + (def, deposit_amounts) + } else { + // Create new definition from cluster config + let def = new_def_from_config(&args)?; + + let deposit_amounts = deposit::eths_to_gweis(&args.deposit_amounts); + + (def, deposit_amounts) + }; + + if deposit_amounts.is_empty() { + deposit_amounts = deposit::default_deposit_amounts(args.compounding); + } + + if secrets.is_empty() { + // This is the case in which split-keys is undefined and user passed validator + // amount on CLI + secrets = generate_keys(def.num_validators)?; + } + + let num_validators_usize = + usize::try_from(def.num_validators).map_err(|_| CreateClusterError::ValueExceedsUsize { + value: def.num_validators, + })?; + + if secrets.len() != num_validators_usize { + return Err(CreateClusterError::KeyCountMismatch { + disk_keys: secrets.len(), + definition_keys: def.num_validators, + } + .into()); + } + + let num_nodes = u64::try_from(def.operators.len()).expect("operators length is too large"); + + // Generate threshold bls key shares + + let (pub_keys, share_sets) = get_tss_shares(&secrets, def.threshold, num_nodes)?; + + // Create cluster directory at the given location + tokio::fs::create_dir_all(&args.cluster_dir).await?; + + // Set directory permissions to 0o755 + let permissions = std::fs::Permissions::from_mode(0o755); + tokio::fs::set_permissions(&args.cluster_dir, permissions).await?; + + // Create operators and their enr node keys + let (ops, node_keys) = get_operators(num_nodes, &args.cluster_dir)?; + + def.operators = ops; + def.set_definition_hashes() + .map_err(CreateClusterError::DefinitionError)?; + + let keys_to_disk = args.keymanager_addrs.is_empty(); + + if keys_to_disk { + write_keys_to_disk( + num_nodes, + &args.cluster_dir, + args.insecure_keys, + &share_sets, + ) + .await?; + } else { + write_keys_to_keymanager(&args, num_nodes, &share_sets).await?; + } + + let network = eth2util::network::fork_version_to_network(&def.fork_version)?; + + let deposit_datas = create_deposit_datas( + &def.withdrawal_addresses(), + &network, + &secrets, + &deposit_amounts, + def.compounding, + )?; + + let eth2util_deposit_datas = deposit_datas + .iter() + .map(|dd| cluster_deposit_data_to_eth2util_deposit_data(dd)) + .collect::>(); + + // Write deposit-data files + eth2util::deposit::write_cluster_deposit_data_files( + ð2util_deposit_datas, + network, + &args.cluster_dir, + usize::try_from(num_nodes).expect("num_nodes should fit in usize"), + ) + .await?; + + let val_regs = create_validator_registrations( + &def.fee_recipient_addresses(), + &secrets, + &def.fork_version, + args.split_keys, + args.target_gas_limit, + )?; + + let vals = get_validators(&pub_keys, &share_sets, &deposit_datas, val_regs)?; + + let mut lock = Lock { + definition: def, + distributed_validators: vals, + ..Default::default() + }; + + lock.set_lock_hash().map_err(CreateClusterError::from)?; + + lock.signature_aggregate = agg_sign(&share_sets, &lock.lock_hash)?; + + for op_key in &node_keys { + let node_sig = + pluto_k1util::sign(op_key, &lock.lock_hash).map_err(CreateClusterError::K1UtilError)?; + lock.node_signatures.push(node_sig.to_vec()); + } + + let mut dashboard_url = String::new(); + if args.publish { + match write_lock_to_api(&args.publish_address, &lock).await { + Ok(url) => dashboard_url = url, + Err(err) => { + warn!(error = %err, "Failed to publish lock file to Obol API"); + } + } + } + + write_lock(&lock, &args.cluster_dir, num_nodes).await?; + + if args.zipped { + app_utils::bundle_output(&args.cluster_dir, "cluster.tar.gz") + .map_err(CreateClusterError::BundleOutputError)?; + } + + if args.split_keys { + write_split_keys_warning(w)?; + } + + write_output( + w, + args.split_keys, + &args.cluster_dir, + num_nodes, + keys_to_disk, + args.zipped, + )?; + + if !dashboard_url.is_empty() { + info!( + "You can find your newly-created cluster dashboard here: {}", + dashboard_url + ); + } + + Ok(()) +} + +async fn write_lock_to_api(publish_addr: &str, lock: &Lock) -> Result { + let client = obolapi::Client::new(publish_addr, obolapi::ClientOptions::default()) + .map_err(CreateClusterError::ObolApiError)?; + match client.publish_lock(lock.clone()).await { + Ok(()) => { + info!(addr = publish_addr, "Published lock file"); + match client.launchpad_url_for_lock(lock) { + Ok(url) => Ok(url), + Err(err) => Err(CreateClusterError::ObolApiError(err)), + } + } + Err(err) => Err(CreateClusterError::ObolApiError(err)), + } +} + +fn create_validator_registrations( + fee_recipient_addresses: &[String], + secrets: &[PrivateKey], + fork_version: &[u8], + split_keys: bool, + target_gas_limit: u64, +) -> Result> { + if fee_recipient_addresses.len() != secrets.len() { + return Err(CreateClusterError::InsufficientFeeAddresses { + expected: secrets.len(), + got: fee_recipient_addresses.len(), + }); + } + + let effective_gas_limit = if target_gas_limit == 0 { + warn!( + default_gas_limit = eth2util_registration::DEFAULT_GAS_LIMIT, + "Custom target gas limit not supported, setting to default" + ); + eth2util_registration::DEFAULT_GAS_LIMIT + } else { + target_gas_limit + }; + + let fork_version: [u8; 4] = fork_version + .try_into() + .map_err(|_| CreateClusterError::InvalidForkVersionLength)?; + + let tbls = BlstImpl; + let mut registrations = Vec::with_capacity(secrets.len()); + + for (secret, fee_address) in secrets.iter().zip(fee_recipient_addresses.iter()) { + let timestamp = if split_keys { + Utc::now() + } else { + eth2util::network::fork_version_to_genesis_time(&fork_version)? + }; + + let pk = tbls.secret_to_public_key(secret)?; + + let unsigned_reg = eth2util_registration::new_message( + pk, + fee_address, + effective_gas_limit, + u64::try_from(timestamp.timestamp()).expect("timestamp should fit in u64"), + )?; + + let sig_root = eth2util_registration::get_message_signing_root(&unsigned_reg, fork_version); + + let sig = tbls.sign(secret, &sig_root)?; + + registrations.push(BuilderRegistration { + message: Registration { + fee_recipient: unsigned_reg.fee_recipient, + gas_limit: unsigned_reg.gas_limit, + timestamp, + pub_key: unsigned_reg.pubkey, + }, + signature: sig, + }); + } + + Ok(registrations) +} + +fn cluster_deposit_data_to_eth2util_deposit_data( + deposit_datas: &[DepositData], +) -> Vec { + deposit_datas + .iter() + .map(|dd| eth2util::deposit::DepositData { + pubkey: dd.pub_key, + withdrawal_credentials: dd.withdrawal_credentials, + amount: dd.amount, + signature: dd.signature, + }) + .collect() +} + +async fn write_keys_to_disk( + num_nodes: u64, + cluster_dir: impl AsRef, + insecure_keys: bool, + share_sets: &[Vec], +) -> Result<()> { + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + + let mut secrets: Vec = Vec::new(); + for shares in share_sets { + secrets.push(shares[i_usize]); + } + + let keys_dir = + helpers::create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)).await?; + + if insecure_keys { + keystore::store_keys_insecure(&secrets, &keys_dir, &CONFIRM_INSECURE_KEYS).await?; + } else { + keystore::store_keys(&secrets, &keys_dir).await?; + } + } + + Ok(()) +} + +fn random_hex64() -> Result { + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut OsRng, &mut bytes); + Ok(hex::encode(bytes)) +} + +async fn write_keys_to_keymanager( + args: &CreateClusterArgs, + num_nodes: u64, + share_sets: &[Vec], +) -> Result<()> { + // Create and verify all keymanager clients first. + let mut clients: Vec = Vec::new(); + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + let cl = keymanager::Client::new( + &args.keymanager_addrs[i_usize], + &args.keymanager_auth_tokens[i_usize], + )?; + cl.verify_connection().await?; + clients.push(cl); + } + + // For each node, build keystores from this node's share of each validator, + // then import them into that node's keymanager. + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + + let mut keystores: Vec = Vec::new(); + let mut passwords: Vec = Vec::new(); + + // share_sets[validator_idx][node_idx] + for shares in share_sets { + let password = random_hex64()?; + let pbkdf2_c = if args.insecure_keys { + // Match Charon's `keystorev4.WithCost(..., 4)` => 2^4 iterations. + Some(16u32) + } else { + None + }; + let store = keystore::encrypt(&shares[i_usize], &password, pbkdf2_c, &mut OsRng)?; + passwords.push(password); + keystores.push(store); + } + + clients[i_usize] + .import_keystores(&keystores, &passwords) + .await + .inspect_err(|_| { + tracing::error!( + addr = %args.keymanager_addrs[i_usize], + "Failed to import keys", + ); + })?; + + info!( + node = format!("node{}", i), + addr = %args.keymanager_addrs[i_usize], + "Imported key shares to keymanager", + ); + } + + info!("Imported all validator keys to respective keymanagers"); + + Ok(()) +} + +fn create_deposit_datas( + withdrawal_addresses: &[String], + network: impl AsRef, + secrets: &[PrivateKey], + deposit_amounts: &[Gwei], + compounding: bool, +) -> Result>> { + if secrets.len() != withdrawal_addresses.len() { + return Err(CreateClusterError::InsufficientWithdrawalAddresses); + } + if deposit_amounts.is_empty() { + return Err(CreateClusterError::EmptyDepositAmounts); + } + let deduped = deposit::dedup_amounts(deposit_amounts); + sign_deposit_datas( + secrets, + withdrawal_addresses, + network.as_ref(), + &deduped, + compounding, + ) +} + +fn sign_deposit_datas( + secrets: &[PrivateKey], + withdrawal_addresses: &[String], + network: &str, + deposit_amounts: &[Gwei], + compounding: bool, +) -> Result>> { + if secrets.len() != withdrawal_addresses.len() { + return Err(CreateClusterError::InsufficientWithdrawalAddresses); + } + if deposit_amounts.is_empty() { + return Err(CreateClusterError::EmptyDepositAmounts); + } + let tbls = BlstImpl; + let mut dd = Vec::new(); + for &deposit_amount in deposit_amounts { + let mut datas = Vec::new(); + for (secret, withdrawal_addr) in secrets.iter().zip(withdrawal_addresses.iter()) { + let withdrawal_addr = eth2util::helpers::checksum_address(withdrawal_addr)?; + let pk = tbls.secret_to_public_key(secret)?; + let msg = deposit::new_message(pk, &withdrawal_addr, deposit_amount, compounding)?; + let sig_root = deposit::get_message_signing_root(&msg, network)?; + let sig = tbls.sign(secret, &sig_root)?; + datas.push(DepositData { + pub_key: msg.pubkey, + withdrawal_credentials: msg.withdrawal_credentials, + amount: msg.amount, + signature: sig, + }); + } + dd.push(datas); + } + Ok(dd) +} + +fn generate_keys(num_validators: u64) -> Result> { + let tbls = BlstImpl; + let mut secrets = Vec::new(); + + for _ in 0..num_validators { + let secret = tbls.generate_secret_key(OsRng)?; + secrets.push(secret); + } + + Ok(secrets) +} + +fn get_operators( + num_nodes: u64, + cluster_dir: impl AsRef, +) -> Result<(Vec, Vec)> { + let mut ops = Vec::new(); + let mut node_keys = Vec::new(); + + for i in 0..num_nodes { + let (record, identity_key) = new_peer(&cluster_dir, i)?; + + ops.push(Operator { + enr: record.to_string(), + ..Default::default() + }); + node_keys.push(identity_key); + } + + Ok((ops, node_keys)) +} + +fn new_peer(cluster_dir: impl AsRef, peer_idx: u64) -> Result<(Record, SecretKey)> { + let dir = node_dir(cluster_dir.as_ref(), peer_idx); + + let p2p_key = p2p_k1::new_saved_priv_key(&dir)?; + + let record = Record::new(&p2p_key, Vec::new())?; + + Ok((record, p2p_key)) +} + +async fn get_keys( + split_keys_dir: impl AsRef, + use_sequence_keys: bool, +) -> Result> { + if use_sequence_keys { + let files = keystore::load_files_unordered(split_keys_dir).await?; + Ok(files.sequenced_keys()?) + } else { + let files = keystore::load_files_recursively(split_keys_dir).await?; + Ok(files.keys()) + } +} + +/// Creates a new cluster definition from the provided configuration. +fn new_def_from_config(args: &CreateClusterArgs) -> Result { + let num_validators = args.num_validators; + if num_validators == 0 { + return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); + } + + let (fee_recipient_addrs, withdrawal_addrs) = validate_addresses( + num_validators, + &args.fee_recipient_addrs, + &args.withdrawal_addrs, + )?; + + let fork_version = if let Some(network) = args.network { + eth2util::network::network_to_fork_version(network.as_str())? + } else if let Some(ref fork_version_hex) = args.testnet_config.fork_version { + fork_version_hex.clone() + } else { + return Err(CreateClusterError::InvalidNetworkConfig( + InvalidNetworkConfigError::MissingNetworkFlagAndNoTestnetConfigFlag, + )); + }; + + let num_nodes = args.nodes; + if num_nodes == 0 { + return Err(CreateClusterError::MissingNodesOrDefinitionFile); + } + + let operators = vec![ + pluto_cluster::operator::Operator::default(); + usize::try_from(num_nodes).expect("num_nodes should fit in usize") + ]; + let threshold = safe_threshold(num_nodes, args.threshold); + + let name = args.name.clone().unwrap_or_default(); + + let consensus_protocol = args.consensus_protocol.clone().unwrap_or_default(); + + let def = pluto_cluster::definition::Definition::new( + name, + num_validators, + threshold, + fee_recipient_addrs, + withdrawal_addrs, + fork_version, + pluto_cluster::definition::Creator::default(), + operators, + deposit::eths_to_gweis(&args.deposit_amounts), + consensus_protocol, + args.target_gas_limit, + args.compounding, + vec![], + )?; + Ok(def) +} + +fn get_tss_shares( + secrets: &[PrivateKey], + threshold: u64, + num_nodes: u64, +) -> Result<(Vec, Vec>)> { + let tbls = BlstImpl; + let mut dvs = Vec::new(); + let mut splits = Vec::new(); + + let num_nodes = u8::try_from(num_nodes) + .map_err(|_| CreateClusterError::ValueExceedsU8 { value: num_nodes })?; + let threshold = u8::try_from(threshold) + .map_err(|_| CreateClusterError::ValueExceedsU8 { value: threshold })?; + + for secret in secrets { + let shares = tbls.threshold_split(secret, num_nodes, threshold)?; + + // Preserve order when transforming from map of private shares to array of + // private keys + let mut entries: Vec<_> = shares.into_iter().collect(); + entries.sort_by_key(|(idx, _)| *idx); + let secret_set = entries.into_iter().map(|(_, share)| share).collect(); + + splits.push(secret_set); + + let pubkey = tbls.secret_to_public_key(secret)?; + dvs.push(pubkey); + } + + Ok((dvs, splits)) +} + +async fn validate_definition( + def: &Definition, + insecure_keys: bool, + keymanager_addrs: &[String], + eth1cl: ð1wrap::EthClient, +) -> Result<()> { + if def.num_validators == 0 { + return Err(CreateClusterError::ZeroValidators); + } + + let num_operators = + u64::try_from(def.operators.len()).expect("operators length should fit in u64"); + if num_operators < MIN_NODES { + return Err(CreateClusterError::TooFewNodes { + num_nodes: num_operators, + }); + } + + if !keymanager_addrs.is_empty() && (keymanager_addrs.len() != def.operators.len()) { + return Err(CreateClusterError::InsufficientKeymanagerAddresses { + expected: def.operators.len(), + got: keymanager_addrs.len(), + }); + } + + if !def.deposit_amounts.is_empty() { + deposit::verify_deposit_amounts(&def.deposit_amounts, def.compounding)?; + } + + let network_name = network::fork_version_to_network(&def.fork_version)?; + + if insecure_keys && is_main_or_gnosis(&network_name) { + return Err(CreateClusterError::InsecureKeysOnMainnetOrGnosis); + } else if insecure_keys { + tracing::warn!("Insecure keystores configured. ONLY DO THIS DURING TESTING"); + } + + if def.name.is_empty() { + return Err(CreateClusterError::DefinitionNameNotProvided); + } + + def.verify_hashes()?; + + def.verify_signatures(eth1cl).await?; + + if !network::valid_network(&network_name) { + return Err(CreateClusterError::UnsupportedNetwork { + network: network_name.to_string(), + }); + } + + if !def.consensus_protocol.is_empty() + && !protocols::is_supported_protocol_name(&def.consensus_protocol) + { + return Err(CreateClusterError::UnsupportedConsensusProtocol { + consensus_protocol: def.consensus_protocol.clone(), + }); + } + + create_dkg::validate_withdrawal_addrs(&def.withdrawal_addresses(), &network_name)?; + + Ok(()) +} + +pub fn is_main_or_gnosis(network: &str) -> bool { + network == network::MAINNET.name || network == network::GNOSIS.name +} + +fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { + if args.nodes == 0 && args.definition_file.is_none() { + return Err(CreateClusterError::MissingNodesOrDefinitionFile); + } + + // Check for valid network configuration. + validate_network_config(args)?; + + detect_node_dirs(&args.cluster_dir, args.nodes)?; + + // Ensure sufficient auth tokens are provided for the keymanager addresses + if args.keymanager_addrs.len() != args.keymanager_auth_tokens.len() { + return Err(CreateClusterError::InvalidKeymanagerConfig { + keymanager_addrs: args.keymanager_addrs.len(), + keymanager_auth_tokens: args.keymanager_auth_tokens.len(), + }); + } + + if !args.deposit_amounts.is_empty() { + let amount = eth2util::deposit::eths_to_gweis(&args.deposit_amounts); + + eth2util::deposit::verify_deposit_amounts(&amount, args.compounding)?; + } + + for addr in &args.keymanager_addrs { + let keymanager_url = + url::Url::parse(addr).map_err(CreateClusterError::InvalidKeymanagerUrl)?; + + if keymanager_url.scheme() == HTTP_SCHEME { + warn!(addr, "Keymanager URL does not use https protocol"); + } + } + + if args.split_keys && args.num_validators != 0 { + return Err(CreateClusterError::CannotSpecifyNumValidatorsWithSplitKeys); + } else if !args.split_keys && args.num_validators == 0 && args.definition_file.is_none() { + return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); + } + + // Don't allow cluster size to be less than `MIN_NODES`. + let num_nodes = args.nodes; + if num_nodes < MIN_NODES { + return Err(CreateClusterError::TooFewNodes { num_nodes }); + } + + if let Some(consensus_protocol) = &args.consensus_protocol + && !protocols::is_supported_protocol_name(consensus_protocol) + { + return Err(CreateClusterError::UnsupportedConsensusProtocol { + consensus_protocol: consensus_protocol.clone(), + }); + } + + Ok(()) +} + +fn detect_node_dirs(cluster_dir: impl AsRef, node_amount: u64) -> Result<()> { + for i in 0..node_amount { + let abs_path = std::path::absolute(node_dir(cluster_dir.as_ref(), i))?; + + if std::fs::exists(abs_path.join("cluster-lock.json"))? { + return Err(CreateClusterError::NodeDirectoryAlreadyExists { node_dir: abs_path }); + } + } + + Ok(()) +} + +fn node_dir(cluster_dir: impl AsRef, node_index: u64) -> PathBuf { + cluster_dir.as_ref().join(format!("node{}", node_index)) +} + +/// Validates the network configuration. +fn validate_network_config(args: &CreateClusterArgs) -> Result<()> { + if let Some(network) = args.network { + if eth2util::network::valid_network(network.as_str()) { + return Ok(()); + } + + return Err(InvalidNetworkConfigError::InvalidNetworkSpecified { + network: network.to_string(), + } + .into()); + } + + // Check if custom testnet configuration is provided. + if !args.testnet_config.is_empty() { + // Add testnet config to supported networks. + eth2util::network::add_test_network(args.testnet_config.clone().into())?; + + return Ok(()); + } + + Err(InvalidNetworkConfigError::MissingNetworkFlagAndNoTestnetConfigFlag.into()) +} + +/// Returns true if the input string is a valid HTTP/HTTPS URI. +fn is_valid_uri(s: impl AsRef) -> bool { + if let Ok(url) = url::Url::parse(s.as_ref()) { + (url.scheme() == HTTP_SCHEME || url.scheme() == HTTPS_SCHEME) + && !url.host_str().unwrap_or("").is_empty() + } else { + false + } +} + +/// Loads and validates the cluster definition from disk or an HTTP URL. +/// +/// It fetches the definition, verifies signatures and hashes, and checks +/// that at least one validator is specified before returning. +async fn load_definition( + definition_file: impl AsRef, + eth1cl: ð1wrap::EthClient, +) -> Result { + let def_file = definition_file.as_ref(); + + // Fetch definition from network if URI is provided + let def = if is_valid_uri(def_file) { + let def = helpers::fetch_definition(def_file).await?; + + info!( + url = def_file, + definition_hash = to_0x_hex(&def.definition_hash), + "Cluster definition downloaded from URL" + ); + + def + } else { + // Fetch definition from disk + let buf = tokio::fs::read(def_file).await?; + let def: Definition = serde_json::from_slice(&buf)?; + + info!( + path = def_file, + definition_hash = to_0x_hex(&def.definition_hash), + "Cluster definition loaded from disk", + ); + + def + }; + + def.verify_signatures(eth1cl).await?; + def.verify_hashes()?; + + if def.num_validators == 0 { + return Err(CreateClusterError::NoValidatorsInDefinition); + } + + Ok(def) +} + +/// Validates that addresses match the number of validators. +/// If only one address is provided, it fills the slice to match num_validators. +/// +/// Returns an error if the number of addresses doesn't match and isn't exactly +/// 1. +fn validate_addresses( + num_validators: u64, + fee_recipient_addrs: &[String], + withdrawal_addrs: &[String], +) -> Result<(Vec, Vec)> { + let num_validators_usize = + usize::try_from(num_validators).map_err(|_| CreateClusterError::ValueExceedsUsize { + value: num_validators, + })?; + + if fee_recipient_addrs.len() != num_validators_usize && fee_recipient_addrs.len() != 1 { + return Err(CreateClusterError::MismatchingFeeRecipientAddresses { + num_validators, + addresses: fee_recipient_addrs.len(), + }); + } + + if withdrawal_addrs.len() != num_validators_usize && withdrawal_addrs.len() != 1 { + return Err(CreateClusterError::MismatchingWithdrawalAddresses { + num_validators, + addresses: withdrawal_addrs.len(), + }); + } + + let mut fee_addrs = fee_recipient_addrs.to_vec(); + let mut withdraw_addrs = withdrawal_addrs.to_vec(); + + // Expand single address to match num_validators + if fee_addrs.len() == 1 { + let addr = fee_addrs[0].clone(); + fee_addrs = vec![addr; num_validators_usize]; + } + + if withdraw_addrs.len() == 1 { + let addr = withdraw_addrs[0].clone(); + withdraw_addrs = vec![addr; num_validators_usize]; + } + + Ok((fee_addrs, withdraw_addrs)) +} + +/// Returns the safe threshold, logging a warning if a non-standard threshold is +/// provided. +fn safe_threshold(num_nodes: u64, threshold: Option) -> u64 { + let safe = pluto_cluster::helpers::threshold(num_nodes); + + match threshold { + Some(0) | None => safe, + Some(t) => { + if t != safe { + warn!( + num_nodes = num_nodes, + threshold = t, + safe_threshold = safe, + "Non standard threshold provided, this will affect cluster safety" + ); + } + t + } + } +} + +/// Builds the list of `DistValidator`s from the DV public keys, precomputed +/// public shares, deposit data and validator registrations. +fn get_validators( + dv_pubkeys: &[PublicKey], + dv_priv_shares: &[Vec], + deposit_datas: &[Vec], + val_regs: Vec, +) -> Result> { + let mut deposit_datas_map: HashMap> = HashMap::new(); + for amount_level in deposit_datas { + for dd in amount_level { + deposit_datas_map + .entry(dd.pub_key) + .or_default() + .push(dd.clone()); + } + } + + let mut vals = Vec::with_capacity(dv_pubkeys.len()); + let tbls = BlstImpl; + + for (idx, dv_pubkey) in dv_pubkeys.iter().enumerate() { + let pub_shares: Vec> = dv_priv_shares + .get(idx) + .map(|shares| { + shares + .iter() + .map(|share| tbls.secret_to_public_key(share)) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default() + .into_iter() + .map(|share| share.to_vec()) + .collect(); + + // Builder registration — same index as the validator. + let builder_registration = val_regs + .get(idx) + .cloned() + .ok_or(CreateClusterError::ValidatorRegistrationNotFound { index: idx })?; + + // Partial deposit data for this DV pubkey. + let partial_deposit_data = deposit_datas_map.remove(dv_pubkey).ok_or_else(|| { + CreateClusterError::DepositDataNotFound { + dv: hex::encode(dv_pubkey), + } + })?; + + vals.push(DistValidator { + pub_key: dv_pubkey.to_vec(), + pub_shares, + partial_deposit_data, + builder_registration, + }); + } + + Ok(vals) +} + +/// Returns a BLS aggregate signature of the message signed by all the shares. +fn agg_sign(secrets: &[Vec], message: &[u8]) -> Result> { + use pluto_crypto::types::Signature; + + let tbls = BlstImpl; + let mut sigs: Vec = Vec::new(); + + for shares in secrets { + for share in shares { + let sig = tbls.sign(share, message)?; + sigs.push(sig); + } + } + + if sigs.is_empty() { + return Ok(Vec::new()); + } + + let agg = tbls.aggregate(&sigs)?; + Ok(agg.to_vec()) +} + +/// Writes `cluster-lock.json` to every node directory under `cluster_dir`. +/// The file is created with 0o400 (owner read-only) permissions. +async fn write_lock(lock: &Lock, cluster_dir: impl AsRef, num_nodes: u64) -> Result<()> { + let json = serde_json::to_string_pretty(lock)?; + let bytes = json.into_bytes(); + + for i in 0..num_nodes { + let lock_path = node_dir(cluster_dir.as_ref(), i).join("cluster-lock.json"); + + tokio::fs::write(&lock_path, &bytes).await?; + + let perms = std::fs::Permissions::from_mode(0o400); + tokio::fs::set_permissions(&lock_path, perms).await?; + } + + Ok(()) +} + +fn write_output( + w: &mut dyn Write, + split_keys: bool, + cluster_dir: impl AsRef, + num_nodes: u64, + keys_to_disk: bool, + zipped: bool, +) -> std::io::Result<()> { + let abs_cluster_dir = std::path::absolute(cluster_dir.as_ref())?; + let abs_str = abs_cluster_dir.display().to_string(); + let abs_str = abs_str.trim_end_matches('/'); + + writeln!(w, "Created charon cluster:")?; + writeln!(w, " --split-existing-keys={}", split_keys)?; + writeln!(w)?; + writeln!(w, "{}/", abs_str)?; + writeln!( + w, + "├─ node[0-{}]/\t\t\tDirectory for each node", + num_nodes.saturating_sub(1) + )?; + writeln!( + w, + "│ ├─ charon-enr-private-key\tCharon networking private key for node authentication" + )?; + writeln!( + w, + "│ ├─ cluster-lock.json\t\tCluster lock defines the cluster lock file which is signed by all nodes" + )?; + writeln!( + w, + "│ ├─ deposit-data-*.json\tDeposit data files are used to activate a Distributed Validator on the DV Launchpad" + )?; + if keys_to_disk { + writeln!( + w, + "│ ├─ validator_keys\t\tValidator keystores and password" + )?; + writeln!( + w, + "│ │ ├─ keystore-*.json\tValidator private share key for duty signing" + )?; + writeln!( + w, + "│ │ ├─ keystore-*.txt\t\tKeystore password files for keystore-*.json" + )?; + } + if zipped { + writeln!(w)?; + writeln!(w, "Files compressed and archived to:")?; + writeln!(w, "{}/cluster.tar.gz", abs_str)?; + } + + Ok(()) +} + +fn write_split_keys_warning(w: &mut dyn Write) -> std::io::Result<()> { + writeln!(w)?; + writeln!( + w, + "***************** WARNING: Splitting keys **********************" + )?; + writeln!( + w, + " Please make sure any existing validator has been shut down for" + )?; + writeln!( + w, + " at least 2 finalised epochs before starting the charon cluster," + )?; + writeln!( + w, + " otherwise slashing could occur. " + )?; + writeln!( + w, + "****************************************************************" + )?; + writeln!(w)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use pluto_cluster::{lock::Lock, version::versions::*}; + use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls as _}; + use pluto_eth1wrap::EthClient; + use pluto_eth2util::{ + deposit, + keystore::{self, CONFIRM_INSECURE_KEYS}, + }; + use rand::Rng as _; + use tempfile::TempDir; + + use super::*; + + const DEF_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-006.json" + ); + const DEF_PATH_TWO_NODES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-001.json" + ); + const DEFAULT_NETWORK: &str = "mainnet"; + + const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/commands/testdata"); + + /// Returns the path for a golden file. + fn golden_path(test_name: &str, subtest: &str) -> std::path::PathBuf { + std::path::Path::new(TESTDATA_DIR).join(format!( + "test_create_cluster_{}_{}.golden", + test_name, subtest + )) + } + + /// Compares `actual` against a golden file. + /// + /// Set `UPDATE_GOLDEN=1` in the environment to regenerate golden files. + fn require_golden_bytes(test_name: &str, subtest: &str, actual: &[u8]) { + let path = golden_path(test_name, subtest); + if std::env::var("UPDATE_GOLDEN").as_deref() == Ok("1") { + std::fs::create_dir_all(TESTDATA_DIR).unwrap(); + std::fs::write(&path, actual).unwrap(); + return; + } + let expected = std::fs::read(&path).unwrap_or_else(|_| { + panic!( + "golden file not found: {}. Run with UPDATE_GOLDEN=1 to create it.", + path.display() + ) + }); + assert_eq!( + String::from_utf8_lossy(actual), + String::from_utf8_lossy(&expected), + "golden file mismatch: {}", + path.display() + ); + } + + /// Serializes `data` as a JSON array with 1-space indent (matching Go's + /// `json.MarshalIndent(data, "", " ")`) and compares against a golden file. + fn require_golden_json(test_name: &str, subtest: &str, data: &[String]) { + let json = if data.is_empty() { + "[]".to_string() + } else { + let items: Vec = data + .iter() + .map(|s| format!(" {}", serde_json::to_string(s).unwrap())) + .collect(); + format!("[\n{}\n]", items.join(",\n")) + }; + require_golden_bytes(test_name, subtest, json.as_bytes()); + } + + #[derive(Debug, Clone)] + struct TestCaseConfig { + num_nodes: u64, + threshold: Option, + num_dvs: u64, + network: Option<&'static str>, + deposit_amounts: Vec, + split_keys: bool, + def_file_path: Option<&'static str>, + name: Option<&'static str>, + consensus_protocol: Option<&'static str>, + target_gas_limit: u64, + testnet_chain_id: Option, + testnet_name: Option<&'static str>, + testnet_fork_version: Option<&'static str>, + } + + #[derive(Debug, Clone, Copy)] + enum PrepKind { + None, + SplitKeys { num_keys: usize }, + RandomAddrs, + } + + #[derive(Debug, Clone, Copy)] + enum DefProvider { + None, + Def006, + DefTwoNodes, + } + + async fn test_eth1_client() -> EthClient { + EthClient::new("http://127.0.0.1:8545").await.unwrap() + } + + fn random_checksummed_eth_address() -> String { + let mut bytes = [0u8; 20]; + rand::thread_rng().fill(&mut bytes[..]); + let hex_addr = format!("0x{}", hex::encode(bytes)); + pluto_eth2util::helpers::checksum_address(&hex_addr).unwrap() + } + + /// Port of Go's testCreateCluster helper. + async fn run_test_create_cluster( + config: TestCaseConfig, + prep: PrepKind, + def_provider: DefProvider, + expected_err: Option<&str>, + ) { + let dir = TempDir::new().unwrap(); + + // Load reference definition for post-creation checks (used when def_file_path + // is set). Inject defaults for fields absent from older JSON examples + // (e.g. `compounding`). + let ref_def: pluto_cluster::definition::Definition = { + let bytes = tokio::fs::read(DEF_PATH).await.unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + if value.get("compounding").is_none() { + value["compounding"] = serde_json::json!(false); + } + serde_json::from_value(value).unwrap() + }; + + // Start a mock HTTP server when the test case needs to serve a definition over + // network. + let mock_server = match def_provider { + DefProvider::None => None, + path @ DefProvider::Def006 | path @ DefProvider::DefTwoNodes => { + let server = wiremock::MockServer::start().await; + let json = if matches!(path, DefProvider::Def006) { + tokio::fs::read(DEF_PATH).await.unwrap() + } else { + tokio::fs::read(DEF_PATH_TWO_NODES).await.unwrap() + }; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(json)) + .mount(&server) + .await; + Some(server) + } + }; + + // Apply defaults matching the Go test loop. + let def_url = mock_server.as_ref().map(|s| s.uri()); + let definition_file = if let Some(url) = &def_url { + Some(url.clone()) + } else { + config.def_file_path.map(String::from) + }; + + let mut fee_addrs = vec![ZERO_ADDRESS.to_string()]; + let mut withdrawal_addrs = vec![ZERO_ADDRESS.to_string()]; + + let target_gas_limit = if config.target_gas_limit != 0 { + config.target_gas_limit + } else if def_url.is_none() { + 30_000_000 + } else { + 0 + }; + + // Split keys temp dir kept alive for the full duration of the test. + let split_keys_temp = TempDir::new().unwrap(); + let mut split_keys_dir: Option = None; + + match prep { + PrepKind::None => {} + PrepKind::SplitKeys { num_keys } => { + let tbls = BlstImpl; + let mut keys = Vec::new(); + for _ in 0..num_keys { + keys.push(tbls.generate_secret_key(rand::thread_rng()).unwrap()); + } + keystore::store_keys_insecure( + &keys, + split_keys_temp.path(), + &CONFIRM_INSECURE_KEYS, + ) + .await + .unwrap(); + split_keys_dir = Some(split_keys_temp.path().to_path_buf()); + } + PrepKind::RandomAddrs => { + fee_addrs = vec![random_checksummed_eth_address()]; + withdrawal_addrs = vec![random_checksummed_eth_address()]; + } + } + + let testnet_config = TestnetConfig { + chain_id: config.testnet_chain_id, + fork_version: config.testnet_fork_version.map(String::from), + genesis_timestamp: config.testnet_chain_id.map(|_| { + u64::try_from(chrono::Utc::now().timestamp()).expect("timestamp fits u64") + }), + testnet_name: config.testnet_name.map(String::from), + }; + + let network = config.network.and_then(|n| Network::try_from(n).ok()); + let execution_engine_addr = definition_file + .as_ref() + .map(|_| "http://127.0.0.1:8545".to_string()); + + let args = CreateClusterArgs { + cluster_dir: dir.path().to_path_buf(), + compounding: false, + consensus_protocol: config.consensus_protocol.map(String::from), + definition_file, + deposit_amounts: config.deposit_amounts.clone(), + execution_engine_addr, + fee_recipient_addrs: fee_addrs, + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: config.name.map(String::from), + network, + nodes: config.num_nodes, + num_validators: config.num_dvs, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: config.split_keys, + split_keys_dir, + target_gas_limit, + testnet_config, + threshold: config.threshold, + withdrawal_addrs, + zipped: false, + }; + + let mut output = Vec::new(); + let result = run(&mut output, args).await; + + if let Some(expected) = expected_err { + let err = result.unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains(expected), + "expected error containing '{expected}', got: {err_str}" + ); + return; + } + + result.unwrap(); + + // Validate lock (port of Go's t.Run("valid lock", ...)). + let eth1 = test_eth1_client().await; + let lock_bytes = tokio::fs::read(dir.path().join("node0/cluster-lock.json")) + .await + .unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + lock.verify_hashes().unwrap(); + lock.verify_signatures(ð1).await.unwrap(); + + // Check validators have unique public keys. + let pub_keys: HashSet> = lock + .distributed_validators + .iter() + .map(|v| v.pub_key.clone()) + .collect(); + assert_eq!( + pub_keys.len(), + usize::try_from(lock.definition.num_validators).unwrap() + ); + + // Check partial deposit data amounts match expected. + let mut amounts = deposit::dedup_amounts(&deposit::eths_to_gweis(&config.deposit_amounts)); + if amounts.is_empty() { + amounts = deposit::default_deposit_amounts(false); + } + for val in &lock.distributed_validators { + assert_eq!(val.partial_deposit_data.len(), amounts.len()); + for (pdd, &expected_amount) in val.partial_deposit_data.iter().zip(amounts.iter()) { + assert_eq!(pdd.amount, expected_amount); + } + } + + // If a definition file was loaded from disk, config hash and creator must be + // preserved, and operators must have their ENRs populated. + if config.def_file_path.is_some() { + assert_eq!(lock.definition.config_hash, ref_def.config_hash); + assert_eq!(lock.definition.creator, ref_def.creator); + for op in &lock.operators { + assert!(!op.enr.is_empty()); + } + } + + const PREV_VERSIONS: &[&str] = &[V1_0, V1_1, V1_2, V1_3, V1_4, V1_5]; + + // Builder registrations must be populated (v1.7+, always true for v1.10). + for val in &lock.distributed_validators { + if PREV_VERSIONS.contains(&lock.definition.version.as_str()) { + continue; + } + + if matches!(lock.definition.version.as_str(), V1_6 | V1_7) { + assert_eq!(val.partial_deposit_data.len(), 1); + } + + if lock.definition.version.as_str() == V1_7 { + assert!(!val.builder_registration.signature.is_empty()); + } + + if config.split_keys { + // For SplitKeys mode the timestamp must be close to now, not a genesis time. + let reg_ts = val.builder_registration.message.timestamp; + let diff = chrono::Utc::now().signed_duration_since(reg_ts); + assert!( + diff.num_minutes().abs() < 5, + "builder registration timestamp likely a genesis time" + ); + } + } + + // Node signatures must be populated (v1.7+). + if lock.version.as_str() == V1_7 { + assert!(!lock.node_signatures.is_empty()); + for sig in &lock.node_signatures { + assert!(!sig.is_empty()); + } + } + + // --- Golden tests --- + + // Extract the leaf test-case name from the thread name + // (e.g. "...::tests::simnet" -> "simnet"). + let test_name = std::thread::current() + .name() + .unwrap_or("") + .rsplit("::") + .next() + .unwrap_or("unknown") + .to_string(); + + // Output golden: replace the temp cluster-dir path with "pluto" to + // produce a deterministic, portable snapshot. + let abs_dir = std::path::absolute(dir.path()).unwrap(); + let output_str = String::from_utf8(output).unwrap(); + let output_replaced = output_str.replace(abs_dir.to_string_lossy().as_ref(), "pluto"); + require_golden_bytes(&test_name, "output", output_replaced.as_bytes()); + + // Files golden: list cluster-dir contents two levels deep (sorted), + // L1 entries first then L2 entries — mirrors Go's + // `filepath.Glob(dir+"/*")` + `filepath.Glob(dir+"/*/*")`. + let mut l1: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + l1.sort_by_key(|e| e.file_name()); + + let mut files: Vec = l1 + .iter() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + + for entry1 in &l1 { + let mut l2: Vec<_> = std::fs::read_dir(entry1.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + l2.sort_by_key(|e| e.file_name()); + for entry2 in &l2 { + files.push(format!( + "{}/{}", + entry1.file_name().to_string_lossy(), + entry2.file_name().to_string_lossy() + )); + } + } + + require_golden_json(&test_name, "files", &files); + } + + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "simnet" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![31, 1], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "two partial deposits" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![8, 8, 8, 8], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "four partial deposits" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 0, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: true, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::SplitKeys { num_keys: 2 }, DefProvider::None, None + ; "splitkeys" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: true, def_file_path: Some(DEF_PATH), name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::SplitKeys { num_keys: 2 }, DefProvider::None, None + ; "splitkeys with cluster definition" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: true, def_file_path: Some(DEF_PATH), name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::SplitKeys { num_keys: 1 }, DefProvider::None, + Some("Amount of keys read from disk") + ; "splitkeys with cluster definition but amount of keys read from disk differ" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Missing --nodes") + ; "missing nodes amount flag" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: None, num_dvs: 0, + network: None, deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Missing --network flag") + ; "missing network flag" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Missing --num-validators") + ; "missing numdvs with no split keys set" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: true, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("--split-existing-keys") + ; "splitkeys with numdvs set" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: MIN_NODES, threshold: Some(3), num_dvs: 2, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "goerli" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: Some(DEF_PATH), name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "solo flow definition from disk" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::Def006, None + ; "solo flow definition from network" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 3, threshold: Some(3), num_dvs: 5, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::RandomAddrs, DefProvider::None, None + ; "test with fee recipient and withdrawal addresses" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 3, + network: None, deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("testnet"), + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: Some(243), + testnet_name: Some("obolnetwork"), + testnet_fork_version: Some("0x00000101"), + }, + PrepKind::None, DefProvider::None, None + ; "custom testnet flags" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 3, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: Some("unreal"), target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Unsupported consensus protocol") + ; "preferred consensus protocol" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 2, threshold: Some(2), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::DefTwoNodes, Some("Too few nodes") + ; "test with number of nodes below minimum" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 3, + network: Some("holesky"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: None, target_gas_limit: 36_000_000, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "custom target gas limit" + )] + #[tokio::test] + async fn test_create_cluster( + config: TestCaseConfig, + prep: PrepKind, + def_provider: DefProvider, + expected_err: Option<&'static str>, + ) { + run_test_create_cluster(config, prep, def_provider, expected_err).await; + } + + const DEF_PATH_004: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-004.json" + ); + + /// Port of Go's TestSplitKeys. + /// + /// Verifies that `run` correctly handles split-keys mode both when a + /// cluster definition is loaded from disk and when it is built from + /// inline config. For each case the test generates BLS private keys, + /// stores them insecurely, runs cluster creation, then checks the + /// produced lock for hash/signature validity and that the number of + /// distributed validators equals the number of keys that were split. + #[test_case::test_case( + 2, Some(DEF_PATH_004), 4, None, None + ; "split keys from local definition" + )] + #[test_case::test_case( + 3, None, MIN_NODES, Some(3), Some("test split keys") + ; "split keys from config with one num-validators" + )] + #[tokio::test] + async fn test_split_keys( + num_split_keys: usize, + def_file_path: Option<&'static str>, + num_nodes: u64, + threshold: Option, + cluster_name: Option<&'static str>, + ) { + let dir = TempDir::new().unwrap(); + let split_keys_temp = TempDir::new().unwrap(); + + // Generate and store split keys insecurely. + let tbls_impl = BlstImpl; + let mut keys = Vec::new(); + for _ in 0..num_split_keys { + keys.push(tbls_impl.generate_secret_key(rand::thread_rng()).unwrap()); + } + keystore::store_keys_insecure(&keys, split_keys_temp.path(), &CONFIRM_INSECURE_KEYS) + .await + .unwrap(); + + let execution_engine_addr = def_file_path.map(|_| "http://127.0.0.1:8545".to_string()); + + let args = CreateClusterArgs { + cluster_dir: dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: def_file_path.map(String::from), + deposit_amounts: vec![], + execution_engine_addr, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: cluster_name.map(String::from), + network: Some(Network::Goerli), + nodes: num_nodes, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(split_keys_temp.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold, + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }; + + let mut output = Vec::new(); + run(&mut output, args).await.unwrap(); + + // Since `cluster-lock.json` is copied into each node directory, use node0. + let lock_bytes = tokio::fs::read(dir.path().join("node0/cluster-lock.json")) + .await + .unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + lock.verify_hashes().unwrap(); + + let eth1 = test_eth1_client().await; + lock.verify_signatures(ð1).await.unwrap(); + + assert_eq!( + usize::try_from(lock.definition.num_validators).unwrap(), + num_split_keys, + ); + } + + const DEF_PATH_002: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-002.json" + ); + + #[tokio::test] + async fn test_validate_definition() { + let num_dvs = 4u64; + let fee_recipient_addrs: Vec = (0..num_dvs) + .map(|_| random_checksummed_eth_address()) + .collect(); + let withdrawal_addrs: Vec = + vec![ZERO_ADDRESS.to_string(); usize::try_from(num_dvs).unwrap()]; + + let args = CreateClusterArgs { + cluster_dir: std::path::PathBuf::from("./"), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs, + insecure_keys: false, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("test".to_string()), + network: Some(Network::Goerli), + nodes: 4, + num_validators: num_dvs, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(3), + withdrawal_addrs, + zipped: false, + }; + + let definition = super::new_def_from_config(&args).unwrap(); + + let remote_def: pluto_cluster::definition::Definition = { + let bytes = tokio::fs::read(DEF_PATH_002).await.unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + if value.get("compounding").is_none() { + value["compounding"] = serde_json::json!(false); + } + serde_json::from_value(value).unwrap() + }; + + let eth1 = test_eth1_client().await; + let keymanager_addrs: Vec = vec![]; + + // "zero address": gnosis fork version with zero withdrawal addrs -> error + { + let mut def = definition.clone(); + def.fork_version = vec![0x00, 0x00, 0x00, 0x64]; // gnosis + assert!( + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .is_err() + ); + } + + // "fork versions": goerli -> ok; mainnet with zero withdrawal addrs -> error + { + let def = definition.clone(); + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap(); + + let mut def = definition.clone(); + def.fork_version = vec![0x00, 0x00, 0x00, 0x00]; // mainnet + assert!( + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .is_err() + ); + } + + // "insufficient keymanager addresses": 1 addr for 4-operator cluster -> error + { + let def = definition.clone(); + let km_addrs = vec!["127.0.0.1:1234".to_string()]; + assert!( + super::validate_definition(&def, true, &km_addrs, ð1) + .await + .is_err() + ); + } + + // "insecure keys": insecure_keys=true, goerli -> ok + { + let def = definition.clone(); + super::validate_definition(&def, true, &keymanager_addrs, ð1) + .await + .unwrap(); + } + + // "insufficient number of nodes": no operators -> "Too few nodes" + { + let mut def = definition.clone(); + def.operators = vec![]; + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("Too few nodes"), + "expected 'Too few nodes', got: {err_str}" + ); + } + + // "name not provided": empty name -> "Name not provided" + { + let mut def = definition.clone(); + def.name = String::new(); + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("Name not provided"), + "expected 'Name not provided', got: {err_str}" + ); + } + + // "zero validators provided": num_validators=0 -> "zero validators" + { + let mut def = definition.clone(); + def.num_validators = 0; + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("zero validators"), + "expected 'zero validators', got: {err_str}" + ); + } + + // "invalid hash": remote def with modified num_validators -> "Invalid config + // hash" + { + let mut def = remote_def.clone(); + def.num_validators = 3; + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("Invalid config hash"), + "expected 'Invalid config hash', got: {err_str}" + ); + } + + // "invalid config signatures": remote def with modified num_validators + rehash + // -> "invalid creator config signature" + { + let mut def = remote_def.clone(); + def.num_validators = 3; + def.set_definition_hashes().unwrap(); + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("invalid creator config signature"), + "expected 'invalid creator config signature', got: {err_str}" + ); + } + + // "unsupported consensus protocol": unrecognised protocol -> error + { + let mut def = definition.clone(); + def.consensus_protocol = "unreal".to_string(); + assert!( + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .is_err() + ); + } + } + + /// Port of Go's TestMultipleAddresses. + #[tokio::test] + async fn test_multiple_addresses() { + // "insufficient fee recipient addresses": 0 addrs for 4 validators → error + { + let err = super::validate_addresses(4, &[], &[]).unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("mismatching --num-validators and --fee-recipient-addresses"), + "expected fee-recipient error, got: {err_str}" + ); + } + + // "insufficient withdrawal addresses": 0 withdrawal addrs for 1 validator → + // error + { + let fee_addr = "0x0000000000000000000000000000000000000000".to_string(); + let err = super::validate_addresses(1, &[fee_addr], &[]).unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("mismatching --num-validators and --withdrawal-addresses"), + "expected withdrawal error, got: {err_str}" + ); + } + + // "insufficient addresses from remote URL": deserializing a definition + // with num_validators=2 but empty validators list must fail with the + // Go-compatible error message. Testing at the JSON-parse level mirrors + // what Go's runCreateCluster triggers when it calls unmarshalDefinitionV1x10. + { + let def_json = tokio::fs::read(DEF_PATH).await.unwrap(); + let mut def_value: serde_json::Value = serde_json::from_slice(&def_json).unwrap(); + // Clear the validators list while keeping num_validators=2 to create a + // mismatch that mirrors the Go test (d.ValidatorAddresses = []). + def_value["validators"] = serde_json::json!([]); + let modified_json = serde_json::to_vec(&def_value).unwrap(); + + let err = + serde_json::from_slice::(&modified_json) + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("num_validators not matching validators length"), + "expected validator length error, got: {err_str}" + ); + } + } + + const DEF_PATH_005: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-005.json" + ); + + #[test_case::test_case( + Some(DEF_PATH_005), 0, 0, None + ; "target gas limit from unsupported version" + )] + #[test_case::test_case( + Some(DEF_PATH), 0, 30_000_000, None + ; "target gas limit from supported version" + )] + #[test_case::test_case( + None, 36_000_000, 36_000_000, None + ; "target gas limit with default version" + )] + #[test_case::test_case( + None, 0, 0, Some("target gas limit should be set") + ; "no target gas limit with default version" + )] + #[tokio::test] + async fn test_target_gas_limit( + def_file_path: Option<&'static str>, + target_gas_limit: u64, + expected_gas_limit: u64, + expected_err: Option<&'static str>, + ) { + let dir = TempDir::new().unwrap(); + + let execution_engine_addr = def_file_path.map(|_| "http://127.0.0.1:8545".to_string()); + + let args = CreateClusterArgs { + cluster_dir: dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: def_file_path.map(String::from), + deposit_amounts: vec![], + execution_engine_addr, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("test target gas limit".to_string()), + network: Some(Network::Mainnet), + nodes: 4, + num_validators: 1, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit, + testnet_config: TestnetConfig::default(), + threshold: Some(3), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }; + + let mut output = Vec::new(); + let result = run(&mut output, args).await; + + if let Some(expected) = expected_err { + let err = result.unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains(expected), + "expected error containing '{expected}', got: {err_str}" + ); + return; + } + + result.unwrap(); + + let lock_bytes = tokio::fs::read(dir.path().join("node0/cluster-lock.json")) + .await + .unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + lock.verify_hashes().unwrap(); + + let eth1 = test_eth1_client().await; + lock.verify_signatures(ð1).await.unwrap(); + + assert_eq!(lock.target_gas_limit, expected_gas_limit); + } + + #[tokio::test] + async fn test_keymanager() { + #[derive(serde::Deserialize)] + struct MockKeymanagerReq { + keystores: Vec, + passwords: Vec, + } + + const TEST_AUTH_TOKEN: &str = "api-token-test"; + + let tbls_impl = BlstImpl; + + let original_secret = tbls_impl.generate_secret_key(rand::thread_rng()).unwrap(); + let key_dir = TempDir::new().unwrap(); + keystore::store_keys_insecure( + std::slice::from_ref(&original_secret), + key_dir.path(), + &CONFIRM_INSECURE_KEYS, + ) + .await + .unwrap(); + + let auth_tokens: Vec = + vec![TEST_AUTH_TOKEN.to_string(); usize::try_from(MIN_NODES).unwrap()]; + + // --- all successful --- + { + let mut servers = Vec::new(); + let mut addrs = Vec::new(); + for _ in 0..MIN_NODES { + let server = wiremock::MockServer::start().await; + addrs.push(server.uri()); + servers.push(server); + } + for server in &servers { + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/eth/v1/keystores")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .mount(server) + .await; + } + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: addrs, + keymanager_auth_tokens: auth_tokens.clone(), + name: Some("TestKeymanager".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(key_dir.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap(); + + let mut shares = std::collections::HashMap::new(); + for (i, server) in servers.iter().enumerate() { + let requests = server.received_requests().await.unwrap(); + assert_eq!( + requests.len(), + 1, + "server {i} should have received exactly 1 request" + ); + + let req: MockKeymanagerReq = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(req.keystores.len(), 1, "only 1 key was split"); + assert_eq!(req.passwords.len(), 1); + + let ks: keystore::Keystore = serde_json::from_str(&req.keystores[0]).unwrap(); + let secret = keystore::decrypt(&ks, &req.passwords[0]).unwrap(); + shares.insert(u8::try_from(i + 1).unwrap(), secret); + } + + let recovered = tbls_impl.recover_secret(&shares).unwrap(); + assert_eq!(recovered, original_secret); + } + + // --- some unsuccessful --- + { + let mut addrs = vec!["http://127.0.0.1:1".to_string()]; + let mut servers = Vec::new(); + for _ in 1..MIN_NODES { + let server = wiremock::MockServer::start().await; + addrs.push(server.uri()); + servers.push(server); + } + for server in &servers { + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/eth/v1/keystores")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .mount(server) + .await; + } + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let err = run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: addrs, + keymanager_auth_tokens: auth_tokens.clone(), + name: Some("TestKeymanager".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(key_dir.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap_err(); + + let err_str = format!("{err}"); + assert!( + err_str.contains("cannot ping address"), + "expected 'cannot ping address' in error, got: {err_str}" + ); + } + + // --- lengths don't match --- + { + let mut addrs = Vec::new(); + let mut servers = Vec::new(); + for _ in 0..MIN_NODES { + let server = wiremock::MockServer::start().await; + addrs.push(server.uri()); + servers.push(server); + } + + let short_tokens = auth_tokens[1..].to_vec(); + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let err = run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: addrs, + keymanager_auth_tokens: short_tokens, + name: Some("TestKeymanager".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(key_dir.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap_err(); + + let err_str = format!("{err}"); + assert!( + err_str.contains("--keymanager-addresses") + && err_str.contains("do not match --keymanager-auth-tokens"), + "expected mismatch error, got: {err_str}" + ); + } + } + + #[tokio::test] + async fn test_publish() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/lock")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("TestPublish".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 1, + publish: true, + publish_address: server.uri(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1, "expected exactly 1 publish request"); + } + + const VALID_ETH_ADDR: &str = "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"; + + #[test_case::test_case(Some(1), 3, Some("Threshold must be greater than") ; "threshold below minimum")] + #[test_case::test_case(Some(5), 4, Some("Threshold cannot be greater than number of operators") ; "threshold above maximum")] + #[test_case::test_case(None, 3, None ; "no threshold provided")] + #[tokio::test] + async fn test_cluster_cli( + threshold: Option, + nodes: u64, + expected_err: Option<&'static str>, + ) { + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let result = run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![VALID_ETH_ADDR.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: None, + network: Some(Network::Holesky), + nodes, + num_validators: 1, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold, + withdrawal_addrs: vec![VALID_ETH_ADDR.to_string()], + zipped: false, + }, + ) + .await; + + if let Some(expected) = expected_err { + let err = result.unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains(expected), + "expected error containing '{expected}', got: {err_str}" + ); + } else { + result.unwrap(); + } + } + + fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), dst.join(entry.file_name()))?; + } + } + Ok(()) + } + + #[tokio::test] + async fn test_zipped() { + let cluster_dir = TempDir::new().unwrap(); + let backup_dir = TempDir::new().unwrap(); + + let mut output = Vec::new(); + run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("test".to_string()), + network: Some(Network::Goerli), + nodes: 4, + num_validators: 4, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(3), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap(); + + copy_dir_all(cluster_dir.path(), backup_dir.path()).unwrap(); + + app_utils::bundle_output(cluster_dir.path(), "cluster.tar.gz").unwrap(); + + let unzipped_dir = TempDir::new().unwrap(); + app_utils::extract_archive( + cluster_dir.path().join("cluster.tar.gz"), + unzipped_dir.path(), + ) + .unwrap(); + + std::fs::remove_file(cluster_dir.path().join("cluster.tar.gz")).unwrap(); + + app_utils::compare_directories(backup_dir.path(), unzipped_dir.path()).unwrap(); + } +} diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs new file mode 100644 index 00000000..d8419e17 --- /dev/null +++ b/crates/cli/src/commands/create_dkg.rs @@ -0,0 +1,69 @@ +//! Create DKG command utilities. +//! +//! This module provides utilities for the `pluto create dkg` command, +//! including validation functions for withdrawal addresses. + +use pluto_eth2util::{self as eth2util}; +use thiserror::Error; + +use crate::commands::create_cluster::{ZERO_ADDRESS, is_main_or_gnosis}; + +/// Errors that can occur during withdrawal address validation. +#[derive(Error, Debug)] +pub enum WithdrawalValidationError { + /// Invalid withdrawal address. + #[error("Invalid withdrawal address: {address}")] + InvalidWithdrawalAddress { + /// The invalid address. + address: String, + }, + + /// Invalid checksummed address. + #[error("Invalid checksummed address: {address}")] + InvalidChecksummedAddress { + /// The address with invalid checksum. + address: String, + }, + + /// Zero address forbidden on mainnet/gnosis. + #[error("Zero address forbidden on this network: {network}")] + ZeroAddressForbiddenOnNetwork { + /// The network name. + network: String, + }, + + /// Eth2util helpers error. + #[error("Eth2util helpers error: {0}")] + Eth2utilHelperError(#[from] eth2util::helpers::HelperError), +} + +/// Validates withdrawal addresses for the given network. +/// +/// Returns an error if any of the provided withdrawal addresses is invalid. +pub fn validate_withdrawal_addrs( + addrs: &[String], + network: &str, +) -> std::result::Result<(), WithdrawalValidationError> { + for addr in addrs { + let checksum_addr = eth2util::helpers::checksum_address(addr).map_err(|_| { + WithdrawalValidationError::InvalidWithdrawalAddress { + address: addr.clone(), + } + })?; + + if checksum_addr != *addr { + return Err(WithdrawalValidationError::InvalidChecksummedAddress { + address: addr.clone(), + }); + } + + // We cannot allow a zero withdrawal address on mainnet or gnosis. + if is_main_or_gnosis(network) && addr == ZERO_ADDRESS { + return Err(WithdrawalValidationError::ZeroAddressForbiddenOnNetwork { + network: network.to_string(), + }); + } + } + + Ok(()) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e18c3e1f..a04b320d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod create_cluster; +pub mod create_dkg; pub mod create_enr; pub mod enr; pub mod relay; diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden new file mode 100644 index 00000000..f53d1513 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden @@ -0,0 +1,22 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-8eth.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-8eth.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-8eth.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-8eth.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden new file mode 100644 index 00000000..2caa6c3e --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden @@ -0,0 +1,20 @@ +[ + "node0", + "node1", + "node2", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden new file mode 100644 index 00000000..a053b82f --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-2]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden new file mode 100644 index 00000000..7f101ca2 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden @@ -0,0 +1,18 @@ + +***************** WARNING: Splitting keys ********************** + Please make sure any existing validator has been shut down for + at least 2 finalised epochs before starting the charon cluster, + otherwise slashing could occur. +**************************************************************** + +Created charon cluster: + --split-existing-keys=true + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden new file mode 100644 index 00000000..7f101ca2 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden @@ -0,0 +1,18 @@ + +***************** WARNING: Splitting keys ********************** + Please make sure any existing validator has been shut down for + at least 2 finalised epochs before starting the charon cluster, + otherwise slashing could occur. +**************************************************************** + +Created charon cluster: + --split-existing-keys=true + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden new file mode 100644 index 00000000..2caa6c3e --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden @@ -0,0 +1,20 @@ +[ + "node0", + "node1", + "node2", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden new file mode 100644 index 00000000..a053b82f --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-2]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden new file mode 100644 index 00000000..181e3b4d --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data-31eth.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data-31eth.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data-31eth.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data-31eth.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index f2ae55a4..2fb454c7 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; +use crate::commands::create_cluster::{MIN_NODES, MIN_THRESHOLD}; + /// Result type for CLI operations. pub type Result = std::result::Result; @@ -72,6 +74,26 @@ pub enum CliError { #[error("Relay P2P error: {0}")] RelayP2PError(#[from] pluto_relay_server::error::RelayP2PError), + /// Create cluster error. + #[error("Create cluster error: {0}")] + CreateClusterError(#[from] CreateClusterError), + + /// Eth1wrap error. + #[error("Eth1wrap error: {0}")] + Eth1wrapError(#[from] pluto_eth1wrap::EthClientError), + + /// Eth2util network error. + #[error("Eth2util network error: {0}")] + Eth2utilNetworkError(#[from] pluto_eth2util::network::NetworkError), + + /// Eth2util deposit error. + #[error("Eth2util deposit error: {0}")] + Eth2utilDepositError(#[from] pluto_eth2util::deposit::DepositError), + + /// Tracing initialization error. + #[error("Tracing initialization error: {0}")] + TracingInitError(#[from] pluto_tracing::init::Error), + /// Command parsing error. #[error("Command parsing error: {0}")] CommandParsingError(#[from] clap::Error), @@ -80,3 +102,307 @@ pub enum CliError { #[error("{0}")] Other(String), } + +#[derive(thiserror::Error, Debug)] +pub enum CreateClusterError { + /// Invalid threshold. + #[error("Invalid threshold: {0}")] + InvalidThreshold(#[from] ThresholdError), + + /// Missing nodes or definition file. + #[error("Missing --nodes or --definition-file flag")] + MissingNodesOrDefinitionFile, + + /// Invalid network configuration. + #[error("Invalid network configuration: {0}")] + InvalidNetworkConfig(InvalidNetworkConfigError), + + /// IO error. + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + /// Node directory already exists. + #[error( + "Existing node directory found, please delete it before running this command: node_dir={node_dir}" + )] + NodeDirectoryAlreadyExists { + /// Node directory. + node_dir: PathBuf, + }, + + /// Invalid keymanager configuration. + #[error( + "number of --keymanager-addresses={keymanager_addrs} do not match --keymanager-auth-tokens={keymanager_auth_tokens}. Please fix configuration flags" + )] + InvalidKeymanagerConfig { + /// Number of keymanager addresses. + keymanager_addrs: usize, + /// Number of keymanager auth tokens. + keymanager_auth_tokens: usize, + }, + + /// Invalid deposit amounts. + #[error("Invalid deposit amounts: {0}")] + InvalidDepositAmounts(#[from] pluto_eth2util::deposit::DepositError), + + /// Invalid keymanager URL. + #[error("Invalid keymanager URL: {0}")] + InvalidKeymanagerUrl(#[from] url::ParseError), + + /// Cannot specify --num-validators with --split-existing-keys. + #[error("Cannot specify --num-validators with --split-existing-keys")] + CannotSpecifyNumValidatorsWithSplitKeys, + + /// Missing --num-validators or --definition-file flag. + #[error("Missing --num-validators or --definition-file flag")] + MissingNumValidatorsOrDefinitionFile, + + /// Too few nodes. + #[error("Too few nodes: {num_nodes}. Minimum is {MIN_NODES}")] + TooFewNodes { + /// Number of nodes. + num_nodes: u64, + }, + + /// Unsupported consensus protocol. + #[error("Unsupported consensus protocol: {consensus_protocol}")] + UnsupportedConsensusProtocol { + /// Consensus protocol. + consensus_protocol: String, + }, + + /// Missing --split-keys-dir flag. + #[error("--split-keys-dir is required when splitting keys")] + MissingSplitKeysDir, + + /// Missing --execution-client-rpc-endpoint flag. + #[error("--execution-client-rpc-endpoint is required when creating a new cluster")] + MissingExecutionEngineAddress, + + /// Amount of keys read from disk differs from cluster definition. + #[error( + "Amount of keys read from disk differs from cluster definition: disk={disk_keys}, definition={definition_keys}" + )] + KeyCountMismatch { + /// Number of keys read from disk. + disk_keys: usize, + /// Number of validators in the definition. + definition_keys: u64, + }, + + /// Crypto error. + #[error("Crypto error: {0}")] + CryptoError(#[from] pluto_crypto::types::Error), + + /// Value exceeds u8::MAX. + #[error("Value {value} exceeds u8::MAX (255)")] + ValueExceedsU8 { + /// The value that exceeds u8::MAX. + value: u64, + }, + + /// Value exceeds usize::MAX. + #[error("Value {value} exceeds usize::MAX")] + ValueExceedsUsize { + /// The value that exceeds usize::MAX. + value: u64, + }, + + /// Keystore error. + #[error("Keystore error: {0}")] + KeystoreError(#[from] pluto_eth2util::keystore::KeystoreError), + + /// Cannot create cluster with zero validators. + #[error("Cannot create cluster with zero validators, specify at least one")] + ZeroValidators, + + /// Insufficient keymanager addresses. + #[error("Insufficient number of keymanager addresses: expected={expected}, got={got}")] + InsufficientKeymanagerAddresses { + /// Expected number of keymanager addresses. + expected: usize, + /// Actual number of keymanager addresses. + got: usize, + }, + + /// Insecure keys not supported on mainnet/gnosis. + #[error("Insecure keys not supported on mainnet or gnosis")] + InsecureKeysOnMainnetOrGnosis, + + /// Definition name not provided. + #[error("Name not provided in cluster definition")] + DefinitionNameNotProvided, + + /// Definition error. + #[error("Definition error: {0}")] + DefinitionError(#[from] pluto_cluster::definition::DefinitionError), + + /// Unsupported network. + #[error("Unsupported network: {network}")] + UnsupportedNetwork { + /// Network name. + network: String, + }, + + /// Withdrawal validation error. + #[error("Withdrawal validation error: {0}")] + WithdrawalValidationError(#[from] crate::commands::create_dkg::WithdrawalValidationError), + + /// Failed to parse definition JSON. + #[error("Failed to parse definition JSON: {0}")] + ParseDefinitionJson(#[from] serde_json::Error), + + /// Cluster fetch error. + #[error("Failed to fetch cluster definition: {0}")] + FetchDefinition(#[from] pluto_cluster::helpers::FetchError), + + /// No validators specified in definition. + #[error("No validators specified in the given definition")] + NoValidatorsInDefinition, + + /// Mismatching number of fee recipient addresses. + #[error( + "mismatching --num-validators and --fee-recipient-addresses: num_validators={num_validators}, addresses={addresses}" + )] + MismatchingFeeRecipientAddresses { + /// Number of validators. + num_validators: u64, + /// Number of addresses. + addresses: usize, + }, + + /// Mismatching number of withdrawal addresses. + #[error( + "mismatching --num-validators and --withdrawal-addresses: num_validators={num_validators}, addresses={addresses}" + )] + MismatchingWithdrawalAddresses { + /// Number of validators. + num_validators: u64, + /// Number of addresses. + addresses: usize, + }, + + /// K1 error. + #[error("K1 error: {0}")] + K1Error(#[from] pluto_p2p::k1::K1Error), + + /// Record error. + #[error("Record error: {0}")] + RecordError(#[from] pluto_eth2util::enr::RecordError), + + /// Eth2util helper error. + #[error("Eth2util helper error: {0}")] + Eth2utilHelperError(#[from] pluto_eth2util::helpers::HelperError), + + /// Insufficient withdrawal addresses. + #[error("Insufficient withdrawal addresses")] + InsufficientWithdrawalAddresses, + + /// Empty deposit amounts. + #[error("Empty deposit amounts")] + EmptyDepositAmounts, + + /// Keymanager error. + #[error("Keymanager error: {0}")] + KeymanagerError(#[from] pluto_eth2util::keymanager::KeymanagerError), + + /// Insufficient fee addresses. + #[error("Insufficient fee addresses: expected {expected}, got {got}")] + InsufficientFeeAddresses { + /// Expected number of fee addresses. + expected: usize, + /// Actual number of fee addresses. + got: usize, + }, + + /// Invalid fork version length. + #[error("Invalid fork version length: expected 4 bytes")] + InvalidForkVersionLength, + + /// Registration error. + #[error("Registration error: {0}")] + RegistrationError(#[from] pluto_eth2util::registration::RegistrationError), + + /// Validator registration not found at the given index. + #[error("Validator registration not found at index {index}")] + ValidatorRegistrationNotFound { + /// Index that was out of bounds. + index: usize, + }, + + /// Deposit data not found for the given distributed validator pubkey. + #[error("Deposit data not found for distributed validator pubkey: {dv}")] + DepositDataNotFound { + /// Hex-encoded distributed validator pubkey. + dv: String, + }, + + /// Lock error (e.g. set_lock_hash failed). + #[error("Lock error: {0}")] + LockError(#[from] pluto_cluster::lock::LockError), + + /// K1 utility signing error. + #[error("K1 util signing error: {0}")] + K1UtilError(#[from] pluto_k1util::K1UtilError), + + /// Obol API error (publish_lock / launchpad URL). + #[error("Obol API error: {0}")] + ObolApiError(#[from] pluto_app::obolapi::ObolApiError), + + /// Bundle output (tar.gz archival) error. + #[error("Bundle output error: {0}")] + BundleOutputError(#[from] pluto_app::utils::UtilsError), +} + +#[derive(thiserror::Error, Debug)] +pub enum ThresholdError { + /// Threshold must be greater than {MIN_THRESHOLD}. + #[error("Threshold must be greater than {MIN_THRESHOLD}, got {threshold}")] + ThresholdTooLow { + /// Threshold value. + threshold: u64, + }, + + /// Threshold must be less than the number of nodes. + #[error( + "Threshold cannot be greater than number of operators (nodes): Threshold={threshold}, Number of nodes={number_of_nodes}" + )] + ThresholdTooHigh { + /// Threshold value. + threshold: u64, + /// Number of operators (nodes). + number_of_nodes: u64, + }, +} + +#[derive(thiserror::Error, Debug)] +pub enum InvalidNetworkConfigError { + /// Invalid network name. + #[error("Invalid network name: {0}")] + InvalidNetworkName(#[from] pluto_eth2util::network::NetworkError), + + /// Invalid network specified. + #[error("Invalid network specified: network={network}")] + InvalidNetworkSpecified { + /// Network name. + network: String, + }, + + /// Missing --network flag or testnet config flags. + #[error("Missing --network flag and no testnet config flag")] + MissingNetworkFlagAndNoTestnetConfigFlag, +} + +impl From for CreateClusterError { + fn from(error: InvalidNetworkConfigError) -> Self { + CreateClusterError::InvalidNetworkConfig(error) + } +} + +impl From for CreateClusterError { + fn from(error: pluto_eth2util::network::NetworkError) -> Self { + CreateClusterError::InvalidNetworkConfig(InvalidNetworkConfigError::InvalidNetworkName( + error, + )) + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b51d65de..65551bc0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -45,6 +45,10 @@ async fn run() -> std::result::Result<(), CliError> { match cli.command { Commands::Create(args) => match args.command { CreateCommands::Enr(args) => commands::create_enr::run(args), + CreateCommands::Cluster(args) => { + let mut stdout = std::io::stdout(); + commands::create_cluster::run(&mut stdout, *args).await + } }, Commands::Enr(args) => commands::enr::run(args), Commands::Version(args) => commands::version::run(args), diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index 2aa54b66..dfbe49b2 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -45,7 +45,7 @@ pub struct NodeIdx { /// Definition defines an intended charon cluster configuration excluding /// validators. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Definition { /// Human-readable random unique identifier. Max 64 chars. pub uuid: String, @@ -161,21 +161,49 @@ impl<'de> Deserialize<'de> for Definition { V1_5 | V1_6 | V1_7 => { let definition: DefinitionV1x5to7 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + != Ok(definition.validator_addresses.len()) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } V1_8 => { let definition: DefinitionV1x8 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + != Ok(definition.validator_addresses.len()) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } V1_9 => { let definition: DefinitionV1x9 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + != Ok(definition.validator_addresses.len()) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } V1_10 => { let definition: DefinitionV1x10 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + != Ok(definition.validator_addresses.len()) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } _ => Err(Error::custom(format!("Unsupported version: {}", version))), @@ -1037,9 +1065,11 @@ pub struct DefinitionV1x4 { pub threshold: u64, /// Fee recipient address for the /// validator. + #[serde(default)] pub fee_recipient_address: String, /// Withdrawal address for the /// validator. + #[serde(default)] pub withdrawal_address: String, /// DKG algorithm to use for key generation. Max 32 chars. pub dkg_algorithm: String, @@ -1484,6 +1514,7 @@ pub struct DefinitionV1x10 { pub target_gas_limit: u64, /// Compounding flag enables compounding rewards for validators by using /// 0x02 withdrawal credentials. + #[serde(default)] pub compounding: bool, /// Config hash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. diff --git a/crates/cluster/src/examples/cluster-definition-005.json b/crates/cluster/src/examples/cluster-definition-005.json index 61d89bbd..596bc3d0 100644 --- a/crates/cluster/src/examples/cluster-definition-005.json +++ b/crates/cluster/src/examples/cluster-definition-005.json @@ -32,7 +32,7 @@ ], "uuid": "B6AE17B3-78F5-147B-2C37-2572B93437DF", "version": "v1.8.0", - "timestamp": "2023-05-18T15:12:46+02:00", + "timestamp": "2023-05-18T15: 12: 46+02: 00", "num_validators": 2, "threshold": 3, "validators": [ diff --git a/crates/cluster/src/helpers.rs b/crates/cluster/src/helpers.rs index c9a06778..ec8fa41d 100644 --- a/crates/cluster/src/helpers.rs +++ b/crates/cluster/src/helpers.rs @@ -153,6 +153,22 @@ pub fn sign_operator( Ok(()) } +/// Returns minimum threshold required for a cluster with given nodes. +/// This formula has been taken from: +/// +/// Computes ceil(2*nodes / 3) using integer arithmetic to avoid floating point +/// conversions. +pub fn threshold(nodes: u64) -> u64 { + // Integer ceiling division: ceil(a/b) = (a + b - 1) / b + // Here we compute: ceil(2*nodes / 3) = (2*nodes + 3 - 1) / 3 = (2*nodes + 2) / + // 3 + let numerator = nodes.checked_mul(2).expect("threshold: nodes * 2 overflow"); + let adjusted = numerator + .checked_add(2) + .expect("threshold: numerator + 2 overflow"); + adjusted / 3 +} + /// Returns a BLS aggregate signature of the message signed by all the shares. pub fn agg_sign( secrets: &[Vec], diff --git a/crates/cluster/src/lock.rs b/crates/cluster/src/lock.rs index a370a738..c664abe3 100644 --- a/crates/cluster/src/lock.rs +++ b/crates/cluster/src/lock.rs @@ -23,7 +23,6 @@ use serde_with::{ serde_as, }; -const EMPTY_FEE_RECIPIENT: [u8; 20] = [0; 20]; const EMPTY_VALIDATOR_PUBKEY: pluto_eth2api::spec::phase0::BLSPubKey = [0; 48]; const EMPTY_SIGNATURE: pluto_eth2api::spec::phase0::BLSSignature = [0; 96]; @@ -145,7 +144,7 @@ type Result = std::result::Result; /// Lock extends the cluster config Definition with bls threshold public keys /// and checksums. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Lock { /// Definition is embedded and extended by Lock. pub definition: Definition, @@ -357,8 +356,12 @@ impl Lock { let fee_recipient_addresses = self.fee_recipient_addresses(); for (validator_idx, validator) in self.distributed_validators.iter().enumerate() { + // In Go, `noRegistration` checks `len == 0` (empty slice), which catches fields + // missing from JSON. The zero Ethereum address ([0;20]) is a valid + // fee_recipient (len=20 in Go, passes the check). Only BLS + // signature and pubkey can never be legitimately all-zero for a + // real registration. let no_registration = validator.builder_registration.signature == EMPTY_SIGNATURE - || validator.builder_registration.message.fee_recipient == EMPTY_FEE_RECIPIENT || validator.builder_registration.message.pub_key == EMPTY_VALIDATOR_PUBKEY; if matches!(