Verifying the Clock in Parallel
The last three pages built the producer’s side of the clock. A Clock Made of
Hashes showed why h = sha256(h) cannot be faked without doing
the work; Stamping Events Into Time folded events in as
sha256(state || event); Ticks, Slots, and Hashes-per-Tick
gave the stream a cadence. In every one of those pages, the point was the same: building the chain is
strictly sequential, so its length is honest proof that time passed.
This page is the other half of the deal — the half that makes the whole scheme pay off. Building the clock
is slow and serial. Checking it is fast and parallel. A verifier replays the chain from genesis and
redoes every hash the producer did — the same total work — yet it can spread that work across all its
cores and finish in a fraction of the time, while a forger trying to fake the chain gets no such help. That
asymmetry is why PoH is worth the trouble, and it is what we build up here, grounded in the real verify()
function and its tamper tests in rust/solmini/src/poh.rs.
What “verify” actually has to prove
Section titled “What “verify” actually has to prove”A verifier is handed two things: the genesis hash (the agreed starting state, sha256(seed)) and a
list of Entry checkpoints the producer emitted. Each entry claims: “starting from wherever the previous
entry ended, I did num_hashes hashes, and here is the resulting hash.” The verifier’s whole job is to
decide whether every one of those claims is true.
Recall the checkpoint type from the previous pages:
pub struct Entry { pub num_hashes: u64, // hashes done since the previous entry (>= 1) pub hash: Hash, // the chain state right after this entry pub mixin: Option<Hash>, // Some(x) if the last hash folded an event in}An entry is a sealed promise about a segment of the timeline. num_hashes is how much time the segment
represents; hash is where the chain stood at the end of it; mixin says whether an event was recorded on
the segment’s final hash. To verify the whole chain, the verifier reproduces every segment in turn and
checks that its recomputed ending hash matches the one the producer wrote down.
Walking through verify()
Section titled “Walking through verify()”Here is the entire verifier from the companion crate. It is short on purpose — the security argument has to fit in your head.
pub fn verify(start: Hash, entries: &[Entry]) -> bool { let mut cur = start; // begin at genesis for e in entries { if e.num_hashes == 0 { return false; // an entry with no work is not a checkpoint } match e.mixin { None => { // a tick: redo num_hashes bare hashes for _ in 0..e.num_hashes { cur = hash_once(&cur); // cur = sha256(cur) } } Some(mix) => { // a record: num_hashes - 1 bare hashes, then one mixin hash for _ in 0..e.num_hashes - 1 { cur = hash_once(&cur); } cur = hash_with(&cur, &mix); // cur = sha256(cur || mix) } } if cur != e.hash { return false; // recomputed hash disagrees -> reject } } true}Read it as three rules:
- Start at genesis.
curbegins atstart, the samesha256(seed)the producer began from. Verifier and producer must agree on genesis; without a shared starting hash there is nothing to check. - Redo the exact work each entry claims. For a tick (
mixin == None) the verifier doesnum_hashesbarehash_oncecalls. For a record (mixin == Some(mix)) it doesnum_hashes - 1bare hashes and then onehash_with(&cur, &mix)— folding in the recorded event on the final hash, exactly asrecord()did on the producer side. This is why the entry carries the mixin: without it, the verifier could not reproduce the segment’s last step. - Compare, then advance. After redoing the segment,
curis what the verifier derived. If it does not equale.hash— the value the producer claimed — the chain is rejected. If it matches,curbecomes the starting state for the next entry and the loop continues.
The num_hashes == 0 guard matters more than it looks. A zero-hash entry would represent “a checkpoint with
no elapsed work,” which is a contradiction — it would let a forger insert boundaries for free. Every entry
must cost at least one real hash.
start ─►( redo e0.num_hashes )─►[cur] ==? e0.hash ─► │ match, cur := e0.hash ▼ ─►( redo e1.num_hashes )─►[cur] ==? e1.hash ─► │ match, cur := e1.hash ▼ ─►( redo e2.num_hashes )─►[cur] ==? e2.hash ─► … all true → verify() == trueNotice what the verifier is not doing. It is not trusting any hash the producer wrote — it recomputes every one and only uses the recorded value as a target to compare against. The recorded hashes are claims; the recomputed hashes are proof.
Why verification is embarrassingly parallel
Section titled “Why verification is embarrassingly parallel”Here is the pivot. Producing the chain is serial because each hash needs the previous one as input — you
literally cannot compute hash N+1 until you hold hash N. So the dependency chain runs the length of the
whole stream and no core can help another.
But verification does not face that constraint, because of one design choice we made pages ago: every
Entry records its own ending hash. And an entry’s ending hash is exactly the starting hash of the next
segment. That means a verifier already knows, up front, both endpoints of every segment: the start (the
previous entry’s hash) and the claimed end (this entry’s hash). It does not need to wait for segment
i to finish before it can begin segment i+1 — it already has segment i+1’s starting point written
down.
entry: e0 e1 e2 e3 hash: H0 H1 H2 H3 │ │ │ │ segment: [start..H0] [H0..H1] [H1..H2] [H2..H3] core: core 0 core 1 core 2 core 3 │ │ │ │ └── recompute ────┴── recompute ────┴── recompute ────┘ and check and check and check == H0? == H1? == H2? …Each core is handed one segment: a starting hash, a num_hashes, an optional mixin, and a claimed ending
hash. It redoes that segment’s hashes in isolation and reports one bit — did my recomputed end match the
recorded end? If all segments pass on all cores, the chain verifies. The segments never talk to each other.
This is the textbook shape of an embarrassingly parallel problem: no coordination, no shared state, just
independent chunks and a final AND of the results.
The companion verify() above runs the segments in a plain loop for clarity. That is a presentation choice,
not a limitation — the data structure permits full parallelism, and real Solana verifiers exploit it. What
makes it possible is entirely the Entry.hash field pinning each boundary.
Under the hood — the sequential/parallel asymmetry, stated precisely
Section titled “Under the hood — the sequential/parallel asymmetry, stated precisely”Let the chain contain T total hashes (total_hashes(&entries), the sum of every num_hashes). Then:
- Production cost:
Thashes, strictly one after another. No parallelism is available, ever. Wall-clock time to produce ≈T / rate_per_core. - Verification cost: also
Thashes — the verifier redoes exactly the same work (this is not a cheaper algorithm). But withKcores splitting the segments, wall-clock time to verify ≈T / (K × rate_per_core).
So a verifier with K cores checks the chain roughly K times faster than it could have produced it.
And the crucial part: a forger gets no such speedup. To fabricate a chain of length T, the forger
must actually compute T sequential hashes — the production side is serial for the attacker too. There is
no parallel shortcut to creating a valid chain, only to checking one. That is the whole leverage of
Proof of History: honest verification rides on many cores, dishonest production is stuck on one.
The three trust properties, proven by tests
Section titled “The three trust properties, proven by tests”The reason to trust verify() is not the prose above — it is that the crate ships tests that try to cheat
and get caught. Each test constructs a valid chain, breaks it in one specific way, and asserts that
verify() returns false. Three distinct attacks, three distinct guarantees.
1. Tampering with a hash is caught
Section titled “1. Tampering with a hash is caught”let mut entries = vec![poh.tick(4), poh.record(2, b"event"), poh.tick(4)];entries[1].hash[0] ^= 0x01; // flip one bit of a recorded hashassert!(!verify(genesis(seed), &entries));Flip a single bit of any recorded hash and verification fails. The verifier recomputes that segment from the
real starting state and gets the correct ending hash, which no longer equals the tampered value stored in
entries[1].hash — so the comparison cur != e.hash fires. You cannot edit a recorded hash and have the
chain still check out.
2. Reordering two events fails
Section titled “2. Reordering two events fails”let a = poh.record(3, b"event-A");let b = poh.record(3, b"event-B");assert!( verify(genesis(seed), &vec![a, b])); // original order: validassert!(!verify(genesis(seed), &vec![b, a])); // swapped: invalidOrder is established by construction, so you cannot rewrite history by shuffling entries. Event A was
mixed into the chain before event B, which means B’s recorded hash was derived from a state that already
contained A. Swap the two entries and the verifier, replaying b first from genesis, computes a different
hash than b.hash (which was produced after A). The swap breaks on the very first entry. This is the
tamper-evidence that makes PoH a total order rather than a bag of timestamps.
3. A forged skip-ahead fails
Section titled “3. A forged skip-ahead fails”let mut entry = poh.tick(1000);entry.hash = [0xAB; 32]; // a hash we *wish* were the answerassert!(!verify(genesis(seed), &[entry]));You cannot claim a long stretch of elapsed time without actually doing the hashes. Here the forger claims
num_hashes = 1000 but supplies a fabricated ending hash instead of the one 1000 real hashes would produce.
The verifier does the 1000 hashes for real, lands on the genuine value, and rejects the fake. There is no
way to fast-forward the clock: to present a valid hash after 1000 ticks you must have computed all 1000, and
that computation is exactly the serial work the forger was trying to skip.
Together these three tests cover the failure modes that matter for an ordering primitive: you can’t edit a recorded state (property 1), you can’t reorder events (property 2), and you can’t fabricate elapsed time (property 3). Everything a verifier needs to trust the clock reduces to these.
The strict-VDF caveat — parallelizable, not cheaper
Section titled “The strict-VDF caveat — parallelizable, not cheaper”It is tempting to hear “verification is fast” and conclude PoH is a verifiable delay function in the
strong cryptographic sense — where checking is asymptotically cheaper than producing (a producer does T
work, a verifier does O(log T) or O(1)). PoH is not that. Say the caveat plainly and keep it:
PoH verification does the same number of hashes as production. It is only parallelizable, not asymptotically cheaper. Its security rests entirely on SHA-256.
Two consequences follow. First, if SHA-256’s sequentiality assumption ever broke — if someone found a way to
compute the N-th iterate of sha256 without doing N sequential hashes — the entire “count = elapsed
time” guarantee collapses, because a forger could then fabricate elapsed time cheaply. PoH’s honesty is
exactly as strong as SHA-256’s pre-image resistance and the absence of a shortcut through its iteration.
Second, the verifier’s speed advantage is hardware, not math: it comes from having many cores, not from
a cleverer algorithm. A single-core verifier does precisely as much work as the producer did (the crate’s
total_hashes() equals poh.count() for exactly this reason). The leverage is real and sufficient — but it
is the leverage of parallelism over a serial forger, nothing more.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because a clock nobody can verify is worthless in a trustless network. PoH’s
producer proves time passed by doing serial work;
verify()exists so anyone can confirm that proof from the data alone, without asking the producer or trusting a wall clock. - What problem does it solve? It lets the whole network confirm the order and elapsed time of events cheaply — spread across cores — instead of exchanging rounds of messages to agree on sequence, which is the throughput bottleneck the whole part exists to remove.
- What are the trade-offs? Verification does the same total hashing as production; you buy speed only through parallelism, not through a cheaper algorithm. And the guarantee is only as strong as SHA-256’s resistance to an iteration shortcut.
- When should I avoid it? When you actually need a strict VDF — verification asymptotically cheaper than production — PoH does not provide it; reach for a real VDF construction instead. And PoH orders events; it does not decide validity or finality, so never use it as consensus on its own.
- What breaks if I remove it? Without verifiable replay, PoH degenerates into “trust the producer’s
timestamps,” which reintroduces exactly the distrust and messaging the clock was built to eliminate. The
parallel
verify()is what makes the clock a shared fact rather than one node’s claim.
Check your understanding
Section titled “Check your understanding”- Walk through what
verify()does for a single record entry (one wheremixinisSome). How many bare hashes does it do, what is the final hash, and what does it compare against? - Verification redoes the same number of hashes the producer did, yet the network checks the chain far faster than any one node built it. Explain precisely how both statements are true at once.
- Which field of
Entrymakes parallel verification possible, and why? What would go wrong if entries did not record their own ending hash? - Name the three trust properties the tests prove, and for each say which line of the attack breaks and
which check in
verify()catches it. - Someone claims PoH is a verifiable delay function where checking is asymptotically cheaper than producing. Correct them: what exactly is cheap about verification, what is not, and what single assumption underpins the whole guarantee?
Show answers
- For a record entry,
verify()doesnum_hashes - 1barehash_oncecalls (eachcur = sha256(cur)), then onehash_with(&cur, &mix)that folds in the recorded mixin (cur = sha256(cur || mix)). The result incuris the segment’s ending hash, which it compares against the entry’s recordedhash. If they differ, it returnsfalse; if they match,curcarries forward as the next segment’s start. - The total work is identical: the verifier computes every hash the producer computed — there is no
cheaper algorithm (
total_hashes()==poh.count()). The wall-clock time differs because production is strictly serial (one dependency chain, no core can help) while verification splits into independent segments. WithKcores the verifier finishes in ≈T / Kcore-time, so it checks roughlyK× faster than one core could have produced — same work, parallelized. Entry.hash— each entry records its own ending hash, which is exactly the starting hash of the next segment. So a verifier already knows both endpoints of every segment up front and can recompute each on a separate core without waiting for the previous one. If entries did not record their ending hashes, a verifier would have to recompute the chain strictly in order (to learn each segment’s start), and verification would be as serial as production — the whole speedup would vanish.- (a) Tampering — flipping a bit of a recorded
hash(entries[1].hash[0] ^= 0x01); caught because the recomputedcurno longer equals the tamperede.hash, trippingcur != e.hash. (b) Reordering — swapping entriesaandb; caught because replayingbfirst from genesis yields a hash different fromb.hash(which was derived after A), failing on the first entry. (c) Skip-ahead — claiming a longnum_hasheswith a fabricatedhash(entry.hash = [0xAB; 32]); caught because the verifier does the real 1000 hashes and lands on the genuine value, not the fake. - It is not a strict VDF. What is cheap is only wall-clock time via parallelism — many cores splitting
independent segments. What is not cheap is the algorithm: verification does the same total number of
hashes as production, so a single-core verifier gets no discount. The whole guarantee rests on one
assumption — SHA-256’s resistance to an iteration shortcut (you cannot compute the
N-th iterate without doingNsequential hashes); if that broke, “hash count = elapsed time” would collapse.