Skip to content

Commit 62af4c2

Browse files
authored
Feat/ef io interface (#579)
* ppend public output across commit ECALLs and raise the totalcap to 64 KB * Add the EF zkVM IO interface * lint * Raise public output cap to 1 MB, guard load_bytes overflow, drop no_mangle from host stubs * Return Result from load_bytes to drop the guest-reachable panic in Print/Panic/Commit ECALLs * adress comments * Use usize::try_from for len in load_bytes instead of an implicit cast. * Guard private input length cast and cross-check public_output before returning VmProof
1 parent 02d44b2 commit 62af4c2

11 files changed

Lines changed: 293 additions & 37 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[target.riscv64im-lambda-vm-elf]
2+
rustflags = [
3+
"--cfg", "getrandom_backend=\"custom\"",
4+
"-C", "passes=lower-atomic"
5+
]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[workspace]
2+
3+
[package]
4+
name = "ef_io_demo"
5+
version = "0.1.0"
6+
edition = "2024"
7+
8+
[dependencies]
9+
lambda-vm-syscalls = { path = "../../../../syscalls" }
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Demo guest exercising the EF zkVM IO interface (`read_input` / `write_output`).
2+
//
3+
// Reads the private input via the EF zero-copy `read_input` shim, then emits it
4+
// back as the public output in TWO `write_output` calls (split in halves) to
5+
// exercise the multi-call concatenation requirement of the EF spec.
6+
use lambda_vm_syscalls as syscalls;
7+
8+
pub fn main() {
9+
let mut buf_ptr: *const u8 = core::ptr::null();
10+
let mut buf_size: usize = 0;
11+
unsafe {
12+
syscalls::ef_io::read_input(&mut buf_ptr, &mut buf_size);
13+
}
14+
15+
if buf_size > 0 {
16+
let half = buf_size / 2;
17+
unsafe {
18+
syscalls::ef_io::write_output(buf_ptr, half);
19+
syscalls::ef_io::write_output(buf_ptr.add(half), buf_size - half);
20+
}
21+
}
22+
}

executor/src/vm/instruction/execution.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ impl Instruction {
304304
// It is not the correct implementation of ecall/ebreak
305305
let pointer = registers.read(10)?;
306306
let len = registers.read(11)?;
307-
let bytes = memory.load_bytes(pointer, len);
307+
let bytes = memory.load_bytes(pointer, len)?;
308308
let value =
309309
str::from_utf8(&bytes).map_err(|_| ExecutionError::IncorrectMessage)?;
310310
println!("PRINT VM: {}", value);
@@ -313,7 +313,7 @@ impl Instruction {
313313
// panic
314314
let pointer = registers.read(10)?;
315315
let len = registers.read(11)?;
316-
let bytes = memory.load_bytes(pointer, len);
316+
let bytes = memory.load_bytes(pointer, len)?;
317317
let value =
318318
str::from_utf8(&bytes).map_err(|_| ExecutionError::IncorrectMessage)?;
319319
return Err(ExecutionError::Panic(value.to_owned()));

executor/src/vm/memory.rs

Lines changed: 124 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ impl BuildHasher for U64BuildHasher {
3838

3939
pub type U64HashMap<V> = HashMap<u64, V, U64BuildHasher>;
4040

41-
// TODO: Correctly define this
42-
const MAX_PUBLIC_OUTPUT_COMMIT_SIZE: u64 = 1024;
43-
const PUBLIC_OUTPUT_START_INDEX: u64 = 0;
41+
/// Total cap on public output bytes across all `commit_public_output` calls.
42+
/// The COMMIT AIR concatenates calls via the running `x254` index, so this
43+
/// is enforced as a running-total budget rather than a per-call limit.
44+
pub const MAX_PUBLIC_OUTPUT_TOTAL_SIZE: u64 = 1024 * 1024;
4445
/// Maximum size of the private input memory region (in bytes).
4546
pub const MAX_PRIVATE_INPUT_SIZE: u64 = 6700000;
4647
/// Fixed high address where private input is mapped. Guest programs can read
@@ -50,19 +51,30 @@ pub const MAX_PRIVATE_INPUT_SIZE: u64 = 6700000;
5051
pub const PRIVATE_INPUT_START_INDEX: u64 = 0xFF000000;
5152

5253
#[derive(Default, Debug)]
53-
pub struct Memory(U64HashMap<[u8; 4]>);
54+
pub struct Memory {
55+
cells: U64HashMap<[u8; 4]>,
56+
/// Bytes committed to public output via `commit_public_output`. The
57+
/// COMMIT AIR doesn't write to a fixed memory region (it streams bytes
58+
/// onto the Commit bus by `index`), so this buffer is purely the
59+
/// executor's view used by `read_return_value` and CLI display.
60+
public_output: Vec<u8>,
61+
}
5462

5563
impl Memory {
5664
pub fn load_byte(&self, address: u64) -> u8 {
5765
let aligned_address = address - address % 4;
58-
let value = self.0.get(&aligned_address).cloned().unwrap_or_default();
66+
let value = self
67+
.cells
68+
.get(&aligned_address)
69+
.cloned()
70+
.unwrap_or_default();
5971
value[(address % 4) as usize]
6072
}
6173

6274
pub fn store_byte(&mut self, address: u64, value: u8) {
6375
let aligned_address = address - address % 4;
6476
let entry = self
65-
.0
77+
.cells
6678
.entry(aligned_address)
6779
.or_insert_with(|| [0, 0, 0, 0]);
6880
entry[(address % 4) as usize] = value;
@@ -72,7 +84,7 @@ impl Memory {
7284
if !address.is_multiple_of(4) {
7385
return Err(MemoryError::UnalignedAccess);
7486
}
75-
let bytes = self.0.get(&address).cloned().unwrap_or_default();
87+
let bytes = self.cells.get(&address).cloned().unwrap_or_default();
7688
Ok(u32::from_le_bytes(bytes))
7789
}
7890

@@ -81,7 +93,7 @@ impl Memory {
8193
return Err(MemoryError::UnalignedAccess);
8294
}
8395
let bytes = value.to_le_bytes();
84-
self.0.insert(address, bytes);
96+
self.cells.insert(address, bytes);
8597
Ok(())
8698
}
8799

@@ -90,8 +102,8 @@ impl Memory {
90102
if !address.is_multiple_of(8) {
91103
return Err(MemoryError::UnalignedAccess);
92104
}
93-
let low_bytes = self.0.get(&address).cloned().unwrap_or_default();
94-
let high_bytes = self.0.get(&(address + 4)).cloned().unwrap_or_default();
105+
let low_bytes = self.cells.get(&address).cloned().unwrap_or_default();
106+
let high_bytes = self.cells.get(&(address + 4)).cloned().unwrap_or_default();
95107
let low = u32::from_le_bytes(low_bytes) as u64;
96108
let high = u32::from_le_bytes(high_bytes) as u64;
97109
Ok(low | (high << 32))
@@ -104,8 +116,8 @@ impl Memory {
104116
}
105117
let low = (value & 0xFFFFFFFF) as u32;
106118
let high = (value >> 32) as u32;
107-
self.0.insert(address, low.to_le_bytes());
108-
self.0.insert(address + 4, high.to_le_bytes());
119+
self.cells.insert(address, low.to_le_bytes());
120+
self.cells.insert(address + 4, high.to_le_bytes());
109121
Ok(())
110122
}
111123

@@ -117,7 +129,11 @@ impl Memory {
117129
);
118130
}
119131
let aligned_address = address - address % 4;
120-
let bytes = self.0.get(&aligned_address).cloned().unwrap_or_default();
132+
let bytes = self
133+
.cells
134+
.get(&aligned_address)
135+
.cloned()
136+
.unwrap_or_default();
121137
let value = &bytes[(address % 4) as usize..(address % 4) as usize + 2];
122138
Ok(u16::from_le_bytes(
123139
value.try_into().map_err(|_| MemoryError::LoadHalf)?,
@@ -130,7 +146,7 @@ impl Memory {
130146
}
131147
let aligned_address = address - address % 4;
132148
let entry = self
133-
.0
149+
.cells
134150
.entry(aligned_address)
135151
.or_insert_with(|| [0, 0, 0, 0]);
136152
let bytes = value.to_le_bytes();
@@ -139,19 +155,25 @@ impl Memory {
139155
Ok(())
140156
}
141157

158+
/// Append `length` bytes from guest memory starting at `address` to the
159+
/// public output. The COMMIT AIR concatenates calls via the running
160+
/// `x254` index, and the trace builder accumulates `commit_ops` into
161+
/// `VmProof.public_output`; this method maintains the executor's view
162+
/// of the same byte stream so `read_return_value` matches.
142163
pub fn commit_public_output(&mut self, address: u64, length: u64) -> Result<(), MemoryError> {
143-
if length > MAX_PUBLIC_OUTPUT_COMMIT_SIZE {
164+
let new_total = (self.public_output.len() as u64)
165+
.checked_add(length)
166+
.ok_or(MemoryError::CommitSizeExceeded)?;
167+
if new_total > MAX_PUBLIC_OUTPUT_TOTAL_SIZE {
144168
return Err(MemoryError::CommitSizeExceeded);
145169
}
146-
self.store_word(PUBLIC_OUTPUT_START_INDEX, length as u32)?;
147-
let inputs = self.load_bytes(address, length);
148-
self.set_bytes_aligned(PUBLIC_OUTPUT_START_INDEX + 4, &inputs)?;
170+
let bytes = self.load_bytes(address, length)?;
171+
self.public_output.extend_from_slice(&bytes);
149172
Ok(())
150173
}
151174

152175
pub fn read_return_value(&self) -> Result<Vec<u8>, MemoryError> {
153-
let size = self.load_word(PUBLIC_OUTPUT_START_INDEX)?;
154-
Ok(self.load_bytes(PUBLIC_OUTPUT_START_INDEX + 4, size as u64))
176+
Ok(self.public_output.clone())
155177
}
156178

157179
/// Pre-loads private input bytes at `PRIVATE_INPUT_START_INDEX` as a
@@ -164,23 +186,29 @@ impl Memory {
164186
if inputs.len() as u64 > MAX_PRIVATE_INPUT_SIZE {
165187
return Err(MemoryError::PrivateInputSizeExceeded);
166188
}
167-
self.store_word(PRIVATE_INPUT_START_INDEX, inputs.len() as u32)?;
189+
let len_u32 =
190+
u32::try_from(inputs.len()).map_err(|_| MemoryError::PrivateInputSizeExceeded)?;
191+
self.store_word(PRIVATE_INPUT_START_INDEX, len_u32)?;
168192
self.set_bytes_aligned(PRIVATE_INPUT_START_INDEX + 4, &inputs)?;
169193
Ok(())
170194
}
171195

172-
pub fn load_bytes(&self, mut addr: u64, len: u64) -> Vec<u8> {
173-
let mut result = Vec::with_capacity(len as usize);
174-
let end = addr + len;
196+
pub fn load_bytes(&self, mut addr: u64, len: u64) -> Result<Vec<u8>, MemoryError> {
197+
let end = addr.checked_add(len).ok_or(MemoryError::AddressOverflow)?;
198+
let len_usize = usize::try_from(len).map_err(|_| MemoryError::AllocationFailed)?;
199+
let mut result = Vec::new();
200+
result
201+
.try_reserve_exact(len_usize)
202+
.map_err(|_| MemoryError::AllocationFailed)?;
175203
while addr < end {
176204
let aligned = addr - (addr % 4);
177-
let bytes = self.0.get(&aligned).cloned().unwrap_or_default();
205+
let bytes = self.cells.get(&aligned).cloned().unwrap_or_default();
178206
let offset = (addr % 4) as usize;
179207
let take = std::cmp::min(4 - offset, (end - addr) as usize);
180208
result.extend_from_slice(&bytes[offset..offset + take]);
181209
addr += take as u64;
182210
}
183-
result
211+
Ok(result)
184212
}
185213

186214
/// Helper method to store a given input at an aligned address. It may also overwrite existing bytes with zero if inputs is not divisible by 4
@@ -192,7 +220,7 @@ impl Memory {
192220
for chunk in inputs.chunks(4) {
193221
let mut bytes = [0u8; 4];
194222
bytes[..chunk.len()].copy_from_slice(chunk);
195-
self.0.insert(addr, bytes);
223+
self.cells.insert(addr, bytes);
196224
addr += 4;
197225
}
198226
Ok(())
@@ -209,6 +237,10 @@ pub enum MemoryError {
209237
CommitSizeExceeded,
210238
#[error("Private input size exceeded")]
211239
PrivateInputSizeExceeded,
240+
#[error("Address range exceeds u64::MAX")]
241+
AddressOverflow,
242+
#[error("Failed to allocate memory for load_bytes")]
243+
AllocationFailed,
212244
}
213245

214246
#[cfg(test)]
@@ -234,7 +266,7 @@ mod tests {
234266
}
235267

236268
#[test]
237-
fn test_commit_public_output_overwrites() {
269+
fn test_commit_public_output_appends() {
238270
let mut memory = Memory::default();
239271
memory.store_byte(0x100, b'a');
240272
memory.store_byte(0x101, b'b');
@@ -248,19 +280,78 @@ mod tests {
248280
.commit_public_output(0x104, 2)
249281
.expect("second commit should succeed");
250282

251-
// Overwrite semantics: second commit replaces first
283+
// Append semantics: calls concatenate (EF zkVM IO interface).
252284
assert_eq!(
253285
memory
254286
.read_return_value()
255287
.expect("public output should be readable"),
256-
b"cd".to_vec()
288+
b"abcd".to_vec()
257289
);
258290
}
259291

260292
#[test]
261-
fn test_commit_public_output_size_exceeded() {
293+
fn test_commit_public_output_empty_is_ok() {
294+
let mut memory = Memory::default();
295+
memory
296+
.commit_public_output(0, 0)
297+
.expect("zero-length commit should succeed");
298+
assert!(
299+
memory
300+
.read_return_value()
301+
.expect("public output should be readable")
302+
.is_empty()
303+
);
304+
}
305+
306+
#[test]
307+
fn test_commit_public_output_address_overflow() {
308+
let mut memory = Memory::default();
309+
let err = memory
310+
.commit_public_output(u64::MAX, 2)
311+
.expect_err("address overflow must error, not panic");
312+
assert!(matches!(err, super::MemoryError::AddressOverflow));
313+
}
314+
315+
#[test]
316+
fn test_load_bytes_huge_len_returns_alloc_error() {
317+
let memory = Memory::default();
318+
// A multi-petabyte allocation request from a guest must fail cleanly,
319+
// not abort the host process via OOM. `addr=0` and `len=1<<50` keep
320+
// `checked_add` happy so the path reaches the allocation.
321+
let huge = 1u64 << 50;
322+
let err = memory
323+
.load_bytes(0, huge)
324+
.expect_err("huge alloc must error, not abort");
325+
assert!(matches!(err, super::MemoryError::AllocationFailed));
326+
}
327+
328+
#[test]
329+
fn test_load_bytes_overflow_errors() {
330+
let memory = Memory::default();
331+
let err = memory
332+
.load_bytes(u64::MAX, 2)
333+
.expect_err("address overflow must error, not panic");
334+
assert!(matches!(err, super::MemoryError::AddressOverflow));
335+
}
336+
337+
#[test]
338+
fn test_commit_public_output_total_cap() {
262339
let mut memory = Memory::default();
263-
let err = memory.commit_public_output(0x100, 1025);
264-
assert!(err.is_err());
340+
// Seed enough source bytes for two 512 KB writes.
341+
let chunk = vec![0xAB; 512 * 1024];
342+
memory
343+
.set_bytes_aligned(0x1_0000, &chunk)
344+
.expect("seed should succeed");
345+
346+
memory
347+
.commit_public_output(0x1_0000, 512 * 1024)
348+
.expect("first 512 KB commit should succeed");
349+
memory
350+
.commit_public_output(0x1_0000, 512 * 1024)
351+
.expect("second 512 KB commit should succeed (total = 1 MB)");
352+
353+
// One more byte exceeds the 1 MB total cap.
354+
let err = memory.commit_public_output(0x1_0000, 1).unwrap_err();
355+
assert!(matches!(err, super::MemoryError::CommitSizeExceeded));
265356
}
266357
}

executor/tests/rust.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ fn test_commit() {
160160
);
161161
}
162162

163+
#[test]
164+
fn test_ef_io_demo_concatenates_writes() {
165+
// Demo guest reads its private input via EF `read_input`, then emits it
166+
// back as the public output via TWO `write_output` calls (split in halves).
167+
// The COMMIT AIR concatenates the two calls; the executor's
168+
// `commit_public_output` appends in the same order.
169+
let input: Vec<u8> = b"hello world!".to_vec();
170+
run_program_and_check_public_output(
171+
"./program_artifacts/rust/ef_io_demo.elf",
172+
input.clone(),
173+
input,
174+
);
175+
}
176+
163177
#[test]
164178
fn test_commit_sum() {
165179
run_program_and_check_public_output(

prover/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,11 @@ pub fn prove_with_options_and_inputs(
651651
.filter(|c| c.is_private_input)
652652
.count();
653653

654+
debug_assert_eq!(
655+
traces.public_output_bytes, result.return_values.memory_values,
656+
"public output diverged between executor view and trace reconstruction"
657+
);
658+
654659
Ok(VmProof {
655660
proof,
656661
runtime_page_ranges,

0 commit comments

Comments
 (0)