Skip to content

research: Poseidon2 Merkle hasher — measured REJECT - #42

Open
alexandre-mrt wants to merge 1 commit into
mainfrom
research/2026-08-24-poseidon2-merkle-hasher
Open

research: Poseidon2 Merkle hasher — measured REJECT#42
alexandre-mrt wants to merge 1 commit into
mainfrom
research/2026-08-24-poseidon2-merkle-hasher

Conversation

@alexandre-mrt

Copy link
Copy Markdown
Owner

Summary

Nightly research loop, 2026-08-24. Queue item #2: does swapping circomlib's Poseidon(2) for
Poseidon2(t=3) as the Merkle-tree node hash (templates/merkle_proof.circom — 20 calls/proof in
both transfer.circom and compliance.circom, the highest-multiplicity hash in the protocol)
reduce constraint count and proving time?

Measured answer: no. Constraint count and proving time both regress across all three circuits:

Circuit R1CS Δ Node proving Δ Browser proving Δ zkey Δ
transfer.circom +1,260 (+9.3%) +132.1 ms (+17.6%) +130.9 ms (+10.8%) +6.6%
compliance.circom +1,260 (+9.9%) +104.1 ms (+14.1%) +106.6 ms (+9.2%) +7.0%
withdraw.circom +63 (+2.1%) +46.2 ms (+18.9%) +23.1 ms (+6.0%) +1.4%

Non-linear (S-box) constraints do drop slightly (~1%), as the Poseidon2 literature predicts — but
@taceo/circom-lib's linear-layer templates encode each MDS multiply through several named
intermediate signals, and circom emits one R1CS linear constraint per signal. That cost dominates
the S-box savings. Full mechanism, raw command output, cross-implementation verification
(circom vs. JS, byte-identical for a fixed input), soundness argument, and leakage analysis in
docs/research/2026-08-24-poseidon2-merkle-hasher.md.

Verdict: REJECT. Branch kept for the record — main, BASELINE.md, and
docs/threat-model.md are unchanged. Ledger and experiment queue updated; a re-rank note in
EXPERIMENTS.md explains why on-chain gas (blocked a third time this session, now by an outright
network-proxy policy denial) is demoted, and adds a new low-priority parked item (a hand-fused
linear layer, which could plausibly recover the win this attempt lost) for a future night.

What's in this PR

  • circuits/templates/poseidon2_hash2.circom — new Poseidon2Hash2 template wrapping
    @taceo/circom-lib's Poseidon2(t) in circomlib's exact Poseidon(2) sponge convention
    (capacity-first, digest = state[0]), so it's a true drop-in.
  • templates/merkle_proof.circom + withdraw.circom (recipHash) rewired to use it — the only
    two arity-2 Poseidon call sites in the protocol. All other (domain-tagged, arity ≥3) hash sites
    are untouched.
  • scripts/bench/witnesses.mjs and all three circuits/test/*.test.mjs updated so their JS
    reference computations match what the new circuits actually enforce.
  • A new negative test (T44 in transfer.test.mjs): a Merkle root computed with the old
    Poseidon(2) is rejected by the new circuit — proof the swap is enforced by R1CS constraints,
    not a JS-side convention nobody checks.
  • One-line fix to scripts/bench/prove-latency.mjs (missing process.exit(0), same
    snarkjs/ffjavascript open-handle issue circuits/test/*.mjs already handles) — found while
    getting clean benchmark output for this experiment.
  • docs/research/2026-08-24-poseidon2-merkle-hasher.md, LEDGER.md, EXPERIMENTS.md updated.

Test plan

  • node --experimental-vm-modules test/{transfer,compliance,withdraw}.test.mjs — 109/109 pass,
    real Groth16 fullProve/verify against freshly compiled artifacts (not hash-only mode)
  • cd scripts && bun run src/test-converter.ts — 109/109 pass (unaffected)
  • cd frontend && bun run test — 19/19 pass (vitest, unaffected)
  • sui move test — not run; sui CLI still unavailable in this session (pre-existing,
    unrelated toolchain gap — no contract code touched by this PR)
  • node scripts/bench/prove-latency.mjs --runs 10 and node scripts/bench/browser-latency.mjs --runs 8
    — raw output in the writeup
  • Cross-implementation check: standalone Poseidon2(3) circuit witness vs. @taceo/poseidon2
    JS bn254.t3.permutation, byte-identical for input [0,1,2]

Generated by Claude Code

Swap circomlib's Poseidon(2) for Poseidon2(t=3) (@taceo/circom-lib,
cross-checked against @taceo/poseidon2's JS implementation) as the Merkle
node hash in templates/merkle_proof.circom and withdraw.circom's recipHash
— the only arity-2 Poseidon call sites, and the highest-multiplicity hash
in the protocol (20 calls/proof via the depth-20 tree).

Measured result: constraint count and proving time both regress across all
three circuits (+9.3%/+9.9%/+2.1% R1CS constraints, +14-19% Node proving
time, +6-11% browser proving time), despite non-linear (S-box) constraints
dropping slightly as predicted. TACEO's linear-layer templates encode each
MDS multiply through several named intermediate signals, and circom emits
one R1CS linear constraint per signal — that cost outweighs the S-box
savings. 109/109 circuit tests pass (real Groth16 proofs), including a new
negative test proving a root computed with the old Poseidon(2) is rejected
by the new circuit.

Verdict: REJECT. Branch kept for the record; main/BASELINE.md/threat-model
unchanged. Full writeup, soundness argument, and leakage analysis in
docs/research/2026-08-24-poseidon2-merkle-hasher.md. Ledger and experiment
queue updated accordingly.

Also fixes scripts/bench/prove-latency.mjs hanging after its last proof
(missing process.exit(0), same issue circuits/test/*.mjs already handles).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants