5 · Poseidon, one hash everywhere
This is the most important invariant in the codebase, and the one most likely to break silently if you are careless: the same Poseidon hash must run in three places, and produce identical bytes in all three.
┌─ the circuit (circuits/src/lib/poseidon255.circom)
Poseidon ───┼─ the contract (soroban-poseidon crate, called from lib.rs)
└─ the client SDK (packages/sdk/src/poseidon.ts)
If any two disagree, nothing throws — the proof simply fails to verify, or the contract computes a different leaf than the prover expected, and you spend hours chasing a "valid proof rejected" ghost. So we treat the hash as a single definition with three implementations kept in lockstep.
Why three places
- The circuit computes commitments and the Merkle path inside the proof (
withdraw,transfer). - The contract computes the deposit leaf on-chain (value binding) and hashes up the tree on every insert.
- The SDK computes the note (
precommitment,label) at deposit, and rebuilds the Merkle path in the browser to feed the prover.
What poseidon255 actually is
A standard HADES-strategy Poseidon over the BLS12-381 scalar field, parameters generated by the
canonical generate_params_poseidon.sage script (the header of poseidon255_constants.circom
records the exact invocation). For an input of n field elements:
- state size
t = n + 1(index 0 is the capacity element, initialized to 0; inputs fill the rest); - 8 full rounds + 56 partial rounds (for
t ≤ 4), 64 rounds total; - each round is add-round-constants → S-box (x⁵) → mix (MDS matrix);
- full rounds apply the S-box to every state element; partial rounds, only to element 0;
- the output is state element 0 after the last round.
That is the whole algorithm. The only things that must match exactly across implementations are
the round constants C(t), the MDS matrix M(t), and the ordering of every step.
How the three are kept identical
- Circuit ↔ contract: the contract calls the published
soroban-poseidoncrate, which was authored to matchposeidon255.circom. We pin its exact version inCargo.tomland treat a version bump as a change that must re-verify the match. - Contract ↔ SDK: the SDK's
poseidon.tsports the permutation, and its constants are generated directly from the circuit's constants file bypackages/sdk/scripts/gen-poseidon-constants.mjs— so the SDK literally reads the circuit's numbers, eliminating transcription risk. - Proof of equality:
packages/sdk/scripts/verify-poseidon.mtshashes known inputs and checks the output against vectors produced by the Rust side. It must print all-PASS.
$ node packages/sdk/scripts/verify-poseidon.mts
PASS A.precommitment (t=3)
PASS A.commitment (t=4)
PASS B.precommitment (t=3)
PASS B.commitment (t=4)
✓ all poseidon vectors match
Because Poseidon(value, label, precommitment) matches across all three, the contract can bind a
deposit's value on-chain (Chapter 3), and the browser can build a Merkle path the on-chain root
will accept (Chapter 9), all without a trusted server.
A note on CAP-0075
Stellar ships a host-function Poseidon (CAP-0075, Protocol 25). It would be cheaper on-chain
than the software soroban-poseidon — but its parameters are not guaranteed identical to
poseidon255, and a mismatch would silently break the circuit ↔ contract equality above. So
Phase 0 deliberately uses the software hash that we know matches. Adopting the host function is
a Phase-1 optimization gated on proving byte-equality first.
Try it: cd packages/sdk && node scripts/gen-poseidon-constants.mjs && node scripts/verify-poseidon.mts
— regenerate the constants from the circuit and re-prove the match.
If you change one thing: never hand-edit src/poseidon-constants.js; regenerate it. And if
you bump soroban-poseidon, re-run the vectors before trusting a single proof.