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 elf2rom reject the ELF with a message naming it.
  • Link with --gc-sections. Otherwise every thunk in the archive stays in the ELF, and elf2rom assembles 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.

#StandardAssessmentOne-line status
1C interface for acceleratorsConformantAll 19 zkvm_* functions implemented natively in .zisk; runtime-validated on real blocks via ziskethone.
1bU256 arithmetic C interfaceConformantAll 27 zkvm_u256_* functions implemented natively in .zisk over the shared uint256 precompile cores; golden-vector-validated against an EVM reference.
2Accelerated memory operationsConformantAll 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.
3ELF loading and validationConformantelf2rom enforces header, PT_LOAD-only loading, zero-fill, W^X and entry-point validation.
4I/O interfaceConformantread_input / write_output implemented in the ZisK library, reached through zkvmcalls.
5Memory layout restrictionsConformantStandard is non-prescriptive; ZisK ships a vendor linker script defining its map.
6Memory safety guard regionsConformantNull 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.
7RISC-V targetPartialRV64IMA, 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.
8Standard termination semanticsTo verifymain return maps to halt + host report; exact exit-code propagation to confirm.
9Static library and linker scriptConformantpackage.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.
10Instruction-address-misaligned semanticsConformantAt 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.
11Host randomnessConformantzkvm_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 its mem*, 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 overlapping memmove in both directions, n == 0, unaligned memcpy, memset with zero and non-zero fill, and the sign of memcmp under ziskemu. 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):

RegionAddress
Free input0x1000_0000 (len 0x8000_0000)
ROM (program)0x9000_0000 (len 0x0800_0000)
RAM base / stack0xa000_0000 (stack 4 MB)
System reserved0xa040_0000
Public output0xa041_0000
RAM top0xc000_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:

shapemeaning
aligned 8B8-byte access on an 8-byte boundary — the only aligned case
unaligned 1Bsingle-byte access (writes split clean/dirty)
unaligned 2B single / 2B double2-byte access within one / straddling two 8-byte rows
unaligned 4B single (32-align vs not) / 4B double4-byte access, within one row / straddling two
unaligned 8B double8-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:

flagwhat it adds
-X / --statsfull statistics report (opcodes + memory usage) — carries the memory data
--mem-statsthe MEM COST BY TYPE section
--mem-full-statsthe 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-statssnapshot 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 C extension is disabled: a 2-byte parcel is an illegal instruction and the run halts with error (CHalt), so ZisK is IALIGN = 32 and 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 for rv64imac.

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:

RequirementProvided by
_start (gp/sp init, C++ constructors, main, destructors, termination)src/_start.s
I/O functionssrc/zkvm_calls.s + include/zkvm_io.h
Host randomness (zkvm_random_u64)src/zkvm_calls.s + include/zkvm_random.h
Accelerator functionssrc/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 compressed being off. With the C extension enabled the non-aligned table is sized to the largest non-aligned address and pre-filled with ZiskInst::default(), so a misaligned pc below that bound would return a fabricated default instruction instead of aborting. That is the correct behavior at IALIGN = 16, where those addresses are legal — but it means the misaligned-abort semantics described here apply specifically at IALIGN = 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, ziskemu takes or draws the seed itself (-v prints it).
  • The guest side. zkvm_random_u64 is a zkvmcall thunk (0x8B3) to ziskasm_zkvm_random_u64 (ziskasm/zisklib/zkvm/random.zisk). On the first call it reads the seed with the FCALL_RANDOM_SEED fcall (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 through ziskos (zkvm_random_u64) or zisklib::random_u64(); it is also exported by libziskos.a and zisklib_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.