From 1f777c4563d8738271b174d8f9c09aae598814c7 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Mon, 3 Aug 2026 22:48:39 -0300 Subject: [PATCH 01/13] feat(dma): prove memset with a dedicated DMA_SET table Routes the guest's strong `memset` symbol through a bounded DMA ecall, the same shape as the memcpy stub #874 added, and proves each chunk with a new 20-column DMA_SET table. memset is cheaper than memcpy rather than a copy of it: there is no source to read, so a row emits one MEMW write and no read (half the memory traffic per byte), and every byte written is the same constant, so one `fill` column replaces memcpy's eight value lanes. `fill_wide` is `fill` on eight-byte rows and zero on one-byte tail rows, which lets one write tuple serve both widths. `fill <= 255` is proven on the first row; the executor rejects wider values and the guest stub masks a1, mirroring how the byte-count bound is handled. Measured on real mainnet block 25368371 (50,781,394 cycles baseline): #874 memcpy alone 41,642,609 -17.99% + memset (this) 40,338,153 -20.57% mem* routines fall from 24.41% to 4.84% of guest cycles. No existing AIR changes: CPU stays at 38 columns and the new table only adds senders to existing buses. --- bench_vs/lambda/recursion/Cargo.lock | 26 +- .../rust/dma_memset_cases/.cargo/config.toml | 9 + .../programs/rust/dma_memset_cases/Cargo.lock | 294 ++++++++++++ .../programs/rust/dma_memset_cases/Cargo.toml | 9 + .../rust/dma_memset_cases/src/main.rs | 49 ++ .../rust/keccak_transcript_pattern/Cargo.lock | 36 +- executor/src/tests/dma_tests.rs | 81 +++- executor/src/vm/instruction/execution.rs | 40 +- executor/tests/rust.rs | 24 +- prover/src/auto_storage.rs | 9 + prover/src/lib.rs | 11 +- prover/src/tables/cpu.rs | 6 + prover/src/tables/dma_set.rs | 453 ++++++++++++++++++ prover/src/tables/mod.rs | 1 + prover/src/tables/trace_builder.rs | 275 +++++++++++ prover/src/tables/types.rs | 8 + prover/src/test_utils.rs | 15 + prover/src/tests/prove_elfs_tests.rs | 22 + syscalls/src/syscalls.rs | 44 ++ 19 files changed, 1350 insertions(+), 62 deletions(-) create mode 100644 executor/programs/rust/dma_memset_cases/.cargo/config.toml create mode 100644 executor/programs/rust/dma_memset_cases/Cargo.lock create mode 100644 executor/programs/rust/dma_memset_cases/Cargo.toml create mode 100644 executor/programs/rust/dma_memset_cases/src/main.rs create mode 100644 prover/src/tables/dma_set.rs diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index 3e7f8e9a5..061f211c1 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -129,8 +129,6 @@ dependencies = [ "digest", "lambda-vm-syscalls", "math", - "rand 0.8.6", - "rand_chacha 0.3.1", "rkyv", "serde", "sha3", @@ -399,7 +397,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "lazy_static", - "rand 0.9.4", + "rand", "riscv", "thiserror", ] @@ -435,7 +433,6 @@ dependencies = [ "getrandom 0.2.17", "num-bigint", "num-traits", - "rand 0.8.6", "rayon", "rkyv", "serde", @@ -585,35 +582,16 @@ dependencies = [ "ptr_meta", ] -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" diff --git a/executor/programs/rust/dma_memset_cases/.cargo/config.toml b/executor/programs/rust/dma_memset_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memset_cases/Cargo.lock b/executor/programs/rust/dma_memset_cases/Cargo.lock new file mode 100644 index 000000000..22c1e11fe --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memset_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memset_cases/Cargo.toml b/executor/programs/rust/dma_memset_cases/Cargo.toml new file mode 100644 index 000000000..de5dc5ede --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memset_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memset_cases/src/main.rs b/executor/programs/rust/dma_memset_cases/src/main.rs new file mode 100644 index 000000000..5caf0e285 --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/src/main.rs @@ -0,0 +1,49 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memset(dst: *mut u8, fill: i32, count: usize) -> *mut u8; +} + +/// `black_box` on the count keeps LLVM from turning these into inline stores, +/// so every call really does reach the strong `memset` symbol and the DMA ecall. +#[inline(never)] +fn dma_set(dst: *mut u8, fill: i32, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memset(dst, fill, count) } +} + +pub fn main() { + let mut buffer = [0u8; 777]; + + // Every row-schedule boundary: empty, sub-tail, exact widths, the 256-byte + // per-ecall cap, and one length that forces several chunked ecalls. + for count in [0usize, 1, 7, 8, 9, 31, 32, 33, 127, 128, 255, 256] { + buffer.fill(0xA5); + let returned = dma_set(buffer.as_mut_ptr(), 0x3C, count); + assert_eq!(returned, buffer.as_mut_ptr()); + assert!(buffer[..count].iter().all(|&byte| byte == 0x3C)); + assert!(buffer[count..].iter().all(|&byte| byte == 0xA5)); + } + + // More than one chunk: 777 bytes becomes four bounded DMA ecalls. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), 0x5A, buffer.len()); + assert!(buffer.iter().all(|&byte| byte == 0x5A)); + + // The guest stub masks the fill to its low byte, matching C's + // `memset(void*, int, size_t)` writing `(unsigned char)c`. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), 0x1FF, 64); + assert!(buffer[..64].iter().all(|&byte| byte == 0xFF)); + + // Unaligned destination that also crosses a 4 KiB page boundary. + let mut page_buffer = [0u8; 8192]; + let to_boundary = 4096 - (page_buffer.as_ptr() as usize & 4095); + let offset = to_boundary.saturating_sub(5); + dma_set(unsafe { page_buffer.as_mut_ptr().add(offset) }, 0x77, 256); + assert!(page_buffer[offset..offset + 256] + .iter() + .all(|&byte| byte == 0x77)); + + syscalls::syscalls::commit(b"dma-memset-ok"); +} diff --git a/executor/programs/rust/keccak_transcript_pattern/Cargo.lock b/executor/programs/rust/keccak_transcript_pattern/Cargo.lock index 4e5afb1bd..0b59195aa 100644 --- a/executor/programs/rust/keccak_transcript_pattern/Cargo.lock +++ b/executor/programs/rust/keccak_transcript_pattern/Cargo.lock @@ -88,8 +88,6 @@ dependencies = [ "digest", "lambda-vm-syscalls", "math", - "rand 0.8.7", - "rand_chacha 0.3.1", "serde", "sha3", ] @@ -240,7 +238,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "lazy_static", - "rand 0.9.5", + "rand", "riscv", "thiserror", ] @@ -270,7 +268,6 @@ dependencies = [ "getrandom 0.2.17", "num-bigint", "num-traits", - "rand 0.8.7", "rayon", "serde", "serde_json", @@ -361,33 +358,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -397,15 +375,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.9.5" diff --git a/executor/src/tests/dma_tests.rs b/executor/src/tests/dma_tests.rs index 7965bfbdb..637507c89 100644 --- a/executor/src/tests/dma_tests.rs +++ b/executor/src/tests/dma_tests.rs @@ -1,6 +1,7 @@ use crate::vm::instruction::decoding::Instruction; use crate::vm::instruction::execution::{ - DMA_MEMCPY_MAX_BYTES, DMA_MEMCPY_SYSCALL_NUMBER, ExecutionError, + DMA_MEMCPY_MAX_BYTES, DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_MAX_FILL, + DMA_MEMSET_SYSCALL_NUMBER, ExecutionError, }; use crate::vm::memory::Memory; use crate::vm::registers::Registers; @@ -115,3 +116,81 @@ proptest! { prop_assert_eq!(actual, expected); } } + +fn run_memset(memory: &mut Memory, dst: u64, fill: u64, count: u64) -> Result<(), ExecutionError> { + let mut registers = Registers::default(); + let mut pc = 0; + registers.write(17, DMA_MEMSET_SYSCALL_NUMBER)?; + registers.write(10, dst)?; + registers.write(11, fill)?; + registers.write(12, count)?; + Instruction::EcallEbreak.run(&mut pc, &mut registers, memory)?; + Ok(()) +} + +#[test] +fn dma_memset_fills_unaligned_body_and_tail() { + let mut memory = Memory::default(); + // 27 bytes = three eight-byte rows plus a three-byte tail, at an unaligned base. + run_memset(&mut memory, 0x2005, 0x3C, 27).unwrap(); + + assert_eq!(memory.load_bytes(0x2005, 27).unwrap(), vec![0x3Cu8; 27]); + // Neighbours must be untouched. + assert_eq!(memory.load_byte(0x2004), 0); + assert_eq!(memory.load_byte(0x2005 + 27), 0); +} + +#[test] +fn dma_memset_zero_count_writes_nothing() { + let mut memory = Memory::default(); + memory.store_byte(0x3000, 0x11); + run_memset(&mut memory, 0x3000, 0xFF, 0).unwrap(); + assert_eq!(memory.load_byte(0x3000), 0x11); +} + +#[test] +fn dma_memset_rejects_wrapping_range() { + let mut memory = Memory::default(); + assert!(run_memset(&mut memory, u64::MAX - 3, 0x11, 8).is_err()); +} + +#[test] +fn dma_memset_rejects_oversized_chunk() { + let mut memory = Memory::default(); + assert!(matches!( + run_memset(&mut memory, 0x2000, 0x11, DMA_MEMCPY_MAX_BYTES + 1), + Err(ExecutionError::DmaMemcpyChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 + )); +} + +#[test] +fn dma_memset_rejects_fill_wider_than_a_byte() { + // The guest stub masks `a1` with `andi ..., 255`, so only a malformed call + // reaches here. Rejecting it is what lets the AIR prove the bound with one LT. + let mut memory = Memory::default(); + assert!(matches!( + run_memset(&mut memory, 0x2000, DMA_MEMSET_MAX_FILL + 1, 8), + Err(ExecutionError::DmaMemsetFillTooLarge(c)) if c == DMA_MEMSET_MAX_FILL + 1 + )); +} + +proptest! { + #[test] + fn dma_memset_matches_reference_fill( + dst_offset in 0usize..64, + count in 0usize..200, + fill in 0u8..=255, + ) { + const BASE: u64 = 0x9000; + const REGION: usize = 320; + + let mut expected = vec![0u8; REGION]; + expected[dst_offset..dst_offset + count].fill(fill); + + let mut memory = Memory::default(); + run_memset(&mut memory, BASE + dst_offset as u64, u64::from(fill), count as u64).unwrap(); + + let actual = memory.load_bytes(BASE, REGION as u64).unwrap(); + prop_assert_eq!(actual, expected); + } +} diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 6c90af714..8ab24763b 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -19,6 +19,9 @@ pub enum SyscallNumbers { // Placeholder discriminant. The actual syscall value is DMA_MEMCPY_SYSCALL_NUMBER. // DMA memcpy chunks are proven by the dedicated DMA table. DmaMemcpy = 95, + // Placeholder discriminant. The actual syscall value is DMA_MEMSET_SYSCALL_NUMBER. + // DMA memset chunks are proven by the dedicated DMA_SET table. + DmaMemset = 96, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -40,6 +43,14 @@ pub const DMA_MEMCPY_SYSCALL_NUMBER: u64 = u64::MAX - 2; /// larger copies, and the prover enforces this bound on every first DMA row. pub const DMA_MEMCPY_MAX_BYTES: u64 = 256; +/// DMA memset syscall number. Must match `syscalls/src/syscalls.rs`. +pub const DMA_MEMSET_SYSCALL_NUMBER: u64 = u64::MAX - 3; +/// Largest fill value a DMA memset ecall accepts. C's `memset` writes +/// `(unsigned char)c`, so the guest stub masks `a1` down to this range; a wider +/// value is a malformed call. Bounding it here lets the DMA_SET AIR prove the +/// same bound with one ALU LT instead of decomposing the register. +pub const DMA_MEMSET_MAX_FILL: u64 = 255; + /// `2^32`. ECSM memory operands must not overflow their lower 32-bit address limb when the /// largest per-access offset is added: the 32-byte operands reach offset +31 (last byte). const LOW_LIMB: u64 = 1 << 32; @@ -55,6 +66,7 @@ impl TryFrom for SyscallNumbers { v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), v if v == DMA_MEMCPY_SYSCALL_NUMBER => Ok(SyscallNumbers::DmaMemcpy), + v if v == DMA_MEMSET_SYSCALL_NUMBER => Ok(SyscallNumbers::DmaMemset), _ => Err(()), } } @@ -79,7 +91,8 @@ impl SyscallNumbers { | SyscallNumbers::Panic | SyscallNumbers::Commit | SyscallNumbers::Halt - | SyscallNumbers::DmaMemcpy => None, + | SyscallNumbers::DmaMemcpy + | SyscallNumbers::DmaMemset => None, } } } @@ -491,6 +504,29 @@ impl Instruction { src2_val = src; dst_val = n; } + SyscallNumbers::DmaMemset => { + // memset(dst = x10, fill = x11, n = x12). No source range + // to snapshot: every byte written is the same constant, so + // the DMA_SET trace carries one fill column instead of the + // eight value columns memcpy needs. + let dst = registers.read(10)?; + let fill = registers.read(11)?; + let n = registers.read(12)?; + if n > DMA_MEMCPY_MAX_BYTES { + return Err(ExecutionError::DmaMemcpyChunkTooLarge(n)); + } + if fill > DMA_MEMSET_MAX_FILL { + return Err(ExecutionError::DmaMemsetFillTooLarge(fill)); + } + dst.checked_add(n).ok_or(MemoryError::AddressOverflow)?; + + let byte = fill as u8; + for i in 0..n { + memory.store_byte(dst + i, byte); + } + src2_val = fill; + dst_val = n; + } SyscallNumbers::Halt => { // halt return Ok(Log { @@ -673,6 +709,8 @@ pub enum ExecutionError { EcsmOperandOverlap, #[error("DMA memcpy chunk has {0} bytes; maximum per ecall is {DMA_MEMCPY_MAX_BYTES}")] DmaMemcpyChunkTooLarge(u64), + #[error("DMA memset fill is {0}; maximum is {DMA_MEMSET_MAX_FILL}")] + DmaMemsetFillTooLarge(u64), #[error("ECSM scalar multiplication error: {0}")] Ecsm(#[from] ecsm::EcsmError), } diff --git a/executor/tests/rust.rs b/executor/tests/rust.rs index 4eb3b32f9..037b64656 100644 --- a/executor/tests/rust.rs +++ b/executor/tests/rust.rs @@ -1,7 +1,10 @@ use executor::{ elf::Elf, vm::execution::{Executor, ReturnValues}, - vm::instruction::{decoding::Instruction, execution::DMA_MEMCPY_SYSCALL_NUMBER}, + vm::instruction::{ + decoding::Instruction, + execution::{DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER}, + }, }; // NOTE: These tests require 64-bit RISC-V ELF files (RV64IM). @@ -149,6 +152,25 @@ fn test_dma_memcpy_cases() { ); } +#[test] +fn test_dma_memset_cases() { + let elf_data = std::fs::read("./program_artifacts/rust/dma_memset_cases.elf").unwrap(); + let program = Elf::load(&elf_data).unwrap(); + let result = Executor::new(&program, vec![]).unwrap().run().unwrap(); + + assert_eq!(result.return_values.memory_values, b"dma-memset-ok"); + assert!( + result.logs.iter().any(|log| { + log.src1_val == DMA_MEMSET_SYSCALL_NUMBER + && matches!( + result.instructions.get(&log.current_pc), + Some(Instruction::EcallEbreak) + ) + }), + "the strong memset symbol must execute at least one DMA ecall" + ); +} + #[test] fn test_hashmap() { run_program_and_check_output("./program_artifacts/rust/hashmap.elf", 3, vec![]); diff --git a/prover/src/auto_storage.rs b/prover/src/auto_storage.rs index 88b363332..8dd7eee67 100644 --- a/prover/src/auto_storage.rs +++ b/prover/src/auto_storage.rs @@ -11,6 +11,9 @@ use crate::tables::commit::{bus_interactions as commit_buses, cols::NUM_COLUMNS use crate::tables::cpu::{bus_interactions as cpu_buses, cols::NUM_COLUMNS as CPU_COLS}; use crate::tables::decode::{bus_interactions as decode_buses, cols::NUM_COLUMNS as DECODE_COLS}; use crate::tables::dma::{bus_interactions as dma_buses, cols::NUM_COLUMNS as DMA_COLS}; +use crate::tables::dma_set::{ + bus_interactions as dma_set_buses, cols::NUM_COLUMNS as DMA_SET_COLS, +}; use crate::tables::dvrm::{bus_interactions as dvrm_buses, cols::NUM_COLUMNS as DVRM_COLS}; use crate::tables::halt::{bus_interactions as halt_buses, cols::NUM_COLUMNS as HALT_COLS}; use crate::tables::load::{bus_interactions as load_buses, cols::NUM_COLUMNS as LOAD_COLS}; @@ -184,6 +187,12 @@ fn table_specs(lengths: &TableLengths) -> Vec { aux_cols(dma_buses().len()), 1, ), + ( + lengths.dma_set_padded_rows, + DMA_SET_COLS as u64, + aux_cols(dma_set_buses().len()), + 1, + ), // BITWISE / DECODE / PAGE / REGISTER take the preprocessed-trace commit // path: it extracts ALL columns into the LDE and builds two Merkle trees // (precomputed_tree + mult_tree), so main_cols = full NUM_COLUMNS and diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 26398acfa..4501eaa3c 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -52,7 +52,7 @@ use crate::tables::trace_builder::count_table_lengths; use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, - create_cpu_air, create_cpu32_air, create_decode_air, create_dma_air, create_dvrm_air, + create_cpu_air, create_cpu32_air, create_decode_air, create_dma_air, create_dma_set_air, create_dvrm_air, create_ecdas_air, create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, @@ -82,8 +82,8 @@ pub struct RuntimePageRange { /// Number of tables that always contribute exactly one sub-proof, regardless /// of `TableCounts`: bitwise, decode, halt, commit, keccak, keccak_rnd, -/// keccak_rc, register, ecsm, ecdas, dma. -pub const FIXED_TABLE_COUNT: usize = 11; +/// keccak_rc, register, ecsm, ecdas, dma, dma_set. +pub const FIXED_TABLE_COUNT: usize = 12; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. @@ -518,6 +518,7 @@ pub(crate) struct VmAirs { pub ecsm: VmAir, pub ecdas: VmAir, pub dma: VmAir, + pub dma_set: VmAir, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -544,6 +545,7 @@ impl VmAirs { (self.ecsm.as_ref(), &mut traces.ecsm, &()), (self.ecdas.as_ref(), &mut traces.ecdas, &()), (self.dma.as_ref(), &mut traces.dma, &()), + (self.dma_set.as_ref(), &mut traces.dma_set, &()), (self.register.as_ref(), &mut traces.register, &()), ]; if self.include_halt { @@ -619,6 +621,7 @@ impl VmAirs { self.ecsm.as_ref(), self.ecdas.as_ref(), self.dma.as_ref(), + self.dma_set.as_ref(), self.register.as_ref(), ]; if self.include_halt { @@ -777,6 +780,7 @@ impl VmAirs { let ecsm: VmAir = Box::new(create_ecsm_air(proof_options)); let ecdas: VmAir = Box::new(create_ecdas_air(proof_options)); let dma: VmAir = Box::new(create_dma_air(proof_options)); + let dma_set: VmAir = Box::new(create_dma_set_air(proof_options)); let register: VmAir = if let Some((commitment, num_preprocessed_cols)) = register_preprocessed { Box::new( @@ -884,6 +888,7 @@ impl VmAirs { ecsm, ecdas, dma, + dma_set, register, pages, memw_registers, diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 88d0bf041..5c0a94be1 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -191,6 +191,9 @@ pub struct CpuOperation { /// Whether this ECALL is a DMA memcpy. Operands are recovered from x10/x11/x12. pub ecall_dma_memcpy: bool, + + /// Whether this ECALL is a DMA memset. Operands are recovered from x10/x11/x12. + pub ecall_dma_memset: bool, } impl CpuOperation { @@ -240,6 +243,8 @@ impl CpuOperation { f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; let ecall_dma_memcpy = f.ecall && log.src1_val == executor::vm::instruction::execution::DMA_MEMCPY_SYSCALL_NUMBER; + let ecall_dma_memset = f.ecall + && log.src1_val == executor::vm::instruction::execution::DMA_MEMSET_SYSCALL_NUMBER; // Word instructions are fully handled by CPU32; the main CPU row is a // delegate that only advances the PC and sends the CPU32 lookup. We still @@ -359,6 +364,7 @@ impl CpuOperation { keccak_state_addr, ecall_ecsm, ecall_dma_memcpy, + ecall_dma_memset, } } diff --git a/prover/src/tables/dma_set.rs b/prover/src/tables/dma_set.rs new file mode 100644 index 000000000..01e11426a --- /dev/null +++ b/prover/src/tables/dma_set.rs @@ -0,0 +1,453 @@ +//! DMA memset table — proves a `memset(dst, fill, n)` off the CPU execution trace. +//! +//! The guest's strong `memset` symbol (see `syscalls/src/syscalls.rs`) dispatches +//! bulk fills to the DMA memset ecall (`DMA_MEMSET_SYSCALL_NUMBER`); this table +//! proves the fill so the per-byte store loop leaves the CPU trace. +//! +//! Same streaming shape as the memcpy table (`dma.rs`): a row writes eight bytes +//! while `count >= 8`, otherwise one byte, and rows chain through `DmaSetNext` +//! until a terminal row where `count == 0`. The LT table pins that choice, so the +//! prover cannot select a convenient partition. +//! +//! Two things make this cheaper than memcpy rather than a copy of it: +//! +//! * **No source.** There is nothing to read, so a row emits one MEMW *write* at +//! `T+1` and no read at all — half the memory traffic per byte. There is also +//! no `src`/`src_incr` pair to carry or range-check. +//! * **No value lanes.** Every byte written is the same constant, so one `fill` +//! column replaces memcpy's eight value columns. `fill_wide` is `fill` on +//! eight-byte rows and zero on one-byte tail rows, which is what lets the same +//! write tuple serve both widths without per-lane constraints. +//! +//! The result is 20 columns against memcpy's 32, and 18 bus interactions against +//! 23. `fill <= 255` is proven on the first row, mirroring how `dma.rs` proves +//! the per-ecall byte bound: the executor rejects a wider value, so an honest +//! guest (whose stub masks `a1`) never trips it. +//! +//! ## Columns (20 total) +//! - `timestamp`: DWordWL (2) — the ECALL timestamp +//! - `dst`: DWordWL (2) — current destination byte address +//! - `dst_incr`: DWordHL (4) — dst + selected width +//! - `count`: DWordWL (2) — remaining byte count (including this byte; 0 on the end row) +//! - `count_decr`: DWordHL (4) — count - width (all 0xFFFF when count == 0) +//! - `fill`: byte being written +//! - `fill_wide`: `fill` on eight-byte rows, 0 on one-byte tail rows +//! - `first`: Bit — first row of a fill +//! - `end`: Bit — last row (count was 0) +//! - `tail`: Bit — `count < 8`; selects a 1-byte rather than 8-byte row +//! - `mu`: Bit — multiplicity (1 real, 0 padding) +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use crate::constraints::templates::{ + AddLinearTerm, AddOperand, emit_add_pair, emit_add_pair_no_overflow, emit_is_bit, +}; + +use executor::vm::instruction::execution::{ + DMA_MEMCPY_MAX_BYTES as EXECUTOR_DMA_MEMCPY_MAX_BYTES, + DMA_MEMSET_MAX_FILL as EXECUTOR_DMA_MEMSET_MAX_FILL, DMA_MEMSET_SYSCALL_NUMBER, +}; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; + +/// DMA memset syscall value, split into 32-bit limbs for the Ecall bus. +const DMA_MEMSET_LO32: u64 = DMA_MEMSET_SYSCALL_NUMBER & 0xFFFF_FFFF; +const DMA_MEMSET_HI32: u64 = DMA_MEMSET_SYSCALL_NUMBER >> 32; +/// Per-ecall byte bound, shared with memcpy so both stubs chunk identically. +pub const DMA_MEMSET_MAX_BYTES: u64 = EXECUTOR_DMA_MEMCPY_MAX_BYTES; +/// Largest accepted fill value, taken from the executor so the bound the AIR +/// proves cannot drift from the bound execution enforces. +pub const DMA_MEMSET_MAX_FILL: u64 = EXECUTOR_DMA_MEMSET_MAX_FILL; + +pub mod cols { + pub const TIMESTAMP_0: usize = 0; + pub const TIMESTAMP_1: usize = 1; + + pub const DST_0: usize = 2; + pub const DST_1: usize = 3; + + pub const DST_INCR_0: usize = 4; + pub const DST_INCR_1: usize = 5; + pub const DST_INCR_2: usize = 6; + pub const DST_INCR_3: usize = 7; + + pub const COUNT_0: usize = 8; + pub const COUNT_1: usize = 9; + + pub const COUNT_DECR_0: usize = 10; + pub const COUNT_DECR_1: usize = 11; + pub const COUNT_DECR_2: usize = 12; + pub const COUNT_DECR_3: usize = 13; + + pub const FILL: usize = 14; + pub const FILL_WIDE: usize = 15; + + pub const FIRST: usize = 16; + pub const END: usize = 17; + pub const TAIL: usize = 18; + pub const MU: usize = 19; + + pub const NUM_COLUMNS: usize = 20; +} + +/// One row of the DMA memset table: eight bytes, one tail byte, or the terminal row. +#[derive(Debug, Clone)] +pub struct DmaSetOperation { + pub timestamp: u64, + pub dst: u64, + /// Remaining byte count (including this byte; 0 on the end row). + pub count: u64, + pub fill: u8, + pub first: bool, + pub end: bool, +} + +/// Generates the DMA memset trace. One row per operation; padded to the next +/// power of two (min 4). Padding rows model an inactive one-byte step so the +/// unconditional `count_decr + step == count` relation still holds. +pub fn generate_dma_set_trace( + ops: &[DmaSetOperation], +) -> TraceTable { + let n = ops.len(); + let num_rows = n.next_power_of_two().max(4); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row_idx, op) in ops.iter().enumerate() { + let tail = op.count < 8; + let width = if tail { 1 } else { 8 }; + table.set_dword_wl(row_idx, cols::TIMESTAMP_0, op.timestamp); + + table.set_dword_wl(row_idx, cols::DST_0, op.dst); + table.set_dword_hl(row_idx, cols::DST_INCR_0, op.dst.wrapping_add(width)); + + table.set_dword_wl(row_idx, cols::COUNT_0, op.count); + table.set_dword_hl(row_idx, cols::COUNT_DECR_0, op.count.wrapping_sub(width)); + + table.set_byte(row_idx, cols::FILL, op.fill); + // Zero on tail rows so the shared write tuple narrows to a single byte. + table.set_byte(row_idx, cols::FILL_WIDE, if tail { 0 } else { op.fill }); + + table.set_bool(row_idx, cols::FIRST, op.first); + table.set_bool(row_idx, cols::END, op.end); + table.set_bool(row_idx, cols::TAIL, tail); + table.set_fe(row_idx, cols::MU, FE::one()); + } + + for row_idx in n..num_rows { + table.set_fe(row_idx, cols::COUNT_0, FE::one()); + table.set_fe(row_idx, cols::DST_INCR_0, FE::one()); + table.set_fe(row_idx, cols::TAIL, FE::one()); + } + + trace +} + +/// Helper: a MEMW register read (CO24, is_register=1, width2), value == old == the +/// register's two 32-bit limbs. Binds `x{reg}` to `(lo_col, hi_col)` at the ecall ts. +fn memw_register_read(reg_addr: u64, lo_col: usize, hi_col: usize) -> Vec { + let limb = |c: usize| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + vec![ + limb(lo_col), + limb(hi_col), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(1), // is_register = 1 + BusValue::constant(reg_addr), // base_address lo = 2*reg + BusValue::constant(0), // base_address hi + limb(lo_col), + limb(hi_col), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + BusValue::constant(0), + limb(cols::TIMESTAMP_0), + limb(cols::TIMESTAMP_1), + BusValue::constant(1), // w2 = 1 (register = 2 words) + BusValue::constant(0), + BusValue::constant(0), + ] +} + +/// An `IsHalfword` range-check sender for one halfword column (mult = mu). +fn halfword(column: usize) -> BusInteraction { + BusInteraction::sender( + BusId::IsHalfword, + Multiplicity::Column(cols::MU), + vec![BusValue::Packed { + start_column: column, + packing: Packing::Direct, + }], + ) +} + +/// DMA memset bus interactions (18 total). +pub fn bus_interactions() -> Vec { + let mu_minus_end = Multiplicity::Diff(cols::MU, cols::END); + let mu_minus_first = Multiplicity::Diff(cols::MU, cols::FIRST); + let direct = |c: usize| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + + vec![ + // 1. Receive ECALL from CPU (mult = first). + BusInteraction::receiver( + BusId::Ecall, + Multiplicity::Column(cols::FIRST), + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::constant(DMA_MEMSET_LO32), + BusValue::constant(DMA_MEMSET_HI32), + ], + ), + // 2. Send to DmaSetNext (mult = mu - end): [ts, dst_incr, count_decr, fill]. + // `fill` rides the chain so every row of one call writes the same byte. + BusInteraction::sender( + BusId::DmaSetNext, + mu_minus_end.clone(), + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::Packed { + start_column: cols::DST_INCR_0, + packing: Packing::DWordHL, + }, + BusValue::Packed { + start_column: cols::COUNT_DECR_0, + packing: Packing::DWordHL, + }, + direct(cols::FILL), + ], + ), + // 3. Receive from DmaSetNext (mult = mu - first): [ts, dst, count, fill]. + BusInteraction::receiver( + BusId::DmaSetNext, + mu_minus_first, + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::Packed { + start_column: cols::DST_0, + packing: Packing::DWordWL, + }, + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + direct(cols::FILL), + ], + ), + // 4-7. IsHalfword: count_decr (mult = mu). + halfword(cols::COUNT_DECR_0), + halfword(cols::COUNT_DECR_1), + halfword(cols::COUNT_DECR_2), + halfword(cols::COUNT_DECR_3), + // 8-11. IsHalfword: dst_incr (mult = mu). + halfword(cols::DST_INCR_0), + halfword(cols::DST_INCR_1), + halfword(cols::DST_INCR_2), + halfword(cols::DST_INCR_3), + // 12. ZERO bus end detection: end == 1 iff all count_decr halfwords are 0xFFFF. + BusInteraction::sender( + BusId::Zero, + Multiplicity::Column(cols::MU), + vec![ + BusValue::linear(vec![ + LinearTerm::Constant(4 * 65535), + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_0, + }, + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_1, + }, + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_2, + }, + LinearTerm::Column { + coefficient: -1, + column: cols::COUNT_DECR_3, + }, + ]), + direct(cols::END), + ], + ), + // 13-15. Register reads (mult = first): x10 = dst, x11 = fill, x12 = count. + // x11's high limb is pinned to 0 by the constant below, so a fill wider + // than 32 bits cannot be smuggled past the `fill <= 255` check. + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register_read(20, cols::DST_0, cols::DST_1), + ), + BusInteraction::sender(BusId::Memw, Multiplicity::Column(cols::FIRST), { + let mut tuple = memw_register_read(22, cols::FILL, cols::FILL); + // x11 = (fill, 0): overwrite both high-limb slots with the constant 0. + tuple[1] = BusValue::constant(0); + tuple[12] = BusValue::constant(0); + tuple + }), + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register_read(24, cols::COUNT_0, cols::COUNT_1), + ), + // 16. ALU LT pins `tail = (count < 8)`. + BusInteraction::sender( + BusId::Alu, + Multiplicity::Column(cols::MU), + vec![ + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + BusValue::constant(8), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + direct(cols::TAIL), + BusValue::constant(0), + ], + ), + // 17. The first row proves `count <= DMA_MEMSET_MAX_BYTES`. + BusInteraction::sender( + BusId::Alu, + Multiplicity::Column(cols::FIRST), + vec![ + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + BusValue::constant(DMA_MEMSET_MAX_BYTES + 1), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + ), + // 18. The first row proves `fill <= DMA_MEMSET_MAX_FILL`, so the byte the + // write tuple broadcasts really is a byte. + BusInteraction::sender( + BusId::Alu, + Multiplicity::Column(cols::FIRST), + vec![ + // The ALU bus takes its left operand as two 32-bit limbs; `fill` + // is a single byte column, so the high limb is a literal zero. + direct(cols::FILL), + BusValue::constant(0), + BusValue::constant(DMA_MEMSET_MAX_FILL + 1), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + ), + // 19. MEMW write to dst at T+1. `w8 = 1-tail`; lanes 1..7 carry `fill_wide`, + // which the constraints force to 0 exactly on one-byte tail rows. + BusInteraction::sender(BusId::Memw, mu_minus_end, { + let mut tuple = Vec::with_capacity(16); + tuple.push(BusValue::constant(0)); // is_register + tuple.push(direct(cols::DST_0)); + tuple.push(direct(cols::DST_1)); + tuple.push(direct(cols::FILL)); + for _ in 1..8 { + tuple.push(direct(cols::FILL_WIDE)); + } + tuple.push(BusValue::linear(vec![ + LinearTerm::Constant(1), + LinearTerm::Column { + coefficient: 1, + column: cols::TIMESTAMP_0, + }, + ])); + tuple.push(direct(cols::TIMESTAMP_1)); + tuple.push(BusValue::constant(0)); // w2 + tuple.push(BusValue::constant(0)); // w4 + tuple.push(BusValue::linear(vec![ + LinearTerm::Constant(1), + LinearTerm::Column { + coefficient: -1, + column: cols::TAIL, + }, + ])); // w8 = 1-tail + tuple + }), + ] +} + +/// The DMA memset constraints: +/// - bitness for `first`, `end`, `tail`, `mu`; +/// - active first/end rows; +/// - `step = 8 - 7*tail` address/count arithmetic; +/// - `fill_wide` equals `fill` on wide rows and 0 on tail rows. +#[derive(Clone, Copy)] +pub struct DmaSetConstraints; + +impl ConstraintSet for DmaSetConstraints { + fn eval>(&self, b: &mut B) { + emit_is_bit(b, 0, cols::FIRST, None); + emit_is_bit(b, 1, cols::END, None); + emit_is_bit(b, 2, cols::TAIL, None); + emit_is_bit(b, 3, cols::MU, None); + + let one = b.one(); + let first = b.main(0, cols::FIRST); + let end = b.main(0, cols::END); + let mu = b.main(0, cols::MU); + b.emit_base(4, (first + end) * (one.clone() - mu)); + + let step = AddOperand::linear( + &[ + AddLinearTerm::Constant(8), + AddLinearTerm::Column { + coefficient: -7, + column: cols::TAIL, + }, + ], + &[], + ); + + emit_add_pair_no_overflow( + b, + 5, + cols::MU, + cols::END, + &AddOperand::dword(cols::DST_0), + &step, + &AddOperand::from_dword_hl(cols::DST_INCR_0), + ); + emit_add_pair( + b, + 7, + &[], + &AddOperand::from_dword_hl(cols::COUNT_DECR_0), + &step, + &AddOperand::dword(cols::COUNT_0), + ); + + // fill_wide == (1 - tail) * fill, expressed as the two cases so the + // degree stays at 2: zero on tail rows, equal to fill otherwise. + let tail = b.main(0, cols::TAIL); + let fill = b.main(0, cols::FILL); + let fill_wide = b.main(0, cols::FILL_WIDE); + b.emit_base(9, tail.clone() * fill_wide.clone()); + b.emit_base(10, (one - tail) * (fill_wide - fill)); + } +} diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index 2f78ec872..950d2cddf 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -29,6 +29,7 @@ pub mod cpu; pub mod cpu32; pub mod decode; pub mod dma; +pub mod dma_set; pub mod dvrm; pub mod ecdas; pub mod ecsm; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index c87e03f00..3b679f1c5 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -47,6 +47,7 @@ use super::cpu::{self, CpuOperation}; use super::cpu32; use super::decode; use super::dma; +use super::dma_set; use super::dvrm::{self, DvrmOperation}; use super::ecdas; use super::ecsm; @@ -551,6 +552,7 @@ fn collect_ops_from_cpu( Vec, Vec, Vec, + Vec, ) { let mut memw = MemwBuckets::with_register_capacity(cpu_ops.len() * 3); let mut load_ops = Vec::with_capacity(cpu_ops.len() / 8 + 1); @@ -563,6 +565,7 @@ fn collect_ops_from_cpu( let mut ecsm_ops = Vec::new(); let mut ecdas_ops = Vec::new(); let mut dma_ops = Vec::new(); + let mut dma_set_ops = Vec::new(); // Seed from the carried x254 (0 for a monolithic run or the first epoch) so a // continuation epoch indexes its commits globally, matching the x254 the // register binding transports across epochs. Resetting to 0 here would drift @@ -665,6 +668,15 @@ fn collect_ops_from_cpu( dma_ops.extend(rows); } + // DMA memset: authenticate x10/x11/x12, then write every destination byte + // at T+1. There is no source phase — every byte written is the same + // constant, so no snapshot is needed and overlap cannot arise. + if op.ecall_dma_memset { + let (memset_memw, rows) = collect_dma_memset_ops(op, memory_state, register_state); + memw.extend_ops(memset_memw); + dma_set_ops.extend(rows); + } + // --- ALU chip dispatch (no state tracking) --- // Word (`*W`) instructions are delegated to CPU32 (which itself drives // the ALU chips); the main CPU does not send the ALU bus for them, so we @@ -721,6 +733,7 @@ fn collect_ops_from_cpu( ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, ) } @@ -1069,6 +1082,104 @@ fn collect_dma_memcpy_ops( (memw_ops, rows) } +/// Replays one DMA memset ecall. +/// +/// Register operands are read at `T`; every destination chunk is written at +/// `T+1`. Chunks are eight bytes while `remaining >= 8`, then one byte per tail +/// row, matching the row schedule the DMA_SET AIR pins through the LT table. +fn collect_dma_memset_ops( + op: &CpuOperation, + memory_state: &mut MemoryState, + register_state: &mut RegisterState, +) -> (Vec, Vec) { + let t = op.timestamp; + let dst = register_state.read(10).0; + let fill = register_state.read(11).0; + let count = register_state.read(12).0; + assert!( + count <= dma_set::DMA_MEMSET_MAX_BYTES, + "successful DMA memset ecall must respect the per-call chunk bound" + ); + assert!( + fill <= dma_set::DMA_MEMSET_MAX_FILL, + "successful DMA memset ecall must carry a byte-sized fill" + ); + let fill_byte = fill as u8; + + let data_rows = count / 8 + count % 8; + let capacity = usize::try_from(data_rows) + .ok() + .and_then(|n| n.checked_add(3)) + .expect("successful DMA memset execution must fit host address space"); + let mut memw_ops = Vec::with_capacity(capacity); + + // Bind the ecall's three argument registers to the first DMA_SET row. + for (reg, value) in [(10u8, dst), (11u8, fill), (12u8, count)] { + let packed = pack_register_value(value); + let (_old_value, old_ts) = register_state.read(reg); + memw_ops.push( + MemwOperation::new(true, 2 * reg as u64, packed, t, 2, true) + .with_old(packed, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), + ); + register_state.write(reg, value, t); + } + + let rows_capacity = usize::try_from(data_rows + 1) + .expect("successful DMA memset execution must fit host address space"); + let mut rows = Vec::with_capacity(rows_capacity); + let mut offset = 0u64; + let mut remaining = count; + let mut first = true; + + while remaining != 0 { + let width = if remaining >= 8 { 8u8 } else { 1u8 }; + let destination_addr = dst + .checked_add(offset) + .expect("DMA memset range was validated by executor"); + // Only the lanes actually written carry the fill; the rest stay zero so + // this matches the AIR, which sends `fill` in lane 0 and `fill_wide` + // (zero on one-byte tail rows) in lanes 1..7. + let mut value = [0u32; 8]; + for lane in value.iter_mut().take(width as usize) { + *lane = fill_byte as u32; + } + let (old_values, old_timestamps) = + memory_state.read_bytes(destination_addr, width as usize); + memw_ops.push( + MemwOperation::new(false, destination_addr, value, t + 1, width, false) + .with_old(old_values, old_timestamps), + ); + let dword = u64::from_le_bytes([fill_byte; 8]); + memory_state.write_bytes(destination_addr, dword, width as usize, t + 1); + + rows.push(dma_set::DmaSetOperation { + timestamp: t, + dst: destination_addr, + count: remaining, + fill: fill_byte, + first, + end: false, + }); + + first = false; + offset += u64::from(width); + remaining -= width as u64; + } + + rows.push(dma_set::DmaSetOperation { + timestamp: t, + dst: dst + .checked_add(count) + .expect("DMA memset range was validated by executor"), + count: 0, + fill: fill_byte, + first, + end: true, + }); + + (memw_ops, rows) +} + /// Sizing-pass replay of one bounded DMA ecall. /// /// This mirrors [`collect_dma_memcpy_ops`] but counts rows and routes each @@ -1166,6 +1277,73 @@ fn replay_dma_memcpy_for_sizing( snapshot_count + 1 } +/// Sizing-pass replay of one bounded DMA memset ecall. +/// +/// Mirrors [`collect_dma_memset_ops`] but counts rows and routes each +/// `MemwOperation` immediately instead of allocating vectors. No snapshot buffer +/// is needed: memset writes a constant, so there is no source to preserve. +#[cfg(feature = "disk-spill")] +fn replay_dma_memset_for_sizing( + op: &CpuOperation, + memory_state: &mut MemoryState, + register_state: &mut RegisterState, + mut visit_memw: impl FnMut(&MemwOperation), +) -> usize { + let t = op.timestamp; + let dst = register_state.read(10).0; + let fill = register_state.read(11).0; + let count = register_state.read(12).0; + assert!( + count <= dma_set::DMA_MEMSET_MAX_BYTES, + "successful DMA memset ecall must respect the per-call chunk bound" + ); + assert!( + fill <= dma_set::DMA_MEMSET_MAX_FILL, + "successful DMA memset ecall must carry a byte-sized fill" + ); + let fill_byte = fill as u8; + + for (reg, value) in [(10u8, dst), (11u8, fill), (12u8, count)] { + let packed = pack_register_value(value); + let (_old_value, old_ts) = register_state.read(reg); + let memw = MemwOperation::new(true, 2 * reg as u64, packed, t, 2, true) + .with_old(packed, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]); + visit_memw(&memw); + register_state.write(reg, value, t); + } + + let mut rows = 0usize; + let mut offset = 0u64; + let mut remaining = count; + let dword = u64::from_le_bytes([fill_byte; 8]); + + while remaining != 0 { + let width = if remaining >= 8 { 8u8 } else { 1u8 }; + let destination_addr = dst + .checked_add(offset) + .expect("DMA memset range was validated by executor"); + // Only the lanes actually written carry the fill; the rest stay zero so + // this matches the AIR, which sends `fill` in lane 0 and `fill_wide` + // (zero on one-byte tail rows) in lanes 1..7. + let mut value = [0u32; 8]; + for lane in value.iter_mut().take(width as usize) { + *lane = fill_byte as u32; + } + let (old_values, old_timestamps) = + memory_state.read_bytes(destination_addr, width as usize); + let memw = MemwOperation::new(false, destination_addr, value, t + 1, width, false) + .with_old(old_values, old_timestamps); + visit_memw(&memw); + memory_state.write_bytes(destination_addr, dword, width as usize, t + 1); + + rows += 1; + offset += u64::from(width); + remaining -= u64::from(width); + } + + rows + 1 +} + /// Collects register read/write operations (M1, M3, M5) from CpuOperation, /// pushing them into `memw_ops`. fn collect_register_ops_from_cpu( @@ -2466,6 +2644,36 @@ fn collect_bitwise_from_commit(commit_ops: &[CommitOperation]) -> Vec Vec { + let mut lookups = Vec::with_capacity(ops.len() * 9); + for op in ops { + let width = if op.count < 8 { 1 } else { 8 }; + let count_decr = op.count.wrapping_sub(width); + let dst_incr = op.dst.wrapping_add(width); + + for value in [count_decr, dst_incr] { + for shift in [0, 16, 32, 48] { + let half = ((value >> shift) & 0xFFFF) as u16; + lookups.push(BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (half & 0xFF) as u8, + (half >> 8) as u8, + )); + } + } + + let halves = [ + (count_decr & 0xFFFF) as u32, + ((count_decr >> 16) & 0xFFFF) as u32, + ((count_decr >> 32) & 0xFFFF) as u32, + ((count_decr >> 48) & 0xFFFF) as u32, + ]; + let zero_input = halves.into_iter().map(|half| 65535 - half).sum(); + lookups.push(BitwiseOperation::zero(zero_input)); + } + lookups +} + fn collect_bitwise_from_dma(dma_ops: &[dma::DmaOperation]) -> Vec { let mut lookups = Vec::with_capacity(dma_ops.len() * 13); for op in dma_ops { @@ -3028,6 +3236,9 @@ pub struct Traces { /// DMA memcpy table (eight-byte body rows plus byte tail rows). pub dma: TraceTable, + /// DMA memset table (eight-byte body rows plus byte tail rows). + pub dma_set: TraceTable, + /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -3072,6 +3283,8 @@ struct CollectedOps { ecdas_ops: Vec, // DMA memcpy rows (eight bytes per body row, byte tail, plus terminal rows). dma_ops: Vec, + // DMA memset rows (same schedule; one fill byte instead of eight value lanes). + dma_set_ops: Vec, } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -3127,6 +3340,7 @@ fn collect_all_ops( ecsm_ops: Vec, ecdas_ops: Vec, dma_ops: Vec, + dma_set_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -3270,6 +3484,7 @@ fn collect_all_ops( ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, } } @@ -3314,6 +3529,7 @@ fn build_traces( ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, } = ops; // ===================================================================== @@ -3332,6 +3548,17 @@ fn build_traces( .filter(|op| op.first) .map(|op| LtOperation::new(op.count, dma::DMA_MEMCPY_MAX_BYTES + 1, false)), ); + lt_ops.extend( + dma_set_ops + .iter() + .map(|op| LtOperation::new(op.count, 8, false)), + ); + lt_ops.extend(dma_set_ops.iter().filter(|op| op.first).flat_map(|op| { + [ + LtOperation::new(op.count, dma_set::DMA_MEMSET_MAX_BYTES + 1, false), + LtOperation::new(u64::from(op.fill), dma_set::DMA_MEMSET_MAX_FILL + 1, false), + ] + })); // ===================================================================== // PHASE 4: All → Bitwise lookups @@ -3398,6 +3625,7 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_memw_aligned(&memw_aligned_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_commit(&commit_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_dma(&dma_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_dma_set(&dma_set_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_keccak(&keccak_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), @@ -3688,6 +3916,7 @@ fn build_traces( let gen_ecsm = || ecsm::generate_ecsm_trace(&ecsm_ops); let gen_ecdas = || ecdas::generate_ecdas_trace(&ecdas_ops); let gen_dma = || dma::generate_dma_trace(&dma_ops); + let gen_dma_set = || dma_set::generate_dma_set_trace(&dma_set_ops); let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -3701,6 +3930,7 @@ fn build_traces( (None, None, None, None); let (mut ecsm_slot, mut ecdas_slot) = (None, None); let mut dma_slot = None; + let mut dma_set_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3743,6 +3973,7 @@ fn build_traces( spawn_into!(ecsm_slot, gen_ecsm); spawn_into!(ecdas_slot, gen_ecdas); spawn_into!(dma_slot, gen_dma); + spawn_into!(dma_set_slot, gen_dma_set); }); } else { cpus_slot = Some(gen_cpus()); @@ -3771,6 +4002,7 @@ fn build_traces( ecsm_slot = Some(gen_ecsm()); ecdas_slot = Some(gen_ecdas()); dma_slot = Some(gen_dma()); + dma_set_slot = Some(gen_dma_set()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3807,6 +4039,8 @@ fn build_traces( let ecdas_trace = ecdas_slot.expect(PHASE5_RAN); #[allow(unused_mut)] let mut dma_trace = dma_slot.expect(PHASE5_RAN); + #[allow(unused_mut)] + let mut dma_set_trace = dma_set_slot.expect(PHASE5_RAN); // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -3828,6 +4062,10 @@ fn build_traces( .main_table .spill_to_disk() .map_err(|e| Error::Prover(format!("disk-spill dma: {e}")))?; + dma_set_trace + .main_table + .spill_to_disk() + .map_err(|e| Error::Prover(format!("disk-spill dma_set: {e}")))?; register_trace .main_table .spill_to_disk() @@ -3879,6 +4117,7 @@ fn build_traces( ecsm: ecsm_trace, ecdas: ecdas_trace, dma: dma_trace, + dma_set: dma_set_trace, memw_registers, local_to_global, touched_memory_cells, @@ -3923,6 +4162,7 @@ pub struct TableLengths { pub branch_padded_rows: u64, pub commit_padded_rows: u64, pub dma_padded_rows: u64, + pub dma_set_padded_rows: u64, pub decode_rows: u64, pub unique_page_count: u64, pub cycle_count: u64, @@ -3963,6 +4203,7 @@ pub fn count_table_lengths( let mut branch_count = 0usize; let mut commit_count = 0usize; let mut dma_count = 0usize; + let mut dma_set_count = 0usize; let mut current_commit_index = 0u32; let partition_memw = |op: &MemwOperation, @@ -4074,6 +4315,26 @@ pub fn count_table_lengths( lt_count += dma_rows + 1; } + if cpu_op.ecall_dma_memset { + let rows = replay_dma_memset_for_sizing( + &cpu_op, + &mut memory_state, + &mut register_state, + |memw_op| { + partition_memw( + memw_op, + &mut memw_by_width, + &mut memw_aligned_count, + &mut memw_register_count, + ); + }, + ); + dma_set_count += rows; + // One LT per row pins the 1-vs-8-byte width; the first row adds two + // more (the chunk cap and the fill-byte bound). + lt_count += rows + 2; + } + // CPU-side per-instruction-kind counters (non-word; word → CPU32, B5b) let f = &cpu_op.decode.fields; if !f.word_instr && f.is_lt() { @@ -4137,6 +4398,10 @@ pub fn count_table_lengths( .checked_next_power_of_two() .unwrap_or(usize::MAX) .max(4) as u64, + dma_set_padded_rows: dma_set_count + .checked_next_power_of_two() + .unwrap_or(usize::MAX) + .max(4) as u64, decode_rows, unique_page_count, cycle_count, @@ -4163,6 +4428,7 @@ impl Traces { use super::decode::NUM_PRECOMPUTED_COLS as DECODE_PRECOMPUTED; use super::decode::cols::NUM_COLUMNS as DECODE_COLS; use super::dma::cols::NUM_COLUMNS as DMA_COLS; + use super::dma_set::cols::NUM_COLUMNS as DMA_SET_COLS; use super::dvrm::cols::NUM_COLUMNS as DVRM_COLS; use super::ecdas::cols::NUM_COLUMNS as ECDAS_COLS; use super::ecsm::cols::NUM_COLUMNS as ECSM_COLS; @@ -4207,6 +4473,7 @@ impl Traces { ecsm, ecdas, dma, + dma_set, memw_registers, eqs, bytewises, @@ -4275,6 +4542,7 @@ impl Traces { total += (ecsm.num_rows() * ECSM_COLS) as u64; total += (ecdas.num_rows() * ECDAS_COLS) as u64; total += (dma.num_rows() * DMA_COLS) as u64; + total += (dma_set.num_rows() * DMA_SET_COLS) as u64; total } @@ -4317,6 +4585,7 @@ impl Traces { let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); let n_dma = aux_cols(super::dma::bus_interactions().len()); + let n_dma_set = aux_cols(super::dma_set::bus_interactions().len()); let Traces { cpus, @@ -4340,6 +4609,7 @@ impl Traces { ecsm, ecdas, dma, + dma_set, memw_registers, eqs, bytewises, @@ -4408,6 +4678,7 @@ impl Traces { total += (ecsm.num_rows() * n_ecsm) as u64; total += (ecdas.num_rows() * n_ecdas) as u64; total += (dma.num_rows() * n_dma) as u64; + total += (dma_set.num_rows() * n_dma_set) as u64; total } @@ -4682,6 +4953,7 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); #[cfg(feature = "instruments")] drop(__sp); @@ -4701,6 +4973,7 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, &mut register_state, is_final, ); @@ -4795,6 +5068,7 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); let ops = collect_all_ops( @@ -4810,6 +5084,7 @@ impl Traces { ecsm_ops, ecdas_ops, dma_ops, + dma_set_ops, &mut register_state, true, ); diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index 0d4a093ee..98c0910f5 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -362,6 +362,12 @@ pub enum BusId { /// copy. Only the first row receives the CPU's `Ecall`; the rest chain here. DmaNext = 29, + /// DMA memset streaming bus: each DMA_SET row sends + /// `(timestamp, dst_incr, count_decr, fill)` to the next row and receives + /// `(timestamp, dst, count, fill)` from the previous one. Separate from + /// [`BusId::DmaNext`] so a memcpy row can never consume a memset token. + DmaSetNext = 32, + // ========================================================================= // Continuations // ========================================================================= @@ -397,6 +403,7 @@ impl BusId { BusId::Ecdas => "Ecdas", BusId::Bit => "Bit", BusId::DmaNext => "DmaNext", + BusId::DmaSetNext => "DmaSetNext", BusId::GlobalMemory => "GlobalMemory", } } @@ -429,6 +436,7 @@ impl TryFrom for BusId { 27 => Ok(BusId::Cpu32), 28 => Ok(BusId::Ecdas), 29 => Ok(BusId::DmaNext), + 32 => Ok(BusId::DmaSetNext), 30 => Ok(BusId::Bit), 31 => Ok(BusId::GlobalMemory), other => Err(other), diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index dd7f97bc3..eab775764 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -58,6 +58,9 @@ use crate::tables::decode::{bus_interactions as decode_bus_interactions, cols as use crate::tables::dma::{ DmaConstraints, bus_interactions as dma_bus_interactions, cols as dma_cols, }; +use crate::tables::dma_set::{ + DmaSetConstraints, bus_interactions as dma_set_bus_interactions, cols as dma_set_cols, +}; use crate::tables::dvrm::{ DvrmConstraints, bus_interactions as dvrm_bus_interactions, cols as dvrm_cols, }; @@ -909,6 +912,18 @@ pub fn create_dma_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + dma_set_cols::NUM_COLUMNS, + dma_set_bus_interactions(), + proof_options, + 1, + DmaSetConstraints, + "DMA_SET", + ) +} + /// Create COMMIT AIR with constraints and bus interactions. pub fn create_commit_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index bdf94b65a..7a64ade45 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1231,6 +1231,28 @@ fn test_prove_dma_memcpy_rust_guest() { ); } +/// End-to-end memset: the guest exercises every row-schedule boundary (empty, +/// sub-tail, exact widths, the per-ecall cap, multi-chunk, a masked wide fill, +/// and an unaligned page-crossing destination), so a passing proof covers the +/// DMA_SET trace, its bus balance, and the fill-byte bound together. +#[test] +fn test_prove_dma_memset_cases_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memset_cases.elf")) + .expect("dma_memset_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memset guest should verify" + ); + assert_eq!(proof.public_output, b"dma-memset-ok"); +} + #[test] fn test_prove_dma_memcpy_cases_rust_guest() { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index ff099f4b1..9d8d8afcc 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -41,6 +41,10 @@ const DMA_MEMCPY_SYSCALL_NUMBER: usize = usize::MAX - 2; #[cfg(target_arch = "riscv64")] const DMA_MEMCPY_MAX_BYTES: usize = 256; +/// DMA memset syscall number. Must match the executor. +#[cfg(target_arch = "riscv64")] +const DMA_MEMSET_SYSCALL_NUMBER: usize = usize::MAX - 3; + /// No-op. The `Print` ecall (a7=1) has no receiver on the Ecall bus, so emitting /// it makes the LogUp bus unbalance and the proof fail to verify. Printing isn't /// needed in provable programs, so `print_string` does nothing on every target. @@ -236,6 +240,46 @@ memcpy: max_bytes = const DMA_MEMCPY_MAX_BYTES, ); +// --------------------------------------------------------------------------- +// DMA memset symbol override +// +// Same shape as `memcpy` above: a strong assembly symbol that splits the fill +// into bounded DMA ecalls. `a1` carries the fill byte rather than a source +// address, so it is NOT advanced across chunks. The `andi` keeps only the low +// byte — C's `memset` takes an `int` but writes `(unsigned char)c`, and the +// executor rejects a wider value so the AIR can prove the byte bound. +// --------------------------------------------------------------------------- + +#[cfg(target_arch = "riscv64")] +global_asm!( + r#" + .section .text.memset,"ax",@progbits + .globl memset + .type memset,@function +memset: + mv t0, a0 + andi a1, a1, 255 + mv t1, a2 + beqz t1, .Ldma_memset_done +.Ldma_memset_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memset_call + mv a2, t1 +.Ldma_memset_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + bnez t1, .Ldma_memset_loop +.Ldma_memset_done: + mv a0, t0 + ret + .size memset, .-memset +"#, + syscall = const DMA_MEMSET_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); + // ============================================================================= // Stub implementations for unsupported std functions // These functions are required by Rust's std zkvm module but are not supported From 77a546792279a0c893a8a3657ab12a5e48fc73f0 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Tue, 4 Aug 2026 10:35:30 -0300 Subject: [PATCH 02/13] feat(dma): route memmove through the memcpy ecall, no new AIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DMA memcpy ecall already snapshots its entire source range before writing (all reads at T+1, all writes at T+2), so one chunk has memmove semantics for free. Chunking is what breaks it: copying [0,256) -> [4,260) clobbers source bytes a later forward chunk still needs. So the memmove stub walks chunks from the END backwards exactly when the destination starts inside the source range (src < dst < src+n); every chunk then reads bytes no earlier chunk has written. Disjoint regions, and dst below src, keep forward chunking. This costs one guest symbol and nothing else — no table, no syscall, no constraint. Measured on real mainnet block 25368371: memcpy + memset 40,338,153 + memmove (this) 39,867,443 -0.93% Cumulative vs the 50,781,394 baseline: -21.49%. The guest test covers both overlap directions at offsets either side of the 256-byte chunk boundary, plus exact aliasing. --- .../rust/dma_memmove_cases/.cargo/config.toml | 9 + .../rust/dma_memmove_cases/Cargo.lock | 294 ++++++++++++++++++ .../rust/dma_memmove_cases/Cargo.toml | 9 + .../rust/dma_memmove_cases/src/main.rs | 70 +++++ prover/src/tests/prove_elfs_tests.rs | 21 ++ syscalls/src/syscalls.rs | 64 ++++ 6 files changed, 467 insertions(+) create mode 100644 executor/programs/rust/dma_memmove_cases/.cargo/config.toml create mode 100644 executor/programs/rust/dma_memmove_cases/Cargo.lock create mode 100644 executor/programs/rust/dma_memmove_cases/Cargo.toml create mode 100644 executor/programs/rust/dma_memmove_cases/src/main.rs diff --git a/executor/programs/rust/dma_memmove_cases/.cargo/config.toml b/executor/programs/rust/dma_memmove_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memmove_cases/Cargo.lock b/executor/programs/rust/dma_memmove_cases/Cargo.lock new file mode 100644 index 000000000..04c10ccfe --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memmove_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memmove_cases/Cargo.toml b/executor/programs/rust/dma_memmove_cases/Cargo.toml new file mode 100644 index 000000000..b81ea25a9 --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memmove_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memmove_cases/src/main.rs b/executor/programs/rust/dma_memmove_cases/src/main.rs new file mode 100644 index 000000000..45ecdb0de --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/src/main.rs @@ -0,0 +1,70 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memmove(dst: *mut u8, src: *const u8, count: usize) -> *mut u8; +} + +#[inline(never)] +fn dma_move(dst: *mut u8, src: *const u8, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memmove(dst, src, count) } +} + +fn fill_pattern(bytes: &mut [u8], seed: u8) { + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(37).wrapping_add(seed); + } +} + +pub fn main() { + // Disjoint regions behave like memcpy. + let mut source = [0u8; 777]; + let mut destination = [0xA5u8; 777]; + fill_pattern(&mut source, 11); + for count in [0usize, 1, 7, 8, 255, 256, 257, 777] { + destination.fill(0xA5); + let returned = dma_move(destination.as_mut_ptr(), source.as_ptr(), count); + assert_eq!(returned, destination.as_mut_ptr()); + assert_eq!(&destination[..count], &source[..count]); + assert!(destination[count..].iter().all(|&b| b == 0xA5)); + } + + // Forward overlap (dst inside [src, src+n)) is the case that needs BACKWARD + // chunking; a forward-chunked copy corrupts it once n exceeds one chunk. + // Offsets below and above 256 exercise both sides of the chunk boundary. + for (offset, count) in [(1usize, 600usize), (17, 600), (255, 600), (256, 600), (300, 700), (4, 8)] { + let mut buffer = [0u8; 1600]; + fill_pattern(&mut buffer, 23); + let before = buffer; + dma_move( + unsafe { buffer.as_mut_ptr().add(offset) }, + buffer.as_ptr(), + count, + ); + assert_eq!(&buffer[offset..offset + count], &before[..count]); + // Bytes below the destination must be untouched. + assert_eq!(&buffer[..offset], &before[..offset]); + } + + // Backward overlap (dst below src) stays forward-chunked. + for (offset, count) in [(1usize, 600usize), (17, 600), (300, 700)] { + let mut buffer = [0u8; 1600]; + fill_pattern(&mut buffer, 41); + let before = buffer; + dma_move( + buffer.as_mut_ptr(), + unsafe { buffer.as_ptr().add(offset) }, + count, + ); + assert_eq!(&buffer[..count], &before[offset..offset + count]); + } + + // Exact aliasing must be a no-op. + let mut same = [0u8; 300]; + fill_pattern(&mut same, 7); + let before = same; + dma_move(same.as_mut_ptr(), same.as_ptr(), 300); + assert_eq!(same, before); + + syscalls::syscalls::commit(b"dma-memmove-ok"); +} diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 7a64ade45..0a61b5046 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1253,6 +1253,27 @@ fn test_prove_dma_memset_cases_rust_guest() { assert_eq!(proof.public_output, b"dma-memset-ok"); } +/// memmove rides the memcpy ecall unchanged. The interesting case is a forward +/// overlap longer than one 256-byte chunk: the stub must walk chunks backwards, +/// or an earlier chunk clobbers source bytes a later one still needs. +#[test] +fn test_prove_dma_memmove_cases_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memmove_cases.elf")) + .expect("dma_memmove_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memmove guest should verify" + ); + assert_eq!(proof.public_output, b"dma-memmove-ok"); +} + #[test] fn test_prove_dma_memcpy_cases_rust_guest() { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 9d8d8afcc..4c031abb3 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -240,6 +240,70 @@ memcpy: max_bytes = const DMA_MEMCPY_MAX_BYTES, ); +// --------------------------------------------------------------------------- +// DMA memmove symbol override +// +// Reuses the memcpy ecall unchanged — no new table, no new syscall. Each ecall +// already snapshots its whole source range before writing (all reads at T+1, +// all writes at T+2), so a single chunk has memmove semantics for free. +// +// Chunking is what breaks it: copying [0,256) -> [4,260) clobbers source bytes +// that a later forward chunk still needs. So when the destination starts inside +// the source range (src < dst < src+n) the chunks are walked from the END +// backwards; every chunk then reads bytes no earlier chunk has written yet. +// Otherwise (disjoint, or dst below src) forward chunking is already safe. +// --------------------------------------------------------------------------- + +#[cfg(target_arch = "riscv64")] +global_asm!( + r#" + .section .text.memmove,"ax",@progbits + .globl memmove + .type memmove,@function +memmove: + mv t0, a0 + beqz a2, .Ldma_memmove_done + bgeu a1, a0, .Ldma_memmove_fwd // src >= dst: forward is safe + add t2, a1, a2 + bgeu a0, t2, .Ldma_memmove_fwd // dst >= src+n: disjoint + // Overlapping with dst inside [src, src+n): walk chunks from the end. + add a0, a0, a2 + add a1, a1, a2 + mv t1, a2 +.Ldma_memmove_back_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memmove_back_call + mv a2, t1 +.Ldma_memmove_back_call: + sub a0, a0, a2 + sub a1, a1, a2 + li a7, {syscall} + ecall + sub t1, t1, a2 + bnez t1, .Ldma_memmove_back_loop + j .Ldma_memmove_done +.Ldma_memmove_fwd: + mv t1, a2 +.Ldma_memmove_fwd_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memmove_fwd_call + mv a2, t1 +.Ldma_memmove_fwd_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + add a1, a1, a2 + bnez t1, .Ldma_memmove_fwd_loop +.Ldma_memmove_done: + mv a0, t0 + ret + .size memmove, .-memmove +"#, + syscall = const DMA_MEMCPY_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); + // --------------------------------------------------------------------------- // DMA memset symbol override // From 8b88a8d676280d25de7e8690423a502c55f6ec27 Mon Sep 17 00:00:00 2001 From: Diego K <43053772+diegokingston@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:29:32 -0300 Subject: [PATCH 03/13] perf(guest): read the private input zero-copy via ef_io::read_input (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(guest): read the private input zero-copy via ef_io::read_input get_private_input() to_vec()'s the whole memory-mapped input before rkyv deserializes it; read_input hands rkyv a slice straight into the input region instead. Same bytes, same private-input commitment. Measured vs origin/main (same fixtures, deterministic): transfers_20 8,732,213 -> 8,692,490 (-39,723) erc20_20 10,328,222 -> 10,278,822 (-49,400) mixed_20 9,817,444 -> 9,768,492 (-48,952) Verified: test_prove_ethrex_empty_block (prove+verify) passes. * fix(guest): take the zero-copy input via the safe get_private_input_slice (#898) The zero-copy read is the right call, but it hand-rolls what `syscalls::get_private_input_slice` already does: borrow the mapped private-input region in place and hand back `&'static [u8]`, no copy and no allocation. `get_private_input` is that same call plus a `to_vec()`, so dropping to the slice is the whole win without the pointer plumbing. Three things that buys: - No raw pointers in guest code. `syscalls.rs` deliberately keeps the region layout and its one `unsafe` block in a single place — that is why `get_private_input_slice` exists. Re-reading the length prefix in the guest duplicates layout knowledge that has to stay in step with the executor. - Restores the length-prefix clamp. `get_private_input_slice` bounds the prefix by `MAX_PRIVATE_INPUT_SIZE`; `ef_io::read_input` returns it raw. The executor rejects oversized inputs, so honest runs are identical — but a forged prefix built a slice reaching past the region instead of a bounded one. - Drops a dependency on unspecified behavior. `ef_io::read_input` documents `buf_ptr` as unspecified when `buf_size == 0`, and the previous code fed it to `from_raw_parts` regardless. Harmless in practice (the implementation always writes it, and ethrex input is never empty), but not a contract to lean on. `bench_vs/lambda/recursion` already reads its blob this way. --------- Co-authored-by: Mauro Toscano <12560266+MauroToscano@users.noreply.github.com> --- executor/programs/rust/ethrex/src/main.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/executor/programs/rust/ethrex/src/main.rs b/executor/programs/rust/ethrex/src/main.rs index 30a39f4b5..8154978cf 100644 --- a/executor/programs/rust/ethrex/src/main.rs +++ b/executor/programs/rust/ethrex/src/main.rs @@ -5,8 +5,13 @@ use lambda_vm_ethrex_crypto::LambdaVmEcsmCrypto; use rkyv::rancor::Error; pub fn main() { - let input = lambda_vm_syscalls::syscalls::get_private_input(); - let input = rkyv::from_bytes::(&input).unwrap(); + // Zero-copy private input: borrow the memory-mapped input region in place + // (the host pre-loads it before execution) so rkyv deserializes straight + // out of it. `get_private_input()` is this same slice plus a `to_vec()` — + // a full extra copy and one large allocation (~50k cycles on a 20-tx + // block). + let input = lambda_vm_syscalls::syscalls::get_private_input_slice(); + let input = rkyv::from_bytes::(input).unwrap(); // LambdaVM crypto provider, defined in the lambda_vm repo and injected here // (so crypto changes don't require an ethrex PR — see `crypto/ethrex-crypto`). // It accelerates trait-routed `keccak256` (via the keccak_permute precompile) From 3c7cdcef7445044b8afc590a613e266811ffc2cd Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 5 Aug 2026 12:31:43 -0300 Subject: [PATCH 04/13] Reformat the prover's AIR import block --- prover/src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 4501eaa3c..032183729 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -52,11 +52,11 @@ use crate::tables::trace_builder::count_table_lengths; use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, - create_cpu_air, create_cpu32_air, create_decode_air, create_dma_air, create_dma_set_air, create_dvrm_air, - create_ecdas_air, create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, - create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, - create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, - create_register_air, create_shift_air, create_store_air, + create_cpu_air, create_cpu32_air, create_decode_air, create_dma_air, create_dma_set_air, + create_dvrm_air, create_ecdas_air, create_ecsm_air, create_eq_air, create_halt_air, + create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, + create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, + create_page_air, create_register_air, create_shift_air, create_store_air, }; // Re-exported for downstream hosts and verifier guests (e.g. the in-VM From ffb2928541dbca3a7a487b52d72ef49e0ef26adf Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 5 Aug 2026 12:33:27 -0300 Subject: [PATCH 05/13] Align the new DMA asm stubs to 4 bytes --- syscalls/src/syscalls.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index f8df0fb7b..db2c3de44 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -263,6 +263,7 @@ memcpy: global_asm!( r#" .section .text.memmove,"ax",@progbits + .p2align 2 .globl memmove .type memmove,@function memmove: @@ -323,6 +324,7 @@ memmove: global_asm!( r#" .section .text.memset,"ax",@progbits + .p2align 2 .globl memset .type memset,@function memset: From 4dfd9af1b523693e825cb75d7be622a100da7ded Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 5 Aug 2026 14:34:42 -0300 Subject: [PATCH 06/13] Rename the DMA chunk-too-large error --- executor/src/tests/dma_tests.rs | 4 ++-- executor/src/vm/instruction/execution.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/executor/src/tests/dma_tests.rs b/executor/src/tests/dma_tests.rs index 637507c89..1a2dd95b0 100644 --- a/executor/src/tests/dma_tests.rs +++ b/executor/src/tests/dma_tests.rs @@ -66,7 +66,7 @@ fn dma_memcpy_rejects_oversized_direct_ecall() { 0x1000, DMA_MEMCPY_MAX_BYTES + 1 ), - Err(ExecutionError::DmaMemcpyChunkTooLarge(n)) + Err(ExecutionError::DmaChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 )); } @@ -159,7 +159,7 @@ fn dma_memset_rejects_oversized_chunk() { let mut memory = Memory::default(); assert!(matches!( run_memset(&mut memory, 0x2000, 0x11, DMA_MEMCPY_MAX_BYTES + 1), - Err(ExecutionError::DmaMemcpyChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 + Err(ExecutionError::DmaChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 )); } diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 4fbe46325..33042839d 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -520,7 +520,7 @@ impl Instruction { let src = registers.read(11)?; let n = registers.read(12)?; if n > DMA_MEMCPY_MAX_BYTES { - return Err(ExecutionError::DmaMemcpyChunkTooLarge(n)); + return Err(ExecutionError::DmaChunkTooLarge(n)); } dst.checked_add(n).ok_or(MemoryError::AddressOverflow)?; src.checked_add(n).ok_or(MemoryError::AddressOverflow)?; @@ -546,7 +546,7 @@ impl Instruction { let fill = registers.read(11)?; let n = registers.read(12)?; if n > DMA_MEMCPY_MAX_BYTES { - return Err(ExecutionError::DmaMemcpyChunkTooLarge(n)); + return Err(ExecutionError::DmaChunkTooLarge(n)); } if fill > DMA_MEMSET_MAX_FILL { return Err(ExecutionError::DmaMemsetFillTooLarge(fill)); @@ -740,8 +740,8 @@ pub enum ExecutionError { EcsmAddressOverflow, #[error("ECSM xG and k operand ranges overlap")] EcsmOperandOverlap, - #[error("DMA memcpy chunk has {0} bytes; maximum per ecall is {DMA_MEMCPY_MAX_BYTES}")] - DmaMemcpyChunkTooLarge(u64), + #[error("DMA chunk has {0} bytes; maximum per ecall is {DMA_MEMCPY_MAX_BYTES}")] + DmaChunkTooLarge(u64), #[error("DMA memset fill is {0}; maximum is {DMA_MEMSET_MAX_FILL}")] DmaMemsetFillTooLarge(u64), #[error("ECSM scalar multiplication error: {0}")] From d1980c60a060e006facc4a97ea87a64f25912dfc Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 5 Aug 2026 14:34:53 -0300 Subject: [PATCH 07/13] Add DMA_SET tests and fix review nits --- .../rust/dma_memset_cases/src/main.rs | 14 + .../rust/dma_memset_min/.cargo/config.toml | 9 + .../programs/rust/dma_memset_min/Cargo.lock | 294 ++++++++++++++++++ .../programs/rust/dma_memset_min/Cargo.toml | 9 + .../programs/rust/dma_memset_min/src/main.rs | 17 + prover/src/tables/dma_set.rs | 4 +- .../tests/count_table_lengths_drift_tests.rs | 27 +- prover/src/tests/dma_set_tests.rs | 179 +++++++++++ prover/src/tests/mod.rs | 1 + prover/src/tests/prove_elfs_tests.rs | 139 +++++++++ 10 files changed, 683 insertions(+), 10 deletions(-) create mode 100644 executor/programs/rust/dma_memset_min/.cargo/config.toml create mode 100644 executor/programs/rust/dma_memset_min/Cargo.lock create mode 100644 executor/programs/rust/dma_memset_min/Cargo.toml create mode 100644 executor/programs/rust/dma_memset_min/src/main.rs create mode 100644 prover/src/tests/dma_set_tests.rs diff --git a/executor/programs/rust/dma_memset_cases/src/main.rs b/executor/programs/rust/dma_memset_cases/src/main.rs index 5caf0e285..318e2b0d1 100644 --- a/executor/programs/rust/dma_memset_cases/src/main.rs +++ b/executor/programs/rust/dma_memset_cases/src/main.rs @@ -30,12 +30,26 @@ pub fn main() { dma_set(buffer.as_mut_ptr(), 0x5A, buffer.len()); assert!(buffer.iter().all(|&byte| byte == 0x5A)); + // Zero is the fill almost every real caller passes (`vec![0; n]` and the + // allocator's `alloc_zeroed`), and it is the one value a dropped write is + // indistinguishable from on a fresh buffer — so start from 0xA5. + buffer.fill(0xA5); + dma_set(buffer.as_mut_ptr(), 0, 100); + assert!(buffer[..100].iter().all(|&byte| byte == 0)); + assert!(buffer[100..].iter().all(|&byte| byte == 0xA5)); + // The guest stub masks the fill to its low byte, matching C's // `memset(void*, int, size_t)` writing `(unsigned char)c`. buffer.fill(0); dma_set(buffer.as_mut_ptr(), 0x1FF, 64); assert!(buffer[..64].iter().all(|&byte| byte == 0xFF)); + // A negative int sign-extends to 0xFFFF_FFFF_FFFF_FFFF under lp64; the + // `andi` is what keeps the executor from rejecting it as a wide fill. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), -1, 32); + assert!(buffer[..32].iter().all(|&byte| byte == 0xFF)); + // Unaligned destination that also crosses a 4 KiB page boundary. let mut page_buffer = [0u8; 8192]; let to_boundary = 4096 - (page_buffer.as_ptr() as usize & 4095); diff --git a/executor/programs/rust/dma_memset_min/.cargo/config.toml b/executor/programs/rust/dma_memset_min/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memset_min/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memset_min/Cargo.lock b/executor/programs/rust/dma_memset_min/Cargo.lock new file mode 100644 index 000000000..47f113220 --- /dev/null +++ b/executor/programs/rust/dma_memset_min/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memset_min" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memset_min/Cargo.toml b/executor/programs/rust/dma_memset_min/Cargo.toml new file mode 100644 index 000000000..3a98a947c --- /dev/null +++ b/executor/programs/rust/dma_memset_min/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memset_min" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memset_min/src/main.rs b/executor/programs/rust/dma_memset_min/src/main.rs new file mode 100644 index 000000000..1705064b6 --- /dev/null +++ b/executor/programs/rust/dma_memset_min/src/main.rs @@ -0,0 +1,17 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memset(dst: *mut u8, fill: i32, count: usize) -> *mut u8; +} + +pub fn main() { + // 43 bytes = five eight-byte rows plus a three-byte tail, so one call yields + // a first row, wide intermediate rows, tail rows and a terminal row. + let mut buffer = [0u8; 43]; + let count = core::hint::black_box(buffer.len()); + + unsafe { + memset(buffer.as_mut_ptr(), 0x3C, count); + } + syscalls::syscalls::commit(&buffer); +} diff --git a/prover/src/tables/dma_set.rs b/prover/src/tables/dma_set.rs index 01e11426a..da30dc704 100644 --- a/prover/src/tables/dma_set.rs +++ b/prover/src/tables/dma_set.rs @@ -19,7 +19,7 @@ //! eight-byte rows and zero on one-byte tail rows, which is what lets the same //! write tuple serve both widths without per-lane constraints. //! -//! The result is 20 columns against memcpy's 32, and 18 bus interactions against +//! The result is 20 columns against memcpy's 32, and 19 bus interactions against //! 23. `fill <= 255` is proven on the first row, mirroring how `dma.rs` proves //! the per-ecall byte bound: the executor rejects a wider value, so an honest //! guest (whose stub masks `a1`) never trips it. @@ -195,7 +195,7 @@ fn halfword(column: usize) -> BusInteraction { ) } -/// DMA memset bus interactions (18 total). +/// DMA memset bus interactions (19 total). pub fn bus_interactions() -> Vec { let mu_minus_end = Multiplicity::Diff(cols::MU, cols::END); let mu_minus_first = Multiplicity::Diff(cols::MU, cols::FIRST); diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index f2cf4bd87..8e4563382 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -55,6 +55,10 @@ fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { predicted.dma_padded_rows, traces.dma.main_table.height as u64, "dma" ); + assert_eq!( + predicted.dma_set_padded_rows, traces.dma_set.main_table.height as u64, + "dma_set" + ); assert_eq!( predicted.decode_rows, traces.decode.main_table.height as u64, "decode" @@ -105,11 +109,12 @@ fn count_table_lengths_matches_traces() { } /// Runs one Rust DMA guest and asserts the sizing pass matches the built traces. -/// The two replays of a DMA ecall (`collect_dma_memcpy_ops` for generation and -/// `replay_dma_memcpy_for_sizing` for counting) must agree, so the fixtures cover -/// both a single chunk and the multi-chunk / overlapping / near-`MAX_DATA_ROWS` -/// cases of `dma_memcpy_cases`. -fn assert_dma_fixture_counts(elf_name: &str) { +/// Each ecall has two hand-maintained replays — `collect_dma_*_ops` for +/// generation and `replay_dma_*_for_sizing` for counting — and they must agree, +/// so the fixtures cover a single chunk plus the multi-chunk / overlapping / +/// near-`MAX_DATA_ROWS` cases of `dma_memcpy_cases`, and the same schedule +/// driven through the memset table. +fn assert_dma_fixture_counts(elf_name: &str, syscall_number: u64) { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root") @@ -125,7 +130,7 @@ fn assert_dma_fixture_counts(elf_name: &str) { assert!( result.logs.iter().any(|log| { - log.src1_val == executor::vm::instruction::execution::DMA_MEMCPY_SYSCALL_NUMBER + log.src1_val == syscall_number && matches!( result.instructions.get(&log.current_pc), Some(Instruction::EcallEbreak) @@ -138,6 +143,12 @@ fn assert_dma_fixture_counts(elf_name: &str) { #[test] fn count_table_lengths_matches_nonempty_dma_trace() { - assert_dma_fixture_counts("dma_memcpy_min.elf"); - assert_dma_fixture_counts("dma_memcpy_cases.elf"); + use executor::vm::instruction::execution::{ + DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER, + }; + + assert_dma_fixture_counts("dma_memcpy_min.elf", DMA_MEMCPY_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memcpy_cases.elf", DMA_MEMCPY_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memset_min.elf", DMA_MEMSET_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memset_cases.elf", DMA_MEMSET_SYSCALL_NUMBER); } diff --git a/prover/src/tests/dma_set_tests.rs b/prover/src/tests/dma_set_tests.rs new file mode 100644 index 000000000..cede12f20 --- /dev/null +++ b/prover/src/tests/dma_set_tests.rs @@ -0,0 +1,179 @@ +use crate::tables::dma_set::{DmaSetOperation, cols, generate_dma_set_trace}; +use crate::tables::types::FE; +use crate::test_utils::{busless_air, validate_busless}; + +fn row(count: u64, first: bool, end: bool) -> DmaSetOperation { + DmaSetOperation { + timestamp: 100, + dst: 0x2000, + count, + fill: 0x3C, + first, + end, + } +} + +#[test] +fn dma_set_trace_uses_eight_byte_rows_then_a_byte_tail() { + let trace = generate_dma_set_trace(&[ + row(10, true, false), + row(2, false, false), + row(1, false, false), + row(0, false, true), + ]); + + let wide = trace.main_table.get_row(0); + assert_eq!(wide[cols::TAIL], FE::zero()); + assert_eq!(wide[cols::DST_INCR_0], FE::from(0x2008u64)); + assert_eq!(wide[cols::COUNT_DECR_0], FE::from(2u64)); + assert_eq!(wide[cols::FILL], FE::from(0x3Cu64)); + assert_eq!(wide[cols::FILL_WIDE], FE::from(0x3Cu64)); + + let tail = trace.main_table.get_row(1); + assert_eq!(tail[cols::TAIL], FE::one()); + assert_eq!(tail[cols::DST_INCR_0], FE::from(0x2001u64)); + assert_eq!(tail[cols::COUNT_DECR_0], FE::one()); + assert_eq!(tail[cols::FILL], FE::from(0x3Cu64)); + // The write tuple broadcasts FILL_WIDE into lanes 1..7, so a one-byte row + // must zero it or the MEMW write widens past the byte it is allowed to touch. + assert_eq!(tail[cols::FILL_WIDE], FE::zero()); + + let terminal = trace.main_table.get_row(3); + assert_eq!(terminal[cols::END], FE::one()); + assert_eq!(terminal[cols::TAIL], FE::one()); + assert_eq!(terminal[cols::COUNT_DECR_0], FE::from(0xFFFFu64)); + assert_eq!(terminal[cols::COUNT_DECR_1], FE::from(0xFFFFu64)); + assert_eq!(terminal[cols::COUNT_DECR_2], FE::from(0xFFFFu64)); + assert_eq!(terminal[cols::COUNT_DECR_3], FE::from(0xFFFFu64)); +} + +#[test] +fn empty_dma_set_call_is_a_single_first_and_terminal_row() { + let trace = generate_dma_set_trace(&[row(0, true, true)]); + let first = trace.main_table.get_row(0); + assert_eq!(first[cols::FIRST], FE::one()); + assert_eq!(first[cols::END], FE::one()); + assert_eq!(first[cols::MU], FE::one()); +} + +#[test] +fn dma_set_constraints_accept_valid_rows_and_reject_a_wide_tail_fill() { + let mut trace = generate_dma_set_trace(&[ + row(2, true, false), + row(1, false, false), + row(0, false, true), + ]); + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + assert!(validate_busless(&air, &trace)); + + // Row 0 is a one-byte row (count = 2 < 8). Constraint 9 (`tail * fill_wide`) + // is the only thing stopping it from broadcasting the fill into lanes 1..7. + trace.main_table.set(0, cols::FILL_WIDE, FE::one()); + assert!( + !validate_busless(&air, &trace), + "a one-byte row must not smuggle a wide fill into lanes 1..7" + ); +} + +#[test] +fn dma_set_constraints_reject_a_wide_row_whose_fill_wide_disagrees_with_fill() { + let mut trace = generate_dma_set_trace(&[row(10, true, false), row(2, false, false)]); + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + assert!(validate_busless(&air, &trace)); + + // Row 0 is a wide row. Constraint 10 pins `fill_wide == fill`; without it the + // seven high lanes could carry a different byte than lane 0. + let fill = *trace.main_table.get(0, cols::FILL); + trace.main_table.set(0, cols::FILL_WIDE, fill + FE::one()); + assert!( + !validate_busless(&air, &trace), + "an eight-byte row must write the same byte in every lane" + ); +} + +#[test] +fn dma_set_constraints_reject_active_destination_wrap() { + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + + let destination_wrap = generate_dma_set_trace(&[DmaSetOperation { + timestamp: 100, + dst: u64::MAX - 3, + count: 8, + fill: 0x3C, + first: true, + end: false, + }]); + assert!( + !validate_busless(&air, &destination_wrap), + "an active destination increment must not wrap modulo 2^64" + ); +} + +#[test] +fn dma_set_terminal_row_may_wrap_unused_successor_columns() { + let trace = generate_dma_set_trace(&[DmaSetOperation { + timestamp: 100, + dst: u64::MAX, + count: 0, + fill: 0x3C, + first: true, + end: true, + }]); + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + assert!( + validate_busless(&air, &trace), + "terminal successors are not consumed and may wrap" + ); +} + +#[test] +fn dma_set_bus_interactions_count() { + use crate::tables::dma_set::bus_interactions; + assert_eq!(bus_interactions().len(), 19); +} + +#[test] +fn dma_set_constraints_count_and_indices() { + use crate::tables::dma_set::DmaSetConstraints; + use stark::constraints::builder::ConstraintSet; + let meta = DmaSetConstraints.meta(); + assert_eq!(meta.len(), 11); + // Dense, idx-ordered. + for (i, m) in meta.iter().enumerate() { + assert_eq!(m.constraint_idx, i); + } + // All constraints are degree 2 (no over-degree slips in a template change). + assert_eq!(DmaSetConstraints.max_degree(), 2); +} + +#[test] +fn dma_set_padding_row_cannot_claim_first_or_end() { + // Constraint 4, `(first + end) * (1 - mu) = 0`, is the sole guard that a + // padding row (mu = 0) cannot masquerade as the first or terminal row of a + // fill — bitness alone accepts first = 1 or end = 1. A padding row claiming + // `first` would forge an ECALL receive; claiming `end` would forge a + // terminal row. + let air = busless_air(cols::NUM_COLUMNS, crate::tables::dma_set::DmaSetConstraints); + let base = generate_dma_set_trace(&[ + row(2, true, false), + row(1, false, false), + row(0, false, true), + ]); + // Row 3 is padding: mu = 0, first = end = 0, and the trace validates. + assert_eq!(base.main_table.get_row(3)[cols::MU], FE::zero()); + assert!(validate_busless(&air, &base)); + + let mut forge_first = base.clone(); + forge_first.main_table.set(3, cols::FIRST, FE::one()); + assert!( + !validate_busless(&air, &forge_first), + "a padding row (mu = 0) must not claim to be a fill's first row" + ); + + let mut forge_end = base; + forge_end.main_table.set(3, cols::END, FE::one()); + assert!( + !validate_busless(&air, &forge_end), + "a padding row (mu = 0) must not claim to be a fill's terminal row" + ); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 5d0a88bdc..effd81f6f 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -39,6 +39,7 @@ pub mod decode_tests; #[cfg(all(test, feature = "disk-spill"))] pub mod disk_spill_tests; #[cfg(test)] +pub mod dma_set_tests; pub mod dma_tests; #[cfg(test)] pub mod dvrm_tests; diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 0a61b5046..abec19ae7 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1231,6 +1231,27 @@ fn test_prove_dma_memcpy_rust_guest() { ); } +/// Positive control for the fixture the memset forgery tests tamper with. Those +/// tests assert that verification FAILS, so without this they would also pass if +/// the untampered trace never verified in the first place. +#[test] +fn test_prove_dma_memset_min_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memset_min.elf")) + .expect("dma_memset_min.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memset guest should verify" + ); + assert_eq!(proof.public_output, [0x3Cu8; 43]); +} + /// End-to-end memset: the guest exercises every row-schedule boundary (empty, /// sub-tail, exact widths, the per-ecall cap, multi-chunk, a masked wide fill, /// and an unaligned page-crossing destination), so a passing proof covers the @@ -1370,6 +1391,124 @@ fn test_prove_dma_memcpy_forged_wide_tail_rejected() { assert_dma_forgery_rejected(&elf, &mut traces, "TAIL must equal count < 8"); } +/// Soundness: the seven high lanes of a wide DMA_SET write cannot carry a byte +/// other than `fill`. `fill_wide` has no counterpart in the memcpy table — it is +/// the column that lets one write tuple serve both widths — so it is the one +/// piece of this AIR with no already-tested ancestor. +#[test] +fn test_prove_dma_memset_forged_fill_wide_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && !tail); + let original = *traces + .dma_set + .main_table + .get(forged_row, dma_set_cols::FILL_WIDE); + traces.dma_set.main_table.set( + forged_row, + dma_set_cols::FILL_WIDE, + original + FieldElement::::one(), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "lanes 1..7 of a wide fill must carry the same byte as lane 0", + ); +} + +/// Soundness: `fill` rides the DmaSetNext chain, so an intermediate row cannot +/// switch to a different byte mid-fill. This is the anchor that makes one +/// register read on the first row bind every subsequent write. +#[test] +fn test_prove_dma_memset_forged_intermediate_fill_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |first, end, _tail| !first && !end); + // Shift both lanes so the row stays internally consistent (constraint 10 + // still holds); only the chain token and the MEMW write disagree. + for column in [dma_set_cols::FILL, dma_set_cols::FILL_WIDE] { + let original = *traces.dma_set.main_table.get(forged_row, column); + traces.dma_set.main_table.set( + forged_row, + column, + original + FieldElement::::one(), + ); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate row must keep the fill byte its predecessor sent", + ); +} + +#[test] +fn test_prove_dma_memset_forged_early_end_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, _tail| !end); + traces + .dma_set + .main_table + .set(forged_row, dma_set_cols::END, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "END must be equivalent to count == 0"); +} + +#[test] +fn test_prove_dma_memset_forged_wide_tail_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && !tail); + traces + .dma_set + .main_table + .set(forged_row, dma_set_cols::TAIL, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "TAIL must equal count < 8"); +} + +fn dma_memset_fixture() -> (Elf, Traces) { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memset_min.elf")) + .expect("dma_memset_min.elf not found — build its make target"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("execution"); + let traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +fn dma_set_row_matching(traces: &Traces, predicate: impl Fn(bool, bool, bool) -> bool) -> usize { + use crate::tables::dma_set::cols as dma_set_cols; + + (0..traces.dma_set.num_rows()) + .find(|&row| { + let active = *traces.dma_set.main_table.get(row, dma_set_cols::MU) + == FieldElement::::one(); + let first = *traces.dma_set.main_table.get(row, dma_set_cols::FIRST) + == FieldElement::::one(); + let end = *traces.dma_set.main_table.get(row, dma_set_cols::END) + == FieldElement::::one(); + let tail = *traces.dma_set.main_table.get(row, dma_set_cols::TAIL) + == FieldElement::::one(); + active && predicate(first, end, tail) + }) + .expect("guest must contain the requested real DMA_SET row") +} + fn dma_memcpy_fixture() -> (Elf, Traces) { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() From a85a41b53abc80884d766ef162d989a0006a0b9a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 5 Aug 2026 15:28:58 -0300 Subject: [PATCH 08/13] Add DMA_SET to the whole-AIR-set test lists --- prover/src/tests/constraint_program_device_tests.rs | 1 + prover/src/tests/constraint_program_tests.rs | 1 + prover/src/tests/mod.rs | 1 + prover/src/tests/ood_window_ir_tests.rs | 1 + prover/tests/gpu_constraint_interp_real.rs | 1 + 5 files changed, 5 insertions(+) diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index 050d7be80..2104dc568 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -158,6 +158,7 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_cpu_air(&opts), "CPU"); check_air_device(&create_dma_air(&opts), "DMA"); + check_air_device(&create_dma_set_air(&opts), "DMA_SET"); check_air_device(&create_bitwise_air(&opts), "BITWISE"); check_air_device(&create_lt_air(&opts), "LT"); check_air_device(&create_shift_air(&opts), "SHIFT"); diff --git a/prover/src/tests/constraint_program_tests.rs b/prover/src/tests/constraint_program_tests.rs index 7a81dfbe1..89438709f 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -156,6 +156,7 @@ fn all_table_programs_match_folders() { check_air(&create_cpu_air(&opts), "CPU"); check_air(&create_dma_air(&opts), "DMA"); + check_air(&create_dma_set_air(&opts), "DMA_SET"); check_air(&create_bitwise_air(&opts), "BITWISE"); check_air(&create_lt_air(&opts), "LT"); check_air(&create_shift_air(&opts), "SHIFT"); diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index effd81f6f..89b1c0295 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -40,6 +40,7 @@ pub mod decode_tests; pub mod disk_spill_tests; #[cfg(test)] pub mod dma_set_tests; +#[cfg(test)] pub mod dma_tests; #[cfg(test)] pub mod dvrm_tests; diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index a3c6e07a3..703aeb2c1 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -91,6 +91,7 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_cpu_air(&opts), true, "CPU"); assert_ood_window_matches_ir(&create_dma_air(&opts), true, "DMA"); + assert_ood_window_matches_ir(&create_dma_set_air(&opts), true, "DMA_SET"); assert_ood_window_matches_ir(&create_bitwise_air(&opts), true, "BITWISE"); assert_ood_window_matches_ir(&create_lt_air(&opts), true, "LT"); assert_ood_window_matches_ir(&create_shift_air(&opts), true, "SHIFT"); diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 14c75459b..df060f52b 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -272,4 +272,5 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); check_air(&create_dma_air(&opts), "DMA"); + check_air(&create_dma_set_air(&opts), "DMA_SET"); } From 13107d44759320b7e875ac95c596167ea8c50e85 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 5 Aug 2026 15:29:07 -0300 Subject: [PATCH 09/13] Tighten the DMA_SET forgery tests --- prover/src/tests/prove_elfs_tests.rs | 64 +++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index abec19ae7..49009f2c8 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1426,7 +1426,10 @@ fn test_prove_dma_memset_forged_intermediate_fill_rejected() { use crate::tables::dma_set::cols as dma_set_cols; let (elf, mut traces) = dma_memset_fixture(); - let forged_row = dma_set_row_matching(&traces, |first, end, _tail| !first && !end); + // `!tail` matters: on a one-byte row `fill_wide` must stay zero, so shifting + // both lanes there would trip constraint 9 locally and the test would prove + // something else. + let forged_row = dma_set_row_matching(&traces, |first, end, tail| !first && !end && !tail); // Shift both lanes so the row stays internally consistent (constraint 10 // still holds); only the chain token and the MEMW write disagree. for column in [dma_set_cols::FILL, dma_set_cols::FILL_WIDE] { @@ -1459,6 +1462,10 @@ fn test_prove_dma_memset_forged_early_end_rejected() { assert_dma_forgery_rejected(&elf, &mut traces, "END must be equivalent to count == 0"); } +/// Flipping `tail` rewrites `step` from 8 to 1, so the row's own address and +/// count arithmetic stop holding. Note this is rejected locally by the ADD +/// carries, NOT by the ALU LT that pins `tail = (count < 8)` — that bus has no +/// negative coverage here, the same gap the memcpy sibling has. #[test] fn test_prove_dma_memset_forged_wide_tail_rejected() { use crate::tables::dma_set::cols as dma_set_cols; @@ -1470,7 +1477,60 @@ fn test_prove_dma_memset_forged_wide_tail_rejected() { .main_table .set(forged_row, dma_set_cols::TAIL, FieldElement::one()); - assert_dma_forgery_rejected(&elf, &mut traces, "TAIL must equal count < 8"); + assert_dma_forgery_rejected(&elf, &mut traces, "a row's width must match its step"); +} + +/// Soundness: a one-byte row must not broadcast its fill into lanes 1..7. This +/// is the direction that matters — it is an eight-byte write where a single byte +/// was authorised. The wide-row test above covers the opposite, harmless case. +#[test] +fn test_prove_dma_memset_forged_tail_fill_wide_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && tail); + let fill = *traces + .dma_set + .main_table + .get(forged_row, dma_set_cols::FILL); + traces + .dma_set + .main_table + .set(forged_row, dma_set_cols::FILL_WIDE, fill); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "a one-byte row must not widen its write to eight lanes", + ); +} + +/// Soundness: the destination chain. The memcpy suite tampers `src`/`src_incr` +/// together; this is the memset analogue, and without it no test moves an +/// address at all. +#[test] +fn test_prove_dma_memset_forged_intermediate_destination_rejected() { + use crate::tables::dma_set::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |first, end, tail| !first && !end && !tail); + + // Shift both the current destination and its locally-consistent successor. + // The row's ADD stays valid; the predecessor's DmaSetNext tuple and the + // memory write no longer match. + for column in [dma_set_cols::DST_0, dma_set_cols::DST_INCR_0] { + let original = *traces.dma_set.main_table.get(forged_row, column); + traces + .dma_set + .main_table + .set(forged_row, column, original + FieldElement::from(8u64)); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate row must stay chained to its predecessor's address", + ); } fn dma_memset_fixture() -> (Elf, Traces) { From 0cc3228c13ce4e6e6f626a8c25fc349ed959cc4b Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 6 Aug 2026 17:50:22 -0300 Subject: [PATCH 10/13] Widen the memset proptest to the chunk cap --- executor/src/tests/dma_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/executor/src/tests/dma_tests.rs b/executor/src/tests/dma_tests.rs index 1a2dd95b0..f85167984 100644 --- a/executor/src/tests/dma_tests.rs +++ b/executor/src/tests/dma_tests.rs @@ -178,7 +178,7 @@ proptest! { #[test] fn dma_memset_matches_reference_fill( dst_offset in 0usize..64, - count in 0usize..200, + count in 0usize..=DMA_MEMCPY_MAX_BYTES as usize, fill in 0u8..=255, ) { const BASE: u64 = 0x9000; From 6949ceb9cac52126d4e54bf025d37479e0f07675 Mon Sep 17 00:00:00 2001 From: Mauro Toscano <12560266+MauroToscano@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:17:31 -0300 Subject: [PATCH 11/13] fix(verifier): pin each trace-opening column width to the AIR, not just their sum (#909) * fix(verifier): pin each trace-opening column width to the AIR, not just their sum The verifier pinned only the SUM of a query opening's precomputed/main/aux column counts (against the AIR-pinned OOD width). Nothing pinned the split, and the Merkle leaf hash pins neither: hash_data_from_slices streams evaluations || evaluations_sym with no length prefix and no separator. Each of the three trees is transcript-bound at a different time, so both splits are exploitable: * precomputed<->main: a non-preprocessed AIR never absorbs the precomputed root, so columns declared 'precomputed' are bound by nothing. A prover can sample the round-2 challenges and then solve for them. * main<->aux: the aux root is absorbed after the shared LogUp challenges, so a column moved from main to aux is chosen after challenges it must precede. trace_opening_widths_well_formed pins all three widths, for both the regular and the symmetric slot, once per table before any opening is read. Co-Authored-By: diegokingston * test(verifier): regression tests for the trace-opening column split Six end-to-end cases against a hostile prover that declares one column 'precomputed' for an AIR that is not preprocessed, plus direct tests of the guard on a RAP proof covering all three widths in both the regular and the symmetric slot. On stock main, three of these fail (the proof is accepted): the honest trace under a split declaration, the adaptively forged trace, and a demonstrably false statement. The other three pass on both and are the non-vacuity controls - in particular a genuinely preprocessed table, which has num_precomputed_columns() > 0, must still verify. The end-to-end cases need TEST_ONLY_SKIP_PRECOMPUTED_ROOT_ABSORB: a hostile prover does not absorb a root the verifier never reads, and without that the same proof is rejected for transcript divergence instead of for its split, which would prove nothing. Co-Authored-By: diegokingston * style: cargo fmt + drop redundant clones flagged by clippy Co-Authored-By: diegokingston * test(verifier): regression tests for the main<->aux opening split (LogUp break) Ports the aux-instance PoC into a permanent regression: a hostile AIR declaring layout (4, 2) against LogReadOnlyRAP's honest (5, 1) moves the multiplicity column into the auxiliary tree, which is transcript-bound only AFTER the shared LogUp challenges. The prover then solves that column against the sampled z/alpha, and the multiset equality the AIR exists to enforce degenerates into one scalar equation. On stock main both break tests are accepted - the structural mis-split and a false memory read (address 3 carrying two values) - the latter also over the rkyv wire through multi_verify_archived, the recursion-guest path. Unlike the precomputed instance this needs no prover change at all: both sides absorb main-root-then-aux-root either way. Three controls (corrupted aux opening, the same lie without the split, the split without the challenge solve) plus an honest LogReadOnlyRAP round trip pass on both, so the harness discriminates and the pin is not vacuous. Co-Authored-By: diegokingston * docs(verifier): record the aux instance at verify_trace_openings and in the guard doc The aux arm authenticates against the aux root but constrains no width; say so, and point at the upstream pin. Same class of stale comment as the two this PR already corrects. Co-Authored-By: diegokingston * test(verifier): drop the prover hook - both instances now pin hook-free The precomputed regression no longer needs the #[cfg(test)] absorb switch in prover.rs. Handing the prover and the verifier AIRs that disagree about num_precomputed_columns, while both absorb the same commitment constant, keeps the transcripts in sync - so the honest in-repo prover builds a proof that stock main accepts and this branch rejects. prover.rs is back to stock: the whole change is now verifier + tests. What the dropped end-to-end tests covered is kept: the 'a non-preprocessed AIR must declare zero precomputed columns' direction is pinned by the direct guard tests (its end-to-end form is masked by transcript divergence and proves nothing on its own), and the aux file demonstrates an executed false statement. Adds a tripwire (precheck_the_width_pin_is_compiled_in) plus attribution asserts in the break tests, so a rejection cannot be read as evidence unless it comes from the guard - the failure mode that made a sibling PoC look non-reproducing. Co-Authored-By: diegokingston * docs(test): state precisely what the round-1 root check does and does not catch The precomputed-width test's comment implied real preprocessed tables are exploitable through this shape. They are not directly: an honest constant is a root over exactly num_precomputed_columns() columns, so a narrower tree hashes differently and round 1 rejects it. Say that, and say why the defence is incidental - nothing states the invariant, nothing checks it, and it is absent entirely for a non-preprocessed AIR. Co-Authored-By: diegokingston * docs(verifier): trim the opening-width doc to the invariant The header carried the two exploit narratives in full, at ~33 lines for a ~40 line function -- 3x the sibling ood_blocks_well_formed. The mechanics belong in the tests that demonstrate them and in the PR; the header only needs the invariant, why an unpinned split is exploitable at all, and where to look. Co-Authored-By: diegokingston --------- Co-authored-by: diegokingston --- .../src/tests/aux_opening_width_tests.rs | 715 ++++++++++++++++++ crypto/stark/src/tests/mod.rs | 2 + crypto/stark/src/tests/opening_width_tests.rs | 532 +++++++++++++ crypto/stark/src/verifier.rs | 113 ++- 4 files changed, 1358 insertions(+), 4 deletions(-) create mode 100644 crypto/stark/src/tests/aux_opening_width_tests.rs create mode 100644 crypto/stark/src/tests/opening_width_tests.rs diff --git a/crypto/stark/src/tests/aux_opening_width_tests.rs b/crypto/stark/src/tests/aux_opening_width_tests.rs new file mode 100644 index 000000000..925f8111c --- /dev/null +++ b/crypto/stark/src/tests/aux_opening_width_tests.rs @@ -0,0 +1,715 @@ +//! Regression tests for the **main↔aux** term of the opening-width pin +//! (`verifier::trace_opening_widths_well_formed`); the precomputed↔main term and +//! the direct guard tests live in `tests::opening_width_tests`. +//! +//! Everything here is attacker-side — a hostile AIR *declaration* plus the trace +//! it implies. Unlike the precomputed instance, this one needs **no prover +//! change at all**: both sides absorb main-root-then-aux-root either way, so the +//! transcripts agree and an untouched prover produces the forgery. +//! +//! Mechanism +//! --------- +//! `verify_trace_openings` only Merkle-checks each of the three trace openings +//! against its own root; it never compared the aux opening width against +//! `air.num_auxiliary_rap_columns()`. The only width constraint was, in +//! `reconstruct_deep_composition_poly_evaluation_pair`: +//! +//! num_base + num_aux == ood_width +//! +//! with `num_base` and `num_aux` read off the *prover-supplied openings*. The +//! **total** is pinned (`ood_blocks_well_formed`) but the **split** was not, so a +//! prover could commit the last `k` main columns in the AUXILIARY tree instead. +//! +//! Why that breaks LogUp: the main root is absorbed in round 1 phase A, the +//! shared LogUp challenges `z`/`alpha` are sampled immediately after, and the aux +//! root only in phase C. A column moved into the aux tree is therefore chosen +//! AFTER `z` and `alpha` are known, which collapses the multiset equality into a +//! single scalar equation the prover solves — no fingerprint collision needed. +//! +//! Vehicle: `LogReadOnlyRAP`, the in-repo continuous read-only-memory AIR whose +//! memory consistency rests entirely on LogUp. Honest layout (5, 1): +//! main = [a, v, a', v', m], aux = [s]. The attacker declares (4, 2): +//! main = [a, v, a', v'], aux = [m, s] — same global column order, same +//! constraints, same OOD width, so an unpinned verifier cannot tell. The +//! multiplicity column `m` is then picked after `z`/`alpha`. The moved column is +//! the multiplicity column on purpose: `traits.rs:182-188` documents the trailing +//! main columns of every preprocessed table as exactly the multiplicities. +//! +//! On stock `main` the two break tests below are ACCEPTED, including over the +//! rkyv wire through `multi_verify_archived` (the recursion-guest path). The +//! three controls are rejected on both, and discriminate the harness. + +use std::marker::PhantomData; + +use crate::constraints::{ + boundary::{BoundaryConstraint, BoundaryConstraints}, + builder::{ + ConstraintBuilder, ConstraintMeta, ConstraintSet, RowDomain, num_base_from_meta, + run_transition_prover, run_transition_verifier, + }, +}; +use crate::context::AirContext; +use crate::examples::read_only_memory_logup::{ + LogReadOnlyPublicInputs, LogReadOnlyRAP, read_only_logup_trace, +}; +use crate::proof::options::ProofOptions; +use crate::proof::view::StarkProofView; +use crate::prover::{IsStarkProver, Prover}; +use crate::trace::TraceTable; +use crate::traits::{AIR, TransitionEvaluationContext}; +use crate::verifier::{IsStarkVerifier, Verifier}; +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField; +use math::field::goldilocks::GoldilocksField; + +type F = GoldilocksField; +type E = Degree3GoldilocksExtensionField; +type Felt = FieldElement; +type Ext = FieldElement; + +// ============================================================================= +// The hostile constraint body: byte-for-byte `LogReadOnlyRAPConstraints` with +// the multiplicity column re-addressed from main[4] to aux[0] and the LogUp +// accumulator from aux[0] to aux[1]. Same values, same degrees, same meta. +// ============================================================================= + +pub struct SplitLogUpConstraints; + +impl ConstraintSet for SplitLogUpConstraints { + fn eval>(&self, b: &mut B) { + let a_sorted_0 = b.main(0, 2); + let a_sorted_1 = b.main(1, 2); + let v_sorted_0 = b.main(0, 3); + let v_sorted_1 = b.main(1, 3); + let one = b.one(); + let addr_diff = a_sorted_1 - a_sorted_0; + + b.emit_base_rows( + 0, + RowDomain::except_last(1), + addr_diff.clone() * (addr_diff.clone() - one.clone()), + ); + b.emit_base_rows( + 1, + RowDomain::except_last(1), + (v_sorted_1 - v_sorted_0) * (addr_diff - one), + ); + + // ---- the only difference: s is aux[1], m is aux[0] (was main[4]) ---- + let s0 = b.aux(0, 1); + let s1 = b.aux(1, 1); + let z = b.challenge(0); + let alpha = b.challenge(1); + let a1 = b.main(1, 0); + let v1 = b.main(1, 1); + let a_sorted_1 = b.main(1, 2); + let v_sorted_1 = b.main(1, 3); + let m = b.aux(1, 0); + let unsorted_term = -(a1 + v1 * alpha.clone()) + z.clone(); + let sorted_term = -(a_sorted_1 + v_sorted_1 * alpha) + z; + b.emit_ext_rows( + 2, + RowDomain::except_last(1), + s0 * unsorted_term.clone() * sorted_term.clone() + m * unsorted_term.clone() + - sorted_term.clone() + - s1 * unsorted_term * sorted_term, + ); + } +} + +/// How the attacker fills the moved multiplicity column. +#[derive(Clone)] +pub enum MPlan { + /// Honest multiplicities, merely committed in the wrong tree. + Honest(Vec), + /// Honest multiplicities except index `idx`, which is SOLVED after `z`, + /// `alpha` are known so the LogUp accumulator still lands on zero. + Forge { base: Vec, idx: usize }, +} + +pub struct SplitLogUpAIR { + context: AirContext, + meta: Vec, + plan: MPlan, + /// Records the challenge-dependent multiplicity the attack solved for. + pub forged_value: std::sync::Mutex>, + /// Records the committed multiplicity column and the (z, alpha) it was + /// solved against, so a test can replay the LogUp identity off-protocol. + pub committed_m: std::sync::Mutex, Ext, Ext)>>, + phantom: PhantomData<(F, E)>, +} + +impl SplitLogUpAIR { + pub fn with_plan(proof_options: &ProofOptions, plan: MPlan) -> Self { + let mut air = ::new(proof_options); + air.plan = plan; + air + } +} + +impl AIR for SplitLogUpAIR { + type Field = F; + type FieldExtension = E; + type PublicInputs = LogReadOnlyPublicInputs; + + fn step_size(&self) -> usize { + 1 + } + + fn new(proof_options: &ProofOptions) -> Self { + let meta = ConstraintSet::::meta(&SplitLogUpConstraints); + let context = AirContext { + proof_options: proof_options.clone(), + trace_columns: 6, + transition_offsets: vec![0, 1], + num_transition_constraints: meta.len(), + }; + Self { + context, + meta, + plan: MPlan::Honest(Vec::new()), + forged_value: std::sync::Mutex::new(None), + committed_m: std::sync::Mutex::new(None), + phantom: PhantomData, + } + } + + /// Runs AFTER the main root is absorbed and AFTER `z`, `alpha` are sampled. + /// Fills aux[0] = m (the moved main column) and aux[1] = s. + fn build_auxiliary_trace( + &self, + trace: &mut TraceTable, + challenges: &[Ext], + ) -> Option> { + let cols = trace.columns_main(); + let (a, v, a_sorted, v_sorted) = (&cols[0], &cols[1], &cols[2], &cols[3]); + let z = &challenges[0]; + let alpha = &challenges[1]; + let n = trace.num_rows(); + + // u_i = 1/(z - (a_i + alpha*v_i)) ; t_i = 1/(z - (a'_i + alpha*v'_i)) + let u: Vec = (0..n) + .map(|i| (-(&a[i] + &v[i] * alpha) + z).inv().unwrap()) + .collect(); + let t: Vec = (0..n) + .map(|i| (-(&a_sorted[i] + &v_sorted[i] * alpha) + z).inv().unwrap()) + .collect(); + + let m: Vec = match &self.plan { + MPlan::Honest(base) => base.iter().map(|x| x.to_extension()).collect(), + MPlan::Forge { base, idx } => { + let mut m: Vec = base.iter().map(|x| x.to_extension()).collect(); + // Solve sum_i m_i t_i = sum_i u_i for m_idx. + let mut rhs = u.iter().fold(Ext::zero(), |acc, x| acc + x); + for i in 0..n { + if i != *idx { + rhs = rhs - &m[i] * &t[i]; + } + } + let solved = rhs * t[*idx].inv().unwrap(); + *self.forged_value.lock().unwrap() = Some(solved); + m[*idx] = solved; + m + } + }; + + *self.committed_m.lock().unwrap() = Some((m.clone(), *z, *alpha)); + + let mut s = Vec::with_capacity(n); + s.push(&m[0] * &t[0] - &u[0]); + for i in 0..n - 1 { + let next = &s[i] + &m[i + 1] * &t[i + 1] - &u[i + 1]; + s.push(next); + } + + for i in 0..n { + trace.set_aux(i, 0, m[i]); + trace.set_aux(i, 1, s[i]); + } + None + } + + /// The lie: 4 main columns, 2 aux columns (honest AIR says 5 and 1). + fn trace_layout(&self) -> (usize, usize) { + (4, 2) + } + + fn boundary_constraints( + &self, + pub_inputs: &Self::PublicInputs, + rap_challenges: &[Ext], + _bus_public_inputs: Option<&crate::lookup::BusPublicInputs>, + trace_length: usize, + ) -> BoundaryConstraints { + let a0 = &pub_inputs.a0; + let v0 = &pub_inputs.v0; + let a_sorted_0 = &pub_inputs.a_sorted_0; + let v_sorted_0 = &pub_inputs.v_sorted_0; + let m0 = &pub_inputs.m0; + let z = &rap_challenges[0]; + let alpha = &rap_challenges[1]; + + let c1 = BoundaryConstraint::new_main(0, 0, a0.to_extension()); + let c2 = BoundaryConstraint::new_main(1, 0, v0.to_extension()); + let c3 = BoundaryConstraint::new_main(2, 0, a_sorted_0.to_extension()); + let c4 = BoundaryConstraint::new_main(3, 0, v_sorted_0.to_extension()); + // main[4] under the honest layout -> aux[0] here. Same GLOBAL index 4, + // which is all the verifier's `main_trace_width + col` mapping sees. + let c5 = BoundaryConstraint::new_aux(0, 0, m0.to_extension()); + + let unsorted_term = (-(a0 + v0 * alpha) + z).inv().unwrap(); + let sorted_term = (-(a_sorted_0 + v_sorted_0 * alpha) + z).inv().unwrap(); + let p0_value = m0 * sorted_term - unsorted_term; + + let c_aux1 = BoundaryConstraint::new_aux(1, 0, p0_value); + let c_aux2 = BoundaryConstraint::new_aux(1, trace_length - 1, Ext::zero()); + + BoundaryConstraints::from_constraints(vec![c1, c2, c3, c4, c5, c_aux1, c_aux2]) + } + + fn constraints_meta(&self) -> &[ConstraintMeta] { + &self.meta + } + + fn compute_transition_prover( + &self, + evaluation_context: &TransitionEvaluationContext, + base_evals: &mut [Felt], + ext_evals: &mut [Ext], + ) { + run_transition_prover( + &SplitLogUpConstraints, + evaluation_context, + base_evals, + ext_evals, + ); + } + + fn compute_transition( + &self, + evaluation_context: &TransitionEvaluationContext, + ) -> Vec { + run_transition_verifier( + &SplitLogUpConstraints, + evaluation_context, + self.num_base_transition_constraints(), + self.num_transition_constraints(), + ) + } + + fn num_base_transition_constraints(&self) -> usize { + num_base_from_meta(&ConstraintSet::::meta(&SplitLogUpConstraints)) + } + + fn context(&self) -> &AirContext { + &self.context + } + + fn composition_poly_degree_bound(&self, trace_length: usize) -> usize { + trace_length * 2 + } +} + +// ============================================================================= +// Fixtures +// ============================================================================= + +/// The exact data of the in-repo happy-path test +/// (`air_tests.rs::test_prove_read_only_memory_logup`): a continuous read-only +/// memory over addresses 1..=5. +fn honest_reads() -> (Vec, Vec) { + ( + vec![3, 2, 2, 3, 4, 5, 1, 3] + .into_iter() + .map(Felt::from) + .collect(), + vec![30, 20, 20, 30, 40, 50, 10, 30] + .into_iter() + .map(Felt::from) + .collect(), + ) +} + +fn public_inputs() -> LogReadOnlyPublicInputs { + LogReadOnlyPublicInputs { + a0: Felt::from(3), + v0: Felt::from(30), + a_sorted_0: Felt::from(1), + v_sorted_0: Felt::from(10), + m0: Felt::from(1), + } +} + +/// Split an honest 5-main-column LogUp trace into the attacker's shape: +/// 4 main columns + 2 (zeroed) aux columns. Returns the m column separately. +fn split_trace(addresses: Vec, values: Vec) -> (TraceTable, Vec) { + let honest: TraceTable = read_only_logup_trace(addresses, values); + let cols = honest.columns_main(); + let n = cols[0].len(); + let m = cols[4].clone(); + let main = vec![ + cols[0].clone(), + cols[1].clone(), + cols[2].clone(), + cols[3].clone(), + ]; + let aux = vec![vec![Ext::zero(); n], vec![Ext::zero(); n]]; + (TraceTable::from_columns(main, aux, 1), m) +} + +fn opts() -> ProofOptions { + ProofOptions::default_test_options() +} + +fn honest_air() -> LogReadOnlyRAP { + LogReadOnlyRAP::::new(&opts()) +} + +fn tr() -> DefaultTranscript { + DefaultTranscript::::new(&[]) +} + +// ============================================================================= +// The two AIRs are indistinguishable to the verifier except for the split, so +// nothing but an explicit width pin can tell them apart. +// ============================================================================= + +#[test_log::test] +fn split_declaration_differs_from_the_honest_air_only_in_the_layout() { + let h = honest_air(); + let a = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(Vec::new())); + assert_eq!( + format!("{:?}", h.constraints_meta()), + format!("{:?}", a.constraints_meta()), + "meta must match" + ); + assert_eq!(h.context().trace_columns, a.context().trace_columns); + assert_eq!( + h.context().transition_offsets, + a.context().transition_offsets + ); + assert_eq!( + h.num_transition_constraints(), + a.num_transition_constraints() + ); + assert_eq!( + h.num_base_transition_constraints(), + a.num_base_transition_constraints() + ); + assert_eq!( + h.trace_ood_next_row_columns(), + a.trace_ood_next_row_columns() + ); + assert_eq!( + h.composition_poly_degree_bound(8), + a.composition_poly_degree_bound(8) + ); + assert_eq!(h.has_aux_trace(), a.has_aux_trace()); + assert_eq!(h.has_trace_interaction(), a.has_trace_interaction()); + // The ONLY divergence: + assert_eq!(h.trace_layout(), (5, 1)); + assert_eq!(a.trace_layout(), (4, 2)); + assert_eq!(h.num_auxiliary_rap_columns(), 1); + assert_eq!(a.num_auxiliary_rap_columns(), 2); + println!("AUXSPLIT/0 honest layout (5,1) attacker layout (4,2) — everything else identical"); +} + +// ============================================================================= +// The structural case: a proof whose aux opening is 2 columns wide, verified +// against an AIR that declares exactly 1. Accepted on stock `main`, and it needs +// no forgery at all — the trace here is honest. +// ============================================================================= + +#[test_log::test] +fn mis_split_aux_opening_is_rejected() { + let (addr, val) = honest_reads(); + let (mut trace, m) = split_trace(addr, val); + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(m)); + + let proof = Prover::prove(&attack_air, &mut trace, &pi, &mut tr()).expect("prove"); + + let aux_w = proof.deep_poly_openings[0] + .aux_trace_polys + .as_ref() + .unwrap() + .evaluations + .len(); + let main_w = proof.deep_poly_openings[0] + .main_trace_polys + .evaluations + .len(); + let h = honest_air(); + println!( + "AUXSPLIT/1 opening widths: main={main_w} aux={aux_w} AIR declares main={} aux={}", + h.trace_layout().0, + h.num_auxiliary_rap_columns() + ); + assert_eq!(main_w, 4); + assert_eq!(aux_w, 2); + assert_ne!(aux_w, h.num_auxiliary_rap_columns()); + + let accepted = Verifier::verify(&proof, &h, &mut tr()); + println!("AUXSPLIT/1 STOCK VERIFIER ACCEPTED MIS-SPLIT PROOF = {accepted}"); + assert!( + !accepted, + "the verifier must reject an aux opening wider than the AIR declares", + ); + + // Attribution: the rejection is the width pin's, not an incidental failure + // elsewhere in verification. A "rejected" verdict is only evidence if it + // comes from the guard under test. + assert!( + !Verifier::trace_opening_widths_well_formed( + &h, + StarkProofView::Owned(&proof), + h.options().fri_number_of_queries, + ), + "the rejection above must come from the opening-width guard", + ); +} + +// ============================================================================= +// CONTROL — the harness discriminates: corrupting one value in the (wrongly +// wide) aux opening must be rejected. Passes on stock `main` too. +// ============================================================================= + +#[test_log::test] +fn corrupted_aux_opening_is_rejected() { + let (addr, val) = honest_reads(); + let (mut trace, m) = split_trace(addr, val); + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(m)); + let proof = Prover::prove(&attack_air, &mut trace, &pi, &mut tr()).expect("prove"); + + let mut corrupted = proof.clone(); + corrupted.deep_poly_openings[0] + .aux_trace_polys + .as_mut() + .unwrap() + .evaluations[0] += Ext::one(); + let accepted = Verifier::verify(&corrupted, &honest_air(), &mut tr()); + println!("AUXSPLIT/CONTROL-A corrupted aux opening accepted = {accepted}"); + assert!(!accepted, "harness must discriminate"); +} + +// ============================================================================= +// The break: a FALSE statement, accepted on stock `main`. +// +// The read column contains address 3 -> 30 (rows 0, 3) AND address 3 -> 999999 +// (row 7). No single-valued read-only memory can serve both, so the LogUp +// multiset equality that this AIR exists to enforce is FALSE. With `m` moved +// into the aux tree the prover solves for m[1] AFTER seeing z, alpha, and the +// stock verifier accepts. +// ============================================================================= + +const BOGUS: u64 = 999999; + +#[test_log::test] +fn false_memory_read_under_aux_split_is_rejected() { + let (addr, mut val) = honest_reads(); + // Honest sorted memory table, built from the HONEST reads. + let (_, honest_m) = split_trace(addr.clone(), val.clone()); + let honest_trace: TraceTable = read_only_logup_trace(addr.clone(), val.clone()); + let sorted_a = honest_trace.columns_main()[2].clone(); + let sorted_v = honest_trace.columns_main()[3].clone(); + + // The lie: read #7 (address 3) now claims value 999999. + val[7] = Felt::from(BOGUS); + + // Sanity: the read multiset is now impossible for a single-valued memory. + let mut same_addr_values: Vec = Vec::new(); + for i in 0..addr.len() { + if addr[i] == Felt::from(3) && !same_addr_values.contains(&val[i]) { + same_addr_values.push(val[i]); + } + } + println!( + "AUXSPLIT/2 reads at address 3 claim {} distinct values: {same_addr_values:?}", + same_addr_values.len() + ); + assert!( + same_addr_values.len() > 1, + "the statement must be false: address 3 must carry two different values" + ); + + let n = addr.len(); + let main = vec![addr.clone(), val.clone(), sorted_a, sorted_v]; + let aux = vec![vec![Ext::zero(); n], vec![Ext::zero(); n]]; + let mut trace = TraceTable::::from_columns(main, aux, 1); + + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan( + &opts(), + MPlan::Forge { + base: honest_m, + idx: 1, + }, + ); + let proof = Prover::prove(&attack_air, &mut trace, &pi, &mut tr()).expect("prove"); + + let forged = attack_air.forged_value.lock().unwrap().unwrap(); + println!("AUXSPLIT/2 solved multiplicity m[1] (challenge-dependent) = {forged:?}"); + + let accepted = Verifier::verify(&proof, &honest_air(), &mut tr()); + println!("AUXSPLIT/2 FALSE STATEMENT ACCEPTED BY STOCK VERIFIER = {accepted}"); + assert!( + !accepted, + "the verifier must reject a false statement carried by an aux mis-split", + ); + + // Attribution: the rejection is the width pin's, not an incidental failure + // elsewhere in verification. A "rejected" verdict is only evidence if it + // comes from the guard under test. + assert!( + !Verifier::trace_opening_widths_well_formed( + &honest_air(), + StarkProofView::Owned(&proof), + honest_air().options().fri_number_of_queries, + ), + "the rejection above must come from the opening-width guard", + ); + + // -------- the same forgery over the WIRE: rkyv-serialize and verify + // through `multi_verify_archived`, the read-in-place path the recursion + // guest uses. Proves this is a transmissible proof, not an in-process + // artefact, and that the archived path shares the hole. ----------------- + let multi = crate::proof::stark::MultiProof { + proofs: vec![proof.clone()], + }; + let bytes = rkyv::to_bytes::(&multi).unwrap(); + println!("AUXSPLIT/2 serialized forged proof: {} bytes", bytes.len()); + let archived = rkyv::access::< + crate::proof::stark::ArchivedMultiProof>, + rkyv::rancor::Error, + >(&bytes) + .unwrap(); + let h = honest_air(); + let airs: Vec< + &dyn AIR>, + > = vec![&h]; + let accepted_archived = + Verifier::multi_verify_archived(&airs, archived, &mut tr(), &Ext::zero()); + println!("AUXSPLIT/2 ARCHIVED (wire) PATH ACCEPTED = {accepted_archived}"); + assert!( + !accepted_archived, + "the archived (recursion-guest) path must reject it too", + ); + + // -------- diagnostic: the accepted LogUp identity is NOT a multiset + // equality, it holds only at the protocol's own (z, alpha). ------------- + let (m_committed, z, alpha) = attack_air.committed_m.lock().unwrap().clone().unwrap(); + let cols = trace.columns_main(); + let logup_residual = |z: &Ext, alpha: &Ext| -> Ext { + let mut acc = Ext::zero(); + for i in 0..n { + let u = (-(&cols[0][i] + &cols[1][i] * alpha) + z).inv().unwrap(); + let t = (-(&cols[2][i] + &cols[3][i] * alpha) + z).inv().unwrap(); + acc = acc + &m_committed[i] * t - u; + } + acc + }; + let at_protocol = logup_residual(&z, &alpha); + let z2 = z + Ext::from(7u64); + let a2 = alpha + Ext::from(11u64); + let at_fresh = logup_residual(&z2, &a2); + println!("AUXSPLIT/2 LogUp residual at the protocol's (z,alpha) = {at_protocol:?}"); + println!("AUXSPLIT/2 LogUp residual at a FRESH (z',alpha') = {at_fresh:?}"); + assert_eq!( + at_protocol, + Ext::zero(), + "the attack balances the bus at the sampled challenges" + ); + assert_ne!( + at_fresh, + Ext::zero(), + "…but not as a rational identity: the two multisets genuinely differ" + ); +} + +// ============================================================================= +// CONTROL — the SAME false trace, proven WITHOUT the split (honest layout, +// honest multiplicities in the main tree). `m` is then bound before z/alpha and +// the bus cannot be made to balance: the proof must be rejected (or the prover +// must refuse). Shows the acceptance above comes from the split, not from a hole +// in the AIR. Passes on stock `main` too. +// ============================================================================= + +#[test_log::test] +fn same_false_read_without_the_split_is_rejected() { + let (addr, mut val) = honest_reads(); + let honest_trace: TraceTable = read_only_logup_trace(addr.clone(), val.clone()); + let sorted_a = honest_trace.columns_main()[2].clone(); + let sorted_v = honest_trace.columns_main()[3].clone(); + let m = honest_trace.columns_main()[4].clone(); + val[7] = Felt::from(BOGUS); + + let n = addr.len(); + let main = vec![addr, val, sorted_a, sorted_v, m]; + let aux = vec![vec![Ext::zero(); n]]; + let mut trace = TraceTable::::from_columns(main, aux, 1); + let pi = public_inputs(); + let h = honest_air(); + + match Prover::prove(&h, &mut trace, &pi, &mut tr()) { + Ok(proof) => { + let accepted = Verifier::verify(&proof, &h, &mut tr()); + println!("AUXSPLIT/CONTROL-B no-split false trace accepted = {accepted}"); + assert!(!accepted, "control must be rejected"); + } + Err(e) => println!("AUXSPLIT/CONTROL-B no-split prover refused: {e:?}"), + } +} + +// ============================================================================= +// CONTROL — the split path is not a free pass: the SAME split declaration with +// HONEST multiplicities over the FALSE read column must be rejected. Only the +// challenge-dependent solve makes the forgery go through. Passes on stock `main` +// too. +// ============================================================================= + +#[test_log::test] +fn aux_split_without_the_challenge_solve_is_rejected() { + let (addr, mut val) = honest_reads(); + let honest_trace: TraceTable = read_only_logup_trace(addr.clone(), val.clone()); + let sorted_a = honest_trace.columns_main()[2].clone(); + let sorted_v = honest_trace.columns_main()[3].clone(); + let m = honest_trace.columns_main()[4].clone(); + val[7] = Felt::from(BOGUS); + + let n = addr.len(); + let main = vec![addr, val, sorted_a, sorted_v]; + let aux = vec![vec![Ext::zero(); n], vec![Ext::zero(); n]]; + let mut trace = TraceTable::::from_columns(main, aux, 1); + let pi = public_inputs(); + let attack_air = SplitLogUpAIR::with_plan(&opts(), MPlan::Honest(m)); + + match Prover::prove(&attack_air, &mut trace, &pi, &mut tr()) { + Ok(proof) => { + let accepted = Verifier::verify(&proof, &honest_air(), &mut tr()); + println!("AUXSPLIT/CONTROL-C split + honest m over false reads accepted = {accepted}"); + assert!(!accepted, "control must be rejected"); + } + Err(e) => println!("AUXSPLIT/CONTROL-C prover refused: {e:?}"), + } +} + +// ============================================================================= +// NON-VACUITY — the honest `LogReadOnlyRAP` (layout (5, 1), aux width 1) must +// still verify. A pin that rejected every aux opening would satisfy every +// rejection test above. +// ============================================================================= + +#[test_log::test] +fn honest_logup_rap_proof_still_verifies() { + let (addr, val) = honest_reads(); + let mut trace: TraceTable = read_only_logup_trace(addr, val); + let air = honest_air(); + let proof = Prover::prove(&air, &mut trace, &public_inputs(), &mut tr()).expect("prove"); + + assert!( + Verifier::verify(&proof, &air, &mut tr()), + "an honest LogUp proof must verify", + ); +} diff --git a/crypto/stark/src/tests/mod.rs b/crypto/stark/src/tests/mod.rs index 15b64d45a..468a4cd3c 100644 --- a/crypto/stark/src/tests/mod.rs +++ b/crypto/stark/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod air_tests; +pub mod aux_opening_width_tests; #[cfg(feature = "debug-checks")] pub mod bus_debug_tests; pub mod bus_tests; @@ -6,6 +7,7 @@ pub mod commitment_tests; pub mod domain_cache_stats; pub mod fri_tests; pub mod grinding_tests; +pub mod opening_width_tests; pub mod proof_options_tests; pub mod prove_verify_roundtrip_tests; pub mod prover_tests; diff --git a/crypto/stark/src/tests/opening_width_tests.rs b/crypto/stark/src/tests/opening_width_tests.rs new file mode 100644 index 000000000..db5764220 --- /dev/null +++ b/crypto/stark/src/tests/opening_width_tests.rs @@ -0,0 +1,532 @@ +//! Negative tests for the trace-opening column split +//! (`verifier::trace_opening_widths_well_formed`). +//! +//! A query opening carries the trace row as three prover-supplied vectors — +//! `precomputed ‖ main` (base field) and `aux` (extension field) — which the +//! DEEP reconstruction consumes as one concatenated row. Only their *sum* used +//! to be pinned (against the AIR-pinned OOD width), and the Merkle leaf hash +//! pins neither split: `hash_data_from_slices` streams `evaluations ‖ +//! evaluations_sym` with no length prefix and no separator. +//! +//! That mattered because the three trees are transcript-bound at different +//! times. This file covers the **precomputed↔main** term; the main↔aux term — +//! the LogUp break, and the instance with an executed false statement — lives in +//! `tests::aux_opening_width_tests`. +//! +//! Two layers, both free of any prover modification: +//! +//! * `precomputed_opening_narrower_than_the_air_declares_is_rejected` — end to +//! end through `Verifier::verify`, accepted on stock `main`. The prover and +//! the verifier's AIR disagree about how many columns the precomputed +//! commitment pins, while both absorb the same constant, so the transcripts +//! agree and the honest in-repo prover builds the proof. +//! * `opening_widths_*` — the guard called directly on surgically re-split +//! openings. These reach what no end-to-end test can: the `evaluations_sym` +//! slot (a separate prover-supplied vector the leaf hash does not pin apart +//! from `evaluations`) and the "a non-preprocessed AIR must declare zero +//! precomputed columns" direction, whose end-to-end form is masked by +//! transcript divergence and so proves nothing on its own. + +use std::marker::PhantomData; + +use crate::config::Commitment; +use crate::constraints::{ + boundary::{BoundaryConstraint, BoundaryConstraints}, + builder::{ + ConstraintMeta, ConstraintSet, num_base_from_meta, run_transition_prover, + run_transition_verifier, + }, +}; +use crate::context::AirContext; +use crate::examples::fibonacci_2_columns::{Fibonacci2ColsConstraints, compute_trace}; +use crate::examples::fibonacci_rap::{FibonacciRAP, FibonacciRAPPublicInputs, fibonacci_rap_trace}; +use crate::examples::simple_fibonacci::FibonacciPublicInputs; +use crate::proof::options::ProofOptions; +use crate::proof::stark::StarkProof; +use crate::proof::view::StarkProofView; +use crate::prover::{IsStarkProver, Prover}; +use crate::traits::{AIR, TransitionEvaluationContext}; +use crate::verifier::{IsStarkVerifier, Verifier}; +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use math::field::goldilocks::GoldilocksField; +use math::field::traits::IsFFTField; + +type F = GoldilocksField; +type Felt = FieldElement; + +const TRACE_LEN: usize = 16; + +/// `Fibonacci2ColsAIR` with two declaration knobs: +/// +/// * `precomputed_columns` — how many leading columns the AIR claims live in the +/// precomputed tree (0 = not preprocessed). Prover and verifier are handed +/// instances that disagree about this, which is the whole point. +/// * `out`, when set, adds a public-output boundary on the last row of column 1. +/// Since `(a0, a1)` determine the whole trace, a wrong `out` would make the +/// claimed statement FALSE. +pub struct FibonacciSplitAIR { + context: AirContext, + meta: Vec, + out: Option>, + precomputed_columns: usize, + precomputed_commitment: Commitment, + phantom: PhantomData, +} + +impl FibonacciSplitAIR { + /// The AIR as the verifier sees it: plain, non-preprocessed. + fn honest(proof_options: &ProofOptions, out: Option>) -> Self { + let mut air = ::new(proof_options); + air.out = out; + air + } + + /// The AIR the hostile prover proves against: same width, same constraints, + /// same boundary constraints — only the precomputed declaration differs. + fn split( + proof_options: &ProofOptions, + out: Option>, + commitment: Commitment, + ) -> Self { + Self::preprocessed_declaring(proof_options, out, 1, commitment) + } + + /// A preprocessed declaration with an explicit precomputed-column count. + /// Handing the verifier a different count than the prover used is how the + /// hook-free test below reaches the precomputed term of the guard: both + /// sides still absorb the same commitment, so the transcripts agree. + fn preprocessed_declaring( + proof_options: &ProofOptions, + out: Option>, + precomputed_columns: usize, + commitment: Commitment, + ) -> Self { + let mut air = Self::honest(proof_options, out); + air.precomputed_columns = precomputed_columns; + air.precomputed_commitment = commitment; + air + } +} + +impl AIR for FibonacciSplitAIR +where + F: IsFFTField + Send + Sync + 'static, +{ + type Field = F; + type FieldExtension = F; + type PublicInputs = FibonacciPublicInputs; + + fn step_size(&self) -> usize { + 1 + } + + fn new(proof_options: &ProofOptions) -> Self { + let meta = Fibonacci2ColsConstraints::::default().meta(); + let context = AirContext { + proof_options: proof_options.clone(), + transition_offsets: vec![0, 1], + num_transition_constraints: meta.len(), + trace_columns: 2, + }; + Self { + context, + meta, + out: None, + precomputed_columns: 0, + precomputed_commitment: [0u8; 32], + phantom: PhantomData, + } + } + + fn boundary_constraints( + &self, + pub_inputs: &Self::PublicInputs, + _rap_challenges: &[FieldElement], + _bus_public_inputs: Option<&crate::lookup::BusPublicInputs>, + _trace_length: usize, + ) -> BoundaryConstraints { + let mut constraints = vec![ + BoundaryConstraint::new_main(0, 0, pub_inputs.a0.clone()), + BoundaryConstraint::new_main(1, 0, pub_inputs.a1.clone()), + ]; + if let Some(out) = &self.out { + constraints.push(BoundaryConstraint::new_main(1, TRACE_LEN - 1, out.clone())); + } + BoundaryConstraints::from_constraints(constraints) + } + + fn constraints_meta(&self) -> &[ConstraintMeta] { + &self.meta + } + + fn compute_transition_prover( + &self, + evaluation_context: &TransitionEvaluationContext, + base_evals: &mut [FieldElement], + ext_evals: &mut [FieldElement], + ) { + run_transition_prover( + &Fibonacci2ColsConstraints::default(), + evaluation_context, + base_evals, + ext_evals, + ); + } + + fn compute_transition( + &self, + evaluation_context: &TransitionEvaluationContext, + ) -> Vec> { + run_transition_verifier( + &Fibonacci2ColsConstraints::default(), + evaluation_context, + self.num_base_transition_constraints(), + self.num_transition_constraints(), + ) + } + + fn num_base_transition_constraints(&self) -> usize { + num_base_from_meta(&Fibonacci2ColsConstraints::::default().meta()) + } + + fn context(&self) -> &AirContext { + &self.context + } + + fn composition_poly_degree_bound(&self, trace_length: usize) -> usize { + trace_length + } + + fn trace_layout(&self) -> (usize, usize) { + (2, 0) + } + + fn is_preprocessed(&self) -> bool { + self.precomputed_columns > 0 + } + + fn num_precomputed_columns(&self) -> usize { + self.precomputed_columns + } + + fn precomputed_commitment(&self) -> Commitment { + self.precomputed_commitment + } +} + +fn pub_inputs() -> FibonacciPublicInputs { + FibonacciPublicInputs { + a0: Felt::one(), + a1: Felt::one(), + } +} + +/// Tripwire. Every break test in this file and in +/// `tests::aux_opening_width_tests` asserts a *rejection*, and a rejection is +/// only evidence if it comes from the width pin — a verifier that rejected +/// everything, or that rejected these proofs for some incidental reason, would +/// satisfy them just as well. A sibling PoC was once misread exactly that way, +/// off a worktree whose verifier was not the one being claimed about. +/// +/// So: the guard must be *defined and called*, not merely present. Deleting the +/// call site while keeping the function — the plausible bad refactor — fails +/// here rather than silently turning the whole file green for the wrong reason. +/// The break tests additionally assert attribution behaviourally, by calling the +/// guard on the very proof they reject. +/// +/// (The prosecution PoC pinned a hash of the whole verifier source. That is +/// right for a throwaway branch and wrong in-repo, where it would break on every +/// unrelated verifier edit.) +#[test_log::test] +fn precheck_the_width_pin_is_compiled_in() { + let src = include_str!("../verifier.rs"); + assert!( + src.contains("fn trace_opening_widths_well_formed("), + "the opening-width guard is gone from the verifier compiled into this binary", + ); + assert!( + src.contains("Self::trace_opening_widths_well_formed("), + "the opening-width guard is defined but never called: every rejection \ + asserted in this file would then be proving something else", + ); +} + +/// The precomputed term, end to end and **hook-free**: the prover commits ONE +/// column in the precomputed tree; the verifier's AIR declares TWO. Both sides +/// absorb the same commitment (the AIR's constant is the tree the prover built), +/// so the transcripts agree and the honest in-repo prover produces the proof — +/// no attacker-side prover switch involved. +/// +/// Stock `main` accepts it: the widths sum to the OOD width and the DEEP +/// reconstruction reads the same concatenated row either way. What the verifier +/// is wrong about is *which* columns the hardcoded commitment pins — it believes +/// two, and only one is in that tree, so the other is prover-supplied while the +/// verifier treats it as fixed. +/// +/// For a *real* preprocessed table (bitwise, decode, keccak_rc) the round-1 root +/// equality would also catch this, since an honest constant is a root over +/// exactly `num_precomputed_columns()` columns and a narrower tree hashes +/// differently. That defence is incidental: nothing states the invariant and +/// nothing checks it, and it does not exist at all for a non-preprocessed AIR, +/// where the root is never absorbed and the same re-split lets a prover choose +/// trace columns after the round-2 challenge. This test pins the width itself, +/// which is the property the reconstruction actually depends on. +#[test_log::test] +fn precomputed_opening_narrower_than_the_air_declares_is_rejected() { + let proof_options = ProofOptions::default_test_options(); + let mut trace = compute_trace([Felt::one(), Felt::one()], TRACE_LEN); + let reference = FibonacciSplitAIR::::honest(&proof_options, None); + let commitment = Prover::compute_precomputed_commitment_for_testing(&trace, &reference, 1) + .expect("precomputed commitment"); + + // Prover: one precomputed column, one main column. + let prover_air = + FibonacciSplitAIR::::preprocessed_declaring(&proof_options, None, 1, commitment); + let proof = Prover::prove( + &prover_air, + &mut trace, + &pub_inputs(), + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + assert_eq!( + proof.deep_poly_openings[0] + .precomputed_trace_polys + .as_ref() + .expect("preprocessed proof opens a precomputed tree") + .evaluations + .len(), + 1, + "test precondition: the proof serves one precomputed column", + ); + + // Verifier: same commitment constant, but the AIR declares two precomputed + // columns — so the second is served from the main tree, not the pinned one. + let verifier_air = + FibonacciSplitAIR::::preprocessed_declaring(&proof_options, None, 2, commitment); + assert!( + !Verifier::verify(&proof, &verifier_air, &mut DefaultTranscript::::new(&[])), + "Verifier must reject a precomputed opening narrower than the AIR declares", + ); + // Attribution: the rejection is the width pin's, not an incidental failure + // elsewhere in verification. + assert!( + !Verifier::trace_opening_widths_well_formed( + &verifier_air, + StarkProofView::Owned(&proof), + verifier_air.options().fri_number_of_queries, + ), + "the rejection above must come from the opening-width guard", + ); +} + +/// Non-vacuity, and the completeness case that matters: a table that genuinely +/// IS preprocessed has `num_precomputed_columns() > 0`, and its proof — with the +/// honest prover, verified against the same preprocessed AIR — must still be +/// accepted. A guard that rejected every split would pass every test above. +#[test_log::test] +fn honest_preprocessed_proof_still_verifies() { + let proof_options = ProofOptions::default_test_options(); + let mut trace = compute_trace([Felt::one(), Felt::one()], TRACE_LEN); + let reference = FibonacciSplitAIR::::honest(&proof_options, None); + let commitment = Prover::compute_precomputed_commitment_for_testing(&trace, &reference, 1) + .expect("precomputed commitment"); + let split_air = FibonacciSplitAIR::::split(&proof_options, None, commitment); + + let proof = Prover::prove( + &split_air, + &mut trace, + &pub_inputs(), + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + + assert!( + Verifier::verify(&proof, &split_air, &mut DefaultTranscript::::new(&[])), + "a genuinely preprocessed table must still verify", + ); +} + +/// Non-vacuity for the plain path: the same AIR without any split declaration. +#[test_log::test] +fn honest_non_preprocessed_proof_still_verifies() { + let proof_options = ProofOptions::default_test_options(); + let mut trace = compute_trace([Felt::one(), Felt::one()], TRACE_LEN); + let out = trace.columns_main()[1][TRACE_LEN - 1]; + let air = FibonacciSplitAIR::::honest(&proof_options, Some(out)); + + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs(), + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + + assert!( + Verifier::verify(&proof, &air, &mut DefaultTranscript::::new(&[])), + "an honest proof of a true statement must verify", + ); +} + +// --------------------------------------------------------------------------- +// Direct tests of the guard, on a RAP proof (2 main + 1 aux columns). +// +// These reach the cases no end-to-end test can: the `evaluations_sym` slot is a +// separate prover-supplied vector that the leaf hash does not pin apart from +// `evaluations` (`hash_data_from_slices` concatenates them), and the aux width +// has its own transcript-timing problem (the aux root is absorbed only after +// the shared LogUp challenges). +// --------------------------------------------------------------------------- + +type RapProof = StarkProof>; + +fn make_valid_rap_proof() -> (FibonacciRAP, RapProof) { + let mut trace = fibonacci_rap_trace([Felt::one(), Felt::one()], TRACE_LEN); + let proof_options = ProofOptions::default_test_options(); + let pub_inputs = FibonacciRAPPublicInputs { + steps: TRACE_LEN, + a0: Felt::one(), + a1: Felt::one(), + }; + let air = FibonacciRAP::::new(&proof_options); + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prove"); + (air, proof) +} + +fn widths_well_formed(air: &FibonacciRAP, proof: &RapProof) -> bool { + Verifier::trace_opening_widths_well_formed( + air, + StarkProofView::Owned(proof), + air.options().fri_number_of_queries, + ) +} + +/// Baseline: the honest proof's split is the AIR's split. +#[test_log::test] +fn opening_widths_accept_an_honest_rap_proof() { + let (air, proof) = make_valid_rap_proof(); + assert_eq!(air.trace_layout(), (2, 1)); + assert!(!air.is_preprocessed()); + assert!( + widths_well_formed(&air, &proof), + "the guard must accept an honest proof", + ); +} + +/// Each of the three widths, in each of the two slots, must be pinned. Every +/// mutation below keeps the *total* column count reachable by the old sum check +/// out of scope — the point is that the individual terms are now checked. +#[test_log::test] +fn opening_widths_reject_every_mismatched_term() { + let (air, proof) = make_valid_rap_proof(); + let extra = Felt::one(); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .main_trace_polys + .evaluations + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide main opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .main_trace_polys + .evaluations + .pop(); + assert!( + !widths_well_formed(&air, &tampered), + "an under-wide main opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .main_trace_polys + .evaluations_sym + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide symmetric main opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .aux_trace_polys + .as_mut() + .expect("the RAP AIR has an aux trace") + .evaluations + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide aux opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0] + .aux_trace_polys + .as_mut() + .expect("the RAP AIR has an aux trace") + .evaluations_sym + .push(extra); + assert!( + !widths_well_formed(&air, &tampered), + "an over-wide symmetric aux opening must be rejected", + ); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[0].aux_trace_polys = None; + assert!( + !widths_well_formed(&air, &tampered), + "a missing aux opening must be rejected when the AIR declares aux columns", + ); + + let mut tampered = proof.clone(); + let mut precomputed = tampered.deep_poly_openings[0].main_trace_polys.clone(); + precomputed.evaluations.truncate(1); + precomputed.evaluations_sym.truncate(1); + tampered.deep_poly_openings[0].precomputed_trace_polys = Some(precomputed); + assert!( + !widths_well_formed(&air, &tampered), + "precomputed openings must be rejected for a non-preprocessed AIR", + ); +} + +/// The guard covers every query the FRI phase will read, not just the first. +#[test_log::test] +fn opening_widths_are_checked_for_every_query() { + let (air, proof) = make_valid_rap_proof(); + let last = air.options().fri_number_of_queries - 1; + assert!(last > 0, "test precondition: more than one query"); + + let mut tampered = proof.clone(); + tampered.deep_poly_openings[last] + .main_trace_polys + .evaluations + .push(Felt::one()); + assert!( + !widths_well_formed(&air, &tampered), + "a mismatched split in the last query's opening must be rejected", + ); +} + +/// Fewer openings than queries is rejected rather than indexed past the end. +#[test_log::test] +fn opening_widths_reject_a_truncated_opening_list() { + let (air, proof) = make_valid_rap_proof(); + let mut tampered = proof.clone(); + tampered.deep_poly_openings.pop(); + assert!( + !widths_well_formed(&air, &tampered), + "an opening list shorter than the query count must be rejected", + ); +} diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 64ae24363..ca6f15152 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -196,6 +196,73 @@ pub trait IsStarkVerifier< && next.height() == expected_next_height } + /// Soundness (I3, opening side): every query opening's column counts are a + /// public function of the AIR, never of the (prover-controlled) proof. + /// + /// An opening splits the trace row into `precomputed ‖ main` (base) and `aux` + /// (extension), which the DEEP reconstruction consumes as one concatenated + /// row — so only their *sum* was pinned, against the AIR-pinned OOD width. + /// The leaf hash pins neither split either: `hash_data_from_slices` streams + /// `evaluations ‖ evaluations_sym` with no length prefix or separator. + /// + /// That is exploitable because the three trees are absorbed at different + /// times: the precomputed root not at all for a non-preprocessed AIR, and the + /// aux root only after the LogUp challenges. An unpinned split therefore lets + /// a prover pick columns *after* challenges they must precede. Both variants + /// accepted a false statement before this check; see `tests::opening_width_tests` + /// and `tests::aux_opening_width_tests`. + /// + /// Runs once per table, before any opening is read. Both slots are checked: + /// they are separate prover-supplied vectors. + fn trace_opening_widths_well_formed( + air: &dyn AIR, + proof: StarkProofView<'_, Field, FieldExtension, PI>, + num_queries: usize, + ) -> bool { + // A non-preprocessed AIR has no precomputed tree, so its openings must + // declare zero precomputed columns — `num_precomputed_columns()` is + // documented as meaningful only under `is_preprocessed()`. + let expected_precomputed = if air.is_preprocessed() { + air.num_precomputed_columns() + } else { + 0 + }; + // Preprocessed tables commit columns `0..n` in the precomputed tree and + // the remaining main columns (the multiplicities) in the main tree. + let expected_main = match air.trace_layout().0.checked_sub(expected_precomputed) { + Some(n) => n, + // An AIR declaring more precomputed columns than it has main columns + // is malformed; no proof can be well formed against it. + None => return false, + }; + let expected_aux = air.num_auxiliary_rap_columns(); + + if proof.deep_poly_openings_len() < num_queries { + return false; + } + (0..num_queries).all(|i| { + let opening = proof.deep_poly_opening(i); + // Absent optional openings count as zero columns, matching how the + // reconstruction reads them (`.unwrap_or(&[])`). + let (precomputed, precomputed_sym) = match opening.precomputed_trace_polys() { + Some(p) => (p.evaluations().len(), p.evaluations_sym().len()), + None => (0, 0), + }; + let (aux, aux_sym) = match opening.aux_trace_polys() { + Some(a) => (a.evaluations().len(), a.evaluations_sym().len()), + None => (0, 0), + }; + let main = opening.main_trace_polys(); + + precomputed == expected_precomputed + && precomputed_sym == expected_precomputed + && main.evaluations().len() == expected_main + && main.evaluations_sym().len() == expected_main + && aux == expected_aux + && aux_sym == expected_aux + }) + } + fn step_2_verify_claimed_composition_polynomial( air: &dyn AIR, proof: StarkProofView<'_, Field, FieldExtension, PI>, @@ -543,9 +610,16 @@ pub trait IsStarkVerifier< iota, ); - // Precomputed trace (preprocessed tables only). Mismatched presence is - // unreachable in practice (multi_verify rejects such proofs upstream), - // but a defensive check keeps this function self-contained. + // Precomputed trace (preprocessed tables only). Mismatched presence: + // `(Some(root), None)` and any `(None, Some(opening))` carrying at least + // one column are rejected upstream by `trace_opening_widths_well_formed` + // (which pins the precomputed opening width to the AIR — zero for a + // non-preprocessed AIR) and, for the missing-root case, by the round-1 + // preprocessed-commitment check. What is left for this arm is the + // degenerate `(None, Some(opening))` with a zero-width opening, which + // upstream cannot distinguish from an absent one. Keep it: this is the + // only site that rejects that shape, and the check keeps the function + // self-contained. ok &= match ( proof.lde_trace_precomputed_merkle_root(), deep_poly_openings.precomputed_trace_polys(), @@ -555,7 +629,13 @@ pub trait IsStarkVerifier< _ => false, }; - // Auxiliary trace. + // Auxiliary trace. This authenticates the opening against the aux root; + // it does NOT constrain how many columns that opening has. Nothing here + // did, and that was a live break: the aux root is absorbed only after the + // shared LogUp challenges, so a prover that moved main columns into the + // aux tree got to choose them after seeing `z`/`alpha` + // (`tests::aux_opening_width_tests`). The width is pinned upstream by + // `trace_opening_widths_well_formed`; do not re-derive it from the proof. ok &= match ( proof.lde_trace_aux_merkle_root(), deep_poly_openings.aux_trace_polys(), @@ -969,6 +1049,16 @@ pub trait IsStarkVerifier< // whose column count does not match the OOD table width, or whose // regular/symmetric base-column split disagree. Without these checks // the indexing below would panic in release builds. + // + // These are panic guards on the *sum* only, and are redundant for proofs + // that reached here through `verify_rounds_2_to_4`: + // `trace_opening_widths_well_formed` already pinned each of the three + // widths (precomputed, main, aux) to the AIR, for both the regular and + // the symmetric slot. That is the authoritative check — soundness must + // not be argued from the sum alone, since the precomputed↔main and + // main↔aux splits move columns between trees that are transcript-bound at + // different times. This function has no AIR, so it keeps the weaker + // guards to stay panic-free on its own. if num_base != num_base_sym { return None; } @@ -1535,6 +1625,21 @@ pub trait IsStarkVerifier< return false; } + // Pin every query opening's precomputed/main/aux column split to the AIR + // before anything reads an opening (step 3 is the first consumer). The + // sum of the three widths was already pinned downstream; the individual + // terms were not, and each tree is transcript-bound at a different time — + // see `trace_opening_widths_well_formed`. Checked over the openings the + // query phase will actually use, which is exactly what the adjacent + // `query_list_len` guard counts (`sample_query_indexes` draws + // `fri_number_of_queries` iotas). + if !Self::trace_opening_widths_well_formed(air, proof, air.options().fri_number_of_queries) + { + #[cfg(not(feature = "test_fiat_shamir"))] + error!("Trace opening column split does not match the AIR"); + return false; + } + // The pruned-OOD layout, read from the AIR once and shared by the round-4 // challenge replay, the block-shape guard, the single grid reconstruction, // and both verify steps below — one reconstruction instead of the previous From 483dc6ea5d8fd6a40bc6f07ec4761662d0126444 Mon Sep 17 00:00:00 2001 From: Mauro Toscano <12560266+MauroToscano@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:22:09 -0300 Subject: [PATCH 12/13] =?UTF-8?q?fix(page):=20private-input=20PAGE=20OFFSE?= =?UTF-8?q?T=20is=20unconstrained=20=E2=80=94=20forgeable=20memory=20conte?= =?UTF-8?q?nts=20(two=20invariants,=20both=20with=20exploits)=20(#904)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(page): preprocess OFFSET on private-input pages A private-input PAGE (and its continuation analogue GLOBAL_MEMORY) skipped `with_preprocessed` entirely, so every column was prover-chosen main trace. PAGE carries `EmptyConstraints` and no constraint anywhere references `cols::OFFSET`, so nothing pinned it — and the Memory-bus address is `address_lo = page_base_lo + OFFSET`. A witness could therefore point a row at any address sharing the page's high limb and mint a second, forged history for it, breaking the one-entry-per-address property the offline memory-checking argument rests on. Reproduced end to end; see below. INIT must stay main-trace — it is the private input, and the verifier must not be able to recompute it. OFFSET has no such constraint: it is the dense `0..page_size-1` enumeration, byte-identical for every page regardless of program or input. Committing it alone binds exactly the column that must not be prover-chosen and publishes nothing. Approach: preprocess OFFSET only, rather than adding AIR constraints (`OFFSET[0] = 0` plus `OFFSET[i+1] = OFFSET[i] + 1`). The constraint route needs a real boundary constraint, and every VM table in this tree is built with `NullBoundaryConstraintBuilder` — there is no boundary machinery to follow, so that route means new infrastructure in the STARK layer. The preprocessed route instead reuses the mechanism that already runs on every proof for ELF-data and zero-init pages, and which `verifier.rs:1184-1213` already enforces. The bug was that private pages bypassed that check; the fix is to stop bypassing it for the one column that is public. It also costs no constraint degree and no constraint-evaluation time. Because OFFSET depends on neither program nor input, one commitment per blowup factor covers every private page, and the same value serves GLOBAL_MEMORY, whose OFFSET column is identical. Static constants follow the existing `static_zero_page_commitment` pattern (generated by `compute_static_commitments`, pinned by a drift test) with the same recompute fallback off the standard coset. Acceptance (full log in fix-acceptance.log): poc_control_honest_harness_verifies ... ok poc_negative_control_forged_run_without_repointed_row_fails ... ok poc_private_page_offset_forges_memory_contents ... FAILED panicked: SOUNDNESS HOLE NOT REPRODUCED: verifier rejected the forged proof The third failing is the point: that test asserts the forgery is ACCEPTED, and it passed on origin/main. The first passing is what shows the fix is not over-broad — honest proving still verifies. The PoC is converted into a regression test in the follow-up commit. * test(page): keep the OFFSET forgery as a regression test Inverts the PoC's central assertion now that the fix is in: the forged proof must be REJECTED. Renamed `poc_private_page_offset_forges_memory_contents` -> `forged_private_page_offset_is_rejected`, and rewrote the module doc, which still described the hole in the present tense. The two controls are unchanged and are what stop this becoming a test that passes for the wrong reason: `poc_control_honest_harness_verifies` fails if the fix breaks honest proving (a verifier that rejects everything would otherwise satisfy the assertion above), and `poc_negative_control_forged_run_without_repointed_row_fails` fails if the harness stops discriminating. Also drops two imports the fix made unused. * fix(verifier): validate and bound runtime_page_ranges before use `runtime_page_ranges` is a prover-chosen `VmProof` field with a free `u64` base and count, and `page_configs_from_elf_and_runtime` expanded it with a plain `for i in 0..count` push loop having validated nothing. The `expected_proof_count` cross-check that would reject a wrong page count runs *after* that loop, so it never got the chance: `RuntimePageRange { base: 0, count: u64::MAX }` made the verifier allocate `PageConfig`s until the process died — a verifier DoS on untrusted input. The function is now fallible and takes a `max_pages` cap enforced before and during expansion. The verifier passes `proofs.len()`: every page config needs its own sub-proof, so a layout wanting more pages than the proof carries can never verify. That makes the bound exact, needing no invented policy constant, and unable to reject anything an honest prover produces. Also validated up front, since all of it is attacker-controlled: - `count == 0`, which the honest run-length encoding never emits; - unaligned bases — which additionally keeps "same base" equivalent to "overlapping" for the duplicate check in the follow-up commit; - ranges running off the end of the address space, which the push loop would otherwise wrap in release. The overflow guard bounds the range's LAST BYTE, not its exclusive end. The stack's top page legitimately sits at the very top of the address space (`0xfffffffffffc0000`), where the exclusive end is exactly 2^64 and only the last byte is representable — bounding the end instead rejects every honest proof. A draft of this commit did exactly that; the PoC harness's honest control caught it, and `the_top_page_of_the_address_space_is_accepted` now pins it. New `Error::MalformedPageLayout`. Test call sites pass `usize::MAX` — they build layouts from honest data, not from a proof. * fix(verifier): reject two page tables covering the same address Second route to the violation the OFFSET binding closed, and this one needs no private input and no free column. `page_configs_from_elf_and_runtime` built a `Vec`, sorted it, and never deduped. So a prover declares `RuntimePageRange { base: , count: 1 }` and that address gets two PAGE tables: the ELF-data page with the real INIT, and a duplicate zero-init page. Both carry correct, verifier-recomputed preprocessed commitments — the duplicate matches the shipped `static_zero_page_commitment` exactly — so nothing is forged at the commitment layer, which is why pinning OFFSET does not touch it. Two genesis tokens then exist for every address in that page. The offline memory-checking argument needs the init set to hold exactly one entry per address; with two, the real page's row consumes the duplicate's token and the duplicate's row consumes the real one, and the bus balances while a value the program never wrote reaches a load. Every other row of the duplicate page self-cancels for free. `FINI`/`TIMESTAMP` are main-trace on every page, not just private ones, which is what lets the two rows swap which token each consumes. Reject rather than dedupe silently: a duplicate is never legitimate — the honest builder derives ELF pages from a `BTreeSet` and run-length-encodes the rest — so silent dedup would mask a prover bug instead of surfacing it. The check is a single adjacent-equality scan after the sort that already existed, which covers all three config sources at once (ELF, runtime, private) and so cannot be bypassed by adding a fourth. It relies on the alignment check from the previous commit to be a complete *overlap* check and not merely an equality one. Severity note: the OFFSET fix does limit this. The injected value is always `0`, since zero-init is the only page type a prover can conjure at an arbitrary base — so it forces a chosen address to read `0` at genesis instead of its real ELF byte. Still a forged execution (zeroing a length, a bound, a chain-id or a root byte suffices), but not an arbitrary byte at an arbitrary address. The framing: pinning `OFFSET` restores one row per address *within* a page; this restores one page per address. Both are needed. * test(page): end-to-end regression tests for both forgery routes Adopts the prosecutor's PoC harness (branch `poc/page-duplication`, 1bc1def6) wholesale rather than keeping my thinner copy, and inverts the assertions the way the OFFSET one was inverted. Their version is strictly better: it runs under PRODUCTION proof options (`GoldilocksCubicProofOptions::with_blowup(2)`, what public `verify` uses) instead of `default_test_options()`, and it carries two controls mine lacked. Eight tests, all passing, 24s: - `poc_control_honest_harness_verifies` — non-vacuity. The one that catches an over-broad fix; it already caught one (see the `runtime_page_ranges` commit). - `forged_private_page_offset_is_rejected` — route 1. Accepts refusal at either layer: `commit_main_trace` caches precomputed trees keyed by the expected root and skips the re-check on a hit, so a cold cache makes the prover refuse while a warm one leaves it to the verifier. Asserting one would be order-dependent. - `poc_negative_control_forged_run_without_repointed_row_fails` — the forged run without the compensating row must fail, so the harness discriminates. - `poc_negative_control_direct_init_tamper_on_preprocessed_page_fails` — rewrites INIT directly on the target's own ELF-data page. The bus balances perfectly, so the only possible rejector is that page's preprocessed commitment. It rejects: the mechanism works on ELF pages, and its absence on private ones was the whole of route 1. - `poc_real_ethrex_inputs_produce_private_input_pages` — reachability on the workload that matters. - `dup_structural_duplicate_page_coverage_is_rejected` — route 2's invariant in isolation: honest execution, every injected row self-cancelling, only the layout malformed. This is the one that flips pass→fail if the duplicate-base check is removed, and it cannot be satisfied by something incidental the way a forgery test might. - `dup_negative_control_without_compensating_row_fails` - `dup_duplicate_page_forgery_is_rejected` — route 2 end to end: ELF `.data` byte 0x11 read as 0x00, which was ACCEPTED against the unmodified ELF even after the OFFSET fix. A rejection now arrives in two shapes — `Ok(false)` from inside STARK verification, and `Err(MalformedPageLayout)` when the layout is refused before any proof is checked — so `verifier_accepts` collapses both and the tests do not have to care which fired. `craft_proof_with_duplicate_page` asserts the layout rebuild fails on duplicate coverage specifically, then still runs the full prove→verify path so the test stays end-to-end rather than degenerating into a unit test of the check. Also documents the test-only `minimal_bitwise` branch in `VmAirs::new`. That BITWISE AIR has no preprocessed commitment, so its lookup table would be prover-chosen — and since BITWISE backs `AreBytes`, an unpinned table would let a witness prove an arbitrary field element is a byte. It is safe only because all three production callers pass `false`; a fourth passing `true` would reintroduce the hole silently. The reconstruction-level tests in `page_layout_tests` stay: they cover shapes these do not (overflow, unaligned bases, count bounds, the top-of-address-space page). * test(page): tolerate prove-time refusal in the tamper regression tests CI failed on `poc_negative_control_direct_init_tamper_on_preprocessed_page_fails`: panicked at page_offset_forgery_poc.rs:455: this tamper leaves OFFSET alone, so the prover still builds it: PrecomputedCommitmentMismatch The `.expect` message was wrong on its own terms. The tamper does leave OFFSET alone, but it rewrites INIT on an ELF-data page — where the preprocessed columns are OFFSET *and* INIT (`NUM_PREPROCESSED_COLS = 2`). So it touches a preprocessed column after all, and `commit_main_trace` can reject it before a proof exists. Which layer fires is not deterministic. That function caches precomputed Merkle trees keyed by *the expected root* and skips the rebuild check on a hit (`crypto/stark/src/prover.rs:1161-1170`). A cold cache — a fresh CI runner — rebuilds from the tampered column and refuses; a warm cache — a local run that already proved something honest — substitutes the correct cached tree and lets the verifier do the rejecting. Local runs were warm, CI is cold. Both outcomes are rejections, so the test now accepts either via a shared `proof_or_prover_refusal`, which still requires an `Err` to be specifically `PrecomputedCommitmentMismatch` rather than any proving error. The test's meaning is unchanged: it pins that the preprocessed commitment rejects a direct INIT rewrite, which is what shows route 1 was that mechanism's *absence* on private pages rather than a flaw in it. `forged_private_page_offset_is_rejected` now shares the same helper instead of its own inline match. Swept the rest of the file for the same assumption. The rule, now documented on `Tamper`: a tamper touching a PREPROCESSED column may be refused at prove time and must go through the helper; one touching only main-trace columns cannot be and may keep `.expect(..)`. By that rule the three remaining `.expect`s are sound, and each now says why rather than asserting it: - the honest control — no tamper at all; - the uncompensated forged run — the forged execution moves FINI/TIMESTAMP (main trace) while OFFSET/INIT still come from the honest ELF; - duplicate-page injection — writes FINI only. Verified both orderings: 8/8 serial (warm cache, verifier path exercised), and each rejection test passing alone in a fresh process (cold cache, the CI path). * Fix/page offset review followups (#910) * drop the accidentally committed fix-acceptance.log' * docs(page): fix a doc comment on the wrong fn --------- Co-authored-by: jotabulacios --- executor/programs/asm/poc_rodata_commit.s | 27 + prover/src/bin/compute_static_commitments.rs | 6 +- prover/src/continuation.rs | 20 +- prover/src/lib.rs | 49 +- prover/src/tables/page.rs | 94 ++- prover/src/tables/trace_builder.rs | 86 +- prover/src/tests/mod.rs | 4 + prover/src/tests/page_layout_tests.rs | 286 +++++++ prover/src/tests/page_offset_forgery_poc.rs | 808 +++++++++++++++++++ prover/src/tests/page_tests.rs | 4 +- prover/src/tests/prove_elfs_tests.rs | 18 +- prover/src/tests/static_commitments_tests.rs | 34 + 12 files changed, 1415 insertions(+), 21 deletions(-) create mode 100644 executor/programs/asm/poc_rodata_commit.s create mode 100644 prover/src/tests/page_layout_tests.rs create mode 100644 prover/src/tests/page_offset_forgery_poc.rs diff --git a/executor/programs/asm/poc_rodata_commit.s b/executor/programs/asm/poc_rodata_commit.s new file mode 100644 index 000000000..b6e2a99ec --- /dev/null +++ b/executor/programs/asm/poc_rodata_commit.s @@ -0,0 +1,27 @@ + .data + .align 3 +secret: + .dword 0x8877665544332211 + + .text + .attribute 5, "rv64i2p1" + .globl main +main: + # Load 8 bytes out of the ELF's own .data section, spill them to the + # stack, and commit them. The committed public output is therefore a + # direct function of the ELF image bytes at `secret`, which the verifier + # binds through the PAGE preprocessed commitment of that data page. + la t0, secret + ld t1, 0(t0) # t1 = *secret + addi sp, sp, -16 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/prover/src/bin/compute_static_commitments.rs b/prover/src/bin/compute_static_commitments.rs index 045e15a4c..a4de1ddaa 100644 --- a/prover/src/bin/compute_static_commitments.rs +++ b/prover/src/bin/compute_static_commitments.rs @@ -54,6 +54,7 @@ fn main() { let bitwise = bitwise::compute_preprocessed_commitment(&options); let keccak_rc = keccak_rc::compute_preprocessed_commitment(&options); let zero_page = page::compute_precomputed_commitment(&zero_page_config, &options); + let private_page = page::compute_offset_only_commitment(&options); println!( "// blowup_factor = {blowup}\n\ @@ -62,10 +63,13 @@ fn main() { // ---- keccak_rc:\n \ {blowup} => Some({keccak_fmt}),\n\ // ---- zero_page:\n \ - {blowup} => Some({zero_page_fmt}),\n", + {blowup} => Some({zero_page_fmt}),\n\ + // ---- private_page (OFFSET only):\n \ + {blowup} => Some({private_page_fmt}),\n", bitwise_fmt = format_commitment(&bitwise), keccak_fmt = format_commitment(&keccak_rc), zero_page_fmt = format_commitment(&zero_page), + private_page_fmt = format_commitment(&private_page), ); } } diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 8f3e68db4..85f2d6223 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -211,12 +211,14 @@ fn l2g_memory_air( /// zero-init pages (stack/heap) via the static zero-page commitment. The prover /// cannot choose those genesis values. /// -/// Private-input pages are built NON-preprocessed (mirrors the monolithic PAGE in +/// Private-input pages preprocess OFFSET **only** (mirrors the monolithic PAGE in /// `VmAirs::new`): INIT is a committed main-trace column the verifier never recomputes /// from the ELF, so the raw private input is neither bundled nor reconstructed by the -/// verifier. Correctness is enforced by the GlobalMemory bus (the genesis token must -/// telescope into the epochs' reads), not by ELF recomputation. (Not a ZK/hiding claim — -/// the committed column is still opened at STARK query positions.) +/// verifier. (Not a ZK/hiding claim — the committed column is still opened at STARK +/// query positions.) OFFSET, by contrast, is preprocessed like everywhere else: it is +/// program- and input-independent, and it is the row's address, so the GlobalMemory bus +/// alone cannot police it. Leaving it free was a soundness hole — the genesis token +/// could name any address in the page's high-limb space. /// `preprocessed`, when `Some`, is used directly instead of recomputing the /// genesis commitment from `config.init_values` — the recursion guest's /// supplied roots skip the in-VM FFT + Merkle build (see `verify_global`). @@ -236,7 +238,15 @@ fn global_memory_air( EmptyConstraints, ); if config.is_private_input { - return air; + // OFFSET only — see the matching branch in `VmAirs::new`. INIT stays a + // main-trace column (it is the private input); OFFSET must be committed or + // `address_lo = page_base_lo + OFFSET` is prover-chosen and the genesis + // token can name an arbitrary address. GLOBAL_MEMORY's OFFSET column is + // identical to PAGE's, so the same commitment serves both. + return air.with_preprocessed( + page::private_page_preprocessed_commitment(opts), + page::NUM_PREPROCESSED_COLS_PRIVATE, + ); } let commitment = preprocessed.unwrap_or_else(|| { if config.init_values.is_some() { diff --git a/prover/src/lib.rs b/prover/src/lib.rs index a8e89f989..985484c04 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -455,6 +455,10 @@ pub enum Error { /// Recursion host-side helper failed (guest-input encoding or /// commitment recompute — see the `recursion` module). Recursion(String), + /// The proof's `runtime_page_ranges` do not describe a well-formed page + /// layout: unaligned or overflowing base, zero count, more pages than the + /// proof can hold, or two pages covering the same address. + MalformedPageLayout(String), } impl fmt::Display for Error { @@ -484,6 +488,7 @@ impl fmt::Display for Error { ) } Error::Recursion(msg) => write!(f, "recursion helper error: {msg}"), + Error::MalformedPageLayout(msg) => write!(f, "malformed page layout: {msg}"), } } } @@ -704,6 +709,20 @@ impl VmAirs { }) .collect(); let bitwise: VmAir = if minimal_bitwise { + // TEST-ONLY BRANCH — must never be reached in production. + // + // This BITWISE AIR carries NO preprocessed commitment, so its lookup + // table's contents are prover-chosen main trace. BITWISE backs + // `AreBytes` and the byte ALU, so an unpinned table lets a witness + // "prove" that an arbitrary field element is a byte — the same class of + // hole as the private-page `OFFSET` one, and a broader one. It is safe + // today only because every production caller passes `false` + // (`lib.rs` verify/prove paths and `continuation.rs`); the minimal + // BITWISE trace exists for unit tests that build the table by hand. + // + // A fourth call site passing `true` would reintroduce the hole silently, + // so if this branch ever needs to be live, give the minimal table its + // own preprocessed commitment first. Box::new(create_bitwise_air(proof_options)) } else { Box::new(create_bitwise_air(proof_options).with_preprocessed( @@ -801,10 +820,24 @@ impl VmAirs { .map(|config| -> VmAir { let air = create_page_air(proof_options, config.page_base); if config.is_private_input { - // Private-input pages: all columns are main trace (not preprocessed). - // The verifier doesn't see the init values; correctness is enforced - // by the memory bus constraints. - Box::new(air) + // Private-input pages: INIT holds the private input, so it stays a + // main-trace column the verifier never recomputes. OFFSET does NOT + // get that treatment — it is the row's address + // (`address_lo = page_base_lo + OFFSET`), and nothing else in the + // system constrains it: PAGE has `EmptyConstraints` and no + // constraint references the column. Left uncommitted, a witness can + // point a row at any address sharing the page's high limb and mint a + // second, forged memory history for it — the init/final sets stop + // holding exactly one entry per address, which is the property the + // offline memory-checking argument rests on. + // + // Committing OFFSET alone publishes nothing: it is the dense + // `0..page_size-1` enumeration, byte-identical for every page + // regardless of program or input. + Box::new(air.with_preprocessed( + page::private_page_preprocessed_commitment(proof_options), + page::NUM_PREPROCESSED_COLS_PRIVATE, + )) } else if config.init_values.is_none() { // Zero-init pages: the shared commitment computed once above. Box::new( @@ -1338,11 +1371,17 @@ fn verify_proof_parts( } } + // `proofs.len()` is the cap: every page config needs its own sub-proof, so a + // layout wanting more pages than the proof carries can never verify. Passing it + // here makes the rejection happen before the configs are allocated — the + // `expected_proof_count` check below runs too late to stop a `count: u64::MAX` + // range from exhausting memory first. let page_configs = Traces::page_configs_from_elf_and_runtime( program, runtime_page_ranges, num_private_input_pages, - ); + proofs.len(), + )?; // Cross-check: table_counts must match the number of sub-proofs. // FIXED_TABLE_COUNT always-present tables, plus page tables. diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index 059ffff3b..6788bee08 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -84,6 +84,16 @@ pub mod cols { /// For zero-init pages, INIT is also preprocessed (constant 0). pub const NUM_PREPROCESSED_COLS: usize = 2; +/// Number of preprocessed columns for a **private-input** page: OFFSET only. +/// +/// INIT holds the private input, so it stays a main-trace column the verifier +/// never recomputes. OFFSET must still be preprocessed — it is the row's +/// address (`address_lo = page_base_lo + OFFSET`), and leaving it prover-chosen +/// lets a witness point a row at any address in the page's high-limb space and +/// forge that address's memory history. Preprocessing covers columns `0..n`, and +/// OFFSET is column 0, so `n = 1` isolates exactly the right one. +pub const NUM_PREPROCESSED_COLS_PRIVATE: usize = 1; + // ========================================================================= // Types // ========================================================================= @@ -419,6 +429,32 @@ pub(crate) fn static_zero_page_commitment(blowup_factor: u8) -> Option Option { + match blowup_factor { + 2 => Some([ + 0x4a, 0x36, 0x1a, 0x29, 0x02, 0xc8, 0x21, 0x8e, 0xc0, 0xfd, 0x6d, 0xbe, 0xb3, 0x5f, + 0x70, 0x54, 0xcb, 0xa3, 0xa7, 0x8c, 0xa2, 0x37, 0xdc, 0xa3, 0x51, 0x29, 0xd8, 0xb8, + 0x94, 0x2d, 0x91, 0x3d, + ]), + 4 => Some([ + 0xa6, 0x53, 0x01, 0xd0, 0x2f, 0x47, 0xca, 0xe8, 0x7a, 0xbd, 0xb7, 0x14, 0x69, 0x28, + 0xaf, 0x67, 0xc9, 0xe5, 0x2d, 0xd6, 0x41, 0x5f, 0x76, 0xd8, 0xc4, 0x59, 0xdd, 0xaa, + 0xd2, 0x32, 0x1f, 0x6f, + ]), + 8 => Some([ + 0xe7, 0x13, 0xe3, 0x59, 0xd6, 0xa5, 0xb9, 0xd5, 0xfa, 0xcb, 0x51, 0x8a, 0x42, 0x52, + 0xaa, 0x25, 0xf9, 0x0d, 0x94, 0xf5, 0xdf, 0x93, 0x56, 0x63, 0x77, 0x2c, 0x08, 0x75, + 0xb7, 0x68, 0xb0, 0x57, + ]), + _ => None, + } +} + /// Computes the Merkle root commitment over the LDE of PAGE precomputed columns. /// /// The commitment covers OFFSET (0..page_size-1) and INIT (from config). @@ -454,8 +490,19 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption init_col[i] = FE::from(init_byte as u64); } - let columns = [offset_col, init_col]; + commit_preprocessed_columns(&[offset_col, init_col], num_rows, options) +} +/// LDE + Merkle-commit a set of preprocessed PAGE columns. Shared by +/// [`compute_precomputed_commitment`] (OFFSET+INIT) and +/// [`compute_offset_only_commitment`] (OFFSET alone) so both go through an +/// identical pipeline — the two commitments must be built the same way or the +/// verifier's recomputation would not match the prover's tree. +fn commit_preprocessed_columns( + columns: &[Vec], + num_rows: usize, + options: &ProofOptions, +) -> Commitment { let polys: Vec> = columns .iter() .map(|col| { @@ -479,6 +526,28 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption root } +/// Commitment over the OFFSET column **alone** — the preprocessed anchor for +/// private-input pages. +/// +/// A private page's INIT holds the private input, which the verifier must not +/// be able to recompute, so it cannot be preprocessed. OFFSET carries no such +/// constraint: it is the dense enumeration `0..page_size-1`, byte-identical for +/// every page of a given size regardless of program *or* input. Committing it +/// on its own binds the one column that must not be prover-chosen while +/// publishing nothing about the input. +/// +/// This is what stops a malicious prover repointing a private page's rows: the +/// Memory-bus address is `page_base_lo + OFFSET`, so a free OFFSET names an +/// arbitrary address and forges that address's memory history. +pub fn compute_offset_only_commitment(options: &ProofOptions) -> Commitment { + let num_rows = DEFAULT_PAGE_SIZE; + let mut offset_col = crate::tables::types::zeroed_fe_vec(num_rows); + for (i, cell) in offset_col.iter_mut().enumerate() { + *cell = FE::from(i as u64); + } + commit_preprocessed_columns(&[offset_col], num_rows, options) +} + /// Returns the zero-init PAGE preprocessed commitment. /// /// Looks up `blowup_factor` in [`static_zero_page_commitment`] when @@ -504,6 +573,29 @@ pub fn zero_init_preprocessed_commitment(options: &ProofOptions) -> Commitment { compute_precomputed_commitment(&PageConfig::zero_init(0), options) } +/// Returns the private-input PAGE preprocessed commitment (OFFSET only). +/// +/// Same static-then-recompute shape as [`zero_init_preprocessed_commitment`]. +/// Because OFFSET depends on neither the program nor the input, one value per +/// `blowup_factor` covers every private page in the system — and the same value +/// serves GLOBAL_MEMORY, whose OFFSET column is identical. +pub fn private_page_preprocessed_commitment(options: &ProofOptions) -> Commitment { + if options.coset_offset == 3 + && let Some(commitment) = static_private_page_commitment(options.blowup_factor) + { + return commitment; + } + log::warn!( + "private-input page preprocessed commitment not static for \ + (blowup={}, coset={}); falling back to recompute. Add a match \ + arm to `static_private_page_commitment` by running \ + `cargo run --bin compute_static_commitments --release`.", + options.blowup_factor, + options.coset_offset, + ); + compute_offset_only_commitment(options) +} + // ========================================================================= // Bus interactions // ========================================================================= diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 5ec9fa566..f51b66166 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -4141,17 +4141,74 @@ impl Traces { /// - Deterministic ELF pages (preprocessed, init from binary) /// - Runtime pages from prover hints (preprocessed, zero-init) /// - Private-input pages (NOT preprocessed, verifier doesn't see init values) + /// + /// `max_pages` caps how many configs may be materialised. `runtime_page_ranges` + /// is a prover-chosen field of `VmProof` with a free `u64` count, and this + /// function is what turns it into allocations — so the cap must be enforced + /// *before* the loop, not by the `expected_proof_count` check downstream, which + /// only runs once the `Vec` already exists. The verifier passes the sub-proof + /// count: a layout needing more pages than the proof has sub-proofs can never + /// verify, so this rejects nothing an honest prover could produce. pub fn page_configs_from_elf_and_runtime( elf: &Elf, runtime_page_ranges: &[crate::RuntimePageRange], num_private_input_pages: usize, - ) -> Vec { + max_pages: usize, + ) -> Result, Error> { let mut configs = Self::page_configs_from_elf(elf); let page_size = page::DEFAULT_PAGE_SIZE; - // Add zero-init runtime pages (stack, heap) + let too_many = |have: usize| { + Error::MalformedPageLayout(format!( + "page layout needs more than {max_pages} pages (at least {have}); \ + the proof cannot contain that many sub-proofs", + )) + }; + if configs.len() > max_pages { + return Err(too_many(configs.len())); + } + + // Add zero-init runtime pages (stack, heap). for r in runtime_page_ranges { let (base, count) = (r.base, r.count); + if count == 0 { + return Err(Error::MalformedPageLayout(format!( + "runtime page range at 0x{base:x} has count 0; the honest \ + run-length encoding never emits an empty range", + ))); + } + // Alignment is what makes the duplicate-base check below a complete + // overlap check: page-aligned pages of one size either share a base or + // are disjoint, so there is no partial-overlap case to consider. + if base % page_size as u64 != 0 { + return Err(Error::MalformedPageLayout(format!( + "runtime page base 0x{base:x} is not {page_size}-byte aligned", + ))); + } + // Reject before allocating: `count` is untrusted, so both the running + // total and the address arithmetic have to be checked up front. + let projected = configs + .len() + .saturating_add(usize::try_from(count).unwrap_or(usize::MAX)); + if projected > max_pages { + return Err(too_many(projected)); + } + // Guards the `base + i * page_size` below for every `i < count`. + // + // Bound the range's LAST BYTE, not its exclusive end: the stack's top page + // legitimately sits at the very top of the address space, where the + // exclusive end is exactly 2^64 and only the last byte is representable. + // Checking the end instead rejects every honest proof (`count >= 1` is + // already established above, so `span - 1` cannot underflow). + count + .checked_mul(page_size as u64) + .and_then(|span| base.checked_add(span - 1)) + .ok_or_else(|| { + Error::MalformedPageLayout(format!( + "runtime page range at 0x{base:x} with count {count} overflows \ + the address space", + )) + })?; for i in 0..count { configs.push(PageConfig::zero_init(base + i * page_size as u64)); } @@ -4165,9 +4222,32 @@ impl Traces { is_private_input: true, }); } + if configs.len() > max_pages { + return Err(too_many(configs.len())); + } configs.sort_by_key(|c| c.page_base); - configs + + // Exactly one page per address. Two PAGE tables covering the same base each + // provide a genesis token for every address in it, and the memory argument's + // soundness rests on the init set holding exactly one entry per address: with + // two, a witness can have the real page's row consume the duplicate's token + // and vice versa, injecting a value the program never wrote. A duplicate is + // never legitimate — the honest builder derives ELF pages from a `BTreeSet` + // and run-length-encodes the rest — so reject rather than dedupe silently, + // which would mask a prover bug instead of surfacing it. + if let Some(w) = configs + .windows(2) + .find(|w| w[0].page_base == w[1].page_base) + { + return Err(Error::MalformedPageLayout(format!( + "two page tables cover base 0x{:x}; each address must have exactly \ + one genesis token", + w[0].page_base, + ))); + } + + Ok(configs) } /// Extracts runtime page ranges from the generated page configs. diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index a3326bcd1..2730a9d98 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -69,6 +69,10 @@ pub mod mul_tests; #[cfg(test)] pub mod ood_window_ir_tests; #[cfg(test)] +pub mod page_layout_tests; +#[cfg(test)] +pub mod page_offset_forgery_poc; +#[cfg(test)] pub mod page_tests; #[cfg(test)] pub mod prove_elfs_tests; diff --git a/prover/src/tests/page_layout_tests.rs b/prover/src/tests/page_layout_tests.rs new file mode 100644 index 000000000..eba8b220c --- /dev/null +++ b/prover/src/tests/page_layout_tests.rs @@ -0,0 +1,286 @@ +//! Regression tests for the verifier's PAGE-layout reconstruction. +//! +//! `runtime_page_ranges` is a prover-chosen field of `VmProof` carrying a free +//! `u64` base and a free `u64` count, and the verifier turns it into PAGE tables +//! with `Traces::page_configs_from_elf_and_runtime`. These tests pin the two +//! properties that reconstruction must enforce on untrusted input. +//! +//! **One page per address.** Two PAGE tables covering the same base each provide +//! a genesis token for every address in that page. The memory argument's +//! soundness rests on the init set holding exactly one entry per address: with +//! two, a witness can have the real page's row consume the duplicate's token and +//! the duplicate's row consume the real one, injecting a value the program never +//! wrote while the bus still balances. A prover reaches this with no private +//! input at all, by declaring a runtime range aliasing a real ELF data page — +//! and *both* pages then carry correct, verifier-recomputed preprocessed +//! commitments, so nothing is forged at the commitment layer. This is the +//! companion to `page_offset_forgery_poc`: pinning `OFFSET` restores one row per +//! address *within* a page, and this restores one page per address. +//! +//! **Bounded before allocation.** The `expected_proof_count` cross-check would +//! reject a wrong page count, but it runs after the configs are materialised, so +//! a `count: u64::MAX` range exhausts memory first — a verifier DoS on untrusted +//! input. +//! +//! These exercise the verifier's own reconstruction path (the same function +//! `verify_proof_parts` calls). They do not build a forged proof end to end; the +//! full attack demonstration for the duplication route lives with the PoC work. + +use crate::tables::page::DEFAULT_PAGE_SIZE; +use crate::tables::trace_builder::Traces; +use crate::test_utils::asm_elf_bytes; +use crate::{Error, RuntimePageRange}; + +use executor::elf::Elf; + +fn test_elf() -> Elf { + Elf::load(&asm_elf_bytes("poc_rodata_commit")).expect("ELF load") +} + +/// Base of some page the ELF itself already defines — the address a duplicate +/// range would alias. +fn an_elf_page_base(elf: &Elf) -> u64 { + Traces::page_configs_from_elf(elf) + .first() + .expect("the ELF must define at least one page") + .page_base +} + +fn layout( + elf: &Elf, + ranges: &[RuntimePageRange], + max_pages: usize, +) -> Result, Error> { + Traces::page_configs_from_elf_and_runtime(elf, ranges, 0, max_pages) +} + +/// Non-vacuity: the honest shape this all has to keep accepting. +#[test] +fn honest_page_layout_is_accepted() { + let elf = test_elf(); + let elf_pages = Traces::page_configs_from_elf(&elf).len(); + + // A runtime range that does not alias any ELF page: well past the ELF image. + let base = 0x8000_0000u64; + let configs = layout(&elf, &[RuntimePageRange { base, count: 3 }], usize::MAX) + .expect("an honest, non-overlapping layout must be accepted"); + assert_eq!(configs.len(), elf_pages + 3); + + // And the result stays sorted with no repeats — what the checks below defend. + assert!(configs.windows(2).all(|w| w[0].page_base < w[1].page_base)); +} + +/// A runtime range aliasing a real ELF page must be rejected: that is the exact +/// shape of the duplication attack, and the one a prover can mount with no +/// private input. +#[test] +fn runtime_range_aliasing_an_elf_page_is_rejected() { + let elf = test_elf(); + let base = an_elf_page_base(&elf); + + let err = layout(&elf, &[RuntimePageRange { base, count: 1 }], usize::MAX) + .expect_err("a runtime page aliasing an ELF page must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("exactly")), + "expected a duplicate-page rejection, got: {err}" + ); +} + +/// Two identical runtime ranges are the same violation without involving the ELF. +#[test] +fn duplicate_runtime_ranges_are_rejected() { + let elf = test_elf(); + let base = 0x8000_0000u64; + + let err = layout( + &elf, + &[ + RuntimePageRange { base, count: 1 }, + RuntimePageRange { base, count: 1 }, + ], + usize::MAX, + ) + .expect_err("two runtime ranges covering the same base must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("exactly")), + "expected a duplicate-page rejection, got: {err}" + ); +} + +/// Overlapping (not merely identical) ranges are caught by the same check, +/// because alignment makes same-size pages either equal or disjoint. +#[test] +fn overlapping_runtime_ranges_are_rejected() { + let elf = test_elf(); + let base = 0x8000_0000u64; + let page = DEFAULT_PAGE_SIZE as u64; + + let err = layout( + &elf, + &[ + RuntimePageRange { base, count: 4 }, + RuntimePageRange { + base: base + 2 * page, + count: 4, + }, + ], + usize::MAX, + ) + .expect_err("overlapping runtime ranges must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("exactly")), + "expected a duplicate-page rejection, got: {err}" + ); +} + +/// Unaligned bases are rejected. Beyond being malformed, this is what keeps "same +/// base" equivalent to "overlapping": page-aligned pages of one size either share a +/// base or are disjoint, with no partial-overlap case. +#[test] +fn unaligned_runtime_page_base_is_rejected() { + let elf = test_elf(); + + let err = layout( + &elf, + &[RuntimePageRange { + base: 0x8000_0000 + 1, + count: 1, + }], + usize::MAX, + ) + .expect_err("an unaligned runtime page base must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("aligned")), + "expected an alignment rejection, got: {err}" + ); +} + +/// DoS: a `u64::MAX` count must be refused up front, not after allocating. +/// +/// The assertion that matters is not just the `Err` but that this test *returns* +/// — before the bound, `for i in 0..count` would allocate `PageConfig`s until the +/// process died, so a regression here shows up as the suite being OOM-killed. +#[test] +fn unbounded_runtime_page_count_is_rejected_without_allocating() { + let elf = test_elf(); + + for count in [u64::MAX, u64::MAX / 2, 1 << 40] { + let err = layout(&elf, &[RuntimePageRange { base: 0, count }], 4096) + .expect_err("an absurd page count must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("more than")), + "expected a page-count rejection for count={count}, got: {err}" + ); + } +} + +/// The cap is the sub-proof count, so a layout one page over it is refused. +/// Nothing an honest prover produces can trip this: every page needs a sub-proof. +#[test] +fn page_count_above_the_cap_is_rejected() { + let elf = test_elf(); + let elf_pages = Traces::page_configs_from_elf(&elf).len(); + + let ranges = [RuntimePageRange { + base: 0x8000_0000, + count: 2, + }]; + // Exactly enough room: accepted. + layout(&elf, &ranges, elf_pages + 2).expect("a layout that fits the cap is fine"); + // One short: refused. + let err = layout(&elf, &ranges, elf_pages + 1) + .expect_err("a layout needing more pages than the proof has sub-proofs must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("more than")), + "expected a page-count rejection, got: {err}" + ); +} + +/// A zero-count range is meaningless — the honest run-length encoding never emits +/// one — so it is refused rather than silently skipped. +#[test] +fn zero_count_runtime_range_is_rejected() { + let elf = test_elf(); + + let err = layout( + &elf, + &[RuntimePageRange { + base: 0x8000_0000, + count: 0, + }], + usize::MAX, + ) + .expect_err("a zero-count runtime range must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("count 0")), + "expected a zero-count rejection, got: {err}" + ); +} + +/// The stack's top page must stay accepted. +/// +/// It sits at the very top of the address space, so its *exclusive* end is exactly +/// `2^64` and only its last byte is representable. An overflow guard written +/// against the exclusive end rejects it — and therefore rejects every honest proof, +/// since every program has a stack. This is a real regression that shipped in a +/// draft of the guard above and was caught by the PoC harness's honest control. +#[test] +fn the_top_page_of_the_address_space_is_accepted() { + let elf = test_elf(); + let page = DEFAULT_PAGE_SIZE as u64; + let top_page_base = u64::MAX - page + 1; + assert_eq!(top_page_base % page, 0, "the top page must be aligned"); + + layout( + &elf, + &[RuntimePageRange { + base: top_page_base, + count: 1, + }], + usize::MAX, + ) + .expect("the top page of the address space is where the stack lives"); +} + +/// A range whose span wraps the address space is refused before the arithmetic +/// that would wrap. Uses the highest page-aligned base so the alignment check +/// (which runs first) passes and the overflow guard is the one under test. +#[test] +fn overflowing_runtime_range_is_rejected() { + let elf = test_elf(); + let page = DEFAULT_PAGE_SIZE as u64; + let top_aligned_base = (u64::MAX / page) * page; + assert_eq!(top_aligned_base % page, 0, "the test base must be aligned"); + + // count * page_size overflows u64 outright, so the guard fires on the + // multiply rather than on the base + span add. + let err = layout( + &elf, + &[RuntimePageRange { + base: top_aligned_base, + count: 1 << 60, + }], + usize::MAX, + ) + .expect_err("an overflowing runtime range must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("overflows")), + "expected an overflow rejection, got: {err}" + ); + + // And the base + span add: a count that fits in u64 on its own but pushes + // the range past the top of the address space. + let err = layout( + &elf, + &[RuntimePageRange { + base: top_aligned_base, + count: 2, + }], + usize::MAX, + ) + .expect_err("a range running off the end of the address space must be rejected"); + assert!( + matches!(&err, Error::MalformedPageLayout(m) if m.contains("overflows")), + "expected an overflow rejection, got: {err}" + ); +} diff --git a/prover/src/tests/page_offset_forgery_poc.rs b/prover/src/tests/page_offset_forgery_poc.rs new file mode 100644 index 000000000..5e2e24d78 --- /dev/null +++ b/prover/src/tests/page_offset_forgery_poc.rs @@ -0,0 +1,808 @@ +//! End-to-end regression tests for two ways a prover could break the memory +//! argument's one-genesis-token-per-address invariant. Both were demonstrated as +//! working forgeries against `origin/main` (b082f9f6) and are now closed. +//! +//! **Route 1 — free `OFFSET` (arbitrary byte, arbitrary address).** A +//! private-input PAGE's `OFFSET` was a free main-trace column: `create_page_air` +//! builds PAGE with `EmptyConstraints`, no constraint references `cols::OFFSET`, +//! and `VmAirs::new` skipped `with_preprocessed` for `is_private_input` pages. The +//! Memory-bus address is `address_lo = page_base_lo + OFFSET`, so a row could be +//! pointed at any address sharing the page's high limb. Closed by preprocessing +//! `OFFSET` (only — `INIT` is the private input and stays main-trace). +//! +//! **Route 2 — duplicate page coverage (forces a chosen address to read `0`).** +//! Survived route 1's fix, and needs no private input at all. Nothing is forged at +//! the commitment layer: the prover declares a `runtime_page_ranges` entry over an +//! address the ELF already covers, and the injected zero-init page's `OFFSET` +//! *and* `INIT` match the shipped static zero-page commitment exactly. The address +//! then has two genesis tokens, and the two pages' rows swap which one each +//! consumes. Closed by rejecting duplicate page bases during the verifier's layout +//! reconstruction. +//! +//! The one-line distinction: preprocessing `OFFSET` restores "one row per address +//! *within* a page"; the duplicate-base check restores "one page per address". +//! Both are needed. +//! +//! The guest loads 8 bytes out of its own ELF `.data`, spills them to the stack +//! and commits them, so the proof's `public_output` is a direct function of the +//! ELF image — which the verifier binds via that data page's preprocessed +//! commitment. Each forgery's claim is that the proof verifies against the +//! *unmodified* ELF while reporting a different output. +//! +//! Run under **production** proof options, not `default_test_options()`, so none +//! of this can be written off as an artefact of a low-query configuration. + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use stark::proof::options::ProofOptions; +use stark::prover::{IsStarkProver, Prover}; + +use crate::statement::{StatementKind, absorb_statement}; +use crate::tables::bitwise::{cols as bw_cols, row_index as bw_row_index}; +use crate::tables::page::cols as page_cols; +use crate::tables::trace_builder::Traces; +use crate::tables::types::{FE, VmTable}; +use crate::test_utils::{E, asm_elf_bytes}; +use crate::{MaxRowsConfig, VmAirs, VmProof}; + +use executor::elf::Elf; +use executor::vm::execution::Executor; + +/// The 8 bytes the PoC guest keeps in `.data` (little-endian `.dword`). +const SECRET: [u8; 8] = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + +/// The byte we forge in its place. +const FORGED_BYTE: u8 = 0xEE; + +/// The PRODUCTION options: exactly what the public `crate::verify` uses +/// (`GoldilocksCubicProofOptions::with_blowup(2)`, 128-bit security target). +/// Deliberately not `default_test_options()` — nobody should be able to write +/// this off as an artefact of a 3-query toy configuration. +fn opts() -> ProofOptions { + crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid") +} + +/// Raw-file offset of `SECRET` inside the ELF, plus the virtual address that +/// offset maps to (via the containing PT_LOAD program header). +fn locate_secret(elf_bytes: &[u8]) -> (usize, u64) { + let file_off = elf_bytes + .windows(SECRET.len()) + .position(|w| w == SECRET) + .expect("SECRET pattern not found in ELF"); + + let rd_u16 = |o: usize| u16::from_le_bytes(elf_bytes[o..o + 2].try_into().unwrap()); + let rd_u32 = |o: usize| u32::from_le_bytes(elf_bytes[o..o + 4].try_into().unwrap()); + let rd_u64 = |o: usize| u64::from_le_bytes(elf_bytes[o..o + 8].try_into().unwrap()); + + let e_phoff = rd_u64(32) as usize; + let e_phentsize = rd_u16(54) as usize; + let e_phnum = rd_u16(56) as usize; + const PT_LOAD: u32 = 1; + + for i in 0..e_phnum { + let ph = e_phoff + i * e_phentsize; + if rd_u32(ph) != PT_LOAD { + continue; + } + let p_offset = rd_u64(ph + 8) as usize; + let p_vaddr = rd_u64(ph + 16); + let p_filesz = rd_u64(ph + 32) as usize; + if file_off >= p_offset && file_off + SECRET.len() <= p_offset + p_filesz { + return (file_off, p_vaddr + (file_off - p_offset) as u64); + } + } + panic!("SECRET is not inside any PT_LOAD segment"); +} + +/// One repointed private-input PAGE row. +struct Forge { + /// The address whose genesis byte we overwrite. + target_addr: u64, + /// The byte the forged init token carries. + forged: u8, + /// The byte the honest (preprocessed-bound) init token carries; the + /// repointed row's PAGE-C4 consumes it so the bus still balances. + real: u8, +} + +/// How the malicious prover deviates from an honest trace. +/// +/// **Which of these can be refused at prove time.** `commit_main_trace` rebuilds a +/// table's preprocessed Merkle tree and compares it to the AIR's commitment, so any +/// tamper touching a PREPROCESSED column may be rejected before a proof exists — +/// non-deterministically, because a warm tree cache skips that check (see +/// `proof_or_prover_refusal`). Tampers touching only main-trace columns cannot be. +/// +/// - `RepointPrivateRow` rewrites `OFFSET` — preprocessed since the fix. **At risk.** +/// - `DirectInitOnHonestPage` rewrites `INIT` on an ELF-data page, where the +/// preprocessed columns are `OFFSET` *and* `INIT`. **At risk.** +/// - Injecting a duplicate zero page writes only `FINI`. Not at risk. +/// - No tamper at all. Not at risk. +/// +/// Anything at risk must go through `proof_or_prover_refusal`, never `.expect(..)`. +enum Tamper { + /// Repoint one private-input PAGE row (the hole under test). + RepointPrivateRow(Forge), + /// Overwrite the target byte's INIT directly on its own ELF-data PAGE. + /// This is the "obvious" attack, and it is the CONTROL: that page IS + /// preprocessed, so its INIT column is pinned by a per-page Merkle root + /// recomputed by the verifier from the ELF. It must be rejected. + DirectInitOnHonestPage { target_addr: u64, forged: u8 }, +} + +/// A malicious prover. Everything is the production pipeline; the only +/// deviations are (a) the execution logs may come from a different ELF than +/// the one whose identity/preprocessed roots are used, and (b) `forge` +/// rewrites one PAGE row. +fn craft_proof( + honest_elf: &[u8], + run_elf: &[u8], + private_inputs: &[u8], + forge: Option, +) -> Result { + let options = opts(); + + // Identity + all preprocessed roots come from the HONEST ELF. + let program = Elf::load(honest_elf).expect("honest ELF load"); + + // Execution logs come from whatever `run_elf` is. + let run_program = Elf::load(run_elf).expect("run ELF load"); + let executor = + Executor::new(&run_program, private_inputs.to_vec()).expect("executor construction"); + let result = executor.run().expect("run"); + + let max_rows = MaxRowsConfig::default(); + let mut traces = Traces::from_elf_and_logs( + &program, + &result.logs, + &max_rows, + private_inputs, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("trace build"); + + match forge { + Some(Tamper::RepointPrivateRow(f)) => apply_forge(&mut traces, &f), + Some(Tamper::DirectInitOnHonestPage { + target_addr, + forged, + }) => apply_direct_init_tamper(&mut traces, target_addr, forged), + None => {} + } + + let table_counts = traces.table_counts(); + let airs = VmAirs::new( + &program, + &options, + false, + &traces.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + + let runtime_page_ranges = traces.runtime_page_ranges(); + let num_private_input_pages = traces + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + honest_elf, + &traces.public_output_bytes, + &table_counts, + num_private_input_pages, + &runtime_page_ranges, + options.fri_final_poly_log_degree, + ); + + let proof = Prover::multi_prove( + airs.air_trace_pairs(&mut traces), + &mut transcript, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + )?; + + Ok(VmProof { + proof, + runtime_page_ranges, + table_counts, + public_output: traces.public_output_bytes.clone(), + num_private_input_pages, + }) +} + +/// Repoint one unused private-input PAGE row at `f.target_addr` so that it +/// PROVIDES `(0, target, ts=0, forged)` on the Memory bus and CONSUMES the +/// honest `(0, target, ts=0, real)` token in its place. +fn apply_forge(traces: &mut Traces, f: &Forge) { + let (page_idx, page_base) = traces + .page_configs + .iter() + .enumerate() + .find(|(_, c)| c.is_private_input) + .map(|(i, c)| (i, c.page_base)) + .expect("a private-input page must exist"); + + assert_eq!( + page_base >> 32, + f.target_addr >> 32, + "address_hi is a constant per page, so the target must share it" + ); + + // Any private-input byte the guest never reads. Row 4096 is well past the + // 4-byte length prefix and the (tiny) input payload. + let row = 4096usize; + { + let page = &traces.pages[page_idx].main_table; + assert_eq!(*page.get(row, page_cols::INIT), FE::zero()); + assert_eq!(*page.get(row, page_cols::FINI), FE::zero()); + assert_eq!(*page.get(row, page_cols::TIMESTAMP_LO), FE::zero()); + assert_eq!(*page.get(row, page_cols::TIMESTAMP_HI), FE::zero()); + assert_eq!(*page.get(row, page_cols::OFFSET), FE::from(row as u64)); + } + + let page = &mut traces.pages[page_idx].main_table; + // address_lo = page_base_lo + OFFSET ⇒ OFFSET = target - page_base (in F_p). + page.set( + row, + page_cols::OFFSET, + FE::from(f.target_addr) - FE::from(page_base), + ); + page.set_byte(row, page_cols::INIT, f.forged); + page.set_byte(row, page_cols::FINI, f.real); + // TIMESTAMP stays 0: PAGE-C4 then consumes the honest genesis token, which + // PAGE-C3 hardcodes at ts = 0. + + // The row's ARE_BYTES[init, fini] send moved from (0, 0) to (forged, real); + // rebalance the BITWISE receiver multiplicities to match. + move_are_bytes_multiplicity(traces, (0, 0), (f.forged, f.real)); +} + +/// Move one unit of `MU_ARE_BYTES` from the pair `from` to the pair `to`, so +/// the ARE_BYTES bus stays balanced after a PAGE row's `(init, fini)` changed. +fn move_are_bytes_multiplicity(traces: &mut Traces, from: (u8, u8), to: (u8, u8)) { + let bw = &mut traces.bitwise.main_table; + let dec = bw_row_index(from.0, from.1, 0); + let inc = bw_row_index(to.0, to.1, 0); + assert_ne!(dec, inc); + let old_dec = *bw.get(dec, bw_cols::MU_ARE_BYTES); + assert_ne!(old_dec, FE::zero(), "source pair must have multiplicity"); + bw.set(dec, bw_cols::MU_ARE_BYTES, old_dec - FE::one()); + let old_inc = *bw.get(inc, bw_cols::MU_ARE_BYTES); + bw.set(inc, bw_cols::MU_ARE_BYTES, old_inc + FE::one()); +} + +/// CONTROL tamper: rewrite the target byte's INIT on its own (preprocessed) +/// ELF-data PAGE. The Memory bus balances perfectly afterwards — the page +/// simply provides the forged genesis token that MEMW consumes — so if this is +/// rejected, the rejection can only come from the preprocessed commitment. +fn apply_direct_init_tamper(traces: &mut Traces, target_addr: u64, forged: u8) { + use crate::tables::page::{offset_in_page, page_base_for_address}; + + let base = page_base_for_address(target_addr); + let offset = offset_in_page(target_addr); + let page_idx = traces + .page_configs + .iter() + .position(|c| c.page_base == base) + .expect("target page must exist"); + assert!( + !traces.page_configs[page_idx].is_private_input, + "the control must target an ELF-data page, not the private page" + ); + assert!( + traces.page_configs[page_idx].init_values.is_some(), + "the control must target a page whose INIT is ELF-derived and committed" + ); + + let (old_init, fini) = { + let page = &traces.pages[page_idx].main_table; + let byte_at = |col: usize| -> u8 { + u8::try_from(page.get(offset, col).to_raw()).expect("column holds a byte") + }; + (byte_at(page_cols::INIT), byte_at(page_cols::FINI)) + }; + traces.pages[page_idx] + .main_table + .set_byte(offset, page_cols::INIT, forged); + move_are_bytes_multiplicity(traces, (old_init, fini), (forged, fini)); +} + +/// Unwrap a crafted proof, or signal that the prover refused to build it. +/// +/// `None` means `multi_prove` rejected the trace outright. That is a legitimate +/// outcome for **any tamper that touches a PREPROCESSED column**, and which of the +/// two layers fires is not deterministic: `commit_main_trace` caches precomputed +/// Merkle trees keyed by *the expected root* and skips the rebuild check on a hit +/// (`crypto/stark/src/prover.rs:1161-1170`). Cold cache — a fresh CI runner — the +/// tree is rebuilt from the tampered column, the root disagrees, and the prover +/// refuses. Warm cache — a local run that already proved something honest — the +/// correct cached tree is substituted, the proof is built, and the verifier is left +/// to reject it. CI failed on exactly this asymmetry. +/// +/// So a rejection test must accept both. A caller may only `.expect(..)` success +/// when its tamper touches main-trace columns alone; see `Tamper`. +fn proof_or_prover_refusal( + crafted: Result, +) -> Option { + match crafted { + Ok(proof) => Some(proof), + Err(e) => { + assert!( + matches!( + e, + stark::prover::ProvingError::PrecomputedCommitmentMismatch + ), + "the tampered trace must be refused for its preprocessed commitment, \ + not for some unrelated proving error: {e:?}" + ); + None + } + } +} + +/// Did the verifier accept this proof? +/// +/// A rejection now arrives in two shapes: `Ok(false)` when a check inside the +/// STARK verification fails, and `Err(MalformedPageLayout)` when the page layout +/// is refused before any proof is checked at all. Both mean "not accepted", and +/// collapsing them here keeps the tests from having to care which fired. +fn verifier_accepts(proof: &VmProof, elf: &[u8]) -> bool { + match crate::verify_with_options(proof, elf, &opts(), None, None) { + Ok(accepted) => accepted, + Err(crate::Error::MalformedPageLayout(_)) => false, + Err(e) => panic!("verification failed for an unexpected reason: {e}"), + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +/// Sanity: the guest commits its own `.data` bytes, and the harness used +/// honestly produces a genuinely valid proof. Guards against a vacuous PoC. +#[test] +fn poc_control_honest_harness_verifies() { + let elf = asm_elf_bytes("poc_rodata_commit"); + let proof = craft_proof(&elf, &elf, &[0u8], None) + .expect("no tamper at all: every preprocessed column is honest, so proving cannot fail"); + assert_eq!( + proof.public_output, + SECRET.to_vec(), + "guest must commit its .data bytes" + ); + assert!( + verifier_accepts(&proof, &elf), + "honest use of the harness must verify" + ); + assert_eq!( + proof.num_private_input_pages, 1, + "one byte of private input must create exactly one private page" + ); +} + +/// NEGATIVE CONTROL: run the patched program but do NOT repoint a PAGE row. +/// The genesis token the MEMW chain consumes at `secret` then has no provider +/// (the honest page provides the real byte), so the bus must not balance. +#[test] +fn poc_negative_control_forged_run_without_repointed_row_fails() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, _addr) = locate_secret(&honest); + let mut patched = honest.clone(); + patched[file_off] = FORGED_BYTE; + + // No tamper: the forged *run* changes FINI/TIMESTAMP (main trace) but the page's + // OFFSET/INIT still come from the honest ELF, so proving cannot fail here. + let proof = craft_proof(&honest, &patched, &[0u8], None) + .expect("the patched run still proves; the verifier must be the one to reject it"); + assert_eq!( + proof.public_output[0], FORGED_BYTE, + "the patched run must commit the forged byte" + ); + assert!( + !verifier_accepts(&proof, &honest), + "without the repointed PAGE row this proof must be rejected" + ); +} + +/// REGRESSION (route 1 — free `OFFSET`): repointing a private-input PAGE row at +/// an arbitrary address must not produce a verifying proof. +/// +/// On `origin/main` this was ACCEPTED against the unmodified ELF while claiming a +/// `public_output` the program cannot produce. `VmAirs::new` now preprocesses +/// `OFFSET`, so the repointed column no longer matches the commitment. +/// +/// The forgery can die at either of two layers and which one fires depends on +/// process state, so both are accepted. `commit_main_trace` caches precomputed +/// Merkle trees keyed by *the expected root* and skips the rebuild check on a hit +/// (`crypto/stark/src/prover.rs:1161-1170`): with a cold cache the prover itself +/// refuses, with a warm one it substitutes the correct cached tree and leaves the +/// verifier to reject. Asserting only one would make this pass or fail on test +/// ordering. +#[test] +fn forged_private_page_offset_is_rejected() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let mut patched = honest.clone(); + patched[file_off] = FORGED_BYTE; + + let crafted = craft_proof( + &honest, + &patched, + &[0u8], + Some(Tamper::RepointPrivateRow(Forge { + target_addr: addr, + forged: FORGED_BYTE, + real: SECRET[0], + })), + ); + + // Repointing rewrites OFFSET, which is preprocessed after the fix, so the + // prover may refuse outright — that is a rejection too. + let Some(proof) = proof_or_prover_refusal(crafted) else { + return; + }; + + // Non-vacuity: the proof really does claim the forged byte. + assert_eq!( + proof.public_output[0], FORGED_BYTE, + "forged proof must claim the forged byte" + ); + assert_ne!(proof.public_output, SECRET.to_vec()); + + assert!( + !verifier_accepts(&proof, &honest), + "SOUNDNESS REGRESSION: the verifier accepted a proof whose public output the \ + program cannot produce — a private-input PAGE row was repointed via its \ + OFFSET column. OFFSET must stay preprocessed (see `VmAirs::new`)." + ); +} + +/// SECOND NEGATIVE CONTROL — isolates the defense being bypassed. +/// +/// Same forged execution, but instead of repointing a private-input row we +/// overwrite INIT directly on the target byte's own ELF-data PAGE. The Memory +/// bus balances perfectly this way (that page simply provides the forged +/// genesis token MEMW consumes), so the ONLY thing that can reject it is that +/// page's preprocessed commitment, which the verifier recomputes from the ELF. +/// +/// It is rejected — which is the point: the preprocessed commitment does its +/// job on ELF-data pages. The private-input page was the sole bypass, precisely +/// because `VmAirs::new` gave it no commitment at all. +/// +/// `INIT` is itself a preprocessed column on an ELF-data page (`OFFSET` *and* +/// `INIT`, `NUM_PREPROCESSED_COLS = 2`), so this tamper can be caught at either +/// layer — see `proof_or_prover_refusal`. Prover-side refusal is if anything the +/// cleaner outcome; what the test pins is that the commitment rejects the rewrite, +/// not which stage notices. +#[test] +fn poc_negative_control_direct_init_tamper_on_preprocessed_page_fails() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let mut patched = honest.clone(); + patched[file_off] = FORGED_BYTE; + + let crafted = craft_proof( + &honest, + &patched, + &[0u8], + Some(Tamper::DirectInitOnHonestPage { + target_addr: addr, + forged: FORGED_BYTE, + }), + ); + + let Some(proof) = proof_or_prover_refusal(crafted) else { + return; + }; + assert_eq!(proof.public_output[0], FORGED_BYTE); + + assert!( + !verifier_accepts(&proof, &honest), + "the preprocessed commitment must reject a direct INIT rewrite" + ); +} + +/// REACHABILITY on the workload that matters. +/// +/// The ethrex block guest reads its ENTIRE `ProgramInput` through +/// `get_private_input()` (`executor/programs/rust/ethrex/src/main.rs:8`), so +/// every real block proof carries private-input pages. This asserts it through +/// the production function itself — `private_input_page_count` is what the +/// trace builder uses to classify pages (`trace_builder.rs:2615`) and what the +/// verifier's `num_private_input_pages` is compared against. +/// +/// Each such page contributes 2^18 = 262,144 rows whose `OFFSET` is free. +#[test] +fn poc_real_ethrex_inputs_produce_private_input_pages() { + use crate::tables::page::{DEFAULT_PAGE_SIZE, private_input_page_count}; + + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + + let mut checked = 0usize; + for name in [ + "ethrex_empty_block", + "ethrex_5_transfers", + "ethrex_10_transfers", + "ethrex_bench_4", + ] { + let path = root.join(format!("executor/tests/{name}.bin")); + let Ok(bytes) = std::fs::read(&path) else { + continue; // fixture not present in this checkout + }; + let pages = private_input_page_count(&bytes); + println!( + "{name}: {} bytes -> {pages} private-input page(s) = {} free-OFFSET rows", + bytes.len(), + pages * DEFAULT_PAGE_SIZE + ); + assert!( + pages > 0, + "{name} must produce at least one private-input page" + ); + checked += 1; + } + assert!(checked > 0, "no ethrex fixture found to check"); + + // Sanity on the classifier: page 0 of that span is classified private. + assert!(crate::tables::page::is_private_input_page( + executor::vm::memory::PRIVATE_INPUT_START_INDEX, + 1 + )); +} + +// ============================================================================= +// SECOND ROUTE: duplicate page coverage — survives the OFFSET fix +// ============================================================================= +// +// Pinning OFFSET restores "one row per address WITHIN a page". It does not +// restore "one page per address". `page_configs_from_elf_and_runtime` +// (`trace_builder.rs:4149-4171`) builds a Vec, appends one zero-init config per +// entry of the prover-supplied `runtime_page_ranges`, sorts by page_base, and +// never dedupes; `verify_proof_parts` validates `table_counts` and +// `num_private_input_pages` and passes `runtime_page_ranges` through untouched. +// So a prover can declare a second, zero-init page over an address the ELF +// already covers. Nothing is forged at the commitment layer — the injected page +// is an ordinary zero page whose OFFSET *and* INIT match the shipped static +// zero-page commitment — yet the address now has two genesis tokens. + +/// Inject a duplicate zero-init PAGE over `base`, which an ELF-data page +/// already covers. When `consume` is `Some((offset, real))`, that row is set to +/// consume the ELF page's genesis token `(base+offset, ts=0, real)`; otherwise +/// every row self-cancels. +fn inject_duplicate_zero_page(traces: &mut Traces, base: u64, consume: Option<(usize, u8)>) { + use crate::tables::page::{DEFAULT_PAGE_SIZE, PageConfig, generate_page_trace_from_dense}; + + // Insert directly after the ELF config for `base`, matching the verifier's + // STABLE `sort_by_key(page_base)` — ELF configs are pushed before runtime + // ones, so the ELF page wins the tie. + let elf_idx = traces + .page_configs + .iter() + .position(|c| c.page_base == base) + .expect("an ELF page for this base must already exist"); + assert!( + traces.page_configs[elf_idx].init_values.is_some(), + "duplicate must shadow an ELF-data page" + ); + + let dup_cfg = PageConfig::zero_init(base); + let mut dup_trace = generate_page_trace_from_dense(&dup_cfg, None, false); + if let Some((offset, real)) = consume { + dup_trace.main_table.set_byte(offset, page_cols::FINI, real); + } + traces.page_configs.insert(elf_idx + 1, dup_cfg); + traces.pages.insert(elf_idx + 1, dup_trace); + + // The injected table sends ARE_BYTES[init, fini] on every row: (0,0) + // throughout, except the one compensating row (0, real). + let bw = &mut traces.bitwise.main_table; + let mut bump = |x: u8, y: u8, n: u64| { + let row = bw_row_index(x, y, 0); + let cur = *bw.get(row, bw_cols::MU_ARE_BYTES); + bw.set(row, bw_cols::MU_ARE_BYTES, cur + FE::from(n)); + }; + match consume { + Some((_, real)) => { + bump(0, 0, (DEFAULT_PAGE_SIZE - 1) as u64); + bump(0, real, 1); + } + None => bump(0, 0, DEFAULT_PAGE_SIZE as u64), + } +} + +/// Like `craft_proof`, but injects a duplicate zero page over `dup_base` after +/// the traces are built. Production prove path otherwise. +fn craft_proof_with_duplicate_page( + honest_elf: &[u8], + run_elf: &[u8], + dup_base: u64, + consume: Option<(usize, u8)>, +) -> VmProof { + let options = opts(); + let program = Elf::load(honest_elf).expect("honest ELF load"); + let run_program = Elf::load(run_elf).expect("run ELF load"); + let executor = Executor::new(&run_program, vec![]).expect("executor construction"); + let result = executor.run().expect("run"); + + let max_rows = MaxRowsConfig::default(); + let mut traces = Traces::from_elf_and_logs( + &program, + &result.logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("trace build"); + + inject_duplicate_zero_page(&mut traces, dup_base, consume); + + let table_counts = traces.table_counts(); + let runtime_page_ranges = traces.runtime_page_ranges(); + let num_private_input_pages = traces + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + + // The verifier rebuilds the layout from `runtime_page_ranges`. Before the + // duplicate-page fix that rebuild reproduced our injected layout exactly, + // which is what made the attack work; now it REJECTS it. Assert that + // directly — it is the fix firing at the layer it should — and keep going so + // the test still exercises the full prove → verify path end to end. + match Traces::page_configs_from_elf_and_runtime( + &program, + &runtime_page_ranges, + num_private_input_pages, + usize::MAX, + ) { + Ok(rebuilt) => { + let ours: Vec = traces.page_configs.iter().map(|c| c.page_base).collect(); + let theirs: Vec = rebuilt.iter().map(|c| c.page_base).collect(); + assert_eq!(ours, theirs, "prover/verifier page layouts must agree"); + } + Err(crate::Error::MalformedPageLayout(msg)) => { + assert!( + msg.contains("exactly"), + "the rebuild must fail on duplicate coverage specifically: {msg}" + ); + } + Err(e) => panic!("unexpected page-layout error: {e}"), + } + + let airs = VmAirs::new( + &program, + &options, + false, + &traces.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + honest_elf, + &traces.public_output_bytes, + &table_counts, + num_private_input_pages, + &runtime_page_ranges, + options.fri_final_poly_log_degree, + ); + + let proof = Prover::multi_prove( + airs.air_trace_pairs(&mut traces), + &mut transcript, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + // The injected duplicate page writes only FINI, a main-trace column, and every + // page's OFFSET/INIT stays honest — so the preprocessed check cannot fire and + // proving is guaranteed to succeed. The rejection is the verifier's to make. + .expect("duplicate-page injection touches no preprocessed column"); + + VmProof { + proof, + runtime_page_ranges, + table_counts, + public_output: traces.public_output_bytes.clone(), + num_private_input_pages, + } +} + +/// STRUCTURAL REGRESSION: one address range covered by TWO PAGE tables must be +/// refused, even when the execution is honest and every injected row +/// self-cancels. +/// +/// This is the invariant, isolated from any forgery: "one page per address". It +/// passed on the pre-fix branch — the layout was simply unvalidated — and is the +/// test that flips to a failure if the duplicate-base check is ever removed. The +/// forgery test below needs a compensating row and so could in principle be +/// blocked by something else; this one cannot. +#[test] +fn dup_structural_duplicate_page_coverage_is_rejected() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (_, addr) = locate_secret(&honest); + let base = crate::tables::page::page_base_for_address(addr); + + let proof = craft_proof_with_duplicate_page(&honest, &honest, base, None); + // The execution itself is honest, so the output is the real one; only the + // page layout is malformed. + assert_eq!(proof.public_output, SECRET.to_vec()); + assert!( + !verifier_accepts(&proof, &honest), + "SOUNDNESS REGRESSION: the verifier accepted a layout with two PAGE tables \ + over one address range. Each address must have exactly one genesis token, \ + or two rows can swap which token each consumes." + ); +} + +/// NEGATIVE CONTROL for the second route: forged run (target byte reads 0), +/// duplicate page present but every row self-cancelling, so the forged genesis +/// token has no provider. Must be rejected. +#[test] +fn dup_negative_control_without_compensating_row_fails() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let base = crate::tables::page::page_base_for_address(addr); + let mut patched = honest.clone(); + patched[file_off] = 0x00; + + let proof = craft_proof_with_duplicate_page(&honest, &patched, base, None); + assert_eq!(proof.public_output[0], 0x00); + assert!( + !verifier_accepts(&proof, &honest), + "without the compensating row this must be rejected" + ); +} + +/// REGRESSION (route 2 — duplicate page): the end-to-end forgery must not verify. +/// +/// Forged run plus the duplicate page's row for the target consuming the ELF +/// page's genesis token. On the pre-fix branch — including after the OFFSET fix — +/// ELF `.data` byte `0x11` was made to read as `0x00` and the proof was ACCEPTED +/// against the UNMODIFIED ELF. +/// +/// Strictly weaker than the OFFSET break: the injected value is always 0, because +/// a zero-init page is the only kind a prover can conjure at a chosen base. But it +/// needs no private input and no free OFFSET, which is why the OFFSET fix alone +/// did not stop it. +#[test] +fn dup_duplicate_page_forgery_is_rejected() { + let honest = asm_elf_bytes("poc_rodata_commit"); + let (file_off, addr) = locate_secret(&honest); + let base = crate::tables::page::page_base_for_address(addr); + let offset = crate::tables::page::offset_in_page(addr); + let mut patched = honest.clone(); + patched[file_off] = 0x00; + + let proof = craft_proof_with_duplicate_page(&honest, &patched, base, Some((offset, SECRET[0]))); + + // Non-vacuity: the proof really does report the zeroed byte. + assert_eq!(proof.public_output[0], 0x00, "forged output"); + assert_ne!(proof.public_output, SECRET.to_vec()); + + assert!( + !verifier_accepts(&proof, &honest), + "SOUNDNESS REGRESSION: an ELF .data byte was made to read as 0 and the proof \ + verified against the unmodified ELF, via a duplicate zero-init page over an \ + address the ELF already covers." + ); +} diff --git a/prover/src/tests/page_tests.rs b/prover/src/tests/page_tests.rs index fe0c534e8..1a223644d 100644 --- a/prover/src/tests/page_tests.rs +++ b/prover/src/tests/page_tests.rs @@ -164,7 +164,9 @@ fn elf_data_page_commitments( &elf, &vm_proof.runtime_page_ranges, vm_proof.num_private_input_pages, - ); + usize::MAX, + ) + .expect("honest page layout"); page_configs .iter() .filter(|c| !c.is_private_input && c.init_values.is_some()) diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index ffe9071b2..7cd6c4e47 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -155,7 +155,9 @@ fn verify_vm_minimal(vm_proof: &VmProof, elf_bytes: &[u8]) -> bool { &elf, &vm_proof.runtime_page_ranges, vm_proof.num_private_input_pages, - ); + usize::MAX, + ) + .expect("honest page layout"); let airs = VmAirs::new( &elf, &proof_options, @@ -1376,7 +1378,8 @@ fn test_prove_elfs_test_commit_4_wrong_pages_rejected() { .expect("Prover failed"); // Verifier uses EMPTY runtime pages → missing stack/public-output pages - let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0); + let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, @@ -2133,7 +2136,9 @@ fn test_deep_stack_runtime_pages_roundtrip() { ) .expect("Prover failed"); // Verifier reconstructs from ELF + runtime_page_ranges hint - let verifier_configs = Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0); + let verifier_configs = + Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, @@ -2208,7 +2213,8 @@ fn test_deep_stack_missing_pages_rejected() { ) .expect("Prover failed"); // Verifier uses EMPTY runtime_page_ranges → missing stack/heap pages - let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0); + let wrong_configs = Traces::page_configs_from_elf_and_runtime(&elf, &[], 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, @@ -2318,7 +2324,9 @@ fn test_heap_alloc_runtime_pages_roundtrip() { ) .expect("Prover failed"); // Verifier reconstructs from ELF + runtime hint (ranges decoded to pages) - let verifier_configs = Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0); + let verifier_configs = + Traces::page_configs_from_elf_and_runtime(&elf, &runtime_page_ranges, 0, usize::MAX) + .expect("honest page layout"); let verifier_airs = crate::VmAirs::new( &elf, &proof_options, diff --git a/prover/src/tests/static_commitments_tests.rs b/prover/src/tests/static_commitments_tests.rs index 01d9817e8..7b3d38e12 100644 --- a/prover/src/tests/static_commitments_tests.rs +++ b/prover/src/tests/static_commitments_tests.rs @@ -112,6 +112,40 @@ fn zero_page_static_matches_recompute_for_all_blowups() { } } +/// Same drift guard for the private-input page's OFFSET-only commitment — the +/// verifier's compiled-in anchor for every private page, and the thing that +/// stops a prover repointing those rows at arbitrary addresses. Also asserts it +/// DIFFERS from the zero-init commitment: the two cover different column sets +/// (OFFSET alone vs OFFSET+INIT), so equal bytes would mean one of the two +/// call sites is committing the wrong number of columns. +#[test] +fn private_page_static_matches_recompute_for_all_blowups() { + for &blowup in STATIC_BLOWUP_FACTORS { + let options = options_for(blowup); + let recomputed = page::compute_offset_only_commitment(&options); + let Some(static_bytes) = page::static_private_page_commitment(blowup) else { + panic!("no static private-page match arm shipped for blowup={blowup}"); + }; + assert_eq!( + static_bytes, recomputed, + "static private-page (OFFSET-only) commitment drifted for blowup={blowup}; \ + regenerate constants via \ + `cargo run --bin compute_static_commitments --release`", + ); + let from_wrapper = page::private_page_preprocessed_commitment(&options); + assert_eq!( + from_wrapper, recomputed, + "private_page_preprocessed_commitment returned a wrong value for blowup={blowup}", + ); + assert_ne!( + recomputed, + page::compute_precomputed_commitment(&page::PageConfig::zero_init(0), &options), + "OFFSET-only and OFFSET+INIT commitments must differ (blowup={blowup}); \ + equality would mean a call site commits the wrong column count", + ); + } +} + /// Asserts the page wrapper's fallback path (no static entry for this /// blowup) recomputes a commitment that matches the direct compute call. /// Ignored by default: at NON_STATIC_BLOWUP=16, the page LDE is 2^22 rows × From 58160b6fb538cc651bd9da093a7168b4dca0d9c7 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 7 Aug 2026 16:15:53 -0300 Subject: [PATCH 13/13] Feat/hint ecall (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add on-demand hint ecall (host-computed) * Add HINT prover table for the hint ecall * Add hint ecall guest tests and test programs * Route ecsm inverses and sqrt through hint ecall * Make the hint ecall ABI big-endian * Validate the Hint ecall operand addresses * Verify hints by difference instead of byte compare * Bind HINT writes to x12 and range-check bytes * Fix hint doc placement and guest cargo config * Verify hints with a mandatory software fallback * Constrain the HINT multiplicity column as boolean * Drop BENCH-ONLY labels from the hint ecall * Test that IS_BIT rejects a non-boolean HINT mu * Run ethrex-crypto host tests in CI * Add software fallback and test seam to field_inv * GPU parity-check the HINT table * Move HINT syscall off the FEXT_FMA numberD * Bind and range-check the HINT ecall operands * lint * Fix stale hint-ecall comments (#899) - executor/Cargo.toml: drop the BENCH ONLY label on the k256 dep. 515a921d3 removed those labels everywhere else; compute_hint is production executor code reached by real ecrecover proofs. - hint_min: the ethrex call site is aligned, not unaligned — get_hint in crypto/ethrex-crypto wraps its output in an align(8) buffer. * Correct the hint_min alignment comment The guest doc claimed the ethrex call site is unaligned, but ethrex-crypto's get_hint wraps its output in an align(8) newtype precisely to keep the four HINT writes on the MEMW_A path — a bare [u8; 32] on the stack is only 1-aligned. Someone trusting the comment and dropping the wrapper would add four wide MEMW rows per hint call, on every ecrecover. * Drop the BENCH ONLY label from the k256 dependency k256 is on the prove path, not only in benchmarks: the trace builder's collect_hint_ops recomputes every hint's output with compute_hint because the value is not carried in the CPU log. A maintainer trusting the label and feature-gating the dependency away would break proving. * Range-check the HINT output address low limb, like the input one The HINT table range-checked in_addr's low limb on the ALU bus but left out_addr to the memory bus, reasoning that an output address straddling the 2^32 limb boundary cannot balance. The bus does bound it, but only to 2^32 - 25: the write bases are out_addr_lo + 8i, so the largest one stops being a canonical limb at 2^32 - 24, while MEMW's carry columns resolve the bytes past it correctly. The executor rejects anything above 2^32 - 32 with HintAddressOverflow, which left the seven-value window 2^32-31 ..= 2^32-25 that the AIR accepted and the executor halts on — a prover could prove a hint call the VM rejects. Send the same LT range-check for out_addr's low limb. The existing in_addr bound is reused unchanged, since 2^32 - 31 is exactly addr_limb_ok(addr, 31) for either operand, and is renamed HINT_ADDR_LIMB_BOUND now that it covers both. The trace builder emits the matching LT op, and the sizing pass counts three LT rows per hint call instead of two — LT is an upper-bound table there, so the count only has to stay >= the built trace, which is why the count_table_lengths drift test does not catch an undercount on its own. Tests assert that both address columns carry an ALU LT sender against that bound, and that the bound accepts exactly the limbs addr_limb_ok accepts, with the seven-value window as an explicit regression. * Derive the HINT selector bound from the executor's accepted set HINT_SELECTOR_BOUND was a literal 3 in the prover, while the executor decided validity with matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT). Nothing linked the two, so appending a fourth selector would make the HINT table assert LT(selector, 3) = 1 against an LT row the builder emits as 0 — an unbalanced ALU bus with no algebraic pointer to the cause. Move the bound next to the selectors it bounds, express the ecall's rejection as is_valid_hint_selector, and const-assert that every selector below the bound is valid and that the bound itself is not. The prover re-exports the bound instead of restating it, so a selector added without moving the bound fails to compile rather than surfacing as a bus imbalance at proving time. * ci(executor): run the executor lib unit tests The unit tests under `executor/src/tests/` live in the lib target (`#[cfg(test)] pub mod tests;` in lib.rs), so none of the `--test ` steps select them, and the `test_ckzg` step filters by name and runs only ignored tests. They therefore never ran in CI — including the hint ecall's `HintUnknownSelector` / `HintAddressOverflow` / per-selector coverage, which has no other home. The new step shares the lib test binary with the `test_ckzg` step, so it costs a test run rather than an extra compile. * test(ethrex-crypto): cover the negated-sqrt and canonical-but-wrong hints The existing lying-hint tests all feed `[0; 32]` / `[0xFF; 32]`, which die in `Scalar::from_repr` / `FieldElement::from_bytes` and never reach the verify predicate. So the checks the fast paths' soundness actually rests on — `(x * inv) == 1` and `x·inv - 1 == 0` — had no test that exercised their rejecting branch. - `field_inv` / `scalar_inv`: hints that parse cleanly and simply are not the inverse (`inv + 1`, `-inv`), which must be rejected and recomputed. - `decompress_r`: an oracle returning the *other* root. That is not a lie — `-y` is as valid a root of x³+7 as `y` — so the verify accepts it and the fallback never runs, leaving the parity-selection branch solely responsible for the sign. With the honest oracle that branch fires only for the `k` whose root happens to have the wrong parity; forcing the negation exercises it for every `k`. Also drops a dangling "property C1" reference from the module doc and states the property directly. * test(hint): exercise all three selectors in the hint_multi guest The guest called `HINT_FIELD_INV` three times, so the AIR's `selector < 3` range-check was only ever exercised at 0 — an accepted-value bound that no end-to-end test pushed against. One call per selector (`HINT_FIELD_INV`, `HINT_SCALAR_INV`, `HINT_FIELD_SQRT`) covers the whole accepted range; `sqrt`'s input is 4, a quadratic residue mod p, so the hint is a real root rather than the zeros `compute_hint` returns on a numeric failure. `test_prove_hint_multi_rust_guest`'s expected value follows, now computed through `compute_hint` per selector instead of assuming three field inverses. * test(hint): pin the guest's selector constants against the executor's `is_valid_hint_selector` and its const-assert tie the AIR's range-check to the executor's accepted set, so the prover and executor can no longer disagree. The *guest* is a third declaration and is still unbound: `lambda-vm-syscalls` re-declares the same three selectors as `usize`, in a crate the workspace excludes, linked to the executor's `u64` copies by nothing but a comment. A divergence there is silent. The ecall would either trap on an unknown selector, or — worse, for a value that stays in range — return the wrong function's answer, which the guest's verify-then-fallback swallows as "the host lied" and quietly recomputes in software. Nothing fails; the guest just runs ~2000x slower for the right result. `lambda-vm-syscalls` is added as a dev-dependency for it. Unlike `crypto/crypto`'s and `ethrex-crypto`'s copies it is not target-gated, so it does build on the host — safe because that crate's guest-only items (the `#[global_allocator]` and the `_start`/`main` entrypoint) are already `cfg(target_arch = "riscv64")`, and `executor::tests` is itself `#[cfg(test)]`, so the non-test lib build never links it. * docs(hint): correct three comments the operand work left stale Follow-on to "Range-check the HINT output address low limb" and "Derive the HINT selector bound", which added interactions and constants but left these behind. - `hint.rs`: the `HintConstraints` doc still said the LogUp argument "already fixes `mu`'s value via the timestamp-unique `Ecall` tuple", framing `IS_BIT` as belt-and-braces. That contradicts the module doc directly above it: the `Ecall` tuple carries a per-instruction timestamp, a free column, so LogUp pins only the *sum* of `mu` over rows sharing a tuple — which a witness can satisfy by spreading `mu` with integer weights summing to 1. `IS_BIT` is load-bearing, and the doc now says so and points at that argument. Its bus list was also stale (one register read, no LT senders); it is three and three. - `prover/src/test_utils.rs`: same stale bus surface on `create_hint_air`. - `crypto/ethrex-crypto/src/lib.rs`: the comment justifying `negate(y2)` over `negate(rhs)` claimed negating `rhs` "would silently compute the wrong value in release". That is not what happens. k256's `negate(magnitude)` computes `2*(magnitude+1)*P_limb - self` under a `debug_assert!(self.magnitude <= magnitude)`; for a magnitude-2 operand the result stays non-negative, so the value is correct and it is the debug assert that fires. The reason to prefer `negate(y2)` is real, but it is a build-configuration hazard, not a wrong answer — worth stating accurately in a comment that exists to explain a non-obvious choice. * ci(ethrex-crypto): run the hint tests in release too, not only debug k256 0.13.4 swaps its FieldElement implementation on `debug_assertions` (arithmetic/field.rs): debug selects the magnitude-tracking `field_impl` wrapper, release selects the raw `FieldElement5x52`. The guest ELF is built with `cargo build --release`, so every hint-verification test was exercising an implementation the guest never compiles -- and `test-ethrex-crypto` was the only test step in pr_main.yaml without `--release`. The two builds are not interchangeable for these tests. `ConstantTimeEq` differs between them: the debug wrapper compares the magnitude and normalized tags alongside the limbs, the release type compares limbs only. A magnitude-contract violation would panic loudly in the tested build and compute a silently wrong value in the shipped one. Keep both: release is what ships, and debug's magnitude asserts turn a contract violation into a panic rather than a wrong answer. --------- Co-authored-by: MauroFab Co-authored-by: Diego K <43053772+diegokingston@users.noreply.github.com> --- .github/workflows/pr_main.yaml | 12 + Cargo.lock | 2 + Makefile | 17 +- bench_vs/lambda/recursion/Cargo.lock | 1 + crypto/ethrex-crypto/src/lib.rs | 216 +++++++++- .../src/tests/ecrecover_tests.rs | 48 +-- crypto/ethrex-crypto/src/tests/ecsm_tests.rs | 10 +- crypto/ethrex-crypto/src/tests/hint_tests.rs | 270 +++++++++++++ .../ethrex-crypto/src/tests/keccak_tests.rs | 7 +- crypto/ethrex-crypto/src/tests/mod.rs | 2 + executor/Cargo.toml | 12 + .../programs/rust/hint_min/.cargo/config.toml | 5 + executor/programs/rust/hint_min/Cargo.lock | 331 ++++++++++++++++ executor/programs/rust/hint_min/Cargo.toml | 9 + executor/programs/rust/hint_min/src/main.rs | 31 ++ .../rust/hint_multi/.cargo/config.toml | 5 + executor/programs/rust/hint_multi/Cargo.lock | 331 ++++++++++++++++ executor/programs/rust/hint_multi/Cargo.toml | 9 + executor/programs/rust/hint_multi/src/main.rs | 43 ++ executor/src/tests/hint_tests.rs | 196 +++++++++ executor/src/tests/mod.rs | 1 + executor/src/vm/instruction/execution.rs | 148 ++++++- prover/src/lib.rs | 13 +- prover/src/tables/cpu.rs | 8 + prover/src/tables/hint.rs | 373 ++++++++++++++++++ prover/src/tables/mod.rs | 1 + prover/src/tables/trace_builder.rs | 161 +++++++- prover/src/test_utils.rs | 18 + .../tests/constraint_program_device_tests.rs | 1 + prover/src/tests/constraint_program_tests.rs | 1 + prover/src/tests/constraint_set_tests_b.rs | 16 + .../tests/count_table_lengths_drift_tests.rs | 47 ++- prover/src/tests/hint_tests.rs | 171 ++++++++ prover/src/tests/mod.rs | 2 + prover/src/tests/ood_window_ir_tests.rs | 1 + prover/src/tests/prove_elfs_tests.rs | 328 +++++++++++++++ prover/tests/gpu_constraint_interp_real.rs | 1 + syscalls/src/syscalls.rs | 36 ++ tooling/ethrex-tests/Cargo.lock | 1 + 39 files changed, 2827 insertions(+), 58 deletions(-) create mode 100644 crypto/ethrex-crypto/src/tests/hint_tests.rs create mode 100644 executor/programs/rust/hint_min/.cargo/config.toml create mode 100644 executor/programs/rust/hint_min/Cargo.lock create mode 100644 executor/programs/rust/hint_min/Cargo.toml create mode 100644 executor/programs/rust/hint_min/src/main.rs create mode 100644 executor/programs/rust/hint_multi/.cargo/config.toml create mode 100644 executor/programs/rust/hint_multi/Cargo.lock create mode 100644 executor/programs/rust/hint_multi/Cargo.toml create mode 100644 executor/programs/rust/hint_multi/src/main.rs create mode 100644 executor/src/tests/hint_tests.rs create mode 100644 prover/src/tables/hint.rs create mode 100644 prover/src/tests/hint_tests.rs diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index 1ff124048..2d7c1723b 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -117,6 +117,15 @@ jobs: run: | cargo test --release -p executor --test flamegraph + # The unit tests under `executor/src/tests/` are a *lib* target (`pub mod tests;` + # in lib.rs), which none of the `--test ` steps above select — and the + # `test_ckzg` step below filters by name, so it doesn't run them either. Without + # this step they never run in CI. It shares the lib test binary with that step, + # so it costs a test run, not an extra compile. + - name: Run executor lib unit tests + run: | + cargo test --release -p executor --lib + - name: Run ignored executor tests run: | cargo test --release -p executor test_ckzg -- --ignored @@ -169,6 +178,9 @@ jobs: - name: Run syscalls host tests (keccak differential vs sha3) run: make test-syscalls + - name: Run ethrex-crypto host tests (hint verify-then-fallback + ecrecover) + run: make test-ethrex-crypto + # "Test" is a required check — keep this name to avoid branch protection changes. # This gate job passes only when CLI, executor, disk-spill, and prover tests succeed. test: diff --git a/Cargo.lock b/Cargo.lock index fd763f24b..2868f3e1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,8 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", + "lambda-vm-syscalls", "rustc-demangle", "serde", "serde_json", diff --git a/Makefile b/Makefile index 25dce43de..a4b05b507 100644 --- a/Makefile +++ b/Makefile @@ -93,7 +93,7 @@ ASM_LDFLAGS ?= -fuse-ld=lld -nostdlib -Wl,-e,main # Custom RV64IM target spec location RV64_TARGET_SPEC=$(CURDIR)/executor/programs/riscv64im-lambda-vm-elf.json -.PHONY: test prepare-sysroot +.PHONY: test test-syscalls test-ethrex-crypto prepare-sysroot # The guard checks for include/stdlib.h (not just the include/ dir) so that a PARTIAL # sysroot — directories present but missing the C standard library headers — is detected @@ -517,7 +517,20 @@ check-ethrex-fixture-checksums: test-syscalls: cd syscalls && cargo test -test: compile-programs test-syscalls +# ethrex-crypto is a detached workspace (excluded from the root members), so a +# root `cargo test` never runs it. Run it explicitly, like test-syscalls. +# Run BOTH profiles deliberately. k256 swaps its FieldElement implementation on +# `debug_assertions` (k256 0.13.4 arithmetic/field.rs): debug uses the +# magnitude-tracking `field_impl` wrapper, release uses the raw FieldElement5x52. +# The guest ELF is built with --release, so a release run is the only one that +# exercises the implementation that actually ships; the debug run is kept because +# its magnitude debug_asserts turn a contract violation into a loud panic instead +# of a silently wrong value. +test-ethrex-crypto: + cd crypto/ethrex-crypto && cargo test + cd crypto/ethrex-crypto && cargo test --release + +test: compile-programs test-syscalls test-ethrex-crypto cargo test # === Quick test shortcuts === diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index 3e7f8e9a5..c358f86ec 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -234,6 +234,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror", ] diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index c1e5d8446..ec36b0831 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -19,8 +19,12 @@ use ethrex_crypto::keccak::keccak_hash; use ethrex_crypto::{Crypto, CryptoError}; use k256::elliptic_curve::group::prime::PrimeCurveAffine; -use k256::elliptic_curve::ops::{Invert, LinearCombination, Reduce}; -use k256::elliptic_curve::point::DecompressPoint; +use k256::elliptic_curve::ops::{LinearCombination, Reduce}; +// `Invert` provides the software `x.invert()/invert_vartime()`. It is used by the +// host path AND, on the riscv64 guest, by the mandatory software fallback that +// runs whenever a hinted inverse fails to verify (a lying host). It is therefore +// needed in every build, not only off-target. +use k256::elliptic_curve::ops::Invert; use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::elliptic_curve::PrimeField; use k256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar, U256}; @@ -60,6 +64,158 @@ impl Crypto for LambdaVmEcsmCrypto { // ── ECDSA secp256k1 recovery via the ECSM precompile ──────────────────────── +/// Obtain a 32-byte big-endian hint for `x_be` via the executor `hint` ecall +/// (the host computes the modular inverse / sqrt; the value is provable via the +/// prover's HINT table). The result is UNTRUSTED — the ecall adds no correctness +/// constraint, so every caller MUST verify it in-guest (`x·inv == 1`, `y² == x³+7`) +/// AND recompute in software on any verification failure. The hint is only ever +/// allowed to save work, never to change the answer: because the prover chooses the +/// bytes, an unverified-or-rejected-outright hint would let it steer a caller's +/// accept/reject outcome (e.g. force a valid signature to look invalid). See +/// [`scalar_inv`] / [`decompress_r`] for the fallback that closes that hole. +#[cfg(target_arch = "riscv64")] +fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { + // 8-byte-aligned output buffer so the HINT table's four 8-byte writes land on the + // aligned memory path (MEMW_A) instead of the general MEMW path. An `[u8; 32]` on + // the stack is only 1-aligned, which forces the four writes onto the unaligned + // path and inflates the trace. + #[repr(C, align(8))] + struct Aligned32([u8; 32]); + let mut out = Aligned32([0u8; 32]); + lambda_vm_syscalls::syscalls::hint(hint_id, &mut out.0, x_be); + out.0 +} + +/// Scalar-field inverse `x⁻¹ mod n`. +/// +/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and +/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed +/// in software.** `x⁻¹` exists for every `x` this is called with — the only caller, +/// `ecsm_ecrecover`, guarantees `r ≠ 0` before calling — so a failed verify can only +/// mean the host lied, and the software value is authoritative. This is what keeps +/// the result independent of the prover-chosen hint: a bad hint makes the guest do +/// more work, it can never change the answer, so it cannot turn a valid signature +/// into a recovery failure. Off-target (host) it inverts in software directly. +fn scalar_inv(x: &Scalar) -> Option { + #[cfg(target_arch = "riscv64")] + { + scalar_inv_with_oracle(x, |x_be| { + get_hint(lambda_vm_syscalls::syscalls::HINT_SCALAR_INV, x_be) + }) + } + #[cfg(not(target_arch = "riscv64"))] + { + x.invert_vartime().into() + } +} + +/// Core of [`scalar_inv`], generic over the hint source so host tests can inject an +/// honest or a lying oracle and assert the software fallback keeps the result +/// correct either way. See [`scalar_inv`] for the verify-then-fallback rationale. +#[cfg(any(target_arch = "riscv64", test))] +fn scalar_inv_with_oracle(x: &Scalar, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + use k256::elliptic_curve::subtle::ConstantTimeEq; + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = hint(&x_be); + // Fast path: a canonical hint that verifies (x·inv == 1 mod n) is used as-is. + if let Some(inv) = Option::::from(Scalar::from_repr(inv_be.into())) { + if bool::from((*x * inv).ct_eq(&Scalar::ONE)) { + return Some(inv); + } + } + // Hint absent / malformed / wrong: recompute authoritatively. `x⁻¹` exists for + // every input the callers pass (`r ≠ 0`), so this is `Some` on the honest path. + x.invert_vartime().into() +} + +/// Decompress R from its x-coordinate + parity. +/// +/// On riscv64 the square root `y = sqrt(x³+7)` is first requested from the untrusted +/// `hint` ecall and verified in-guest (`y² == x³+7`), with parity selection; **on any +/// verification failure the point is recomputed with the software +/// `AffinePoint::decompress`.** Unlike the inverse, a failure here is *not* +/// necessarily a lying host: a genuine non-residue (an invalid signature) has no +/// root and must legitimately yield `None`. So the fallback is the authoritative +/// software decompress, which returns `Some` for a residue and `None` for a +/// non-residue regardless of the prover-chosen hint — the hint can only save work, +/// never steer the accept/reject outcome. Off-target it uses the software +/// decompress directly. +fn decompress_r(r_bytes: &FieldBytes, y_is_odd: bool) -> Option { + #[cfg(target_arch = "riscv64")] + { + decompress_r_with_oracle(r_bytes, y_is_odd, |rhs_be| { + get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_SQRT, rhs_be) + }) + } + #[cfg(not(target_arch = "riscv64"))] + { + use k256::elliptic_curve::point::DecompressPoint; + AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into() + } +} + +/// Core of [`decompress_r`], generic over the hint source for host tests: try the +/// hinted sqrt, then fall back to the authoritative software decompress on any +/// failure. See [`decompress_r`] for the rationale. +#[cfg(any(target_arch = "riscv64", test))] +fn decompress_r_with_oracle(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + if let Some(p) = decompress_r_hinted(r_bytes, y_is_odd, hint) { + return Some(p); + } + // Hinted root absent / malformed / wrong, OR a genuine non-residue: the software + // decompress is authoritative — `Some` for a residue, `None` for a non-residue. + use k256::elliptic_curve::point::DecompressPoint; + AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into() +} + +/// The hint-accelerated decompress attempt: returns the point only if the hinted +/// root verifies (`y² == x³+7`); `None` on any failure, so the caller falls back to +/// the software decompress. Never the last word — a `None` here is not a decision +/// that R is invalid, only that the fast path did not produce a verified root. +#[cfg(any(target_arch = "riscv64", test))] +fn decompress_r_hinted(r_bytes: &FieldBytes, y_is_odd: bool, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + let x: FieldElement = Option::from(FieldElement::from_bytes(r_bytes))?; + // secp256k1: y² = x³ + 7. + let mut seven_bytes = [0u8; 32]; + seven_bytes[31] = 7; + let seven: FieldElement = Option::from(FieldElement::from_bytes(&seven_bytes.into()))?; + let x3: FieldElement = x.square() * x; + let rhs: FieldElement = x3 + seven; + // Hinted sqrt (BE in/out), then verify y² == rhs canonically. + let rhs_be: [u8; 32] = rhs.to_bytes().into(); + let y_be = hint(&rhs_be); + let mut y: FieldElement = Option::from(FieldElement::from_bytes(&y_be.into()))?; + let y2: FieldElement = y.square(); + // Verify the untrusted root: y² must equal x³+7. Negate `y2`, not `rhs`: + // `Neg` is `negate(1)`, whose debug assert requires magnitude <= 1. `square()` + // always returns magnitude 1, whereas `rhs` is a sum carrying magnitude 2, so + // negating it would trip that assert and panic in debug builds. (The value would + // still come out right — `negate(m)` computes `2*(m+1)*P_limb - self`, which for a + // magnitude-2 operand stays non-negative — so this is a build-configuration + // hazard, not a wrong answer.) + // (`ct_eq` is unusable here for the same reason as in `field_inv`.) + if !bool::from((rhs + y2.negate(1)).normalizes_to_zero()) { + return None; + } + // Select the root whose canonical LSB matches the requested parity. + let y_odd = (y.to_bytes()[31] & 1) == 1; + if y_odd != y_is_odd { + y = -y; + } + // Build the affine point; `from_encoded_point` re-checks it's on-curve. + let ep = EncodedPoint::from_affine_coordinates(&x.to_bytes(), &y.to_bytes(), false); + Option::from(AffinePoint::from_encoded_point(&ep)) +} + /// Recover the uncompressed public key bytes (X‖Y, 64 bytes) from a 64-byte /// signature, recovery id, and 32-byte message hash. Used by the ECRECOVER /// precompile (0x01). @@ -96,15 +252,14 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], // precompile; we don't handle it (decompression simply fails), matching the // trait default. let y_is_odd = (recid & 1) != 0; - let r_point: Option = - AffinePoint::decompress(r_bytes, u8::from(y_is_odd).into()).into(); + let r_point: Option = decompress_r(r_bytes, y_is_odd); let Some(r_point) = r_point else { return Err(CryptoError::RecoveryFailed); }; let r_proj = ProjectivePoint::from(r_point); let z = >::reduce_bytes(&FieldBytes::from(*msg)); - let r_inv: Option = r.invert_vartime().into(); + let r_inv: Option = scalar_inv(&r); let Some(r_inv) = r_inv else { return Err(CryptoError::RecoveryFailed); }; @@ -180,6 +335,55 @@ fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { Option::from(FieldElement::from_bytes(&xr_le.into())) } +/// Base-field inverse `x⁻¹ mod p`. +/// +/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and +/// verified in-guest (`x·inv == 1`); **on any verification failure it is recomputed +/// in software.** A bad hint can only cost the guest extra work, never change the +/// answer — it cannot steer a caller's accept/reject outcome. Off-target it inverts +/// in software directly. Returns `None` only for a genuinely non-invertible input +/// (`x = 0`), which the callers' degeneracy guards already exclude. +#[cfg(any(target_arch = "riscv64", test))] +fn field_inv(x: &FieldElement) -> Option { + #[cfg(target_arch = "riscv64")] + { + field_inv_with_oracle(x, |x_be| { + get_hint(lambda_vm_syscalls::syscalls::HINT_FIELD_INV, x_be) + }) + } + #[cfg(not(target_arch = "riscv64"))] + { + Option::from(x.invert()) + } +} + +/// Core of [`field_inv`], generic over the hint source so host tests can inject an +/// honest or a lying oracle and assert the software fallback keeps the result +/// correct either way. See [`scalar_inv`] for the verify-then-fallback rationale. +#[cfg(any(target_arch = "riscv64", test))] +fn field_inv_with_oracle(x: &FieldElement, hint: O) -> Option +where + O: FnOnce(&[u8; 32]) -> [u8; 32], +{ + let x_be: [u8; 32] = x.to_bytes().into(); + let inv_be = hint(&x_be); + // Fast path: a canonical hint that verifies (x·inv == 1 mod p) is used as-is. + // Verify by asking whether the difference normalizes to zero — a value-level test + // that skips the two full normalizations a `to_bytes()` compare pays. `ct_eq` is + // NOT a substitute: k256's FieldElement compares raw limbs *and* the magnitude and + // `normalized` tags, so a `mul` result (magnitude 1, unnormalized) never compares + // equal to the normalized `ONE` constant whatever its value. + // `Neg` is `negate(1)`, valid here because `mul` yields magnitude 1. + if let Some(inv) = Option::::from(FieldElement::from_bytes(&inv_be.into())) { + if bool::from((*x * inv - FieldElement::ONE).normalizes_to_zero()) { + return Some(inv); + } + } + // Hint absent / malformed / wrong: recompute authoritatively. `None` only for a + // genuine `x = 0`, excluded by the callers' guards. + Option::from(x.invert()) +} + /// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any /// degenerate-configuration guard trips. /// @@ -232,7 +436,7 @@ where // One shared inversion for the two λ denominators and the final chord. let den1 = y1.double() * dx1; let den2 = y2.double() * dx2; - let inv = Option::::from((den1 * den2 * dxq).invert())?; + let inv = field_inv(&(den1 * den2 * dxq))?; let inv_den1 = inv * den2 * dxq; let inv_den2 = inv * den1 * dxq; let inv_dxq = inv * den1 * den2; diff --git a/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs index f9c1d9242..af2ab1f1d 100644 --- a/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs @@ -57,36 +57,24 @@ fn make_ecdsa_fixture(d: Scalar, kk: Scalar, msg: [u8; 32]) -> ([u8; 64], u8, [u fn ecrecover_known_answer_three_tuples() { // Three distinct (d, kk, msg) tuples — deterministic, no RNG. let tuples: &[(u64, u64, [u8; 32])] = &[ - ( - 0x0000_0000_0000_0001u64, - 0x0000_0000_dead_beefu64, - { - let mut m = [0u8; 32]; - m[31] = 0x42; - m - }, - ), - ( - 0x00c0_ffee_dead_beef_u64, - 0x0123_4567_89ab_cdef_u64, - { - let mut m = [0u8; 32]; - m[0] = 0xff; - m[31] = 0x01; - m - }, - ), - ( - 0x0bad_f00d_1337_cafe, - 0xfeed_face_0000_0001, - { - let mut m = [0u8; 32]; - for (i, b) in m.iter_mut().enumerate() { - *b = i as u8; - } - m - }, - ), + (0x0000_0000_0000_0001u64, 0x0000_0000_dead_beefu64, { + let mut m = [0u8; 32]; + m[31] = 0x42; + m + }), + (0x00c0_ffee_dead_beef_u64, 0x0123_4567_89ab_cdef_u64, { + let mut m = [0u8; 32]; + m[0] = 0xff; + m[31] = 0x01; + m + }), + (0x0bad_f00d_1337_cafe, 0xfeed_face_0000_0001, { + let mut m = [0u8; 32]; + for (i, b) in m.iter_mut().enumerate() { + *b = i as u8; + } + m + }), ]; for &(d_u64, kk_u64, msg) in tuples { diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 89c911db7..42e80224b 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -61,8 +61,14 @@ fn edge_scalars_fall_back() { let p2 = g_times(5); let ok = Scalar::from(12345u64); for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { - assert!(lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle).is_none()); - assert!(lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle).is_none()); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle) + .is_none() + ); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle) + .is_none() + ); } } diff --git a/crypto/ethrex-crypto/src/tests/hint_tests.rs b/crypto/ethrex-crypto/src/tests/hint_tests.rs new file mode 100644 index 000000000..ace59f208 --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/hint_tests.rs @@ -0,0 +1,270 @@ +//! Host tests for the untrusted-hint verify-then-fallback paths (`scalar_inv`, +//! `field_inv`, `decompress_r`). +//! +//! The guest asks the (untrusted, prover-chosen) `hint` ecall for a modular +//! inverse / square root, then verifies it in-circuit. These tests inject the +//! oracle directly — an *honest* oracle (matching the executor's `compute_hint`) +//! and a *lying* one — and assert the software fallback makes the result identical +//! either way. That is the property the whole hint design rests on: because the +//! prover chooses the hinted bytes and the ecall adds no correctness constraint, a +//! bad hint must only be able to make the guest do more work, never change its +//! accept/reject outcome. On the guest this code is `cfg(target_arch = "riscv64")`; +//! the `test` gate on `*_with_oracle` is what lets CI compile and exercise it on +//! the host. + +use crate::*; + +/// A `[u8; 32]` big-endian field element from a small integer. +fn fe_from_u64(k: u64) -> FieldElement { + let mut be = [0u8; 32]; + be[24..32].copy_from_slice(&k.to_be_bytes()); + Option::::from(FieldElement::from_bytes(&be.into())).expect("k < p") +} + +/// Honest scalar-inverse oracle (BE in/out, mod n) — mirrors the executor's +/// `compute_hint(HINT_SCALAR_INV, ..)`: the inverse if it exists, else zeros. +fn honest_scalar_inv(x_be: &[u8; 32]) -> [u8; 32] { + let x = Option::::from(Scalar::from_repr((*x_be).into())).expect("canonical input"); + match Option::::from(x.invert()) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } +} + +/// Honest base-field sqrt oracle (BE in/out, mod p) — mirrors +/// `compute_hint(HINT_FIELD_SQRT, ..)`: a root if one exists, else zeros. +fn honest_field_sqrt(rhs_be: &[u8; 32]) -> [u8; 32] { + let rhs = Option::::from(FieldElement::from_bytes(&(*rhs_be).into())) + .expect("canonical"); + match Option::::from(rhs.sqrt()) { + Some(y) => y.to_bytes().into(), + None => [0u8; 32], + } +} + +fn sec1(p: &AffinePoint) -> Vec { + p.to_encoded_point(false).as_bytes().to_vec() +} + +#[test] +fn scalar_inv_honest_hint_matches_software() { + for k in [1u64, 2, 3, 7, 1000, 12345, u64::MAX] { + let x = Scalar::from(k); + let sw = x.invert_vartime().expect("k != 0 is invertible"); + let got = scalar_inv_with_oracle(&x, honest_scalar_inv).expect("inverse exists"); + assert_eq!( + got, sw, + "honest hint must equal the software inverse (k={k})" + ); + } +} + +#[test] +fn scalar_inv_lying_hint_falls_back_to_software() { + // The prover-chosen hint returns garbage; the result must be unchanged. `x⁻¹` + // exists (the caller guarantees `r != 0`), so the software fallback is + // authoritative — a lie cannot turn a recoverable signature into a failure. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 2, 12345, u64::MAX] { + let x = Scalar::from(k); + let sw = x.invert_vartime().unwrap(); + let got = scalar_inv_with_oracle(&x, |_| lie).expect("fallback recomputes"); + assert_eq!( + got, sw, + "lying hint must fall back to the software inverse (k={k})" + ); + } + } +} + +#[test] +fn scalar_inv_canonical_but_wrong_hint_falls_back_to_software() { + // The `[0; 32]` / `[0xFF; 32]` lies above both die in `Scalar::from_repr` — they + // never reach the verify predicate. These two are perfectly canonical scalars that + // simply aren't the inverse, so they exercise the rejecting branch of + // `(x * inv) == 1` itself, which is the check that actually has to hold. + for k in [1u64, 2, 12345] { + let x = Scalar::from(k); + let sw = x.invert_vartime().unwrap(); + for (name, lie) in [("inv + 1", sw + Scalar::ONE), ("-inv", -sw)] { + let lie_be: [u8; 32] = lie.to_bytes().into(); + let got = scalar_inv_with_oracle(&x, |_| lie_be).expect("fallback recomputes"); + assert_eq!( + got, sw, + "a canonical-but-wrong hint ({name}) must be rejected and recomputed (k={k})" + ); + } + } +} + +#[test] +fn decompress_r_honest_hint_matches_software() { + // x-coordinates of real points are guaranteed residues. + for k in [1u64, 2, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, honest_field_sqrt) + .expect("valid residue decompresses"); + assert_eq!( + sec1(&got), + sec1(&p), + "honest hint must recover the point (k={k})" + ); + } +} + +#[test] +fn decompress_r_lying_hint_falls_back_to_software() { + // A residue x with a garbage sqrt hint must still decompress to the true point. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, |_| lie) + .expect("software fallback decompresses a residue"); + assert_eq!( + sec1(&got), + sec1(&p), + "lying hint must fall back to software (k={k})" + ); + } + } +} + +/// Sqrt oracle returning the *other* root (`−y`). Not a lie: `−y` is as valid a root +/// of `x³+7` as `y`, so the in-guest verify accepts it and the software fallback +/// never runs — fixing the sign is entirely on the parity-selection branch. +fn negated_field_sqrt(rhs_be: &[u8; 32]) -> [u8; 32] { + let honest = honest_field_sqrt(rhs_be); + let y = Option::::from(FieldElement::from_bytes(&honest.into())) + .expect("the honest root is canonical"); + (-y).normalize().to_bytes().into() +} + +#[test] +fn decompress_r_negated_sqrt_hint_recovers_the_point() { + // The hinted root's parity is the host's choice — `compute_hint` returns whichever + // root k256's `sqrt()` picks, so the caller must not depend on it. With the honest + // oracle the parity branch fires only for the `k` values whose root happens to have + // the wrong parity; forcing the negation exercises the *other* half of the branch + // for every `k`. A `Some` here comes from the hinted path, not the fallback, so a + // broken parity fix would return `-P` and fail the comparison. + for k in [1u64, 2, 5, 12345] { + let p = (ProjectivePoint::GENERATOR * Scalar::from(k)).to_affine(); + let (x, y) = affine_xy(&p).unwrap(); + let rb = x.to_bytes(); + let y_is_odd = (y.normalize().to_bytes()[31] & 1) == 1; + let got = decompress_r_with_oracle(&rb, y_is_odd, negated_field_sqrt) + .expect("the other root is still a root"); + assert_eq!( + sec1(&got), + sec1(&p), + "a negated (but valid) root must still recover the point (k={k})" + ); + } +} + +#[test] +fn decompress_r_non_residue_is_none_regardless_of_hint() { + // Find a small x whose x³+7 has no square root: R is genuinely undecompressable + // and must be `None`. A lying hint must NOT be able to force a `Some`, and the + // honest path must NOT spuriously fail — both stem from the same software + // fallback being the sole authority on rejection. + let mut seven = [0u8; 32]; + seven[31] = 7; + let seven = Option::::from(FieldElement::from_bytes(&seven.into())).unwrap(); + + let x = (1u64..10_000) + .map(fe_from_u64) + .find(|x| { + let rhs = (x.square() * *x + seven).normalize(); + Option::::from(rhs.sqrt()).is_none() + }) + .expect("some small x has a non-residue x³+7"); + let rb = x.to_bytes(); + + assert!( + decompress_r_with_oracle(&rb, false, honest_field_sqrt).is_none(), + "a genuine non-residue must decompress to None (honest hint)" + ); + for lie in [[0u8; 32], [0xFFu8; 32]] { + assert!( + decompress_r_with_oracle(&rb, false, |_| lie).is_none(), + "a lying hint must not force a non-residue to decompress" + ); + } +} + +/// Honest base-field inverse oracle (BE in/out, mod p) — mirrors the executor's +/// `compute_hint(HINT_FIELD_INV, ..)`: the inverse if it exists, else zeros. +fn honest_field_inv(x_be: &[u8; 32]) -> [u8; 32] { + let x = Option::::from(FieldElement::from_bytes(&(*x_be).into())) + .expect("canonical input"); + match Option::::from(x.invert()) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } +} + +#[test] +fn field_inv_honest_hint_matches_software() { + for k in [1u64, 2, 3, 7, 1000, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()).expect("k != 0 is invertible"); + let got = field_inv_with_oracle(&x, honest_field_inv).expect("inverse exists"); + assert_eq!( + got.normalize().to_bytes(), + sw.normalize().to_bytes(), + "honest hint must equal the software inverse (k={k})" + ); + } +} + +#[test] +fn field_inv_lying_hint_falls_back_to_software() { + // A prover-chosen garbage inverse must not change the result: `x⁻¹` exists for + // every input the callers pass (guarded non-zero denominators), so the software + // fallback is authoritative — a lie can only cost work, never steer the outcome. + for lie in [[0u8; 32], [0xFFu8; 32]] { + for k in [1u64, 2, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()).unwrap(); + let got = field_inv_with_oracle(&x, |_| lie).expect("fallback recomputes"); + assert_eq!( + got.normalize().to_bytes(), + sw.normalize().to_bytes(), + "lying hint must fall back to the software inverse (k={k})" + ); + } + } +} + +#[test] +fn field_inv_canonical_but_wrong_hint_falls_back_to_software() { + // As in the scalar case: the `[0; 32]` / `[0xFF; 32]` lies die in + // `FieldElement::from_bytes`, so they never reach the verify predicate. These two + // parse cleanly and are simply not the inverse, exercising the rejecting branch of + // `x·inv − 1 == 0` — the check the fast path's soundness actually rests on. + for k in [1u64, 2, 12345] { + let x = fe_from_u64(k); + let sw = Option::::from(x.invert()) + .unwrap() + .normalize(); + for (name, lie) in [ + ("inv + 1", (sw + FieldElement::ONE).normalize()), + ("-inv", -sw), + ] { + let lie_be: [u8; 32] = lie.normalize().to_bytes().into(); + let got = field_inv_with_oracle(&x, |_| lie_be).expect("fallback recomputes"); + assert_eq!( + got.normalize().to_bytes(), + sw.to_bytes(), + "a canonical-but-wrong hint ({name}) must be rejected and recomputed (k={k})" + ); + } + } +} diff --git a/crypto/ethrex-crypto/src/tests/keccak_tests.rs b/crypto/ethrex-crypto/src/tests/keccak_tests.rs index cde649fcb..14d497520 100644 --- a/crypto/ethrex-crypto/src/tests/keccak_tests.rs +++ b/crypto/ethrex-crypto/src/tests/keccak_tests.rs @@ -8,7 +8,12 @@ use crate::*; fn check_keccak(input: &[u8]) { let got = keccak256_with_permute(input, keccak::f1600); let want = keccak_hash(input); - assert_eq!(got, want, "keccak256 mismatch for {}-byte input", input.len()); + assert_eq!( + got, + want, + "keccak256 mismatch for {}-byte input", + input.len() + ); } /// Cross-check our sponge against a hardcoded vector from the Ethereum spec. diff --git a/crypto/ethrex-crypto/src/tests/mod.rs b/crypto/ethrex-crypto/src/tests/mod.rs index f050a8e48..37fc9b3a0 100644 --- a/crypto/ethrex-crypto/src/tests/mod.rs +++ b/crypto/ethrex-crypto/src/tests/mod.rs @@ -3,4 +3,6 @@ pub mod ecrecover_tests; #[cfg(test)] pub mod ecsm_tests; #[cfg(test)] +pub mod hint_tests; +#[cfg(test)] pub mod keccak_tests; diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 3f278e1c6..91ae64ae9 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -8,8 +8,20 @@ license.workspace = true thiserror = "1.0.68" rustc-demangle = "0.1" ecsm = { path = "../crypto/ecsm" } +# Host-side computation of non-constraining hints (modular inverse / sqrt) for the +# `Hint` ecall — same k256 arithmetic the guest verifies against. Production code: +# `compute_hint` runs in every proving execution of a hint-using guest. +k256 = { version = "0.13", default-features = false, features = ["arithmetic", "expose-field"] } [dev-dependencies] +# Test-only: the guest-side syscall crate re-declares the `hint` selectors as `usize` +# and they must stay equal to the `u64` copies here (see `hint_selectors_match_the_guest`). +# Unlike `crypto/crypto`'s and `ethrex-crypto`'s copies of this dep, it is NOT +# target-gated, so it does build on the host — safe because the only guest-only items +# (the `#[global_allocator]` and the `_start`/`main` entrypoint) are already +# `cfg(target_arch = "riscv64")` in that crate, and `executor::tests` is itself +# `#[cfg(test)]`, so the non-test lib build never links it. +lambda-vm-syscalls = { path = "../syscalls" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tiny-keccak = { version = "2.0", features = ["keccak"] } diff --git a/executor/programs/rust/hint_min/.cargo/config.toml b/executor/programs/rust/hint_min/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/hint_min/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] diff --git a/executor/programs/rust/hint_min/Cargo.lock b/executor/programs/rust/hint_min/Cargo.lock new file mode 100644 index 000000000..cc02eff98 --- /dev/null +++ b/executor/programs/rust/hint_min/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hint_min" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/executor/programs/rust/hint_min/Cargo.toml b/executor/programs/rust/hint_min/Cargo.toml new file mode 100644 index 000000000..4bfe4614f --- /dev/null +++ b/executor/programs/rust/hint_min/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "hint_min" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/hint_min/src/main.rs b/executor/programs/rust/hint_min/src/main.rs new file mode 100644 index 000000000..833a01b8a --- /dev/null +++ b/executor/programs/rust/hint_min/src/main.rs @@ -0,0 +1,31 @@ +//! Minimal P0 guest for the Hint prover table: one `hint` ecall (field inverse of +//! a small value) + commit the result. No in-guest verify — this exercises exactly +//! the Hint table's bus surface (Ecall receive, the register read binding `out_addr` +//! to `a2`, four 8-byte MEMW writes and the output range checks; the input read is +//! deliberately not modelled) so we can get prove→verify to balance before scaling +//! to ethrex. +//! +//! Buffers are 8-byte aligned so the writes land in the aligned MEMW table — the same +//! choice the ethrex call site makes (`get_hint` in `crypto/ethrex-crypto` wraps its +//! output in an `align(8)` buffer). Alignment is a preference rather than a +//! requirement — `classify_memw` routes unaligned accesses to the general MEMW table. + +use lambda_vm_syscalls as syscalls; + +#[repr(align(8))] +struct Aligned32([u8; 32]); + +pub fn main() { + // input = 3 (big-endian), a valid invertible field element. + let mut x = Aligned32([0u8; 32]); + x.0[31] = 3; + let mut inv = Aligned32([0u8; 32]); + + syscalls::syscalls::hint( + syscalls::syscalls::HINT_FIELD_INV, + &mut inv.0, + &x.0, + ); + + syscalls::syscalls::commit(&inv.0); +} diff --git a/executor/programs/rust/hint_multi/.cargo/config.toml b/executor/programs/rust/hint_multi/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/hint_multi/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] diff --git a/executor/programs/rust/hint_multi/Cargo.lock b/executor/programs/rust/hint_multi/Cargo.lock new file mode 100644 index 000000000..9803c875a --- /dev/null +++ b/executor/programs/rust/hint_multi/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hint_multi" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/executor/programs/rust/hint_multi/Cargo.toml b/executor/programs/rust/hint_multi/Cargo.toml new file mode 100644 index 000000000..faacdb38e --- /dev/null +++ b/executor/programs/rust/hint_multi/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "hint_multi" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/hint_multi/src/main.rs b/executor/programs/rust/hint_multi/src/main.rs new file mode 100644 index 000000000..2a03a644d --- /dev/null +++ b/executor/programs/rust/hint_multi/src/main.rs @@ -0,0 +1,43 @@ +//! Multi-hint P0/P2 guest for the Hint prover table: THREE `hint` ecalls, one per +//! selector, each result read back with ordinary `LOAD`s (XOR-accumulated) and the +//! accumulator committed. +//! +//! Complements `hint_min` (one hint, read back via `commit`): this exercises the +//! parts the ethrex consumer relies on that a single-call guest does not — +//! **multiple real HINT rows** (padded to a power of two), **all three selectors** +//! (`HINT_FIELD_INV` / `HINT_SCALAR_INV` / `HINT_FIELD_SQRT`, so the AIR's +//! `selector < 3` range-check is exercised at every accepted value rather than only +//! at 0) and **read-back of the hinted output via normal `LOAD` instructions** +//! (whose MEMW reads must chain to the HINT table's writes). Buffers are 8-byte +//! aligned so the writes land in the aligned MEMW table. + +use lambda_vm_syscalls as syscalls; + +#[repr(align(8))] +struct Aligned32([u8; 32]); + +pub fn main() { + let mut acc = Aligned32([0u8; 32]); + + // One call per selector. 4 is a quadratic residue mod p, so the sqrt hint has a + // real root rather than the zeros `compute_hint` returns on a numeric failure. + for (hint_id, seed) in [ + (syscalls::syscalls::HINT_FIELD_INV, 3u8), + (syscalls::syscalls::HINT_SCALAR_INV, 5u8), + (syscalls::syscalls::HINT_FIELD_SQRT, 4u8), + ] { + let mut x = Aligned32([0u8; 32]); + x.0[31] = seed; + let mut out = Aligned32([0u8; 32]); + + syscalls::syscalls::hint(hint_id, &mut out.0, &x.0); + + // Read the hinted output back via ordinary loads and fold it in, so the + // MEMW reads of `out` must chain to the HINT table's writes. + for i in 0..32 { + acc.0[i] ^= out.0[i]; + } + } + + syscalls::syscalls::commit(&acc.0); +} diff --git a/executor/src/tests/hint_tests.rs b/executor/src/tests/hint_tests.rs new file mode 100644 index 000000000..2ed8c096c --- /dev/null +++ b/executor/src/tests/hint_tests.rs @@ -0,0 +1,196 @@ +//! Tests for the non-constraining `Hint` syscall. + +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ + ExecutionError, HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV, HINT_SYSCALL_NUMBER, + compute_hint, +}; +use crate::vm::memory::Memory; +use crate::vm::registers::Registers; + +fn write_u256(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) { + for i in 0..4 { + let mut dw = [0u8; 8]; + dw.copy_from_slice(&bytes[i * 8..i * 8 + 8]); + memory + .store_doubleword(addr + (i as u64) * 8, u64::from_le_bytes(dw)) + .unwrap(); + } +} + +fn read_u256(memory: &Memory, addr: u64) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..4 { + let dw = memory.load_doubleword(addr + (i as u64) * 8).unwrap(); + out[i * 8..i * 8 + 8].copy_from_slice(&dw.to_le_bytes()); + } + out +} + +/// Runs one `Hint` ecall with the given operand addresses, returning the 32 bytes +/// written at `out_addr`. +fn run_hint_at( + hint_id: u64, + in_addr: u64, + out_addr: u64, + input: &[u8; 32], +) -> Result<[u8; 32], ExecutionError> { + let mut memory = Memory::default(); + let mut registers = Registers::default(); + let mut pc = 0u64; + + write_u256(&mut memory, in_addr, input); + registers.write(17, HINT_SYSCALL_NUMBER).unwrap(); + registers.write(10, hint_id).unwrap(); + registers.write(11, in_addr).unwrap(); + registers.write(12, out_addr).unwrap(); + Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; + Ok(read_u256(&memory, out_addr)) +} + +/// The base-field inverse hint round-trips through guest memory, big-endian in and +/// out, and matches `compute_hint` (the value the prover recomputes). +#[test] +fn hint_syscall_writes_the_field_inverse() { + let mut input = [0u8; 32]; + input[31] = 3; // 3, big-endian + + let out = run_hint_at(HINT_FIELD_INV, 0x1000, 0x2000, &input).expect("hint must run"); + assert_eq!(out, compute_hint(HINT_FIELD_INV, &input)); + + // 3 · 3⁻¹ ≡ 1 (mod p) — the same check the guest performs on the untrusted value. + let three: k256::FieldElement = + Option::from(k256::FieldElement::from_bytes(&input.into())).unwrap(); + let inv: k256::FieldElement = + Option::from(k256::FieldElement::from_bytes(&out.into())).unwrap(); + assert_eq!( + (three * inv).to_bytes(), + k256::FieldElement::ONE.to_bytes(), + "hinted inverse must satisfy x·inv == 1" + ); +} + +/// Both operands must keep their 32-byte range inside the lower address limb: the +/// HINT table sends the output writes as `[out_addr_lo + 8i, out_addr_hi]`, which +/// cannot represent a carry into the high limb, so a straddling operand would make +/// the trace unprovable. The executor rejects it upfront instead. +#[test] +fn hint_syscall_rejects_address_overflow() { + let input = [0u8; 32]; + // Last accessed byte is at +31, so the first rejected base is 2^32 - 31. + for (in_addr, out_addr) in [ + (0x1000, 0xFFFF_FFE8), + (0xFFFF_FFE8, 0x2000), + (0x1000, 0xFFFF_FFE1), + (0xFFFF_FFE1, 0x2000), + (0x1000, 0xFFFF_FFFF), + ] { + let err = run_hint_at(HINT_FIELD_INV, in_addr, out_addr, &input) + .expect_err("straddling operand must be rejected"); + assert!( + matches!(err, ExecutionError::HintAddressOverflow), + "expected address overflow for in={in_addr:#x}, out={out_addr:#x}, got {err:?}" + ); + } +} + +/// The boundary case: an operand ending exactly on the last byte of the limb is +/// still representable and must be accepted. +#[test] +fn hint_syscall_accepts_operand_ending_at_the_limb_boundary() { + let input = [0u8; 32]; + // 2^32 - 32: last byte lands at 2^32 - 1, the largest in-limb address. + run_hint_at(HINT_FIELD_INV, 0x1000, 0xFFFF_FFE0, &input) + .expect("operand ending at the limb boundary must run"); + run_hint_at(HINT_FIELD_INV, 0xFFFF_FFE0, 0x2000, &input) + .expect("operand ending at the limb boundary must run"); +} + +/// The scalar-field inverse hint (mod n) round-trips through guest memory and +/// satisfies `x·inv == 1 (mod n)` — the check the guest performs on the untrusted +/// value. Used by production ecrecover (`r⁻¹`). +#[test] +fn hint_syscall_writes_the_scalar_inverse() { + use k256::elliptic_curve::PrimeField; + + let mut input = [0u8; 32]; + input[31] = 3; // 3, big-endian + + let out = run_hint_at(HINT_SCALAR_INV, 0x1000, 0x2000, &input).expect("hint must run"); + assert_eq!(out, compute_hint(HINT_SCALAR_INV, &input)); + + let three: k256::Scalar = Option::from(k256::Scalar::from_repr(input.into())).unwrap(); + let inv: k256::Scalar = Option::from(k256::Scalar::from_repr(out.into())).unwrap(); + assert_eq!( + (three * inv).to_bytes(), + k256::Scalar::ONE.to_bytes(), + "hinted scalar inverse must satisfy x·inv == 1 (mod n)" + ); +} + +/// The base-field sqrt hint (mod p) round-trips and satisfies `y² == rhs (mod p)`. +/// Used by production ecrecover (decompressing R). `4 = 2²` is a residue. +#[test] +fn hint_syscall_writes_the_field_sqrt() { + let mut input = [0u8; 32]; + input[31] = 4; // rhs = 4, big-endian + + let out = run_hint_at(HINT_FIELD_SQRT, 0x1000, 0x2000, &input).expect("hint must run"); + assert_eq!(out, compute_hint(HINT_FIELD_SQRT, &input)); + + let rhs: k256::FieldElement = + Option::from(k256::FieldElement::from_bytes(&input.into())).unwrap(); + let y: k256::FieldElement = Option::from(k256::FieldElement::from_bytes(&out.into())).unwrap(); + assert_eq!( + y.square().to_bytes(), + rhs.to_bytes(), + "hinted sqrt must satisfy y² == rhs (mod p)" + ); +} + +/// An unknown `hint_id` is rejected up front. Silently writing zeros would be +/// indistinguishable from a legitimate numeric failure and — because the guest reads +/// the value back — could let a prover-chosen selector steer a caller's accept/reject +/// outcome. The executor traps so a guest bug surfaces loudly. `HINT_FIELD_SQRT = 2` +/// is the last known selector, so 3 is the first unknown one. +#[test] +fn hint_syscall_rejects_an_unknown_selector() { + let mut input = [0u8; 32]; + input[31] = 3; + for bad in [3u64, 100, u64::MAX] { + let err = run_hint_at(bad, 0x1000, 0x2000, &input).expect_err("unknown selector must trap"); + assert!( + matches!(err, ExecutionError::HintUnknownSelector(id) if id == bad), + "expected HintUnknownSelector({bad}), got {err:?}" + ); + } +} + +/// The guest's `lambda-vm-syscalls` crate re-declares the selectors as `usize`, +/// linked to the `u64` copies here only by a comment. A divergence is **silent**: +/// the ecall would trap on an unknown selector, or — worse for the selectors that +/// stay in range — hand back the wrong function's answer, which the guest's +/// verify-then-fallback swallows as "the host lied" and quietly recomputes in +/// software. Nothing fails; the guest just runs ~2000× slower for the right result. +/// This test is the only thing that would notice. +/// +/// `is_valid_hint_selector`'s const-assert pins the AIR's range-check to this crate's +/// accepted set, but nothing ties the *guest's* copy of the selectors to it — that is +/// a third declaration, in a crate the workspace excludes, and this is what binds it. +/// +/// The syscall number itself is not asserted here: the guest's copy is +/// `#[cfg(target_arch = "riscv64")]` and private, so it does not exist in a host +/// build. It is covered indirectly — a wrong number makes every `hint` guest fail +/// to prove, which `test_prove_hint_min_rust_guest` catches loudly. +#[cfg(test)] +mod guest_constant_sync { + use super::{HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV}; + use lambda_vm_syscalls::syscalls as guest; + + #[test] + fn hint_selectors_match_the_guest() { + assert_eq!(guest::HINT_FIELD_INV as u64, HINT_FIELD_INV); + assert_eq!(guest::HINT_SCALAR_INV as u64, HINT_SCALAR_INV); + assert_eq!(guest::HINT_FIELD_SQRT as u64, HINT_FIELD_SQRT); + } +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 456607433..244447b22 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod ecsm_tests; pub mod flamegraph_tests; +pub mod hint_tests; pub mod keccak_tests; pub mod memory_tests; diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index c92c0ab88..592af95e8 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -16,6 +16,9 @@ pub enum SyscallNumbers { Halt = 93, // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. Ecsm = 94, + // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. + // Non-constraining hint (host computes modular inverse/sqrt, guest verifies). + Hint = 95, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -31,6 +34,46 @@ const KECCAK_STATE_BYTES: u64 = 25 * 8; /// bus as `[lo32, hi32] = [2^32 - 11, 2^32 - 1]`. pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; +/// Syscall number for the non-constraining `Hint` ecall. +/// +/// The host computes a modular inverse or square root and writes it back to the +/// guest, which MUST verify it (e.g. `x·inv == 1`) and recompute in software on a +/// verification failure. The ecall adds no in-circuit correctness constraint of its +/// own — it lets the guest replace an expensive computation with a cheap check, +/// without letting the (prover-chosen) hinted value change the guest's result. +pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 30; + +/// Hint operation selector passed in `a0`. +pub const HINT_FIELD_INV: u64 = 0; // secp256k1 base-field inverse (mod p) +pub const HINT_SCALAR_INV: u64 = 1; // secp256k1 scalar-field inverse (mod n) +pub const HINT_FIELD_SQRT: u64 = 2; // secp256k1 base-field square root + +/// One past the largest valid hint selector. The prover's HINT table range-checks +/// `a0 < HINT_SELECTOR_BOUND` on the ALU bus to accept exactly the set +/// [`is_valid_hint_selector`] accepts, so both live here rather than being restated +/// independently in the AIR. +pub const HINT_SELECTOR_BOUND: u64 = 3; + +/// Whether `hint_id` names a hint [`compute_hint`] can produce. The ecall rejects +/// anything else up front with [`ExecutionError::HintUnknownSelector`]. +pub const fn is_valid_hint_selector(hint_id: u64) -> bool { + matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT) +} + +// The AIR's range-check and the executor's accepted set must denote the same set: every +// selector below the bound is valid, and the bound itself is not. Appending a selector +// without moving the bound (or vice versa) fails to compile here, instead of making the +// HINT table assert `LT(selector, bound) = 1` against an LT row the builder emits as 0 — +// an unbalanced ALU bus with no algebraic pointer to the cause. +const _: () = { + let mut id = 0; + while id < HINT_SELECTOR_BOUND { + assert!(is_valid_hint_selector(id)); + id += 1; + } + assert!(!is_valid_hint_selector(HINT_SELECTOR_BOUND)); +}; + /// `2^32`. ECSM memory operands must not overflow their lower 32-bit address limb when the /// largest per-access offset is added: the 32-byte operands reach offset +31 (last byte). const LOW_LIMB: u64 = 1 << 32; @@ -45,6 +88,7 @@ impl TryFrom for SyscallNumbers { 93 => Ok(SyscallNumbers::Halt), v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), + v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } } @@ -68,7 +112,8 @@ impl SyscallNumbers { SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit - | SyscallNumbers::Halt => None, + | SyscallNumbers::Halt + | SyscallNumbers::Hint => None, } } } @@ -93,8 +138,59 @@ fn store_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) -> Result<(), Ok(()) } -/// Checks the ECSM address-alignment assumption: `(addr mod 2^32) + max_offset < 2^32`. -fn ecsm_addr_ok(addr: u64, max_offset: u64) -> bool { +/// Compute a non-constraining hint (modular inverse / sqrt) with the same k256 +/// arithmetic the guest verifies against. Input/output are 32-byte big-endian, +/// k256's own serialization — unlike the ECSM ABI, which is little-endian because +/// its chip consumes little-endian limbs. The HINT table only copies these bytes +/// into memory writes, so the order is free to match the consumers. +/// +/// On a numeric failure (non-canonical input, no inverse/sqrt) returns zeros. This +/// is NOT a loud failure and must not be treated as one: the guest's in-circuit +/// verify rejects the value and recomputes it in software (see the `ethrex-crypto` +/// crate), so a zero/garbage hint only costs the guest extra work — it can never +/// change the guest's result. An *unknown* `hint_id` never reaches here: the ecall +/// dispatch rejects it up front with [`ExecutionError::HintUnknownSelector`], so the +/// `_` arm below is defensive only. +/// +/// `pub` so the prover's `collect_hint_ops` can reproduce the exact output value +/// the executor wrote to guest memory (the value is not carried in the CPU log). +pub fn compute_hint(hint_id: u64, in_be: &[u8; 32]) -> [u8; 32] { + use k256::elliptic_curve::PrimeField; + let mut fb = k256::FieldBytes::default(); + fb.copy_from_slice(in_be); + + match hint_id { + HINT_FIELD_INV => { + let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); + match x.and_then(|x| Option::::from(x.invert())) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } + } + HINT_SCALAR_INV => { + let x: Option = Option::from(k256::Scalar::from_repr(fb)); + match x.and_then(|x| Option::::from(x.invert())) { + Some(inv) => inv.to_bytes().into(), + None => [0u8; 32], + } + } + HINT_FIELD_SQRT => { + let x: Option = Option::from(k256::FieldElement::from_bytes(&fb)); + match x.and_then(|x| Option::::from(x.sqrt())) { + Some(r) => r.to_bytes().into(), + None => [0u8; 32], + } + } + _ => [0u8; 32], + } +} + +/// Checks that a 32-byte operand does not overflow its lower 32-bit address limb: +/// `(addr mod 2^32) + max_offset < 2^32`. Tables that send an address to the memory +/// bus as a `[lo32, hi32]` pair with the per-access offset added to `lo32` alone +/// cannot represent a carry into `hi32`, so an operand straddling the limb boundary +/// makes the trace unprovable. Used by the ECSM and Hint ecalls. +fn addr_limb_ok(addr: u64, max_offset: u64) -> bool { (addr % LOW_LIMB) + max_offset < LOW_LIMB } @@ -429,9 +525,9 @@ impl Instruction { let addr_xr = registers.read(10)?; let addr_xg = registers.read(11)?; let addr_k = registers.read(12)?; - if !ecsm_addr_ok(addr_xg, 31) - || !ecsm_addr_ok(addr_xr, 31) - || !ecsm_addr_ok(addr_k, 31) + if !addr_limb_ok(addr_xg, 31) + || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); } @@ -454,6 +550,42 @@ impl Instruction { src2_val = addr_xg; dst_val = addr_k; } + SyscallNumbers::Hint => { + // Non-constraining hint: host computes a modular inverse/sqrt + // and writes it to the guest, which verifies it (and falls back + // to software on failure). a0 = hint_id, a1 = input addr + // (32-byte BE), a2 = output addr. The `_le` helpers only move + // bytes in address order, which is what a raw big-endian buffer + // needs. + let hint_id = registers.read(10)?; + let in_addr = registers.read(11)?; + let out_addr = registers.read(12)?; + // Reject an unrecognized selector up front: an unknown `hint_id` + // would otherwise silently produce a zero output (see + // `compute_hint`), indistinguishable from a legitimate numeric + // failure. Fail loudly instead so a guest bug surfaces here. + if !is_valid_hint_selector(hint_id) { + return Err(ExecutionError::HintUnknownSelector(hint_id)); + } + // Both operands are bounded so their 32-byte ranges cannot cross the + // 2^32 limb boundary, and the HINT table range-checks both low limbs + // against the same bound (`HINT_ADDR_LIMB_BOUND`) so the AIR accepts + // exactly what this rejects. The memory bus does not do that job on + // its own: it bounds `out_addr` only to 2^32 - 25, because the write + // bases are `out_addr_lo + 8i` and MEMW's carry columns resolve the + // bytes past the largest base. `in_addr` is not on the bus at all + // (the input read is not modeled). Bounding both also keeps + // `load_u256_le`/`store_u256_le` from overflowing their address + // arithmetic. + if !addr_limb_ok(in_addr, 31) || !addr_limb_ok(out_addr, 31) { + return Err(ExecutionError::HintAddressOverflow); + } + let input = load_u256_le(memory, in_addr)?; + let output = compute_hint(hint_id, &input); + store_u256_le(memory, out_addr, &output)?; + src2_val = in_addr; + dst_val = out_addr; + } SyscallNumbers::Halt => { // halt return Ok(Log { @@ -634,6 +766,10 @@ pub enum ExecutionError { EcsmAddressOverflow, #[error("ECSM xG and k operand ranges overlap")] EcsmOperandOverlap, + #[error("Hint address range overflows the lower 32-bit limb")] + HintAddressOverflow, + #[error("Unknown hint selector: {0}")] + HintUnknownSelector(u64), #[error("ECSM scalar multiplication error: {0}")] Ecsm(#[from] ecsm::EcsmError), } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 985484c04..79ef4c715 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -53,8 +53,8 @@ use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, create_ecdas_air, - create_ecsm_air, create_eq_air, create_halt_air, create_keccak_air, create_keccak_rc_air, - create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, + create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, create_keccak_air, + create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, create_register_air, create_shift_air, create_store_air, }; @@ -82,8 +82,8 @@ pub struct RuntimePageRange { /// Number of tables that always contribute exactly one sub-proof, regardless /// of `TableCounts`: bitwise, decode, halt, commit, keccak, keccak_rnd, -/// keccak_rc, register, ecsm, ecdas. -pub const FIXED_TABLE_COUNT: usize = 10; +/// keccak_rc, register, ecsm, ecdas, hint. +pub const FIXED_TABLE_COUNT: usize = 11; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. @@ -522,6 +522,7 @@ pub(crate) struct VmAirs { pub keccak_rc: VmAir, pub ecsm: VmAir, pub ecdas: VmAir, + pub hint: VmAir, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -547,6 +548,7 @@ impl VmAirs { (self.keccak_rc.as_ref(), &mut traces.keccak_rc, &()), (self.ecsm.as_ref(), &mut traces.ecsm, &()), (self.ecdas.as_ref(), &mut traces.ecdas, &()), + (self.hint.as_ref(), &mut traces.hint, &()), (self.register.as_ref(), &mut traces.register, &()), ]; if self.include_halt { @@ -621,6 +623,7 @@ impl VmAirs { self.keccak_rc.as_ref(), self.ecsm.as_ref(), self.ecdas.as_ref(), + self.hint.as_ref(), self.register.as_ref(), ]; if self.include_halt { @@ -792,6 +795,7 @@ impl VmAirs { )); let ecsm: VmAir = Box::new(create_ecsm_air(proof_options)); let ecdas: VmAir = Box::new(create_ecdas_air(proof_options)); + let hint: VmAir = Box::new(create_hint_air(proof_options)); let register: VmAir = if let Some((commitment, num_preprocessed_cols)) = register_preprocessed { Box::new( @@ -912,6 +916,7 @@ impl VmAirs { keccak_rc, ecsm, ecdas, + hint, register, pages, memw_registers, diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 781bb02b0..fc4c2f976 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -188,6 +188,11 @@ pub struct CpuOperation { /// Whether this ECALL is an ECSM (elliptic-curve scalar multiply) syscall pub ecall_ecsm: bool, + + /// Whether this ECALL is a non-constraining Hint syscall. The hint operand + /// addresses (x10/x11/x12) are recovered from the register state in the trace + /// builder, exactly like ECSM. + pub ecall_hint: bool, } impl CpuOperation { @@ -235,6 +240,8 @@ impl CpuOperation { // in the trace builder. let ecall_ecsm = f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; + let ecall_hint = + f.ecall && log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; // Word instructions are fully handled by CPU32; the main CPU row is a // delegate that only advances the PC and sends the CPU32 lookup. We still @@ -353,6 +360,7 @@ impl CpuOperation { ecall_keccak, keccak_state_addr, ecall_ecsm, + ecall_hint, } } diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs new file mode 100644 index 000000000..cb1dab9f3 --- /dev/null +++ b/prover/src/tables/hint.rs @@ -0,0 +1,373 @@ +//! HINT table — receiver for the non-constraining `hint` ecall. +//! +//! The `hint` ecall (syscall `u64::MAX - 30`) lets the executor hand the guest a +//! value that is expensive to compute but cheap to verify (modular inverse, sqrt, +//! …); the guest verifies it with ordinary constrained instructions. Unlike a +//! normal `STORE`, the ecall writes the 32-byte output to guest memory *directly* +//! (not through the CPU load/store decode), so those writes are invisible to the +//! CPU op stream — this table is what puts them into the memory argument. +//! +//! The table therefore does exactly four things, and constrains **nothing** about +//! *which* value was hinted (that is the point — soundness lives in the guest's +//! verify). It does constrain *where* the value lands and that it is 32 bytes: +//! +//! 1. **Receives** the `Hint` ecall on the `Ecall` bus (balances the CPU's send; +//! a syscall with no receiver leaves the LogUp argument unbalanced). +//! 2. **Reads `x12`** (`a2`) through the memory argument, which pins `out_addr` to +//! the value the CPU had in that register. The writes below take their base from +//! an ordinary trace column, so without this read that column is free and the +//! witness chooses *where* the 32 bytes land — an arbitrary memory write, which +//! is a strictly larger hole than the unconstrained value. +//! 3. **Sends** the four 8-byte MEMW writes of the output at `out_addr` +0/8/16/24 +//! (received by the MEMW table). Without these the output's initial→final +//! memory chain is unexplained and the memory argument fails to balance. +//! 4. **Range-checks** the 32 output cells as bytes (`AreBytes`). MEMW does not +//! range-check what it receives, so each table that writes fresh values into +//! memory checks its own cells; skipping it lets the witness put arbitrary field +//! elements where loads and the ALU expect bytes. +//! +//! The input read (the ecall also reads `in_addr`) is intentionally **not** modeled: +//! a read leaves the value unchanged, the guest supplies the input via ordinary +//! stores, and nothing depends on the ecall having re-read it — so omitting it is +//! sound and avoids the mixed-timestamp bookkeeping of a partial-buffer read. +//! +//! `mu` is constrained to a bit (`IS_BIT`, the table's only algebraic constraint) — +//! the same guard every other multiplicity-column table carries (ECSM/ECDAS/COMMIT/ +//! STORE/MEMW_R). The `Ecall` bus alone does not establish it: its tuple carries the +//! timestamp, a free column, so the LogUp identity pins only the *sum* of `mu` over +//! the rows sharing a `(ts, syscall)` tuple to the CPU's send — it does not rule out +//! a witness that spreads `mu` across rows with integer weights summing to 1 (a `+1` +//! row plus a `+1`/`-1` pair, each keeping its own `out_addr`, the base the four +//! output writes take). MEMW does NOT catch this: it only ever receives the legal +//! `+1`, while the `-1` cancels an honest STORE on the sender side, so MEMW's own +//! multiplicity constraints stay satisfied and nothing downstream rejects it. The +//! `IS_BIT` on `mu` here is therefore load-bearing -- not a redundant restatement of +//! a check some other table performs. +//! +//! ## Columns (41) +//! - `timestamp[0..1]` (DWordWL): the ecall timestamp `T` +//! - `out_addr[0..1]` (DWordWL): base address of the 32-byte output buffer +//! - `out_bytes[0..31]`: the 32 output bytes (the hint) — **unconstrained** +//! - `mu`: multiplicity flag (1 = real hint call, 0 = padding) — gates every bus +//! - `selector[0..1]` (DWordWL): `a0`, bound to `x10` and range-checked `< 3` +//! - `in_addr[0..1]` (DWordWL): `a1`, bound to `x11`; its low limb is range-checked +//! so the ecall's input range cannot straddle the 32-bit limb boundary +//! +//! Both address low limbs are range-checked against [`HINT_ADDR_LIMB_BOUND`]; see that +//! constant for why the memory bus alone does not bound `out_addr` tightly enough. + +use executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use crate::constraints::templates::emit_is_bit; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; + +/// One past the largest valid hint selector (`a0 ∈ {0, 1, 2}` = FIELD_INV / SCALAR_INV / +/// FIELD_SQRT). Re-exported from the executor, which const-asserts that the bound and its +/// `is_valid_hint_selector` set coincide — so the AIR's range-check cannot drift from the +/// set the executor accepts. +pub use executor::vm::instruction::execution::HINT_SELECTOR_BOUND; + +/// Bound the low 32-bit limb of `in_addr` and `out_addr` must stay under so the +/// ecall's 32-byte range (`+0..+31`) cannot straddle the 2^32 limb boundary. Mirrors +/// the executor's `addr_limb_ok(addr, 31)`: `(addr % 2^32) + 31 < 2^32`, i.e. the +/// largest accepted limb is `2^32 - 32`. +/// +/// Both operands need this explicitly. `in_addr` because it is not on the memory bus +/// at all (the input read is not modelled). `out_addr` because the bus bounds it only +/// to `2^32 - 25`: the write bases are `out_addr_lo + 8i`, so the largest one +/// (`+24`) stops being a canonical limb at `2^32 - 24`, while MEMW's `carry` +/// columns resolve the *bytes* past it correctly. That left a seven-value window +/// (`2^32-31 ..= 2^32-25`) the AIR accepted and the executor rejected with +/// `HintAddressOverflow` — a prover could prove a hint call the VM halts on. +pub const HINT_ADDR_LIMB_BOUND: u64 = (1 << 32) - 31; + +pub mod cols { + /// timestamp[0]: lower 32 bits of the ecall timestamp + pub const TIMESTAMP_0: usize = 0; + /// timestamp[1]: upper 32 bits (always 0 — timestamps fit u32) + pub const TIMESTAMP_1: usize = 1; + /// out_addr[0]: lower 32 bits of the output base address + pub const ADDR_OUT_0: usize = 2; + /// out_addr[1]: upper 32 bits of the output base address + pub const ADDR_OUT_1: usize = 3; + /// out_bytes[0..31]: the 32 output bytes, one per column + pub const OUT: usize = 4; + /// multiplicity flag (1 = real hint call, 0 = padding) + pub const MU: usize = 36; + /// selector[0]: lower 32 bits of `a0` (the hint id) + pub const SEL_0: usize = 37; + /// selector[1]: upper 32 bits of `a0` + pub const SEL_1: usize = 38; + /// in_addr[0]: lower 32 bits of `a1` (the input base address) + pub const ADDR_IN_0: usize = 39; + /// in_addr[1]: upper 32 bits of `a1` + pub const ADDR_IN_1: usize = 40; + + pub const NUM_COLUMNS: usize = 41; + + /// Column of output byte `i` (0..32). + #[inline] + pub const fn out(i: usize) -> usize { + OUT + i + } +} + +/// One `hint` ecall: the timestamp, the output base address, and the 32 output +/// bytes the executor wrote to guest memory (recomputed by the trace builder). +#[derive(Debug, Clone)] +pub struct HintOperation { + pub timestamp: u64, + pub out_addr: u64, + pub out_bytes: [u8; 32], + /// `a0` — the hint selector, bound to `x10` and range-checked `< 3`. + pub hint_id: u64, + /// `a1` — the input base address, bound to `x11` and low-limb range-checked. + pub in_addr: u64, +} + +/// Generates the HINT trace: one row per hint-ecall call (in program order), +/// `mu = 1`; padding rows are all-zero (`mu = 0`, inert on the bus). Empty (all +/// padding) for programs that make no hint calls. +pub fn generate_hint_trace( + ops: &[HintOperation], +) -> TraceTable { + let num_rows = ops.len().next_power_of_two().max(4); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row, op) in ops.iter().enumerate() { + debug_assert!( + op.timestamp <= u32::MAX as u64, + "HINT timestamp {} exceeds u32", + op.timestamp + ); + table.set_dword_wl(row, cols::TIMESTAMP_0, op.timestamp); + table.set_dword_wl(row, cols::ADDR_OUT_0, op.out_addr); + table.set_bytes(row, cols::OUT, &op.out_bytes); + table.set_dword_wl(row, cols::SEL_0, op.hint_id); + table.set_dword_wl(row, cols::ADDR_IN_0, op.in_addr); + table.set_fe(row, cols::MU, FE::one()); + } + + trace +} + +// ========================================================================= +// Bus interactions +// ========================================================================= + +fn packed(col: usize) -> BusValue { + BusValue::Packed { + start_column: col, + packing: Packing::Direct, + } +} + +/// The eight output bytes of doubleword `chunk` (`out_bytes[8*chunk .. 8*chunk+7]`) +/// as MEMW value elements. +fn out_dword_bytes(chunk: usize) -> [BusValue; 8] { + std::array::from_fn(|b| packed(cols::out(8 * chunk + b))) +} + +/// A 16-element MEMW **write** tuple (CO25): `[is_register=0, base_lo, base_hi, +/// value[8], ts_lo, ts_hi, w2=0, w4=0, w8=1]`. The MEMW table supplies `old`. +fn memw_write(value: [BusValue; 8], base_lo: BusValue, base_hi: BusValue) -> Vec { + let mut v = Vec::with_capacity(16); + v.push(BusValue::constant(0)); // is_register = 0 (memory) + v.push(base_lo); + v.push(base_hi); + v.extend(value); + v.push(packed(cols::TIMESTAMP_0)); // ts_lo + v.push(packed(cols::TIMESTAMP_1)); // ts_hi + v.push(BusValue::constant(0)); // w2 + v.push(BusValue::constant(0)); // w4 + v.push(BusValue::constant(1)); // w8 = 1 (8-byte write) + v +} + +/// A 24-element MEMW **read** tuple (CO24) for a register: `[old[8], is_register=1, +/// base_lo=2*reg, base_hi=0, value[8], ts_lo, ts_hi, w2=1, w4=0, w8=0]`, with +/// `old == value` because a read leaves the register unchanged. Binds `x{reg}` to +/// the `(lo, hi)` column pair at the ecall timestamp. +fn memw_register_read(reg: u64, lo_col: usize, hi_col: usize) -> Vec { + let value = || [packed(lo_col), packed(hi_col)]; + let mut v = Vec::with_capacity(24); + v.extend(value()); // old[0..2] + v.extend(std::iter::repeat_n(BusValue::constant(0), 6)); // old[2..8] + v.push(BusValue::constant(1)); // is_register = 1 + v.push(BusValue::constant(2 * reg)); // base_address lo + v.push(BusValue::constant(0)); // base_address hi + v.extend(value()); // value[0..2] == old + v.extend(std::iter::repeat_n(BusValue::constant(0), 6)); // value[2..8] + v.push(packed(cols::TIMESTAMP_0)); + v.push(packed(cols::TIMESTAMP_1)); + v.push(BusValue::constant(1)); // w2 = 1 (register = 2 words) + v.push(BusValue::constant(0)); // w4 + v.push(BusValue::constant(0)); // w8 + v +} + +/// Bus interactions: +/// - **`Ecall` receiver** (mult `mu`): `[timestamp, cast(HINT_SYSCALL_NUMBER, +/// DWordWL)]` — HALT-shaped, balances the CPU's ECALL send. +/// - **MEMW register-read sender** (mult `mu`): binds `out_addr` to `x12`, the +/// ecall's `a2`. Without it the write addresses below are free columns, so a +/// witness could place the output bytes at any address it likes — an arbitrary +/// memory write, independent of whether the hinted *value* is constrained. +/// - **MEMW write senders** (mult `mu`, ×4): the four 8-byte writes of the output +/// at `out_addr` +0/8/16/24, timestamp `T`. Received by the MEMW table. +/// - **`AreBytes` senders** (mult `mu`, ×16): range-check the 32 output cells. +/// +/// - **MEMW register-read senders** (mult `mu`, ×2): bind `a0` (`x10`, the selector) +/// and `a1` (`x11`, the input address) to their register columns. +/// - **ALU `LT` senders** (mult `mu`, ×3): assert `selector < 3` and that both +/// `in_addr`'s and `out_addr`'s low limbs are `< 2^32 − 31`, matching the executor's +/// up-front rejections (`HintUnknownSelector`, `HintAddressOverflow`). Without them +/// the AIR would accept hints the executor rejects — a malicious prover could prove +/// an execution the VM would halt on. The value stays unconstrained (the guest +/// verifies it); this only pins the *operands* to the executor's accepted set. +pub fn bus_interactions() -> Vec { + let mu = || Multiplicity::Column(cols::MU); + let mut out = Vec::with_capacity(27); + + // ECALL receiver: [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. + out.push(BusInteraction::receiver( + BusId::Ecall, + mu(), + vec![ + packed(cols::TIMESTAMP_0), + packed(cols::TIMESTAMP_1), + BusValue::constant(HINT_SYSCALL_NUMBER & 0xFFFF_FFFF), + BusValue::constant(HINT_SYSCALL_NUMBER >> 32), + ], + )); + + // Bind out_addr to x12 (a2): without this the write base below is a free column. + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_register_read(12, cols::ADDR_OUT_0, cols::ADDR_OUT_1), + )); + + // Bind a0 (x10 = selector) and a1 (x11 = in_addr). Without these the range-checks + // below would constrain free columns instead of the registers the CPU held. + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_register_read(10, cols::SEL_0, cols::SEL_1), + )); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_register_read(11, cols::ADDR_IN_0, cols::ADDR_IN_1), + )); + + // ALU LT: selector < 3 (full 64-bit value), asserting the result is 1. A witness + // with an out-of-range selector has no matching LT row and unbalances the bus. + // ALU LT tuple (matching the LT table's receiver): `[lhs_lo, lhs_hi, rhs_lo, + // rhs_hi, op_encoding, result, 0]` — both operands are two elements (low, high + // 32-bit words), `op_encoding = LT` for an unsigned non-inverted compare, and + // `result = 1` asserts the strict inequality holds. + // + // selector < 3 (full 64-bit value: SEL_0/SEL_1). + out.push(BusInteraction::sender( + BusId::Alu, + mu(), + vec![ + BusValue::Packed { + start_column: cols::SEL_0, + packing: Packing::DWordWL, + }, + BusValue::constant(HINT_SELECTOR_BOUND), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + )); + + // in_addr's and out_addr's low limbs < 2^32 - 31, matching addr_limb_ok(addr, 31). + // The lhs high word is a literal 0, so only the low limb is compared — exactly the + // executor's check, which ignores the high limb. `out_addr` needs its own check even + // though it is on the memory bus: the bus only bounds it to 2^32 - 25 (see + // HINT_ADDR_LIMB_BOUND), leaving a window the executor rejects. + for addr_lo in [cols::ADDR_IN_0, cols::ADDR_OUT_0] { + out.push(BusInteraction::sender( + BusId::Alu, + mu(), + vec![ + packed(addr_lo), + BusValue::constant(0), + BusValue::constant(HINT_ADDR_LIMB_BOUND), + BusValue::constant(0), + BusValue::constant(alu_op::LT as u64), + BusValue::constant(1), + BusValue::constant(0), + ], + )); + } + + // write output: 4 doublewords at out_addr + 8i (timestamp T). + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_OUT_0, + }, + LinearTerm::Constant((8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_write(out_dword_bytes(i), base_lo, packed(cols::ADDR_OUT_1)), + )); + } + + // ARE_BYTES[out_bytes[2i], out_bytes[2i+1]]: the output cells are free columns + // that enter memory as MEMW write values, and MEMW range-checks nothing it + // receives. Every other table that puts fresh values into memory (STORE, KECCAK, + // ECSM, PAGE) range-checks its own cells for this reason: the value is allowed to + // be *wrong* here, but it must still be 32 bytes, or the witness can smuggle + // arbitrary field elements into memory and break the byte decomposition that + // loads and the ALU depend on. 16 sends, pairing cells as ECSM/KECCAK do. + for i in 0..16 { + out.push(BusInteraction::sender( + BusId::AreBytes, + mu(), + vec![packed(cols::out(2 * i)), packed(cols::out(2 * i + 1))], + )); + } + + out +} + +// ========================================================================= +// Single-source constraint set (ConstraintBuilder front-end) +// ========================================================================= + +/// The HINT table's single transition constraint: `mu·(1−mu) = 0`. +/// +/// `mu` is the multiplicity gating every one of this table's bus interactions +/// (the `Ecall` receive, the three register reads, the three `LT` range-checks, the +/// four output writes, the 16 byte range-checks). It must be boolean, or a witness +/// could put a non-`{0,1}` value on the `AreBytes`/MEMW sends. This is load-bearing, +/// not a redundant restatement of a bus check: the `Ecall` bus pins only the *sum* +/// of `mu` over the rows sharing a tuple — see the module-level docs for the +/// spread-multiplicity witness it rules out. +#[derive(Clone, Copy)] +pub struct HintConstraints; + +impl ConstraintSet for HintConstraints { + fn eval>(&self, b: &mut B) { + // idx 0: IS_BIT for mu. + emit_is_bit(b, 0, cols::MU, None); + } +} diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index 0a86e4149..f1a899f56 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -34,6 +34,7 @@ pub mod ecsm; pub mod eq; pub mod global_memory; pub mod halt; +pub mod hint; pub mod keccak; pub mod keccak_rc; pub mod keccak_rnd; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index f51b66166..29874caef 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -51,6 +51,7 @@ use super::ecdas; use super::ecsm; use super::eq; use super::halt; +use super::hint; use super::keccak::{self, KeccakOperation}; use super::keccak_rc; use super::keccak_rnd::{self, KeccakRoundOperation}; @@ -549,6 +550,7 @@ fn collect_ops_from_cpu( Vec, Vec, Vec, + Vec, ) { let mut memw = MemwBuckets::with_register_capacity(cpu_ops.len() * 3); let mut load_ops = Vec::with_capacity(cpu_ops.len() / 8 + 1); @@ -560,6 +562,7 @@ fn collect_ops_from_cpu( let mut cpu32_ops = Vec::new(); let mut ecsm_ops = Vec::new(); let mut ecdas_ops = Vec::new(); + let mut hint_ops = Vec::new(); // Seed from the carried x254 (0 for a monolithic run or the first epoch) so a // continuation epoch indexes its commits globally, matching the x254 the // register binding transports across epochs. Resetting to 0 here would drift @@ -654,6 +657,13 @@ fn collect_ops_from_cpu( ecdas_ops.extend(ecdas_rows); } + // Collect Hint ecall operations (the 32-byte output write). + if op.ecall_hint { + let (hint_memw, hint_op) = collect_hint_ops(op, memory_state, register_state); + memw.extend_ops(hint_memw); + hint_ops.push(hint_op); + } + // --- ALU chip dispatch (no state tracking) --- // Word (`*W`) instructions are delegated to CPU32 (which itself drives // the ALU chips); the main CPU does not send the ALU bus for them, so we @@ -709,6 +719,7 @@ fn collect_ops_from_cpu( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) } @@ -948,6 +959,81 @@ fn collect_ecsm_ops( (memw_ops, ecsm_op, ecdas_ops) } +/// Collects the memory operations for a `Hint` ecall. +/// +/// The `hint` ecall writes a 32-byte value (a modular inverse / sqrt) to guest +/// memory *directly* — bypassing the CPU load/store decode — so the trace builder +/// must reproduce that write itself: the value is not carried in the CPU log. We +/// re-derive the operand addresses from the register state (a0/a1/a2 = x10/x11/x12, +/// like ECSM), read the input from the replayed memory, recompute the output with +/// the executor's `compute_hint` (deterministic, same k256 arithmetic), then emit +/// four 8-byte MEMW writes at `out_addr` +0/8/16/24 and advance `memory_state`. +/// +/// The input read is intentionally not modeled (a read leaves the value unchanged; +/// the guest supplied the input via ordinary stores). The value itself is +/// unconstrained — soundness lives in the guest's in-circuit verify. +fn collect_hint_ops( + op: &CpuOperation, + memory_state: &mut MemoryState, + register_state: &mut RegisterState, +) -> (Vec, hint::HintOperation) { + let t = op.timestamp; + let hint_id = register_state.read(10).0; + let in_addr = register_state.read(11).0; + let out_addr = register_state.read(12).0; + + let mut memw_ops = Vec::with_capacity(7); + + // Bind a0/a1/a2 (x10/x11/x12) at ts through the memory argument. x12 ties the + // output-write base below to the ecall's a2; x10 (selector) and x11 (in_addr) pin + // the operands the HINT table range-checks against the executor's accepted set, so + // the AIR cannot prove a hint the executor would reject. All three are register + // reads (old == value; a read leaves the register unchanged). See `tables::hint`. + for (reg, value) in [(10u8, hint_id), (11, in_addr), (12, out_addr)] { + let reg_value = pack_register_value(value); + let (_old_val, old_ts) = register_state.read(reg); + memw_ops.push( + MemwOperation::new(true, 2 * reg as u64, reg_value, t, 2, true) + .with_old(reg_value, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), + ); + register_state.write(reg, value, t); + } + + // Read the 32-byte big-endian input from the replayed memory. + let mut input = [0u8; 32]; + for (i, b) in input.iter_mut().enumerate() { + *b = memory_state.read_byte(in_addr.wrapping_add(i as u64)).0; + } + + // Recompute the output exactly as the executor did (the value isn't in the log). + let out_bytes = executor::vm::instruction::execution::compute_hint(hint_id, &input); + + // Emit the 32-byte output as four 8-byte MEMW writes at ts = T. + for i in 0..4 { + let addr = out_addr.wrapping_add((8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + let byte = out_bytes[8 * i + j]; + value[j] = byte as u32; + dword |= (byte as u64) << (8 * j); + } + let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops + .push(MemwOperation::new(false, addr, value, t, 8, false).with_old(old_vals, old_ts)); + memory_state.write_bytes(addr, dword, 8, t); + } + + let hint_op = hint::HintOperation { + timestamp: t, + out_addr, + out_bytes, + hint_id, + in_addr, + }; + (memw_ops, hint_op) +} + /// Collects register read/write operations (M1, M3, M5) from CpuOperation, /// pushing them into `memw_ops`. fn collect_register_ops_from_cpu( @@ -2248,6 +2334,23 @@ fn collect_bitwise_from_commit(commit_ops: &[CommitOperation]) -> Vec Vec { + let mut lookups = Vec::with_capacity(16 * hint_ops.len()); + for op in hint_ops { + for i in 0..16 { + lookups.push(BitwiseOperation::byte_op( + BitwiseOperationType::AreBytes, + op.out_bytes[2 * i], + op.out_bytes[2 * i + 1], + )); + } + } + lookups +} + // ============================================================================= // BITWISE lookup helpers // ============================================================================= @@ -2767,6 +2870,9 @@ pub struct Traces { /// ECDAS double/add table (variable rows per ecall) pub ecdas: TraceTable, + /// HINT table (one row per non-constraining hint ecall). + pub hint: TraceTable, + /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -2809,6 +2915,8 @@ struct CollectedOps { // EC scalar-multiplication accelerator chips. ecsm_ops: Vec, ecdas_ops: Vec, + // Non-constraining hint ecall. + hint_ops: Vec, } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -2863,6 +2971,7 @@ fn collect_all_ops( cpu32_ops: Vec, ecsm_ops: Vec, ecdas_ops: Vec, + hint_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -3005,6 +3114,7 @@ fn collect_all_ops( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } } @@ -3048,6 +3158,7 @@ fn build_traces( cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, } = ops; // ===================================================================== @@ -3055,6 +3166,16 @@ fn build_traces( // ===================================================================== lt_ops.extend(collect_lt_from_memw(&memw_ops)); lt_ops.extend(collect_lt_from_memw_aligned(&memw_aligned_ops)); + // HINT range-checks: selector < 3 and both address low limbs < 2^32 - 31 (matching + // the executor's HintUnknownSelector / HintAddressOverflow rejections). Three LT ops + // per hint call; the HINT table sends the matching ALU LT interactions. + lt_ops.extend(hint_ops.iter().flat_map(|op| { + [ + LtOperation::new(op.hint_id, hint::HINT_SELECTOR_BOUND, false), + LtOperation::new(op.in_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), + LtOperation::new(op.out_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), + ] + })); // ===================================================================== // PHASE 4: All → Bitwise lookups @@ -3084,7 +3205,8 @@ fn build_traces( // chunk size used to split them into instances so multiplicities match the per-instance // sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G - // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL. + // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; + // HINT sends ARE_BYTES for its 32 output cells. // We never concatenate the lookups into one giant `Vec` (~140 M ops / // ~560 MB at 10-tx whose only consumer is the multiplicity count). Each collector bumps // the `BitwiseHistogram` it is handed: the heavy sources (MEMW_R one-per-row, PAGE @@ -3123,6 +3245,7 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_keccak(&keccak_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops))), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]; if let Some(image) = initial_image @@ -3409,6 +3532,8 @@ fn build_traces( // ECSM accelerator traces (empty/all-padding for programs that do not use ECSM). let gen_ecsm = || ecsm::generate_ecsm_trace(&ecsm_ops); let gen_ecdas = || ecdas::generate_ecdas_trace(&ecdas_ops); + // HINT table (all-padding for programs that make no hint ecalls). + let gen_hint = || hint::generate_hint_trace(&hint_ops); let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -3421,6 +3546,7 @@ fn build_traces( let (mut eqs_slot, mut bytewises_slot, mut stores_slot, mut cpu32s_slot) = (None, None, None, None); let (mut ecsm_slot, mut ecdas_slot) = (None, None); + let mut hint_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3462,6 +3588,7 @@ fn build_traces( spawn_into!(cpu32s_slot, gen_cpu32s); spawn_into!(ecsm_slot, gen_ecsm); spawn_into!(ecdas_slot, gen_ecdas); + spawn_into!(hint_slot, gen_hint); }); } else { cpus_slot = Some(gen_cpus()); @@ -3489,6 +3616,7 @@ fn build_traces( cpu32s_slot = Some(gen_cpu32s()); ecsm_slot = Some(gen_ecsm()); ecdas_slot = Some(gen_ecdas()); + hint_slot = Some(gen_hint()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3523,6 +3651,7 @@ fn build_traces( let mut halt_trace = halt_slot.expect(PHASE5_RAN); let ecsm_trace = ecsm_slot.expect(PHASE5_RAN); let ecdas_trace = ecdas_slot.expect(PHASE5_RAN); + let hint_trace = hint_slot.expect(PHASE5_RAN); // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -3590,6 +3719,7 @@ fn build_traces( keccak_rc: keccak_rc_trace, ecsm: ecsm_trace, ecdas: ecdas_trace, + hint: hint_trace, memw_registers, local_to_global, touched_memory_cells, @@ -3763,6 +3893,25 @@ pub fn count_table_lengths( .ok_or_else(|| Error::Execution("commit index exceeds u32 range".into()))?; } + if cpu_op.ecall_hint { + // Mirror `collect_hint_ops`: three register reads (a0/a1/a2) and four + // 8-byte output writes go through the memory argument, plus the three LT + // range-checks (selector < 3, in_addr and out_addr low limbs). Replaying it + // here keeps memory/register state in sync with generation, exactly like + // commit above. + let (hint_memw, _hint_op) = + collect_hint_ops(&cpu_op, &mut memory_state, &mut register_state); + for memw_op in &hint_memw { + partition_memw( + memw_op, + &mut memw_by_width, + &mut memw_aligned_count, + &mut memw_register_count, + ); + } + lt_count += 3; + } + // CPU-side per-instruction-kind counters (non-word; word → CPU32, B5b) let f = &cpu_op.decode.fields; if !f.word_instr && f.is_lt() { @@ -3852,6 +4001,7 @@ impl Traces { use super::ecsm::cols::NUM_COLUMNS as ECSM_COLS; use super::eq::cols::NUM_COLUMNS as EQ_COLS; use super::halt::cols::NUM_COLUMNS as HALT_COLS; + use super::hint::cols::NUM_COLUMNS as HINT_COLS; use super::keccak::cols::NUM_COLUMNS as KECCAK_COLS; use super::keccak_rc::NUM_PRECOMPUTED_COLS as KECCAK_RC_PRECOMPUTED; use super::keccak_rc::cols::NUM_COLUMNS as KECCAK_RC_COLS; @@ -3890,6 +4040,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -3957,6 +4108,7 @@ impl Traces { } total += (ecsm.num_rows() * ECSM_COLS) as u64; total += (ecdas.num_rows() * ECDAS_COLS) as u64; + total += (hint.num_rows() * HINT_COLS) as u64; total } @@ -3998,6 +4150,7 @@ impl Traces { let n_cpu32 = aux_cols(super::cpu32::bus_interactions().len()); let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); + let n_hint = aux_cols(super::hint::bus_interactions().len()); let Traces { cpus, @@ -4020,6 +4173,7 @@ impl Traces { keccak_rc, ecsm, ecdas, + hint, memw_registers, eqs, bytewises, @@ -4087,6 +4241,7 @@ impl Traces { } total += (ecsm.num_rows() * n_ecsm) as u64; total += (ecdas.num_rows() * n_ecdas) as u64; + total += (hint.num_rows() * n_hint) as u64; total } @@ -4440,6 +4595,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); #[cfg(feature = "instruments")] drop(__sp); @@ -4458,6 +4614,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, is_final, ); @@ -4551,6 +4708,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); let ops = collect_all_ops( @@ -4565,6 +4723,7 @@ impl Traces { cpu32_ops, ecsm_ops, ecdas_ops, + hint_ops, &mut register_state, true, ); diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index d7969612f..d6a8b8608 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -66,6 +66,9 @@ use crate::tables::ecsm::{ }; use crate::tables::eq::{EqConstraints, bus_interactions as eq_bus_interactions, cols as eq_cols}; use crate::tables::halt::{bus_interactions as halt_bus_interactions, cols as halt_cols}; +use crate::tables::hint::{ + HintConstraints, bus_interactions as hint_bus_interactions, cols as hint_cols, +}; use crate::tables::keccak::{ KeccakConstraints, bus_interactions as keccak_bus_interactions, cols as keccak_cols, }; @@ -894,6 +897,21 @@ pub fn create_halt_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + hint_cols::NUM_COLUMNS, + hint_bus_interactions(), + proof_options, + 1, + HintConstraints, + "HINT", + ) +} + /// Create COMMIT AIR with constraints and bus interactions. pub fn create_commit_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index a2863b2f0..a29a7cb49 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -181,4 +181,5 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air_device(&create_ecsm_air(&opts), "ECSM"); check_air_device(&create_ecdas_air(&opts), "ECDAS"); + check_air_device(&create_hint_air(&opts), "HINT"); } diff --git a/prover/src/tests/constraint_program_tests.rs b/prover/src/tests/constraint_program_tests.rs index 3ae46494d..e227da53d 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -179,4 +179,5 @@ fn all_table_programs_match_folders() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); + check_air(&create_hint_air(&opts), "HINT"); } diff --git a/prover/src/tests/constraint_set_tests_b.rs b/prover/src/tests/constraint_set_tests_b.rs index 0348c2b70..a7f68ecfd 100644 --- a/prover/src/tests/constraint_set_tests_b.rs +++ b/prover/src/tests/constraint_set_tests_b.rs @@ -299,3 +299,19 @@ mod cpu { check_table("cpu", &CpuConstraints, cols::NUM_COLUMNS); } } + +// ============================================================================= +// hint.rs +// ============================================================================= + +mod hint { + use super::*; + use crate::tables::hint::{HintConstraints, cols}; + + #[test] + fn hint_constraint_set_folder_capture_agree() { + // The one constraint is IS_BIT(mu): a single dense, idx-0, base-field root. + assert_eq!(HintConstraints.meta().len(), 1); + check_table("hint", &HintConstraints, cols::NUM_COLUMNS); + } +} diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index 6855fcb5b..7337f0790 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -3,16 +3,17 @@ use crate::tables::MaxRowsConfig; use crate::tables::trace_builder::{Traces, count_table_lengths}; use crate::test_utils::run_asm_elf; +use executor::elf::Elf; +use executor::vm::execution::Executor; +use executor::vm::logs::Log; -#[test] -fn count_table_lengths_matches_traces() { - let (elf, logs, _) = run_asm_elf("fib_iterative_372k"); +fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { let max_rows = MaxRowsConfig::default(); let predicted = - count_table_lengths(&elf, &logs, &max_rows, &[]).expect("count_table_lengths succeeds"); - let traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &max_rows, &[]) - .expect("trace build succeeds"); + count_table_lengths(elf, logs, &max_rows, &[]).expect("count_table_lengths succeeds"); + let traces = + Traces::from_elf_and_logs_minimal(elf, logs, &max_rows, &[]).expect("trace build succeeds"); let sum_heights = |tables: &[stark::trace::TraceTable<_, _>]| -> u64 { tables.iter().map(|t| t.main_table.height as u64).sum() @@ -91,3 +92,37 @@ fn count_table_lengths_matches_traces() { // Mirrors hardcoded `halt_rows = 1` in `auto_storage::table_specs`. assert_eq!(traces.halt.main_table.height, 1, "halt_rows"); } + +#[test] +fn count_table_lengths_matches_traces() { + let (elf, logs, _) = run_asm_elf("fib_iterative_372k"); + assert_count_table_lengths_matches(&elf, &logs); +} + +/// The `hint` ecall routes three register reads (`a0`/`a1`/`a2`) and four output +/// writes through the memory argument, plus two LT range-checks (selector, in_addr). +/// `count_table_lengths` must replay all of that exactly, or `memw_register` (an +/// exact-match table) drifts. Uses a real hint guest so the counts are non-trivial. +#[test] +fn count_table_lengths_matches_nonempty_hint_trace() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + let elf = Elf::load(&elf_bytes).expect("valid hint guest ELF"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("hint guest execution"); + + assert!( + result.logs.iter().any(|log| { + log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER + }), + "fixture must contain a hint ecall" + ); + assert_count_table_lengths_matches(&elf, &result.logs); +} diff --git a/prover/src/tests/hint_tests.rs b/prover/src/tests/hint_tests.rs new file mode 100644 index 000000000..479e7b001 --- /dev/null +++ b/prover/src/tests/hint_tests.rs @@ -0,0 +1,171 @@ +//! HINT constraint tests. + +use crate::tables::hint::{ + HINT_ADDR_LIMB_BOUND, HintConstraints, HintOperation, bus_interactions, cols, + generate_hint_trace, +}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField}; +use math::field::element::FieldElement; +use stark::constraints::builder::{ConstraintSet, ProverEvalFolder}; +use stark::frame::Frame; +use stark::lookup::{BusValue, LinearTerm}; +use stark::table::TableView; +use stark::traits::TransitionEvaluationContext; + +/// Evaluate the HINT constraint set on one main-trace row. +fn eval_main_row(main: Vec) -> Vec { + let n = HintConstraints.meta().len(); + let frame = Frame::::new(vec![TableView::new( + vec![main], + vec![vec![]], + )]); + let no_e: Vec> = vec![]; + let offset_e = FieldElement::::zero(); + let ctx = + TransitionEvaluationContext::new_prover(frame.as_row_frame(), &no_e, &no_e, &offset_e); + let mut base = vec![FE::zero(); n]; + let mut ext = vec![FieldElement::::zero(); n]; + let mut folder = ProverEvalFolder::new(&ctx, &mut base, &mut ext); + HintConstraints.eval(&mut folder); + base +} + +fn op(timestamp: u64, out_addr: u64) -> HintOperation { + HintOperation { + timestamp, + out_addr, + out_bytes: std::array::from_fn(|i| i as u8), + hint_id: 0, + in_addr: 0x3000, + } +} + +#[test] +fn constraint_set_count() { + assert_eq!(HintConstraints.meta().len(), 1); +} + +/// Every constraint holds on a generated trace — real rows (`mu = 1`) and the +/// all-zero padding rows (`mu = 0`) alike. +#[test] +fn constraints_hold_on_generated_trace() { + let trace = generate_hint_trace(&[op(4, 0x1000), op(8, 0x2000)]); + for row in 0..trace.num_rows() { + let main: Vec = (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(row, c)) + .collect(); + for (i, v) in eval_main_row(main).iter().enumerate() { + assert_eq!(*v, FE::zero(), "constraint {i} must hold at row {row}"); + } + } +} + +/// `IS_BIT(mu)` rejects a row whose multiplicity is not a bit. +/// +/// The `Ecall` bus does not establish this on its own: its tuple carries a +/// per-instruction timestamp, so LogUp pins the *sum* of `mu` over the rows sharing a +/// tuple, which a witness can satisfy by spreading `mu` across rows with integer +/// weights summing to 1 (the real exploit uses a `+1`/`-1` pair, not a fractional +/// split; MEMW does not catch it — it only sees the legal `+1`, the `-1` cancelling an +/// honest STORE). This constraint rejects any non-boolean `mu` locally. The test below +/// tampers with a fractional `1/2`, which `IS_BIT` also rejects. +#[test] +fn is_bit_mu_rejects_non_boolean_multiplicity() { + let trace = generate_hint_trace(&[op(4, 0x1000)]); + let mut main: Vec = (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(0, c)) + .collect(); + assert_eq!(main[cols::MU], FE::one(), "row 0 must be a real hint row"); + + // A halved multiplicity: 1/2 + 1/2 across two rows keeps the Ecall bus balanced. + let half = (FE::one() / (FE::one() + FE::one())).expect("2 is invertible"); + main[cols::MU] = half; + assert_ne!( + eval_main_row(main.clone())[0], + FE::zero(), + "IS_BIT(mu) must reject a fractional multiplicity" + ); + + // And any other non-bit value. + main[cols::MU] = FE::from(2u64); + assert_ne!( + eval_main_row(main)[0], + FE::zero(), + "IS_BIT(mu) must reject mu = 2" + ); +} + +/// The lhs column of an ALU `LT` sender, and the constant it is compared against. +fn alu_lt_senders() -> Vec<(usize, u64)> { + let id: u64 = BusId::Alu.into(); + bus_interactions() + .iter() + .filter(|i| i.is_sender && i.bus_id == id) + .map(|i| { + let lhs = match &i.values[0] { + BusValue::Packed { start_column, .. } => *start_column, + BusValue::Linear(_) => panic!("LT lhs must be a column, not a constant"), + }; + let bound = match &i.values[2] { + BusValue::Linear(terms) => match terms.as_slice() { + [LinearTerm::Constant(c)] => *c as u64, + _ => panic!("LT rhs must be a single constant"), + }, + BusValue::Packed { .. } => panic!("LT rhs must be a constant"), + }; + (lhs, bound) + }) + .collect() +} + +/// Both address low limbs are range-checked, not just `in_addr`. +/// +/// `out_addr` is on the memory bus, which is why it originally had no LT sender — but the +/// bus bounds it only to `2^32 - 25` (the largest write base is `out_addr_lo + 24`, and +/// MEMW's carry columns resolve the bytes past it), while the executor rejects anything +/// above `2^32 - 32`. Without this sender the AIR accepted the seven-value window in +/// [`addr_limb_bound_rejects_every_operand_the_executor_rejects`]. +#[test] +fn alu_lt_senders_range_check_selector_and_both_address_limbs() { + let senders = alu_lt_senders(); + assert_eq!(senders.len(), 3, "selector + in_addr + out_addr"); + + for col in [cols::ADDR_IN_0, cols::ADDR_OUT_0] { + let bound = senders + .iter() + .find_map(|(lhs, bound)| (*lhs == col).then_some(*bound)) + .unwrap_or_else(|| panic!("column {col} must have an ALU LT range-check")); + assert_eq!( + bound, HINT_ADDR_LIMB_BOUND, + "column {col} must be checked against the executor's bound" + ); + } +} + +/// The bound accepts exactly the operands `addr_limb_ok(addr, 31)` accepts. +/// +/// The seven values in `2^32-31 ..= 2^32-25` are the regression: the executor rejects +/// them with `HintAddressOverflow`, and before the `out_addr` sender existed the AIR +/// accepted them for the output address — a provable hint call the VM halts on. +#[test] +fn addr_limb_bound_rejects_every_operand_the_executor_rejects() { + // `addr_limb_ok(addr, 31)`: the 32-byte range must fit under 2^32. + let executor_accepts = |limb: u64| limb + 31 < (1 << 32); + // The AIR accepts iff the LT range-check passes. + let air_accepts = |limb: u64| limb < HINT_ADDR_LIMB_BOUND; + + for limb in (1u64 << 32) - 40..1u64 << 32 { + assert_eq!( + air_accepts(limb), + executor_accepts(limb), + "AIR and executor disagree on out_addr low limb {limb:#x}" + ); + } + + // The window that used to verify while the executor halted on it. + for limb in (1u64 << 32) - 31..=(1u64 << 32) - 25 { + assert!(!air_accepts(limb), "{limb:#x} must be rejected"); + } + // And the largest operand that must still run. + assert!(air_accepts((1 << 32) - 32)); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 2730a9d98..9288cf2ac 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -47,6 +47,8 @@ pub mod ecsm_tests; #[cfg(test)] pub mod eq_tests; #[cfg(test)] +pub mod hint_tests; +#[cfg(test)] pub mod ir_stats_dump; #[cfg(test)] pub mod keccak_rnd_tests; diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index b4ff5766c..29d224627 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -114,4 +114,5 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_keccak_rc_air(&opts), true, "KECCAK_RC"); assert_ood_window_matches_ir(&create_ecsm_air(&opts), true, "ECSM"); assert_ood_window_matches_ir(&create_ecdas_air(&opts), true, "ECDAS"); + assert_ood_window_matches_ir(&create_hint_air(&opts), true, "HINT"); } diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 7cd6c4e47..bbc8d2c63 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1212,6 +1212,334 @@ fn test_prove_ecsm_rust_guest() { ); } +/// End-to-end prove→verify for the non-constraining `Hint` ecall: the minimal Rust +/// guest does one `hint` call (secp256k1 base-field inverse of 3) and commits the result. +/// This exercises the whole HINT table bus surface (Ecall receive, the x10/x11/x12 +/// register reads, the two ALU `LT` operand range-checks, the four 8-byte output MEMW +/// writes and the output byte range-checks) end-to-end through prove→verify, de-risking +/// the bus balance before scaling to real consumers. The committed output must equal +/// the value the executor's `compute_hint` produced (= 3^{-1} mod p). +#[test] +fn test_prove_hint_min_rust_guest() { + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "hint_min rust guest should verify" + ); + + // Committed output must equal the hinted value (field inverse of 3, 32-byte BE). + let mut input = [0u8; 32]; + input[31] = 3; + let expected = + executor::vm::instruction::execution::compute_hint(0 /* HINT_FIELD_INV */, &input); + assert_eq!(proof.public_output, expected.to_vec()); +} + +/// Multi-hint: three `hint` ecalls, one per selector, each result read back with +/// ordinary `LOAD`s. Complements `test_prove_hint_min_rust_guest` by proving the +/// paths the ethrex consumer relies on that a single-call guest doesn't: **multiple +/// real HINT rows** (padded), **all three selectors** (so the AIR's `selector < 3` +/// range-check is exercised at every accepted value, not only at 0) and **read-back +/// via normal LOAD** (MEMW reads chaining to the HINT writes). Committed output = +/// XOR of the three hinted values. +#[test] +fn test_prove_hint_multi_rust_guest() { + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_multi.elf")) + .expect("hint_multi.elf not found — run `make compile-programs-rust`"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "hint_multi rust guest should verify" + ); + + // Expected = XOR of inv(3) mod p, inv(5) mod n and sqrt(4) mod p (32-byte BE), + // matching the guest's one-call-per-selector loop. + use executor::vm::instruction::execution::{ + HINT_FIELD_INV, HINT_FIELD_SQRT, HINT_SCALAR_INV, compute_hint, + }; + let mut expected = [0u8; 32]; + for (hint_id, seed) in [ + (HINT_FIELD_INV, 3u8), + (HINT_SCALAR_INV, 5u8), + (HINT_FIELD_SQRT, 4u8), + ] { + let mut input = [0u8; 32]; + input[31] = seed; + let out = compute_hint(hint_id, &input); + for i in 0..32 { + expected[i] ^= out[i]; + } + } + assert_eq!(proof.public_output, expected.to_vec()); +} + +/// Consistency: the verifier REJECTS a HINT row that disagrees with the +/// MEMW rows. +/// +/// The HINT table's `out_bytes` are unconstrained *by the table* — the point of a +/// non-constraining hint. Editing one output byte on the (single) real HINT row makes +/// the MEMW write it sends stop matching the write the MEMW table received (the honest +/// value `collect_hint_ops` derived), so the Memw LogUp bus unbalances and the proof +/// must fail to verify. +/// +/// What this covers is an *internally inconsistent* trace — the failure mode of a buggy +/// trace builder. It is **not** a forgery test: a prover that edits the HINT row and the +/// corresponding MEMW rows together satisfies every constraint, because nothing in the +/// AIR pins *which* value was hinted. That guarantee lives in the guest's verify +/// (`x·inv == 1`, `y² == x³+7`), which this minimal guest deliberately omits. What the +/// AIR does pin is *where* the value lands and that it is 32 bytes — see +/// `test_hint_binds_out_addr_to_x12` and `test_hint_range_checks_its_output_bytes`. +#[test] +fn test_prove_hint_min_inconsistent_output_rejected() { + use crate::tables::hint::cols as hint_cols; + + let _ = env_logger::builder().is_test(true).try_init(); + + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let executor = Executor::new(&elf, vec![]).expect("Failed to create executor"); + let result = executor.run().expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + + // Forge the low byte of the output on the (single) real HINT row. + let orig = *traces.hint.main_table.get(0, hint_cols::out(0)); + let forged = orig + FieldElement::::one(); + traces.hint.main_table.set(0, hint_cols::out(0), forged); + + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a forged hint output byte" + ); +} + +/// Load `hint_min` and build its minimal traces (for the operand-forgery tests below). +fn hint_min_traces() -> (Elf, Traces) { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/hint_min.elf")) + .expect("hint_min.elf not found — run `make compile-programs-rust`"); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let result = Executor::new(&elf, vec![]) + .expect("Failed to create executor") + .run() + .expect("Failed to run program"); + let traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +/// Soundness: the verifier REJECTS a HINT row whose selector is out of range. +/// +/// The executor rejects `hint_id ∉ {0,1,2}` up front (`HintUnknownSelector`). The AIR +/// now matches that: it binds the selector to `x10` and range-checks it `< 3`, so a +/// witness cannot prove a hint the executor would reject. Before `a0` was bound this +/// forgery verified. Forcing the selector to 3 (one past the valid set) unbalances both +/// the `x10` register read and the `LT(selector, 3)` interaction. +#[test] +fn test_prove_hint_min_forged_selector_rejected() { + use crate::tables::hint::cols as hint_cols; + let (elf, mut traces) = hint_min_traces(); + traces.hint.main_table.set( + 0, + hint_cols::SEL_0, + FieldElement::::from(3u64), + ); + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a hint with an out-of-range selector" + ); +} + +/// Soundness: the verifier REJECTS a HINT row whose input address would straddle the +/// 32-bit limb boundary — the executor rejects it (`HintAddressOverflow`), and the AIR +/// now binds `in_addr` to `x11` and range-checks its low limb `< 2^32 - 31`. Forcing +/// the low limb to `2^32 - 1` unbalances the `x11` read and the `LT` interaction. +#[test] +fn test_prove_hint_min_forged_input_address_rejected() { + use crate::tables::hint::cols as hint_cols; + let (elf, mut traces) = hint_min_traces(); + traces.hint.main_table.set( + 0, + hint_cols::ADDR_IN_0, + FieldElement::::from(0xFFFF_FFFFu64), + ); + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a hint whose input range crosses the limb boundary" + ); +} + +/// Column a bus value reads, for the structural HINT tests below. +fn hint_bus_column(v: &stark::lookup::BusValue) -> Option { + match v { + stark::lookup::BusValue::Packed { start_column, .. } => Some(*start_column), + stark::lookup::BusValue::Linear(_) => None, + } +} + +/// Constant a bus value holds, for the structural HINT tests below. +fn hint_bus_constant(v: &stark::lookup::BusValue) -> Option { + match v { + stark::lookup::BusValue::Linear(terms) => match terms.as_slice() { + [stark::lookup::LinearTerm::Constant(c)] => Some(*c), + _ => None, + }, + stark::lookup::BusValue::Packed { .. } => None, + } +} + +/// Soundness: the HINT table must bind its output address to `x12` (the ecall's `a2`). +/// +/// The four output writes take their base from `ADDR_OUT_0`, an ordinary column in a +/// table with no algebraic constraints, so the register read asserted here is the only +/// thing pinning that column to the register the CPU actually held. Without it the +/// witness chooses *where* the 32 hinted bytes land — an arbitrary memory write, which +/// is a strictly larger hole than the unconstrained value the table is designed around. +/// +/// Asserted structurally rather than by tampering: editing `ADDR_OUT_0` in a trace also +/// unbalances the honest MEMW rows, so a tamper test passes either way and would not +/// notice this interaction being dropped. +#[test] +fn test_hint_binds_out_addr_to_x12() { + use crate::tables::hint::{bus_interactions, cols as hint_cols}; + use crate::tables::types::BusId; + use stark::lookup::Multiplicity; + + let memw_id = u64::from(BusId::Memw); + let reads: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| i.bus_id == memw_id && i.is_sender && i.values.len() == 24) + .collect(); + assert_eq!( + reads.len(), + 3, + "HINT must send three MEMW register reads (a0 → x10, a1 → x11, a2 → x12)" + ); + // The out_addr binding is the x12 read (base address 2*12); the a0/a1 reads bind + // the selector and input address, checked by the range-check interactions. + let out_read = reads + .iter() + .find(|r| hint_bus_constant(&r.values[9]) == Some(2 * 12)) + .expect("HINT must send a MEMW register read for x12 (out_addr)"); + let v = &out_read.values; + + // CO24 read layout: old[8], is_register, base_lo, base_hi, value[8], ts_lo, ts_hi, + // w2, w4, w8. + assert_eq!(hint_bus_constant(&v[8]), Some(1), "is_register must be 1"); + assert_eq!( + hint_bus_constant(&v[9]), + Some(2 * 12), + "register address must be x12 (the ecall's a2)" + ); + assert_eq!(hint_bus_constant(&v[10]), Some(0), "address hi must be 0"); + assert_eq!( + hint_bus_constant(&v[21]), + Some(1), + "w2 must be 1 for a 2-word register access" + ); + for (slot, col) in [(0, hint_cols::ADDR_OUT_0), (1, hint_cols::ADDR_OUT_1)] { + assert_eq!( + hint_bus_column(&v[slot]), + Some(col), + "old[{slot}] must carry out_addr" + ); + assert_eq!( + hint_bus_column(&v[11 + slot]), + Some(col), + "value[{slot}] must carry out_addr (a read leaves the register unchanged)" + ); + } + // The read must happen at THE ecall's timestamp (ts_lo/ts_hi = slots 19/20). A + // register read bound to x12 but at some other timestamp would pin out_addr to + // whatever x12 held then, not at the ecall — the writes below all use the same + // TIMESTAMP columns, so the binding is only meaningful if it reads x12 at T. + assert_eq!( + hint_bus_column(&v[19]), + Some(hint_cols::TIMESTAMP_0), + "ts_lo must be the ecall timestamp (the read must occur at T)" + ); + assert_eq!( + hint_bus_column(&v[20]), + Some(hint_cols::TIMESTAMP_1), + "ts_hi must be the ecall timestamp (the read must occur at T)" + ); + assert!( + matches!(out_read.multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), + "the register read must be gated by mu, like every other HINT interaction" + ); +} + +/// Soundness: the HINT table must range-check all 32 output cells as bytes. +/// +/// The cells are free columns that enter memory as MEMW write values, and MEMW +/// range-checks nothing it receives — every table that writes fresh values into memory +/// (STORE, KECCAK, ECSM, PAGE) checks its own cells for that reason. The hinted value is +/// allowed to be wrong; it is not allowed to be a field element outside `[0, 256)`, or +/// the witness can smuggle non-bytes into memory and break the byte decomposition that +/// loads and the ALU rely on. +#[test] +fn test_hint_range_checks_its_output_bytes() { + use crate::tables::hint::{bus_interactions, cols as hint_cols}; + use crate::tables::types::BusId; + use stark::lookup::Multiplicity; + + let are_bytes_id = u64::from(BusId::AreBytes); + let checks: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| i.bus_id == are_bytes_id) + .collect(); + assert_eq!(checks.len(), 16, "32 output cells, paired two per lookup"); + + let mut covered = std::collections::BTreeSet::new(); + for check in &checks { + assert!(check.is_sender, "range checks are sends; BITWISE receives"); + assert_eq!(check.values.len(), 2, "ARE_BYTES takes exactly two values"); + assert!( + matches!(check.multiplicity, Multiplicity::Column(c) if c == hint_cols::MU), + "range checks must be gated by mu, or padding rows unbalance BITWISE" + ); + for v in &check.values { + covered + .insert(hint_bus_column(v).expect("a range check must reference an output column")); + } + } + + // 16 lookups × 2 slots = 32 slots; 32 distinct columns means each cell exactly once. + let expected: std::collections::BTreeSet = (0..32).map(hint_cols::out).collect(); + assert_eq!( + covered, expected, + "every output cell must be range-checked exactly once" + ); +} + /// Soundness: the verifier REJECTS a forged ECSM result. /// /// A malicious prover must not be able to claim a wrong `k·G`. We tamper the result diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 2cea4be1b..4446fb446 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -271,4 +271,5 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); + check_air(&create_hint_air(&opts), "HINT"); } diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 7165dff81..5228455ea 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -33,6 +33,16 @@ const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; +/// Syscall number for the non-constraining Hint ecall. +/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 30). +#[cfg(target_arch = "riscv64")] +const HINT_SYSCALL_NUMBER: usize = usize::MAX - 30; + +/// Hint selectors passed in `a0` (must match the executor's `HINT_*`). +pub const HINT_FIELD_INV: usize = 0; +pub const HINT_SCALAR_INV: usize = 1; +pub const HINT_FIELD_SQRT: usize = 2; + /// No-op. The `Print` ecall (a7=1) has no receiver on the Ecall bus, so emitting /// it makes the LogUp bus unbalance and the proof fail to verify. Printing isn't /// needed in provable programs, so `print_string` does nothing on every target. @@ -187,6 +197,32 @@ pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } +/// Ask the host for a non-constraining hint (modular inverse/sqrt). +/// `hint_id` selects the operation ([`HINT_FIELD_INV`]/[`HINT_SCALAR_INV`]/ +/// [`HINT_FIELD_SQRT`]); `input`/`out` are 32-byte **big-endian** field/scalar +/// elements — k256's own serialization, so consumers pass `to_bytes()` straight +/// through. Note this differs from [`ecsm_mul`], which is little-endian. +/// The result is UNTRUSTED — the caller MUST verify it in-guest (e.g. `x·inv == 1`) +/// AND recompute in software on failure, since this ecall adds no correctness +/// constraint and the prover chooses the returned bytes. +#[cfg(target_arch = "riscv64")] +pub fn hint(hint_id: usize, out: &mut [u8; 32], input: &[u8; 32]) { + unsafe { + asm!( + "ecall", + in("a0") hint_id, // x10 = hint selector + in("a1") input.as_ptr(), // x11 = input address (32-byte BE) + in("a2") out.as_mut_ptr(), // x12 = output address (32-byte BE) + in("a7") HINT_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn hint(_hint_id: usize, _out: &mut [u8; 32], _input: &[u8; 32]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + // ============================================================================= // Stub implementations for unsupported std functions // These functions are required by Rust's std zkvm module but are not supported diff --git a/tooling/ethrex-tests/Cargo.lock b/tooling/ethrex-tests/Cargo.lock index 250e2411f..4295b4402 100644 --- a/tooling/ethrex-tests/Cargo.lock +++ b/tooling/ethrex-tests/Cargo.lock @@ -875,6 +875,7 @@ name = "executor" version = "0.1.0" dependencies = [ "ecsm", + "k256", "rustc-demangle", "thiserror 1.0.69", ]