Skip to main content
Version: 0.3.0

Cryptography Reference

All cryptographic primitives and parameters used in the system.

Curves

Grumpkin is the only curve used across all layers. It is an embedded curve over BN254's scalar field, which makes it efficient to operate on inside circuits that use BN254 as the proof system's native field.

ParameterValue
Equationy² = x³ − 17
Base fieldBN254 scalar field (Fr): 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001
Curve orderBN254 base field (Fq): 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47
GeneratorG = (1, 0x02cf135e7506a45d632d270d45f1181294833fc48d823f272c)
Cofactor1

The identity element is encoded as (0,0) throughout the codebase — this is not a point on the curve, but is special-cased as the identity in all Solidity, Noir, and TypeScript code.

Field Representation of Grumpkin Scalars

In Noir circuits, secret keys (ESK, SK) are Grumpkin scalars — elements of the Grumpkin scalar field F_p. However, they are represented as Field (the BN254 scalar field F_r) in circuit inputs. The relevant field sizes are:

FieldType in NoirSizeValue
BN254 scalar field F_rFieldr0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001
Grumpkin scalar field F_pEmbeddedCurveScalarp0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47

Note that p > r, but the difference is small:

p − r = 0x6f4d8248eeb859fbf83e9682e87cfd46 ≈ 2^127

Since both fields are ~2^254, the fraction of Grumpkin scalars that do not fit in F_r is (p − r) / p ≈ 2^-127, which is cryptographically negligible. When we sample a secret key uniformly at random from F_p, with all but negligible probability it is also a valid element of F_r. This is why it is safe to represent ElGamal secret keys (type EmbeddedCurveScalar) as Field in circuit inputs. The alternative would be to pass them as two Field elements (the lo/hi limb representation that EmbeddedCurveScalar uses internally), but this would increase the number of circuit inputs and constraints, resulting in larger circuits for no practical gain.

Grumpkin Point Representation

A Grumpkin point appears in four forms across the stack. The identity element (point at infinity) has a distinct encoding in each.

Affine (x, y) — canonical form

Used at every ABI boundary (events, storage, calldata). Coordinates are elements of F_r in [0, P).

  • Identity encoding: (0, 0) by convention. This is not a point satisfying y² = x³ − 17 (the RHS is −17, not 0), but every Grumpkin arithmetic primitive (addAffine, doubleAffine, negateAffine, isOnCurveAffine) special-cases it as the identity.
  • isOnCurveAffine((0, 0)) == true is intentional. The validator is identity-inclusive so a single check can qualify inputs for all affine operations, which uniformly accept (0, 0). Call sites that need to reject identity specifically (EPK registration, AR public key update, totalSupplyPk init) must also check (x, y) != (0, 0) before isOnCurveAffine. This is enforced in EBEMT._validateEpk and in the explicit require(…_x != 0 || …_y != 0, …) guards on AR / total-supply key writes.

Jacobian (X, Y, Z) — internal accumulator form

Used only inside Solidity hot paths (addMixedJacobian, doubleJacobian, jacobianToAffine). Represents the affine point (X/Z², Y/Z³).

  • Identity encoding: Z = 0 (X, Y are unused when Z = 0).
  • Never crosses the ABI: scalar multiplication builds up a Jacobian accumulator and converts to affine exactly once at the end (single modular inverse).

Compressed bytes32 — storage form

Used as the storage key for encryptedBalances, pendingBalances, and as the indexed event key for CompressedPoint.

  • Non-identity points: bit 255 = y-parity (1 if odd), bits 0–254 = x. Since P < 2²⁵⁴, x fits with one bit of headroom.
  • Identity encoding: bytes32(0).
  • Decompression is implemented only off-chain (packages/core/src/keys.ts via noble-curves). Solidity has compressEpk but no on-chain decompressor — a deliberate choice since all on-chain call sites receive points in affine and use the compressed form only as a key.

In-circuit (Noir EmbeddedCurvePoint)

Noir's standard library type is a struct { x: Field, y: Field, is_infinite: bool }. Circuits accept points via a simplified Point { x, y } input struct and convert through Into<EmbeddedCurvePoint> in circuits/eb/src/lib.nr, which sets is_infinite = (x == 0) & (y == 0). (0, 0) inputs therefore become proper Noir identity points — no more "lying" (0, 0, false) values entering the circuit.

Noir's embedded_curve_add blackbox still rejects infinite operands, so verify_elgamal_decryption short-circuits on R.is_infinite before calling multi_scalar_mul. This handles the on-chain publicToEncryptedTransfer path: the contract stores ciphertexts with R = (0, 0) (no randomness), which persists under homomorphic addition until an encrypted-to-encrypted transfer. The short-circuit reduces the assertion to C == m·G, matching the intended semantics.

EPK, trcPk, and totalSupplyPk are validated on-chain as non-identity curve points (_validateEpk, explicit non-zero + isOnCurveAffine). Circuit-generated R / C for new ciphertexts (new_balance_ciphertext, transfer_ciphertext, trc_ciphertext) are computed from non-zero prover randomness and would fail equality against an honest-identity input if ever accidentally zero. No soundness risk, just a stuck prover.

On-Chain Arithmetic

  • Affine add (addAffine) uses the modexp precompile (0x05) for 1/Δx via Fermat's little theorem — ~2.3k gas per modular inverse under EIP-2565.
  • Scalar mul with generator (ScalarBaseMul.mul) decomposes a ≤2^TABLE_SIZE scalar (currently 2^64) and sums precomputed 2^i · G using Jacobian mixed addition. The single modular inverse at the end saves ~60k+ gas vs. per-step affine addition. Scalars outside the supported range revert with ScalarTooLarge.
  • Modular arithmetic primitives (modInverse, expMod) are in contracts/src/Grumpkin.sol.

Proof System

UltraHonk via Noir and Barretenberg (bb.js). All circuits compile to constraint systems of size 2^15 (32,768 gates).

CircuitPublic inputs
Transfer23
Reveal12
EPK Ownership3
Mint12
Burn16
Shielded Deposit18
Merge18
Shielded Withdrawal14

Proof verification happens on-chain in Solidity via hardcoded verification keys.

Encryption

ElGamal in exponential form on the Grumpkin curve. This variant is additively homomorphic, which allows the contract to update recipient balances via point addition without decrypting.

EncryptEnc(m, pk, r) = (r·G, m·G + r·pk)
Decryptm·G = C − sk·R, then solve discrete log for m
Homomorphic additionEnc(a) + Enc(b) = Enc(a+b) via component-wise point addition

The trade-off is that decryption requires solving a discrete log on the Grumpkin curve. See discrete-log solver.

Hash Functions

Poseidon2 is the primary hash function, used everywhere inside circuits:

  • Balance commitments
  • Note commitments (Shielded Layer)
  • Nullifier derivation

The sponge construction uses rate 3, capacity 1 (4-element state). The implementation uses Noir's built-in poseidon2_permutation blackbox.

SHA-256 is used only in TypeScript for key derivation.

Keccak-256 is used on-chain (Solidity) and as an option in the proof backend.

Note Commitment

A note commitment binds an encrypted amount to the holder's public key:

commitment = Poseidon2(R_x, R_y, S_x, S_y, blinding, PK_x, PK_y)

Where:

  • (R, S) is the ElGamal ciphertext Enc(amount, PK, r) — the amount encrypted to the holder's public key
  • blinding is a random secret chosen at deposit time
  • (PK_x, PK_y) is the holder's Grumpkin public key

The PK binding means the note is not a bearer instrument — only the holder who knows the corresponding secret key SK can withdraw. The encrypted balance means intermediaries (e.g., the merge service) handle ciphertexts opaquely without learning plaintext amounts.

Nullifier Mapping

Nullifiers are stored in a flat on-chain mapping:

mapping(bytes32 => bool) public nullifiers;

The contract checks nullifiers[n] == false on each spend and sets nullifiers[n] = true. This is an O(1) lookup — no tree witnesses and no circuit involvement for non-membership proofs. Nullifiers are published on-chain and are publicly visible.

Nullifier Derivation

nullifier = Poseidon2(blinding, leafIndex)

blinding is the random secret from the note commitment. leafIndex is the position of the note commitment in the commitment tree. Anyone who knows both values — including the merge service — can compute the nullifier. This is safe because the nullifier is only a double-spend prevention mechanism: the merge circuit enforces that merged notes can only be consolidated to the identical PK, and only the SK holder can perform a Shielded Withdrawal. Security rests on the circuit constraints, not on nullifier secrecy.

The nullifier is deterministic — the same note always produces the same nullifier, preventing double-spending.

Burn EPK

Administrative and compliance burns send funds to an unspendable burn address. The burn EPK is derived using RFC 9380 hash-to-curve, which maps an arbitrary input to a Grumpkin point with no known discrete log. Since no entity knows the corresponding secret key, funds sent to the burn EPK can never be spent.

burnEPK = hash_to_curve("burn", domain_separator)

The transfer and withdrawal circuits validate EPK ownership via scalar_mul_base(SK) == EPK. Because the burn EPK has no known SK, this check can never be satisfied — the funds are provably irrecoverable.