The transact circuit
The one zero-knowledge circuit behind every private spend - its inputs, constraints, public inputs, ExtData binding, sizes and toolchain.
Every private spend, whether a send, a sale or an unshield, is one proof of the transact circuit.
It spends up to two notes, creates two notes, and optionally lets shares
leave the private side. The contract verifies the proof with an on-chain UltraHonk verifier.
Inputs
Public inputs, in this exact order (PrivateVault._publicInputs builds the same array):
| # | Name | Meaning |
|---|---|---|
| 0 | root | A Merkle root the contract knows (isKnownRoot) |
| 1-2 | nullifiers[2] | Nullifiers of the two inputs |
| 3-4 | out_commitments[2] | Commitments of the two outputs |
| 5 | exit_shares | Shares leaving the private side, gas fee included |
| 6 | ext_data_hash | keccak256(abi.encode(ext)) mod p |
| 7 | domain_hi | High 128 bits of the vault's EIP-712 domain separator |
| 8 | domain_lo | Low 128 bits of the domain separator |
The contract computes ext_data_hash from the calldata and the domain halves from its own _domainSeparatorV4();
the prover does not choose them.
Private inputs:
| Name | Type | Meaning |
|---|---|---|
in_nk | [Field; 2] | Nullifier key of each input |
in_rho, in_r | [Field; 2] | Note secrets of each input |
in_shares | [Field; 2] | Shares of each input |
in_index | [u32; 2] | Leaf index of each input |
in_path | [[Field; 24]; 2] | Merkle siblings of each input |
out_stub | [Field; 2] | Stub of each output, H(opk, rho, r) of its owner |
out_shares | [Field; 2] | Shares of each output |
pk_x, pk_y | [u8; 32] each | The owner's wallet key, uncompressed secp256k1, big-endian |
signature | [u8; 64] | The wallet's Spend signature, `r |
Constraints
For each input i:
in_shares[i] < 2^120
in_index[i] < 2^24
cm_i = H(1, H(H(in_nk[i], x_hi, x_lo, y_hi, y_lo), in_rho[i], in_r[i]), in_shares[i])
if in_shares[i] != 0: merkle_root(cm_i, in_index[i], in_path[i]) == root
nullifiers[i] == H(2, in_nk[i], cm_i, in_index[i])For each output j:
out_shares[j] < 2^120
out_commitments[j] == H(1, out_stub[j], out_shares[j])Then:
exit_shares < 2^120
in_shares[0] + in_shares[1] == out_shares[0] + out_shares[1] + exit_sharesThe 120-bit range checks make the sums unable to wrap the field: without them an output of p - 1 could balance a
larger second output. Both inputs are owned by the same wallet key, because one (pk_x, pk_y) enters both owner keys.
A zero-value input is a dummy and needs no membership, but its nullifier is still checked.
Finally, the authorization:
transaction = H(root, nf0, nf1, cm0, cm1, exit_shares, ext_data_hash)
struct_hash = keccak256(SPEND_TYPEHASH || transaction) SPEND_TYPEHASH = keccak256("Spend(bytes32 transaction)")
domain_hi, domain_lo < 2^128
digest = keccak256(0x19 || 0x01 || domain_hi (16 bytes) || domain_lo (16 bytes) || struct_hash)
ecdsa_secp256k1_verify(pk_x, pk_y, signature, digest) == trueThis is the EIP-712 digest the wallet signed (see Keys).
Checks outside the circuit
PrivateVault._transact adds the checks that need chain state:
rootis a known root: one of the last 64, or a final root of a full tree (UnknownRoot);- the two nullifiers differ and neither is spent (
DuplicateNullifier,NullifierAlreadySpent); - commitments, nullifiers and
exitSharesare belowp(NotAFieldElement); ext.gasFee <= exitShares(FeeExceedsExit);- if user shares leave (
exitShares - gasFee > 0) and it is not a sale in escrow,ext.recipientis not zero (ZeroAddress); - if
ext.calleris not zero, only that address may submit (WrongCaller).
ExtData
struct ExtData {
address recipient; // receives (exitShares - gasFee), converted to NOIR, by ERC-20 transfer
address caller; // if non-zero, the only allowed submitter: the router, or the vault for ERC-4337
uint256 gasFee; // shares, out of exitShares, paid to the gas reserve
bytes data; // parameters for `caller`
bytes ciphertext0; // encrypted output note 0
bytes ciphertext1; // encrypted output note 1
}
function extDataHash(ExtData memory ext) public pure returns (uint256) {
return uint256(keccak256(abi.encode(ext))) % NoteHash.FIELD;
}Everything in ExtData is bound to the proof and to the wallet's signature, so no submitter can change it:
| Field | What binding it prevents |
|---|---|
recipient | Redirecting an unshield |
caller | Submitting a router-bound or ERC-4337 proof somewhere else (for example straight to transact) |
gasFee | Raising the fee a user pays |
data | Changing sale terms. For PrivateRouter.sell: abi.encode(ethRecipient, minEthOut). For ERC-4337: abi.encode(minCallGasLimit, ethRecipient, minEthOut) |
ciphertext0, ciphertext1 | Replacing the encrypted notes, which would make them unfindable for their owners |
Sizes
| Measure | Value |
|---|---|
| Circuit size | 83,781 gates (2^17), of which about 73,000 check the wallet signature |
| Proof | 9,152 bytes |
| On-chain verification | about 2.35M gas |
| Proving time | about 1.3 s in Node (bb.js), 3 to 5 s in desktop Chrome |
The verifier is the largest cost of a private transaction: a sale through handleOps used about 4.76M gas on
testnet. Measured numbers are listed with the rest in Constants.
Proof system and toolchain
| Component | Version |
|---|---|
nargo / @noir-lang/noir_js | 1.0.0-beta.22 |
bb / @aztec/bb.js | 5.0.0-nightly.20260522 |
| noir-lang/poseidon | v0.3.0 |
| noir-lang/keccak256 | v0.1.3 |
The proof system is UltraHonk. It needs no circuit-specific trusted setup: there is no ceremony to run or trust
for this circuit. It uses Barretenberg's universal CRS (common reference string). The verifier is generated with the
EVM target (-t evm), the zero-knowledge variant with a keccak transcript, and deployed as HonkVerifier. Groth16
would verify for less gas but needs a per-circuit ceremony, and was not adopted.
Where proving happens
Proofs are built on the user's device. The app runs noir_js to solve the witness and bb.js (UltraHonkBackend,
verifierTarget: "evm") to prove, in the browser. The page is cross-origin isolated so bb.js can use WebAssembly
threads. The witness, which holds nk, the note secrets and the signature, never leaves the device.
The CRS (2^17 points: g1_compressed.dat, g2.dat, grumpkin_g1.dat) is served by the app itself from /crs,
with pinned hashes. Otherwise bb.js would fetch it from Aztec's CDN, which would see the user's IP each time they
prepare a private transaction.
The vault's verifier is immutable. A changed circuit needs a new verifier, and so a new vault.
Keys
How the privacy keys are derived from one wallet signature, why every spend needs a fresh wallet signature, and how keys are registered.
The vault and the holder yield
How notes hold shares of the vault's NOIR, the exact conversion formulas and rounding, how the fee harvester turns the Pons creator fees into donations, and the locked seed against the first-depositor attack.