Skip to content

feat: add standalone zkvm guest target - #2

Merged
Filter94 merged 23 commits into
masterfrom
feat/standalone-zkvm-guest
Sep 15, 2026
Merged

Filter94 merged 23 commits into
masterfrom
feat/standalone-zkvm-guest

Conversation

@ivokub

@ivokub ivokub commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

This PR adds the EVM precompiles and makes the library compilable to R5 standalone target.


Note

High Risk
Touches KZG trusted-setup loading, pairing correctness, and new cryptographic entry points for bare-metal builds; BN254 fix changes EVM pairing behavior for mixed infinity inputs.

Overview
Adds a freestanding RISC-V64 (rv64im) build path for zkVM guests: Nim --os:standalone gates file I/O and stdio, a clang shim plus stub headers/standalone_stdio.c satisfy the runtime, and make_lib_riscv64_freestanding produces libconstantine.riscv64.a with embedded KZG verification-only (CTT_EMBEDDED_KZG + CTT_KZG_VERIFICATION_ONLY), no threads, and an archive symbol check.

Embedded KZG can load the full ceremony from a compile-time .dat blob (ctt_eth_kzg_context_new_embedded) or, in verification-only mode, only embed [τ]G2 and strip prover/PeerDAS APIs via fullKzgContext / fullKzgApi macros and slimmer C headers.

zkVM secp256k1 adds raw eth_zkvm_secp256k1_ecrecover / eth_zkvm_secp256k1_verify (digest-in, strict scalar checks, new cttEVM_MalformedSignature) plus verifyFromDigest for ECDSA.

BN254 pairing (EIP-197) fixes a regression where seeing an infinity pair short-circuited the whole check to success; infinity pairs are now skipped in the product like the spec expects, with dedicated tests/vectors.

CI gains embedded-KZG test batches, BN254 pairing and zkVM secp256k1 suites, and .gitattributes LF enforcement for the embedded setup file.

Reviewed by Cursor Bugbot for commit ba2b28f. Bugbot is set up for automated code reviews on this repo. Configure here.

Roman and others added 17 commits September 4, 2026 13:45
defined(standalone) shims for platforms with no OS primitives:
- sysrand: deterministic zero-fill stub (no OS CSPRNG; the zkVM circuit
  guarantees integrity, so side-channel blinding is unnecessary)
- threadpool: route barriers/futexes/topology/threads to *_standalone
  single-hart implementations
Single-hart zkVM guest implementations:
- panicoverride: trap loop instead of OS abort
- barriers_standalone: N=1 barrier releases immediately
- futexes_standalone: spin-wait degenerates to a fence with one worker
- threads_standalone: createThread traps (spawn loop is empty at n=1)
- topology_standalone: report one core / one available thread
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
…on function name to zkvm as well

Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
…grams

Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com>
@ivokub

ivokub commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review Comments

1. bindings/panicoverride.nim: Trap on freestanding panic instead of infinite spin loop

In a bare-metal zkVM guest, while true: discard causes any assertion failure or out-of-memory error to spin until the prover hits its maximum cycle budget or times out, resulting in an opaque cycle-exhaustion error rather than an immediate diagnostic failure.

Calling __builtin_trap() (matching standalone_stdio.c:exit) emits a trap instruction (unimp / ebreak), halting the guest immediately and cleanly.

-proc panic(s: string) {.noreturn, compilerproc,
-    codegenDecl: "static $# $#$#".} =
-  rawoutput(s)
-  while true:
-    discard
+proc c_builtin_trap() {.importc: "__builtin_trap", nodecl.}
+
+proc panic(s: string) {.noreturn, compilerproc,
+    codegenDecl: "static $# $#$#".} =
+  rawoutput(s)
+  c_builtin_trap()

2. constantine.nimble: Support configurable output paths and remove redundant chmod +x in make_lib_riscv64_freestanding

When Constantine is consumed as a Zig package dependency (~/.cache/zig/p/...), the package directory is immutable and read-only. In make_lib_riscv64_freestanding:

  1. chmod +x constantine/platforms/clang-rv64-standalone.sh fails on read-only filesystems. The script is already committed with executable mode 100755 in git, so this chmod can be dropped.
  2. Hardcoded writes to nimcache/libconstantine_riscv64_freestanding and lib/libconstantine.riscv64.a write directly into the dependency tree.

Allowing environment variable overrides (e.g. CTT_OUTDIR and CTT_NIMCACHE) enables embedders to redirect build outputs into their own build cache directories:

   let wrapper = "constantine/platforms/clang-rv64-standalone.sh"
-  let nimcache = "nimcache/libconstantine_riscv64_freestanding"
-  exec "chmod +x " & wrapper
+  let outdir = if existsEnv"CTT_OUTDIR": getEnv"CTT_OUTDIR" else: "lib"
+  let nimcache = if existsEnv"CTT_NIMCACHE": getEnv"CTT_NIMCACHE" else: "nimcache/libconstantine_riscv64_freestanding"
   exec "rm -rf " & nimcache
+  exec "mkdir -p " & outdir
   let nim = if existsEnv"NIM": getEnv"NIM" else: "nim"
   exec nim & " c " &
        releaseBuildOptions(bmStaticLib) &
        " --cc:clang " &
        " --cpu:riscv64 --os:standalone -d:noSignalHandler -d:CTT_EMBEDDED_KZG -d:CTT_KZG_VERIFICATION_ONLY " &
        " --clang.exe:" & wrapper & " --clang.linkerexe:" & wrapper &
        " --threads:off " &
        " --noMain --app:staticlib " &
        " --nimMainPrefix:ctt_init_ " &
-       " --out:libconstantine.riscv64.a --outdir:lib " &
+       " --out:libconstantine.riscv64.a --outdir:" & outdir & " " &
        " --nimcache:" & nimcache & " " &
        " bindings/lib_constantine_riscv64_freestanding.nim"
   exec wrapper & " -c constantine/platforms/standalone_stdio.c" &
        " -o " & nimcache & "/standalone_stdio.riscv64.o"
   let ar = if existsEnv"LLVM_AR": getEnv"LLVM_AR"
            elif fileExists"/opt/homebrew/opt/llvm/bin/llvm-ar": "/opt/homebrew/opt/llvm/bin/llvm-ar"
            elif fileExists"/usr/local/opt/llvm/bin/llvm-ar": "/usr/local/opt/llvm/bin/llvm-ar"
            else: "llvm-ar"
-  exec "rm -f lib/libconstantine.riscv64.a"
-  exec ar & " rcs lib/libconstantine.riscv64.a" &
+  let archive = outdir / "libconstantine.riscv64.a"
+  exec "rm -f " & archive
+  exec ar & " rcs " & archive &
        " " & nimcache & "/*.o"

3. constantine/platforms/clang-rv64-standalone.sh: Guard Homebrew LLVM lookup by OS in clang driver wrapper

clang-rv64-standalone.sh runs for every single C compilation unit spawned by Nim. Because brew --prefix llvm 2>/dev/null executes unconditionally when CLANG is unset, running on Linux spawns a failing brew subprocess for every object file.

Guarding the lookup with [ "$(uname -s)" = "Darwin" ] eliminates this overhead on Linux while preserving the Homebrew fallback on macOS:

 if [ -z "${CLANG:-}" ]; then
-  BREW_LLVM=$(brew --prefix llvm 2>/dev/null || true)
-  CLANG=${BREW_LLVM:+$BREW_LLVM/bin/clang}
+  if [ "$(uname -s)" = "Darwin" ]; then
+    BREW_LLVM=$(brew --prefix llvm 2>/dev/null || true)
+    CLANG=${BREW_LLVM:+$BREW_LLVM/bin/clang}
+  fi
   CLANG=${CLANG:-clang}
 fi

@ivokub ivokub changed the title feat: add standaolne zkvm guest target feat: add standalone zkvm guest target Sep 9, 2026
Signed-off-by: Ivo Kubjas <ivo.kubjas@consensys.net>
Signed-off-by: Ivo Kubjas <ivo.kubjas@consensys.net>
@ivokub

ivokub commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Second review round. The guest build and archive checks pass locally and the freestanding plug looks minimal and correct — no findings there.

One request: eth_zkvm_secp256k1_verify / eth_zkvm_secp256k1_ecrecover have no behavioral tests in this repo (only the symbol check in check_riscv64_freestanding_archive.sh). Since these are the ABI the downstream accelerator contract consumes directly, please add a small test covering:

  • sign → ecrecover → verify roundtrip
  • negative cases: recid ∉ {0,1}, r or s = 0 / ≥ n, off-curve pubkey, x with no on-curve point (recovery yields neutral → cttEVM_MalformedSignature)

The canonical-scalar checks are load-bearing here because Fr.fromBig silently reduces mod n, so they deserve a regression pin. tests/t_ethereum_evm_bn254_pairing.nim is a decent template.

@Filter94

Copy link
Copy Markdown
Collaborator

Second review round. The guest build and archive checks pass locally and the freestanding plug looks minimal and correct — no findings there.

One request: eth_zkvm_secp256k1_verify / eth_zkvm_secp256k1_ecrecover have no behavioral tests in this repo (only the symbol check in check_riscv64_freestanding_archive.sh). Since these are the ABI the downstream accelerator contract consumes directly, please add a small test covering:

  • sign → ecrecover → verify roundtrip
  • negative cases: recid ∉ {0,1}, r or s = 0 / ≥ n, off-curve pubkey, x with no on-curve point (recovery yields neutral → cttEVM_MalformedSignature)

The canonical-scalar checks are load-bearing here because Fr.fromBig silently reduces mod n, so they deserve a regression pin. tests/t_ethereum_evm_bn254_pairing.nim is a decent template.

Thanks for the comments! I addressed them

@ivokub ivokub left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks for the fixes. For downstream usage, have a look at the comment below

make_lib_riscv64_freestanding now requires llvm-nm in addition to llvm-ar. The new archive symbol-check gate at the end of the task (constantine.nimble:369-375
(

" " & nimcache & "/*.o"
let nm = if existsEnv"LLVM_NM": getEnv"LLVM_NM"
elif fileExists"/opt/homebrew/opt/llvm/bin/llvm-nm": "/opt/homebrew/opt/llvm/bin/llvm-nm"
elif fileExists"/usr/local/opt/llvm/bin/llvm-nm": "/usr/local/opt/llvm/bin/llvm-nm"
else: "llvm-nm"
exec "sh tests/check_riscv64_freestanding_archive.sh " & nm &
" " & archive
)) shells out to tests/check_riscv64_freestanding_archive.sh with an llvm-nm binary,
resolved via LLVM_NM env var → Homebrew fallbacks → plain llvm-nm on PATH. That matches the existing llvm-ar resolution, so the toolchain surface just grows by one tool.

Two small downstream implications:

  1. zkevm-monorepo/riscv-guests's guest-crypto-ctt/build.zig resolves and forwards LLVM_AR explicitly but doesn't know about LLVM_NM — a fresh machine with llvm-ar but no llvm-nm on PATH will fail at the very end
    of the archive build, after all compilation succeeded. Worth a one-line addition to the install-constantine-deps make target/docs (and possibly an LLVM_NM forward in guest-crypto-ctt/build.zig for symmetry)
    when the pinned Constantine commit is bumped to include this PR.

  2. If CTT_OUTDIR is ever used to redirect the archive outside the dependency tree (the new env override), note the symbol check runs against that redirected path, which is the desired behavior — just worth knowing
    the check is no longer anchored to lib/ when overriding.

@Filter94
Filter94 merged commit 4b0d7a4 into master Sep 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants