evm2rv takes EVM runtime bytecode, the output of solc --bin-runtime, and turns it into a Rand program. It writes C, and rand-guest build compiles the C into an RV32IM image. The image takes the same input vector as Rand’s EVM interpreter guest, evm.bin, and publishes the same eight output words. It runs the contract natively instead of interpreting it opcode by opcode. This page shows the whole path on an ERC-20.
What it is
| input | EVM runtime bytecode, as hex text (.hex) or raw bytes |
| output | a C shim crate; rand-guest build makes it an image container |
| where it runs | off chain, on the developer’s machine; the chain only sees the image |
| what it must match | evm.bin’s status, eight output words and gas_used |
| repository | evm2rv/ in the circuits repository |
| pinned toolchain | Rust 1.98.1, clang 23.1.1, the cc crate 1.4.6 |
The translator takes runtime code, not creation code. A proof covers one call to a deployed contract, so the constructor never runs. The state the constructor would write is part of the pre-state instead, as storage witnesses.
There are two translation stages. Both give the same results and gas; each is its own program with its own hc.
| stage | flag | what it does |
|---|---|---|
| one | --stage 1 | every opcode over a memory stack |
| two | --stage 2, the default | each block’s stack words in C locals, constants folded at translation |
The input vector is the interpreter’s: the code, the calldata, the caller and other environment words, the gas limit, the pre-state root, and one storage witness per slot the call touches. The outputs are out[0], the status (1 success, 0 revert, 2 exceptional halt), and out[1..8], the EVM_OUT digest over the code hash, both state roots, the return data and the logs.
Trust model
A verifier checks a translated contract in two steps.
hc == translate(bytecode). Re-runevm2rvon the published bytecode with the same--stageand--chain-id, rebuild with the pinned toolchain, and comparehc. The generated crate pinsccexactly and carries its ownCargo.lock.- The code guard binds
hcto the bytecode. The translated logic is compiled into the image, butCODECOPYandCODESIZEread the code in the input vector. Without a check, onehccould run with code bytes the caller chose. Socontract.ccarries a Poseidon2 digest of the source bytecode (its length and bytes), and the shim hashes the input vector’s code the same way before any translated code runs. Other code is refused: status 2,gas_used0, haltOutOfBounds.
The EVM_OUT digest also binds keccak256 of the code, as the interpreter’s does. The chain does not record the source bytecode or its hash. To let others check hc, publish the bytecode, or the Solidity source and the exact solc build.
The guard costs 1,008 cycles on the transfer. Code one byte different from the source is refused in 30,418 cycles at stage two (tier 16).
Coverage, and what traps
Opcodes. The Shanghai set. Compile with --evm-version shanghai: newer targets emit MCOPY and other Cancun opcodes, which trap.
Environment opcodes:
| opcode | translation |
|---|---|
ADDRESS, CALLER, CALLVALUE, CALLDATASIZE, CODESIZE | from the input vector, as the interpreter reads them |
CHAINID | the --chain-id value, baked in at translation, so hc binds it; required when the code contains CHAINID |
ORIGIN | equals CALLER (one call, no relayer) |
GASPRICE, COINBASE, TIMESTAMP, NUMBER, PREVRANDAO, GASLIMIT, SELFBALANCE, BASEFEE, BLOCKHASH | trap (status 2), as in the interpreter |
The nine block-context opcodes trap until there is a design for binding them to a block. A private input cannot carry a block fact, because a verifier cannot open H_IN. Until that decision is made, a contract that reads the time or the block number (a permit deadline, for example) traps.
Calls. CALL, CALLCODE, DELEGATECALL and STATICCALL run a precompile when the target is address 1 to 9. Any other target traps. A nonzero value on CALL or CALLCODE traps: there is no balance model.
Precompiles. All nine run in software. Some are too slow to prove today, because a proof holds at most 2²⁰ rows:
| precompile | vector | cycles | fits 2²⁰ |
|---|---|---|---|
| 1 ecrecover | ValidKey | 14,510,525 | no |
| 2 sha256 | ”abc” / 200 bytes | 2,791 / 5,289 | yes |
| 3 ripemd160 | ”abc” | 12,195 | yes |
| 4 identity | 100 bytes | 4,498 | yes |
| 5 modexp | nagydani-1-square / -1-pow0x10001 | 122,332 / 874,548 | yes |
| 5 modexp | eip_example1 (256-bit) | 11,170,118 | no |
| 5 modexp | nagydani-5-pow0x10001 (1,024-byte) | 55,076,950 | no |
| 6 bn256 add | chfast1 | 978,262 | yes, just |
| 7 bn256 mul | chfast1 | 3,060,669 | no |
| 8 bn256 pairing | 1 pair / 2 pairs | 1,853,656,212 / 2,019,000,469 | no |
| 9 blake2f | 12 rounds | 14,493 | yes, up to about 1,800 rounds |
modexp’s base and modulus are capped at 1,024 bytes each.
Accepted divergences. Status, the eight words and gas_used must match. Three differences are accepted: the halt kind may differ (static gas is charged at each block’s head, so OutOfGas and another halt can swap; both are status 2 with all gas used); the translation implements CHAINID, ORIGIN and precompile calls, which the interpreter traps; and the code guard refuses code other than its source.
The numbers
ERC-20 calls, with the code guard in. Cycles from rand-guest run; tier in parentheses.
| call | interpreter (evm.bin) | stage one | stage two (default) |
|---|---|---|---|
transfer(BOB, 250) | 121,638 (18) | 80,211 (18) | 66,235 (18) |
approve(BOB, 5) | 85,645 (18) | 56,261 (16) | 48,119 (16) |
transferFrom(ALICE, BOB, 100) | 161,434 (18) | 110,074 (18) | 88,824 (18) |
| program | words | hc |
|---|---|---|
evm.bin, the interpreter | 18,009 | 7e1aea2b…854c08 |
| ERC-20, stage one | 15,804 | 9cdb79e7…2fd048c8 |
| ERC-20, stage two | 11,686 | a0feae92a7311c9562495717100eb7aea71270a38ed435d06dc31387e0ea8ff6 |
Stage two runs the three calls in 54.5%, 56.2% and 55.0% of the interpreter’s cycles. The transfer stays at tier 18: its 66,235 cycles plus 3,155 digest rows are 69,390 rows, 3,855 over tier 16’s 65,535.
About 60% of a stage-two transfer (about 39,928 cycles) is the harness both images share: decoding the input, verifying the storage witnesses, the ABI’s keccak256 calls and the output digest. No translation work goes below that floor.
Walkthrough: an ERC-20, from Solidity to matching outputs
Every command runs from the root of a circuits checkout at 7ef3220, with the toolchain from Deploy a Rust program and clang 23.1.1 from Deploy a RISC-V program. Long output is cut where marked ….
0. Build the tools
(cd evm2rv && cargo +1.98.1 build --release)
(cd rand-guest && cargo +1.98.1 build --release)
1. Compile the Solidity to runtime bytecode
The contract is the committed guests-compiled/evm/contracts/ERC20.sol: a self-contained ERC-20 with OpenZeppelin v4’s storage layout, pragma solidity 0.8.37. Use exactly solc 0.8.37; another version optimises differently and gives different bytecode.
| release | v0.8.37 from github.com/ethereum/solidity/releases |
| macOS asset | solc-macos, sha256 a27396e7732aa52e80ff89ad7bd8a2e46fec2a6dcc4ef20cd16e5e0c502d6821 |
| Linux asset | solc-static-linux, from the same release |
$ curl -sSL -o solc https://github.com/ethereum/solidity/releases/download/v0.8.37/solc-macos
$ shasum -a 256 solc
a27396e7732aa52e80ff89ad7bd8a2e46fec2a6dcc4ef20cd16e5e0c502d6821 solc
$ chmod +x solc && ./solc --version
solc, the solidity compiler commandline interface
Version: 0.8.37+commit.f401782d.Darwin.appleclang
$ (cd guests-compiled/evm && ../../solc --optimize --optimize-runs 200 --evm-version shanghai \
--bin-runtime contracts/ERC20.sol) | tee solc.out
======= contracts/ERC20.sol:ERC20 =======
Binary of the runtime part:
608060405234801561000f575f5ffd5b5060043610610060575f3560e01c8063095ea7b31461006457806318160ddd1461008c57806323b872dd1461…
$ tail -1 solc.out > evm2rv/target/mytoken.hex
The last line is the runtime bytecode, 1,296 bytes. It is byte-identical to the committed guests-compiled/evm/contracts/erc20.runtime.hex; SOLC=$PWD/solc bash guests-compiled/evm/contracts/build.sh checks that and prints contracts: erc20.runtime.hex reproduces.
The flags matter:
--bin-runtime: the deployed code, not the creation code.--evm-version shanghai: the default target for 0.8.37 emitsMCOPY, which traps.--optimize --optimize-runs 200: a deployed-contract profile.
2. Translate
$ evm2rv/target/release/evm2rv evm2rv/target/mytoken.hex --out evm2rv/target/mytoken --chain-id 12
1296 code bytes: 74 blocks, 798 opcodes, 51 jumpdests (stage 2)
CHAINID is the constant 12
no trapping opcodes present
wrote evm2rv/target/mytoken (crate mytoken): contract.c, Cargo.toml, Cargo.lock, build.rs, src/main.rs, shim.ld
--outmust be inside a circuits checkout. Without it,evm2rvonly analyses the code.- Any trapping opcode would be listed here as a warning, with its pc.
- This session was captured with
--chain-id 12. This ERC-20 has noCHAINID, so--chain-iddoes not change itshc. A contract that readsCHAINIDrequires the flag; set it to the chain you will deploy on (--chain-id 19for the live chain).
contract.c ends with the code guard’s digest:
const uint32_t *evm_code_digest(void) {
static const uint32_t d[8] = {0xf679280fu, 0x86c10e83u, 0x7e8f1f39u, 0xa2377de3u, 0x8a2e282fu, 0xc54cad42u, 0xddfaf009u, 0x7d28a8afu};
return d;
}
3. Build the image
The default cap is 4,096 words, so pass --max-words 65535.
$ rand-guest/target/release/rand-guest build evm2rv/target/mytoken --max-words 65535
…
11686 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote evm2rv/target/mytoken/image.bin and its .sha256 (11686 words, hc a0feae92a7311c9562495717100eb7aea71270a38ed435d06dc31387e0ea8ff6, program id f074c4eb834cf01886a8241b6a2e0caf6e1cee5327fee6cb1a1a37436607280d)
$ rand-guest/target/release/rand-guest info evm2rv/target/mytoken/image.bin
form: image container
text 11147 words at 0x10000; data 408 words (205 non-zero) at 0x1ae2c; prologue 539 words; program 11686 words from base_pc 0xf794
hc a0feae92a7311c9562495717100eb7aea71270a38ed435d06dc31387e0ea8ff6
program id f074c4eb834cf01886a8241b6a2e0caf6e1cee5327fee6cb1a1a37436607280d
11686 words against a cap of 4096: does not fit
This is the published stage-two hc. The crate name does not affect it.
4. Build an input vector and run a transfer
The erc20_vector example prints the parity test’s vectors as input words: transfer (ALICE sends BOB 250 of her 1,000), approve (ALICE lets BOB spend 5) and transferFrom (BOB moves 100 of ALICE’s 1,000 against an allowance of 300). For your own contract, build an EvmCall the same way in Rust; the example is the template.
$ (cd evm2rv && cargo +1.98.1 run -q --release --example erc20_vector -- transfer > target/transfer.words)
921 input words
$ rand-guest/target/release/rand-guest run evm2rv/target/mytoken/image.bin --input $(cat evm2rv/target/transfer.words)
out[0] = 1
out[1] = 513227413
out[2] = 3087537901
out[3] = 995619457
out[4] = 4004100029
out[5] = 234390638
out[6] = 3262201797
out[7] = 3842361427
cycles 66235
tier 18
5. Compare with the interpreter
The same input on the interpreter guest:
$ rand-guest/target/release/rand-guest run guests-compiled/bin/evm.bin --input $(cat evm2rv/target/transfer.words)
out[0] = 1
out[1] = 513227413
out[2] = 3087537901
out[3] = 995619457
out[4] = 4004100029
out[5] = 234390638
out[6] = 3262201797
out[7] = 3842361427
cycles 121638
tier 18
And the interpreter run natively on the host, the tests’ oracle:
$ (cd evm2rv && cargo +1.98.1 run -q --release --example erc20_vector -- transfer --expected)
out[0] = 1
out[1] = 513227413
…
out[7] = 3842361427
interpreter (native): status 1, Return, gas_used 29956
The check as one command:
$ diff <(rand-guest/target/release/rand-guest run evm2rv/target/mytoken/image.bin --input $(cat evm2rv/target/transfer.words) | grep '^out') \
<(rand-guest/target/release/rand-guest run guests-compiled/bin/evm.bin --input $(cat evm2rv/target/transfer.words) | grep '^out') \
&& echo "the eight words are identical"
the eight words are identical
Repeat steps 4 and 5 with approve and transferFrom; both give identical words. cd evm2rv && cargo +1.98.1 test --release runs these and five more vectors under both stages, and also checks gas_used (70 tests, plus 2 ignored proofs).
| translated (stage two) | interpreter (evm.bin) | |
|---|---|---|
| eight output words | identical | identical |
| cycles, transfer | 66,235 | 121,638 |
| tier | 18 | 18 |
| program words | 11,686 | 18,009 |
6. Deploy
rand program deploy evm2rv/target/mytoken/image.bin
The wallet prints the program id, the word count and hc, then checks the chain’s cap before it proves anything. The image is 11,686 words, so it needs a chain whose genesis sets max_program_words to at least 11,686. Chain 12 ran the default 4,096, so the deploy was refused there. Chain 13, the v0.4 chain, set max_program_words to 65,535, and every chain since keeps that value. Chain 21, the live chain since 2026-10-12 (fullnode v0.8.0, genesis ca76c2766ed6090ac6ba5dad86b724dd5d2b80c0edfaed287cd8b129e663ca9a), also keeps chain 13’s call-envelope and public-input limits, sets the proof cap at 6 MiB (6,291,456 bytes; chains 17 to 20 set 4 MiB, chains 13 to 16 8 MiB) and the block cap at 19 MiB. The word cap is no longer the only gate. Chain 21’s genesis, like chain 20’s and chain 19’s, sets hardening_v6, and under it a deploy of more program words than a call can hold at the tier-14 call cap is invalid (ProgramUncallable): at most 8,180 words with no public input. This image is 11,686 words, so chain 21 refuses the deploy, and it would refuse the calls as well: every ERC-20 call proves at tier 16 or 18, above the tier-14 call cap (step 7). Block aggregation is off on chain 21 (its genesis has no aggregation section); its bootstrap nodes are listed in Running a validator. The deploy fee is 0.001 + 0.0001 × 11,686 = 1.1696 RAND, the fee the chain-13 deploy below paid.
This deploy ran on chain 13 on 2026-09-19, while chain 13 was the live chain (production FRI profile, genesis 8123ccac1883a45750e4df6964fb7cd3f0b321798cde4c0ef406a0293939ece3), from the genesis wallet shielded-1. Every chain since (14 to 19) was cut as a fresh chain without chain 13’s programs, and the image has not been redeployed on any of them, so the transaction and the height below are chain 13’s and no longer resolve on the explorer. The program id is a hash of base_pc and the words alone, so the same image gets the same id on any chain that admits the deploy. The wallet’s output, with its progress lines elided (…):
$ rand program deploy <image.bin>
program id: f074c4eb834cf01886a8241b6a2e0caf6e1cee5327fee6cb1a1a37436607280d (11686 words, hc 92aefea0951c31a717574962aeb70e10a37012a7d035d48e8713c36df68feae0)
…
proved in 92.2s: tier 14, 1419655 bytes
submitted deploy c7a66dc4d9cdb7f9bfc4bde01f618c356e5abad7097e6bf7dc04e2e49c53acd0
…
| program id | f074c4eb834cf01886a8241b6a2e0caf6e1cee5327fee6cb1a1a37436607280d |
| image | 11,686 words; hc a0feae92… in rand-guest’s spelling, 92aefea0… in the wallet’s |
| fee bundle (chain 13’s 2-in-2-out bundle) | tier 14, 1,419,655 bytes, proved in 92.2 s |
| deploy tx (chain 13) | c7a66dc4d9cdb7f9bfc4bde01f618c356e5abad7097e6bf7dc04e2e49c53acd0 |
| committed at height (chain 13) | 381 |
Since chain 14 the paying bundle is the one 4-in-4-out hidden-asset bundle every transaction carries, proved against the transaction’s binding. It is still tier 14: about 100 s and about 1.42 MB at the production profile on chains 14 and 15. Since chain 17 a bundle also carries a tier-10 auth proof (split authorisation), so on chains 18 and 19 a transaction’s fee bundle is about 1.49 MB of bundle proof and about 1.37 MB of auth proof.
7. Call
A transfer call carries the 921-word vector as private inputs, under the input cap on any chain (4,295 words by default, 16,071 on chains 20 and 21), and no public words. It cannot be submitted to chain 21: the program cannot be deployed there (step 6), the transfer proves at tier 18, and since fullnode v0.5.6 (2026-09-25) a node refuses any call proof above tier 14 with CallTierTooHigh, before it builds a verifier key. The command below proves locally and the chain refuses the result; the measurements are off-chain proofs. rand call takes each word as a repeated --input:
rand call f074c4eb834cf01886a8241b6a2e0caf6e1cee5327fee6cb1a1a37436607280d \
$(for w in $(cat evm2rv/target/transfer.words); do printf -- '--input %s ' "$w"; done)
Now measured. A tier-18 proof was killed on a 48 GB laptop at 24.7 GB, but on a 128 GB droplet (m-16vcpu-128gb) the translated transfer call proved: 85.0 GB peak RSS, 3,230.5 s (53.8 min), an 811,600-byte proof. It verified in 101.4 s on the droplet and 50.2 s at 3.66 GB RSS on a 48 GB Apple-silicon laptop (proving memory). Under the ledger’s flat tier schedule, the rule on chains without a gas section (up to chain 17), the call fee at tier 18 was 0.0024 RAND. Chains 18 and 19 price a call by its declared gas limit and its bytes instead, 100 units per gas and 800 per KiB at the genesis prices, and admits no call above tier 14.
The call on chain 13, 2026-09-19
The approve(BOB, 5) call (649 private words, tier 16) ran on chain 13 on 2026-09-19, while it was the live chain, production FRI profile, from the same wallet. No call has been made on any later chain, where the program is not deployed, and chain 21, like chain 20, refuses this tier-16 call proof (CallTierTooHigh). The wallet’s output, with its progress lines elided (…):
$ rand call f074c4eb834cf01886a8241b6a2e0caf6e1cee5327fee6cb1a1a37436607280d --input <w0> --input <w1> … # the 649 words of approve(BOB, 5)
…
proved in 388.7s: tier 16, 3412405 bytes, outputs [1, 942495465, 790002515, 1351749335, 1059083501, 2923046783, 2814575942, 696258848]
…
proved in 96.0s: tier 14, 1420423 bytes
submitted call 279e1f62abf3f177ca71b08c633e051c37bc4720118c79ddb00936f036c4bfa7
…
| call proof | tier 16, 3,412,405 bytes, proved in 388.7 s |
| fee bundle (chain 13’s 2-in-2-out bundle) | tier 14, 1,420,423 bytes, proved in 96.0 s |
| sealed envelope | 2,700 bytes |
| call tx (chain 13) | 279e1f62abf3f177ca71b08c633e051c37bc4720118c79ddb00936f036c4bfa7 |
| anchored at / committed at | height 802 / height 919 |
| fee | 0.00357 RAND |
receipt h_pub | null |
| receipt outputs | equal to the interpreter’s eight words for the same vector |
| wall time, peak RSS | 622.8 s, 22.9 GB, on a 48 GB M-series laptop |
- The production proof is 3,412,405 bytes, over chain 12’s 2 MiB proof cap. Chain 12 could not accept this call; chain 13’s 8 MiB cap did. Chains 14 to 16 kept the same cap, chains 17 to 20 set 4 MiB (4,194,304 bytes) and chain 21 sets 6 MiB, all of which a proof of this size fits under. Since v0.5.6 a node refuses the call for its tier before it looks at its size.
- The fee includes the byte term of chain 13’s schedule: the 3.41 MB proof is above the 2 MiB + 18,432-byte free allowance. Since chain 18 every call pays per KiB from byte 0.
The receipt’s eight words are the ones in step 4. The chain keeps no EVM state: the caller supplies the pre-state root and the storage witnesses, and the post-state root is inside the EVM_OUT digest.
Limits
- No block context. The nine block-context opcodes trap until a block-binding design exists.
- No value transfers. A
CALLwith nonzero value traps. Calls to contracts other than precompiles trap. - ecrecover, bn256 mul, the pairing and large modexp cannot be proved today. Each exceeds the 2²⁰-row tier cap; they need coprocessor tables.
- No EVM state on chain. A call is one proof over one run; the caller carries the state.
- Neither the deploy nor any ERC-20 call is admissible on chain 21. Under chain 21’s
hardening_v6a deploy of more words than a tier-14 call can hold is invalid (ProgramUncallable; 8,180 words at most), and the image is 11,686. Since fullnode v0.5.6 a call proof must be tier 14 or below (CallTierTooHigh);approveproves at tier 16,transferandtransferFromat tier 18. The same check caps the hash tables a call may declare: 2¹² Keccak rows (128 permutations) and 2¹³ SHA-256 rows (128 compressions). The calls still prove off chain, as below. - The storage index is 32 bits deep.
evm-coreplaces a contract storage leaf by the top 32 bits ofkeccak256(slot), so a slot that collides with a chosen one costs about 2³² Keccak hashes to find, and while it holds a non-zero value the chosen slot is frozen (no forgery and no lost value). This is dormant:evm.binis 18,009 words, and a call at the tier-14 cap holds at most 8,184, so no EVM runtime is callable on any chain. The fullnode’sdocs/confidential.mdrecords the precondition beside the call cap (audit v6, ZKV-6; onmainsince 2026-09-30, after the v0.6.7 tag): before a chain lifts the tier-14 call cap or admits an EVM runtime small enough to call, the storage index must be deepened andevm.binre-pinned. - Proven. A stage-one transfer proof (tier 18) was SIGKILLed on a 48 GB laptop at 24.7 GB after 1,016 s, but the stage-two
transfercall proved on a 128 GB droplet (m-16vcpu-128gb): 85.0 GB peak RSS, 3,230.5 s (53.8 min), an 811,600-byte proof, verified in 101.4 s (droplet) and 50.2 s at 3.66 GB RSS (48 GB laptop). The interpreter’sevm.bintransfer proof matched closely: 85.5 GB, 3,143.3 s (52.4 min), an 805,108-byte proof — and the two proofs carry identical public output words, confirming parity on the real prover.approve(tier 16, translated) is far cheaper: 21.7 GB, 786.7 s (13.1 min), a 798,930-byte proof. Translation only lowers proving cost when it drops a call into a smaller tier. - The chain does not record the bytecode. Publish it so others can rebuild
hc.