← All posts

March 29, 2026 · Permissionless Technologies

From BN254 to BLS12-381: Migrating a Privacy Pool to PLONK

A step-by-step developer guide to migrating ZK circuits from BN254 Groth16 to BLS12-381 PLONK — with all the bugs we hit and how we fixed them.

zero-knowledgeBLS12-381PLONKcircomEIP-2537solidityprivacy

The Problem: A Hash Mismatch That Broke Everything

We were building a privacy pool — the Universal Private Pool (UPP) — where users can shield any ERC20 token and transfer privately using ZK proofs. Our compliance layer, the Universal Private Compliance (UPC) SDK, handles Association Set Providers (ASPs) that maintain Merkle trees of approved addresses.

Everything worked independently. Then we tried to connect them.

The pool's ZK circuits used BN254 Groth16 — the standard for Ethereum ZK applications. Circom compiles to BN254 by default, snarkjs generates Groth16 proofs, and the EVM has native precompiles for BN254 pairing (ecPairing at 0x08). It's the path of least resistance.

But BN254 has a problem: ~100-bit security. That's below NIST's 128-bit minimum. For a compliance system where a forged membership proof means bypassing sanctions checks, this matters. Our compliance SDK had already moved to BLS12-381 — 128-bit security, the same curve used by Ethereum's beacon chain for BLS signatures.

The ASP service built its Merkle tree with BLS12-381 Poseidon. The circuit verified ASP membership with BN254 Poseidon. Same algorithm, different field primes, different hash outputs. The Merkle roots never matched. Every transaction with ASP verification reverted at the circuit constraint:

aspRootDiff * aspMustMatch === 0;  // Line 151: always fails

We had two options: downgrade the compliance SDK to BN254 (losing the security guarantee), or migrate the entire pool to BLS12-381.

We chose the migration.

What We're Migrating

The full stack has five layers, each needing changes:

LayerBN254 (before)BLS12-381 (after)
Circom circuitsPoseidon(N) from circomlibPoseidon255(N) from poseidon-bls12381-circom
Proof systemGroth16 (per-circuit ceremony)PLONK (universal setup)
SDK hashcircomlibjs (async, BN254)poseidon-bls12381 (sync, BLS12-381)
On-chain Merkle treePoseidonT3 from poseidon-solidityCustom PoseidonBLS12381T3 (generated)
On-chain verifiersnarkjs Groth16 templatePlonkVerifierBLS12381 with EIP-2537

Nine circom circuits. One SDK. One Solidity Poseidon implementation. One PLONK verifier. One Merkle tree library. All interconnected, all needing to produce identical hash outputs.

Phase 1: Converting the Circuits

The Poseidon Signal Name Trap

The first change looks simple — replace the Poseidon import:

// Before (BN254)
include "circomlib/circuits/poseidon.circom";
hashers[i] = Poseidon(2);
hashers[i].inputs[0] <== left;
hashers[i].inputs[1] <== right;

// After (BLS12-381)
include "poseidon-bls12381-circom/circuits/poseidon255.circom";
hashers[i] = Poseidon255(2);
hashers[i].inputs[0] <== left;   // ← WRONG
hashers[i].inputs[1] <== right;  // ← WRONG

The circomlib Poseidon template uses inputs[N] as its signal name. The poseidon-bls12381-circom package uses in[N]. Same algorithm, different signal names. The compiler error is clear:

error[T2046]: inputs is not defined in hashers

Fix: hashers[i].in[0] and hashers[i].in[1]. This affected every circuit file — the Merkle tree, note commitments, nullifiers, everything.

Removing BabyJubJub

BN254 circuits commonly use the BabyJubJub curve for key derivation — it's a twisted Edwards curve embedded in BN254's scalar field, making it efficient for in-circuit elliptic curve operations. Our note commitment was:

commitment = Poseidon(amount, pubkeyX, pubkeyY, blinding, origin, token)  // 6 inputs

Where (pubkeyX, pubkeyY) is a BabyJubJub public key derived from a spending secret. BabyJubJub is defined over BN254. It doesn't exist in BLS12-381.

We replaced it with hash-based ownership:

ownerHash = Poseidon255(secret)
commitment = Poseidon255(amount, ownerHash, blinding, origin, token)  // 5 inputs

No curve operations in the circuit. The DerivePublicKey template (BabyJubJub scalar multiplication) becomes DeriveOwnerHash (a single Poseidon hash). The VerifyPubkeyOnCurve check disappears entirely. The circuits get simpler, smaller, and field-agnostic.

Compiling for BLS12-381

Circom 2.1.0+ supports the --prime flag:

circom transfer.circom --r1cs --wasm --sym --prime bls12381 -l node_modules

All six circuits compiled successfully. The constraint counts dropped slightly (no BabyJubJub curve operations), and the WASM files were smaller (~2MB vs ~3.3MB).

Phase 2: PLONK Trusted Setup

Why PLONK Instead of Groth16

Groth16 requires a per-circuit trusted setup ceremony — a multi-party computation (MPC) where participants generate toxic waste that must be destroyed. If all participants collude, proofs can be forged. For a compliance system, this is an audit nightmare: "Who ran the ceremony? How was the toxic waste destroyed?"

PLONK has a universal trusted setup. Phase 1 (Powers of Tau) is reusable across any circuit. Phase 2 is fully deterministic — given the circuit's R1CS and the Phase 1 output, the proving key is uniquely determined. No toxic waste. No ceremony participants to audit.

Generating the BLS12-381 Powers of Tau

There are no pre-computed BLS12-381 .ptau files available for download. Unlike BN254 (which has Hermez ceremony files), the BLS12-381 ecosystem doesn't have a shared Phase 1 ceremony yet.

We generate locally with snarkjs:

npx snarkjs powersoftau new bls12-381 18 pot_0000.ptau -v
npx snarkjs powersoftau contribute pot_0000.ptau pot_0001.ptau --name="Dev" -v
npx snarkjs powersoftau beacon pot_0001.ptau pot_beacon.ptau <random_hex> 10 -v
npx snarkjs powersoftau prepare phase2 pot_beacon.ptau pot_bls12381_18.ptau -v

This takes ~30 minutes for power 18 (2^18 = 262,144 — needed because PLONK expands constraints). Our largest circuit (joinsplit, 4-in-2-out) has 106,301 R1CS constraints but 148,621 PLONK constraints after copy constraint expansion.

Gotcha: We initially used power 17 (2^17 = 131,072). The PLONK setup failed with:

circuit too big for this power of tau ceremony. 148621 > 2**17

PLONK constraint count > R1CS constraint count. Always check with snarkjs r1cs info and use a power that accommodates the PLONK expansion.

PLONK Setup

With PLONK, Phase 2 is a single deterministic command per circuit:

snarkjs plonk setup transfer.r1cs pot_bls12381_18.ptau transfer.zkey
snarkjs zkey export verificationkey transfer.zkey transfer.vkey.json

No contributions. No ceremony. Fully reproducible.

Phase 3: The SDK

Switching the Hash Function

The SDK's Poseidon wrapper changed from async (circomlibjs requires initialization) to sync (poseidon-bls12381 is ready immediately):

// Before: async, BN254
import { buildPoseidon } from 'circomlibjs'
const poseidonFn = await buildPoseidon()  // one-time init
const hash = poseidonFn.F.toObject(poseidonFn(inputs))

// After: sync, BLS12-381
import { poseidon2, poseidon5 } from 'poseidon-bls12381'
const hash = poseidon5([amount, ownerHash, blinding, origin, token])

The field prime changes:

// BN254: ~254 bits, ~100-bit security
export const FIELD_PRIME = 21888242871839275222246405745257275088548364400416034343698204186575808495617n

// BLS12-381: ~255 bits, 128-bit security
export const FIELD_PRIME = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n

Switching the Prover

// Before
const result = await snarkjs.groth16.fullProve(inputs, wasmFile, zkeyFile)

// After
const result = await snarkjs.plonk.fullProve(inputs, wasmFile, zkeyFile, logger)

The logger parameter is new — PLONK proving is CPU-intensive (10-30 seconds for BLS12-381), and the logger lets you show progress to users:

const logger = {
  debug: (msg) => {
    if (msg.includes('ROUND 1')) onProgress('Computing wire polynomials...')
    if (msg.includes('ROUND 3')) onProgress('Computing quotient polynomial...')
    if (msg.includes('ROUND 5')) onProgress('Computing opening proofs...')
  },
  info: () => {}, warn: () => {}, error: () => {},
}

The Proof Format Change

Groth16 proofs are 8 uint256s (pi_a[2] + pi_b[2][2] + pi_c[2]). PLONK proofs are a struct with 9 G1 point commitments (128 bytes each for BLS12-381) and 6 scalar evaluations:

interface PlonkProofStruct {
  A: `0x${string}`     // 128-byte G1 point
  B: `0x${string}`     // 128-byte G1 point
  // ... 7 more G1 points
  eval_a: bigint       // scalar field element
  eval_b: bigint
  // ... 4 more scalars
}

Each G1 point is encoded as 128 bytes: 64 bytes for x (padded to 64 from 48-byte Fp element) + 64 bytes for y. This is the EIP-2537 format.

Phase 4: On-Chain Poseidon for BLS12-381

The Merkle Tree Hash Mismatch

The on-chain Merkle tree used PoseidonT3 from the poseidon-solidity library — a BN254 Poseidon implementation. With BLS12-381 circuits, the off-chain SDK computes commitments with BLS12-381 Poseidon, but the on-chain tree hashes with BN254 Poseidon. The Merkle roots would never match.

We needed a Solidity implementation of Poseidon for the BLS12-381 scalar field.

Generating PoseidonBLS12381T3.sol

Poseidon is the same algorithm regardless of field — S-box (x^5), MDS matrix multiply, round constant addition. Only the constants and modulus change. We wrote a Node.js generator that extracts the BLS12-381 round constants from poseidon-bls12381-circom and outputs a fully unrolled Solidity contract:

// For each of 64 rounds: S-box + MDS + round constants
for (let r = 0; r < 64; r++) {
  // S-box: state^5
  lines.push(`scratch0 := mulmod(state0, state0, F)`)
  lines.push(`state0 := mulmod(mulmod(scratch0, scratch0, F), state0, F)`)
  // MDS mix + next round constants (using addmod, not add!)
  lines.push(`scratch0 := addmod(addmod(addmod(
    mulmod(state0, M00, F), mulmod(state1, M01, F), F),
    mulmod(state2, M02, F), F), ${roundConstant}, F)`)
}

Critical bug we caught: The BN254 version uses add (raw assembly addition) for the MDS accumulation. This works because BN254 is ~254 bits — three additions of ~254-bit values fit in uint256 (256 bits). But BLS12-381 is ~255 bits. Three additions of ~255-bit values overflow uint256:

3 × 2^255 ≈ 2^257 > 2^256  // OVERFLOW!

We replaced all add with addmod to keep values within the field. The generated contract is 638 lines and costs ~46k gas per hash — verified against the JavaScript implementation to produce identical outputs.

Forking the LeanIMT

The zk-kit LeanIMT library hardcodes SNARK_SCALAR_FIELD as the BN254 prime and uses PoseidonT3 for hashing. We forked it as InternalLeanIMTBLS12381.sol with two changes:

  1. PoseidonT3PoseidonBLS12381T3
  2. SNARK_SCALAR_FIELDBLS12381_SCALAR_FIELD

Same algorithm, different hash and field boundary.

Phase 5: The PLONK Verifier (Where It Got Hard)

EIP-2537: BLS12-381 Precompiles

Ethereum added native BLS12-381 precompiles in the Pectra hard fork (May 2025):

AddressOperationCost
0x0bG1ADD~500 gas
0x0cG1MSM (multi-scalar multiplication)variable
0x0dG2ADD~800 gas
0x0fPAIRING_CHECK~43k + 22.5k per pair

These let us verify BLS12-381 PLONK proofs on-chain — something that was impossible before Pectra.

The snarkjs Template Doesn't Work

snarkjs can generate Solidity verifier contracts, but the built-in PLONK template is for BN254. When we tried it with BLS12-381 zkeys:

uint256 constant X2x1 = 858395874993300978...;  // 107 digits
// Error: Literal is too large to fit in uint256

BLS12-381 field elements are ~384 bits — they don't fit in uint256. We needed a custom verifier using EIP-2537 precompiles.

Stack-Too-Deep Without via_ir

The PlonkVerifierBLS12381 contract implements the full PLONK verification protocol: Fiat-Shamir challenges, polynomial evaluations, linearization commitments, and the final pairing check. It's math-heavy with many local variables.

Solidity's default backend limits the stack to 16 slots. The verification function exceeded this:

Stack too deep. Try compiling with `--via-ir`

Instead of enabling via_ir (which produces larger bytecode), we refactored:

  1. Packed challenges into a struct to reduce stack depth in the main function
  2. Split computeD into three sub-functionscomputeDGates (5-point MSM), computeDPerm (2-point MSM), computeDQuot (3-point MSM) — each with fewer locals
  3. Extracted computeR0 into its own function

The contract compiles without via_ir.

The abi.decode(abi.encodePacked()) Bug

The original verifier code used this pattern to copy bytes from storage/calldata to memory:

points[0] = abi.decode(abi.encodePacked(vk.Qm), (bytes));

This is broken for bytes types. abi.encodePacked(bytes_var) returns raw bytes. But abi.decode(raw, (bytes)) expects ABI-encoded data with an offset + length prefix. The raw bytes don't have this structure, so the decode produces garbage.

The fix is embarrassingly simple:

points[0] = vk.Qm;  // Solidity auto-copies storage bytes to memory

This bug caused every precompile call to receive corrupted G1 point data, making every transaction revert. It was present in 19 locations across the verifier.

The G2 Generator: Wrong Numbers From the Spec

The PLONK pairing check uses the BLS12-381 G2 generator. We hardcoded it from the spec's decimal values:

x_c0 = 352701069587466261239808297958820961600909952015232985336523687806198876186570020818903068082509079973797527891384

The transaction reverted with: "Pairing check call failed"

We tested step by step against Sepolia's EIP-2537 precompiles:

  • G1ADD: works ✓
  • G1MSM: works ✓
  • G2ADD with our G2 generator: "invalid point: not on curve"

All four orderings of the Fp2 components failed. The decimal values from the spec, when converted to hex via JavaScript BigInt, produced slightly different values than the actual hex coordinates used by go-ethereum and blst.

The issue: JavaScript BigInt conversion from the spec's decimal strings introduced errors. The correct approach is to use the hex values directly from reference implementations (blst, gnark-crypto), not decimal values from specs.

We verified the correct hex values by testing against the live precompile:

# G2ADD with correct hex values
cast call --data 0x<correct_g2_hex><correct_g2_hex> \
  0x000000000000000000000000000000000000000d \
  --rpc-url $SEPOLIA_RPC
# → Success!

# Pairing identity check: e(G1, G2) * e(-G1, G2) = 1
# → 0x...0001 (TRUE!)

G1 Negation: Don't Do Manual Field Arithmetic

Our initial g1Negate function did manual 512-bit subtraction of the BLS12-381 base field prime:

unchecked {
    neg_lo = p_lo - y_lo;
    neg_hi = p_hi - y_hi;
    if (p_lo < y_lo) neg_hi -= 1; // borrow
}

This produced points that the pairing precompile rejected. Instead of debugging the subtraction (carry logic across 256-bit boundaries is error-prone), we replaced it with a precompile call:

function g1Negate(bytes memory point) internal view returns (bytes memory) {
    return g1Mul(point, Q - 1);  // (Q-1)*P = -P since Q*P = O
}

One line. Uses the G1MSM precompile. Correct by construction. Costs slightly more gas but eliminates an entire class of bugs.

Foundry Can't Test EIP-2537

Important discovery: Foundry's --fork-url mode does not emulate EIP-2537 precompiles, even when forking a post-Pectra chain. Precompile calls return success = true but with empty output data (all zeros). This means:

  • G1ADD/G1MSM "succeed" but return zero points
  • Pairing checks fail because they receive zero inputs
  • Tests pass with weak assertions but produce wrong results

We had to test against the actual Sepolia EVM using cast call against the deployed contract. Foundry integration tests for EIP-2537 require waiting for native precompile support.

Phase 6: Deployment

Per-Circuit Verification Keys

Each circuit gets its own PlonkVerifierBLS12381 instance with a hardcoded verification key. We wrote a generator script that reads .vkey.json files and outputs Solidity:

library VKeyDeployers {
    function deployTransferVKey() internal returns (PlonkVerifierBLS12381) {
        PlonkVerifierBLS12381.VerificationKey memory vk;
        vk.n = 65536;
        vk.nPublic = 6;
        vk.omega = 0x...;
        vk.Qm = hex"...";  // 128-byte G1 point
        // ... 8 more G1 points + G2 point
        return new PlonkVerifierBLS12381(vk);
    }
}

The G1 points in verification keys use the same 128-byte EIP-2537 encoding. The G2 point (X_2 from the trusted setup) is 256 bytes.

The Contract ABI

The pool contract accepts the PLONK proof as a Solidity struct — not flat bytes:

function transfer(
    PlonkVerifierBLS12381.Proof calldata proof,  // tuple with 15 fields
    bytes32 nullifier,
    uint256 stateRoot,
    // ...
) external { ... }

viem (the TypeScript Ethereum library) handles ABI encoding of the struct automatically. The ABI type is tuple with 9 bytes components (G1 points) and 6 uint256 components (evaluations).

Gotcha: We initially set the ABI type to "bytes" instead of the proper "tuple". This caused the wallet (Rabby) to fail with: "Cannot convert string to Uint8Array." The fix: regenerate the ABI from Forge's compiled output (forge buildout/Contract.json), which correctly types the struct as a tuple.

What We Learned

  1. Don't derive hex from spec decimal values. JavaScript BigInt can lose precision in intermediate operations. Use hex values from reference implementations (blst, gnark-crypto, go-ethereum).

  2. BLS12-381 field arithmetic overflows uint256. Any raw add of multiple ~255-bit values needs addmod. BN254 (~254 bits) gets away with add because 3 × 2^254 < 2^256.

  3. abi.decode(abi.encodePacked(x)) is broken for bytes. Just use x directly — Solidity copies storage/calldata bytes to memory automatically.

  4. PLONK constraint count > R1CS constraint count. Always check snarkjs r1cs info and size your Powers of Tau accordingly.

  5. Foundry can't test EIP-2537 yet. Test against real Sepolia. Use cast call for quick precompile validation.

  6. Use precompiles for field arithmetic when possible. g1Mul(point, Q-1) for negation is safer than manual 512-bit subtraction.

  7. No pre-computed BLS12-381 ptau files exist. Generate your own with snarkjs. For production, run a multi-party ceremony.

  8. The Poseidon signal name convention differs between packages. circomlib uses inputs[N], poseidon-bls12381-circom uses in[N].

The Result

The full stack — circuits, SDK, on-chain Poseidon, Merkle tree, PLONK verifier — is now BLS12-381 end-to-end. Hash compatibility between the ASP compliance layer and the privacy pool is achieved. The security level is 128-bit (NIST compliant). The proof system is PLONK with a universal trusted setup and no toxic waste.

The code is open source:

If you're building ZK applications on Ethereum and considering the move from BN254 to BLS12-381, we hope this guide saves you a few days of debugging. The bugs are subtle, the tooling has gaps, but the result — 128-bit security with on-chain verification via EIP-2537 — is worth it.