The visualizer keeps the transcript, the duplex sponge, both round machines and the full 254-bit state on screen at once and steps them together. Every part of it is meant to be read against the others — which is the whole point, and exactly what shrinking it onto a phone would take away.
It needs at least 1024 × 560 logical pixels — a landscape tablet, a laptop, or a desktop.
On a tablet, turning it to landscape is usually enough. Otherwise open this page on a laptop or a desktop.
Read what it does instead →A production Fiat–Shamir transcript for a zkVM whose native field is the BN254 scalar field: the width-3 Poseidon2 permutation driving a persistent rate-2 / capacity-1 duplex challenger. Everything on this page is computed by the page, in BigInt arithmetic modulo the BN254 scalar modulus, from the reference round constants reproduced below.
The suite identifier is
Poseidon2-BN254-Fr-t3-r2-c1-a5-RF8-RP56. Nothing here is about proving,
arithmetization or commitment schemes — only about the transcript: what goes in, what comes
out, and in what order.
| Field | BN254 scalar field Fr — not the curve base field |
Modulus p | 21888242871839275222246405745257275088548364400416034343698204186575808495617 |
State width t | 3 |
Rate r / capacity c | 2 / 1 — lanes s0, s1 are the rate; s2 is the capacity |
| S-box | x → x5 (α = 5, three multiplications each) |
Full rounds RF | 8 — four before the partial rounds, four after |
Partial rounds RP | 56 — S-box on lane 0 only |
| Round constants | the exact width-3 BN254 RC3 table from the Horizen Labs reference instance, listed in full below |
Poseidon2's cost saving is the partial round, and a partial round is only sound if the linear layer still mixes every lane. Both matrices below are cheap: each is one shared sum plus a doubling.
One entry differs. That single change is what makes MI safe to iterate
fifty-six times against a single-lane S-box, where repeating ME would not be.
// the initial external linear layer comes before any constant or S-box S ← M_E · S // rounds 0..3 — external (full) for r in 0..4: S ← S + RC[r] // all three lanes S[i] ← S[i]^5 // all three lanes S ← M_E · S // rounds 4..59 — internal (partial) for r in 4..60: S[0] ← S[0] + RC[r][0] // lane 0 only S[0] ← S[0]^5 // lane 0 only S ← M_I · S // rounds 60..63 — external (full) for r in 60..64: S ← S + RC[r] S[i] ← S[i]^5 S ← M_E · S
The state is [s0, s1, s2], initialised to all zeros, and it is kept across the entire
proof — a fresh state per challenge would break the binding that Fiat–Shamir depends on.
Two buffers sit alongside it.
The three lanes are not peers. s0 and s1 are the rate: the
public half, the only lanes absorbed material is written into and the only lanes challenges are read
out of. s2 is the capacity, and it is sealed — no absorbed
value is ever placed there and nothing is ever squeezed from it. It is where the sponge's security
margin lives, and an adversary who can set it or read it has broken the construction. Exactly two
things touch it: the absorbed-length tag, and the permutation. The diagram draws that literally
— the rate sits on the horizontal path between the two buffers, the capacity sits off it.
observe(x):
output_buffer.clear() // any unconsumed challenge is now stale
input_buffer.push(x)
if input_buffer.len() == 2: duplex()
sample():
if input_buffer non-empty or output_buffer empty: duplex()
return output_buffer.pop() // from the END
duplex():
n = input_buffer.len()
if n > 0:
state[i] = input_buffer[i] // OVERWRITE, not add
state[n..2] = 0 // zero the unused rate lanes
state[2] += n // bind the absorbed length into the capacity
state = Poseidon2(state)
input_buffer.clear()
output_buffer = [state[0], state[1]]
Four details in there are easy to get wrong, and each of them changes the challenges:
state[2] += n is
what makes absorbing one element distinguishable from absorbing two elements the
second of which is zero. Zero-padding alone is not injective — those two land on an
identical state — so it is the length tag that repairs it, and the two rules only work
as a pair.[s0, s1], and pop() takes the last one — so the first
challenge is s1 and the second is s0. This follows the Plonky3 duplex
challenger, and it is a pure convention: get it backwards and every challenge is wrong while
every individual operation still looks right.Provenance. All four rules are Plonky3's, not a variant of it: its
DuplexChallenger clears the rate slots the inputs did not overwrite and adds the
absorbed length into sponge_state[RATE], in its own words to make the absorb
“prefix-free so length and zero-padding cannot collide.” This page reproduces its
challenges exactly. Two version notes worth carrying into a review: length binding landed in
Plonky3 #1769
(June 2026), so an implementation pinned to anything earlier will produce different
challenges from the same inputs; and upstream stores the tag as a u8, so absorb
lengths are distinguishable only up to 255 — irrelevant at rate 2, but not at wider rates.
A duplex called from sample() with nothing buffered does none of the absorb
work — no overwrite, no zeroing, no length tag. It just permutes and refills the output buffer.
The last call of the loaded transcript is that case.
An arbitrary byte string must not be read as a 256-bit integer and reduced mod p:
that map is not injective, so two different strings can produce the same field element. The
encoding used here absorbs the byte length first, then splits the string into 31-byte
chunks read as unsigned little-endian integers. At most 248 bits, every chunk is already
below p and needs no reduction.
The loaded transcript starts from field elements already, so this step is not shown on the diagram — but it is where the elements a real protocol absorbs come from, and it is one of the rules that has to be frozen alongside the transcript itself.
The reference BN254 width-3 instance fixes one test vector. If an implementation does not reproduce it, nothing downstream of it is compatible. This runs the permutation on the page, right now.
RC3, all 64 roundsFull rounds carry three constants; partial rounds carry one, and the reference table stores the two unused lanes as zero. Nothing here is generated at runtime.
| r | lane 0 | lane 1 | lane 2 |
|---|
RC3 table and matrices reproduced above.