EVM/Tron Circuit
Overview
Hinkal's MainEVMCircuit is a generalized, multi‑token transaction circuit. It allows a user to consume up to inputCount input UTXOs for each of tokenCount distinct token types, and to create up to outputCount output UTXOs per token type, inside a single zero‑knowledge proof. The circuit:
reconstructs each input note's stealth public key from the spender's secret key,
recomputes each input commitment and proves its membership in the on‑chain commitment tree,
recomputes each input's nullifier and binds it to the publicly declared nullifier (preventing double‑spends),
recomputes and checks each output commitment,
enforces a balance equation per token type (linking inputs, outputs and a declared net change),
proves spend authorization through an EdDSA signature over a message that is itself pinned to a secret seed,
and derives the sender's stealth address plus the elliptic‑curve helper values that the smart contract reuses on‑chain.
Because the number of tokens, inputs and outputs is parameterized, all per‑UTXO data is matrix‑shaped ([tokenCount][inputCount] or [tokenCount][outputCount]) and the circuit loops over these dimensions internally. It is instantiated at fixed sizes (e.g. 1x2x1, 2x2x2, 3x2x1, Min0) to generate the concrete proving artifacts.
It is built from Poseidon‑based primitives in circomlib:
StealthAddressCalculator
Derives the sender's stealth address and H1 from H0, the spending key and the nullifying key.
StealthAddressCalculatorExtended
Derives each input note's public key, supporting both a legacy and a new derivation scheme.
CommitmentCalculator
Poseidon‑4 note commitment, zeroed when the amount is zero.
Signature
Poseidon‑2 signature of a commitment under the nullifying key.
NullifierCalculator
Poseidon‑2 nullifier from commitment + signature, zeroed when the commitment is zero.
SignatureVerifier
EdDSA‑Poseidon verification of the spending‑key signature.
MerkleRootCalculator
Recomputes a Merkle root from a leaf + sibling path.
OverflowPreventer
Range‑checks each amount to prevent field wrap‑around.
Template parameters
tokenCount— number of distinct token types handled in one proof.inputCount— maximum input UTXOs per token type.outputCount— maximum output UTXOs per token type.treeDepth— depth of the commitment Merkle tree.
Public inputs
rootHashHinkal— Merkle root of the on‑chain note‑commitment tree. Every non‑empty input must prove membership against this root.signedMessageHash— the message that the EdDSA signature must verify against underspendingPublicKey.erc20TokenAddresses[tokenCount]— the token address for each token type (zero address = ETH). All entries must be distinct.amountChanges[tokenCount]— the net change for each token type. For each token, the sum of outputs must equal the sum of inputs plus this value (positive for deposits/refunds, negative for withdrawals/spends).outTimeStamp— a single timestamp applied to all output commitments.inNullifiers[tokenCount][inputCount]— the declared nullifier for each input; each must equal the value the circuit recomputes.outCommitments[tokenCount][outputCount]— the claimed output commitments.calldataHash— a calldata digest exposed for binding by the contract; not otherwise constrained inside the circuit.H0Ax,H0Ay— the BabyJubJub curve point used to derive the sender's own stealth address.
(message and outH1Ay below are also part of the public signal vector, as circuit outputs.)
Public outputs
outStealthAddress— the sender's stealth address:Poseidon(6)(signs, H0Ay, H1Ay, spendingPublicKey[0], spendingPublicKey[1], nullifyingPrivateKey), wheresignscomes from compressing the(H0, H1)coordinates.outH1Ay— the y‑coordinate ofH1 = nullifyingPrivateKey · H0. Exposed so the contract can reconstruct commitments without redoing the expensive elliptic‑curve scalar multiplication.message—Poseidon(1)(messageSeed), binding the publicly visible message to a private seed.
Private inputs (witness)
nullifyingPrivateKey— the spender's secret (nullifying) key.spendingPublicKey[2]— the spending public key, a point on the BabyJubJub curve.eddsaSignature[3]— the EdDSA signature(R8x, R8y, S)oversignedMessageHash.messageSeed— secret preimage ofmessage.inAmounts[tokenCount][inputCount]— amount of each input UTXO.inH0Ax,inH0Ay[tokenCount][inputCount]— the per‑input curve pointH0used to rebuild that note's public key.isNewStyle[tokenCount][inputCount]— per‑input flag selecting the new (Poseidon‑6) vs legacy (Poseidon‑3) public‑key derivation.inTimeStamps[tokenCount][inputCount]— timestamp baked into each input commitment.inCommitmentSiblings[tokenCount][inputCount][treeDepth]andinCommitmentSiblingSides[tokenCount][inputCount][treeDepth]— the Merkle authentication path for each input (left = 0,right = 1).outAmounts[tokenCount][outputCount]— amount of each output UTXO.outPublicKeys[tokenCount][outputCount]— recipient stealth public key for each output.
Conditions enforced by the circuit
1. Valid spending signature
A SignatureVerifier (EdDSAPoseidonVerifier, always enabled) checks that eddsaSignature is a valid signature on signedMessageHash under spendingPublicKey. This proves the prover controls the spending key without revealing it.
2. Message pinning
message <== Poseidon(1)([messageSeed]). The public message output is forced to be the Poseidon hash of the private messageSeed, binding the exposed value to a secret the prover knows.
3. Correct nullifiers (per input)
For every token i and input j:
Reconstruct the note's public key via
StealthAddressCalculatorExtendedfromnullifyingPrivateKey,(inH0Ax, inH0Ay)andisNewStyle[i][j]. Internally it computesH1 = nullifyingPrivateKey · H0, compresses the coordinates to obtainsigns, and then blends two derivations:new style:
Poseidon(6)(signs, H0Ay, H1Ay, spendingPublicKey[0], spendingPublicKey[1], nullifyingPrivateKey)legacy style:
Poseidon(3)(signs, H0Ay, H1Ay)
selecting between them with
out = new + isOldStyle·(old − new)(i.e.isNewStyle = 0⇒ legacy, else new). This lets old and new notes be spent together in one proof.Rebuild the input commitment with
CommitmentCalculator:Poseidon(4)(inAmounts, erc20TokenAddresses[i], publicKey, inTimeStamps) · (1 − isZero(inAmounts)). A zero amount yields a zero commitment.Sign the commitment with
Signature:Poseidon(2)(nullifyingPrivateKey, commitment).Compute the nullifier with
NullifierCalculator:Poseidon(2)(commitment, signature) · (1 − isZero(commitment)).Assert
inNullifiers[i][j] === computed nullifier.
This binds each spent note to its owner and prevents double‑spending. Empty (zero‑amount) slots collapse to a zero commitment and a zero nullifier.
4. Valid Merkle membership for inputs (per input)
For each input, MerkleRootCalculator(treeDepth) folds the input commitment up through inCommitmentSiblings / inCommitmentSiblingSides to a candidate root. A ForceEqualIfEnabled then asserts that root equals rootHashHinkal, enabled only when inAmounts[i][j] is non‑zero. Zero‑amount padding inputs therefore skip the membership check.
5. Correct output commitments (per output)
For every token i and output j, CommitmentCalculator recomputes Poseidon(4)(outAmounts, erc20TokenAddresses[i], outPublicKeys, outTimeStamp) and asserts it equals outCommitments[i][j]. (Negative output amounts are impossible — they would fail as a non‑field element.)
6. Balanced amounts (per token type)
For each token type i, the circuit accumulates inTotal over all inputs and outTotal over all outputs and enforces:
so the difference between inputs and outputs exactly equals the declared amountChanges[i] — no value is minted or destroyed.
7. Distinct token addresses
For every pair (i, j) with i < j, an IsEqual gadget is constrained so its output is 0, asserting that every entry of erc20TokenAddresses is unique. This requires tokenCount·(tokenCount−1)/2 comparators.
8. Sender stealth‑address derivation
Using H0Ax, H0Ay, spendingPublicKey and nullifyingPrivateKey, StealthAddressCalculator computes H1 = nullifyingPrivateKey · H0 and the Poseidon‑6 stealth address, exposing outStealthAddress and outH1Ay. The contract reuses these to reconstruct commitments cheaply, avoiding the elliptic‑curve work on‑chain.
9. Overflow prevention
Every inAmounts[i][j] and outAmounts[i][j] is passed through an OverflowPreventer (a ConditionalOverflowPreventer, always enabled), range‑checking the value to prevent field wrap‑around. All hashing and signature checks use Poseidon‑based primitives from circomlib.
Last updated