diff --git a/CLAUDE.md b/CLAUDE.md index faa7281e..1a6c36ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ cargo build ```bash target/debug/summit --help # Usage: summit -# Commands: run, keys, help +# Commands: run, keys, genesis, help ``` ## Test @@ -153,7 +153,6 @@ cd testnet && ./reset.sh | ----------------------------------------- | ------------ | --------------------------------------------------- | | `summit` | — | Main validator node | | `testnet` | — | Spin up 4-node local testnet (needs `reth` in PATH) | -| `genesis` | — | Generate genesis files from validator list | | `stake-and-checkpoint` | `e2e` | E2E: stake validator + checkpoint test | | `stake-and-join-with-outdated-checkpoint` | `e2e` | E2E: join with outdated checkpoint | | `withdraw-and-exit` | `e2e` | E2E: withdrawal flow test | @@ -164,14 +163,15 @@ cd testnet && ./reset.sh ## Project Layout ``` -node/ Main binary crate (summit, testnet, genesis) +node/ Main binary crate (summit, testnet) src/engine.rs Central coordinator — component lifecycle, message routing src/args.rs CLI argument parsing (clap) src/config.rs Channel sizes, timeouts, default paths src/keys.rs Key management (generate/show) + src/genesis.rs Genesis file utilities (set-validators/digest) src/test_harness/ Shared test harness for e2e tests src/tests/ Integration tests (syncer, checkpointing, execution requests) - src/bin/ Additional binaries (testnet, genesis, e2e, bench) + src/bin/ Additional binaries (testnet, e2e, bench) application/ Consensus interface — implements Simplex Automaton + Relay traits src/actor.rs Propose, verify, broadcast blocks diff --git a/README.md b/README.md index be1d4e3f..1ddcdd3d 100644 --- a/README.md +++ b/README.md @@ -47,21 +47,26 @@ cargo build --release ### 1. Generate Validator Keys ```bash -cargo run -- keys generate --key-store-path /path/to/keys +cargo run --bin summit -- keys generate --key-store-path /path/to/keys ``` ### 2. View Your Public Key ```bash -cargo run -- keys show --key-store-path /path/to/keys +cargo run --bin summit -- keys show --key-store-path /path/to/keys ``` ### 3. Configure Genesis Create a genesis file that references your EVM genesis configuration. See [example_genesis.toml](https://github.com/SeismicSystems/summit/blob/main/example_genesis.toml) for the required format. +Print the config digest that identifies the chain it founds — every validator must agree on it, and the file is checked on the way in: +```bash +cargo run --bin summit -- genesis digest /path/to/genesis.toml +``` + ### 4. Start Your Validator Ensure your EVM client is running, then: ```bash -cargo run -- run \ +cargo run --bin summit -- run \ --key-store-path /path/to/keys \ --store-path /storage/directory \ --engine-ipc-path /tmp/reth_engine_api.ipc \ diff --git a/docs/running-local-network.md b/docs/running-local-network.md index b9f917e6..38663f3a 100644 --- a/docs/running-local-network.md +++ b/docs/running-local-network.md @@ -73,7 +73,7 @@ This removes `node*/data/reth_db`, `node*/db`, and `./stores`. Keys in `testnet/ ## Running distributed -To run a fresh network on multiple systems you should install Summit on each server and then run `cargo run -- keys generate` and `cargo run -- keys show` to get the keys for each node. +To run a fresh network on multiple systems you should install Summit on each server and then run `cargo run --bin summit -- keys generate` and `cargo run --bin summit -- keys show` to get the keys for each node. You will then recreate the example_genesis.toml file to have the keys and IPs of all your nodes. diff --git a/example_genesis.toml b/example_genesis.toml index 1a99d331..541a8837 100644 --- a/example_genesis.toml +++ b/example_genesis.toml @@ -1,3 +1,12 @@ +# Example Summit genesis. `summit genesis digest ` prints a genesis file's +# config digest: the chain identity every node must agree on, derived from the +# parsed content below (see `Genesis::config_digest`). +# +# `validators[].ip_address` is the one field the digest does NOT cover. It is +# network topology, not consensus identity, so a validator that changes IP still +# belongs to the same chain. Every other field here is hashed. + +namespace = "_SUMMIT" eth_genesis_hash = "0x78ab9057bb67f95a6182969c5d755ac02802c98c0d2f0d8daeb52f4bddc60be5" leader_timeout_ms = 2000 notarization_timeout_ms = 4000 @@ -5,36 +14,43 @@ nullify_timeout_ms = 4000 activity_timeout_views = 256 skip_timeout_views = 32 max_message_size_bytes = 10485760 -namespace = "_SUMMIT" + +# Everything below is a governed protocol parameter: hashed here as the value the +# chain launches with, while the live value is chain state, changed by transaction +# through ProtocolParams.sol. The digest keeps committing to what the chain +# started with, not to what it currently runs. For the parameter ids and their +# bounds, see types/src/protocol_params.rs. validator_minimum_stake = 32000000000 validator_maximum_stake = 32000000000 blocks_per_epoch = 10000 allowed_timestamp_future_ms = 10000 +treasury_address = "0x0000000000000000000000000000000000000000" max_deposits_per_epoch = 3 max_withdrawals_per_epoch = 16 observers_per_validator = 5 minimum_validator_count = 3 +invalid_deposit_tax = 0 [[validators]] node_public_key = "1be3cb06d7cc347602421fb73838534e4b54934e28959de98906d120d0799ef2" consensus_public_key = "a6f61154ae7be4fd38cd43cf69adfd4896c57473cacb389702bb83f8adf923eecf4854c745e064c0a2db79db5674332b" -ip_address = "127.0.0.1:26600" withdrawal_credentials = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +ip_address = "127.0.0.1:26600" # not covered by the config digest (see above) [[validators]] node_public_key = "32efa16e3cd62292db529e8f4babd27724b13b397edcf2b1dbe48f416ce40f0d" consensus_public_key = "b82eaa7fbc7f9cf9d60826e5155ca8ccc46e13d87f64f7bcdcaa2972c370766b87635334bfc49b8fba7fb784e763d44e" -ip_address = "127.0.0.1:26610" withdrawal_credentials = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +ip_address = "127.0.0.1:26610" [[validators]] node_public_key = "ce9b314ac9d55d28bedf543164120eecf737380015c977eaa78d59894bbccf52" consensus_public_key = "b806a3638975b2c33ccdda9cc55231e1328a0d1f70a3dde804e473e22ec21d5703ee34264c702b971cd0f9d5892434e8" -ip_address = "127.0.0.1:26620" withdrawal_credentials = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" +ip_address = "127.0.0.1:26620" [[validators]] node_public_key = "f205c8c88d5d1753843dd0fc9810390efd00d6f752dd555c0ad4000bfcac2226" consensus_public_key = "83c50f2fa828c5e132a2ac00792ef9c892372976ff26400711b7061bdb045f0751559b4a04cfd7399555a2aed5b54f3f" -ip_address = "127.0.0.1:26630" withdrawal_credentials = "0x90F79bf6EB2c4f870365E785982E1f101E93b906" +ip_address = "127.0.0.1:26630" diff --git a/node/Cargo.toml b/node/Cargo.toml index bff822e3..3a6e5e44 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -7,10 +7,6 @@ edition.workspace = true name = "testnet" path = "src/bin/testnet.rs" -[[bin]] -name = "genesis" -path = "src/bin/genesis.rs" - [[bin]] name = "stake-and-checkpoint" path = "src/bin/stake_and_checkpoint.rs" diff --git a/node/src/args.rs b/node/src/args.rs index 3552965b..0edc8aa9 100644 --- a/node/src/args.rs +++ b/node/src/args.rs @@ -4,6 +4,7 @@ use crate::{ MESSAGE_BACKLOG, PENDING_CHANNEL, RECOVERED_CHANNEL, RESOLVER_CHANNEL, expect_key_store, }, engine::Engine, + genesis::GenesisSubCmd, keys::KeySubCmd, }; use clap::{Args, Parser, Subcommand}; @@ -80,6 +81,9 @@ pub enum Command { /// Key management utilities #[command(subcommand)] Keys(KeySubCmd), + /// Genesis file utilities + #[command(subcommand)] + Genesis(GenesisSubCmd), } #[derive(Args, Debug, Clone)] @@ -225,6 +229,8 @@ impl Command { Command::Run { flags } => self.run_node(flags), Command::Keys(cmd) => cmd.exec(), + + Command::Genesis(cmd) => cmd.exec(), } } diff --git a/node/src/bin/genesis.rs b/node/src/bin/genesis.rs deleted file mode 100644 index 5cfd9f47..00000000 --- a/node/src/bin/genesis.rs +++ /dev/null @@ -1,65 +0,0 @@ -use alloy_primitives::FixedBytes; -use clap::Parser; -use std::fs; -use summit_types::{Genesis, GenesisValidator}; - -const DEFAULT_GENESIS_FILE: &str = "./example_genesis.toml"; - -#[derive(Parser, Debug)] -struct Args { - /// input for genesis file - #[arg(short = 'i', long, default_value_t = String::from(DEFAULT_GENESIS_FILE))] - genesis_in: String, - /// output for genesis file - #[arg(short = 'o', long)] - out_dir: String, - /// Filepath with IP addresses - #[arg(short = 'v', long)] - validators_path: String, - /// Genesis hash - #[arg(short = 'g', long)] - genesis_hash: Option>, -} - -fn parse_validators( - validators_path: &String, -) -> Result, Box> { - let rdr = std::fs::File::open(validators_path)?; - let mut validators: Vec = serde_json::from_reader(rdr)?; - // NOTE: (important!) - // Sort public keys in the same order we do in summit - validators.sort_by(|a, b| { - let a_pubkey = a.node_pubkey(); - let b_pubkey = b.node_pubkey(); - a_pubkey.partial_cmp(&b_pubkey).unwrap() - }); - Ok(validators) -} - -fn main() -> Result<(), Box> { - let args = Args::parse(); - - let validators = parse_validators(&args.validators_path)?; - let node_count = validators.len() as u32; - - // Load the template into summit's canonical Genesis type rather than a - // local copy — a private struct here silently drops fields the runtime - // requires (e.g. validator_minimum_stake), producing a genesis the node - // can't load. Fill in the validators, then write it back out. - let mut genesis: Genesis = toml::from_str(&fs::read_to_string(&args.genesis_in)?)?; - if let Some(genesis_hash) = args.genesis_hash { - let hash_str = genesis_hash.to_string(); - println!("Overriding eth_genesis_hash to {hash_str}"); - genesis.eth_genesis_hash = hash_str; - } - genesis.validators = validators; - - fs::write( - format!("{}/genesis.toml", args.out_dir), - toml::to_string_pretty(&genesis)?, - )?; - println!("Updated genesis config at {}", args.out_dir); - println!("\nSetup complete for {} nodes", node_count); - - Ok(()) -} diff --git a/node/src/genesis.rs b/node/src/genesis.rs new file mode 100644 index 00000000..3f6202b1 --- /dev/null +++ b/node/src/genesis.rs @@ -0,0 +1,209 @@ +use clap::Subcommand; +use commonware_utils::from_hex_formatted; +use std::fs; +use summit_types::{Genesis, GenesisValidator}; + +/// Offline genesis-file utilities. Both subcommands go through summit's own +/// `Genesis` type, so the file a network is founded on and the digest that +/// identifies it come from the same definition a validator loads at startup. +#[derive(Subcommand, PartialEq, Eq, Debug, Clone)] +pub enum GenesisSubCmd { + /// Set the genesis validator set, replacing any the template declares. + /// + /// The validators read from JSON become the whole set: whatever the input + /// declared is discarded, never appended to. Everything else comes from the + /// input untouched — EL genesis hash, namespace, timeouts, stake bounds. + /// + /// The set is emitted sorted by node public key, because `config_digest` + /// hashes the validators in file order: their order is part of the chain + /// identity every node has to agree on. + SetValidators { + /// Genesis template to fill in + #[arg(short = 'i', long)] + genesis_in: String, + /// JSON file listing the genesis validators + #[arg(short = 'v', long)] + validators_path: String, + /// File the completed genesis is written to; stdout if omitted + #[arg(short = 'o', long)] + genesis_out: Option, + }, + /// Load a genesis file and print its config digest. + /// + /// The digest identifies the chain the file founds: it domain-separates + /// every consensus signature, and it is what a network manifest pins to + /// commit to a validator set. Output is a single 0x-prefixed 32-byte hex + /// line, so tooling can shell out for it. + /// + /// The file is loaded exactly as a starting validator loads it, so a + /// successful digest doubles as a verdict that the genesis is well formed: + /// anything this accepts a validator accepts, and anything it rejects a + /// validator would refuse to start on. + Digest { + /// Path to the summit genesis.toml + genesis_path: String, + }, +} + +impl GenesisSubCmd { + pub fn exec(&self) { + match self { + GenesisSubCmd::SetValidators { + genesis_in, + validators_path, + genesis_out, + } => { + let genesis = fill_template(&read(genesis_in), &read(validators_path)) + .unwrap_or_else(|e| fail(format!("failed to fill the genesis template: {e}"))); + let rendered = toml::to_string_pretty(&genesis) + .unwrap_or_else(|e| fail(format!("failed to render genesis: {e}"))); + + // Re-parse what we are about to emit, down the same path a + // starting validator takes. Emitting a genesis no node can load + // is the failure this command exists to prevent, so it surfaces + // here rather than at someone's boot. + Genesis::from_toml_str(&rendered) + .unwrap_or_else(|e| fail(format!("the genesis produced does not load: {e}"))); + + match genesis_out { + Some(genesis_out) => { + fs::write(genesis_out, rendered).unwrap_or_else(|e| { + fail(format!("failed to write {genesis_out}: {e}")) + }); + // Progress goes to stderr so stdout carries genesis and + // nothing else, whichever destination was chosen. + eprintln!( + "Wrote genesis for {} validators to {genesis_out}", + genesis.validator_count() + ); + } + None => print!("{rendered}"), + } + } + GenesisSubCmd::Digest { genesis_path } => { + let genesis = load(genesis_path); + println!("0x{}", commonware_utils::hex(&genesis.config_digest())); + } + } + } +} + +/// Fill `template` with the validator set from `validators_json`. +/// +/// The template is parsed into summit's canonical `Genesis` type rather than a +/// local copy of the schema: a private struct here silently drops fields the +/// runtime requires (e.g. `validator_minimum_stake`), producing a genesis the +/// node can't load. +fn fill_template( + template: &str, + validators_json: &str, +) -> Result> { + let mut genesis: Genesis = toml::from_str(template)?; + let validators: Vec = serde_json::from_str(validators_json)?; + + // Sort by decoded node key, the order `config_digest` — and so the chain + // domain every node derives — is computed over. Decoding also accepts the + // `0x`-prefixed and mixed-case spellings `Genesis` itself accepts on load. + let mut keyed = validators + .into_iter() + .map(|validator| { + let key = from_hex_formatted(&validator.node_public_key).ok_or_else(|| { + format!( + "validator node_public_key is not valid hex: {:?}", + validator.node_public_key + ) + })?; + Ok((key, validator)) + }) + .collect::, String>>()?; + keyed.sort_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs)); + + genesis.validators = keyed.into_iter().map(|(_, validator)| validator).collect(); + Ok(genesis) +} + +/// Load a genesis file, exiting non-zero with the parse/validation error on +/// stderr. This is shelled out to by tooling that pins the digest, so failures +/// must be an exit code and a message, not a panic backtrace. +fn load(genesis_path: &str) -> Genesis { + Genesis::load_from_file(genesis_path) + .unwrap_or_else(|e| fail(format!("failed to load genesis from {genesis_path}: {e}"))) +} + +fn read(path: &str) -> String { + fs::read_to_string(path).unwrap_or_else(|e| fail(format!("failed to read {path}: {e}"))) +} + +fn fail(message: String) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXAMPLE: &str = "../example_genesis.toml"; + + /// The emitter must produce a complete genesis: every template field + /// preserved, and the validator set sorted by node key whatever order it + /// arrived in — file order is what the config digest commits to. + #[test] + fn emits_complete_genesis_sorted_by_node_key() { + let example = Genesis::load_from_file(EXAMPLE).unwrap(); + + // Feed the validators back in reverse, one of them respelled with a + // `0x` prefix and upper-case digits — a spelling `Genesis` accepts. + let mut shuffled = example.validators.clone(); + shuffled.reverse(); + shuffled[0].node_public_key = format!("0x{}", shuffled[0].node_public_key.to_uppercase()); + let validators_json = serde_json::to_string(&shuffled).unwrap(); + + let built = fill_template(&read(EXAMPLE), &validators_json).unwrap(); + + // Template fields the validator set doesn't carry must survive. + assert_eq!(built.namespace, example.namespace); + assert_eq!(built.eth_genesis_hash, example.eth_genesis_hash); + assert_eq!( + built.validator_minimum_stake, + example.validator_minimum_stake + ); + assert_eq!(built.blocks_per_epoch, example.blocks_per_epoch); + + let keys: Vec> = built + .validators + .iter() + .map(|v| from_hex_formatted(&v.node_public_key).unwrap()) + .collect(); + assert_eq!(keys.len(), example.validators.len()); + assert!( + keys.windows(2).all(|pair| pair[0] < pair[1]), + "emitted validators must be ascending by node key" + ); + } + + /// What the emitter writes must load through summit's own loader and keep + /// the chain identity intact — the guard against emitting a genesis that + /// parses here but not in the node, or that quietly founds a different chain. + #[test] + fn emitted_genesis_loads_back_with_the_same_identity() { + let example = Genesis::load_from_file(EXAMPLE).unwrap(); + let validators_json = serde_json::to_string(&example.validators).unwrap(); + let built = fill_template(&read(EXAMPLE), &validators_json).unwrap(); + + let rendered = toml::to_string_pretty(&built).unwrap(); + let loaded = Genesis::from_toml_str(&rendered).unwrap(); + + assert_eq!(loaded.validator_count(), example.validator_count()); + assert_eq!(loaded.config_digest(), example.config_digest()); + } + + #[test] + fn rejects_a_validator_whose_node_key_is_not_hex() { + let example = Genesis::load_from_file(EXAMPLE).unwrap(); + let mut validators = example.validators.clone(); + validators[0].node_public_key = "not-a-key".into(); + let validators_json = serde_json::to_string(&validators).unwrap(); + assert!(fill_template(&read(EXAMPLE), &validators_json).is_err()); + } +} diff --git a/node/src/lib.rs b/node/src/lib.rs index 45b23301..6da97131 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -1,6 +1,7 @@ pub mod args; pub mod config; pub mod engine; +mod genesis; mod keys; mod nat; pub mod telemetry; diff --git a/types/src/genesis.rs b/types/src/genesis.rs index 69e799b6..e287f1e6 100644 --- a/types/src/genesis.rs +++ b/types/src/genesis.rs @@ -216,7 +216,16 @@ impl Genesis { pub fn load_from_file(path: &str) -> Result> { let file_string = std::fs::read_to_string(path)?; - let genesis: Genesis = toml::from_str(&file_string)?; + Self::from_toml_str(&file_string) + } + + /// Parse and validate a genesis from TOML text: the same path + /// [`load_from_file`](Self::load_from_file) takes once the bytes are read, + /// so a genesis accepted here is one a starting validator accepts. Lets a + /// tool that produces genesis bytes check them before they reach a disk or + /// a node. + pub fn from_toml_str(text: &str) -> Result> { + let genesis: Genesis = toml::from_str(text)?; genesis.validate()?; Ok(genesis) }