Reference

Circuits

The aggregate disclosure circuits Tally ships, and the OpenZeppelin circuits used on-chain.

Two groups of circuits are involved.

GroupWhereVerifiedProof mode
Tally's aggregate disclosure circuits, tally_aggregate_n{8,16,64}circuits/Off-chain, by the donorZero-knowledge (keccakZK)
OpenZeppelin v0.9.0 register, transfer, withdrawvendor/stellar-contracts, compiled into ct/sdk/circuitsOn-chain, by the verifier contractNon-zk (keccak); the on-chain verifier implements only the non-zk flavour

The aggregate disclosure circuit

tally_aggregate_nN proves to one disclosure recipient that a set of on-chain confidential transfers, sent by accounts the prover controls, sums to exactly a total, without revealing any individual amount. The circuits are generated from circuits/_template.nr for N = 8, 16 and 64.

Inputs

KindInputs
Private, per eventsk (the sending lane's spending key), r_e (the ephemeral scalar), v_tx (the amount)
Private, oncer_disc
Public, commonaddr_f (the token contract as a field), n_active
Public, per eventactive, pvk_a_x/y (sender), pvk_b_x/y (recipient), r_e_x/y, sigma, v_tilde
Public, disclosure channelp_r_x/y (the donor's key), nu (the donor's nonce), r_disc_x/y, v_tilde_disc (the sealed total)

There are 9n + 8 public inputs.

What it checks, per active event

  • active is 0 or 1.
  • The spending key derives the sender's public viewing key, binding the event to its sender.
  • r_e matches the event's published ephemeral point, so only the party that made the transfer can satisfy it.
  • The recipient key is on the curve and not the identity.
  • Recomputing the encrypted amount from r_e, the recipient key and sigma gives the event's published ciphertext. This recovers the amount from the sender side.
  • The amount fits in 127 bits.

Then, once: the sum of active amounts is sealed to the donor's key and nonce, n_active equals the number of active slots, and n_active ≥ MIN_ACTIVE.

Differences from OpenZeppelin's aggregate

The upstream aggregate disclosure is written for one sender account. Tally extends it in two ways, both raised as OpenZeppelin/stellar-contracts#849 (fixed upstream on 2026-09-10):

UpstreamTally
Recipient keyNot per eventPer event: each outbound transfer has its own recipient
Sender key and spending keyCommon: one sender accountPer event: one proof spans every lane in a round
n_activeNot presentPublic input
Minimum group sizeNot presentEnforced in the circuit

Measured

From circuits/README.md, re-measured 2026-10-05 on v0.9.0 (nargo info; pnpm bench:aggregate on an Apple M4 Pro, second run after warm-up):

nACIR opcodesProveVerifyProof sizePublic inputs
83321,163 ms380 ms16,224 B80
166361,891 ms581 ms16,224 B152
642,4605,983 ms1,472 ms16,224 B584

Proof size is the same for every n. The demo's own n = 16 proof took 4,723 ms in the run that published round-005; timings vary between runs.

Build and pinning

pnpm build:circuits     # needs nargo 1.0.0-beta.11

This regenerates aggregate_n*/src/main.nr from the template, compiles each circuit, copies the result to aggregate_n*/circuit.json, and re-pins aggregate_n*/vk.zk.bin. Both files are committed, so verifying a round needs no Noir toolchain. tally verify refuses to verify if the key it derives differs from vk.zk.bin, and CI rebuilds both and fails on any difference.

Do not edit aggregate_n*/src/main.nr by hand; edit _template.nr. MIN_ACTIVE (default 5) and the set of sizes can be overridden when generating:

MIN_ACTIVE=8 SIZES="16 32" bash circuits/scripts/generate.sh

The on-chain circuits

The token contract verifies an UltraHonk proof on every state change. Tally uses OpenZeppelin's v0.9.0 circuits unchanged, compiled with nargo 1.0.0-beta.11. The verification keys registered on-chain are OpenZeppelin's committed .vk.bin files; the matching .vk.json files were reproduced byte-for-byte with nargo 1.0.0-beta.11 and bb 0.87.0 (see ct/NOTICE.md).

CircuitPublic inputsProof size
register614,592 B
transfer2514,592 B

Source: pnpm test:prove (ct/sdk/test/prove.ts).