Skip to content

Stamping Events Into Time

The previous page, A Clock Made of Hashes, built the clock itself: feed a hash’s output back into its input — h = sha256(h) — and because SHA-256 is pre-image resistant, the only way to reach the value after n iterations is to actually compute all n of them, one after another. The chain is a count of real elapsed work. It ticks, and each tick provably came after the one before it.

But a clock that only counts is a metronome, not a ledger. To be useful to a global state machine we need to do more than measure time — we need to stamp events into it, so that “transaction A happened before transaction B” is a fact you can check, not a claim you have to trust. This page is about that stamping: the single, small operation that folds an event into the chain, why that operation freezes the event’s position forever, and the exact bookkeeping — the Entry checkpoint — that a verifier needs to replay it. Everything here is grounded in poh.rs in the book’s companion crate solmini.

The whole trick is that the clock has two step functions, and they differ by one input.

A bare tick is the clock’s heartbeat. It hashes the current state and nothing else:

/// One step of the bare clock: `h = sha256(h)`.
pub fn hash_once(h: &Hash) -> Hash {
sha256(h)
}

A mix-in is how an event enters the chain. It hashes the current state concatenated with the event:

/// One step that *mixes in* an event: `h = sha256(h || mixin)`.
pub fn hash_with(h: &Hash, mixin: &Hash) -> Hash {
let mut hasher = Sha256::new();
hasher.update(h); // the current chain state
hasher.update(mixin); // the event, reduced to 32 bytes
hasher.finalize().into()
}

That is the entire difference. Side by side:

bare tick : h_next = sha256(state)
mix-in : h_next = sha256(state || sha256(event))
└──────┬──────┘
the event is now
an input to the hash

The event itself is not fed in raw. It is first reduced to a fixed 32-byte value with mixin_of, so that whether the “event” is one transaction or a batch of ten thousand, exactly 32 bytes get folded into the chain:

/// Reduce arbitrary event bytes (a transaction, a batch root, …) to a
/// 32-byte value suitable for mixing into the chain.
pub fn mixin_of(data: &[u8]) -> Hash {
sha256(data)
}

So the full mix-in step is h_next = sha256(state || sha256(event)). One SHA-256 over the running state to move the clock forward; the event rides along inside that same hash.

Why one mixed-in hash fixes an event forever

Section titled “Why one mixed-in hash fixes an event forever”

Here is the load-bearing observation. After you compute h_next = sha256(state || sha256(event)), the value h_next depends on the event. Change one bit of the event and mixin_of(event) changes completely (SHA-256 has no partial correlation between input and output), so h_next changes completely.

Now keep hashing. The next tick is sha256(h_next), and the one after that is sha256(sha256(h_next)), and so on. Every hash from here to the end of the chain has h_next somewhere in its ancestry. Because each depends on the last, and h_next depended on the event, every future value depends on the event too.

… ─►(tick)─►(mix event A)─►(tick)─►(tick)─►(mix event B)─► …
│ ▲ ▲ ▲
│ └───────┴──────────┘
└── event A is baked into ALL of these,
because each one is a hash of the one before it

This is what “stamped into time” means. The event’s position is not recorded alongside the chain — it is part of the chain’s substance. To move event A one step earlier or later, you would have to change what got hashed at that point, which changes h_next, which changes every single hash after it. And because the chain is sequential and un-parallelizable to produce, redoing all of those hashes is exactly as expensive as it was the first time. You cannot cheaply forge a different order. Order is established by construction.

Notice what is not here: no wall clock, no timestamp, no round of nodes agreeing on when something happened. The position is a byte-for-byte consequence of where in the sequence the mix-in occurred. Two validators replaying the same chain will always agree on the order, because the order is the chain.

The generator, Poh, exposes exactly these two moves. It owns the running state and a count of hashes performed since genesis, and it does one of two things at each step.

A tick means time passed and nothing happened. It advances the bare clock n times and emits an entry with no event:

/// Advance the bare clock by `n` hashes and emit a *tick* entry (no event).
/// This is the runtime saying "time passed and nothing happened".
pub fn tick(&mut self, n: u64) -> Entry {
for _ in 0..n {
self.state = hash_once(&self.state);
self.count += 1;
}
Entry { num_hashes: n, hash: self.state, mixin: None }
}

Ticks give the chain a steady cadence even when no transactions arrive — the clock keeps beating so that “how much time has passed” is always answerable, busy or idle. (The next page, Ticks, Slots, and Hashes-per-Tick, is entirely about that cadence.)

A record means seal an event. It does n - 1 bare hashes, then one final hash that folds the event in:

/// Record an event: do `n - 1` bare hashes, then one hash that folds in
/// `sha256(data)`. After this call the event is *sealed in time*.
pub fn record(&mut self, n: u64, data: &[u8]) -> Entry {
assert!(n >= 1, "an entry must contain at least one hash");
for _ in 0..n - 1 {
self.state = hash_once(&self.state);
self.count += 1;
}
let mix = mixin_of(data);
self.state = hash_with(&self.state, &mix);
self.count += 1;
Entry { num_hashes: n, hash: self.state, mixin: Some(mix) }
}

The two are deliberately symmetric: both advance the clock by num_hashes and both leave the chain in a well-defined state. The only difference is whether the last hash of the group was a bare sha256(state) (tick) or a mixing sha256(state || mix) (record). That single distinction — encoded as mixin: None versus mixin: Some(mix) — is the whole event-versus-no-event story.

tick(3) record(3, event)
────── ────────────────
sha256(state) sha256(state) ┐ n-1 = 2 bare
sha256(state) sha256(state) ┘ ticks first
sha256(state) sha256(state || event) ← the mix-in is the LAST hash
mixin: None mixin: Some(...)

Producing the chain is only half the job; someone has to be able to verify it later, on a different machine, having seen none of the intermediate hashes. That is what an Entry is for — it is the minimal bookkeeping a verifier needs to replay one segment of the chain and confirm it.

/// One recorded checkpoint in the chain.
pub struct Entry {
pub num_hashes: u64, // hashes since the previous entry (always >= 1)
pub hash: Hash, // the chain state right after this entry
pub mixin: Option<Hash>, // Some(...) if the last hash folded an event in
}

Three fields, each answering one question a verifier must ask:

  • num_hasheshow much work happened here? This is the count of hashes since the previous entry. It is the “how much time passed” number: a big num_hashes after an idle period, a small one when events are dense.
  • mixindid an event get sealed, and which one? If None, all num_hashes steps were bare ticks. If Some(mix), the first num_hashes - 1 were bare ticks and the last hash folded in mix. The verifier needs the actual mix value to reproduce that final step.
  • hashwhat should the state be when I’m done? This is the chain state immediately after the entry. The verifier recomputes the segment and checks that it lands on exactly this value.

With those three fields the verifier can stand alone. Given the starting state and a list of entries, it replays each segment and compares:

pub fn verify(start: Hash, entries: &[Entry]) -> bool {
let mut cur = start;
for e in entries {
if e.num_hashes == 0 { return false; } // no work is not a checkpoint
match e.mixin {
None => {
for _ in 0..e.num_hashes { cur = hash_once(&cur); }
}
Some(mix) => {
for _ in 0..e.num_hashes - 1 { cur = hash_once(&cur); }
cur = hash_with(&cur, &mix); // reproduce the mix-in
}
}
if cur != e.hash { return false; } // must land on the recorded hash
}
true
}

Note that verify replays the mix-in exactly the way record produced it: n - 1 bare hashes, then one hash_with. The producer and the verifier run the same code path over the same inputs; that is why the recomputed cur must equal the recorded hash down to the last bit. The chain is honest by reconstruction, not by assertion.

Reordering breaks the chain — even if you keep both events

Section titled “Reordering breaks the chain — even if you keep both events”

The sharpest way to see that order is baked in: try to cheat, and watch it fail. Suppose two events are recorded back to back, A then B. You keep both — you do not drop or alter either — you only want to present them in the other order, B then A.

honest: start ──record(A)──► h1 ──record(B)──► h2
h1 was hashed WITH A,
h2 was hashed FROM h1 WITH B
swapped: present [B, A] to the verifier starting from `start`
step 1: recompute from `start` with B's mixin → some h'
but B's recorded entry says its hash is h2 ✗
→ verify() returns false at the first entry

B’s Entry.hash was computed by hashing A’s output with B’s mixin. If you replay B first — starting from start instead of from h1 — you get a different value, which will not match B’s recorded hash. The verifier rejects it immediately. This is exactly the reordering_two_events_breaks_the_chain test in the companion crate:

let a = poh.record(3, b"event-A");
let b = poh.record(3, b"event-B");
assert!(verify(genesis(seed), &vec![a, b])); // correct order verifies
assert!(!verify(genesis(seed), &vec![b, a])); // swapped order does NOT

The events are byte-for-byte identical in both vectors. What differs is only their order, and that alone is enough to fail verification — because each event’s position was folded into every hash that came after it. There is no separate “sequence number” an attacker could edit; the sequence is the chain. This is the property the rest of Solana leans on: the ordering of transactions is decided before consensus votes on anything, a point we return to in Order First, Vote Second.

The mix-in is the operation that turns a clock into a ledger, so it is worth examining through the five questions.

  • Why does it exist? To give every event an unforgeable position in time — a place in a total order that can be verified by anyone, without trusting the producer’s word or any wall clock.
  • What problem does it solve? Agreeing on the order of events in a distributed system. Instead of nodes exchanging timestamps and voting on sequence, the order is a byte-for-byte consequence of where each event was hashed into a single sequential chain.
  • What are the trade-offs? You get order for free from hashing, but only relative order and elapsed hash-work, not true wall-clock time. And the chain must be produced sequentially by one party at a time — the leader — because the mix-in must happen at a single, definite point.
  • When should I avoid it? When you do not actually need a total order — an eventually-consistent store, or a system where events are genuinely independent, gains nothing from forcing them into one sequence and pays for the sequential bottleneck.
  • What breaks if I remove it? The clock goes back to being a metronome: it still counts time, but nothing is stamped into it, so you lose the one thing that made ordering free. You would be back to timestamp agreement and consensus-per-transaction — the expensive path from Why Ordering Is Expensive.
  1. Write the two step functions of the clock — a bare tick and a mix-in — as h_next = … expressions, and name the two companion-crate functions that implement them.
  2. After an event is mixed in with sha256(state || sha256(event)), why does every later hash in the chain depend on that event? What makes moving the event to a different position expensive?
  3. Both Poh::tick and Poh::record advance the clock by num_hashes and update count. What is the single concrete difference between them, and how is that difference recorded in the resulting Entry?
  4. An Entry has three fields: num_hashes, hash, and mixin. For each, state the question a verifier answers with it when replaying the chain.
  5. Two events A and B are recorded in that order. An attacker keeps both events byte-for-byte but presents them as [B, A]. Explain precisely why verify rejects this at the first entry, even though nothing was deleted or altered.
Show answers
  1. A bare tick is h_next = sha256(state), implemented by hash_once. A mix-in is h_next = sha256(state || sha256(event)), implemented by hash_with (with the event first reduced to 32 bytes by mixin_of).
  2. The mixed-in hash h_next is a function of the event, so it changes completely if the event changes. Every subsequent hash is sha256(...) applied on top of h_next, so each one carries h_next — and therefore the event — in its ancestry. Moving the event changes h_next, which changes every hash after it; because the chain must be produced sequentially with no shortcut, redoing all of those hashes costs exactly as much as producing them did the first time.
  3. The difference is the last hash of the group. tick makes all num_hashes steps bare sha256(state); record makes the first num_hashes - 1 bare and the final one a mixing sha256(state || mixin). This shows up in the Entry as mixin: None (tick) versus mixin: Some(mix) (record).
  4. num_hashes answers how much work / elapsed clock happened in this segment (always ≥ 1). mixin answers did the last hash seal an event, and which one (None = pure ticks, Some(mix) = the value folded into the final hash). hash answers what the chain state must equal after replaying this segment — the verifier recomputes and checks it lands exactly there.
  5. B’s recorded Entry.hash (call it h2) was computed by hashing A’s output together with B’s mixin. Replaying [B, A] starts B from start instead of from A’s output, so the recomputed value does not equal h2. verify compares cur != e.hash on the first entry, finds a mismatch, and returns false immediately. The events are unchanged; it is their position — folded into every following hash — that fails to reproduce.