EF zkEVM Standards Conformance
ZisK aims to conform to the Ethereum Foundation zkEVM standards published by
the eth-act working group at
https://github.com/eth-act/zkevm-standards/tree/main/standards. These standards
define a common contract between a zkVM and the guest programs it proves — the
RISC-V target, the ELF it loads, the memory layout, the I/O and termination
interfaces, the C ABI for cryptographic accelerators (the EVM precompiles), the
C ABI for 256-bit EVM-word arithmetic, and host randomness —
so that a single guest can be built once and proven on any conforming zkVM.
This document records, standard by standard, how ZisK currently measures up.
Note: the EF standards target a RISC-V guest. ZisK proves RISC-V programs; the ZisK-assembly (
.zisk) routines mentioned below are ZisK's internal hand-written implementations that the guest's calls are turned into jumps to at transpile time — they are an implementation detail of how ZisK accelerates the standard interfaces, not something a conforming guest is written in.
How the standard interfaces are wired: zkvmcalls
The guest is compiled by an ordinary RISC-V toolchain that knows nothing about
ZisK. It must nonetheless call the standard symbols — zkvm_keccak256,
read_input, write_output, and so on. The real
implementations are hand-written .zisk files under ziskasm/zisklib/ (e.g.
zkvm/keccak.zisk). They are assembled — via include_str!, at ZisK build time —
into a reserved ROM/RAM region (ZISKLIB_ROM_ADDR, carved out of the address
space so it never collides with guest allocations) and merged into the guest's ROM.
The guest reaches them through zkvmcalls.
A zkvmcall takes one of two forms.
Thunk. Every function in zkvm_accelerators.h, zkvm_io.h and
zkvm_random.h, the division
family and exp of zkvm_u256.h and zkvm_u256_le.h, and every function in
ZisK's zkvm_zisklib.h, is a two-instruction thunk — for the C ABI in
ziskasm/lang/c/src/zkvm_calls.s, for Rust guests as naked functions in the
zisklib crate:
zkvm_modexp:
csrs 0x856, x0
ret
The CSR number identifies the function; the IDs live in
definitions/src/zkvmcall.rs (0x850..=0x8BF). The caller has already put the
arguments in a0..a7 and its return address in ra, following the RISC-V calling
convention. When elf2rom converts the ELF into a ZisK ROM, it replaces each
csrs <id>, x0 with a static tail-jump into the library routine. Because it is
a tail jump, ra is untouched, so the .zisk routine's own ret returns
straight to the guest's original caller; the thunk's ret never runs. The
transpiler finds zkvmcalls by instruction, not by symbol name, so the guest ELF
may be stripped.
Inline zkvmcall. By default the other zkvm_u256.h and zkvm_u256_le.h
functions (add, compare, shift, ...) are static inline asm in the header: a
sequence of csrs, the zkvmcall carrying argument 0 and one csrs 0x8E0 + k - 1
per further argument k, each naming the register the compiler chose for it.
elf2rom replaces the whole sequence with the routine's body, reading those
registers: no argument moves, no call and no return, and the constant ZKVM_EOK
status folds away. The body writes only memory and the virtual registers
r32..r39, so the asm clobbers no register. Each such zkvm_u256.h function also
has a thunk (used with ZKVM_U256_CALLS or by a declarations-only header), which
expands the same body; the inline zkvm_u256_le.h functions have none.
definitions/src/zkvmcall.rs marks which IDs are inline and explains the
sequence; the C binding's README lists
the builds of each family.
zkvm_keccak_f1600 is neither: it is a single keccak-f precompile, so the header
defines it inline (csrs 0x800, state) and it costs one instruction at the call
site.
Consequences worth knowing:
- A bad zkvmcall is a transpile error. An unknown or malformed zkvmcall makes
elf2romreject the ELF with a message naming it. - Link with
--gc-sections. Otherwise every thunk in the archive stays in the ELF, andelf2romassembles the library even for a guest that calls none of them.
A guest that uses no zkvmcall never gets the library: elf2rom then neither
assembles nor merges it, and the ROM is exactly what it would be without it.
How we test conformance: ziskethone (for now)
The EF standards are written around a C/C++ guest that calls the standard
interfaces (read_input/write_output, zkvm_accelerators.h, the standard
_start, etc.). Our long-term guest is evm-asm — an EVM client in RISC-V
assembly generated from LEAN code — but it is not ready yet.
Until then we exercise and validate the standards using ziskethone, the
evmone-based C++ block prover (cpp-guest). ziskethone is a real Ethereum
execution-layer client compiled to a RISC-V ELF, so it stresses every standard in
this document: it is loaded and validated by elf2rom, runs against our memory
layout and RISC-V target, reads its block input and writes the block hash through
the I/O interface, and — most importantly — routes all of its EVM precompiles
through the standard zkvm_accelerators.h C ABI. We validate correctness by
running ziskethone on real mainnet blocks through ziskemu and checking that the
computed block hash is byte-identical to the native (non-accelerated) reference.
When evm-asm matures it will replace ziskethone as the conformance vehicle, but
the standards it must satisfy are the ones tracked here.
Building and running the test
Two builds are involved: ZisK (to get ziskemu, which carries the elf2rom
transpiler and the zkvmcall table that maps the standard zkvm_* functions onto
the native .zisk accelerators) and ziskethone (the guest ELF,
built with the standard C ABI turned on).
1. Build ZisK (ziskemu) — from the zisk repository:
cargo build --release -p ziskemu --bin ziskemu # -> target/release/ziskemu
The ZisK library is always built in: every ziskemu and cargo-zisk handles
zkvmcall guests, in emulation, ROM generation and proving alike. (The ziskasm
features under test-artifacts/programs/ are unrelated: they switch those test
guests from the Rust zisklib to the ZisK library.)
2. Build the ziskethone guest — from the ziskethone repository, branch
feature/zkvm-abi. A RISC-V bare-metal C++ toolchain (xPack riscv-none-elf-g++
14.x) must be on PATH:
# once: fetch evmone and apply the branch's patches (host build, cpp-guest/build)
cmake -S cpp-guest -B cpp-guest/build
# the ZisK guest
cmake -S cpp-guest/zisk -B cpp-guest/zisk/build \
-DCMAKE_TOOLCHAIN_FILE=$(pwd)/cpp-guest/zisk/toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release
cmake --build cpp-guest/zisk/build -j8
# -> cpp-guest/zisk/build/zisk_eth_guest.elf
On this branch the guest always uses the zkVM interface; there is no switch to
turn it off. Every precompile, the EVM's 256-bit arithmetic, its memory operations
and its I/O call the standard functions (zkvm_keccak256, zkvm_u256_*,
read_input, ...), whose zkvmcalls elf2rom turns into the native .zisk
routines. EVM_BACKEND=zevm selects ziskethone's hand-written interpreter instead
of evmone (use a separate build directory); the guest's
cpp-guest/zisk/README.md
lists the other options and which ABI function each guest feature uses.
3. Run through ziskemu and read the public output (the 32-byte block hash):
ZE=<zisk>/target/release/ziskemu
GUEST=<ziskethone>/cpp-guest/zisk/build/zisk_eth_guest.elf
$ZE -e "$GUEST" -i <block-input>.bin -o /tmp/out.bin -X # -X prints step/cost stats
xxd -p -c32 /tmp/out.bin # the block hash
ziskemu expects the input framed as [u64 LE length][payload], where the payload
is ziskethone's block container (it starts with the ZEG0 magic). Framed inputs
live in the zisk-eth-client repo, e.g.
bin/guests/stateless-validator-ziskethone/inputs/*.bin.
4. Confirm conformance (A/B). Run the same block through ziskethone's native host build, which uses none of the zkVM interface, and check that it prints the same block hash:
cmake --build cpp-guest/build -j8 # the native guest
tail -c +9 <block-input>.bin > /tmp/blk.container # drop the 8-byte length frame
./cpp-guest/build/zisk_eth_guest /tmp/blk.container | tail -1
A byte-identical hash shows the zkVM interface produces exactly the native result.
The rarer precompiles, which real blocks may not reach, are covered by the
per-function golden-vector guests under ziskasm/zisklib/scripts/benchmark/.
Summary
Legend: Conformant — meets the normative requirements; Partial — meets the substance, with gaps or items still to confirm; To verify — believed to conform but not yet audited against the spec.
| # | Standard | Assessment | One-line status |
|---|---|---|---|
| 1 | C interface for accelerators | Conformant | All 19 zkvm_* functions implemented natively in .zisk; runtime-validated on real blocks via ziskethone. |
| 1b | U256 arithmetic C interface | Conformant | All 27 zkvm_u256_* functions implemented natively in .zisk over the shared uint256 precompile cores; golden-vector-validated against an EVM reference. |
| 2 | Accelerated memory operations | Conformant | All four mem* accelerated via DMA precompiles in ziskos and in the packaged C archive; memmove shares memcpy's DMA op, which is overlap-safe in the emulator and the prover; the C archive's definitions sit in _start's object, so they win symbol resolution regardless of link order. |
| 3 | ELF loading and validation | Conformant | elf2rom enforces header, PT_LOAD-only loading, zero-fill, W^X and entry-point validation. |
| 4 | I/O interface | Conformant | read_input / write_output implemented in the ZisK library, reached through zkvmcalls. |
| 5 | Memory layout restrictions | Conformant | Standard is non-prescriptive; ZisK ships a vendor linker script defining its map. |
| 6 | Memory safety guard regions | Conformant | Null region and the span below the stack bottom are both unmapped and trap; the guard is adjacent with no gap, named in core/src/mem.rs and enforced by compile-time assertions. |
| 7 | RISC-V target | Partial | RV64IMA, little-endian, LP64, unaligned access supported and counted/priced by ziskemu stats; compressed (C) feature implemented (off by default). The observability wording is now resolved: it requires a per-proof count via CLI/log, so two changes remain — emit that count from the proving path (the tally already exists in MemCounters), and stage unaligned precompile operands into aligned buffers. |
| 8 | Standard termination semantics | To verify | main return maps to halt + host report; exact exit-code propagation to confirm. |
| 9 | Static library and linker script | Conformant | package.sh stages libzisklib_c.a (_start incl. C++ ctors/dtors, I/O, all accelerators) + headers + the W^X linker script exporting _heap_start/_heap_end; verified by linking C and C++ guests against the installed artifacts alone. |
| 10 | Instruction-address-misaligned semantics | Conformant | At the default IALIGN=32 (compressed off): misaligned entry points and executable-segment starts rejected at load; a misaligned computed jump hits its own emu_end slot in the per-byte jump map (rounding impossible by construction) and exits with end=0; the Rust emulator panics. |
| 11 | Host randomness | Conformant | zkvm_random_u64 returns the SHAKE256 stream of a 256-bit seed drawn per execution from the OS CSPRNG (or provided with --random-seed to replay a run); the guest cannot set or observe it. Not available with hints. Standard still a draft (eth-act PR #42). |
1. C interface for accelerators
Standard. Defines the portable C API (zkvm_accelerators.h) through which a
guest reaches the zkVM's optimized implementations of the EVM precompiles.
Byte-encoded (big-endian) field elements, a zkvm_status return, byte-struct
operands.
ZisK. This is the standard we implement most completely. All 19 functions
of zkvm_accelerators.h are implemented as hand-written ZisK-assembly routines
under ziskasm/zisklib/zkvm/*.zisk, exposed under the exact ziskasm_zkvm_*
entry points and reached from a guest through the zkvmcalls of the standard zkvm_*
functions:
- Hashes:
zkvm_keccak256,zkvm_sha256,zkvm_ripemd160 - secp256k1:
zkvm_secp256k1_verify,zkvm_secp256k1_ecrecover - secp256r1 (P-256):
zkvm_secp256r1_verify - Modular exponentiation:
zkvm_modexp - BLAKE2:
zkvm_blake2f - BN254 (alt_bn128):
zkvm_bn254_g1_add,zkvm_bn254_g1_mul,zkvm_bn254_pairing - BLS12-381:
zkvm_bls12_g1_add,zkvm_bls12_g2_add,zkvm_bls12_g1_msm,zkvm_bls12_g2_msm,zkvm_bls12_pairing,zkvm_bls12_map_fp_to_g1,zkvm_bls12_map_fp2_to_g2 - KZG (EIP-4844):
zkvm_kzg_point_eval
Each routine performs the byte↔limb marshalling required by the EF encoding
(including the BN254 EIP-197 imaginary-first Fp2 order and the BLS12-381 packed
48-byte fields) and returns ZKVM_EOK. The header and the zkvmcall thunks live in
ziskasm/lang/c/.
Validation. Beyond per-function golden-vector tests, the full ABI was wired into ziskethone and run on three real mainnet blocks; the block hash is byte-identical to the native software path in every case, exercising keccak/sha256/secp256k1 and BN254 end-to-end on-chain traffic.
Assessment: Conformant.
1b. U256 arithmetic accelerator C interface
Standard. A companion header (zkvm_u256.h) in the same
c-interface-accelerators family, defining accelerated 256-bit unsigned-integer
(EVM word) arithmetic: the arithmetic, comparison, bitwise and shift operations
that back the EVM opcodes. Every operand and result is a 32-byte big-endian
array (zkvm_u256, reusing zkvm_bytes_32); the result pointer may alias any
input; division/modulo by zero and addmod/mulmod with a zero modulus return
zero (EVM semantics), and mulmod must reduce the full 512-bit product.
ZisK. All 27 functions of zkvm_u256.h are implemented as hand-written
ZisK-assembly routines in ziskasm/zisklib/zkvm/u256.zisk, exposed under the
ziskasm_zkvm_u256_* entry points and reached from a guest through the zkvmcalls
of the standard zkvm_u256_* functions:
- Arithmetic:
add,sub,mul,div,mod,divmod,addmod,mulmod,exp - Signed (two's complement):
sdiv,smod,sdivmod - Comparison:
lt,gt,slt,sgt,eq,iszero - Bitwise / shifts:
and,or,xor,not,byte,shl,shr,sar - Extended:
signextend
Rather than a separate bignum implementation, these wrappers map onto the shared
ZisK arithmetic precompiles already used by ziskos: they byte-reverse each
big-endian operand to the little-endian limbs the precompiles expect and reuse the
uint256/*.zisk cores — add256 (add/sub), arith256 (mul, and the div_rem256
quotient/remainder hint-verify), arith256_mod (addmod/mulmod/exp) — then
byte-reverse the result back. Bitwise and equality operations are byte-order
agnostic and act on the raw words directly (no marshalling). The EVM edge cases
the cores don't cover are added in the wrappers: a zero divisor/modulus yields
zero (instead of the core's panic), and the signed operations go through
absolute-value plus sign (so -2^255 / -1 = -2^255 falls out naturally). Because
all inputs are read into private scratch before the output is written, the
result-aliases-input guarantee holds. The header and zkvmcall thunks live
in ziskasm/lang/c/ (zkvm_u256.h, zkvm_calls.s) and the zisklib Rust crate.
Validation. A generated guest exercises all 27 functions (45 cases including
the div/mod/addmod/mulmod-by-zero guards, cross-word shifts, sar sign-fill and
shift >= 256, byte >= 32, signextend, and the signed min-int case) and every
result is byte-identical to a Python EVM reference.
Assessment: Conformant.
2. Accelerated memory operations
Standard. Acceleration of memcpy / memmove / memset / memcmp is
optional. If a zkVM provides them, they must be behaviorally identical to libc
for all inputs and alignments (including n == 0), assume no alignment, and be
guaranteed to win symbol resolution in the guest link (strong runtime definition
or --whole-archive; link order alone does not conform).
ZisK. ZisK accelerates bulk memory operations through dedicated DMA
precompiles (dma_memcpy, dma_memcmp, dma_xmemset). The guest runtime
defines memcpy/memmove/memcmp/memset as assembly thunks whose
CSR 0x813/0x814/0x816 + add/addi pattern the transpiler lowers to a single
DMA op. The Rust runtime has them in ziskos/entrypoint/src/dma/*.s, the packaged
C archive in ziskasm/lang/c/src/_start.s, and ziskethone vendors its own copies
(built with -fno-builtin so the calls stay out-of-line). Arbitrary alignment
and n == 0 are handled, and the DMA circuits prove the aligned, unaligned and
partial head/tail cases (precompiles/dma/).
memmove. memmove uses the same CSR 0x813 op as memcpy. That op has
memmove semantics: when source and destination overlap, the emulator copies
through a temporary buffer (Mem::memcpy in core/src/mem.rs), and the prover
constrains the overlapping case too (confirmed with the DMA precompile
developers). One DMA op is therefore correct for every overlap direction.
Link precedence. The thunks are strong global definitions.
- ziskos (the mandatory Rust guest runtime): they override
compiler_builtins' weak byte-loop fallbacks. - Packaged C archive (§9,
libzisklib_c.a): the thunks are in the same object as_start, not in archive members of their own.ENTRY(_start)pulls that object into every guest, so the accelerated definitions are always linked, whatever the link order. If a libc earlier on the command line also contributes itsmem*, the link fails with a multiple-definition error instead of silently keeping the byte loop. A separate archive member would lose silently in that case, since it is only pulled when the symbol is still undefined. Both outcomes were checked against a stand-in libc archive. A runtime test linked only against the installed package checks overlappingmemmovein both directions,n == 0, unalignedmemcpy,memsetwith zero and non-zero fill, and the sign ofmemcmpunderziskemu. Each call executes as one DMA op. - ziskethone links its copies as object files, so they are always part of the link.
Assessment: Conformant (all four operations accelerated, memmove overlap-safe
end to end, and precedence independent of link order).
3. ELF loading and validation
Standard. The loader must validate the ELF header (\x7fELF, ELFCLASS64,
little-endian, EM_RISCV, ET_EXEC), load only from PT_LOAD program headers,
zero-fill p_memsz > p_filesz, keep all addresses in range, reject
misaligned/overlapping segments, enforce W^X (reject PF_W|PF_X; executable
segments only PF_X or PF_X|PF_R), and validate the entry point (aligned,
inside a loaded executable segment). Invalid ELFs must be rejected with
diagnostics before any state reaches the prover.
ZisK. transpilers/riscv/src/elf2rom.rs (with the elf_extraction module)
builds the ROM exclusively from PT_LOAD segments, zero-fills BSS, checks every
segment/address lies within the ZisK addressable space (and errors otherwise,
e.g. the PT_LOAD 0x0-0x0 rejection), and validates the entry point
(validate_entry_point): non-zero e_entry, aligned to IALIGN (4 bytes by
default, 2 with the compressed feature — see §10), inside a loaded
executable segment — with an explicit diagnostic instructing the user to declare
ziskos::entrypoint!. Executable segments are PF_X/PF_X|PF_R; a PF_X|PF_R
segment currently produces a performance warning (allowed by the standard),
and the guest linker script emits a clean W^X layout (see §9).
Assessment: Conformant (header-field strictness, e.g. rejecting a non-RISC-V
e_machine, is worth an explicit audit but the substantive checks are in place).
4. I/O interface
Standard. Must provide void read_input(const uint8_t** buf_ptr, size_t* buf_size) (returns a read-only pointer + length to the private input; never
fails; idempotent) and void write_output(const uint8_t* output, size_t size)
(successive calls concatenate into the public result; never fails).
ZisK. Both functions are implemented in the ZisK library (zisklib_read_input,
zisklib_write_output) and reached through zkvmcalls (0x863, 0x864). Input is exposed at the memory-mapped
free-input region (INPUT_ADDR = 0x1000_0000); output is written to the public
output region (OUTPUT_ADDR = 0xa041_0000). Reads are non-failing and side-effect
free; successive write_output calls concatenate. C guests get both from
zkvm_io.h and the zkvmcall thunks in
ziskasm/lang/c/src/zkvm_calls.s, linked via zisklib_c (§9).
Assessment: Conformant.
5. Memory layout restrictions
Standard. Intentionally non-prescriptive: vendors define their own memory map via a vendor-specific linker script rather than a single standardized map. The requirement is that a vendor provides such a script (and, when linking against libc, defines heap boundaries and region demarcations), and that programs are linked with it.
ZisK. ZisK defines its map in core/src/mem.rs and ships the matching guest
linker script (ziskbuild/zisk_linker_script.ld):
| Region | Address |
|---|---|
| Free input | 0x1000_0000 (len 0x8000_0000) |
| ROM (program) | 0x9000_0000 (len 0x0800_0000) |
| RAM base / stack | 0xa000_0000 (stack 4 MB) |
| System reserved | 0xa040_0000 |
| Public output | 0xa041_0000 |
| RAM top | 0xc000_0000 |
Assessment: Conformant (ZisK supplies and requires its own vendor linker script, exactly as the standard intends).
6. Memory safety guard regions
Standard. Two mandatory guard regions whose access must abnormally terminate:
a null-pointer trap over 0x0000–0x0FFF (unmapped), and a stack guard
of at least 4 kB immediately below the stack bottom, contiguous with no gap.
ZisK. Both guard regions are satisfied by the address map, and the invariant is now enforced rather than incidental.
Null-pointer trap. The lowest data area is the input window at
INPUT_ADDR = 0x1000_0000. Below it there is only the BIOS ROM (0x1000–0x0fff_ffff),
which holds instructions and is never a data area: no Mem or RomData section covers
any address below INPUT_ADDR. So a load or store anywhere in 0x0000–0x0FFF fails the
Mem section bounds check and aborts, and since no instruction lives there either, a jump
into it aborts too.
Stack guard. The stack bottom is STACK_ADDR = RAM_ADDR = 0xa000_0000 — the
lowest mapped RAM address — with the stack growing down from 0xa040_0000. ROM ends
at 0x97ff_ffff, so the span 0x9800_0000–0x9fff_ffff (128 MB) is unmapped. That
region therefore begins at the byte immediately below the stack bottom, which is
exactly the standard's "contiguous and adjacent to the bottom of the stack with no
gap between them", and it is far larger than the required 4 kB.
core/src/mem.rs names the region — STACK_GUARD_ADDR = ROM_ADDR + ROM_SIZE,
STACK_GUARD_SIZE = STACK_ADDR - STACK_GUARD_ADDR (128 MB) — with
STACK_GUARD_MIN_SIZE recording the 4 kB the standard demands. The assembly
emulator's GUARD_ADDR/GUARD_SIZE are the same span, derived the same way, so the
two cannot drift.
The invariant is enforced at compile time: the stack must start at RAM_ADDR, the
guard must be adjacent to it, at least STACK_GUARD_MIN_SIZE, and the input window
must stay below the program ROM, which ends where the guard starts. Each failure mode was checked by making the change and observing
the build stop — growing ROM_SIZE until under 4 kB remains, growing it past
RAM_ADDR (which underflows the size computation and fails const evaluation),
widening MAX_INPUT_SIZE, and moving STACK_ADDR off the RAM base.
ziskasm::assemble_library additionally rejects a rom_base outside ROM or a
ram_base below RAM_ADDR, since those bases are caller-supplied and a library
placed in the guard would defeat it.
Host-level reservation (assembly emulator). The Rust emulator validates every
address against its section bounds in software, so an access to the guard aborts
regardless of the host. The assembly emulator does not: it maps the guest regions
MAP_FIXED at their real addresses and relies on the host MMU, so a guest access
traps only because nothing is mapped there. An unmapped hole is exactly where the
kernel may satisfy a later mmap(NULL, …) or a large malloc in the same process —
and if anything landed in the span, a guest access into the guard would silently
succeed.
server_setup() (emulator-asm/src/server.c) therefore reserves the whole span
GUARD_ADDR..RAM_ADDR (0x9800_0000–0x9fff_ffff, 128 MB) as
PROT_NONE | MAP_FIXED | MAP_NORESERVE: the range is claimed so nothing else can be
placed in it, any access faults, and MAP_NORESERVE keeps it off the commit charge
(a single VMA, no backing pages). Measured before and after in a standalone harness:
an allocation targeting the middle of the span succeeds without the reservation
and is refused with it, and an access immediately below the stack bottom raises
SIGSEGV.
Verified. Writes at 0x0, 0x800 and 0xfff (the null region, at both ends and
the middle) and at 0x9fff_fff8 and 0x9fff_f000 (immediately below the stack bottom
and 4 kB below it) each abort with
Mem::write_silent() invalid addr=… write section start=a0000000 end=c0000000, and
no output file is produced — matching the standard's Detection and Reporting
clause that execution halts, failure is reported, and no valid proof of successful
execution may be generated.
Assessment: Conformant.
7. RISC-V target
Standard. Base RV64I; MUST support M and Zicclsm (misaligned loads/stores to main memory); MUST NOT support C (compressed) or F/D (floating point); little-endian; LP64 soft-float ABI; flat memory, no MMU; statically linked ELF; machine mode only. Zicclsm additionally requires the zkVM to expose visibility into the number of unaligned accesses during proving.
ZisK. ZisK targets RV64IMA — it provides the required M extension
(plus A, atomics, which are trivially satisfied on the single-threaded
machine), is little-endian, uses the LP64 soft-float ABI, has a flat
no-MMU memory model, and loads statically linked ELFs in machine mode. Guests are
compiled -march=rv64ima (no F/D). Unaligned loads/stores are supported (the
library and guests rely on them, e.g. unaligned 64-bit absorbs in keccak).
Unaligned-access visibility. Zicclsm requires the zkVM to expose how many
unaligned accesses a run performed. ziskemu already does — it counts and prices
every memory access, classifying it by shape, in
emulator/src/stats/mem_operations_stats.rs (MemoryOperationsStats). Because
RISC-V registers are memory-mapped in ZisK, this covers register accesses too.
Each access to a known region (RAM-stack, RAM-non-stack, ROM, INPUT) is tagged as
aligned 8B or one of the unaligned shapes, and both a count and a cost
are accumulated per shape and per region:
| shape | meaning |
|---|---|
aligned 8B | 8-byte access on an 8-byte boundary — the only aligned case |
unaligned 1B | single-byte access (writes split clean/dirty) |
unaligned 2B single / 2B double | 2-byte access within one / straddling two 8-byte rows |
unaligned 4B single (32-align vs not) / 4B double | 4-byte access, within one row / straddling two |
unaligned 8B double | 8-byte access straddling two rows |
("double" = the access crosses an 8-byte boundary and so touches two memory rows;
it is the case that costs an extra memory row in the proof, which is why the split
matters. The two widest double cases also set a MEM_ACCESS_MONITOR flag so the
emulator can log them with full execution context.)
The totals are read back through accessors such as get_unaligned_count() /
get_unaligned_cost() (grand totals) and their per-region variants
(get_ram_unaligned_count, get_rom_unaligned_count, get_input_unaligned_count,
get_ram_stack_…, get_ram_no_stack_…), each with an _aligned_ counterpart.
They surface in the statistics report:
| flag | what it adds |
|---|---|
-X / --stats | full statistics report (opcodes + memory usage) — carries the memory data |
--mem-stats | the MEM COST BY TYPE section |
--mem-full-stats | the DETAILED MEM COST section: per-region/per-shape count+cost rows (e.g. RAM STACK unaligned 8B double read) plus TOTAL unaligned 1B/2B/4B/8B rollups (requires -X) |
--save-stats / --ref-stats | snapshot the numbers to a file and diff two runs |
(-x / --legacy-stats is the older step/usage report and does not carry this
breakdown; -m / --log-metrics is performance metrics.)
Running a guest with those flags produces (excerpt — the MEM COST BY TYPE and
DETAILED MEM COST sections, from a ZisK guest that does heavy byte↔limb
marshalling):
$ ziskemu -e guest.elf -i input.bin -o out.bin -X --mem-stats --mem-full-stats
...
MEM COST BY TYPE COUNT % COST %
------------------------------------------------------------------------
RAM STACK ALIGNED 194 0.92% 3,298 0.69%
RAM NO STACK ALIGNED 1,644 7.77% 28,006 5.84%
RAM INIT 13,960 65.98% 251,280 52.38%
ROM ALIGNED 366 1.73% 5,124 1.07%
ROM INIT 2,956 13.97% 41,384 8.63%
INPUT ALIGNED 47 0.22% 1,363 0.28%
RAM NO STACK UNALIGNED 1,990 9.41% 149,220 31.11%
ROM UNALIGNED 1 0.00% 39 0.01%
-----------------------------------------------
TOTAL ALIGNED 19,167 90.59% 330,455 68.89%
TOTAL UNALIGNED 1,991 9.41% 149,259 31.11%
DETAILED MEM COST COUNT % COST %
---------------------------------------------------------------------------------------------
RAM NO STACK unaligned 1B read 1,443 6.82% 59,163 12.33%
RAM NO STACK unaligned 4B single 32-align read 45 0.21% 5,490 1.14%
RAM NO STACK unaligned 1B clean write 97 0.46% 6,402 1.33%
RAM NO STACK unaligned 4B single 32-align clean write 304 1.44% 58,672 12.23%
RAM NO STACK unaligned 4B single 32-align dirty write 101 0.48% 19,493 4.06%
ROM unaligned 1B read 1 0.00% 39 0.01%
-----------------------------------------------
TOTAL unaligned 1B 1,541 7.28% 65,604 13.68%
TOTAL unaligned 4B 450 2.13% 83,655 17.44%
...
DETAILED OFFSET BYTE MEMORY OPERATIONS
--------------------------------------
offset 0 1 2 3 4 5 6 7 total
reads 180 180 180 181 180 181 182 180 1,444
clean writes 12 12 12 12 12 11 9 17 97
dirty writes 0 0 0 0 0 0 0 0 0
TOTAL UNALIGNED (both COUNT and COST) is the headline figure; the
DETAILED MEM COST rows attribute it by region and access shape, and the final
DETAILED OFFSET BYTE MEMORY OPERATIONS table shows the distribution across the
eight byte offsets within an 8-byte word. (RAM INIT / ROM INIT are the
one-time zero-initialization accounting, not run-time accesses.)
Precompile operand alignment. The memory-based precompiles (keccak, sha256,
the arithmetic/EC precompiles, …) require their operand buffers to be aligned
— they read and write the state as aligned words, not through the byte-granular
unaligned path. It is therefore the caller's responsibility to pass aligned
pointers: when a guest's argument is not aligned, the calling code must copy it
into a temporary aligned buffer, invoke the precompile on that buffer, and copy
the result back. This keeps the precompiles on the fast aligned path and means an
unaligned guest argument never reaches a precompile directly. (The .zisk
accelerator routines that marshal the EF byte-array ABI already stage their
operands in library-owned, aligned scratch buffers, so this holds for the
standard zkvm_* entry points.)
Compressed instructions. The misaligned-instruction semantics of §10 are
defined for IALIGN = 32 (no C), and the ZisK transpiler is able to decode
2-byte (compressed) instructions. As agreed with the EF, this is now controlled by
a dedicated compressed cargo feature (off by default) that gates RVC decoding
in both the RISC-V decoder and the transpiler (zisk-riscv:
riscv_interpreter.rs / riscv2zisk_context.rs), propagated up the build chain the
same way float is (through zisk-transpiler-common/-riscv, zisk-rom-setup,
zisk-prover-backend, and the ziskemu/cargo-zisk binaries):
- Without the feature (the default), the
Cextension is disabled: a 2-byte parcel is an illegal instruction and the run halts with error (CHalt), so ZisK isIALIGN = 32and matches the standard's "MUST NOT support C". - With
--features compressed, RVC instructions decode and execute normally, which is the configuration that fully meets a guest built forrv64imac.
This was validated end-to-end: an rv64ima (no-C) guest runs identically with and
without the feature, an rv64imac (with-C) guest is rejected (halt-with-error)
by a default ziskemu and runs correctly under ziskemu --features compressed.
Observability: what the standard actually requires. The open question we carried here — whether an offline, emulator-reported count satisfies "during proving" — is settled by the standard's own wording (standards/riscv-target/target.md):
"zkVMs must provide visibility into the number of unaligned memory accesses that occur during proof generation." At minimum, they should "expose a count of unaligned accesses per proof through command-line output or log files, though more granular metrics are encouraged."
So the bar is explicitly CLI or log output, per proof. There is no requirement for
the count to be an in-circuit quantity or a prover-internal artifact — our earlier
reading was stricter than the text. But it does rule out what we ship today:
ziskemu -X --mem-stats is a separate, offline invocation, not per-proof output
from proof generation.
The count already exists on the proving path. MemCounters tallies
mem_align_counters.{full_5, full_3, full_2, read_byte, write_byte}
(state-machines/mem-common/src/mem_counters.rs) unconditionally — it has to, because
that is how the MemAlign AIRs are sized — and a MemAlign operation is an unaligned
access. The existing mem_align_stats cargo feature gates only the per-instance
printing, not the counting. The totals are therefore available for free at planning
time, on both paths: MemPlanner::plan() (state-machines/mem/src/mem_planner.rs),
which receives every chunk's counters and is driven from
emulator-asm/asm-runner/src/asm_mo_runner.rs, and gpu_count_and_plan.rs, which
already aggregates the same five values for the GPU path.
TODO 1 — report the count per proof. Sum the five shapes across all chunks and emit one line per proof, at a stable and greppable format, from both planning paths. It must not sit behind a cargo feature: the standard says MUST, so it has to be on by default. The per-shape breakdown comes for free and covers the "more granular metrics are encouraged" clause. The exact log format becomes a documented interface the EF may check against, so it should be fixed here once chosen.
TODO 2 — align precompile operands. The memory-based precompiles require aligned
operand buffers (see Precompile operand alignment above), and today that is stated
as the caller's responsibility. The .zisk accelerators already stage the EF
byte-array ABI into aligned library scratch, so the standard zkvm_* entry points
hold; the gap is the zkvm_zisklib_* functions and direct zisklib calls from Rust guests,
where an unaligned guest pointer can reach a precompile. Make those call sites copy
into a local aligned buffer when the argument is not aligned, and copy back. Note
this costs a branch — and, when unaligned, a copy — on the common aligned path, so it
should be measured against the keccak_ab/modexp_ab dual-backend benchmarks.
Assessment: Partial (base + M + little-endian + LP64 + unaligned support are met
and the compressed feature is implemented; the observability question is now
resolved against us — the required per-proof CLI/log output is TODO 1 — and TODO 2
remains).
8. Standard termination semantics
Standard. Successful termination halts with a complete/valid trace and a
provable execution; main returning 0 is success, non-zero is abnormal
termination carrying that value as the error code. Failure halts and reports the
code to the host; the verifier either rejects failed-execution proofs (Type 1) or
accepts only proofs matching the expected code (Type 2). Runtimes must map
language-level aborts (Rust panic, C abort()) onto this interface.
ZisK. ZisK programs run from the boot thunk to a defined ROM exit; a normal
return halts the machine and produces the public output, and the ziskos
runtime maps panics/aborts to a halt. The zero/non-zero success convention and
end-to-end propagation of a non-zero main exit code to the host/verifier layer
should be audited against this standard.
Assessment: To verify (halt-and-report is in place; exact exit-code propagation and the verifier-side Type 1/Type 2 handling to be confirmed).
9. Static library and linker script
Standard. The vendor must ship a .a providing _start (stack/global-pointer
/ I/O init, C++ constructors, then main), the I/O functions, all accelerator
functions, and a GNU ld/LLD-compatible linker script that sets _start as the
entry, enforces W^X (executable .text*/.init/.fini separate from read-only
.rodata*), and exports _heap_start/_heap_end. main is int main(void).
ZisK. ZisK provides the ziskos runtime (entry _start, boot/IO setup) for
Rust guests and, for the EF C ABI, the ziskasm/lang/c binding. CMakeLists.txt
builds the whole guest-side surface into one static library, zisklib_c:
| Requirement | Provided by |
|---|---|
_start (gp/sp init, C++ constructors, main, destructors, termination) | src/_start.s |
| I/O functions | src/zkvm_calls.s + include/zkvm_io.h |
Host randomness (zkvm_random_u64) | src/zkvm_calls.s + include/zkvm_random.h |
| Accelerator functions | src/zkvm_calls.s (19 zkvm_* + 27 zkvm_u256_*; zkvm_keccak_f1600 is inline in the header), plus ZisK's 24 zkvm_zisklib_* |
Every entry is a zkvmcall thunk that the transpiler turns into a jump to the
hand-written .zisk routine, so a C guest compiles against the headers, links
zisklib_c, and runs the ziskasm implementations. main is int main(void).
_start walks [__init_array_start, __init_array_end) before main and
(__fini_array_end, __fini_array_start] after it, so a C++ guest's static
constructors run in priority order and its destructors in reverse — the standard
requires _start to do this, and the linker script's KEEP'd .init_array is
inert without it. Verified with a guest carrying two constructors and two
destructors: the emitted order is 01 02 5A 82 81.
There is a single guest linker script, ziskbuild/zisk_linker_script.ld
(embedded for Rust guests via ZISK_LINKER_SCRIPT, and referenced directly by the C
example). It sets ENTRY(_start) and lays out clean W^X segments via PHDRS:
text FLAGS(1) — execute-only, the form ZisK prefers for performance and the
stricter of the two the standard permits — with rodata FLAGS(4) and data/bss
FLAGS(6), and it KEEPs .init_array/.fini_array for C++ ctors/dtors.
The script exports _heap_start / _heap_end as required. ZisK's own allocator
consumes the same bounds under its historical names (_heap_bottom / _heap_top,
with _heap_size), so the standard names are added alongside rather than renaming
them, leaving existing consumers untouched.
The two standard names are assigned unconditionally, not with PROVIDE. This
matters: PROVIDE only materializes a symbol that some input object references,
and a consumer that discovers the heap bounds by reading the ELF symbol table never
references them at link time — so under PROVIDE they are absent from .symtab for
exactly the guests the requirement exists to serve. Verified with a guest that does
not mention either name: both appear in nm output with the correct addresses.
Packaging. ziskasm/lang/c/package.sh builds the distributable artifact with a
cross toolchain file (zisk-guest-toolchain.cmake) and stages it:
dist/include/{zisklib.h,zkvm_accelerators.h,zkvm_io.h,zkvm_random.h,zkvm_u256.h}
dist/lib/libzisklib_c.a
dist/share/zisk/zisk_linker_script.ld
The archive and the script are installed together because the archive's _start
depends on symbols only the script defines. It is built rv64ima, not rv64imac,
so it contains no 16-bit encodings and is usable by a default (IALIGN = 32) ZisK —
verified by disassembling the archive. Verified end to end by linking both a C guest
and a C++ guest with a static object against only the installed artifacts, with
nothing from the source tree: the C guest reports 08 01 aa 5a (read_input,
heap bounds, write_output) and the C++ guest c7 5a (constructor, then main).
One property to communicate with the artifact: it is not standalone-functional. Every accelerator and I/O function in it is a zkvmcall thunk, so a guest linked against it runs only under ZisK. A clean link proves nothing on its own.
Assessment: Conformant (_start incl. C++ constructors/destructors, the I/O
functions, every accelerator, a W^X linker script exporting _heap_start/_heap_end,
and a build that stages them as a distributable .a + script).
10. Instruction-address-misaligned exception semantics
Standard. When the ISA would raise an instruction-address-misaligned
exception (a jump/branch to a misaligned target), the zkVM must abnormally
terminate — no recovery or continuation, and specifically no rounding the
target down to an aligned address. Applies to RISC-V without the C extension
(IALIGN = 32).
ZisK. ZisK has a compressed cargo feature, off by default (§7), that
controls whether the C extension is decoded. With it off ZisK is IALIGN = 32, so
"misaligned" means pc % 4 != 0, and the requirement is met at load time and on both
runtime paths.
Load time. validate_entry_point (§3) rejects an entry point that is not
IALIGN-aligned, and requires the same of every executable segment's start — the
transpiler decodes instructions from a segment's first byte, so a misaligned start
would desynchronize the whole segment. The alignment constant tracks the feature
(4 without compressed, 2 with), so the loader enforces IALIGN = 32 exactly when
the ISA is at IALIGN = 32.
Runtime — x86-64 assembly emulator (production). Computed jumps resolve through
the map_pc_* branch table, which is indexed per byte:
[map_pc_80000000 + (pc - ROM_ADDR)*8], one .quad per byte address
(core/src/zisk_rom_2_asm.rs). Every slot that is not a valid instruction address —
which includes every misaligned address — holds .quad emu_end instead of a real
target. A misaligned target therefore has its own slot and lands on the exit
path; it cannot alias onto a nearby valid entry, so rounding down is impossible by
construction rather than by check. The exit is also unsuccessful: end = 1 is
written only when executing an instruction explicitly marked as terminating, so
leaving through a map slot leaves end = 0, distinguishable from a normal exit.
Runtime — Rust emulator (-l only). ZiskRom::get_instruction(pc)
(core/src/zisk_rom.rs) routes a misaligned pc to rom_program_na_instructions,
the non-aligned instruction table. Without compressed the transpiler never emits a
non-aligned instruction, so that table is empty (num_program_na_instructions == 0); any misaligned pc fails its bounds check and panics, aborting immediately
rather than rounding, continuing, or fabricating an instruction.
This guarantee is contingent on
compressedbeing off. With the C extension enabled the non-aligned table is sized to the largest non-aligned address and pre-filled withZiskInst::default(), so a misalignedpcbelow that bound would return a fabricated default instruction instead of aborting. That is the correct behavior atIALIGN = 16, where those addresses are legal — but it means the misaligned-abort semantics described here apply specifically atIALIGN = 32, i.e. with the default feature set.
Assessment: Conformant at the default IALIGN = 32: a misaligned entry point or
executable segment start is rejected at load time; at runtime a misaligned computed
jump exits through emu_end with end = 0 in production and panics in the Rust
emulator. No rounding, no continuation, no successful termination.
11. Host randomness
Standard. Draft, eth-act/zkevm-standards PR #42
(standards/host-randomness). The vendor static library must export
uint64_t zkvm_random_u64(void) (zkvm_random.h). Each call returns 64 uniformly
distributed bits, independent of every other call, and cannot fail. The host draws
them from a cryptographically secure pseudorandom number generator, independently
for each execution, so the author of the guest input cannot predict them. They are
free witness values with no attestation: for a fixed input, the guest's public output
and exit code must be the same for every sequence the function can return (that part
is the guest's job). The host may force the values to replay a run; the guest has no
seeding function and cannot tell whether they were forced. The motivation is hash-map
seeding: without it, one set of colliding keys found offline would slow down every
prover running the same guest.
ZisK. Every execution has a 256-bit seed, and zkvm_random_u64 returns the
SHAKE256 output stream of the seed's 32 bytes, read as consecutive little-endian
64-bit words.
- The seed. It is decided once per task, before any emulator runs, because every
emulation of the task (emulator threads, the ASM services, MPI ranks, distributed
workers) must see the same values. It is drawn from the OS CSPRNG unless the host
provides one with
--random-seed/ZISK_RANDOM_SEED(every command that runs a guest) to replay a run; every run reports the seed it used, and a remote coordinator accepts a client random seed only if configured to allow it (common/src/seed.rs,RandomSeed). - Reaching the emulators. The executor hands the seed to the active emulator: the
Rust emulator in
EmuOptions.random_seed, the ASM services through the control-input shared memory (words 4..7 hold the seed, word 8 is set once it is written), read when each execution starts. Standalone,ziskemutakes or draws the seed itself (-vprints it). - The guest side.
zkvm_random_u64is a zkvmcall thunk (0x8B3) toziskasm_zkvm_random_u64(ziskasm/zisklib/zkvm/random.zisk). On the first call it reads the seed with theFCALL_RANDOM_SEEDfcall (27) and absorbs it as SHAKE256 does; then each call returns the next word, and every 17 words one keccak-f precompile squeezes the next block. The common case costs 5 steps. Rust guests get the same routine throughziskos(zkvm_random_u64) orzisklib::random_u64(); it is also exported bylibziskos.aandzisklib_c. - No seed, no values. An emulator that was given no seed fails the fcall (and so the execution) rather than fall back to a fixed default.
The guest cannot reach the seed: there is no seeding function, and nothing in its inputs or memory reveals whether the seed was forced.
Not with hints. A guest that uses zkvm_random_u64 cannot be proven with
precompile hints. The hints come from a separate native run of the guest, which would
draw other values than the prover's, so the precompile calls it records could differ
from the ones the prover executes. This is enforced in code: the hints setup of the
ASM emulator rejects such a guest (HINTS_WITH_RANDOM_ERROR), and in a hints build the
native zkvm_random_u64 panics.
Validation. emulator/tests/zkvm_random.rs runs a committed guest
(elf-regressions/zkvm_random/, from ziskasm/lang/c/example/zkvm_random_guest.c)
that draws 500 values and checks in the guest that they are distinct and that every
bit varies. The test compares the output with SHAKE256 of the seed computed by Python's
hashlib, covers the minimal-trace path and the hints rejection, and checks that a
missing seed fails. The same guest was run through the SDK with provided seeds on both
the Rust and the ASM emulator, and through the ASM server/client with the seed passed
in the control input.
Assessment: Conformant. The interface, the per-execution unpredictable seed and replay without guest visibility are all in place. Two caveats:
- The standard's goal of following a recognized random bit generation specification cites NIST SP 800-90A. SHAKE256 keyed with a 256-bit seed is a standard XOF at 256-bit security, but it is not one of the 800-90A DRBG constructions.
- A guest that uses it cannot use hints (see above).
Roadmap
- Confirm the Partial/To verify items above (the explicit stack-guard region; whether
ziskemu's offline unaligned-access count satisfies the EF "during proving" wording; exit-code propagation;_heap_*symbols and the packaged.a). - Track the host randomness draft (eth-act PR #42) until it is merged, and revisit the generator if the standard ends up requiring an SP 800-90A DRBG.
- Replace ziskethone with evm-asm as the conformance vehicle once it is ready, re-running the same real-block validation against the C ABI.