Introduction
On April 2, 2025, RiscZero engaged zkSecurity to perform a short review of “R0VM Helios”. The audit focused on two sides of the same application:
- Rust logic to produce Ethereum consensus proofs using the RiscZero’s zkVM.
- Solidity logic to run an on-chain Ethereum light client on arbitrary EVM chains.
The scope specifically targeted the pull request https://github.com/risc0/r0vm-helios/pull/2/files which included:
- A new
R0VMHelios.sol
smart contract to be deployed on a destination EVM chain. - R0VM code making use of Helios to verifiably advance the Ethereum sync committee state and the header it points to, as well as exposing an arbitrary number of storage slot proofs for the finalized header. This code included both a guest program to run inside the zkVM, and a host program to produce the private inputs to send to the guest program.
Out of scope were:
- Core R0VM code, including risc0-ethereum.
- The Helios light client itself, although some time was spent to understand if the API was correctly used by the guest R0VM program.
Note that the audit primarily focused on the protocol’s soundness rather than its liveness. More concretely, we focused on the guest program correctness, on the verification of R0VM proofs on-chain, and on the correctness and access controls of the light client smart contract. On the other hand, we overlooked glue code and operating tools (especially as the operator code had todo!()
placeholders that prevented it to be run).
We observed that the documentation for the Helios light client was limited, which may reflect the current developmental stage of the protocol. This was also echoed in findings Next Sync Committee Might Be Arbitrarily Set and Lack of Assurance on Verified Chain Identity.
Given the short time frame of our review, we recommend a more comprehensive audit of the Helios light client to thoroughly evaluate its security guarantees and verify that the assumptions underpinning the R0VM Helios program are valid
Overview of R0VM Helios
From the R0VM document:
R0VM Helios verifies the consensus of a source chain in the execution environment of a destination chain. For example, you can run an R0VM Helios light client on Polygon that verifies Ethereum Mainnet’s consensus.
As stated above, there are two sides to this application: the production of consensus proofs locally (by operators) and their verification on-chain to maintain the state of a light client. We survey both sides in the sections below.
R0VM Helios Guest Program
An R0VM program is split into two parts: the guest program which runs inside the zkVM, and the host program which runs the zkVM and produces the private inputs to send to the guest program.
The guest program heavily relies on helios (an Ethereum light client) and alloy (mostly for Merkle proofs). The light client follows the Altair specification which introduced verifiable sync committee updates for light clients in Ethereum.
The logic of the guest program performs the following steps:
1. Deserialize Inputs. It reads private inputs sent by the host and initializes the light client state with it. The private inputs also contain a series of light client updates, as well as one finality update.
2. Process Sync Committee Updates. It iterates through a series of light client updates, verifying and applying each update sequentially to the light client state. This produces a sync committee that can verify the finality update.
3. Apply Finality Update. The finality update is verified and applied to the light client state. The finalized header, the current sync committee, and the next sync committee are extracted from the finalized state.
4. Verify Storage Slot Proofs. The finalized header’s state root is used to prove (using Merkle proofs) a number of arbitrary storage accesses on its post state.
5. Commit New State Outputs. The starting and ending states that comprised the proven state transition, information on the next sync committee, as well as the values read from the finalized Ethereum post state are committed to the journal (which is R0VM’s term for exposing variables in the public input).
The storage accesses are proven using Merkle proofs on the authenticated state root, as illustrated in the diagram below.
We can categorize the public input data into three main groups:
Previous Light Client State.
prevHeader
: the header used to kickstart the state transition.prevHead
: the head used to kickstart the state transition.startSyncCommitteeHash
: a digest of the sync committee used to kickstart the state transition.
New Light Client State.
executionStateRoot
: the post-state root of the finalized header.newHead
: the block number of the finalized blocknewHeader
: the root of the state merkle tree of the finalized header.syncCommitteeHash
: a digest of the sync committee in the sync period of the finalized header.nextSyncCommitteeHash
: same but for the next sync period.
Post-State Storage Accesses.
slots
: an arbitrary number of storage slots accessed on the update post-state.
The on-chain light-client
The on-chain light client is responsible for maintaining and updating the consensus state of a source chain by verifying proofs produced off-chain. Its design closely follows the Altair light client specification and leverages several key components:
Access Control. The smart contract relies on OpenZeppelin’s AccessControlEnumerable to manage access control to the contract’s functionalities.
Proof Verification. The smart contract relies on RiscZero’s own IRiscZeroVerifier contract to verify zkVM proofs submitted to the contract.
The contract is initialized with a set of “updaters” that are the only entities capable of updating the state of the on-chain light client.
The result of updates are provided and stored in the contract under different mappings, and the updates themselves are verified by verifying the R0VM proofs as can be seen below:
function update(bytes calldata seal, bytes calldata journalData, uint256 fromHead)
external
onlyRole(UPDATER_ROLE)
{
// TRUNCATED...
IRiscZeroVerifier(verifier).verify(seal, heliosImageID, sha256(journalData));
In addition, the prover can choose a number of storage slots accesses in the post-state, and these get stored in a mapping as well (recording selected storage slots and their values at specific block numbers).