diff --git a/.github/workflows/ev_deployer.yml b/.github/workflows/ev_deployer.yml new file mode 100644 index 00000000..ef57c12a --- /dev/null +++ b/.github/workflows/ev_deployer.yml @@ -0,0 +1,79 @@ +name: EV Deployer CI + +on: + push: + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/ev_deployer.yml' + - 'contracts/src/**' + - 'contracts/foundry.toml' + - 'bin/ev-deployer/**' + pull_request: + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/ev_deployer.yml' + - 'contracts/src/**' + - 'contracts/foundry.toml' + - 'bin/ev-deployer/**' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + verify-bytecodes: + name: EV Deployer bytecode verification + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Run bytecode verification tests + run: cargo test -p ev-deployer -- --ignored --test-threads=1 + + unit-tests: + name: EV Deployer unit tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Run unit tests + run: cargo test -p ev-deployer + + e2e-genesis: + name: EV Deployer e2e genesis test + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Run e2e genesis test + run: bash bin/ev-deployer/tests/e2e_genesis.sh diff --git a/Cargo.lock b/Cargo.lock index e66fd482..ef8c2de4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2917,6 +2917,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "ev-deployer" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "clap", + "eyre", + "serde", + "serde_json", + "tempfile", + "toml 0.8.23", +] + [[package]] name = "ev-dev" version = "0.1.0" @@ -5848,7 +5861,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.5+spec-1.1.0", ] [[package]] @@ -6595,7 +6608,7 @@ dependencies = [ "tar", "tokio", "tokio-stream", - "toml", + "toml 0.9.12+spec-1.1.0", "tracing", "url", "zstd", @@ -6673,7 +6686,7 @@ dependencies = [ "reth-stages-types", "reth-static-file-types", "serde", - "toml", + "toml 0.9.12+spec-1.1.0", "url", ] @@ -8004,7 +8017,7 @@ dependencies = [ "shellexpand", "strum", "thiserror 2.0.18", - "toml", + "toml 0.9.12+spec-1.1.0", "tracing", "url", "vergen", @@ -9804,6 +9817,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -10460,6 +10482,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -10468,13 +10502,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned", + "serde_spanned 1.0.4", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -10493,6 +10536,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.5+spec-1.1.0" @@ -10514,6 +10571,12 @@ dependencies = [ "winnow 1.0.0", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.7+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3a3d8245..70c3486e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "bin/ev-deployer", "bin/ev-dev", "bin/ev-reth", "crates/common", diff --git a/bin/ev-deployer/Cargo.toml b/bin/ev-deployer/Cargo.toml new file mode 100644 index 00000000..b80d21a8 --- /dev/null +++ b/bin/ev-deployer/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ev-deployer" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +alloy-primitives = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive", "env"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +toml = "0.8" +eyre = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/bin/ev-deployer/README.md b/bin/ev-deployer/README.md new file mode 100644 index 00000000..2e659459 --- /dev/null +++ b/bin/ev-deployer/README.md @@ -0,0 +1,113 @@ +# EV Deployer + +CLI tool for generating genesis alloc entries for ev-reth contracts. It reads a declarative TOML config and produces the JSON needed to embed contracts into a chain's genesis state. + +## Building + +```bash +just build-deployer +``` + +The binary is output to `target/release/ev-deployer`. + +## Configuration + +EV Deployer uses a TOML config file to define what contracts to include and how to configure them. See [`examples/devnet.toml`](examples/devnet.toml) for a complete example. + +```toml +[chain] +chain_id = 1234 + +[contracts.admin_proxy] +address = "0x000000000000000000000000000000000000Ad00" +owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +``` + +### Config reference + +#### `[chain]` + +| Field | Type | Description | +|------------|------|-------------| +| `chain_id` | u64 | Chain ID | + +#### `[contracts.admin_proxy]` + +| Field | Type | Description | +|-----------|---------|---------------------------| +| `address` | address | Address to deploy at | +| `owner` | address | Owner (must not be zero) | + +## Usage + +### Generate a starter config + +```bash +ev-deployer init --output deploy.toml +``` + +This creates a TOML config template with all supported contracts commented out and documented. + +### Generate genesis alloc + +Print alloc JSON to stdout: + +```bash +ev-deployer genesis --config deploy.toml +``` + +Write to a file: + +```bash +ev-deployer genesis --config deploy.toml --output alloc.json +``` + +### Merge into an existing genesis file + +Insert the generated entries into an existing `genesis.json`. The merged result is written to `--output` (or stdout if `--output` is omitted): + +```bash +ev-deployer genesis --config deploy.toml --merge-into genesis.json --output genesis-out.json +``` + +If an address already exists in the genesis, the command fails. Use `--force` to overwrite: + +```bash +ev-deployer genesis --config deploy.toml --merge-into genesis.json --output genesis-out.json --force +``` + +### Export address manifest + +Write a JSON mapping of contract names to their configured addresses: + +```bash +ev-deployer genesis --config deploy.toml --addresses-out addresses.json +``` + +Output: + +```json +{ + "admin_proxy": "0x000000000000000000000000000000000000Ad00" +} +``` + +### Look up a contract address + +```bash +ev-deployer compute-address --config deploy.toml --contract admin_proxy +``` + +## Contracts + +| Contract | Description | +|----------------|-----------------------------------------------------| +| `admin_proxy` | Proxy contract with owner-based access control | + +Runtime bytecodes are embedded in the binary — no external toolchain is needed at deploy time. + +## Testing + +```bash +just test-deployer +``` diff --git a/bin/ev-deployer/examples/devnet.toml b/bin/ev-deployer/examples/devnet.toml new file mode 100644 index 00000000..c0201807 --- /dev/null +++ b/bin/ev-deployer/examples/devnet.toml @@ -0,0 +1,6 @@ +[chain] +chain_id = 1234 + +[contracts.admin_proxy] +address = "0x000000000000000000000000000000000000Ad00" +owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" diff --git a/bin/ev-deployer/src/config.rs b/bin/ev-deployer/src/config.rs new file mode 100644 index 00000000..b4794b4d --- /dev/null +++ b/bin/ev-deployer/src/config.rs @@ -0,0 +1,123 @@ +//! TOML config types, parsing, and validation. + +use alloy_primitives::Address; +use serde::Deserialize; +use std::path::Path; + +/// Top-level deploy configuration. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct DeployConfig { + /// Chain configuration. + pub chain: ChainConfig, + /// Contract configurations. + #[serde(default)] + pub contracts: ContractsConfig, +} + +/// Chain-level settings. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct ChainConfig { + /// The chain ID. + pub chain_id: u64, +} + +/// All contract configurations. +#[derive(Debug, Deserialize, Default)] +pub(crate) struct ContractsConfig { + /// `AdminProxy` contract config (optional). + pub admin_proxy: Option, +} + +/// `AdminProxy` configuration. +#[derive(Debug, Deserialize)] +pub(crate) struct AdminProxyConfig { + /// Address to deploy at. + pub address: Address, + /// Owner address. + pub owner: Address, +} + +impl DeployConfig { + /// Load and validate config from a TOML file. + pub(crate) fn load(path: &Path) -> eyre::Result { + let content = std::fs::read_to_string(path)?; + let config: Self = toml::from_str(&content)?; + config.validate()?; + Ok(config) + } + + /// Validate config values. + fn validate(&self) -> eyre::Result<()> { + if let Some(ref ap) = self.contracts.admin_proxy { + eyre::ensure!( + !ap.owner.is_zero(), + "admin_proxy.owner must not be the zero address" + ); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_config() { + let toml = r#" +[chain] +chain_id = 1234 + +[contracts.admin_proxy] +address = "0x000000000000000000000000000000000000Ad00" +owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +"#; + let config: DeployConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.chain.chain_id, 1234); + assert!(config.contracts.admin_proxy.is_some()); + config.validate().unwrap(); + } + + #[test] + fn reject_zero_owner() { + let toml = r#" +[chain] +chain_id = 1 + +[contracts.admin_proxy] +address = "0x000000000000000000000000000000000000Ad00" +owner = "0x0000000000000000000000000000000000000000" +"#; + let config: DeployConfig = toml::from_str(toml).unwrap(); + assert!(config.validate().is_err()); + } + + #[test] + fn no_contracts_section() { + let toml = r#" +[chain] +chain_id = 1 +"#; + let config: DeployConfig = toml::from_str(toml).unwrap(); + config.validate().unwrap(); + assert!(config.contracts.admin_proxy.is_none()); + } + + #[test] + fn admin_proxy_only() { + let toml = r#" +[chain] +chain_id = 1 + +[contracts.admin_proxy] +address = "0x000000000000000000000000000000000000Ad00" +owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +"#; + let config: DeployConfig = toml::from_str(toml).unwrap(); + config.validate().unwrap(); + assert!(config.contracts.admin_proxy.is_some()); + } +} diff --git a/bin/ev-deployer/src/contracts/admin_proxy.rs b/bin/ev-deployer/src/contracts/admin_proxy.rs new file mode 100644 index 00000000..ed187b12 --- /dev/null +++ b/bin/ev-deployer/src/contracts/admin_proxy.rs @@ -0,0 +1,81 @@ +//! `AdminProxy` bytecode and storage encoding. + +use crate::{config::AdminProxyConfig, contracts::GenesisContract}; +use alloy_primitives::{hex, Bytes, B256, U256}; +use std::collections::BTreeMap; + +/// `AdminProxy` runtime bytecode compiled with solc 0.8.33 (`cbor_metadata=false`). +/// Regenerate with: `cd contracts && forge inspect AdminProxy deployedBytecode` +const ADMIN_PROXY_BYTECODE: &[u8] = &hex!("60806040526004361061007e575f3560e01c80638da5cb5b1161004d5780638da5cb5b1461012d578063e30c397814610157578063f2fde38b14610181578063fa4bb79d146101a957610085565b806318dfb3c7146100895780631cff79cd146100c557806379ba5097146101015780638b5298541461011757610085565b3661008557005b5f5ffd5b348015610094575f5ffd5b506100af60048036038101906100aa9190610cf8565b6101e5565b6040516100bc9190610ea1565b60405180910390f35b3480156100d0575f5ffd5b506100eb60048036038101906100e69190610f70565b6104d9565b6040516100f89190611015565b60405180910390f35b34801561010c575f5ffd5b5061011561066c565b005b348015610122575f5ffd5b5061012b6107ed565b005b348015610138575f5ffd5b506101416108b4565b60405161014e9190611044565b60405180910390f35b348015610162575f5ffd5b5061016b6108d8565b6040516101789190611044565b60405180910390f35b34801561018c575f5ffd5b506101a760048036038101906101a2919061105d565b6108fd565b005b3480156101b4575f5ffd5b506101cf60048036038101906101ca91906110bb565b610aa4565b6040516101dc9190611015565b60405180910390f35b60605f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161461026c576040517f30cd747100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8282905085859050146102ab576040517fff633a3800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8484905067ffffffffffffffff8111156102c8576102c761112c565b5b6040519080825280602002602001820160405280156102fb57816020015b60608152602001906001900390816102e65790505b5090505f5f90505b858590508110156104d0575f5f87878481811061032357610322611159565b5b9050602002016020810190610338919061105d565b73ffffffffffffffffffffffffffffffffffffffff1686868581811061036157610360611159565b5b90506020028101906103739190611192565b604051610381929190611230565b5f604051808303815f865af19150503d805f81146103ba576040519150601f19603f3d011682016040523d82523d5f602084013e6103bf565b606091505b50915091508161040657806040517fa5fa8d2b0000000000000000000000000000000000000000000000000000000081526004016103fd9190611015565b60405180910390fd5b87878481811061041957610418611159565b5b905060200201602081019061042e919061105d565b73ffffffffffffffffffffffffffffffffffffffff167fc96720f35dd524e76ea92971ce13d08e9a17816bf3b0008a7083e6032354ebb587878681811061047857610477611159565b5b905060200281019061048a9190611192565b8460405161049a93929190611274565b60405180910390a2808484815181106104b6576104b5611159565b5b602002602001018190525050508080600101915050610303565b50949350505050565b60605f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610560576040517f30cd747100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f5f8573ffffffffffffffffffffffffffffffffffffffff168585604051610589929190611230565b5f604051808303815f865af19150503d805f81146105c2576040519150601f19603f3d011682016040523d82523d5f602084013e6105c7565b606091505b50915091508161060e57806040517fa5fa8d2b0000000000000000000000000000000000000000000000000000000081526004016106059190611015565b60405180910390fd5b8573ffffffffffffffffffffffffffffffffffffffff167fc96720f35dd524e76ea92971ce13d08e9a17816bf3b0008a7083e6032354ebb586868460405161065893929190611274565b60405180910390a280925050509392505050565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146106f2576040517f1853971c00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b3373ffffffffffffffffffffffffffffffffffffffff165f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e060405160405180910390a3335f5f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f60015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550565b5f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610872576040517f30cd747100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f60015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550565b5f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610982576040517f30cd747100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f73ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036109e7576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508073ffffffffffffffffffffffffffffffffffffffff165f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b60605f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610b2b576040517f30cd747100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f5f8673ffffffffffffffffffffffffffffffffffffffff16848787604051610b55929190611230565b5f6040518083038185875af1925050503d805f8114610b8f576040519150601f19603f3d011682016040523d82523d5f602084013e610b94565b606091505b509150915081610bdb57806040517fa5fa8d2b000000000000000000000000000000000000000000000000000000008152600401610bd29190611015565b60405180910390fd5b8673ffffffffffffffffffffffffffffffffffffffff167fc96720f35dd524e76ea92971ce13d08e9a17816bf3b0008a7083e6032354ebb5878784604051610c2593929190611274565b60405180910390a28092505050949350505050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f840112610c6357610c62610c42565b5b8235905067ffffffffffffffff811115610c8057610c7f610c46565b5b602083019150836020820283011115610c9c57610c9b610c4a565b5b9250929050565b5f5f83601f840112610cb857610cb7610c42565b5b8235905067ffffffffffffffff811115610cd557610cd4610c46565b5b602083019150836020820283011115610cf157610cf0610c4a565b5b9250929050565b5f5f5f5f60408587031215610d1057610d0f610c3a565b5b5f85013567ffffffffffffffff811115610d2d57610d2c610c3e565b5b610d3987828801610c4e565b9450945050602085013567ffffffffffffffff811115610d5c57610d5b610c3e565b5b610d6887828801610ca3565b925092505092959194509250565b5f81519050919050565b5f82825260208201905092915050565b5f819050602082019050919050565b5f81519050919050565b5f82825260208201905092915050565b8281835e5f83830152505050565b5f601f19601f8301169050919050565b5f610de182610d9f565b610deb8185610da9565b9350610dfb818560208601610db9565b610e0481610dc7565b840191505092915050565b5f610e1a8383610dd7565b905092915050565b5f602082019050919050565b5f610e3882610d76565b610e428185610d80565b935083602082028501610e5485610d90565b805f5b85811015610e8f5784840389528151610e708582610e0f565b9450610e7b83610e22565b925060208a01995050600181019050610e57565b50829750879550505050505092915050565b5f6020820190508181035f830152610eb98184610e2e565b905092915050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610eea82610ec1565b9050919050565b610efa81610ee0565b8114610f04575f5ffd5b50565b5f81359050610f1581610ef1565b92915050565b5f5f83601f840112610f3057610f2f610c42565b5b8235905067ffffffffffffffff811115610f4d57610f4c610c46565b5b602083019150836001820283011115610f6957610f68610c4a565b5b9250929050565b5f5f5f60408486031215610f8757610f86610c3a565b5b5f610f9486828701610f07565b935050602084013567ffffffffffffffff811115610fb557610fb4610c3e565b5b610fc186828701610f1b565b92509250509250925092565b5f82825260208201905092915050565b5f610fe782610d9f565b610ff18185610fcd565b9350611001818560208601610db9565b61100a81610dc7565b840191505092915050565b5f6020820190508181035f83015261102d8184610fdd565b905092915050565b61103e81610ee0565b82525050565b5f6020820190506110575f830184611035565b92915050565b5f6020828403121561107257611071610c3a565b5b5f61107f84828501610f07565b91505092915050565b5f819050919050565b61109a81611088565b81146110a4575f5ffd5b50565b5f813590506110b581611091565b92915050565b5f5f5f5f606085870312156110d3576110d2610c3a565b5b5f6110e087828801610f07565b945050602085013567ffffffffffffffff81111561110157611100610c3e565b5b61110d87828801610f1b565b93509350506040611120878288016110a7565b91505092959194509250565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5f833560016020038436030381126111ae576111ad611186565b5b80840192508235915067ffffffffffffffff8211156111d0576111cf61118a565b5b6020830192506001820236038313156111ec576111eb61118e565b5b509250929050565b5f81905092915050565b828183375f83830152505050565b5f61121783856111f4565b93506112248385846111fe565b82840190509392505050565b5f61123c82848661120c565b91508190509392505050565b5f6112538385610fcd565b93506112608385846111fe565b61126983610dc7565b840190509392505050565b5f6040820190508181035f83015261128d818587611248565b905081810360208301526112a18184610fdd565b905094935050505056"); + +/// Build a genesis alloc entry for `AdminProxy`. +pub(crate) fn build(config: &AdminProxyConfig) -> GenesisContract { + let mut storage = BTreeMap::new(); + + // Slot 0: owner (address left-padded to 32 bytes) + let owner_value = B256::from(U256::from_be_bytes(config.owner.into_word().0)); + storage.insert(B256::ZERO, owner_value); + + GenesisContract { + address: config.address, + code: Bytes::from_static(ADMIN_PROXY_BYTECODE), + storage, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + use std::{path::PathBuf, process::Command}; + + #[test] + fn golden_admin_proxy_storage() { + let config = AdminProxyConfig { + address: address!("000000000000000000000000000000000000Ad00"), + owner: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + }; + let contract = build(&config); + + let expected_slot0: B256 = + "0x000000000000000000000000f39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + .parse() + .unwrap(); + assert_eq!(contract.storage[&B256::ZERO], expected_slot0); + } + + #[test] + #[ignore = "requires forge CLI"] + fn admin_proxy_bytecode_matches_solidity_source() { + let contracts_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .join("contracts"); + + let output = Command::new("forge") + .args(["inspect", "AdminProxy", "deployedBytecode", "--root"]) + .arg(&contracts_root) + .output() + .expect("forge not found"); + + assert!( + output.status.success(), + "forge inspect failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let forge_hex = String::from_utf8(output.stdout) + .unwrap() + .trim() + .trim_start_matches("0x") + .to_lowercase(); + + let hardcoded_hex = hex::encode(ADMIN_PROXY_BYTECODE); + + assert_eq!( + forge_hex, hardcoded_hex, + "AdminProxy bytecode mismatch! Update the constant with: cd contracts && forge inspect AdminProxy deployedBytecode" + ); + } +} diff --git a/bin/ev-deployer/src/contracts/mod.rs b/bin/ev-deployer/src/contracts/mod.rs new file mode 100644 index 00000000..569e4510 --- /dev/null +++ b/bin/ev-deployer/src/contracts/mod.rs @@ -0,0 +1,16 @@ +//! Contract bytecode and storage encoding. + +pub(crate) mod admin_proxy; + +use alloy_primitives::{Address, Bytes, B256}; +use std::collections::BTreeMap; + +/// A contract ready to be placed in genesis alloc. +pub(crate) struct GenesisContract { + /// The address to deploy at. + pub address: Address, + /// Runtime bytecode. + pub code: Bytes, + /// Storage slot values. + pub storage: BTreeMap, +} diff --git a/bin/ev-deployer/src/genesis.rs b/bin/ev-deployer/src/genesis.rs new file mode 100644 index 00000000..167499bb --- /dev/null +++ b/bin/ev-deployer/src/genesis.rs @@ -0,0 +1,204 @@ +//! Genesis alloc JSON builder. + +use crate::{ + config::DeployConfig, + contracts::{self, GenesisContract}, +}; +use alloy_primitives::B256; +use serde_json::{Map, Value}; +use std::path::Path; + +/// Build the alloc JSON from config. +pub(crate) fn build_alloc(config: &DeployConfig) -> Value { + let mut alloc = Map::new(); + + if let Some(ref ap_config) = config.contracts.admin_proxy { + let contract = contracts::admin_proxy::build(ap_config); + insert_contract(&mut alloc, &contract); + } + + Value::Object(alloc) +} + +/// Build alloc and merge into an existing genesis JSON file. +pub(crate) fn merge_into( + config: &DeployConfig, + genesis_path: &Path, + force: bool, +) -> eyre::Result { + let content = std::fs::read_to_string(genesis_path)?; + let mut genesis: Value = serde_json::from_str(&content)?; + + let alloc = build_alloc(config); + + let genesis_alloc = genesis + .get_mut("alloc") + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| eyre::eyre!("genesis JSON missing 'alloc' object"))?; + + let new_alloc = alloc.as_object().unwrap(); + for (addr, entry) in new_alloc { + let canonical = normalize_addr(addr); + let existing_key = genesis_alloc + .keys() + .find(|k| normalize_addr(k) == canonical) + .cloned(); + if existing_key.is_some() && !force { + eyre::bail!("address collision at {addr}; use --force to overwrite"); + } + if let Some(key) = existing_key { + genesis_alloc.remove(&key); + } + genesis_alloc.insert(canonical, entry.clone()); + } + + Ok(genesis) +} + +fn normalize_addr(addr: &str) -> String { + addr.strip_prefix("0x").unwrap_or(addr).to_lowercase() +} + +fn insert_contract(alloc: &mut Map, contract: &GenesisContract) { + let addr_key = normalize_addr(&format!("{}", contract.address)); + + let mut storage_map = Map::new(); + for (slot, value) in &contract.storage { + let slot_key = format_slot_key(slot); + storage_map.insert(slot_key, Value::String(format!("{value}"))); + } + + let mut entry = Map::new(); + entry.insert("balance".to_string(), Value::String("0x0".to_string())); + entry.insert( + "code".to_string(), + Value::String(format!( + "0x{}", + alloy_primitives::hex::encode(&contract.code) + )), + ); + entry.insert("storage".to_string(), Value::Object(storage_map)); + + alloc.insert(addr_key, Value::Object(entry)); +} + +/// Format a storage slot key as a full 32-byte hex string. +/// `B256::ZERO` -> "0x0000000000000000000000000000000000000000000000000000000000000000" +fn format_slot_key(slot: &B256) -> String { + format!("{slot}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::*; + use alloy_primitives::address; + + fn test_config() -> DeployConfig { + DeployConfig { + chain: ChainConfig { chain_id: 1234 }, + contracts: ContractsConfig { + admin_proxy: Some(AdminProxyConfig { + address: address!("000000000000000000000000000000000000ad00"), + owner: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + }), + }, + } + } + + #[test] + fn alloc_json_structure() { + let alloc = build_alloc(&test_config()); + let obj = alloc.as_object().unwrap(); + assert!(obj.contains_key("000000000000000000000000000000000000ad00")); + + let entry = obj + .get("000000000000000000000000000000000000ad00") + .unwrap() + .as_object() + .unwrap(); + assert_eq!(entry["balance"], "0x0"); + assert!(entry["code"].as_str().unwrap().starts_with("0x")); + assert!(entry.contains_key("storage")); + } + + #[test] + fn alloc_golden_value() { + let alloc = build_alloc(&test_config()); + let storage = alloc + .as_object() + .unwrap() + .get("000000000000000000000000000000000000ad00") + .unwrap() + .get("storage") + .unwrap() + .as_object() + .unwrap(); + + assert_eq!( + storage["0x0000000000000000000000000000000000000000000000000000000000000000"], + "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ); + } + + #[test] + fn slot_key_formatting() { + assert_eq!( + format_slot_key(&B256::ZERO), + "0x0000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!( + format_slot_key(&B256::with_last_byte(1)), + "0x0000000000000000000000000000000000000000000000000000000000000001" + ); + assert_eq!( + format_slot_key(&B256::with_last_byte(6)), + "0x0000000000000000000000000000000000000000000000000000000000000006" + ); + } + + #[test] + fn merge_detects_collision() { + let genesis = r#"{"alloc":{"000000000000000000000000000000000000ad00":{"balance":"0x0"}}}"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), genesis).unwrap(); + + let result = merge_into(&test_config(), tmp.path(), false); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("address collision")); + } + + #[test] + fn merge_force_overwrites() { + let genesis = r#"{"alloc":{"000000000000000000000000000000000000ad00":{"balance":"0x0"}}}"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), genesis).unwrap(); + + let result = merge_into(&test_config(), tmp.path(), true); + assert!(result.is_ok()); + } + + #[test] + fn merge_detects_collision_with_0x_prefix() { + let genesis = + r#"{"alloc":{"0x000000000000000000000000000000000000ad00":{"balance":"0x0"}}}"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), genesis).unwrap(); + + let result = merge_into(&test_config(), tmp.path(), false); + assert!(result.is_err()); + } + + #[test] + fn merge_detects_collision_with_mixed_case() { + let genesis = r#"{"alloc":{"000000000000000000000000000000000000AD00":{"balance":"0x0"}}}"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), genesis).unwrap(); + + let result = merge_into(&test_config(), tmp.path(), false); + assert!(result.is_err()); + } +} diff --git a/bin/ev-deployer/src/init_template.toml b/bin/ev-deployer/src/init_template.toml new file mode 100644 index 00000000..d147156f --- /dev/null +++ b/bin/ev-deployer/src/init_template.toml @@ -0,0 +1,16 @@ +# EV Deployer configuration +# See: bin/ev-deployer/README.md + +[chain] +# The chain ID for the target network. +chain_id = 0 + +# ── Contracts ──────────────────────────────────────────── +# Uncomment and configure the contracts you want to include +# in the genesis alloc. + +# AdminProxy: transparent proxy with owner-based access control. +# The owner address is stored in slot 0. +# [contracts.admin_proxy] +# address = "0x000000000000000000000000000000000000Ad00" +# owner = "0x..." diff --git a/bin/ev-deployer/src/main.rs b/bin/ev-deployer/src/main.rs new file mode 100644 index 00000000..78b88ec4 --- /dev/null +++ b/bin/ev-deployer/src/main.rs @@ -0,0 +1,130 @@ +//! EV Deployer — genesis alloc generator for ev-reth contracts. + +mod config; +mod contracts; +mod genesis; +mod output; + +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +/// EV Deployer: generate genesis alloc entries for ev-reth contracts. +#[derive(Parser)] +#[command( + name = "ev-deployer", + about = "Generate genesis alloc for ev-reth contracts" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Generate a starter config file with all supported contracts commented out. + Init { + /// Write config to this file instead of stdout. + #[arg(long)] + output: Option, + }, + /// Generate genesis alloc JSON from a deploy config. + Genesis { + /// Path to the deploy TOML config. + #[arg(long)] + config: PathBuf, + + /// Write alloc JSON to this file instead of stdout. + #[arg(long)] + output: Option, + + /// Merge alloc entries into an existing genesis JSON file. + #[arg(long)] + merge_into: Option, + + /// Allow overwriting existing addresses when merging. + #[arg(long, default_value_t = false)] + force: bool, + + /// Write an address manifest to this file. + #[arg(long)] + addresses_out: Option, + }, + /// Compute the address for a configured contract. + ComputeAddress { + /// Path to the deploy TOML config. + #[arg(long)] + config: PathBuf, + + /// Contract name (e.g. `admin_proxy`). + #[arg(long)] + contract: String, + }, +} + +fn main() -> eyre::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Command::Genesis { + config: config_path, + output, + merge_into, + force, + addresses_out, + } => { + let cfg = config::DeployConfig::load(&config_path)?; + + let result = if let Some(ref genesis_path) = merge_into { + genesis::merge_into(&cfg, genesis_path, force)? + } else { + genesis::build_alloc(&cfg) + }; + + let json = serde_json::to_string_pretty(&result)?; + + if let Some(ref out_path) = output { + std::fs::write(out_path, &json)?; + eprintln!("Wrote alloc to {}", out_path.display()); + } else { + println!("{json}"); + } + + if let Some(ref addr_path) = addresses_out { + let manifest = output::build_manifest(&cfg); + let manifest_json = serde_json::to_string_pretty(&manifest)?; + std::fs::write(addr_path, &manifest_json)?; + eprintln!("Wrote address manifest to {}", addr_path.display()); + } + } + Command::Init { output } => { + let template = include_str!("init_template.toml"); + + if let Some(ref out_path) = output { + std::fs::write(out_path, template)?; + eprintln!("Wrote config to {}", out_path.display()); + } else { + print!("{template}"); + } + } + Command::ComputeAddress { + config: config_path, + contract, + } => { + let cfg = config::DeployConfig::load(&config_path)?; + + let address = match contract.as_str() { + "admin_proxy" => cfg + .contracts + .admin_proxy + .as_ref() + .map(|c| c.address) + .ok_or_else(|| eyre::eyre!("admin_proxy not configured"))?, + other => eyre::bail!("unknown contract: {other}"), + }; + + println!("{address}"); + } + } + + Ok(()) +} diff --git a/bin/ev-deployer/src/output.rs b/bin/ev-deployer/src/output.rs new file mode 100644 index 00000000..b30e373c --- /dev/null +++ b/bin/ev-deployer/src/output.rs @@ -0,0 +1,18 @@ +//! Address manifest output. + +use crate::config::DeployConfig; +use serde_json::{Map, Value}; + +/// Build an address manifest JSON from config. +pub(crate) fn build_manifest(config: &DeployConfig) -> Value { + let mut manifest = Map::new(); + + if let Some(ref ap) = config.contracts.admin_proxy { + manifest.insert( + "admin_proxy".to_string(), + Value::String(format!("{}", ap.address)), + ); + } + + Value::Object(manifest) +} diff --git a/bin/ev-deployer/tests/e2e_genesis.sh b/bin/ev-deployer/tests/e2e_genesis.sh new file mode 100755 index 00000000..c1bee05d --- /dev/null +++ b/bin/ev-deployer/tests/e2e_genesis.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# End-to-end test: generate genesis with ev-deployer, boot ev-reth, verify contracts via RPC. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +DEPLOYER="$REPO_ROOT/target/release/ev-deployer" +EV_RETH="$REPO_ROOT/target/release/ev-reth" +CONFIG="$REPO_ROOT/bin/ev-deployer/examples/devnet.toml" +BASE_GENESIS="$REPO_ROOT/bin/ev-dev/assets/devnet-genesis.json" + +RPC_PORT=18545 +RPC_URL="http://127.0.0.1:$RPC_PORT" +NODE_PID="" +TMPDIR_PATH="" + +cleanup() { + if [[ -n "$NODE_PID" ]]; then + kill "$NODE_PID" 2>/dev/null || true + wait "$NODE_PID" 2>/dev/null || true + fi + if [[ -n "$TMPDIR_PATH" ]]; then + rm -rf "$TMPDIR_PATH" + fi +} +trap cleanup EXIT + +# ── Helpers ────────────────────────────────────────────── + +fail() { echo "FAIL: $1" >&2; exit 1; } +pass() { echo "PASS: $1"; } + +rpc_call() { + local method="$1" + local params="$2" + curl -s --connect-timeout 5 --max-time 10 -X POST "$RPC_URL" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['result'])" +} + +wait_for_rpc() { + local max_attempts=30 + for i in $(seq 1 $max_attempts); do + if curl -s --connect-timeout 1 --max-time 2 -X POST "$RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + 2>/dev/null | grep -q result; then + return 0 + fi + sleep 1 + done + fail "node did not become ready after ${max_attempts}s" +} + +# ── Step 1: Build ──────────────────────────────────────── + +echo "=== Building ev-deployer and ev-reth ===" +cargo build --release --bin ev-deployer --bin ev-reth --manifest-path "$REPO_ROOT/Cargo.toml" \ + 2>&1 | tail -3 + +[[ -x "$DEPLOYER" ]] || fail "ev-deployer binary not found" +[[ -x "$EV_RETH" ]] || fail "ev-reth binary not found" + +# ── Step 2: Generate genesis ───────────────────────────── + +TMPDIR_PATH="$(mktemp -d)" +GENESIS="$TMPDIR_PATH/genesis.json" +DATADIR="$TMPDIR_PATH/data" + +echo "=== Generating genesis with ev-deployer ===" +"$DEPLOYER" genesis \ + --config "$CONFIG" \ + --merge-into "$BASE_GENESIS" \ + --output "$GENESIS" \ + --force + +echo "Genesis written to $GENESIS" + +# Quick sanity: address should be in the alloc +grep -q "000000000000000000000000000000000000Ad00" "$GENESIS" \ + || fail "AdminProxy address not found in genesis" + +pass "genesis contains AdminProxy address" + +# ── Step 3: Start ev-reth ──────────────────────────────── + +echo "=== Starting ev-reth node ===" +"$EV_RETH" node \ + --dev \ + --chain "$GENESIS" \ + --datadir "$DATADIR" \ + --http \ + --http.addr 127.0.0.1 \ + --http.port "$RPC_PORT" \ + --http.api eth,net,web3 \ + --disable-discovery \ + --no-persist-peers \ + --port 0 \ + --log.stdout.filter error \ + & +NODE_PID=$! + +echo "Node PID: $NODE_PID, waiting for RPC..." +wait_for_rpc +pass "node is up and responding to RPC" + +# ── Step 4: Verify AdminProxy ──────────────────────────── + +ADMIN_PROXY="0x000000000000000000000000000000000000Ad00" +ADMIN_OWNER="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + +echo "=== Verifying AdminProxy at $ADMIN_PROXY ===" + +# Check code is present +admin_code=$(rpc_call "eth_getCode" "[\"$ADMIN_PROXY\", \"latest\"]") +[[ "$admin_code" != "0x" && "$admin_code" != "0x0" && ${#admin_code} -gt 10 ]] \ + || fail "AdminProxy has no bytecode (got: $admin_code)" +pass "AdminProxy has bytecode (${#admin_code} hex chars)" + +# Check owner in slot 0 +admin_slot0=$(rpc_call "eth_getStorageAt" "[\"$ADMIN_PROXY\", \"0x0\", \"latest\"]") +# Owner should be in the lower 20 bytes, left-padded to 32 bytes +expected_owner_slot="0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266" +[[ "$(echo "$admin_slot0" | tr '[:upper:]' '[:lower:]')" == "$(echo "$expected_owner_slot" | tr '[:upper:]' '[:lower:]')" ]] \ + || fail "AdminProxy slot 0 (owner) mismatch: got $admin_slot0, expected $expected_owner_slot" +pass "AdminProxy owner slot 0 = $ADMIN_OWNER" + +# ── Done ───────────────────────────────────────────────── + +echo "" +echo "=== All checks passed ===" diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 00000000..aee2c9a8 --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,5 @@ +{ + "lib/forge-std": { + "rev": "887e87251562513a7b5ab1ea517c039fe6ee0984" + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 25b918f9..7bfd1700 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -2,5 +2,8 @@ src = "src" out = "out" libs = ["lib"] +solc_version = "0.8.33" +cbor_metadata = false +bytecode_hash = "none" # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/justfile b/justfile index 935c3a03..757a5b96 100644 --- a/justfile +++ b/justfile @@ -34,6 +34,10 @@ build-maxperf: build-all: {{cargo}} build --workspace --release +# Build the ev-deployer binary in release mode +build-deployer: + {{cargo}} build --release --bin ev-deployer + # Testing ────────────────────────────────────────────── # Run all tests @@ -64,6 +68,10 @@ test-evolve: test-common: {{cargo}} test -p ev-common +# Test the deployer crate +test-deployer: + {{cargo}} test -p ev-deployer + # Development ────────────────────────────────────────── # Run the ev-reth node with default settings