diff --git a/Cargo.lock b/Cargo.lock index 6a65521ba..fc1390143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2054,6 +2054,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "ethlambda-prover-core" +version = "0.1.0" +dependencies = [ + "ethlambda-types", + "thiserror 2.0.18", +] + [[package]] name = "ethlambda-rpc" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a0cfcfd05..76422bf14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,19 @@ members = [ "crates/net/api", "crates/net/p2p", "crates/net/rpc", + "crates/prover/core", "crates/storage", ] +# NOTE: zkVM guest programs are their own workspaces: they build for the respective +# target ISA via their respective build commands, not for the host, so `cargo {clippy,test} +# --workspace` must not try to compile them. +# +# `ethlambda-prover-sp1` targets the host, but its build script compiles the +# guest ELF and needs the SP1 (`cargo prove`) toolchain, so it is kept out of +# the default `--workspace` build too. +exclude = ["crates/guest-program/sp1", "crates/prover/sp1"] + [workspace.package] authors = ["LambdaClass"] edition = "2024" @@ -64,6 +74,8 @@ ethlambda-network-api = { path = "crates/net/api" } ethlambda-p2p = { path = "crates/net/p2p" } ethlambda-rpc = { path = "crates/net/rpc" } ethlambda-storage = { path = "crates/storage" } +ethlambda-prover-core = { path = "crates/prover/core" } +ethlambda-prover-sp1 = { path = "crates/prover/sp1" } tracing = "0.1" thiserror = "2.0.9" @@ -109,11 +121,17 @@ rand = "0.10" lru = "0.16" rocksdb = "0.24" libc = "0.2" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", +] } eyre = "0.6" # Allocator + heap profiling -tikv-jemallocator = { version = "0.6", features = ["stats", "unprefixed_malloc_on_supported_platforms", "profiling"] } +tikv-jemallocator = { version = "0.6", features = [ + "stats", + "unprefixed_malloc_on_supported_platforms", + "profiling", +] } jemalloc_pprof = { version = "0.8", features = ["flamegraph"] } # NOTE: Shadow-simulator builds also replace quinn-udp with the fallback crate diff --git a/crates/blockchain/state_transition/Cargo.toml b/crates/blockchain/state_transition/Cargo.toml index dcb916dbd..9ef43d535 100644 --- a/crates/blockchain/state_transition/Cargo.toml +++ b/crates/blockchain/state_transition/Cargo.toml @@ -11,9 +11,13 @@ version.workspace = true [dependencies] ethlambda-types.workspace = true -ethlambda-metrics.workspace = true - thiserror.workspace = true + +# Host-only: prometheus (metrics) and tracing have no meaning in the zkVM guest +# and prometheus doesn't build for the target. Included on every target except +# the zkVM guest (`target_os = "zkvm"`), which drops them automatically. +[target.'cfg(not(target_os = "zkvm"))'.dependencies] +ethlambda-metrics.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index cde9b615c..ac8022b7b 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -1,3 +1,7 @@ +// On the zkVM guest (`target_os = "zkvm"`) `info!`/`warn!` become no-ops, so +// imports and bindings that only feed them go unused — silence those there. +#![cfg_attr(target_os = "zkvm", allow(unused_imports, unused_variables))] + use std::collections::{HashMap, HashSet}; use ethlambda_types::{ @@ -8,9 +12,29 @@ use ethlambda_types::{ primitives::{H256, HashTreeRoot as _}, state::{HISTORICAL_ROOTS_LIMIT, JustificationValidators, State}, }; +#[cfg(not(target_os = "zkvm"))] use tracing::{info, warn}; +// for the zkVM guest, tracing is not compiled in, so set the macros to no-ops. +// `info!`/`warn!` call sites stay unchanged. Defined before first use. +#[cfg(target_os = "zkvm")] +macro_rules! info { + ($($t:tt)*) => {{}}; +} +#[cfg(target_os = "zkvm")] +macro_rules! warn { + ($($t:tt)*) => {{}}; +} + pub mod justified_slots_ops; + +// `metrics` is prometheus-backed on the host, but the zkVM guest +// (`target_os = "zkvm"`) has no prometheus, so there it is swapped for a no-op +// stub with identical signatures. Call sites are the same in both builds. +#[cfg(not(target_os = "zkvm"))] +pub mod metrics; +#[cfg(target_os = "zkvm")] +#[path = "metrics_noop.rs"] pub mod metrics; #[derive(Debug, thiserror::Error)] diff --git a/crates/blockchain/state_transition/src/metrics.rs b/crates/blockchain/state_transition/src/metrics.rs index 8df309099..f0816d5b0 100644 --- a/crates/blockchain/state_transition/src/metrics.rs +++ b/crates/blockchain/state_transition/src/metrics.rs @@ -2,6 +2,7 @@ use std::sync::LazyLock; +pub use ethlambda_metrics::TimingGuard; use ethlambda_metrics::*; static LEAN_STATE_TRANSITION_SLOTS_PROCESSED_TOTAL: LazyLock = LazyLock::new(|| { @@ -30,21 +31,6 @@ static LEAN_FINALIZATIONS_TOTAL: LazyLock = LazyLock::new(|| { .unwrap() }); -/// Increment the slots processed counter by the given amount. -pub fn inc_slots_processed(count: u64) { - LEAN_STATE_TRANSITION_SLOTS_PROCESSED_TOTAL.inc_by(count); -} - -/// Increment the attestations processed counter by the given amount. -pub fn inc_attestations_processed(count: u64) { - LEAN_STATE_TRANSITION_ATTESTATIONS_PROCESSED_TOTAL.inc_by(count); -} - -/// Increment the finalization counter with the given result. -pub fn inc_finalizations(result: &str) { - LEAN_FINALIZATIONS_TOTAL.with_label_values(&[result]).inc(); -} - static LEAN_STATE_TRANSITION_TIME_SECONDS: LazyLock = LazyLock::new(|| { register_histogram!( "lean_state_transition_time_seconds", @@ -84,6 +70,21 @@ static LEAN_STATE_TRANSITION_ATTESTATIONS_PROCESSING_TIME_SECONDS: LazyLock TimingGuard { TimingGuard::new(&LEAN_STATE_TRANSITION_TIME_SECONDS) diff --git a/crates/blockchain/state_transition/src/metrics_noop.rs b/crates/blockchain/state_transition/src/metrics_noop.rs new file mode 100644 index 000000000..20ef0baf5 --- /dev/null +++ b/crates/blockchain/state_transition/src/metrics_noop.rs @@ -0,0 +1,23 @@ +//! No-op metrics stub for the zkVM guest (`target_os = "zkvm"`), where +//! prometheus is unavailable. Compiled in place of `metrics.rs` +//! every function mirrors its host counterpart but records nothing. + +/// Zero-sized stand-in for `ethlambda_metrics::TimingGuard`; records nothing. +pub struct TimingGuard; + +pub fn inc_slots_processed(_count: u64) {} +pub fn inc_attestations_processed(_count: u64) {} +pub fn inc_finalizations(_result: &str) {} + +pub fn time_state_transition() -> TimingGuard { + TimingGuard +} +pub fn time_slots_processing() -> TimingGuard { + TimingGuard +} +pub fn time_block_processing() -> TimingGuard { + TimingGuard +} +pub fn time_attestations_processing() -> TimingGuard { + TimingGuard +} diff --git a/crates/common/types/src/lib.rs b/crates/common/types/src/lib.rs index cfb8957b2..8a6e600bd 100644 --- a/crates/common/types/src/lib.rs +++ b/crates/common/types/src/lib.rs @@ -7,6 +7,7 @@ pub mod constants; pub mod genesis; pub mod primitives; pub mod state; +pub mod stf; /// Display helper for truncated root hashes (8 hex chars) pub struct ShortRoot<'a>(pub &'a [u8; 32]); diff --git a/crates/common/types/src/stf.rs b/crates/common/types/src/stf.rs new file mode 100644 index 000000000..4244b5e87 --- /dev/null +++ b/crates/common/types/src/stf.rs @@ -0,0 +1,49 @@ +use libssz::{SszDecode, SszEncode}; +use serde::{Deserialize, Serialize}; + +use crate::{block::Block, primitives::H256, state::State}; + +/// Input type for the zkVM's +/// +/// The inputs to the zkVM need to derive serde Serialize/deserialize +/// which is not derived for state (libssz traits) +/// wrapping pre-serialized SSZ bytes sidesteps that part. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StfInput { + state: Vec, + block: Vec, +} + +/// Public values committed by the STF guest program. +/// +/// These bind a proof to one concrete transition: applying the block with +/// `block_root` to the state with `pre_state_root` yields the state with +/// `post_state_root`. A verifier reads these back from the proof without ever +/// seeing the full pre-state or block, and can chain proofs by matching one +/// transition's `post_state_root` to the next's `pre_state_root`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StfPublicValues { + /// `hash_tree_root` of the pre-state. + pub pre_state_root: H256, + /// `hash_tree_root` of the block being applied. + pub block_root: H256, + /// `hash_tree_root` of the post-state (equals `block.state_root`). + pub post_state_root: H256, +} + +impl StfInput { + pub fn new(state: State, block: Block) -> Self { + StfInput { + state: state.to_ssz(), + block: block.to_ssz(), + } + } + + pub fn state(&self) -> State { + State::from_ssz_bytes(&self.state).expect("error decoding State") + } + + pub fn block(&self) -> Block { + Block::from_ssz_bytes(&self.block).expect("error decoding block") + } +} diff --git a/crates/guest-program/sp1/Cargo.toml b/crates/guest-program/sp1/Cargo.toml new file mode 100644 index 000000000..1e2a93c7e --- /dev/null +++ b/crates/guest-program/sp1/Cargo.toml @@ -0,0 +1,15 @@ +# Standalone workspace: this guest builds for a RISC-V zkVM target via +# `cargo prove build`, so it is excluded from the root workspace and cannot +# inherit its `[workspace.package]` / `[workspace.dependencies]` values. +[workspace] + +[package] +name = "zkvm_guest_sp1" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +sp1-zkvm = "6.1.0" +ethlambda-types = { path = "../../common/types" } +ethlambda-state-transition = { path = "../../blockchain/state_transition" } diff --git a/crates/guest-program/sp1/src/main.rs b/crates/guest-program/sp1/src/main.rs new file mode 100644 index 000000000..859fb72a3 --- /dev/null +++ b/crates/guest-program/sp1/src/main.rs @@ -0,0 +1,29 @@ +#![no_main] +sp1_zkvm::entrypoint!(main); + +use ethlambda_state_transition::state_transition; +use ethlambda_types::{ + primitives::HashTreeRoot, + stf::{StfInput, StfPublicValues}, +}; + +fn main() { + let input: StfInput = sp1_zkvm::io::read(); + let mut state = input.state(); + let block = input.block(); + + // Capture the pre-state and block roots before mutating the state, so the + // committed public values bind the proof to this specific transition. + let pre_state_root = state.hash_tree_root(); + let block_root = block.hash_tree_root(); + + state_transition(&mut state, &block).expect("state transition failed"); + + let public_values = StfPublicValues { + pre_state_root, + block_root, + // state_transition already asserts this equals block.state_root. + post_state_root: state.hash_tree_root(), + }; + sp1_zkvm::io::commit(&public_values); +} diff --git a/crates/net/rpc/src/base.rs b/crates/net/rpc/src/base.rs index 0ccf47edf..f6b5c9b31 100644 --- a/crates/net/rpc/src/base.rs +++ b/crates/net/rpc/src/base.rs @@ -68,7 +68,7 @@ pub(crate) fn json_response(value: T) -> axum::response::Re response } -fn ssz_response(bytes: Vec) -> axum::response::Response { +pub(crate) fn ssz_response(bytes: Vec) -> axum::response::Response { let mut response = bytes.into_response(); response.headers_mut().insert( header::CONTENT_TYPE, diff --git a/crates/net/rpc/src/blocks.rs b/crates/net/rpc/src/blocks.rs index 9082b43d6..5ecae39a1 100644 --- a/crates/net/rpc/src/blocks.rs +++ b/crates/net/rpc/src/blocks.rs @@ -7,14 +7,17 @@ use axum::{ }; use ethlambda_storage::Store; use ethlambda_types::primitives::H256; +use libssz::SszEncode as _; use serde_json::json; -use crate::json_response; +use crate::{json_response, ssz_response}; pub(crate) fn routes() -> Router { Router::new() .route("/lean/v0/blocks/{block_id}", get(get_block)) .route("/lean/v0/blocks/{block_id}/header", get(get_block_header)) + .route("/lean/v0/blocks/{block_id}/ssz", get(get_block_ssz)) + .route("/lean/v0/states/{block_id}", get(get_state_by_id)) } /// `GET /lean/v0/blocks/:block_id` — returns the block as JSON. @@ -53,6 +56,47 @@ pub(crate) async fn get_block_header( } } +/// `GET /lean/v0/blocks/:block_id/ssz` — returns the block as SSZ bytes. +pub(crate) async fn get_block_ssz( + Path(block_id): Path, + State(store): State, +) -> impl IntoResponse { + let root = match resolve_block_id(&store, &block_id) { + Ok(root) => root, + Err(err) => return err.into_response(), + }; + + match store.get_block(&root) { + Ok(Some(block)) => ssz_response(block.to_ssz()), + Ok(None) => BlockIdError::NotFound.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// `GET /lean/v0/states/:block_id` — returns that block's post-state as SSZ. +/// +/// The replay tool fetches a block's pre-state as the post-state of its parent +/// (`states/{parent_root}`). `state_root` is zeroed to the canonical post-state +/// form, matching `/states/finalized`. +pub(crate) async fn get_state_by_id( + Path(block_id): Path, + State(store): State, +) -> impl IntoResponse { + let root = match resolve_block_id(&store, &block_id) { + Ok(root) => root, + Err(err) => return err.into_response(), + }; + + match store.get_state(&root) { + Ok(Some(mut state)) => { + state.latest_block_header.state_root = H256::ZERO; + ssz_response(state.to_ssz()) + } + Ok(None) => BlockIdError::NotFound.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + /// Resolve a `block_id` (hex root or decimal slot) into a block root. /// /// Slot lookups use the head state's `historical_block_hashes`, so only diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 328584712..05f76d2b9 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -21,7 +21,7 @@ mod node; mod spec; pub mod test_driver; -pub(crate) use base::json_response; +pub(crate) use base::{json_response, ssz_response}; #[derive(Debug, Clone)] pub struct RpcConfig { diff --git a/crates/prover/core/Cargo.toml b/crates/prover/core/Cargo.toml new file mode 100644 index 000000000..a8f3187d7 --- /dev/null +++ b/crates/prover/core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ethlambda-prover-core" +authors.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +ethlambda-types.workspace = true +thiserror.workspace = true + diff --git a/crates/prover/core/src/lib.rs b/crates/prover/core/src/lib.rs new file mode 100644 index 000000000..6e92f2388 --- /dev/null +++ b/crates/prover/core/src/lib.rs @@ -0,0 +1,89 @@ +use std::time::{Duration, Instant}; + +pub use ethlambda_types::stf::{StfInput, StfPublicValues}; + +/// A serialized proof of a single state transition. +/// +/// The bytes depend upon the specific zkVM being used, (SP1ProofWithPublicValues) +/// and carry both the proof and the committed public values, so +/// [`StfProver::verify`] can recover the [`StfPublicValues`] without re-running +/// the transition. +#[derive(Debug, Clone)] +pub struct Proof(pub Vec); + +impl Proof { + /// Borrow the raw proof bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl From> for Proof { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +/// Proves and verifies state-transition executions on a zkVM backend. +/// +/// The methods are `async` because real backends (SP1, RISC0, …) drive an +/// async prover client. +/// +/// `async_fn_in_trait` is allowed deliberately: we don't constrain the returned +/// futures to `Send`, since backend prover clients don't all guarantee it. +#[allow(async_fn_in_trait)] +pub trait StfProver { + /// Prove that applying the input's block to its pre-state is a valid + /// transition, returning [`Proof`]. + async fn prove(&self, input: &StfInput) -> Result; + + /// Verify a proof and return the public values it commits to. + async fn verify(&self, proof: &Proof) -> Result; + + /// Execute the guest program, without generating the proof. + async fn execute(&self, input: &StfInput) -> Result; + + /// [`Self::execute`] plus the wall-clock duration it took. + async fn execute_timed( + &self, + input: &StfInput, + ) -> Result<(StfPublicValues, Duration), ProverError> { + let start = Instant::now(); + let public_values = self.execute(input).await?; + Ok((public_values, start.elapsed())) + } + + /// [`Self::prove`] plus the wall-clock duration it took. + async fn prove_timed(&self, input: &StfInput) -> Result<(Proof, Duration), ProverError> { + let start = Instant::now(); + let proof = self.prove(input).await?; + Ok((proof, start.elapsed())) + } + + /// [`Self::verify`] plus the wall-clock duration it took. + async fn verify_timed( + &self, + proof: &Proof, + ) -> Result<(StfPublicValues, Duration), ProverError> { + let start = Instant::now(); + let public_values = self.verify(proof).await?; + Ok((public_values, start.elapsed())) + } +} + +/// Errors raised while proving or verifying a state transition. +#[derive(Debug, thiserror::Error)] +pub enum ProverError { + /// The backend failed to produce a proof. + #[error("proving failed: {0}")] + Prove(String), + /// The proof did not verify, or verification could not run. + #[error("verification failed: {0}")] + Verify(String), + /// A proof or its public values could not be (de)serialized. + #[error("proof (de)serialization failed: {0}")] + Serialization(String), + /// The execution of the guest program failed. + #[error("execution failed: {0}")] + Execute(String), +} diff --git a/crates/prover/sp1/Cargo.toml b/crates/prover/sp1/Cargo.toml new file mode 100644 index 000000000..395223c3d --- /dev/null +++ b/crates/prover/sp1/Cargo.toml @@ -0,0 +1,20 @@ +# Standalone workspace: this crate is excluded from the root workspace because +# its build script needs the SP1 (`cargo prove`) toolchain. +[workspace] + +[package] +name = "ethlambda-prover-sp1" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +sp1-sdk = "6.3.1" +ethlambda-prover-core = { path = "../core" } +ethlambda-types = { path = "../../common/types" } +# Serializes the whole SP1ProofWithPublicValues into `Proof` so verify can +# recover the committed public values. +bincode = "1.3" + +[build-dependencies] +sp1-build = "6.3.1" diff --git a/crates/prover/sp1/build.rs b/crates/prover/sp1/build.rs new file mode 100644 index 000000000..e61913f70 --- /dev/null +++ b/crates/prover/sp1/build.rs @@ -0,0 +1,14 @@ +use sp1_build::{BuildArgs, build_program_with_args}; + +fn main() { + // Compile the RISC-V guest program so `include_elf!("zkvm_guest_sp1")` can + // embed its ELF. Path is relative to this crate's manifest directory. + // + // `ignore_rust_version` removes the compatibility issues with the toolchain + // being used for ethlambda and the sp1 toolchain + let args = BuildArgs { + ignore_rust_version: true, + ..Default::default() + }; + build_program_with_args("../../guest-program/sp1", args); +} diff --git a/crates/prover/sp1/src/lib.rs b/crates/prover/sp1/src/lib.rs new file mode 100644 index 000000000..63eab1b78 --- /dev/null +++ b/crates/prover/sp1/src/lib.rs @@ -0,0 +1,88 @@ +use ethlambda_prover_core::{Proof, ProverError, StfInput, StfProver, StfPublicValues}; +use sp1_sdk::{ + Elf, MockProver, ProveRequest, Prover, ProvingKey, SP1ProofWithPublicValues, SP1ProvingKey, + SP1Stdin, SP1VerifyingKey, include_elf, +}; + +const STATE_TRANSITION_ELF: Elf = include_elf!("zkvm_guest_sp1"); +const CYCLE_LIMIT: u64 = 10_000_000; + +/// SP1 prover: proves the STF guest and verifies its proofs. +/// +/// The proving/verifying keys are derived once via [`Sp1Prover::new`] because +/// `setup` is expensive and must not run per proof. +/// [TODO!]: check if using Lazylock might be better +pub struct Sp1Prover { + client: MockProver, + pk: SP1ProvingKey, + vk: SP1VerifyingKey, +} + +impl Sp1Prover { + /// Build the prover once, caching the proving/verifying keys and + /// currently uses the MockProver. + pub async fn new() -> Self { + let client = MockProver::new().await; + // let client = ProverClient::builder().cpu().await; + // `setup` returns only the proving key (fallible); the verifying key is + // derived from it. + let pk = client + .setup(STATE_TRANSITION_ELF) + .await + .expect("failed to set up SP1 proving key"); + let vk = pk.verifying_key().clone(); + Self { client, pk, vk } + } +} + +impl StfProver for Sp1Prover { + async fn prove(&self, input: &StfInput) -> Result { + let mut stdin = SP1Stdin::new(); + stdin.write(input); + + // use `.groth16()` instead of `.compressed()` for real proving and verification + let proof = self + .client + .prove(&self.pk, stdin) + .compressed() + .cycle_limit(CYCLE_LIMIT) + .await + .map_err(|err| ProverError::Prove(err.to_string()))?; + + // Store the whole proof (including public values) so `verify` can + // recover the committed `StfPublicValues`. + let bytes = bincode::serialize(&proof) + .map_err(|err| ProverError::Serialization(err.to_string()))?; + Ok(Proof(bytes)) + } + + async fn verify(&self, proof: &Proof) -> Result { + let mut sp1_proof: SP1ProofWithPublicValues = bincode::deserialize(proof.as_bytes()) + .map_err(|err| ProverError::Serialization(err.to_string()))?; + + // `verify` is synchronous in this SDK and takes an optional status code. + self.client + .verify(&sp1_proof, &self.vk, None) + .map_err(|err| ProverError::Verify(err.to_string()))?; + + // The guest committed `StfPublicValues` via `io::commit`; read it back. + Ok(sp1_proof.public_values.read::()) + } + + async fn execute(&self, input: &StfInput) -> Result { + let mut stdin = SP1Stdin::new(); + stdin.write(input); + + // Runs the guest in the SP1 executor without proving; the future yields + // `(SP1PublicValues, ExecutionReport)`. We ignore the report for now. + let (mut public_values, _report) = self + .client + .execute(STATE_TRANSITION_ELF, stdin) + .cycle_limit(CYCLE_LIMIT) + .await + .map_err(|err| ProverError::Execute(err.to_string()))?; + + // The guest committed `StfPublicValues` via `io::commit`; read it back. + Ok(public_values.read::()) + } +} diff --git a/tooling/replay/Cargo.toml b/tooling/replay/Cargo.toml new file mode 100644 index 000000000..a3674b3e6 --- /dev/null +++ b/tooling/replay/Cargo.toml @@ -0,0 +1,31 @@ +# Standalone workspace (like tooling/event-monitor and crates/prover/sp1): the +# `sp1` feature pulls the SP1 prover + toolchain, so this is kept out of the +# root workspace and built on its own. +[workspace] + +[package] +name = "ethlambda-replay" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "replay" +path = "src/main.rs" + +[dependencies] +ethlambda-prover-core = { path = "../../crates/prover/core" } +ethlambda-types = { path = "../../crates/common/types" } +ethlambda-state-transition = { path = "../../crates/blockchain/state_transition" } +libssz = "0.3.0" +hex = "0.4" +clap = { version = "4", features = ["derive", "env"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +eyre = "0.6" +ethlambda-prover-sp1 = { path = "../../crates/prover/sp1", optional = true } + +[features] +default = ["exec"] +exec = [] +sp1 = ["dep:ethlambda-prover-sp1"] diff --git a/tooling/replay/src/fetcher.rs b/tooling/replay/src/fetcher.rs new file mode 100644 index 000000000..69164241c --- /dev/null +++ b/tooling/replay/src/fetcher.rs @@ -0,0 +1,75 @@ +//! Fetch real `(block, pre_state)` pairs from a running ethlambda node over RPC +//! and turn them into `StfInput`s to replay. + +use ethlambda_prover_core::{StfInput, StfPublicValues}; +use ethlambda_types::{block::Block, primitives::HashTreeRoot as _, state::State}; +use eyre::{Context, bail}; +use libssz::SszDecode as _; + +/// A single transition ready to replay: the guest input plus the roots a +/// correct run must commit (computed here from the fetched block/state). +pub struct ReplayInput { + pub id: String, + pub slot: u64, + pub attestations: usize, + pub input: StfInput, + pub expected: StfPublicValues, +} + +/// Fetch and assemble one transition, addressed by slot or `0x`-prefixed root. +pub async fn fetch_transition( + client: &reqwest::Client, + rpc_url: &str, + block_id: &str, +) -> eyre::Result { + let block = fetch_block(client, rpc_url, block_id) + .await + .wrap_err_with(|| format!("fetching block {block_id}"))?; + + // Pre-state = parent's post-state. + let parent_hex = format!("0x{}", hex::encode(block.parent_root.0)); + let state = fetch_state(client, rpc_url, &parent_hex) + .await + .wrap_err_with(|| format!("fetching pre-state at parent {parent_hex}"))?; + + let expected = StfPublicValues { + pre_state_root: state.hash_tree_root(), + block_root: block.hash_tree_root(), + // `state_transition` asserts the computed post-state equals this. + post_state_root: block.state_root, + }; + + Ok(ReplayInput { + id: block_id.to_string(), + slot: block.slot, + attestations: block.body.attestations.len(), + input: StfInput::new(state, block), + expected, + }) +} + +async fn fetch_block(client: &reqwest::Client, rpc_url: &str, id: &str) -> eyre::Result { + let bytes = get_ssz(client, &format!("{rpc_url}/lean/v0/blocks/{id}/ssz")).await?; + Block::from_ssz_bytes(&bytes).map_err(|e| eyre::eyre!("decoding block SSZ: {e:?}")) +} + +async fn fetch_state(client: &reqwest::Client, rpc_url: &str, id: &str) -> eyre::Result { + let bytes = get_ssz(client, &format!("{rpc_url}/lean/v0/states/{id}")).await?; + State::from_ssz_bytes(&bytes).map_err(|e| eyre::eyre!("decoding state SSZ: {e:?}")) +} + +async fn get_ssz(client: &reqwest::Client, url: &str) -> eyre::Result> { + let resp = client + .get(url) + .send() + .await + .wrap_err("HTTP request failed")?; + if !resp.status().is_success() { + bail!("GET {url} -> {}", resp.status()); + } + Ok(resp + .bytes() + .await + .wrap_err("reading response body")? + .to_vec()) +} diff --git a/tooling/replay/src/main.rs b/tooling/replay/src/main.rs new file mode 100644 index 000000000..5c4d1012c --- /dev/null +++ b/tooling/replay/src/main.rs @@ -0,0 +1,51 @@ +mod fetcher; +mod run; + +use clap::{Parser, Subcommand, ValueEnum}; + +/// Replay real ethlambda state transitions through the STF prover. +#[derive(Parser)] +#[command(name = "replay")] +struct Cli { + /// RPC URL of a running ethlambda node. + #[arg(long, env = "REPLAY_RPC_URL", default_value = "http://127.0.0.1:5052")] + rpc_url: String, + /// Guest path per transition (SP1 backend only; the exec backend always executes). + #[arg(long, value_enum, default_value_t = Action::Execute)] + action: Action, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Replay a single block, addressed by slot or root. + Block { id: String }, + /// Replay an inclusive range of slots. + Blocks { from: u64, to: u64 }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum Action { + Execute, + Prove, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let cli = Cli::parse(); + let client = reqwest::Client::new(); + + let ids: Vec = match &cli.command { + Command::Block { id } => vec![id.clone()], + Command::Blocks { from, to } => (*from..=*to).map(|slot| slot.to_string()).collect(), + }; + + println!("Fetching {} block(s) from {}", ids.len(), cli.rpc_url); + let mut transitions = Vec::with_capacity(ids.len()); + for id in &ids { + transitions.push(fetcher::fetch_transition(&client, &cli.rpc_url, id).await?); + } + + run::run(cli.action, &transitions).await +} diff --git a/tooling/replay/src/run.rs b/tooling/replay/src/run.rs new file mode 100644 index 000000000..089da5a44 --- /dev/null +++ b/tooling/replay/src/run.rs @@ -0,0 +1,155 @@ +//! Backend dispatch + timing report (ethrex-replay style). +//! +//! Each transition is run through the host `exec` baseline (default) or the SP1 +//! prover (`--features sp1`), and one line is logged per block with its +//! execution / proving / verification time, followed by a run summary with the +//! total and a throughput figure. + +use std::time::Duration; + +use ethlambda_types::ShortRoot; + +use crate::Action; +use crate::fetcher::ReplayInput; + +/// Human-readable duration, e.g. `1.23s` / `45.67ms`. +fn fmt_dur(d: Duration) -> String { + format!("{d:.2?}") +} + +/// Run throughput: transitions per second. +fn throughput(count: usize, total: Duration) -> String { + let secs = total.as_secs_f64(); + if secs > 0.0 { + format!("{:.2} transitions/s", count as f64 / secs) + } else { + "n/a".to_string() + } +} + +/// Host baseline: run `state_transition` directly, no zkVM. `--action` is +/// irrelevant here (there is no proof to generate). +#[cfg(not(feature = "sp1"))] +pub async fn run(_action: Action, transitions: &[ReplayInput]) -> eyre::Result<()> { + use ethlambda_state_transition::state_transition; + use ethlambda_types::primitives::HashTreeRoot as _; + use std::time::Instant; + + println!("[replay] backend=exec (host state_transition, no zkVM)"); + let mut total = Duration::ZERO; + for t in transitions { + let mut state = t.input.state(); + let block = t.input.block(); + + let start = Instant::now(); + state_transition(&mut state, &block) + .map_err(|e| eyre::eyre!("state_transition failed for {}: {e:?}", t.id))?; + let elapsed = start.elapsed(); + total += elapsed; + + assert_eq!( + state.hash_tree_root(), + t.expected.post_state_root, + "post_state_root mismatch at {}", + t.id + ); + println!( + "[replay] Block: {} (slot {}, attestations {}), post {} | Execution Time: {}", + t.id, + t.slot, + t.attestations, + ShortRoot(&t.expected.post_state_root.0), + fmt_dur(elapsed), + ); + } + println!( + "[replay] Executed {} transition(s) in {} ({})", + transitions.len(), + fmt_dur(total), + throughput(transitions.len(), total), + ); + Ok(()) +} + +/// SP1 backend: run each transition through the guest via `execute` or +/// `prove` + `verify`, timing each step with the trait's `*_timed` wrappers and +/// checking committed roots against the host expectation. +#[cfg(feature = "sp1")] +pub async fn run(action: Action, transitions: &[ReplayInput]) -> eyre::Result<()> { + use ethlambda_prover_core::StfProver; + use ethlambda_prover_sp1::Sp1Prover; + + println!("[replay] backend=sp1 (MockProver)"); + let prover = Sp1Prover::new().await; + let mut total = Duration::ZERO; + for t in transitions { + match action { + Action::Execute => { + let (pv, elapsed) = prover + .execute_timed(&t.input) + .await + .map_err(|e| eyre::eyre!("execute {}: {e}", t.id))?; + total += elapsed; + check(&pv, &t.expected, &t.id); + println!( + "[replay] Block: {} (slot {}, attestations {}), post {} | Execution Time: {}", + t.id, + t.slot, + t.attestations, + ShortRoot(&t.expected.post_state_root.0), + fmt_dur(elapsed), + ); + } + Action::Prove => { + let (proof, prove_dur) = prover + .prove_timed(&t.input) + .await + .map_err(|e| eyre::eyre!("prove {}: {e}", t.id))?; + let (pv, verify_dur) = prover + .verify_timed(&proof) + .await + .map_err(|e| eyre::eyre!("verify {}: {e}", t.id))?; + total += prove_dur + verify_dur; + check(&pv, &t.expected, &t.id); + println!( + "[replay] Block: {} (slot {}, attestations {}), post {}, proof {} bytes | Proving Time: {}, Verification Time: {}", + t.id, + t.slot, + t.attestations, + ShortRoot(&t.expected.post_state_root.0), + proof.as_bytes().len(), + fmt_dur(prove_dur), + fmt_dur(verify_dur), + ); + } + } + } + let verb = match action { + Action::Execute => "Executed", + Action::Prove => "Proved", + }; + println!( + "[replay] {verb} {} transition(s) in {} ({})", + transitions.len(), + fmt_dur(total), + throughput(transitions.len(), total), + ); + Ok(()) +} + +#[cfg(feature = "sp1")] +fn check( + pv: ðlambda_prover_core::StfPublicValues, + expected: ðlambda_prover_core::StfPublicValues, + id: &str, +) { + assert_eq!( + pv.pre_state_root, expected.pre_state_root, + "pre_state_root at {id}" + ); + assert_eq!(pv.block_root, expected.block_root, "block_root at {id}"); + assert_eq!( + pv.post_state_root, expected.post_state_root, + "post_state_root at {id}" + ); +}