Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WEB3-88: feat: implement minimal beacon API client #214

Merged
merged 14 commits into from
Sep 12, 2024
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
7 changes: 5 additions & 2 deletions steel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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",
]
241 changes: 184 additions & 57 deletions steel/src/beacon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub struct BeaconInput<H> {
}

impl<H: EvmBlockHeader> BeaconInput<H> {
/// 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<H> {
Expand Down Expand Up @@ -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};
Expand All @@ -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",
);

Expand All @@ -171,30 +135,153 @@ mod host {
}
}

/// Returns the inclusion proof of `block_hash` in the given `BeaconBlock`.
fn prove_block_hash_inclusion<T: SimpleSerialize>(
beacon_block: T,
) -> Result<ProofAndWitness, MerkleizationError> {
// 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<T> {
data: T,
#[serde(flatten)]
meta: HashMap<String, serde_json::Value>,
}

/// Wrapper returned by the API calls that includes a version.
#[derive(Serialize, Deserialize)]
struct VersionedResponse<T> {
version: Fork,
#[serde(flatten)]
inner: Response<T>,
}

/// 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<U: IntoUrl>(endpoint: U) -> Result<Self, Error> {
let client = reqwest::Client::new();
Ok(Self {
http: client,
endpoint: endpoint.into_url()?,
})
}

async fn http_get<T: serde::de::DeserializeOwned>(
&self,
path: &str,
) -> Result<T, Error> {
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<GetBlockHeaderResponse, Error> {
let path = format!("eth/v1/beacon/headers/{block_id}");
let result: Response<GetBlockHeaderResponse> = 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<SignedBeaconBlock, Error> {
let path = format!("eth/v2/beacon/blocks/{block_id}");
let result: VersionedResponse<SignedBeaconBlock> = 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<BeaconHeaderSummary> {
parent: GetBlockHeaderResponse,
) -> anyhow::Result<GetBlockHeaderResponse> {
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;
Expand All @@ -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<T: SimpleSerialize>(
beacon_block: T,
) -> Result<ProofAndWitness, MerkleizationError> {
// 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<Proof> for MerkleProof {
type Error = anyhow::Error;

Expand All @@ -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;

Expand Down
7 changes: 7 additions & 0 deletions steel/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading