Created: 2025-11-24
Last Updated: 2025-11-27
Status: Verified - Spec models core protocol; see "Pacemaker: Spec vs Implementation" for production extensions
The TLA+ specification (hotstuff2.tla) accurately models the Go implementation.
Verification Results:
- Safety: 12 invariants verified (358K distinct states)
- Liveness: 4 properties verified (397K distinct states)
- Fault tolerance: Verified with f=1 crash fault (10K distinct states)
All critical protocol rules match:
- View guard: Strict equality (
v = view[r]) - Quorum:
2f+1formula - SafeNode: OR semantics (higher QC OR extends lock)
- Commit: Two-chain rule (consecutive QCs)
- Lock update: Higher view only
- Leader implicit vote: Leader counts its own vote (critical for liveness under faults)
| TLA+ Variable | Type | Go Field | Location |
|---|---|---|---|
view[r] |
Replica -> View |
Context.view |
context.go:24 |
lockedQC[r] |
Replica -> Block \cup {Nil} |
Context.lockedQC |
context.go:28 |
highQC[r] |
Replica -> Block \cup {Nil} |
Context.highQC |
context.go:31 |
committed[r] |
Replica -> SUBSET Block |
Context.committed |
context.go:34 |
votes[v][b] |
[View -> [Block -> SUBSET Replica]] |
Context.votes |
context.go:37 |
blocks |
SUBSET Block |
Context.blocksByHash |
context.go:40 |
blockParent[b] |
[Block -> Block \cup {Genesis}] |
Context.parents |
context.go:46 |
network |
SUBSET Message |
Network channels | hotstuff2.go:30 |
- Committed blocks: TLA+ tracks set of all committed blocks; Go tracks by height for efficiency
- Vote organization: TLA+ indexes by
[view][block]; Go indexes by[view][nodeHash][validatorIndex] - Network: TLA+ uses persistent message set; Go uses channels
| TLA+ Action | Go Method | Location | Match |
|---|---|---|---|
LeaderPropose(r) |
propose() |
hotstuff2.go:510-553 |
Yes |
ReplicaVote(r) |
onProposal() |
hotstuff2.go:174-279 |
Yes |
LeaderFormQC(r) |
onVote() |
hotstuff2.go:289-388 |
Yes |
ReplicaUpdateOnQC(r) |
onProposal() (QC handling) |
hotstuff2.go:190-205 |
Yes |
ReplicaTimeout(r) |
onViewTimeout() |
hotstuff2.go:461-477 |
Yes |
LeaderProcessNewView(r) |
onNewView() |
hotstuff2.go:431-452 |
Partial |
TLA+ (hotstuff2.tla:179-205):
LeaderPropose(r) ==
/\ r \in Honest \* Only honest leaders propose
/\ r = Leader(v)
/\ proposals[v] = Nil
/\ Create new block with parent = highQC
/\ Broadcast PROPOSE message
/\ Add leader's own VOTE to network \* CRITICAL: implicit voteGo (hotstuff2.go:608-663):
func (hs *HotStuff2[H]) propose() {
if !hs.cfg.IsLeader(view) { return }
block, err := hs.cfg.Executor.CreateBlock(height, parentHash, hs.cfg.MyIndex)
hs.cfg.Network.Broadcast(proposal)
// Leader implicitly votes for its own proposal (CRITICAL for liveness)
// With n=3f+1 and f faults, only 2f+1 honest nodes remain.
// If leader doesn't vote: only 2f non-leader votes available < 2f+1 quorum
leaderVote, _ := NewVote(view, block.Hash(), hs.cfg.MyIndex, hs.cfg.PrivateKey)
hs.ctx.AddVote(leaderVote)
}Why this matters: Without the leader's implicit vote, liveness fails under Byzantine faults. With n=4 and f=1 faulty replica, we have 3 honest nodes. If the leader is honest but doesn't count its own vote, only 2 votes are available (from non-leader honest nodes), but quorum requires 2f+1=3 votes. This causes deadlock.
TLA+ (hotstuff2.tla:244-261):
ReplicaVote(r) ==
/\ msg.type = "PROPOSE"
/\ v = view[r] \* CRITICAL: Current view only
/\ SafeNodeRule(r, block, qc)
/\ Send VOTE message
/\ Update lockedQC if qc.view > lockedQC.viewGo (hotstuff2.go:174-279):
func (hs *HotStuff2[H]) onProposal(payload ConsensusPayload[H]) {
if view != currentView { return } // TLA+ guard: v = view[r]
if !hs.ctx.SafeToVote(block, justifyQC) { return } // SafeNodeRule
hs.ctx.UpdateLockedQC(justifyQC) // UpdateLockCond
hs.cfg.Network.Broadcast(voteMsg)
}TLA+ (hotstuff2.tla:163-166):
SafeNodeRule(r, block, qc) ==
\/ lockedQC[r] = Nil
\/ (qc /= Nil /\ blockView[qc] > blockView[lockedQC[r]])
\/ IsAncestor(lockedQC[r], block)Go (context.go:314-332):
func (c *Context[H]) SafeToVote(block Block[H], justifyQC *QC[H]) bool {
if c.lockedQC == nil { return true }
if justifyQC != nil && justifyQC.View() > c.lockedQC.View() { return true }
if c.isAncestorLocked(c.lockedQC.Node(), block.Hash()) { return true }
return false
}Analysis: Exact match - OR semantics preserved
TLA+ (hotstuff2.tla:178-181):
UpdateLockCond(r, qc) ==
/\ qc /= Nil
/\ qc \in blocks
/\ (lockedQC[r] = Nil \/ blockView[qc] > blockView[lockedQC[r]])Go (context.go:99-113):
func (c *Context[H]) UpdateLockedQC(qc *QC[H]) bool {
if qc == nil { return false }
if c.lockedQC == nil || qc.View() > c.lockedQC.View() {
c.lockedQC = qc
return true
}
return false
}Analysis: Match - Go assumes QC validity (verified elsewhere)
TLA+ (hotstuff2.tla:170-175):
CanCommit(block, qc) ==
/\ qc /= Nil
/\ qc \in blocks
/\ block \in blocks
/\ blockParent[qc] = block
/\ blockView[qc] = blockView[block] + 1Go (hotstuff2.go:398-429 and context.go:359-378):
func (hs *HotStuff2[H]) checkCommit(block Block[H], qc *QC[H]) {
qcBlock, exists := hs.ctx.GetBlock(qc.Node())
if !qcBlock.PrevHash().Equals(block.Hash()) { return }
hs.ctx.Commit(block)
}
func (c *Context[H]) CanCommit(block Block[H], qc *QC[H]) bool {
qcBlock, exists := c.blocksByHash[qc.Node().String()]
if !qcBlock.PrevHash().Equals(block.Hash()) { return false }
return true
}Analysis: Match - both verify parent-child relationship
TLA+ (hotstuff2.tla:94-109):
RECURSIVE AncestorK(_,_,_)
AncestorK(a, b, k) == ...
IsAncestor(a, b) == \E i \in 0..MaxHeight : AncestorK(a, b, i)Go (context.go:286-312 and context.go:334-357):
func (c *Context[H]) IsAncestor(ancestor H, descendant H) bool {
if ancestor.Equals(descendant) { return true }
currentStr := descendant.String()
for i := 0; i < maxDepth; i++ {
parent, exists := c.parents[currentStr]
if ancestor.Equals(parent) { return true }
currentStr = parent.String()
}
return false
}Analysis: Match - both traverse parent chain with bounded depth
All 12 safety invariants from the TLA+ spec are satisfied:
| Invariant | TLA+ Line | Description |
|---|---|---|
TypeInvariant |
169-179 | Variables maintain correct types |
AgreementInvariant |
374-379 | No conflicting commits at same height |
ValidityInvariant |
381-382 | Only proposed blocks committed |
QCIntegrityInvariant |
384-387 | QCs require quorum |
ForkFreedomInvariant |
389-395 | No conflicting QCs at same height |
CommitChainInvariant |
397-400 | Committed blocks form valid chain |
NoDoubleVoteInvariant |
402-406 | At most one vote per view per replica |
SingleProposalInvariant |
408-409 | At most one proposal per view |
VotesSubsetReplicasInvariant |
411-414 | Votes from valid replicas only |
UniqueQCPerViewInvariant |
416-419 | At most one QC per view |
HonestAgreementInvariant |
430-435 | Honest replicas never commit conflicting blocks |
HonestValidityInvariant |
438-439 | Honest commits are valid proposed blocks |
The TLA+ spec models Byzantine fault tolerance via the Faulty constant:
| TLA+ Construct | Description |
|---|---|
Faulty |
Set of faulty replica IDs (configured in .cfg file) |
Honest == Replica \ Faulty |
Set of honest (non-faulty) replicas |
FaultyAssumption |
Invariant: |Faulty| <= f |
The current model is crash faults (fail-stop):
- Faulty replicas never participate (no votes, proposals, or NEWVIEWs)
- All TLA+ actions have guard
r \in Honest - This models worst-case liveness: maximum unresponsive nodes
These are verified once at model initialization, not as state invariants:
| Assumption | Description |
|---|---|
Cardinality(Faulty) <= F |
BFT assumption: at most f faulty replicas |
QuorumIntersectionProperty |
Any two quorums share at least one honest replica |
| Invariant | Description |
|---|---|
HonestAgreementInvariant |
Honest replicas never commit conflicting blocks |
HonestValidityInvariant |
Honest commits are valid proposed blocks |
Use hotstuff2_fault.cfg to test fault tolerance:
Replica = {1, 2, 3, 4}
Faulty = {4} \* Replica 4 is crash-faulty
The TLA+ spec abstracts these implementation details:
| Detail | TLA+ Abstraction | Justification |
|---|---|---|
| Cryptography | Boolean validity | Assumes crypto correct |
| Network (gRPC) | Async message set | Models delivery semantics |
| Timer/pacemaker | Nondeterministic timeout | Any replica can timeout |
| Ed25519 signatures | Abstract signature | Scheme doesn't affect safety |
| Persistence | Atomic transitions | Assumes storage atomicity |
| Concurrency | Atomic actions | Each step is atomic |
These abstractions are valid because safety properties don't depend on timing or crypto implementation.
The Go implementation includes an extended pacemaker with production features not modeled in the TLA+ spec. This section documents these differences.
The spec models the core pacemaker behavior required for safety and liveness:
| Feature | TLA+ | Description |
|---|---|---|
TimeoutPropose |
Yes | Modeled via ReplicaTimeout action - any replica can timeout |
| View advancement | Yes | view' = view + 1 on timeout or QC formation |
| NEWVIEW messages | Yes | LeaderProcessNewView waits for 2f+1 NEWVIEWs |
| Exponential backoff | Implicit | Modeled as nondeterministic timeout (any time) |
The Go implementation (pacemaker.go, PacemakerConfig) includes additional features for production use:
| Feature | In Spec? | Purpose |
|---|---|---|
TimeoutPropose |
Yes | Wait time for block proposal |
TimeoutVote |
No* | Wait time for votes (unused in two-phase HotStuff-2) |
TimeoutCommit |
No | Target block time - minimum delay between blocks |
BackoffMultiplier |
No | Explicit exponential backoff factor (e.g., 1.5x) |
MaxTimeout |
No | Cap on timeout growth |
SkipTimeoutCommit |
No | Fast path optimization |
*TimeoutVote exists in config but is primarily for compatibility with three-phase BFT protocols.
TimeoutCommit is a rate-limiting mechanism, not a consensus protocol feature:
-
Safety:
TimeoutCommitdoes not affect safety properties. Blocks are still committed via the two-chain rule regardless of timing. -
Liveness: The spec's liveness properties (eventual commit under partial synchrony) hold whether blocks are produced at 1/second or 1000/second.
-
Purpose:
TimeoutCommitexists to:- Control block production rate for public blockchains
- Reduce resource usage (CPU, bandwidth, storage)
- Provide predictable block times for applications
-
Implementation detail: It's enforced by waiting after commit before the next proposal:
// In hotstuff2.go:advanceViewWithQC() if waitForCommitDelay { hs.pm.WaitForCommitDelay() // Enforces TimeoutCommit }
The implementation provides preset configurations for different use cases:
| Config | TimeoutCommit | Use Case |
|---|---|---|
DefaultPacemakerConfig() |
0 (immediate) | Testing, benchmarks - optimistically responsive |
DemoPacemakerConfig() |
1s | Demos - visible block production (~1 block/sec) |
ProductionPacemakerConfig(t) |
t | Production - target block time (e.g., 5s for public chain) |
If formal verification of timing properties becomes necessary, separate specs could model:
- Throughput bounds: Verify block rate stays within configured limits
- Backoff convergence: Verify timeout eventually stabilizes after partitions heal
- Fast path optimization: Verify
SkipTimeoutCommitmaintains target rate on average
These would be liveness refinements, not safety properties, and would require a timed/real-time model (e.g., TLA+ with real-time extensions or a different formalism).
- TLA+ Spec:
hotstuff2.tla - Go Implementation:
../../hotstuff2.go,../../context.go - HotStuff-2 Paper: https://eprint.iacr.org/2023/397.pdf
- Algorithm Explanation:
../../docs/ALGORITHM_EXPLAINED.md