Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3b80313
feat(test): import poseidon2-opt benchmark suite
KyrinCode Apr 17, 2026
016a913
chore(poseidon2-opt): auto-populate lib/ when scripts run
KyrinCode Apr 17, 2026
0bb7ad9
chore(poseidon2-opt): add Makefile and bootstrap pot12.ptau
KyrinCode Apr 17, 2026
aebf17f
docs(poseidon2-opt): document make-based workflow and auto-setup
KyrinCode Apr 17, 2026
64f2f2a
fix(poseidon2-opt): make bench-circom cache actually hit and surface …
KyrinCode Apr 17, 2026
686f4c3
fix(poseidon2-opt): staticcall Poseidon2Yul directly, drop wrapper layer
KyrinCode Apr 17, 2026
8a3e6d6
fix(poseidon2-opt): use working pot12.ptau mirror and pin sha256
KyrinCode Apr 17, 2026
833b817
chore(poseidon2-opt): drop stale plan docs and orphan benchmark wrappers
KyrinCode Apr 17, 2026
de0a41f
chore(poseidon2-opt): slim down overgrown gitignores
KyrinCode Apr 17, 2026
c24efd1
chore(poseidon2-opt): drop last orphan benchmark circuit
KyrinCode Apr 17, 2026
90af4a1
chore(poseidon2-opt): prefix NethermindEth vendored circom with nm_
KyrinCode Apr 17, 2026
a2c8063
test(poseidon2-opt): add Foundry fuzz tests + cross-language fuzz mode
KyrinCode May 9, 2026
ea5aba9
fix(poseidon2-opt): boundary sweep in cross_check.sh + decimal-only l…
KyrinCode May 9, 2026
7e53f3e
fix(poseidon2-opt): make CROSS_CHECK_FUZZ=0 actually run zero random …
KyrinCode May 9, 2026
f4e6929
docs(poseidon2-opt): document fuzz testing and cross-fuzz mode
KyrinCode May 9, 2026
a4c3afb
fix(poseidon2-opt): slugify cross-check label before using as path co…
KyrinCode May 9, 2026
3ae9ed5
fix(poseidon2-opt): let cross_check stderr surface real errors
KyrinCode May 9, 2026
6700b98
feat(poseidon2-opt): preflight external dependencies before circom pi…
KyrinCode May 9, 2026
35b5c9e
refactor(poseidon2-opt): extract preflight + slugify into scripts/lib.sh
KyrinCode May 9, 2026
8b159e2
fix(poseidon2-opt): address review items 7-9 (gitignore, scripts, EIP…
KyrinCode May 9, 2026
f3faca3
refactor(poseidon2-opt): smoother first-run experience for new contri…
KyrinCode May 9, 2026
a248589
feat(poseidon2-opt): live progress, Foundry version note, sharper dow…
KyrinCode May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions test/poseidon2-opt/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Foundry build artifacts
cache/
out/

# Vendored third-party libraries (populate locally via `make setup`)
# lib/forge-std https://github.com/foundry-rs/forge-std
# lib/poseidon2-evm https://github.com/zemse/poseidon2-evm
# lib/poseidon2-solidity https://github.com/V-k-h/poseidon2-solidity
lib/

# Circom benchmark artefacts (populated by `make cross-check` / `bench-circom`;
# also ignored by bench/circom/.gitignore — duplicated here so the root
# .gitignore is self-documenting)
bench/circom/pot12.ptau
bench/circom/build_*

# Safety catch-all for accidental secrets
.env
43 changes: 43 additions & 0 deletions test/poseidon2-opt/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.PHONY: help setup setup-light build test bench cross-check cross-fuzz bench-circom clean

help:
@echo "poseidon2-opt — Makefile targets"
@echo ""
@echo " make setup populate lib/ AND download pot12.ptau (idempotent)"
@echo " make build forge build (lib/ only — skips ptau)"
@echo " make test forge test — correctness + fuzz suite (lib/ only)"
@echo " make bench forge test — gas benchmark (lib/ only)"
@echo " make cross-check Solidity <-> Circom equality on fixed inputs"
@echo " make cross-fuzz Solidity <-> Circom equality on random inputs"
@echo " (override count via CROSS_CHECK_FUZZ=N, default 4)"
@echo " make bench-circom Circom R1CS + Groth16 proving benchmark"
@echo " make clean remove Foundry and Circom build artifacts"

setup:
@bash scripts/setup-libs.sh

# Solidity-only setup — lib/ clones without the ~5 MB pot12.ptau download.
setup-light:
@SKIP_PTAU=1 bash scripts/setup-libs.sh

build: setup-light
@forge build

test: setup-light
@forge test

bench: setup-light
@FOUNDRY_PROFILE=bench forge test -vv

cross-check: setup
@bash test/cross_check.sh

cross-fuzz: setup
@CROSS_CHECK_FUZZ=$${CROSS_CHECK_FUZZ:-4} bash test/cross_check.sh

bench-circom: setup
@bash bench/circom/scripts/bench_full.sh

clean:
@forge clean
@rm -rf bench/circom/build_*
161 changes: 161 additions & 0 deletions test/poseidon2-opt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Poseidon2 Optimized Implementations

Gas-optimized Poseidon2 hash function implementations in **Solidity** and **Circom**, targeting **BN254 scalar field** with **x^5 S-box**. Compatible with Noir/Barretenberg.

## Quick Start

On a fresh checkout, no manual setup is required — `make test`/`build`/`bench` auto-populate `lib/`, and `make cross-check`/`cross-fuzz`/`bench-circom` additionally fetch `pot12.ptau` (~5 MB) on first use.

```shell
make test # correctness + fuzz suite (forge test, ~0.2 s, 256 fuzz runs/test)
make bench # Solidity gas benchmark
make cross-check # Solidity <-> Circom equality on 9 fixed inputs
make cross-fuzz # Solidity <-> Circom equality on boundary + N random inputs (default N=4)
make bench-circom # Circom R1CS + Groth16 proving benchmark
make help # list all targets
```

To populate dependencies manually (e.g. before running `forge` directly):

```shell
make setup # full bootstrap: lib/ + pot12.ptau
# or `SKIP_PTAU=1 bash scripts/setup-libs.sh` for lib/ only
```

This clones `forge-std`, `zemse/poseidon2-evm` and `V-k-h/poseidon2-solidity` into `lib/` at pinned refs and (unless `SKIP_PTAU=1`) downloads the Powers-of-Tau file into `bench/circom/`. Both `lib/` and `pot12.ptau` are **gitignored** — they live locally only.

## Implementations

| Contract / Circuit | t | Interface | Key Optimization |
|--------------------|---|-----------|------------------|
| **Poseidon2T2** | 2 | `hash1(uint256)` | D=[1,2], zero mulmod in internal matrix |
| **Poseidon2T2FF** | 2 | `compress(uint256, uint256)` | Feed-forward, optimal for Merkle trees |
| **Poseidon2T3** | 3 | `hash2(uint256, uint256)` | D=[1,1,2], zero mulmod |
| **Poseidon2T4** | 4 | `hash3(uint256, uint256, uint256)` | M4 matrix manually unrolled |
| **Poseidon2T4Sponge** | 4 | `hash1` - `hash9` | Sponge with dirty-value tracking, Noir-compatible IV |
| **Poseidon2T8** | 8 | `hash7`, `hash4_padded` - `hash6_padded` | M4 Kronecker product, packed RC storage |

All Solidity implementations are `library` contracts with `internal pure` functions. Circom circuits mirror the same algorithms with `var` optimization to minimize R1CS constraints.

### Optimization Techniques

- **Dirty-value tracking**: Annotate value bounds (0/3, 1/3, 2/3) to safely use `add` instead of `addmod`, saving gas in hot loops
- **Packed RC storage**: Partial rounds store only 1 RC per round (for state[0]) instead of t, reducing bytecode by 60-77%
- **S-box function extraction** (T4S, T8): Deduplicate inline x^5 S-box blocks into a reusable Yul function
- **Circom var optimization**: Use `var` for matrix intermediates instead of `signal`, reducing R1CS constraints (e.g. T4: 612 vs NethermindEth 648)

## Project Structure

```
src/
├── solidity/ # Solidity implementations (Poseidon2T2..T8)
└── circom/ # Circom implementations (poseidon2_t2..t8)

bench/ # Benchmark suite (Poseidon1 vs Poseidon2 vs third-party)
├── solidity/ # Gas benchmarks (wrappers, vendored, FullBenchmark.t.sol)
└── circom/ # Constraint benchmarks (circuits, vendored, scripts)

test/
├── Correctness.t.sol # Fixed test vectors + cross-impl agreement (zemse / V-k-h)
├── Fuzz.t.sol # Property-based fuzz: input mod invariance + output in-range
└── cross_check.sh # Solidity <-> Circom equality (fixed inputs + optional fuzz mode)

scripts/
├── setup-libs.sh # Idempotent bootstrap: clones lib/* + (optionally) fetches pot12.ptau
└── lib.sh # Shared shell helpers: preflight checks, slugify, circom autodetect

Makefile # Wraps forge/cross-check/bench workflows with auto-setup
```

## Usage

The recommended entry points are the Makefile targets in [Quick Start](#quick-start). Each auto-runs `setup-libs.sh`, so a fresh clone works out of the box.

Under the hood they map to:

| Make target | Underlying command | Approx wall-clock |
| ------------------- | ------------------------------------------------------------------------ | ---------------------- |
| `make build` | `forge build` | ~5 s |
| `make test` | `forge test` (correctness vectors + 6 fuzz tests × 256 runs each) | <1 s |
| `make bench` | `FOUNDRY_PROFILE=bench forge test -vv` | <1 s |
| `make cross-check` | `bash test/cross_check.sh` (9 fixed Solidity↔Circom comparisons) | ~2 min |
| `make cross-fuzz` | `CROSS_CHECK_FUZZ=$${CROSS_CHECK_FUZZ:-4} bash test/cross_check.sh` (9 fixed + 36 boundary + 6N random) | ~12 min at N=4 |
| `make bench-circom` | `bash bench/circom/scripts/bench_full.sh` | ~10 min |
| `make clean` | `forge clean && rm -rf bench/circom/build_*` | <1 s |

External prerequisites (must be installed on the host). The `cross-check`,
`cross-fuzz`, and `bench-circom` targets pre-flight all of these and abort
with an actionable error if any is missing.

| Tool | Required for | Install command |
|------|--------------|-----------------|
| `forge` (from [Foundry](https://book.getfoundry.sh/), 2024-08+ recommended for solc 0.8.30 support) | every target | `curl -L https://foundry.paradigm.xyz \| bash && foundryup` |
| [`circom`](https://docs.circom.io/) | cross-check, cross-fuzz, bench-circom | `cargo install --git https://github.com/iden3/circom` |
| [`snarkjs`](https://github.com/iden3/snarkjs) | cross-check, cross-fuzz, bench-circom | `npm install -g snarkjs` |
| `node` (≥ 18) | cross-check, cross-fuzz, bench-circom | https://nodejs.org/ |
| `python3` | cross-fuzz only (random uint256 generation) | (preinstalled on macOS / most Linux) |
| `bc`, `/usr/bin/time` | bench-circom only (averaging + timing) | `apt install bc time` / preinstalled on macOS |
| `curl` or `wget` | first run only (downloads `pot12.ptau`) | (preinstalled on macOS / Linux) |

## Adding a New Implementation for Comparison

To add a new Poseidon-family implementation to the benchmark matrix:

### Solidity (gas benchmark)

1. **Import or vendor the source** into `bench/solidity/vendored/<impl>.sol`, or add it as a git dependency in `scripts/setup-libs.sh`.
2. **Write a thin wrapper** at `bench/solidity/wrappers/<Impl>Wrapper.sol` with one `external view` method per arity the impl supports. For `internal pure` libraries the wrapper inlines them; for standalone contracts (non-standard ABI like zemse-yul) staticcall them directly from `FullBenchmark` helpers instead.
3. **Wire into `bench/solidity/FullBenchmark.t.sol`**: add the wrapper instance in `setUp()` and a `g = gasleft(); <wrapper>.hash_N(...); console.log(...)` line in the matching `test_gas_N_inputs()` function.
4. Run `make bench` to see the number next to the others.

### Circom (constraint benchmark)

1. **Vendor the `.circom` source(s)** under `bench/circom/vendored/` (re-namespaced if needed to avoid `include` collisions).
2. **Add a per-arity wrapper circuit** at `bench/circom/circuits/bench_<label>_<hashN>.circom` whose `component main = …(N)` instantiates the benchmark target. Keep it under 3 lines per circuit.
3. **Add one `bench` line per arity** in `bench/circom/scripts/bench_full.sh` pointing at the new circuit and an input file from `mk_input`.
4. Run `make bench-circom` to see R1CS constraint count and Groth16 proving time.

### Correctness

If the new implementation shares RCs with any `lib/` dependency or `src/` variant, add an `assertEq` against it in `test/Correctness.t.sol`. If it uses different RCs, cross-checking is not meaningful and you can skip this step.

### Fuzz coverage (our own libraries only)

`test/Fuzz.t.sol` only exercises libraries under `src/solidity/`. If you add a new in-house library variant there, add a matching `testFuzz_<Name>_invariants` function asserting (a) `output < PRIME` and (b) `hash(a) == hash(a % PRIME)`. Third-party impls under `lib/` are not fuzzed here — their cross-language consistency is instead covered by the optional `make cross-fuzz` mode in `test/cross_check.sh`.

## Key Results

| Scenario | Best Choice | Solidity Gas | Circom Constraints |
|----------|-------------|-------------|-------------------|
| Merkle tree 2-to-1 | T2FF compress | 17,468 | 419 |
| 3-input hash | T4S | 28,779 | 612 |
| Variable-length (1-9) | T4S sponge | 28K-71K | 612-1,842 |
| 5-7 inputs (exact) | T8 | 58,692 | 1,120 |

vs Poseidon1: **30-40% gas savings** for 2+ inputs. vs other Poseidon2 (NethermindEth, Worldcoin): **lowest constraint count** at every t value.

## Benchmarked Implementations

| ID | Source | Type | Deployable |
|----|--------|------|------------|
| P1-chancehudson | [poseidon-solidity](https://github.com/chancehudson/poseidon-solidity) | Poseidon1, hand-written assembly, t=2-6 | Yes |
| P1-circomlibjs | [circomlibjs](https://github.com/iden3/circomlibjs) | Poseidon1, JS-generated bytecode, t=2-7 | Yes (t=7 near 24KB limit) |
| P2-zemse | [poseidon2-evm](https://github.com/zemse/poseidon2-evm) | Poseidon2, Yul inline assembly, t=4 | No (32KB, exceeds EIP-170) |
| P2-Vkh | [poseidon2-solidity](https://github.com/V-k-h/poseidon2-solidity) | Poseidon2, pure Solidity sponge, t=4 | No (63KB, exceeds EIP-170) |
| P2-sserrano44 | [elHub](https://github.com/sserrano44/elHub) | Poseidon2, pure Solidity, t=3 | Yes |

### Bytecode Size Note

Gas benchmarks measure external calls to wrapper contracts. For implementations using `internal` library functions (ours, P1-chancehudson, P2-Vkh, P2-sserrano44), the library code is **inlined** into the wrapper at compile time, so wrapper bytecode size closely reflects the actual library size. P1-circomlibjs contracts are deployed directly via `vm.etch`. P2-zemse is a standalone contract called via `staticcall` -- the reported 32,207 bytes is the library contract itself, not the thin wrapper.

## Dependencies

Build / toolchain:

- [Foundry](https://book.getfoundry.sh/) — Solc 0.8.30, Cancun EVM
- [forge-std](https://github.com/foundry-rs/forge-std) — pinned `v1.15.0`

Benchmarked (cloned into `lib/` by `setup-libs.sh`; **not tracked**):

- [poseidon2-evm](https://github.com/zemse/poseidon2-evm) — pinned `v1.0.0` (zemse)
- [poseidon2-solidity](https://github.com/V-k-h/poseidon2-solidity) — pinned `f48a837` (V-k-h)
2 changes: 2 additions & 0 deletions test/poseidon2-opt/bench/circom/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.ptau
build_*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
pragma circom 2.0.0;

include "../vendored/bk_poseidon2_perm.circom";

// Benchmark: bkomuves/hash-circuits Poseidon2 t=3 Compression
// compress(left, right) = perm(left, right, 0)[0] (no feed-forward)
template BenchBkT3Compress() {
signal input left;
signal input right;
signal output out;

component c = Compression();
c.inp[0] <== left;
c.inp[1] <== right;
out <== c.out;
}

component main = BenchBkT3Compress();
18 changes: 18 additions & 0 deletions test/poseidon2-opt/bench/circom/circuits/bench_compress.circom
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
pragma circom 2.0.0;

include "../vendored/nm_poseidon2_compress.circom";

// Benchmark: compress using t=2 (2 inputs, feed-forward: P(x)+x)
// NethermindEth style Merkle compression
template BenchCompress() {
signal input left;
signal input right;
signal output out;

component c = PoseidonCompress();
c.inputs[0] <== left;
c.inputs[1] <== right;
out <== c.out;
}

component main = BenchCompress();
19 changes: 19 additions & 0 deletions test/poseidon2-opt/bench/circom/circuits/bench_hash2.circom
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
pragma circom 2.0.0;

include "../vendored/nm_poseidon2_hash.circom";

// Benchmark: hash2 using t=3 (2 inputs + 1 capacity)
// Noir-compatible style: no feed-forward
template BenchHash2() {
signal input left;
signal input right;
signal output out;

component h = Poseidon2(2);
h.inputs[0] <== left;
h.inputs[1] <== right;
h.domainSeparation <== 0;
out <== h.out;
}

component main = BenchHash2();
21 changes: 21 additions & 0 deletions test/poseidon2-opt/bench/circom/circuits/bench_nm_t4_perm.circom
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
pragma circom 2.2.2;

include "../vendored/nm_poseidon2_perm.circom";

// Benchmark: NethermindEth Poseidon2 raw permutation t=4
// Direct permutation, no hash wrapper
template BenchNMT4Perm() {
signal input left;
signal input right;
signal input third;
signal output out;

component perm = Permutation(4);
perm.inputs[0] <== left;
perm.inputs[1] <== right;
perm.inputs[2] <== third;
perm.inputs[3] <== 0;
out <== perm.out[0];
}

component main = BenchNMT4Perm();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(2);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(3);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(4);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(5);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(6);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(7);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(8);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4_sponge.circom";
component main = Poseidon2Hash(9);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(2);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(3);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(4);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(5);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(6);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(7);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(8);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.0.0;
include "../vendored/circomlib_poseidon.circom";
component main = Poseidon(9);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t2.circom";
component main = Poseidon2T2_Hash1();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t2.circom";
component main = Poseidon2T2FF_Compress();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t3.circom";
component main = Poseidon2T3_Hash2();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pragma circom 2.2.2;
include "../../../src/circom/poseidon2_t4.circom";
component main = Poseidon2T4_Hash3();
Loading