Skip to content

Implement EIP-8025 guest program design#7

Merged
jsign merged 48 commits into
mainfrom
jsign-eip-8025
Jan 19, 2026
Merged

Implement EIP-8025 guest program design#7
jsign merged 48 commits into
mainfrom
jsign-eip-8025

Conversation

@jsign

@jsign jsign commented Jan 16, 2026

Copy link
Copy Markdown
Collaborator

This PR implements the current EIP-8025 draft. In this repo, today we support Ethrex and Reth so both work -- but this code can help other ELs to understand how EIP-8025 expects guests to work.

It is a big-ish PR, so I’ll summarize the key points here to keep the important parts concise and make the review feel natural (no quirks/hacks). Still, I will soon create PR comments to revisit the points below and help as much as possible with the review. (I think it is quite hard to do this in multiple PRs). I'll also create some issues to track many comments.

TL;DR:

  • Previously, the guest program's private input included the EL block (+exec witness). Now it receives NewPayloadRequest (+exec witness) instead. We support multiple NewPayloadRequest versions since this change per fork (I use an enum).
  • The public input switches from (parent_hash, block_hash, valid/invalid) to (tree_hash_root(NewPayloadRequest), valid/invalid)
  • Important missing protocol validations are now present:
    • Before, we received the EL block. This didn’t allow the guest program to perform many important checks outside the STF logic without asking CLs to do them (which requires RLP and similar)
    • Now we do the full checking of the EngineAPI layer e.g. block_hash check between the NewPayloadRequest and derived block, CL versioned_blob_hashes against the present ones in the EL transactions, etc.
  • Guest program logic is wrapped to catch panics and have the expected (tree_hash_root(NewPaylaodRequest), false) — this is a technical detail that is preferred now until there is more clarity reg panic behavior in zkVMs, since we have the true/false field in public inputs.

Performance impact

Comparing the previous version to the current one is like comparing apples to oranges — in the previous version, we didn’t have many checks we knew were needed. Still, I ran the previous and new versions of Reth/Zisk over 50 mainnet blocks using an 8x5090 prover.

The average speedup is 0.96x — much better than I expected. For the SSZ hashing, the SHA2 zkVM precompile prob helped a lot, and I paid attention to our NewPayloadRequest transformation to the underlying EL block has as few allocations/copy as possible — this is usually possible since there is a quite direct map between our standardized NewPayloadRequest and the underlying EL types (just had to be lucky enough to transform types with moves instead of copy).

Mid-level important comments

Other important comments.

Regarding how to ingest NewPayloadRequest into the guest program

  • NewPayloadRequest is not a data structure related to EL RPCs. Ideally, I wanted a way to still be able to generate proofs only relying on EL RPC (i.e. eth_getBlock + debug_executionWitness). We could rely on CL and EL RPC, but that would add an extra RPC dependency and introduce more edge cases with unsynced nodes.
  • To still allow, only requiring EL RPCs for proof creation (mostly for optional proofs, not block building for obvious reasons). I created some helper code that derives the NewExecutionPayload from an EL block. It is not enough to “map fields,” since, for example, the request_bytes aren’t present in the EL block, but only in the root. To solve this, I execute the block on the host, pull the' request_bytes' result, and use it to fill NewExecutionPayload. For optional proofs, this adds an extra execution in the host, but we could eventually include request_bytes in a newly designed debug_executionWitnessV2 to avoid the extra execution (should be easy).
  • The last point means that zkboost and workload repos are unaffected by this change in their needs to generate private inputs, which I wanted to preserve to simplify infrastructure.

Reg CL/EngineAPI types for NewPayloadRequest and other downstream types

  • I decided not to leverage any existing crate for CL types like e.g. NewPayloadRequest, ExecutionPayloadV{1,2,3}, Withdawals, etc.
  • The reason is that existing ones like Lighthouse are in pretty big types crate, which contains other CL types that need complicated dependencies for zkVMs (e.g. blst, kzg) without an easy way to feature-gate them.
  • I decided to do our own minimal version of exactly what we need, with as few dependencies as possible.
  • Additionally, we want to eventually ELs (e.g. Ethrex, Reth in repo today) to upstream the code I wrote reg NewPayloadRequest->EL block of the guest into their codebases (since we use Rust here at least). I doubt they will accept using Lighthouse crates if that might pull many other deps for them. I wouldn't complain at all if the Lighthouse team is okay with extracting what we need into a separate, minimal crate so we don't have to maintain our own :)
  • Added support for both serde and rkyv in these minimal-CL types I did, since they are used by Reth and Ethrex, respectively. This was to allow them to keep their preferred serialization of private inputs. Needed a few custom serde/rkyv wrappers to make some ssz_types work.
  • Bottom line: the received NewPayloadRequest is the same for Ethrex and Reth, compared to our previous reality, where each received their own “Block type”. This is nice since we’re moving closer to them receiving a standardized input.

SSZ

  • For SSZ, I use ethereum_ssz from Lighthouse, since it is properly isolated from kzg and blst dependencies. Although every code is critical for the guest program, def I think we don't need new SSZ impl if possible. The only side effect is that it is not no_std which is bad for Airbender. I spent a while patching crates, but I already needed to patch 6 crates before I felt too uncomfortable having so many patches -- prob worth revisiting in a separate PR again to double-check if there's a simpler way.

Reg public input verification

  • Since now the public input is tree_hash_root(NewPayloadRequest) instead of our previous block_hash, there wasn’t a clear way to verify that the guest program execution is correct since this root isn’t part of EL RPC (eth_getBlock and debug_executionWitness). As in, there isn’t a clear way to generate the expected public input without relying on EL guest execution itself.
  • To be completely sure things are running as expected, I had to write an independent CLI tool based on Lighthouse to calculate this root for any slot. This is to have an independent value to compare against in our existing integration tests mainnet blocks.
  • We have 15 mainnet blocks in integration tests. I looked at which slots they correspond to, and used this tool in my Lighthouse CLI to generate the hash_tree_root(NewPayaloadRequest). Using this as an independent source of truth, I compare EL guest execution public input against this map.
  • This has helped me find many bugs during the implementation. I could bisect why the roots were unmatching by looking deeper into the internal roots and catching/fixing bugs.
  • This tool is quite dirty — at some point, I’ll clean it up and put it somewhere just in case we change the blocks in the integration tests.

Crate patching

  • The only crate I had to patch was ethereum_hashing, which is an indirect dependency from ethereum_ssz if you rely on TreeHash derive. ethereum_hashing is only used as a proxy to decide which SHA256 crate is used for SSZ hashing. It unconditionally pulled ring crate, which uses a C-lib -- not feature gated, unfortunately. It has support for sha2 (which we want since most zkVM precompiles patch it) but only enabled by CPU-feature detection, and not a direct way through feature-gating. In any case, it is an extremely simple patch and will require zero maintenance (but we could/should upstream at some point).

Unmodified Reth and Ethrex repos

  • This was important IMO, since forking ELs is hairy. I was very lucky that Reth recently exposed the block execution output in the stateless validator function, which allowed me to capture the generated requests (for the host).
  • Unfortunately-ish, for Reth and Ethrex I had to pull some code in our codebase for EngineAPI checks reg block and payload. Their corresponding crates had networking dependencies or C-libs. This is because they usually live in their Engine API crates, which makes sense.
  • For Reth, I only had to copy paste one small function — but we need to create a PR to add some features to gate some dependencies to remove this in our code.
  • For Ethrex, had to pull multiple functions, all quite simple. Still, we need to create a PR or ask them to extract these Payload/Block checks outside the Engine API crate to avoid network dependencies — should be very easy to do.
  • TL;DR: In both cases, the code is minimal and simple, and I specifically isolated it so it is easy to delete once upstream has it in a nice shape to call directly. I preferred this to forking their codebases.

What did all this mean for core guest program logic

The only change that the existing guest program logic has compared to before this PR is:

  • Transform NewPayloadRequest from private input into their corresponding EL block type (for Ethrex and Reth are distinct types ofc). I did this with a method that tries to avoid as many allocations/copies as possible and leverage existing code in their codebases (since they do this in the EngineAPI). As part of that, they have to do the Payload/Block validation checks I mentioned in the TL;DR.
  • Do the NewPayloadRequest.tree_hash_root() for the public input — very easy and transparent from the guest program perspective.
  • All the rest is the same, since once you have the EL block, you do the same STF checking logic as before.

exclude:
- guest: stateless-validator-ethrex
zkvm: airbender
- guest: stateless-validator-reth

@jsign jsign Jan 16, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the PR description, ethereum_ssz has some chain of crates dependencies which aren't no_std. Went into that rabbit hole a bit, and got pretty far but required at least these patches:

ethereum_ssz = { git = "https://github.com/han0110/ethereum_ssz", branch = "feature/no-std" }
ethereum_ssz_derive = { git = "https://github.com/han0110/ethereum_ssz", branch = "feature/no-std" }
ethereum_serde_utils = { git = "https://github.com/han0110/ethereum_serde_utils", branch = "feature/no-std" }
tree_hash = { git = "https://github.com/jsign/tree_hash", rev = "2263d501c0765837ef099796f153ba323939e614" }
tree_hash_derive = { git = "https://github.com/jsign/tree_hash", rev = "2263d501c0765837ef099796f153ba323939e614" }
ssz_types = { git = "https://github.com/jsign/ssz_types", rev = "679b7ba3e7402df8f8441af2242a6117c91fc84a" }

The first three are ones that we already did before for similar reasons, and bottom three are ones I had to do because of new needs. I got pretty close -- but there is still some extra things to do potentially. I prefered to stop and not include it in this PR since it was already quite big; but I think we can revisit this to see if we need to continue patching or there is other way.

#8

Comment thread Cargo.toml
Comment on lines +82 to +85
tree_hash = "0.10.0"
tree_hash_derive = "0.10.0"
ssz_types = { version = "0.11", default-features = false }
typenum = { version = "1.18", default-features = false }

@jsign jsign Jan 16, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only rely on ssz and tree hash from Lighthouse, not on their types crates. All the types to be used are in stateless-validator-common.

crypto-bigint = { git = "https://github.com/risc0/RustCrypto-crypto-bigint", tag = "v0.5.5-risczero.0" }
c-kzg = { git = "https://github.com/risc0/c-kzg-4844", tag = "c-kzg/v2.1.1-risczero.0" }
substrate-bn = { git = "https://github.com/risc0/paritytech-bn", tag = "v0.6.0-risczero.0" }
ethereum_hashing = { git = "https://github.com/jsign/ethereum_hashing", rev = "08c88633b700d7447ef840d19ce7b369c44311de" }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only patched crate -- it is an easy one if you want to take a look, and shouldn't be maintained so feels okay. We can eventually polish and upstream it.

}

/// Reads all stateless validator fixtures.
pub fn get_fixtures() -> Vec<StatelessValidatorFixture> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change things a bit so integration-tests call this directly, to avoid some repetition. And also clean up the previous blocks folder which remained.

Comment on lines +154 to +172
/// A platform that to run guests outside zkVMs.
#[derive(Debug)]
pub struct NoopPlatform;

impl Platform for NoopPlatform {
#[allow(unreachable_code)]
fn read_whole_input() -> impl std::ops::Deref<Target = [u8]> {
panic!("Can't read input in NoopPlatform");
&[] as &[u8]
}

fn write_whole_output(_: &[u8]) {
panic!("Can't write output in NoopPlatform");
}

fn print(message: &str) {
println!("{}", message);
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a helper ere Platform used to execute the guest program on the host.

};

/// Converts a [`NewPayloadRequest`] into an ethrex [`Block`].
pub fn get_block_from_new_payload_request(req: NewPayloadRequest) -> Result<Block> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the gist of the work that EL guests have to do for NewPayloadRequest to their own EL types.

It is "boring code" -- the only important thing was to try to avoid as many allocations and copies as possible, since we're in the guest.

Comment on lines -24 to +35
/// The stateless input for the stateless validation function.
pub stateless_input: StatelessInput,
/// New payload request data.
pub new_payload_request: NewPayloadRequest,
/// Execution witness for the EL block.
pub witness: ExecutionWitness,
/// Chain configuration for the stateless validation function
#[serde_as(as = "alloy_genesis::serde_bincode_compat::ChainConfig<'_>")]
pub chain_config: ChainConfig,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar as my comment in Ethrex. StatelessInput has the Reth Block type, and now they receive NewPayloadRequest. So now the guest input type is mainly the same as StatelessInput but with a new_payload_request field -- the rest is the same.

Comment on lines +33 to +34
let requests = get_requests(stateless_input, &signers, valid_block)?;
let new_payload_request = to_new_payload_request(stateless_input, requests)?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In short, the new way that host constructs the private input part.

}
}

fn get_requests(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the point on how the host re-executes the block to get teh request bytes I mentioened before required in NewPayloadRequest.

Comment on lines +34 to +41
/// This method is copied from `reth-ethereum-payload-builder` crate.
/// https://github.com/paradigmxyz/reth/blob/8eecad3d1d433ed509373713c21c31504290d17d/crates/ethereum/payload/src/validator.rs#L66
/// Unfortunately, that crate does not allow to have minimal activated
/// features in alloy-consensus to use directly without pulling in blst
/// and other non-friendly dependencies for zkVMs.
/// TODO: If we can upstream some changes to this crate accordingly, we
/// can remove this method and use directly from reth.
fn ensure_well_formed_payload<ChainSpec, T>(

@jsign jsign Jan 16, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only method pulled from the Reth codebase -- by doing some changes in Reth, we can delete this method and use directly.

#10

@han0110 han0110 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First round review, nice work! Left some comments and questions

Comment thread crates/stateless-validator-ethrex/src/guest.rs Outdated
Comment thread crates/stateless-validator-ethrex/src/guest.rs Outdated
@@ -0,0 +1,321 @@
//! Consensus types to support new payload requests.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Learned a lot about manually impl of rkyv!

Comment thread crates/stateless-validator-common/src/new_payload_request.rs Outdated
Comment thread crates/stateless-validator-ethrex/src/execution_payload.rs Outdated
Comment thread crates/integration-tests/tests/stateless-validator-ethrex.rs Outdated
Comment thread crates/integration-tests/Cargo.toml Outdated
Comment thread crates/stateless-validator-ethrex/src/new_payload_request.rs

@han0110 han0110 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

(just 2 l2 stuff of ethrex needed to be cleanup, but we can do that later since we never use that feature)

Comment thread crates/stateless-validator-ethrex/src/guest.rs Outdated
Comment thread crates/stateless-validator-ethrex/src/host.rs Outdated
@han0110 han0110 mentioned this pull request Jan 19, 2026
jsign added 2 commits January 19, 2026 09:30
Signed-off-by: jsign <jsign.uy@gmail.com>
Signed-off-by: jsign <jsign.uy@gmail.com>
@jsign jsign merged commit 31c8ad5 into main Jan 19, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants