noircashdocs

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):

#NameMeaning
0rootA Merkle root the contract knows (isKnownRoot)
1-2nullifiers[2]Nullifiers of the two inputs
3-4out_commitments[2]Commitments of the two outputs
5exit_sharesShares leaving the private side, gas fee included
6ext_data_hashkeccak256(abi.encode(ext)) mod p
7domain_hiHigh 128 bits of the vault's EIP-712 domain separator
8domain_loLow 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:

NameTypeMeaning
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] eachThe 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_shares

The 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) == true

This is the EIP-712 digest the wallet signed (see Keys).

Checks outside the circuit

PrivateVault._transact adds the checks that need chain state:

  • root is 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 exitShares are below p (NotAFieldElement);
  • ext.gasFee <= exitShares (FeeExceedsExit);
  • if user shares leave (exitShares - gasFee > 0) and it is not a sale in escrow, ext.recipient is not zero (ZeroAddress);
  • if ext.caller is 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:

FieldWhat binding it prevents
recipientRedirecting an unshield
callerSubmitting a router-bound or ERC-4337 proof somewhere else (for example straight to transact)
gasFeeRaising the fee a user pays
dataChanging sale terms. For PrivateRouter.sell: abi.encode(ethRecipient, minEthOut). For ERC-4337: abi.encode(minCallGasLimit, ethRecipient, minEthOut)
ciphertext0, ciphertext1Replacing the encrypted notes, which would make them unfindable for their owners

Sizes

MeasureValue
Circuit size83,781 gates (2^17), of which about 73,000 check the wallet signature
Proof9,152 bytes
On-chain verificationabout 2.35M gas
Proving timeabout 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

ComponentVersion
nargo / @noir-lang/noir_js1.0.0-beta.22
bb / @aztec/bb.js5.0.0-nightly.20260522
noir-lang/poseidonv0.3.0
noir-lang/keccak256v0.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.

On this page