Pedersen commitments, in production
Sections
The ZERA Labs site lists three “Cryptographic Innovations” on the front page: Pedersen Commitments, Zero-Knowledge Proofs, Nullifier Generation. If you read the SDK, you will not find a function called pedersenCommit. You will find computeCommitment and behind it a Poseidon hash. The first time someone asked me to reconcile the two, I gave a bad answer. This post is the answer I should have given.
A Pedersen commitment, in the textbook sense, is C = a·G + b·H where G and H are independent elliptic-curve generators, a is the value, and b is the blinding factor. The construction is homomorphic (you can add commitments and you add their values) and perfectly hiding under the discrete-log assumption (without the blinding factor, the commitment leaks zero information about the value). Bitcoin’s Confidential Transactions used Pedersen commitments. So did the original Zcash Sprout for note values. They are the canonical “I’m hiding a number” primitive in ZK literature.
What ZERA ships is not that. What ZERA ships is a Poseidon-based commitment — a hash-based commitment that hides the same set of fields (amount, asset, secret, blinding, memo[0..3]) and is binding under the collision-resistance of Poseidon. The marketing copy keeps the word “Pedersen” because that’s the term-of-art for the role — a hiding, binding commitment to a confidential note. The implementation is the right primitive for the deployment target, which is Solana, which has a sol_poseidon syscall, which means Poseidon costs us a few thousand compute units and Pedersen would cost us hundreds of thousands.
This post walks the why, the what, and the receipts.
What we actually wanted from “Pedersen”
Strip the construction down to the requirement. A note commitment in a shielded pool has to be:
- Hiding. Given the on-chain commitment, no observer can recover the amount, secret, blinding, asset, or memo.
- Binding. Once posted, the depositor cannot later “open” the commitment to a different note.
- Cheap inside a circuit. The prover needs to recompute the commitment from the private inputs and assert equality with the public input. Every constraint there shows up in proving time and
.zkeysize. - Cheap on-chain. The settlement layer recomputes hashes whenever the Merkle tree advances. If that primitive is expensive, every deposit is expensive.
Pedersen on bn254 G1 nails (1) and (2) but blows (3) and (4). Each scalar multiplication inside a Groth16 circuit is hundreds of constraints. On-chain, you’d be paying for elliptic-curve group ops on every leaf hash. Solana’s compute-unit budget is generous but not infinite, and the on-chain Merkle tree is the hottest piece of state in the protocol.
Poseidon flips that. It’s a permutation-based hash specifically designed for ZK circuits — x^5 S-boxes, eight full rounds, partial rounds chosen for the field. The 2-to-1 variant we use for Merkle nodes costs us dozens of constraints, not hundreds. And on-chain, Solana provides it as a syscall that sips compute units. The hiding/binding properties come from collision-resistance of the hash and the fresh random blinding factor on every note.
So the engineering choice was: keep the role of a Pedersen commitment, swap the primitive for one that fits the deployment surface. Cypherpunk purity loses to compute units every time.
The Rust core that everything else has to agree with
The canonical implementation lives in crates/zera-core/src/note.rs. The crate documentation is intentionally clinical:
//! Note primitives for the ZERA shielded pool.
//!
//! A **Note** represents a confidential UTXO inside the pool. It carries an
//! amount, asset identifier, a secret (private key material), a blinding
//! factor, and an optional 4-element memo field.
//!
//! The note commitment is computed as:
//!
//! commitment = Poseidon(amount, asset, secret, blinding, memo[0..3])
//!
//! The nullifier is:
//!
//! nullifier = Poseidon(secret, commitment)
The shape of the Note struct enforces the contract:
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct Note {
/// Token amount in the smallest denomination (e.g. USDC lamports).
pub amount: u64,
/// Asset identifier — typically `pubkey_to_field_bytes(mint.to_bytes())`.
pub asset: [u8; 32],
/// Secret key material (random 32 bytes). **Must be kept private.**
pub secret: [u8; 32],
/// Blinding factor for the Pedersen-like commitment (random 32 bytes).
pub blinding: [u8; 32],
/// Optional 4-element memo field (each element 32 bytes).
pub memo: [[u8; 32]; 4],
}
Two things to notice. First, the doc comment for blinding literally says “Pedersen-like.” That’s the gap I described in the intro, written into the source for anyone who knows enough to look. Second, the secret and the blinding are sampled separately. They serve different roles: secret derives the nullifier, blinding derives the hiding property. If they were the same value, an attacker who learned the nullifier preimage would also unmask the amount. Sampling them independently is the cheap way to keep those failure modes from chaining.
The compute function:
pub fn compute_commitment(note: &Note) -> Result<[u8; 32]> {
let amount_fr = Fr::from(note.amount);
let asset_fr = Fr::from_be_bytes_mod_order(¬e.asset);
let secret_fr = Fr::from_be_bytes_mod_order(¬e.secret);
let blinding_fr = Fr::from_be_bytes_mod_order(¬e.blinding);
let memo0_fr = Fr::from_be_bytes_mod_order(¬e.memo[0]);
let memo1_fr = Fr::from_be_bytes_mod_order(¬e.memo[1]);
let memo2_fr = Fr::from_be_bytes_mod_order(¬e.memo[2]);
let memo3_fr = Fr::from_be_bytes_mod_order(¬e.memo[3]);
let inputs = [
amount_fr, asset_fr, secret_fr, blinding_fr,
memo0_fr, memo1_fr, memo2_fr, memo3_fr,
];
let h = poseidon_hash(&inputs)?;
Ok(field_to_bytes32_be(&h))
}
That is the entire commitment. Eight field elements, one Poseidon, 32 bytes out. The Fr::from_be_bytes_mod_order is the unglamorous load-bearing call — it reduces a 32-byte big-endian array into the BN254 scalar field by modular reduction, which is the only way to ensure the JavaScript SDK and the Rust crate agree on the byte representation of a value that might exceed the field. The Solana on-chain program does the same thing in the same direction. Get the endianness wrong and your prover and your verifier disagree silently, which is the kind of bug that costs an audit cycle.
Why three implementations of the same hash exist
If you grep the SDK, you find Poseidon implemented (or wrapped) four times:
crates/zera-core/src/poseidon.rs— Rust, vialight-poseidonnew_circom.packages/sdk/src/crypto/poseidon.ts— TypeScript, viacircomlibjs.crates/zera-neon/— Neon binding so Node can call the Rust core.- The on-chain program — Solana’s
sol_poseidonsyscall.
That’s four entry points to the same hash function, and they all have to produce the same 32 bytes for the same inputs or the protocol falls over. The reason for the proliferation is platform: snarkjs in the browser wants a JS hash, the on-chain program wants a syscall, the Rust core wants no JS dependencies, and Node consumers benefit from native performance. The SDK’s docs/CRYPTOGRAPHY.md enumerates the cross-validation:
All four are verified to produce the same output for known test vectors:
Poseidon(0, 0) = 14744269619966411208579211824598458697587494354926760081771325075741142829156 Poseidon(1, 2) = 7853200120776062878684798364095072458815029376092732009249414926327459813530
Those two test vectors are the cheapest possible smoke test that the four implementations agree at the byte level. They are run in CI on every commit. If any of them drift — different parameter set, different endianness, different round constants — the Vitest run goes red instantly and we don’t ship.
The hiding argument, written out once
The reason a hash with a fresh random blinding factor is hiding has nothing to do with Poseidon being magical. It’s the same argument that justifies any hash-based commitment. Given H(amount, asset, secret, blinding, memo) and the value amount, the attacker has to find (secret', blinding', memo') such that H(amount, asset, secret', blinding', memo') == commitment. Because Poseidon is collision-resistant and the input space of (secret, blinding) is 2^254 × 2^254, this is computationally infeasible. Without blinding, the commitment would be enumerable for small amount spaces — an attacker could precompute H(0, asset, ...), H(1, asset, ...), etc. With it, the precomputation is impossible.
The binding argument is the dual: to “open” the commitment to a different amount', the attacker has to find a Poseidon collision. This reduces to the same hardness assumption.
This is the same contract the textbook Pedersen commitment provides, with a different cryptographic primitive backing it. The marketing word “Pedersen” is therefore not wrong, just collapsed. The role is identical. The construction is platform-appropriate.
What Poseidon costs us
Poseidon is younger than SHA-256 and has received less cryptanalytic attention. The SDK’s SECURITY.md is honest about this:
Poseidon has been analyzed extensively in the academic literature. No practical attacks are known for the parameter sets used by circomlib. However, Poseidon is relatively new compared to SHA-256 and has received less cryptanalytic attention.
That’s the right tone. The construction is sound, the parameter set is the one the entire ZK ecosystem uses, and the cryptanalysis pipeline is active and global. But Poseidon is a hash function in motion, and we should expect adjustments — Reinforced Concrete, Rescue, the next variant — to land over the next few years. The SDK is structured so the hash function is a single Rust module and a single TypeScript module. If we ever have to migrate, it’s a contained change with a clear cross-validation surface.
The other thing Poseidon costs us, less obvious: it removes the homomorphic property of textbook Pedersen. You cannot add two Poseidon commitments and get a commitment to the sum. That property is what made Pedersen useful for aggregate confidential transactions in older protocols. ZERA does not need it, because the value-conservation check is enforced inside the transfer circuit (inAmount == outAmount1 + outAmount2), not by adding commitments outside the circuit. Different design point, different primitive.
Why this matters for what ZERA is
If you read the Why I started Zera Labs letter, the founding bet is that ZK is finally fast enough, cheap enough, and verifiable enough to leave the laboratory. The “cheap enough” leg is exactly the trade-off this post describes. We do not get to ship a privacy pool to mainstream users at 1¢ per transfer if we spend 200,000 compute units per Merkle node hash. Poseidon is the engineering choice that turns ZK from a research demo into a checkout button.
The ZERA Labs front page says “Pedersen Commitments” because the audience is people who want to know we have hiding/binding commitments to confidential notes. The SDK ships Poseidon because that’s the implementation that makes the commitment cheap. Both are true, and the gap between them is the part of the work nobody sees.
Further reading
- zeralabs.org — Cryptographic Innovation pillar (Pedersen Commitments / Zero-Knowledge Proofs / Nullifier Generation).
- zera-sdk
crates/zera-core/src/note.rs— the canonical Rust implementation. - zera-sdk
docs/CRYPTOGRAPHY.md— the cross-implementation invariant spec. - zera-sdk
docs/SECURITY.md— threat model + cryptographic assumptions. - light-poseidon crate — Rust implementation we depend on.
- Building the Zera SDK: Day One — where the four-implementation invariant first landed.
- Why I started Zera Labs — the “fast enough, cheap enough” thesis.
- Building A Better Cryptocurrency — the privacy thesis these primitives implement.