How the Light Client Works

The source code for the light client contract can be found on GitHub.

The LightClient contract validates the HotShot state through cryptographic proofs (SNARK proofs). Rollups can use the new finalizedState from the LightClient contract to confirm the validity and finality of transactions that have been bundled and processed.

LightClientState public finalizedState;

Light Client State

This LightClientState includes information such as:

  • the Merkle root of finalized block commitments, blockCommRoot

  • the fee ledger commitment, feeLedgerComm

  • the active stake table commitment, stakeTable*Comm

  • the latest view number, viewNum, and block height, blockHeight, of the finalized HotShot chain

For the next release, a fixed stake table is used so stake table commitments will not be updated.

struct LightClientState {
    uint64 viewNum;
    uint64 blockHeight;
    BN254.ScalarField blockCommRoot;
    BN254.ScalarField feeLedgerComm;
    BN254.ScalarField stakeTableBlsKeyComm;
    BN254.ScalarField stakeTableSchnorrKeyComm;
    BN254.ScalarField stakeTableAmountComm;
    uint256 threshold;

The most important part of the LightClientState is the blockCommRoot, which is the root of an append-only Merkle tree of all blocks finalized by HotShot. This root is public, allowing other contracts to use succinct Merkle proofs to verify the inclusion of a certain block commitment at a certain height and to update the root when new blocks are appended.

At any given time, the LightClientState contains Espresso block commitments from height 0 up to (but not including) the current block height, blockHeight. All commitments for heights greater or equal to blockHeight are not present—the corresponding leaf is empty in the tree.

Rollups and the Light Client Contract

Rollup contracts on L1 must use the blockCommRoot when validating a state transition to ensure that the rollup block claimed to have been executed is indeed the next block in the canonical sequence.

The proposer of a rollup state transition must provide a proof, relative to blockCommRoot, showing that the Espresso state commitment at a specified height is consistent with the rollup block commitment.

For rollups to integrate with Espresso, they need to modify their contract on L1 to prove that their state is derived from the Espresso state. For instance, consider a scenario where the Espresso prover pushes a new Espresso state commitment to the light client contract every 1 minute, and the rollup prover submits a new rollup block commitment every 10 minutes to their rollup contract. The rollup prover needs to prove that the block commitment it publishes to the rollup contract corresponds to all its rollup transactions contained in the Espresso blocks during the 10-minute period.

Each Espresso block commitment also commits to a list of rollup transactions (among other metadata) which facilitates lightweight proofs with transaction granularity for arbitrarily old rollup blocks. This approach allows for the verification of these rollups blocks on Espresso's chain and enables the light client contract to operate with a constant amount of storage, irrespective of the HotShot chain's length.

Data Availability

Since the actual block commitments (let alone the full blocks) are not stored on-chain, it is important to understand the data availability properties ensuring that clients can always retrieve an old block, block commitment, or a block's Merkle proof.

Clients can fetch a Merkle proof for any block from an archival query service and authenticate it against the block root in the light client state. Failing that, they can fetch the individual blocks from HotShot DA and extract the proof themselves.

ZK Proofs, SNARK Proofs, and ZK Circuits

A circuit defines the computation to be proven. Zero-knowledge proof (ZKP) systems can generate cryptographic proofs attesting to the validity of statements described by the circuit without revealing underlying witness. In our context, we rely on SNARK proofs (a special kind of ZKP), whose succinct nature is especially valuable for verifying computation in smart contracts where gas costs are a critical concern. Irrespective of the number of signatures/consensus votes, the size and verification cost of the proof remain constant.

In a zero-knowledge protocol there are two main roles: (i) the prover and (ii) the verifier.

The prover uses a combination of secret inputs (HotShot nodes' Schnorr signatures), also called witnesses, public inputs (the LightClientState), and a circuit description in order to generate a SNARK proof.

The verifier uses the public inputs and the SNARK proof to verify that the rules defined by the ZK circuit are satisfied. In this case, the LightClient contract acts as the verifier for this ZK proof via the verifyProof method which is invoked within the newFinalizedState function.

Finally, both the prover and verifier use some public parameters. These public parameters are derived from the circuit and a structured reference string (SRS) that requires a trusted setup to be generated but can be reused for other circuits.

The Light Client Circuit

Let the following circuit Cqc\mathcal{C}_{\sf qc} over prime field Fp\mathbb{F}_p, the corresponding Jubjub curve group G=g\mathbb{G}=\langle g\rangle whose scalar field is Fr\mathbb{F}_r and base field is exactly Fp\mathbb{F}_p, so that each group element Fp×Fp\in \mathbb{F}_p \times\mathbb{F}_p.

  • Public input:

    • stake table commitment: cmTFp\mathsf{cm}_\mathcal{T} \in \mathbb{F}_p

      • A stake table entry now composes of a triple (bls_ver_key,schnorr_ver_key,stake_amount)(bls\_ver\_key, schnorr\_ver\_key, stake\_amount). BLS verification key is under ark_bn254::Fq, Schnorr verification key is under ark_bn254::Fr, and the stake amount is within the range of ark_bn254::Fr. The commitment should be computed in the following way: first serialize all BLS keys into elements of ark_bn254::Fr, follows by a list of Schnorr keys and then the stake amount. The commitment is the rescue hash of this list.

    • quorum Threshold: TFpT \in \mathbb{F}_p

    • attested new finalized hotshot state: m:=(v,h,rootcm,cmledger,cmstake_table)Fp5m:=(v, h, \mathsf{root_{cm}, cm_{ledger}, cm_{stake\_table}}) \in \mathbb{F}_p^5

      • the merkle tree for block commitments can use any hash function (e.g. SHA2) and best if there is an injective mapping between the root value to a Fp\mathbb{F}_p element.

  • Secret witness:

    • signers indicator vector: vS\vec{v}_S

    • stake table vector (consists of public key, weight pair): T=[(pkiG,wiFp)]i[n]\mathcal{T}=[(pk_i \in\mathbb{G}, w_i \in \mathbb{F}_p)]_{i\in[n]}

    • list of schnorr signatures: {σi=(si,Ri)Fr×G}i[n]\{\sigma_i = (s_i, R_i) \in \mathbb{F}_r \times \mathbb{G}\}_{i\in [n]}

  • Relation:

    • the input signers indicator vector vS\vec{v}_S is a bit vector

    • correct stake table commitment: cmT=commit(T)\mathsf{cm}_\mathcal{T}= \mathtt{commit}(\mathcal{T})

      • we use Rescue-based commitment, thus all operations are native

    • accumulated weighted sum exceeds threshold: iSwi>T\sum_{i\in S}{w_i} > T

      • assumption: there’s no overflow!! NOTE: outside the circuit, the client software that’s in charge of stake table management needs to check the accumulated sum does not exceed modulus pp AT ALL TIMES! ⚠️

    • signature verification (on each): Vfy(pki,m,σi)=1\mathsf{Vfy}(pk_i, m, \sigma_i) = 1 for all i[n]\forall i\in [n].

      • c=H(R,m,..)c = H(R, m, ..): rescue-based hash to get challenge

      • x=gsx = g^s: a fixed-base scalar mul

        • internally, involves bit-decomposition of ss and elliptic curve addition based on the bits.

      • y=R+pkcy = R + pk^c: a variable-base scalar multiplication + an elliptic curve addition.

      • x=?yx\overset{?}{=}y: point equality check

Updating and Verifying LightClientState

The LightClientState is updated by any state prover that submits valid updates to the LightClient contract via the newFinalizedState method which sets the latest finalizedState.

function newFinalizedState(
    LightClientState memory newState,
    IPlonkVerifier.PlonkProof memory proof
) external {

We assume an altruistic, honest prover for now and leave the design of prover market to future work.

For replicas:

  • upon receiving new QC from the leader, generate a Schnorr signature over the updated finalized HotShot state, and send it over to the CDN and store it locally for a while.

  • the local storage for the Schnorr signatures (for each block) can be a sliding window of a fixed size where older signatures got pruned. The window size can be set based on the expected interval for on-chain update plus some buffer accounting for temporarily failing prover.

For the altruistic prover:

  • will continuously listen passively for the HotShot state changes, e.g. when a new block has been decided

  • periodically requests the Schnorr signatures from the DA layer by sending a request on a specific view vv. This will be the signature for the new finalized state from the consensus nodes

    • For convenience of signature collection, prover will first fetch from CDN (we refer to as the relay server) for the list of Schnorr signatures, if failed, then ask each individual replica (note: not small DA committee, or VID, but each node individually).

  • Once a sufficient amount of valid signatures is collected, some provers can then generate a SNARK proof which is submitted alongside the new finalized state to the light client contract.

For the next release, only a permissioned prover doing the computations will call this function.

Replica nodes update the snapshot of the stake table at the beginning of an epoch and this snapshot is used to define the set of stakers for the next epoch. The light client state must be updated at least once per epoch.

For the next release, we are not using epochs so numBlockPerEpoch is set to type(uint32).max during deployment.

HotShot state authentication via Schnorr signatures

When a set of HotShot nodes reach consensus and the finalized HotShot state has been updated, they each sign a Schnorr signature on this updated HotShot state. These signatures assert that the signer agrees with the state of each proposed block. The signatures are stored locally on the DA layer, and to save space, older signatures are pruned using a sliding window mechanism. The window size can be set based on the expected interval for on-chain update plus some buffer accounting for a temporarily failing prover.

When a prover (an entity that confirms the truth of a claim) retrieves these signatures, a SNARK proof is then generated. This proof, is used by the LightClient contract to efficiently verify these Schnorr signatures. The state of the sequencer contract can be updated only if a correct SNARK proof is provided. This is a critical step that ensures the validity and security of state updates in Espresso's consensus protocol⁠.

The proof of the Schnorr signatures is sent to the newFinalizedState function of the LightClient contract.

Verifying the Signatures and Light Client State

The LightClient contract also does the work of verifying the proof that is sent by the prover on L1. The verifyProof method accepts the proof and a set of public inputs (the LightClientState) to check whether the proof correctly verifies the new state being submitted.

function verifyProof(LightClientState memory state, IPlonkVerifier.PlonkProof memory proof)
        IPlonkVerifier.VerifyingKey memory vk = VkLib.getVk();
        uint256[] memory publicInput = preparePublicInput(state);

        if (!PlonkVerifier.verify(vk, publicInput, proof, bytes(""))) {
            revert InvalidProof();

Verifying a SNARK proof requires a constant amount of space and computation, no matter how many HotShot node signatures are involved. This is unlike verifying the signatures directly, which would require space and computation proportional to the number of signers.

The proof itself contains the HotShot state, the stake table info and the list of Schnorr signatures of the HotShot nodes that formed a Quorum and came to consensus on that state.

This verifyProof method is executed when the newFinalizedState method is called so that the new state is accepted only if the proof succeeds.

Public Write Methods


This method updates the latest finalized light client state. It is updated per epoch. An update for the last block for every epoch has to be submitted before any newer state can be accepted since the stake table commitments of that block become the snapshots used for vote verifications later on.

in the next launch, only a permissioned prover doing the computations will call this function

function newFinalizedState(LightClientState memory newState, IPlonkVerifier.PlonkProof memory proof)





new light client state



Plonk proof


Given the light client state, compute the short commitment of the stake table

function computeStakeTableComm(LightClientState memory state) public pure returns (bytes32);

Light Client Contract UML

Light Client Contract Interaction Diagram

Last updated