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.
| Parameter | Value |
|---|---|
| Equation | y² = x³ − 17 |
| Base field | BN254 scalar field (Fr): 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001 |
| Curve order | BN254 base field (Fq): 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47 |
| Generator | G = (1, 0x02cf135e7506a45d632d270d45f1181294833fc48d823f272c) |
| Cofactor | 1 |
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:
| Field | Type in Noir | Size | Value |
|---|---|---|---|
| BN254 scalar field F_r | Field | r | 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001 |
| Grumpkin scalar field F_p | EmbeddedCurveScalar | p | 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47 |
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)) == trueis 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,totalSupplyPkinit) must also check(x, y) != (0, 0)beforeisOnCurveAffine. This is enforced in EBEMT._validateEpk and in the explicitrequire(…_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,Yare unused whenZ = 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.tsvia noble-curves). Solidity hascompressEpkbut 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_SIZEscalar (currently 2^64) and sums precomputed2^i · Gusing Jacobian mixed addition. The single modular inverse at the end saves ~60k+ gas vs. per-step affine addition. Scalars outside the supported range revert withScalarTooLarge. - 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).
| Circuit | Public inputs |
|---|---|
| Transfer | 23 |
| Reveal | 12 |
| EPK Ownership | 3 |
| Mint | 12 |
| Burn | 16 |
| Shielded Deposit | 18 |
| Merge | 18 |
| Shielded Withdrawal | 14 |
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.
| Encrypt | Enc(m, pk, r) = (r·G, m·G + r·pk) |
| Decrypt | m·G = C − sk·R, then solve discrete log for m |
| Homomorphic addition | Enc(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 ciphertextEnc(amount, PK, r)— the amount encrypted to the holder's public keyblindingis 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.