Skip to content

Commit 340e388

Browse files
ColoCarletticlaude
andcommitted
feat: add empty Ethereum block proving pipeline
- Add `prove_with_input()` and `private_input` param to `prove_with_options()` so guest programs can receive input through the prover API - Add `Exit = 3` syscall (Rust std runtime exit path) - Fix commit output being overwritten by std cleanup (store in dedicated field) - Make commit syscall no-op for fd != 1 (stderr writes from std) - Add `tools/generate_input/` fixture generator for empty block ProgramInput - Add `tools/run_ethrex/` to measure execution (11M instructions, 148ms) - Add `test_ethrex_empty_block` executor test (passes) - Add `test_prove_ethrex_empty_block` prover test (ignored, needs ~64GB RAM) - Add empty block fixture `executor/tests/ethrex_empty_block.bin` (1876 bytes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e8e7ef8 commit 340e388

11 files changed

Lines changed: 294 additions & 29 deletions

File tree

bin/cli/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ fn cmd_prove(elf_path: PathBuf, output_path: PathBuf, blowup: Option<u8>, time:
276276
"Generating proof (blowup={b}, queries={})...",
277277
opts.fri_number_of_queries
278278
);
279-
prover::prove_with_options(&elf_data, &opts, &Default::default())
279+
prover::prove_with_options(&elf_data, vec![], &opts, &Default::default())
280280
}
281281
None => {
282282
eprintln!("Generating proof...");

executor/src/vm/instruction/execution.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const REGULAR_PC_UPDATE: u64 = 4;
1010
pub enum SyscallNumbers {
1111
Print = 1,
1212
Panic = 2,
13+
Exit = 3,
1314
GetPrivateInputs = 4,
1415
Commit = 64,
1516
Halt = 93,
@@ -21,6 +22,7 @@ impl TryFrom<u64> for SyscallNumbers {
2122
match value {
2223
1 => Ok(SyscallNumbers::Print),
2324
2 => Ok(SyscallNumbers::Panic),
25+
3 => Ok(SyscallNumbers::Exit),
2426
4 => Ok(SyscallNumbers::GetPrivateInputs),
2527
64 => Ok(SyscallNumbers::Commit),
2628
93 => Ok(SyscallNumbers::Halt),
@@ -309,12 +311,11 @@ impl Instruction {
309311
// x11 = buf_addr (buffer address in memory)
310312
// x12 = count (number of bytes to write)
311313
let fd = registers.read(10)?;
312-
if fd != 1 {
313-
return Err(ExecutionError::InvalidCommitFd(fd));
314-
}
315314
let buf_addr = registers.read(11)?;
316315
let count = registers.read(12)?;
317-
memory.commit_public_output(buf_addr, count)?;
316+
if fd == 1 {
317+
memory.commit_public_output(buf_addr, count)?;
318+
}
318319
src2_val = buf_addr;
319320
dst_val = count;
320321
}
@@ -326,8 +327,8 @@ impl Instruction {
326327
memory.store_byte(pointer + i as u64, *byte);
327328
}
328329
}
329-
SyscallNumbers::Halt => {
330-
// halt
330+
SyscallNumbers::Exit | SyscallNumbers::Halt => {
331+
// halt / exit
331332
return Ok(Log {
332333
current_pc: pc,
333334
next_pc: 0, // We halt by setting pc to 0

executor/src/vm/memory.rs

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,23 @@ const MAX_PRIVATE_INPUT_SIZE: u64 = 6700000;
4747
const PRIVATE_INPUT_START_INDEX: u64 = 0xFF000000;
4848

4949
#[derive(Default, Debug)]
50-
pub struct Memory(U64HashMap<[u8; 4]>);
50+
pub struct Memory {
51+
data: U64HashMap<[u8; 4]>,
52+
/// Committed public output bytes, stored separately so std cleanup can't overwrite them.
53+
committed_output: Vec<u8>,
54+
}
5155

5256
impl Memory {
5357
pub fn load_byte(&self, address: u64) -> u8 {
5458
let aligned_address = address - address % 4;
55-
let value = self.0.get(&aligned_address).cloned().unwrap_or_default();
59+
let value = self.data.get(&aligned_address).cloned().unwrap_or_default();
5660
value[(address % 4) as usize]
5761
}
5862

5963
pub fn store_byte(&mut self, address: u64, value: u8) {
6064
let aligned_address = address - address % 4;
6165
let entry = self
62-
.0
66+
.data
6367
.entry(aligned_address)
6468
.or_insert_with(|| [0, 0, 0, 0]);
6569
entry[(address % 4) as usize] = value;
@@ -69,7 +73,7 @@ impl Memory {
6973
if !address.is_multiple_of(4) {
7074
return Err(MemoryError::UnalignedAccess);
7175
}
72-
let bytes = self.0.get(&address).cloned().unwrap_or_default();
76+
let bytes = self.data.get(&address).cloned().unwrap_or_default();
7377
Ok(u32::from_le_bytes(bytes))
7478
}
7579

@@ -78,7 +82,7 @@ impl Memory {
7882
return Err(MemoryError::UnalignedAccess);
7983
}
8084
let bytes = value.to_le_bytes();
81-
self.0.insert(address, bytes);
85+
self.data.insert(address, bytes);
8286
Ok(())
8387
}
8488

@@ -87,8 +91,8 @@ impl Memory {
8791
if !address.is_multiple_of(8) {
8892
return Err(MemoryError::UnalignedAccess);
8993
}
90-
let low_bytes = self.0.get(&address).cloned().unwrap_or_default();
91-
let high_bytes = self.0.get(&(address + 4)).cloned().unwrap_or_default();
94+
let low_bytes = self.data.get(&address).cloned().unwrap_or_default();
95+
let high_bytes = self.data.get(&(address + 4)).cloned().unwrap_or_default();
9296
let low = u32::from_le_bytes(low_bytes) as u64;
9397
let high = u32::from_le_bytes(high_bytes) as u64;
9498
Ok(low | (high << 32))
@@ -101,8 +105,8 @@ impl Memory {
101105
}
102106
let low = (value & 0xFFFFFFFF) as u32;
103107
let high = (value >> 32) as u32;
104-
self.0.insert(address, low.to_le_bytes());
105-
self.0.insert(address + 4, high.to_le_bytes());
108+
self.data.insert(address, low.to_le_bytes());
109+
self.data.insert(address + 4, high.to_le_bytes());
106110
Ok(())
107111
}
108112

@@ -114,7 +118,7 @@ impl Memory {
114118
);
115119
}
116120
let aligned_address = address - address % 4;
117-
let bytes = self.0.get(&aligned_address).cloned().unwrap_or_default();
121+
let bytes = self.data.get(&aligned_address).cloned().unwrap_or_default();
118122
let value = &bytes[(address % 4) as usize..(address % 4) as usize + 2];
119123
Ok(u16::from_le_bytes(
120124
value.try_into().map_err(|_| MemoryError::LoadHalf)?,
@@ -127,7 +131,7 @@ impl Memory {
127131
}
128132
let aligned_address = address - address % 4;
129133
let entry = self
130-
.0
134+
.data
131135
.entry(aligned_address)
132136
.or_insert_with(|| [0, 0, 0, 0]);
133137
let bytes = value.to_le_bytes();
@@ -140,15 +144,17 @@ impl Memory {
140144
if length > MAX_PUBLIC_OUTPUT_COMMIT_SIZE {
141145
return Err(MemoryError::CommitSizeExceeded);
142146
}
147+
// Store in the regular memory address space (for prover trace compatibility)
143148
self.store_word(PUBLIC_OUTPUT_START_INDEX, length as u32)?;
144149
let inputs = self.load_bytes(address, length);
145150
self.set_bytes_aligned(PUBLIC_OUTPUT_START_INDEX + 4, &inputs)?;
151+
// Also store in the dedicated field so std runtime cleanup can't overwrite it
152+
self.committed_output = inputs;
146153
Ok(())
147154
}
148155

149156
pub fn read_return_value(&self) -> Result<Vec<u8>, MemoryError> {
150-
let size = self.load_word(PUBLIC_OUTPUT_START_INDEX)?;
151-
Ok(self.load_bytes(PUBLIC_OUTPUT_START_INDEX + 4, size as u64))
157+
Ok(self.committed_output.clone())
152158
}
153159

154160
pub fn store_private_inputs(&mut self, inputs: Vec<u8>) -> Result<(), MemoryError> {
@@ -172,7 +178,7 @@ impl Memory {
172178
let end = addr + len;
173179
while addr < end {
174180
let aligned = addr - (addr % 4);
175-
let bytes = self.0.get(&aligned).cloned().unwrap_or_default();
181+
let bytes = self.data.get(&aligned).cloned().unwrap_or_default();
176182
let offset = (addr % 4) as usize;
177183
let take = std::cmp::min(4 - offset, (end - addr) as usize);
178184
result.extend_from_slice(&bytes[offset..offset + take]);
@@ -190,7 +196,7 @@ impl Memory {
190196
for chunk in inputs.chunks(4) {
191197
let mut bytes = [0u8; 4];
192198
bytes[..chunk.len()].copy_from_slice(chunk);
193-
self.0.insert(addr, bytes);
199+
self.data.insert(addr, bytes);
194200
addr += 4;
195201
}
196202
Ok(())
1.83 KB
Binary file not shown.

executor/tests/rust.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,22 @@ fn test_ethrex() {
278278
);
279279
}
280280

281+
#[ignore = "Ignored until the vm is fast enough to run this test"]
282+
#[test]
283+
fn test_ethrex_empty_block() {
284+
use guest_program::{execution::execution_program, input::ProgramInput};
285+
use rkyv::rancor::Error;
286+
use std::fs;
287+
let inputs = fs::read("tests/ethrex_empty_block.bin").unwrap();
288+
let input = rkyv::from_bytes::<ProgramInput, Error>(&inputs).unwrap();
289+
let output = execution_program(input).unwrap();
290+
run_program_and_check_public_output(
291+
"./program_artifacts/rust/ethrex.elf",
292+
output.encode(),
293+
inputs,
294+
);
295+
}
296+
281297
#[ignore = "Ignored until the vm is fast enough to run this test"]
282298
#[test]
283299
fn test_ckzg() {

prover/src/lib.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,17 @@ pub(crate) fn compute_expected_commit_bus_balance(
463463
pub fn prove(elf_bytes: &[u8]) -> Result<VmProof, Error> {
464464
prove_with_options(
465465
elf_bytes,
466+
vec![],
467+
&GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid"),
468+
&MaxRowsConfig::default(),
469+
)
470+
}
471+
472+
/// Prove an ELF binary execution with private input.
473+
pub fn prove_with_input(elf_bytes: &[u8], private_input: Vec<u8>) -> Result<VmProof, Error> {
474+
prove_with_options(
475+
elf_bytes,
476+
private_input,
466477
&GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid"),
467478
&MaxRowsConfig::default(),
468479
)
@@ -471,6 +482,7 @@ pub fn prove(elf_bytes: &[u8]) -> Result<VmProof, Error> {
471482
/// Prove an ELF binary execution with custom proof options and max rows config.
472483
pub fn prove_with_options(
473484
elf_bytes: &[u8],
485+
private_input: Vec<u8>,
474486
proof_options: &ProofOptions,
475487
max_rows: &MaxRowsConfig,
476488
) -> Result<VmProof, Error> {
@@ -482,7 +494,8 @@ pub fn prove_with_options(
482494
let phase_start = std::time::Instant::now();
483495

484496
let program = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?;
485-
let executor = Executor::new(&program, vec![]).map_err(|e| Error::Execution(format!("{e}")))?;
497+
let executor =
498+
Executor::new(&program, private_input).map_err(|e| Error::Execution(format!("{e}")))?;
486499
let result = executor
487500
.run()
488501
.map_err(|e| Error::Execution(format!("{e}")))?;

prover/src/tests/prove_elfs_tests.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() {
801801
fn test_verify_rejects_tampered_public_output() {
802802
let elf_bytes = crate::test_utils::asm_elf_bytes("test_commit_4");
803803
let proof_options = ProofOptions::default_test_options();
804-
let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &Default::default())
804+
let vm_proof = crate::prove_with_options(&elf_bytes, vec![], &proof_options, &Default::default())
805805
.expect("Prover should succeed for test_commit_4");
806806
assert!(
807807
crate::verify_with_options(&vm_proof, &elf_bytes, &proof_options)
@@ -1669,7 +1669,7 @@ fn test_verify_rejects_zero_table_counts() {
16691669
let elf_bytes = crate::test_utils::asm_elf_bytes("sub");
16701670
let proof_options = ProofOptions::default_test_options();
16711671

1672-
let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &Default::default())
1672+
let vm_proof = crate::prove_with_options(&elf_bytes, vec![], &proof_options, &Default::default())
16731673
.expect("Prover should succeed on valid program");
16741674

16751675
assert!(
@@ -1703,7 +1703,7 @@ fn test_verify_rejects_zero_cpu_count() {
17031703
let elf_bytes = crate::test_utils::asm_elf_bytes("sub");
17041704
let proof_options = ProofOptions::default_test_options();
17051705

1706-
let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &Default::default())
1706+
let vm_proof = crate::prove_with_options(&elf_bytes, vec![], &proof_options, &Default::default())
17071707
.expect("Prover should succeed on valid program");
17081708

17091709
let tampered_proof = crate::VmProof {
@@ -1724,7 +1724,7 @@ fn test_verify_rejects_zero_memw_count() {
17241724
let elf_bytes = crate::test_utils::asm_elf_bytes("sub");
17251725
let proof_options = ProofOptions::default_test_options();
17261726

1727-
let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &Default::default())
1727+
let vm_proof = crate::prove_with_options(&elf_bytes, vec![], &proof_options, &Default::default())
17281728
.expect("Prover should succeed on valid program");
17291729

17301730
let tampered_proof = crate::VmProof {
@@ -1799,7 +1799,7 @@ fn test_small_max_rows_splits_tables() {
17991799
let proof_options = ProofOptions::default_test_options();
18001800
let max_rows = crate::tables::MaxRowsConfig::small();
18011801

1802-
let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &max_rows)
1802+
let vm_proof = crate::prove_with_options(&elf_bytes, vec![], &proof_options, &max_rows)
18031803
.expect("Prover should succeed with small max_rows");
18041804

18051805
// With 2^5 max rows and 64+ instructions, tables should have multiple chunks.
@@ -1850,7 +1850,7 @@ fn test_verify_rejects_inflated_table_counts() {
18501850
let elf_bytes = crate::test_utils::asm_elf_bytes("sub");
18511851
let proof_options = ProofOptions::default_test_options();
18521852

1853-
let vm_proof = crate::prove_with_options(&elf_bytes, &proof_options, &Default::default())
1853+
let vm_proof = crate::prove_with_options(&elf_bytes, vec![], &proof_options, &Default::default())
18541854
.expect("Prover should succeed on valid program");
18551855

18561856
// Inflate cpu count — total won't match proof.proofs.len()
@@ -1870,6 +1870,24 @@ fn test_verify_rejects_inflated_table_counts() {
18701870
);
18711871
}
18721872

1873+
/// Prove and verify an empty Ethereum block via the ethrex guest program.
1874+
/// Requires ~64GB RAM (11M instructions generate large multi-table traces).
1875+
#[test]
1876+
#[ignore = "Requires ~64GB RAM: proves an empty Ethereum block (11M instructions)"]
1877+
fn test_prove_ethrex_empty_block() {
1878+
let _ = env_logger::builder().is_test(true).try_init();
1879+
let elf_bytes = std::fs::read("../executor/program_artifacts/rust/ethrex.elf")
1880+
.expect("Failed to read ethrex ELF");
1881+
let input = std::fs::read("../executor/tests/ethrex_empty_block.bin")
1882+
.expect("Failed to read empty block fixture");
1883+
let vm_proof = crate::prove_with_input(&elf_bytes, input).expect("Proving failed");
1884+
assert!(
1885+
crate::verify(&vm_proof, &elf_bytes).expect("Verification failed"),
1886+
"Empty block proof should verify"
1887+
);
1888+
assert_eq!(vm_proof.public_output.len(), 160, "L1 output should be 160 bytes (5 * 32)");
1889+
}
1890+
18731891
/// Regression test: addiw with negative immediate must verify.
18741892
/// arg2_sign_bit is the sign bit of rv2 (bit 31), not of arg2, per spec
18751893
/// constraint CPU-CE61: MSB16[arg2_sign_bit; rv2[1]].

tools/generate_input/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[workspace]
2+
3+
[package]
4+
name = "generate-input"
5+
version = "0.1.0"
6+
edition = "2024"
7+
8+
[dependencies]
9+
ethrex-blockchain = { git = "https://github.com/lambdaclass/ethrex.git", branch = "crypto-default-features" }
10+
ethrex-storage = { git = "https://github.com/lambdaclass/ethrex.git", branch = "crypto-default-features" }
11+
ethrex-common = { git = "https://github.com/lambdaclass/ethrex.git", branch = "crypto-default-features" }
12+
guest-program = { git = "https://github.com/lambdaclass/ethrex.git", package = "guest_program", default-features = false, branch = "crypto-default-features" }
13+
rkyv = { version = "0.8.10", features = ["std", "unaligned"] }
14+
tokio = { version = "1", features = ["full"] }

0 commit comments

Comments
 (0)