The security and correctness of blockchain protocols are guaranteed by numerous advanced mathematical theories, which are complex and require a significant amount of time to comprehend for most people. Although there are tons of papers and technical blogs that “prove” the security of those protocols in human’s natural language, there is no guarantee that those “manually-proved” proofs are correct. Unfortunately, humans are often misguided, and we frequently make incorrect claims (as evidenced by numerous examples of widely accepted mathematical results that were later proven to be false, as discussed in this mathoverflow thread).
One way to make the proof more reliable is to formally verify its correctness using automated theorem-proving systems, such as Coq and Lean4. Those tools allow us to formulate and verify mathematical proofs systematically, while their use itself is not easy to learn.
As an introductory tutorial, this post utilizes Lean4 to formally verify the consistency of the simple consensus protocol introduced in Professor Tim Roughgarden's great lecture at Columbia University. Although the assumption of this protocol is stronger than real situations, the following posts in this series aim to demonstrate the correctness of more practical protocols, such as Tendermint.
At the heart of every blockchain protocol lies a fundamental challenge: How can a group of distributed computers agree on a single sequence of transactions? This problem is solved by what’s known as a consensus mechanism.
To put it simply, blockchains function as shared global computers — master programs that coordinate all applications running on them, such as smart contracts. For this to work, all participating computers (called validators) must agree on the blockchain’s history: a chain of transactions that everyone accepts as the source of truth.
Let’s clarify some key terms:
- Validators are physical machines that run the blockchain protocol.
- Transactions are user-submitted actions, such as transferring tokens or executing contract logic.
- Blocks are ordered sequences of transactions.
- A chain is a sequence of blocks, forming the ledger history.
The role of a consensus mechanism is to ensure that all validators agree on a single chain of blocks. At each time step, validators:
- Perform local computations,
- Receive transactions from users and messages from other validators,
- Send messages to others to coordinate their views of the chain.
For simplicity, let’s begin with two basic assumptions:
- Fixed validator set: The set of validators is predetermined and remains constant.
- Synchronized time: All validators share a common notion of time, meaning they produce blocks at the same time intervals.
These assumptions simplify the design and analysis of consensus protocols.
There are two key properties that a consensus mechanism should satisfy:
- Consistency: All validators should agree on the same transaction history. In formal terms, the local chain of each validator must be a prefix of some single, agreed-upon global chain.
- Liveness: Every valid transaction submitted by a client will eventually be included in the blockchain. That is, progress is guaranteed.
Protocol A
Let’s start with the most basic idea for achieving consensus among validators.
# Protocol AAt each time step, all validators do the following:
1. Validators take turns acting as the leader.
2. The current leader proposes a block of transactions.
3. The leader broadcasts this block to all other validators.
At first glance, this protocol seems reasonable. However, it fails in more realistic network settings. For example, if the current leader crashes or experiences network delays, some validators may not receive the block in time. This leads to inconsistency in the validators’ local views of the chain.
Protocol B
To address the limitations of Protocol A, we now design Protocol B, a more resilient consensus mechanism that ensures consistency and liveness under the following assumptions:
- Crash Faults: Some validators may crash and stop participating permanently.
- Synchronous Network: Messages are delivered within a known delay bound ∆ (Delta).
Then, we propose the following mechanism:
# Protocol BEach time interval is divided into views, and each view lasts 3Δ time steps.
Let v be the current view number. At each view v, the protocol proceeds as follows:
1. At time ∆ ⋅ v:
- Each validator i sends its current local chain Cᵢ to the designated leader ℓ for this view.
2. At time ∆ ⋅ v + ∆:
- The leader ℓ gathers the received chains.
- Let C be the longest among the received chains.
- The leader assembles a block B of all valid transactions it knows about that are not yet included in C.
- It forms the new chain C^ = (C, B) and broadcasts C^ to all validators.
3. At time ∆ ⋅ v + 2∆:
- If a validator i receives C^ by this time, it updates its local chain Cᵢ := C*.
In this blog post, we focus on why Protocol B guarantees consistency among all non-crashed validators.
We first show that at the end of every view v, the chains of all non-crashed validators are consistent, i.e., they are all prefixes of a single common chain. We use mathematical induction with respect to v.
Base Case (initial state):
All validators start with the same initial empty chain, so the claim holds trivially.
Inductive Step (view v):
Assume that at the beginning of viewv, the local chains of all non-crashed validators are consistent — they are all prefixes of a common chain.
- Each validator sends its chain Cᵢ to the leader ℓ. By the inductive hypothesis, all these Cᵢs are consistent.
- The leader selects the longest among them as C. Since all Cᵢs are consistent, C extend all of them.
- The new chain C* = (C, B) also extends all of them.
- Any validator that receives C* will update its local chain to C*, maintaining consistency.
- Even if some validators do not receive the new chain in time, their chains remain prefixes of C*, so consistency is preserved.
We then convert the above proof, written in human’s natural language, into a formal proof written in Lean4. We omit the detailed proofs of several lemmas for simplicity, but the complete code is available at https://github.com/Koukyosyumei/PoL.
We first model the basic components: block, chain, validator, and system. A block (Block) represented as a string (e.g., its hash). A chain (Chain) is a list of Blocks. Its current chain models a validator (Validator) and a flag indicating if it has crashed. A state of each view (StateB) consists of a list of validators and a designated leader.
abbrev Block := Stringabbrev Chain := List Block
structure ValidatorB where
chain : List Block
crashed : Bool
id : ℕ
deriving Repr
structure StateB where
validators : List ValidatorB
leader : ℕ
deriving Reprha
We then define the single step of the Protocol B and the state at each time step as follows. In each view, the leader collects the chains of the validators, selects the longest chain, constructs new_chain by appending a new block to that chain, and sends new_chainto every non‐crashed validator. Here, we treat the leader's crash at each time step (is_leader_crashed_at_t) and the choice of the next leader at each time step (next_leader_at_t) as black boxes.
def protocolB_step_with_crash(state : StateB) (new_block: Block) (is_leader_crashed: ℕ → Bool) (next_leader: StateB → ℕ) : StateB :=
let chains := state.validators.filterMap (λ v ↦ if v.crashed = false then some v.chain else none)
let longest_chain := get_longest_chain chains
let new_chain := longest_chain ++ [new_block]
let new_validators := state.validators.map (λ v ↦
if (¬ v.crashed) ∧ (¬ is_leader_crashed v.id) then { chain := new_chain, crashed := v.crashed, id := v.id } else v)
{ validators := new_validators, leader := next_leader state }
def protocolB_evolve
(initial_state : StateB) (blocks : ℕ → Block)
(is_leader_crashed_at_t: ℕ → ℕ → Bool) (next_leader_at_t: ℕ → StateB → ℕ)
: ℕ → StateB
| 0 => initial_state
| t+1 => protocolB_step_with_crash
(protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t)
(blocks t) (is_leader_crashed_at_t t) (next_leader_at_t t)
We then define the consistency of the system, which is the goal of our proof. Recall that two chains are consistent if one is a prefix of the other, and we define it as ChainsAreConsistent. This ensures there are no forks among the chains. Then, this system has the consistency property if the chains of any two non-crashed validators are consistent.
def ChainsAreConsistent (c₁ c₂ : Chain) : Prop :=c₁ <+: c₂ ∨ c₂ <+: c₁
def StateBIsConsistent (state : StateB) : Prop :=
∀ v₁ ∈ state.validators,
∀ v₂ ∈ state.validators,
¬v₁.crashed → ¬v₂.crashed → ChainsAreConsistent v₁.chain v₂.chain
Given the above building blocks, our goal is to prove the following theorem: if the initial state is consistent, which holds trivially since all validators start with empty chains, then, at any time step t, the state evolved under Protocol B remains consistent, regardless of the choice of leader or when the leader crashes
theorem protocolB_consistency(initial_state : StateB)
(blocks : ℕ → Block)
(is_leader_crashed_at_t: ℕ → ℕ → Bool)
(h_initial_consistent : StateBIsConsistent initial_state)
(next_leader_at_t: ℕ → StateB → ℕ) :
∀ t, StateBIsConsistent (protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t) := by
Since this blog is a tutorial, we avoid relying heavily on automation and instead provide detailed, step-by-step proofs to make the explanations as straightforward as possible.
As we did in the proof in natural language, we use mathematical induction with respect to the time stamp t . When t = 0 , the goal is exactly the assumption h_initial_consistent .
theorem protocolB_consistency(initial_state : StateB)
(blocks : ℕ → Block)
(is_leader_crashed_at_t: ℕ → ℕ → Bool)
(h_initial_consistent : StateBIsConsistent initial_state)
(next_leader_at_t: ℕ → StateB → ℕ) :
∀ t, StateBIsConsistent (protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t) := by
intro t
induction t with
| zero =>
exact h_initial_consistent
| succ t ih => {
We first define the abbreviation of key components:
| succ t ih => {-- Define the key components from the protocol step.
let state_t := protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t
let state_tp1 := protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t (t + 1)
let new_block := blocks t
let is_leader_crashed := is_leader_crashed_at_t t
let old_chains := state_t.validators.filterMap (fun v ↦ if v.crashed = false then some v.chain else none)
let longest_chain := get_longest_chain old_chains
let new_chain := longest_chain ++ [new_block]
have h_state_t : state_t = (protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t) := rfl
have h_state_tp1 : state_tp1 = (protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t (t + 1)) := rfl
have h_old_chains : old_chains = List.filterMap (fun v => if v.crashed = false then some v.chain else none) state_t.validators := rfl
have h_longest_chains : longest_chain = get_longest_chain old_chains := rfl
have h_new_chain : new_chain = longest_chain ++ [new_block] := rfl
have h_new_block : new_block = blocks t := rfl
Then, we use intro tactic as the pre-process:
intro v₁ hv₁ v₂ hv₂ hnc₁ hnc₂rw[← h_state_tp1] at hv₁ hv₂
As a consequence, our goal is transformed into the following:
initial_state : StateBblocks : ℕ → Block
is_leader_crashed_at_t : ℕ → ℕ → Bool
h_initial_consistent : StateBIsConsistent initial_state
next_leader_at_t : ℕ → StateB → ℕ
t : ℕ
ih : StateBIsConsistent (protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t)
state_t : StateB := protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t
state_tp1 : StateB := protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t (t + 1)
new_block : Block := blocks t
is_leader_crashed : ℕ → Bool := is_leader_crashed_at_t t
old_chains : List (List Block) := List.filterMap (fun v => if v.crashed = false then some v.chain else none) state_t.validators
longest_chain : Chain := get_longest_chain old_chains
new_chain : Chain := longest_chain ++ [new_block]
h_state_t : state_t = protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t t
h_state_tp1 : state_tp1 = protocolB_evolve initial_state blocks is_leader_crashed_at_t next_leader_at_t (t + 1)
h_old_chains : old_chains = List.filterMap (fun v => if v.crashed = false then some v.chain else none) state_t.validators
h_longest_chains : longest_chain = get_longest_chain old_chains
h_new_chain : new_chain = longest_chain ++ [new_block]
h_new_block : new_block = blocks t
v₁ : ValidatorB
hv₁ : v₁ ∈ state_tp1.validators
v₂ : ValidatorB
hv₂ : v₂ ∈ state_tp1.validators
hnc₁ : ¬v₁.crashed = true
hnc₂ : ¬v₂.crashed = true
⊢ ChainsAreConsistent v₁.chain v₂.chain
We first prove three auxiliary lemmas. h_chains_consistent says that any pair of the old chain (chain of the time stamp t ) satisfies the consistency.
have h_chains_consistent : ∀ c₁ ∈ old_chains, ∀ c₂ ∈ old_chains, ChainsAreConsistent c₁ c₂ := by {apply system_consistency_unfolded_to_chains
exact ih
rfl
}
h_prefix says that for any non-crashed validator at the t+1 -th state, its chain is the prefix of the new chain at t+1 , where new_chain is the newly updated chain at the time stamp t .
have h_prefix : ∀ v ∈ state_tp1.validators, v.crashed = false → v.chain <+: new_chain := by {intro v hv
obtain ⟨v_old, hv_mem, rfl⟩ := List.mem_map.1 hv
by_cases h₁ : ¬v_old.crashed = true;
. by_cases h₂ : is_leader_crashed_at_t t v_old.id = true;
. rw[← h_state_t] at hv_mem
rw[← h_old_chains]
have htmp : v_old.crashed = false := by simp_all
rw [← h_state_t] at ih
have h_validator_chain_prefix_of_longest_chain
:= validator_chain_prefix_of_longest_chain
state_t v_old old_chains longest_chain ih hv_mem htmp h_old_chains h_longest_chains
simp [h₁, h₂]
apply prefix_of_append_singleton
exact h_validator_chain_prefix_of_longest_chain
. simp[h₁, h₂]
unfold old_chains state_t at h_longest_chains
rw[← h_longest_chains]
. simp_all
}
h_nonupdate states that for any non-crashed validator at the t+1 -th state, if its chain is not equal to the new_chain , which means that the this validator couldn’t receive the new_chain at t , its chain belongs to the set of chains of the non-crashed validator at the t -th state.
have h_nonupdate : ∀ v ∈ state_tp1.validators, v.crashed = false → v.chain ≠ new_chain → v.chain ∈ old_chains := by {intro v hv hcf hne
obtain ⟨v_old, hv_mem, rfl⟩ := List.mem_map.1 hv
have h_crash_v : is_leader_crashed_at_t t v_old.id = true := by {
by_contra
rename_i h_id
by_cases h₁ : ¬v_old.crashed = true;
. simp [h₁, h_id] at hcf hv hne
rw[← h_state_t, ← h_old_chains, ← h_longest_chains, ← h_new_chain] at hne
simp at hne
. simp at h₁
simp[h₁] at hcf
}
rw[h_crash_v]
by_cases h₁ : ¬v_old.crashed = true;
. simp_all
rw[← h_state_t]
rw[← h_state_t] at hv_mem
use v_old
. simp_all
}
Then, our final proof is as follows. This is somewhat redundant, so I encourage interested readers to optimize and shorten this proof. Since our goal is ChainsAreConsistent v1.chain v2.chain , we consider the four cases: whether v1.chain = new_chainand whether v2.chain = new_chain .
by_cases h_c₁_new : v₁.chain = new_chain;. by_cases h_c₂_new : v₂.chain = new_chain;
. unfold ChainsAreConsistent
left
rw[h_c₂_new]
apply h_prefix
exact hv₁
simp at hnc₁
exact hnc₁
. unfold ChainsAreConsistent
right
rw[h_c₁_new]
apply h_prefix
exact hv₂
unfold Not at hnc₂
simp at hnc₂
exact hnc₂
. by_cases h_c₂_new : v₂.chain = new_chain;
. unfold ChainsAreConsistent
left
rw[h_c₂_new]
apply h_prefix
exact hv₁
unfold Not at hnc₁
simp at hnc₁
exact hnc₁
. apply h_chains_consistent
. apply h_nonupdate
exact hv₁
unfold Not at hnc₁
simp at hnc₁
exact hnc₁
exact h_c₁_new
. apply h_nonupdate
exact hv₂
unfold Not at hnc₂
simp at hnc₂
exact hnc₂
exact h_c₂_new
For example, when both are true, our proof proceeds as follows:
- We first begin by unfold ChainsAreConsistent , whicn converts the goal into:
2. We focus on the left predicate by left tactic:
⊢ v₁.chain <+: v₂.chain3. We rewrite v2.chain with new_chain based on h_c2_new :
⊢ v₁.chain <+: new_chain4. We apply h_prefix (h_prefix : ∀ v ∈ state_tp1.validators, v.crashed = false → v.chain <+: new_chain), yielding two goals, which are trivially satisfied by the existing assumptions.
⊢ v₁ ∈ state_tp1.validators⊢ v₁.crashed = false
We can also finish the proofs of the other three cases with similar tactics.
In this post, we introduce the use of Lean4 to formally verify the consistency property of a simple blockchain consensus protocol. Although it may seem like a complicated task, it is worth learning and exploring the world of formal methods if you are dealing with mathematical properties that are directly related to millions of assets, such as blockchains. In the following post, we will also provide detailed tutorials on more realistic protocols and properties of blockchains with Lean4.
.png)


