Espresso Q1 2025 Epoch Change Protocol Audit Report Final
Security Audit Report
Espresso Q1 2025: Epoch Change Protocol
Last revised 09.04.2025
Authors: Tatjana Kirda, Josef Widder, Martin Hutle, Hernán Vanzetto ©2025 Informal Systems Espresso Q1 2025
Contents Audit Overview 3 The project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Scope of this report . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Audit plan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Audit Dashboard 4 Target summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Engagement summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Severity summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
System Overview 5 Epoch transition description . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 View changes by timeouts and view synchronization . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
Correspondence Between Specification and Implementation 7 Implementation of the specified functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 Core implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Threat Model 17 Approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 Optimal run . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 Timeout / Sync View change in v and v+1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 Timeout / Sync View change in v+2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 Timeout / Sync View change in v+3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 Epoch sync . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 Local concurrency considerations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
Findings 25 Threshold calculation inconsistency between stake and node count . . . . . . . . . . . . . . . . . . . . . . 26 Unreachable epoch handling code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 Miscellaneous code findings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 Specification is lacking details and is inconsistent with implementation . . . . . . . . . . . . . . . . . . . . 29
Appendix: Vulnerability classification 31
Disclaimer 34
2 ©2025 Informal Systems Espresso Q1 2025
Audit Overview The project In March 2025, Espresso Systems engaged Informal Systems to work on a partnership and conduct a security audit of the following items in espresso-network software:
- Introduction of epoch change function in the HotShot protocol.
Relevant code commits The audited code was from: • commit hash 1ed39cc0a835d34653ccbc36993e00548bbdf72f
Scope of this report The audit conducted an evaluation of the correctness and liveness properties associated with the modifications introduced through the epoch change mechanism in the HotShot protocol. The review focused on the examination of the code additions that support epoch changes, paying special attention to edge cases defined in the threat model.
Audit plan The audit was conducted between March 13th, 2025 and April 4th, 2025 by the following personnel: • Hernán Vanzetto • Josef Widder • Martin Hutle • Tatjana Kirda
Conclusions After conducting a thorough review of the project, we found it to be carefully designed and generally well-implemented. We found that the mapping between the specification and the implementation was not obvious, so we documented in the section “Correspondence Between Specification and Implementation” how the specified functions are represented in the code, and checked whether the discrepancies cause any issues. In the end, we used the implementation to understand the protocol where documentation was lacking detail. Based on all the information, we formulated potential scenarios that could happen during the epoch change, and analyzed them in the “Threat model” section. As a result, in addition to the mentioned analysis section, during the audit, 4 findings were documented. One is classified as critical severity, the others as informational severity. Detailed descriptions of these issues are available on the “Findings” section.
3 ©2025 Informal Systems Espresso Q1 2025
Audit Dashboard Target summary • Type: Protocol and Implementation • Platform: Rust • Artifacts: – Changes related to epoch change in folders: ∗ hotshot-task-impls/src/consensus ∗ hotshot-task-impls/src/quorum_proposal ∗ hotshot-task-impls/src/quorum_proposal_recv ∗ hotshot-task-impls/src/quorum_vote – Additional helper function related to the epoch change in files: ∗ hotshot-task-impls/src/helpers.rs ∗ hotshot-task-impls/src/vid.rs ∗ types/src/v0/impls/stake_table.rs ∗ hotshot-types/src/utils.rs ∗ hotshot-types/src/consensus.rs ∗ hotshot-types/src/data.rs
Engagement summary • Dates: March 13th, 2025 - April 4th, 2025 • Method: Manual code review, protocol analysis
Severity summary
Finding Severity Number Critical 1 High 0 Medium 0 Low 0 Informational 3 Total 4
4 ©2025 Informal Systems Espresso Q1 2025
System Overview HotShot is the Consensus Protocol that is employed in the Espresso Sequencer. It works together with a DA layer to order transactions for the L2 rollup.
Figure 1: Espresso Sequencer
The HotShot protocol is based on a chained version of the HotStuff-2 consensus protocol. In incorporates the following concepts that are important for our analysis: • There is no fixed set of nodes but a dynamic changing set of validators nodes in a proof-of-stake setting. Every K blocks, a new epoch is started, the validator set and stake table for each epoch e+1 is determined at the beginning of epoch e and stored on the L1 chain. • As all protocols of the HotStuff family, HotShot operates in views. The views and a leader for each view are provided by a pacemaker protocol that manages timeouts, view sync and leader election. To enable bribery resistance, leader election and view sync are not deterministically determined but derived from a decentralized random beacon. • Threshold cryptography is used to build a certificate with lower communication complexity. • Data propagation (transactions, blocks) is deferred to a data availability layer. Focus of this audit is the epoch change protocol, i.e. the modification to the consensus algorithm to allow transitioning from an epoch e to another epoch e+1 with a different validator set and stake table.
Epoch transition description Each epoch consists of constant number K of blocks. The staking table for each epoch e is known at the beginning of epoch e-1. Stake tables are stored in L1 contract. Nodes in both epochs e and e+1 have access to both stake tables.
5 ©2025 Informal Systems Espresso Q1 2025
Transition from epoch e to e+1 in a favorable run:
- The leader l_v of view v sends a quorum proposal for the last block B_k of epoch e to all nodes in e and e+1.
- The nodes in e and e+1 validate the proposal and send the votes to l_v. The nodes store the last block certificate C(B_k’) as highQC.
- The leader of view v+1 in epoch e accumulates the votes. The QC1 for B_k can be formed once the thresholds for e and e+1 are reached. The leader sends the quorum proposal using the new formed QC1 to the nodes in epoch e and e+1.
- The nodes in e and e+1 store the QC1 they receive from the leader as highQC and send their vote for the proposal of the leader of v+1 to the leader of v+2.
- The leader of view v+2 accumulates the votes and form the new certificate when both thresholds have been reached. The leader sends a new proposal with the new formed QC2 to the nodes in e and e+1. This is the proposal for the extended quorum certificate.
- The nodes in e and e+1 store the QC2 as highQC and send their votes for the extended proposal to all nodes in epoch e+1 (only).
- Each node in e+1 accumulates the votes from the nodes in e and e+1. Once the threshold is reached the extended quorum certificate eQC is formed and the block B_k is decided. The leader of v+3 of epoch e+1 builds a new block B_{k+1} and sends it as a proposal with eQC as justification to all nodes in e+1.
View changes by timeouts and view synchronization • If a view change is not based on the quorum certificates (i.e., nodes do not have a QC for views v, v+1 or v+2, but progress based on timeout certificates or view synchronization certificates, they need to restart in step 1 of the protocol. • The synchronization across epochs is done among the nodes in e+1 using the Naor-Keidar procotol (as for normal view synchronization) but with the eQC as justification and with the nodes from e as trigger.
6 ©2025 Informal Systems Espresso Q1 2025
Correspondence Between Specification and Implementation This document outlines the relationship between the pseudo-code in the consensus protocol specification and its current Rust implementation. While the high-level flow is similar, the mapping between pseudo-code and implementation is not always exact. The Rust code addresses complexities that are not present in the specification, employs different control flows, and addresses additional edge cases. We aim to align the simplified pseudo-code with the code references and highlight significant differences.
Implementation of the specified functions Function certificate(type, data, view) function certificate(type, data, view): c.type ← type c.data ← data c.view ← view c.signatures ← votes_map[(vote.type, vote.data, vote.view, current_epoch)] if data.height mod K == 0: // add optional signatures from the next epoch when applicable c.next_epoch_signatures <- votes_map[(vote.type, vote.data, vote.view, next return c
• Partially defined in the function create_signed_certificate. The implementation depends on the type of the certificate, and it’s defined in the hotshot-types/src/simple_certificate.rs file. – It’s called from the functions build_cert and accumulate. • if data.height mod K == 0 section is implemented in the function is_last_block_in_epoch. This function is used in several places to handle epoch transitions, the relevant for this context being in quorum_vote/handlers.rs, when submitting votes, function submit_vote (code ref). • Votes must be accumulated to create the certificate. In the submit_vote function, a node can vote only if it is a committee member in either epoch e or e+1 (code ref). The votes are processed in consensus/handlers.rs, where they are collected in both the current and next epoch vote collectors (code ref).
Function vote(type, data, view) function vote(type, data, view): v.type <- type v.data <- data v.view <- view v.signature <- signVote(type, data, view) return v
Implemented in the function create_signed_vote, which is called from the function submit_vote function (code ref), as well as in multiple points in the hotshot-task-impls/src/view_sync.rs file.
Function validSignature(data, signature, public_key) function validSignature(data, signature, public_key): if commit(data) == decrypt(signature, public_key) return true return false
The validSignature function is called during both proposal validation and vote validation.
7 ©2025 Informal Systems Espresso Q1 2025
• When validating a proposal, this function maps to the validate_signature function that checks the signature against the proposal data. This function is called from the function validate_proposal_view_and_certs (code ref). • When receiving the votes, the vote signature is validated as part of the accumulate function (code ref). Note that the Rust implementation does not have a direct one-to-one correspondence with this function.
Function validVote(vote, node) function validVote(vote, node) // old vote if vote.view < current_view return false // This a vote for the last block in epoch if vote.data.height mod K == 0: epoch <- vote.data.height / K // Votes for the eQC go to every node in e+1 // This is not a vote for the eQC, it should go to the leader in the old epoch if vote.type != "extended": if we are not leader of vote.view + 1 in epoch return false // Nodes from both epochs can vote for the last block in epoch if node not in epoch and node not in epoch+1: return false else epoch <- vote.data.height / K + 1 if we are not leader of vote.view+1 in epoch return false if node not in epoch: return false if not validSignature((v.type, v.data, v.view), v.signature, node.public_key) return false return true
The function handle_quorum_vote_recv handles the validation of the vote. • The vote view is validated in the accumulate_vote function (code ref). • When validating the leader in the function handle_quorum_vote_recv (code ref), the utilized epoch is retrieved from vote.data.epoch (code ref). • As previously mentioned, the vote signature is validated as part of the accumulate function (code ref).
Function accumulateSignatures(vote, node) function accumulateSignatures(vote, node): // votes_map has a static lifetime // votes_map maps tuple (type, data, view, epoch) // to accumulated signatures epochs = [] if vote.data.height mod K == 0: epoch <- vote.data.height / K if node in epoch: epochs.add(epoch) if node in epoch+1: epochs.add(epoch+1) else: epoch <- vote.data.height / K + 1 if node in epoch:
8 ©2025 Informal Systems Espresso Q1 2025
epochs.add(epoch) for epoch in epochs: if votes_map[(vote.type, vote.data, vote.view, epoch)] is not empty: signatures <- votes_map[(vote.type, vote.data, vote.view, epoch)] // adding a signature to signatures means: // cryptographically accumulate partial signatures votes_map[(vote.type, vote.data, vote.view, epoch)] <- signatures + vote.s else: votes_map[(vote.type, vote.data, vote.view, epoch)] <- vote.signature
if data.height mod K == 0: • Implemented in the function is_last_block_in_epoch. This function is used in multiple locations in the code to handle epoch transitions, notably in consensus/handlers.rs. When handling votes, the function handle_quorum_vote_recv uses the transition_indicator and utilizes the function is_high_qc_for_last_block (code ref). epoch <- vote.data.height / K and epoch <- vote.data.height / K + 1: • The epoch calculation is implemented in the option_epoch_from_block_number function. When accumulating the votes, the epoch from the vote sender is used (code ref). When the vote is created, the epoch is determined using the option_epoch_from_block_number function (code ref). Votes for both the current and next epochs are handled in the function handle_quorum_vote_recv when handle_vote is called (code ref). This function handles the creation of the vote collector and uses the appropriate handle_vote_event implementation based on the vote type being processed. Current and next epoch votes are accumulated separately in separate vote collectors. The function accumulate accumulates the voters’ stake (code ref) and the signatures (code ref). The mapping between a voter’s public key and their vote information are stored in the vote accumulator (code ref).
Function thresholdReached(vote) function thresholdReached(vote): // comparison checks whether we have enough accumulated partial signatures two_epochs_threshold = false if vote.data.height mod K == 0: two_epochs_threshold = true epoch <- vote.data.height / K else: epoch <- vote.data.height / K + 1 if two_epochs_threshold: votes_map[(vote.type, vote.data, vote.view, epoch)] >= CURRENT_EPOCH_T and votes_map[(vote.type, vote.data, vote.view, epoch+1)] >= NEXT_EPOC else votes_map[(vote.type, vote.data, vote.view, epoch)] >= CURRENT_EPOCH_T
The Rust implementation uses separate vote collectors for the current and next epochs and checks thresholds independently in each collector. The accumulate function checks if enough votes have been collected to reach the threshold (code ref). The threshold is determined by the threshold function, which is implemented differently for each certificate type. In the current implementation, the success threshold is determined based on the number of nodes participating in the epoch, and it is compared to the stake of the voters, not the signers.
Function proposal(type, data, view, cert) function proposal(type, data, view, cert):
9 ©2025 Informal Systems Espresso Q1 2025
p.type <- type p.data <- data p.view <- view p.justify_cert <- cert p.signature <- signProposal(type, data, view, cert) return p
Implemented as part of the publish_proposal function (code ref).
Function formProposal(cert) function formProposal(cert): // This is the last block in the current epoch if cert.data.height mod K == 0: // We've just formed eQC, this means we are leader in the next epoch // Obtain a new block and use eQC as justification if cert.type == "extended": type <- "quorum" data <- obtain a new block // We're still forming eQC, reuse data else: data <- cert.data // high_qc and locked_qc are for the same block // and we are in the third consecutive view. // We can try to form eQC if high_qc.data == locked_qc.data and current_view - 1 == high_qc.view and high_qc.view - 1 == locked_qc.view: type <- "extended" else: type <- "quorum" view <- current_view justify_cert <- cert // This is NOT the last block in the current epoch else: data <- obtain a new block view <- current_view justify_cert <- certfunction formProposal(cert return proposal(type, data, view, justify_cert)
This function is partially implemented as a component of the publish_proposal function (code ref). The func- tion is_qc_forming_eqc returns true if the QC is for the last block in the epoch and it’s not an eQC. If the is_qc_forming_eqc returns true, the block header is reused, otherwise, a new block is used. When forming the proposal, there is no explicit setting of the certificate type. In the code, the extended QC is formed after enough votes have been accumulated. • The justify_cert <- cert part is implemented when creating the proposal (code ref). The parent_qc is retrieved in function handle_dep_result (code ref). • The view <- current_view component is implemented when creating the proposal (code ref). The view_number is part of the state of ProposalDependencyHandle (code ref).
Function broadcast_proposal(proposal) function broadcast_proposal(proposal): if proposal.data.height mod K == 0:
10 ©2025 Informal Systems Espresso Q1 2025
epoch <- proposal.data.height / K broadcast proposal to nodes in epoch and epoch+1 else: epoch <- proposal.data.height / K + 1 broadcast proposal to nodes in epoch
The proposal is broadcasted from the function publish_proposal (code ref). The parse_event function determines the type of the transmit (code ref). The broadcast section doesn’t dif- ferentiate between the end of the epoch and the regular proposal. That distinction is implemented in the vote handling logic, specifically in the submit_vote function, based on the extended_vote flag (code ref). The votes would be broadcasted to everyone in the case of HotShotEvent::ExtendedQuorumVoteSend (code ref), while the HotShotEvent::QuorumVoteSend vote is sent only to the leader (code ref).
Function validProposal(proposal, node) function validProposal(proposal, node): if proposal.view < current_view return false if not validSignature((proposal.type, proposal.data, proposal.view, proposa.just return false if not validLeader(proposal, node): return false if proposal.type == "extended": if not
Excerpt (19967 of 75057 characters). Read the whole page on informalsystems/audits ↗