feat(syscalls): add the EF IO write_log symbol (no-op) per zkvm-standards#27 - #916
feat(syscalls): add the EF IO write_log symbol (no-op) per zkvm-standards#27#916MauroToscano wants to merge 3 commits into
write_log symbol (no-op) per zkvm-standards#27#916Conversation
zkvm-standards PR #27 adds a third function to the IO interface we already implement and cite at the top of `ef_io.rs`: void write_log(const uint8_t* utf8_bytes, size_t utf8_len) It is a diagnostic (`println`-style) channel: the text does not contribute to the public output, is not part of the statement being proven, and the standard says calls "may be ignored by production proving environments that do not expose diagnostics". Implement it as a no-op that emits no ecall. The natural lowering would be the `Print` ecall (a7=1), but that ecall has no receiver on the Ecall bus, so emitting it unbalances the LogUp argument and every proof using it fails to verify — the same reason `syscalls::print_string` is a no-op. Exporting the symbol means portable applications that call `write_log` link and run unchanged; ignoring the text is conforming.
`write_log` is dead-stripped from every guest that does not call it: `#[unsafe(no_mangle)]` in an rlib prevents mangling and makes the symbol visible in that crate's object, but it is not a link root, so `--gc-sections` drops the unreferenced section. Confirmed by disassembly — before this commit the symbol is absent from `ef_io_demo.elf` while `read_input`/`write_output` are present, precisely because those two are called. So nothing demonstrated the only thing the previous commit adds: that a portable guest calling `write_log` links and runs. Call it once from the demo, which already exercises the other two functions of the interface. The symbol now appears in the artifact at 4 bytes (a single `ret`), and the guest's `ecall` count is unchanged, so the call adds no bus interaction.
The module doc claimed three functions match the linked standard, but that README currently documents only read_input and write_output -- write_log lives in the unmerged eth-act/zkevm-standards#27. Cite the PR explicitly so the claim is accurate today and so a signature change before merge is visible rather than silent drift.
|
Reviewed adversarially — independent prosecutor and defense, both building guest artifacts rather than reading source. Both returned REFUTED: no harm mechanism could be closed. Two things worth adding to the description above, from the prosecutor's pass: The GC result holds under the shipped guest's real profile. I quoted the default build; it was also verified under A sensitivity control I had not thought to ask for: a comment-only edit to the same file produces a bit-for-bit identical ELF. So the comparison tool is sensitive to real changes only, which is what makes " Doc fix applied ( Two items handed back, neither blocking and neither introduced here:
|
Implements
write_logfrom eth-act/zkvm-standards#27, which amends the IO interface standard we already implement and cite by URL atsyscalls/src/ef_io.rs:1. That interface goes from two functions to three:Exported under the exact C signature, as a no-op. Two commits: the symbol, then a call from
ef_io_demoso the symbol contract is actually exercised.Why a no-op is the architecture's answer, not a stopgap
The standard permits it outright — calls "may be ignored by production proving environments that do not expose diagnostics", and the text "does not contribute to the public output, is not part of the statement being proven". But the stronger reason is that nothing else is available, and it is worth stating because it is not obvious:
The natural lowering is the
Printecall (a7=1). The CPU table sends onBusId::EcallwithMultiplicity::Column(cols::ECALL), andcols::ECALLis set from the decoded opcode (prover/src/tables/cpu.rs:514) and bound into the DECODE packing (:655) — so everyecallsends[timestamp, 0, a7]regardless ofa7, and the prover cannot suppress it. The receivers on that bus are exactly four: COMMIT (commit.rs:247), HALT (halt.rs:168), KECCAK (keccak.rs:169), ECSM (ecsm.rs:308). Nothing receivesa7=1, so an emittedPrintis an unmatched send, LogUp does not balance, and every proof using it fails to verify. This is whysyscalls::print_stringis already a no-op.The decisive constraint is that one artifact serves both paths. The executor does fully implement Print today (
executor/src/vm/instruction/execution.rs:363-373— readsa0/a1,load_bytes, validates UTF-8, prints), so Lambda VM already exposes diagnostics in execute-only mode. But the same ELF is executed and proven; the symbol cannot emit an ecall in one mode and not the other. Lowering to Print would make every proof of a logging guest fail. A no-op is the only implementation that keeps a single artifact valid on both paths.Evidence
Reviewed adversarially, with an independent prosecutor and defense; both built guest artifacts rather than reading source, and both reached the same conclusions.
It cannot reach the
Ecallbus. The strongest form is not the function body but the whole binary: with the symbol added, a real guest's.textis byte-identical — 22,736 bytes,cmp-clean, with a determinism control (each side built twice, matching sha256). The instruction stream is bit-for-bit unchanged, so no execution path can differ at all, let alone emit an ecall.Directly, in a probe that declares only
unsafe extern "C" { fn write_log(*const u8, usize); }— no Rust path, so the compiler cannot see the body:One instruction, four bytes, no
ecall. Contrast<write_output>in the same binary:li a7,0x40 ; ecall ; ret.And the negative: the static ecall inventory is unchanged.
ef_io_demo(0 calls), and probes with 2 and 3 calls, all contain the same three ecall sites —a7=0x5d(Halt),a7=0x2(Panic),a7=0x40(Commit). Noa7=1anywhere.Prover cost is zero.
#[no_mangle]in an rlib prevents mangling but is not a link root, so--gc-sectionsdrops the symbol from any guest that does not call it — absent fromef_io_demobefore the second commit, and fromckzg.elf(2.2 MB, heavy C deps). All threePT_LOADsegments are byte-identical, so the loaded guest image is unchanged: no DECODE rows, no page-commitment movement, no guest binary growth. Callers pay 4 bytes plusjalr/ret, opt-in.The symbol contract works, tested rather than assumed. The
extern "C"-only probe links and runs knowing nothing but the C name and signature — which is the portable-guest case the standard exists to enable.The second commit, and why it is not decoration
Because the symbol is dead-stripped unless called, exporting it alone would ship something provably absent from every artifact and exercised by nothing.
ef_io_demonow calls it, which is the only thing demonstrating the contract:The call is genuinely emitted and not folded away despite the empty body (
12528: jalr 0x5ec(ra) <write_log>insidemain) — worth checking rather than assuming, since an empty function is exactly what LTO would inline to nothing, after which the linker would GC the symbol again and the call would have demonstrated nothing.ecallcount in the demo's.textis 3 before and 3 after, so the call site adds none.Both existing tests cover this guest and pass against the rebuilt ELF, including a full prove→verify:
The prove test asserts
public_output == b"hello world!", so the two-write_outputconcatenation still holds with a log call between them. The artifact is gitignored; CI rebuilds it.One honest caveat:
program_idmovesThe ELFs are not byte-identical. ~128 bytes differ, all in
.strtab, from rustc's content-derived.Lanon.<hash>local-symbol names..text,.rodata,.eh_frame,.dataand everyPT_LOADbyte are identical;.strtabis in no loadable segment.That matters only because
statement::elf_digest(prover/src/statement.rs:25-29) is a Keccak over the entire raw file, including non-loaded sections, and it feeds both the Fiat-Shamir statement absorb andrecursion::program_id(recursion.rs:234-246). Soprogram_idchanges for every guest linking the syscalls crate, despite zero instruction changes and the symbol not even being present.Not a defect of this change, on four grounds:
elf_bytesfrom the same artifact; a changed file getting a changed id is a stricter identity, not a weaker one.decode_commitmentand the page commitments are unaffected — they derive from the loaded image, which is identical..gitignore:7,git ls-files→ 0) and rebuilt bymake compile-programs;recursion_smoke_test.rs:186recomputesexpected_program_idfrom the ELF at runtime; no hardcoded digest exists anywhere inprover/src,executor/src,binorcrypto.program_idby the same mechanism and the same order of bytes (124 vs 120, same.strtabregion,.textbit-identical in both). Note the obvious control is the wrong one: a comment-only edit produces a bit-for-bit identical ELF, so "does any edit move it?" answers no and would misleadingly implicate this diff. The precise statement is that any source change altering a guest-linked crate's HIR items shifts rustc's mangled-name/CGU hashes in.strtab; comments and whitespace do not, because they never reach the HIR.Worth surfacing as a pre-existing sharp edge this change happens to illustrate: a verifier-visible identity is sensitive to compiler-internal symbol names with no loadable content, so bit-identical code can carry different
program_ids. That matters to anyone reasoning about reproducible builds or proof caching, and it is its own discussion.Verification
make test-syscalls→ 4 passed.make lint→ exit 0.cargo fmt --checkandcargo clippy -D warningsagainst the changed crates → clean. Guest builds across four programs, zero warnings attributable to this change.But note
make lintgives zero coverage of the changed file, and that is worth knowing independently of this PR:lambda-vm-syscallsis workspace-excluded (Cargo.toml:17) and every path dep on it is either[target.'cfg(target_arch = "riscv64")'.dependencies](crypto/crypto/Cargo.toml:31,crypto/ethrex-crypto/Cargo.toml:30) or a[patch]entry — so on a host it sits outside the workspace dependency graph entirely and no CI lint job compiles it in any form (cargo tree --workspace -i lambda-vm-syscalls→ "nothing to print"). The gates that actually cover this change are the syscalls host build,make test-syscalls, and the guest build.A consequence, filed as a follow-up rather than fixed here:
cargo clippy -D warningsrun insidesyscalls/fails onkeccak.rs:104-105(manual_is_multiple_of) onmaintoday. Pre-existing, zero diagnostics fromef_io.rs, and it survives precisely because of the gap above — the crate holding the ecall ABI, thememcpyoverride and the allocator has no clippy coverage in CI.If a real implementation is ever wanted
An execution-only path is available and needs no AIR receiver: a reserved MMIO store window the executor snoops. Ordinary stores are already fully constrained by MEMW/memory-consistency, so nothing unbalances and no table is added; cost is MEMW rows proportional to bytes logged.
A receiver AIR would also work and would be far cheaper than COMMIT — HALT-shaped, one row per call rather than per byte, log text never entering the trace — but it pays the always-on-AIR tax on every proof, including the overwhelming majority that log nothing. Given that the entire value of
write_logis realised at execution time and a verifier must never see logs, the execution-side route is the right shape if this is ever taken further.