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 block
newHeader: 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).
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.
Description. In the R0VM guest program, a series of updates is applied to a light client state in order to transition it to a newer state.
To ensure that the sync committee updates originate from the correct blockchain (and fork) the genesis block and the expected fork IDs are passed to the Helios verification functions:
pub fn main() {
let encoded_inputs = env::read_frame();
let ProofInputs {
// TRUNCATED...
genesis_root,
forks,
// TRUNCATED...
} = serde_cbor::from_slice(&encoded_inputs).unwrap();
// TRUNCATED...
// 1. Apply sync committee updates, if any
for (index, update) in sync_committee_updates.iter().enumerate() {
// TRUNCATED...
let update_is_valid =
verify_update(update, expected_current_slot, &store, genesis_root, &forks).is_ok();
// TRUNCATED...
}
// 2. Apply finality update
let finality_update_is_valid = verify_finality_update(
&finality_update,
expected_current_slot,
&store,
genesis_root,
&forks,
)
.is_ok();
// TRUNCATED...
let proof_outputs = ProofOutputs {
executionStateRoot: execution_state_root,
newHeader: header,
nextSyncCommitteeHash: next_sync_committee_hash,
newHead: U256::from(head),
prevHeader: prev_header,
prevHead: U256::from(prev_head),
syncCommitteeHash: sync_committee_hash,
startSyncCommitteeHash: start_sync_committee_hash,
slots: verified_slots,
};
env::commit_slice(&proof_outputs.abi_encode());
}
In turn, Helios will use the genesis root and the fork ID to verify that the sync committee signature is valid:
let fork_version = calculate_fork_version::<S>(forks, update.signature_slot.saturating_sub(1));
let fork_data_root = compute_fork_data_root(fork_version, genesis_root);
let is_valid_sig = verify_sync_committee_signature(
&pks,
update.attested_header.beacon(),
&update.sync_aggregate.sync_committee_signature,
fork_data_root,
);
if !is_valid_sig {
return Err(ConsensusError::InvalidSignature.into());
}
It is crucial for the light client to correctly identify the chain it is syncing to. Therefore, these two values must be exposed in the journal.
Recommendation. Expose these values, and enforce that they are equal to the expected values in the on-chain light client solidity implementation.
Right now the on-chain smart contract sets GENESIS_VALIDATORS_ROOT and SOURCE_CHAIN_ID at deployment and never use them later on.
Description. The initial store is completely decided by the prover, as it is passed to the guest program via private inputs. It is not completely unconstrained though, as a number of fields are exposed as public outputs and verified to be consistent with the on-chain light client.
Still, some fields that are not exposed as public outputs might pose some problems depending on how they are handled by the Helios implementation. For example, this is the case with the initial store value of the next_sync_committee field that’s not exposed as a public output. This could lead to a situation where the prover sets the next_sync_committee to an invalid value during a sync committee update.
Note that sync updates might not necessarily carry a next sync committee with them, as might be implied by its type. This is because they are first converted to GenericUpdate, and during the conversion a default value will be interpreted as field that is not set. You can see this in /ethereum/consensus-core/src/types/mod.rs in Helios:
impl<S: ConsensusSpec> From<&Update<S>> for GenericUpdate<S> {
fn from(update: &Update<S>) -> Self {
Self {
attested_header: update.attested_header().clone(),
sync_aggregate: update.sync_aggregate().clone(),
signature_slot: *update.signature_slot(),
next_sync_committee: default_to_none(update.next_sync_committee().clone()),
next_sync_committee_branch: default_branch_to_none(update.next_sync_committee_branch()),
finalized_header: default_header_to_none(update.finalized_header().clone()),
finality_branch: default_branch_to_none(update.finality_branch()),
}
}
}
Recommendation. There are a few ways to address this issue besides exposing the next_sync_committee in the public output and verifying its consistency with what is on chain. Instead, one could ensure that all initial values in a light client store are set to mimic the bootstrapping process:
pub fn apply_bootstrap<S: ConsensusSpec>(
store: &mut LightClientStore<S>,
bootstrap: &Bootstrap<S>,
) {
*store = LightClientStore {
finalized_header: bootstrap.header().clone(),
current_sync_committee: bootstrap.current_sync_committee().clone(),
next_sync_committee: None,
optimistic_header: bootstrap.header().clone(),
previous_max_active_participants: 0,
current_max_active_participants: 0,
best_valid_update: None,
};
}
But this could lead to issues if no update can be applied to set the next_sync_committee. Another solution could be to simply expose the entire store of the light client, while this seems like a much stronger solution this would force on-chain updaters to replicate the exact state of the light client, which might not necessarily be straightforward.