diff --git a/Cargo.toml b/Cargo.toml index 98971187..cbbdf7db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,6 @@ alloy = { version = "0.3" } alloy-trie = { version = "0.5" } # Beacon chain support -beacon-api-client = { git = "https://github.com/ralexstokes/ethereum-consensus.git", rev = "cf3c404043230559660810bc0c9d6d5a8498d819" } ethereum-consensus = { git = "https://github.com/ralexstokes/ethereum-consensus.git", rev = "cf3c404043230559660810bc0c9d6d5a8498d819" } anyhow = { version = "1.0" } @@ -43,10 +42,12 @@ log = "0.4" nybbles = { version = "0.2.1" } once_cell = "1.19" revm = { version = "14.0", default-features = false, features = ["std"] } +reqwest = "0.12" serde = "1.0" serde_json = "1.0" sha2 = { version = "0.10" } test-log = "0.2.15" +thiserror = "1.0" tokio = { version = "1.35" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = { version = "2.5" } diff --git a/steel/Cargo.toml b/steel/Cargo.toml index b24e2f56..928aaa3c 100644 --- a/steel/Cargo.toml +++ b/steel/Cargo.toml @@ -19,14 +19,16 @@ alloy-rlp = { workspace = true } alloy-rlp-derive = { workspace = true } alloy-sol-types = { workspace = true } anyhow = { workspace = true } -beacon-api-client = { workspace = true, optional = true } ethereum-consensus = { workspace = true, optional = true } log = { workspace = true, optional = true } nybbles = { workspace = true, features = ["serde"] } once_cell = { workspace = true } +reqwest = { workspace = true, optional = true } revm = { workspace = true, features = ["serde"] } serde = { workspace = true } +serde_json = { workspace = true, optional = true } sha2 = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true, optional = true } url = { workspace = true, optional = true } @@ -42,9 +44,10 @@ test-log = { workspace = true } default = [] host = [ "dep:alloy", - "dep:beacon-api-client", "dep:ethereum-consensus", "dep:log", + "dep:reqwest", + "dep:serde_json", "dep:tokio", "dep:url", ] diff --git a/steel/src/beacon.rs b/steel/src/beacon.rs index e41cd596..ca7f0d6e 100644 --- a/steel/src/beacon.rs +++ b/steel/src/beacon.rs @@ -27,7 +27,7 @@ pub struct BeaconInput { } impl BeaconInput { - /// Converts the input into a [EvmEnv] for a verifiable state access in the guest. + /// Converts the input into a [EvmEnv] for verifiable state access in the guest. /// /// [EvmEnv]: crate::EvmEnv pub fn into_env(self) -> GuestEvmEnv { @@ -89,9 +89,9 @@ mod host { EvmBlockHeader, }; use alloy::{network::Ethereum, providers::Provider, transports::Transport}; - use alloy_primitives::Sealable; + use alloy_primitives::{Sealable, B256}; use anyhow::{bail, ensure, Context}; - use beacon_api_client::{mainnet::Client as BeaconClient, BeaconHeaderSummary, BlockId}; + use client::{BeaconClient, GetBlockHeaderResponse}; use ethereum_consensus::{ssz::prelude::*, types::SignedBeaconBlock, Fork}; use log::info; use proofs::{Proof, ProofAndWitness}; @@ -118,47 +118,11 @@ mod host { let input = BlockInput::from_env(env) .await .context("failed to derive block input")?; - let client = BeaconClient::new(url); - // first get the header of the parent and then the actual block header - let parent_beacon_header = client - .get_beacon_header(BlockId::Root(parent_beacon_block_root.0.into())) - .await - .with_context(|| { - format!("failed to get block header {}", parent_beacon_block_root) - })?; - let beacon_header = get_child_beacon_header(&client, parent_beacon_header) - .await - .with_context(|| { - format!("failed to get child of block {}", parent_beacon_block_root) - })?; - - // get the entire block - let signed_beacon_block = client - .get_beacon_block(BlockId::Root(beacon_header.root)) - .await - .with_context(|| format!("failed to get block {}", beacon_header.root))?; - // create the inclusion proof of the execution block hash depending on the fork version - let (proof, beacon_root) = match signed_beacon_block { - SignedBeaconBlock::Deneb(signed_block) => { - prove_block_hash_inclusion(signed_block.message)? - } - _ => { - bail!( - "invalid version of block {}: expected {}; got {}", - beacon_header.root, - Fork::Deneb, - signed_beacon_block.version() - ); - } - }; - - // convert and verify the proof - let proof: MerkleProof = proof - .try_into() - .context("proof derived from API is invalid")?; + let client = BeaconClient::new(url).context("invalid URL")?; + let (proof, beacon_root) = create_proof(parent_beacon_block_root, client).await?; ensure!( - proof.process(block_hash).0 == beacon_root.0, + proof.process(block_hash) == beacon_root, "proof derived from API does not verify", ); @@ -171,30 +135,153 @@ mod host { } } - /// Returns the inclusion proof of `block_hash` in the given `BeaconBlock`. - fn prove_block_hash_inclusion( - beacon_block: T, - ) -> Result { - // the `block_hash` is in the ExecutionPayload in the BeaconBlockBody in the BeaconBlock - beacon_block.prove(&[ - "body".into(), - "execution_payload".into(), - "block_hash".into(), - ]) + mod client { + use ethereum_consensus::{ + phase0::SignedBeaconBlockHeader, primitives::Root, types::mainnet::SignedBeaconBlock, + Fork, + }; + use reqwest::IntoUrl; + use serde::{Deserialize, Serialize}; + use std::{collections::HashMap, fmt::Display}; + use url::Url; + + /// Errors returned by the [BeaconClient]. + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error("could not parse URL: {0}")] + Url(#[from] url::ParseError), + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("version field does not match data version")] + VersionMismatch, + } + + /// Response returned by the `get_block_header` API. + #[derive(Debug, Serialize, Deserialize)] + pub struct GetBlockHeaderResponse { + pub root: Root, + pub canonical: bool, + pub header: SignedBeaconBlockHeader, + } + + /// Wrapper returned by the API calls. + #[derive(Serialize, Deserialize)] + struct Response { + data: T, + #[serde(flatten)] + meta: HashMap, + } + + /// Wrapper returned by the API calls that includes a version. + #[derive(Serialize, Deserialize)] + struct VersionedResponse { + version: Fork, + #[serde(flatten)] + inner: Response, + } + + /// Simple beacon API client for the `mainnet` preset that can query headers and blocks. + pub struct BeaconClient { + http: reqwest::Client, + endpoint: Url, + } + + impl BeaconClient { + /// Creates a new beacon endpoint API client. + pub fn new(endpoint: U) -> Result { + let client = reqwest::Client::new(); + Ok(Self { + http: client, + endpoint: endpoint.into_url()?, + }) + } + + async fn http_get( + &self, + path: &str, + ) -> Result { + let target = self.endpoint.join(path)?; + let resp = self.http.get(target).send().await?; + let value = resp.error_for_status()?.json().await?; + Ok(value) + } + + /// Retrieves block header for given block id. + pub async fn get_block_header( + &self, + block_id: impl Display, + ) -> Result { + let path = format!("eth/v1/beacon/headers/{block_id}"); + let result: Response = self.http_get(&path).await?; + Ok(result.data) + } + + /// Retrieves block details for given block id. + pub async fn get_block( + &self, + block_id: impl Display, + ) -> Result { + let path = format!("eth/v2/beacon/blocks/{block_id}"); + let result: VersionedResponse = self.http_get(&path).await?; + if result.version.to_string() != result.inner.data.version().to_string() { + return Err(Error::VersionMismatch); + } + Ok(result.inner.data) + } + } + } + + /// Creates the [MerkleProof] of `block_hash` in the `BeaconBlock` with the given + /// `parent_beacon_block_root`. + async fn create_proof( + parent_root: B256, + client: BeaconClient, + ) -> anyhow::Result<(MerkleProof, B256)> { + // first get the header of the parent and then the actual block header + let parent_beacon_header = client + .get_block_header(parent_root) + .await + .with_context(|| format!("failed to get block header {}", parent_root))?; + let beacon_header = get_child_beacon_header(&client, parent_beacon_header) + .await + .with_context(|| format!("failed to get child of block {}", parent_root))?; + + // get the entire block + let signed_beacon_block = client + .get_block(beacon_header.root) + .await + .with_context(|| format!("failed to get block {}", beacon_header.root))?; + // create the inclusion proof of the execution block hash depending on the fork version + let (proof, beacon_root) = match signed_beacon_block { + SignedBeaconBlock::Deneb(signed_block) => prove_block_hash(signed_block.message)?, + _ => { + bail!( + "invalid version of block {}: expected {}; got {}", + beacon_header.root, + Fork::Deneb, + signed_beacon_block.version() + ); + } + }; + let proof: MerkleProof = proof + .try_into() + .context("proof derived from API is invalid")?; + + Ok((proof, beacon_root.0.into())) } /// Returns the header, with `parent_root` equal to `parent.root`. /// /// It iteratively tries to fetch headers of successive slots until success. - /// TODO: use [BeaconClient::get_beacon_header_for_parent_root], once the nodes add support. + /// TODO: use `eth/v1/beacon/headers?parent_root`, once all the nodes support it. async fn get_child_beacon_header( client: &BeaconClient, - parent: BeaconHeaderSummary, - ) -> anyhow::Result { + parent: GetBlockHeaderResponse, + ) -> anyhow::Result { let parent_slot = parent.header.message.slot; let mut request_error = None; for slot in (parent_slot + 1)..=(parent_slot + 32) { - match client.get_beacon_header(BlockId::Slot(slot)).await { + match client.get_block_header(slot).await { Err(err) => request_error = Some(err), Ok(resp) => { let header = &resp.header.message; @@ -214,6 +301,18 @@ mod host { Err(err.context("no valid response received for the 32 consecutive slots")) } + /// Returns the inclusion proof of `block_hash` in the given `BeaconBlock`. + fn prove_block_hash( + beacon_block: T, + ) -> Result { + // the `block_hash` is in the ExecutionPayload in the BeaconBlockBody in the BeaconBlock + beacon_block.prove(&[ + "body".into(), + "execution_payload".into(), + "block_hash".into(), + ]) + } + impl TryFrom for MerkleProof { type Error = anyhow::Error; @@ -228,10 +327,38 @@ mod host { }) } } + + #[cfg(test)] + mod tests { + use super::*; + use alloy::{eips::BlockNumberOrTag, network::BlockResponse, providers::ProviderBuilder}; + + #[tokio::test] + #[ignore] // This queries actual RPC nodes, running only on demand. + async fn eth_mainnet_proof() { + const EL_URL: &str = "https://ethereum-rpc.publicnode.com"; + const CL_URL: &str = "https://ethereum-beacon-api.publicnode.com"; + + let el = ProviderBuilder::new().on_builtin(EL_URL).await.unwrap(); + let cl = BeaconClient::new(CL_URL).unwrap(); + + let block = el + .get_block_by_number(BlockNumberOrTag::Finalized, false) + .await + .expect("eth_getBlockByNumber failed") + .unwrap(); + let header = block.header(); + + let (proof, beacon_root) = create_proof(header.parent_beacon_block_root.unwrap(), cl) + .await + .expect("proving failed"); + assert_eq!(proof.process(header.hash), beacon_root); + } + } } #[cfg(test)] -pub(crate) mod tests { +mod tests { use super::*; use alloy_primitives::b256; diff --git a/steel/src/config.rs b/steel/src/config.rs index 2d2eaaa7..371a22fe 100644 --- a/steel/src/config.rs +++ b/steel/src/config.rs @@ -55,6 +55,13 @@ pub struct ChainSpec { impl ChainSpec { /// Creates a new configuration consisting of only one specification ID. + /// + /// For example, this can be used to create a [ChainSpec] for an anvil instance: + /// ```rust + /// # use revm::primitives::SpecId; + /// # use risc0_steel::config::ChainSpec; + /// let spec = ChainSpec::new_single(31337, SpecId::CANCUN); + /// ``` pub fn new_single(chain_id: ChainId, spec_id: SpecId) -> Self { ChainSpec { chain_id, diff --git a/steel/src/contract.rs b/steel/src/contract.rs index 557c3cad..d366d0d7 100644 --- a/steel/src/contract.rs +++ b/steel/src/contract.rs @@ -34,13 +34,13 @@ use revm::{ /// /// ### Usage /// - **Preflight calls on the Host:** To prepare calls on the host environment and build the -/// necessary proof, use [Contract::preflight]. The environment can be initialized using -/// [EthEvmEnv::from_rpc] or [EvmEnv::new]. +/// necessary proof, use [Contract::preflight]. The environment can be initialized using the +/// [EthEvmEnv::builder] or [EvmEnv::builder]. /// - **Calls in the Guest:** To initialize the contract in the guest environment, use /// [Contract::new]. The environment should be constructed using [EvmInput::into_env]. /// /// ### Examples -/// ```rust no_run +/// ```rust,no_run /// # use risc0_steel::{ethereum::EthEvmEnv, Contract, host::BlockNumberOrTag}; /// # use alloy_primitives::address; /// # use alloy_sol_types::sol; @@ -53,14 +53,12 @@ use revm::{ /// function balanceOf(address account) external view returns (uint); /// } /// } -/// -/// let get_balance = IERC20::balanceOfCall { -/// account: address!("F977814e90dA44bFA03b6295A0616a897441aceC"), -/// }; +/// let account = address!("F977814e90dA44bFA03b6295A0616a897441aceC"); +/// let get_balance = IERC20::balanceOfCall { account }; /// /// // Host: /// let url = "https://ethereum-rpc.publicnode.com".parse()?; -/// let mut env = EthEvmEnv::from_rpc(url, BlockNumberOrTag::Latest).await?; +/// let mut env = EthEvmEnv::builder().rpc(url).build().await?; /// let mut contract = Contract::preflight(contract_address, &mut env); /// contract.call_builder(&get_balance).call().await?; /// @@ -75,9 +73,9 @@ use revm::{ /// # } /// ``` /// +/// [EthEvmEnv::builder]: crate::ethereum::EthEvmEnv::builder +/// [EvmEnv::builder]: crate::EvmEnv::builder /// [EvmInput::into_env]: crate::EvmInput::into_env -/// [EvmEnv::new]: crate::EvmEnv::new -/// [EthEvmEnv::from_rpc]: crate::ethereum::EthEvmEnv::from_rpc pub struct Contract { address: Address, env: E, diff --git a/steel/src/host/mod.rs b/steel/src/host/mod.rs index f1454779..fcfcf2c8 100644 --- a/steel/src/host/mod.rs +++ b/steel/src/host/mod.rs @@ -91,7 +91,33 @@ where T: Transport + Clone, P: Provider, { - /// Converts the environment into a [EvmInput] committing to a Beacon block root. + /// Converts the environment into a [EvmInput] committing to a Ethereum Beacon block root. + /// + /// This function assumes that the + /// [mainnet](https://github.com/ethereum/consensus-specs/blob/v1.4.0/configs/mainnet.yaml) + /// preset of the consensus specs is used. + /// + /// ```rust,no_run + /// # use alloy_primitives::{address, Address}; + /// # use risc0_steel::{Contract, ethereum::EthEvmEnv}; + /// # use url::Url; + /// # #[tokio::main(flavor = "current_thread")] + /// # async fn main() -> anyhow::Result<()> { + /// // Create an environment. + /// let rpc_url = Url::parse("https://ethereum-rpc.publicnode.com")?; + /// let mut env = EthEvmEnv::builder().rpc(rpc_url).build().await?; + /// + /// // Preflight some contract calls... + /// # let address = address!("dAC17F958D2ee523a2206206994597C13D831ec7"); + /// # alloy::sol!( function balanceOf(address account) external view returns (uint); ); + /// # let call = balanceOfCall{ account: Address::ZERO }; + /// Contract::preflight(address, &mut env).call_builder(&call).call().await?; + /// + /// // Use EIP-4788 beacon commitments. + /// let beacon_api_url = Url::parse("https://ethereum-beacon-api.publicnode.com")?; + /// let input = env.into_beacon_input(beacon_api_url).await?; + /// # Ok(()) + /// # } pub async fn into_beacon_input(self, url: Url) -> Result> { Ok(EvmInput::Beacon( BeaconInput::from_env_and_endpoint(self, url).await?, @@ -101,6 +127,18 @@ where impl EvmEnv<(), H> { /// Creates a builder for building an environment. + /// + /// Create an Ethereum environment bast on the latest block: + /// ```rust,no_run + /// # use risc0_steel::ethereum::EthEvmEnv; + /// # use url::Url; + /// # #[tokio::main(flavor = "current_thread")] + /// # async fn main() -> anyhow::Result<()> { + /// # let url = Url::parse("https://ethereum-rpc.publicnode.com")?; + /// let env = EthEvmEnv::builder().rpc(url).build().await?; + /// # Ok(()) + /// # } + /// ``` pub fn builder() -> EvmEnvBuilder { EvmEnvBuilder { provider: NoProvider, @@ -112,6 +150,8 @@ impl EvmEnv<(), H> { } /// Builder for building an [EvmEnv] on the host. +/// +/// The builder can be created using [EvmEnv::builder()]. #[derive(Clone, Debug)] pub struct EvmEnvBuilder { provider: P, diff --git a/steel/src/lib.rs b/steel/src/lib.rs index 6a2ac7a3..5e05f0cd 100644 --- a/steel/src/lib.rs +++ b/steel/src/lib.rs @@ -22,8 +22,8 @@ use block::BlockInput; use revm::primitives::{BlockEnv, CfgEnvWithHandlerCfg, SpecId}; use state::StateDb; -pub mod beacon; -pub mod block; +mod beacon; +mod block; pub mod config; mod contract; pub mod ethereum; @@ -73,7 +73,7 @@ pub struct EvmEnv { impl EvmEnv { /// Creates a new environment. /// It uses the default configuration for the latest specification. - pub fn new(db: D, header: Sealed) -> Self { + pub(crate) fn new(db: D, header: Sealed) -> Self { let cfg_env = CfgEnvWithHandlerCfg::new_with_spec_id(Default::default(), SpecId::LATEST); let commitment = Commitment::from_header(&header);