Introduction
On April 22, 2024, Matter Labs tasked zkSecurity with auditing its Era Consensus implementation. The specific code to review was shared via GitHub as a public repository (https://github.com/matter-labs/era-consensus at commit e008dc2717b5d7a6a2d8fb95fa709ec9dd52887f
). Matter Labs emphasized that this was a preliminary review, with another one planned in the future, when the code is more stable. The audit lasted 3 weeks with 2 consultants.
Internal design documentation was also provided by Matter Labs, including a network design document, a specification on their consensus protocol (called Two-step Fast-HotStuff), a document on security considerations, a security review on their cryptographic primitives, a roadmap to gradually replace the current protocol with their decentralized sequencer, a document on handling hard forks, and a rationale document on the reasons behind decentralizing the sequencer.
The documentation provided, and the code that zkSecurity looked at, was found to be of great quality. In addition, the Matter Labs team was responsive and helpful in answering questions and providing additional context.
Scope
The work centered around the consensus implementation, which comprised of:
- An Actor model with three actors (called bft, network, and sync block, although the last one was later merged in the network actor) and a supervisor (called executor).
- A variety of libraries to support the topology of the network, including an instantiation of the Noise protocol framework to secure connections between nodes.
- Cryptographic wrappers, as well as an implementation of the BLS signature scheme using the BN254 curve.
Overview of Era Consensus
The zkSync Era consensus protocol is to be seen in the context of the zkSync Era rollup. Currently, the zk rollup has a single sequencer, which processes transactions and orders them before their execution can be proven (and before the proof can be submitted on-chain). The goal of the consensus protocol is to decentralize the liveness of the zkSync protocol, by having the on-chain smart contract be a node processing the output of the consensus.
The consensus protocol we reviewed is a mix of FaB Paxos and Fast-HotStuff, which assumes participants including byzantine nodes. This is a larger threshold, and as such tolerates less faulty nodes, than some of the other consensus protocols which usually rely on participants (and so can tolerate a third of the nodes being faulty concurrently). The reason is that this threshold allows for a smaller number of rounds to commit. This insight also appeared in Good-case Latency of Byzantine Broadcast: A Complete Categorization which proved that a closely-related bound () is the minimum threshold (of byzantine node to number of nodes) to achieve such a short number of round trips before being able to commit.
The protocol works in successive views, in which an elected leader (via a round-robin election) proposes a single block, and other participants (called replicas) attempt to commit to it.
As different replicas see different things, the subtlety of the protocol is to ensure that the protocol never leads to forks, meaning that different replicas commit to conflicting blocks. In the current implementation, a fork would translate to different honest replicas commiting to different blocks sharing the same block number.
To understand the protocol, one must understand that there are three important cases that need to be taken into account:
- A proposal is committed and the protocol moves on.
- The protocol times out but the proposal is committed by some.
- The protocol times out and the proposal is not committed.
The protocol aims at being able to go “full-throttle” when the first case happens (which is expected to be true for long periods of “normal” operations), while making sure that if case 2 happens, then the next leader is forced to repropose to ensure that every honest node eventually commits the proposal.
Sometimes, as you will see, case 3 leads to a missed proposal still being reproposed, because we are not sure if we are in case 2 or 3. But before we can explain how the safety of the protocol is guarded, let’s see how things work when we’re in the first case.
The “Happy Path” of The Protocol
There are four steps to a view, which are split equally into two “phases”: a prepare phase, and a commit phase. Messages from the leader of a view always start with Leader_
and end with the name of the phase, same for the replicas.
A new view can start (in the prepare phase) for two reasons: either because things went well, or because replicas timed out. We will discuss these in detail later.
During the prepare phase, a leader proposes a block using a LeaderPrepare
message, which it broadcasts to all replicas (including itself). In the current implementation, a block can be uniquely identified using its block number and hash. In addition, the LeaderPrepare
message must carry a justification for why the leader is allowed to enter a new view and propose (more on that later).
After receiving a LeaderPrepare
message, each replica will verify the proposal as well as the justification (more on that later), and vote on it if they deem it valid. Voting on it happens by sending a ReplicaCommit
message back to the leader, which contains a signature of the view and the proposal.
If the leader can collect a “threshold” of (specifically, ) such messages, it creates a certificate called a CommitQC
that proves that a block can be committed, and broadcasts it to all replicas (within a LeaderCommit
message).
Importantly, if at this point a leader can create a CommitQC
, then we know that the block can be committed even if the leader fails to broadcast it in the next step. We will explain later why.
When replicas receive this message, they are ready to enter the next view (which they can do by incrementing it like a counter). When entering a new view, they nudge the newly-elected leader to do so as well by sending them a ReplicaPrepare
message.
A ReplicaPrepare
message is sort of an informational announcement about a replica’s state, which carries their highest vote (for the highest view) as well as the highest committed block they’ve seen (through a CommitQC
).
The new view’s leader, who sees such a CommitQC
in one of the replicas’ message can use that as justification to enter the new view and propose a new block.
Note that while in the “happy path”, it is enough to justify a proposal with the previous view’s CommitQC
, the implementation currently has leaders include a threshold ( specifically) of such ReplicaPrepare
messages.
As such, a replica can commit to a block in any of the two types of leader messages they can receive:
- when they observe a
CommitQC
in aLeaderCommit
message, at the end of a view, or - if they fail to observe such a view ending (due to network issue, for example), they will see it in the next
LeaderPrepare
message which will carry them in theReplicaPrepare
messages it carries
We summarize the full protocol in the following diagram:
Next, let’s talk about what happens when things don’t go so well…
The “Unhappy Path”: Timeouts and Reproposals
The previous explanation emphasized the “happy path”, which is the easiest way to understand the protocol.
What if a network issue, or slow participants (including a slow leader), or worse malicious participants (including a malicious leader), prevent the protocol from moving forward at one of the previous steps?
In this case, replicas are expected to wait a certain amount of time until they time out. The time they will wait depends on how long it’s been since they last saw a commit. When a time out happens, replicas preemptively enter the next view, sending the same ReplicaPrepare
message as we’ve seen previously.
The next leader will use a threshold of them (which some protocols call that a “timeout certificate”) as justification to enter the new view. This justification will show that enough replicas were ready to enter the new view.
Importantly at this point, we must be able to detect if it is possible that something was committed by an honest node. One example of such a scenario is the leader being honest and committing but being unable to broadcast the CommitQC
due to network issues. Another example could be a malicious leader that would only send the CommitQC
to a single node (who would then not be able to share it with the rest of the participants).
As the justification carried by the LeaderPrepare
message also include the highest vote and highest commit of replicas, we can distinguish two scenarios:
- a commit was seen by some people
- no commit was seen by most people
The first case is easy to handle, as anyone can see the previous CommitQC
in some of the replicas’ status, and as we said previously a CommitQC
is enough of a justification for the leader to propose something new.
We illustrate such a scenario in the following diagram:
The latter case will also be obvious, as the high_vote
of replicas won’t match their high_commit
. But still… it doesn’t give us the full picture. Since we only see a threshold of the status, it is not immediately obvious if some of the silent honest replicas have committed to the previous proposal.
Either the proposal failed to commit, or some nodes committed to it but failed to broadcast the CommitQC
. If some honest nodes committed to it, we need to honor their decision and ensure that we do not throw away the previous proposal. If we were to do that, we would allow other nodes to commit to something at the same block number (which would be a fork, and thus a breach of the safety property of the consensus protocol).
The solution is in this simple rule: if the leader proposal’s justification carries a subqorum of high votes that are for the previous proposal, then we force the leader to repropose the previous proposal. We illustrate such a scenario in the following diagram:
Why does this work? Let’s look at the following safety proof. We need to prove that if someone has committed a proposal , then either a new view’s leader will extend the proposal (by including a CommitQC
for proposal ), or they will repropose the proposal .
- Let’s say that someone commits a proposal .
- This can only happen because they saw a
CommitQC
on proposal . - Which can only happen because there were votes for the proposal .
- Out of these votes, at least votes are from honest nodes.
- In the next view, the proposer will carry a
PrepareQC
showcasing new view messages from replicas, which will showcase the replicas highest votes. - These highest votes will be taken as a combination of highest votes.
- From the honest nodes, AT MOST might not be included in the highest votes.
- This means that AT LEAST of these votes on proposal will be included in the new leader’s proposal.
- Since there were , the new leader MUST repropose it, and all honest nodes will verify that this was done.
As such, we can say with strong confidence that a block gets committed as soon as honest nodes vote for it.
Catching up
In case a node is lagging behind, there needs to be a way for them to catch up.
There’s a few situations where a node realizes that they are lagging behind, which arises when receiving any of the four different types of messages. As such, a node should always be ready to receive messages that are in future views.
A node should also make sure that they are not being fooled (otherwise a byzantine node could force other replicas to randomly catch up for no good reason). This can be done by always validating signatures and messages before acting on them, and only catching up when receiving a valid CommitQC
(which can be received in any of the messages except a ReplicaCommit
message).
In addition, one should avoid to slow down the protocol when possible. For example, a leader might receive enough information to understand that they have to repropose the previous proposal without knowing what it is (as enough replicas are ready to revote on it). Another example is if a replica sees a commitQC
for a view in the future, it should store it in its state as the highest commit seen without having to fetch all the now-committed blocks.
Catching up means that a replica will fetch all the blocks which have a block number between the highest one we have in storage and the one we just saw a CommitQC
for. Each block in between will come accompanied with their own CommitQC
, proving that they were committed at some point in time.
Weighted BFT
Such BFT protocols are often seen implemented (in cryptocurrencies) with a proof of stake protocol in order to define who the replicas are. In such a setting, nodes become replicas by staking some amount of tokens, and their votes are weighted based on the amount of tokens they have staked compared to the rest of the replicas.
A weighted vote essentially means that a replica can play the role of multiple replicas in the protocol. More subtly, what it also means is that the thresholds that are computed are not necessarily as exact as with the discrete version of a protocol. For example, while the discrete protocol might consider messages to represent a certificate, a weighted protocol might not be able to have exactly that number of messages (as the sum of the votes might either carry less or more, but not exactly, the threshold we have set).