Multi Asset Pool Circuit (Zero Knowledge Proof)

A set membership proof proves that a leaf, which is the hash(commitment) of a UTXO, belongs to a Merkle tree by proving the root of the Merkle tree as public input.
These proofs, in combination with checks that ensure no new tokens are created out of thin air, are verified in system verifiers. Nullifiers uniquely identify a UTXO without revealing any information and are saved to prevent double-spending of a UTXO.
Additional logic can be specified in a UTXO and is enforced by a specific application verifier program. System and application verifiers are connected by both exposing a hash over all UTXOs used in the transaction, which is checked by executing both verifier programs.
The circuit has two kinds of inputs, public inputs, and private inputs. Public inputs are visible on the blockchain and used by the Solana program to check the plausibility of the proof. Private inputs are the majority of data being proven in the zero-knowledge proof. This data is only known to the prover; the proof does not reveal anything about private inputs.
The circuit takes UTXO parameters for input and output UTXOs as inputs.

Public inputs:

  • Merkle tree root
  • Mint
  • public amount (spl token amount which is deposited or withdrawn)
  • fee amount (sol amount which is deposited or withdrawn)
  • integrity hash (hash over data that is checked in the program)
  • nullifiers
  • commitments
  • connecting hash (only if application UTXOs enabled)
  • verifier (only if application UTXOs enabled)
Private Inputs
  • inUtxos:
    • inputNullifier public input nullifier
    • inAmount amounts of input utxos
    • inPrivateKey private key of
    • inBlinding
    • inInstructionType
    • inPathIndices
    • inPathElements
    • inPoolType
    • inVerifierPubkey
    • inIndices
  • outUtxos:
    • outputCommitment
    • outAmount
    • outPubkey
    • outBlinding
    • outInstructionType
    • outPoolType
    • outVerifierPubkey
    • outIndices
  • assetPubkeys

Circuit Config

  • tree height: 18
  • number of inputs: 2 / 10 / 4 (different instantiations verifier zero, one, two)
  • number of outputs 2 / 2 / 4 (different instantiations verifier zero, one, two)
  • fee asset: sol
  • index fee asset: 0
  • index public asset: 1
  • number of In assets: 3
  • number of out assets: 3
  • number of assets: 3

Circuit Checks:

  • Asset checks
  • Input utxo check: input utxos are well formed
  • Set Membership in Merkle Tree for non-zero input utxos Is checked with merkle proof private inputs only enabled if amounts are non zero
  • Output utxo check: output utxos are well formed
  • Sumcheck: the sum over the amounts of every assets plus spl public amount & sol public amount
  • Nullifier check: no nullifier is contained twice
  • spl mint check
  • integrity hash check
  • connecting hash check (only if application utxos enabled)
  • verifier check (only if application utxos enabled)
Asset Checks
We need to check that all assets are smaller than the field size to avoid overflows.
for (var i = 0; i < nAssets; i++) {
assetCheck[i] = Num2Bits(248);
assetCheck[i].in <== assetPubkeys[i];
Asset assignment:
We use inIndices and outIndices to check that all utxos only include the assets specified in assetPubkeys.
Output Utxos:
Check Indices: To make this secure we need to enforce that in every row only one number is 1 and all others are 0, plus that if the amount is greater than 0 an asset has to be defined.
Input Utxos:
Input Utxos Check
This is educational pseudo code look here for the implementation.
inKeypair[tx] = Keypair();
inKeypair[tx].privateKey <== inPrivateKey[tx];
// determine the asset type
// and checks that the asset is included in assetPubkeys[nInAssets]
// skips first asset since that is the feeAsset
// iterates over remaining assets and adds the assetPubkey if index is 1
// all other indices are zero
inAssetsHasher[tx] = Poseidon(nInAssets);
for (var a = 0; a < nInAssets; a++) {
var assetPubkey = 0;
for (var i = 0; i < nAssets; i++) {
inGetAsset[tx][a][i] = AND();
inGetAsset[tx][a][i].a <== assetPubkeys[i];
inGetAsset[tx][a][i].b <== inIndices[tx][a][i];
assetPubkey += inGetAsset[tx][a][i].out;
inAssetsHasher[tx].inputs[a] <== assetPubkey;
inAmountsHasher[tx] = Poseidon(nInAssets);
var sumInAmount = 0;
for (var a = 0; a < nInAssets; a++) {
inAmountCheck[tx][a] = Num2Bits(64);
inAmountCheck[tx][a].in <== inAmount[tx][a];
inAmountsHasher[tx].inputs[a] <== inAmount[tx][a];
sumInAmount += inAmount[tx][a];
inCommitmentHasher[tx] = Poseidon(7);
inCommitmentHasher[tx].inputs[0] <== inAmountsHasher[tx].out;
inCommitmentHasher[tx].inputs[1] <== inKeypair[tx].publicKey;
inCommitmentHasher[tx].inputs[2] <== inBlinding[tx];
inCommitmentHasher[tx].inputs[3] <== inAssetsHasher[tx].out;
inCommitmentHasher[tx].inputs[4] <== inInstructionType[tx];
inCommitmentHasher[tx].inputs[5] <== inPoolType[tx];
inCommitmentHasher[tx].inputs[6] <== inVerifierPubkey[tx];
Set Membership in Merkle Tree for non-zero input utxos
inTree[tx] = MerkleProof(levels);
inTree[tx].leaf <== inCommitmentHasher[tx].out;
inTree[tx].pathIndices <== inPathIndices[tx];
for (var i = 0; i < levels; i++) {
inTree[tx].pathElements[i] <== inPathElements[tx][i];
Output Utxos Check
This is educational pseudo code look here for the implementation.
outCommitmentHasher[tx] = Poseidon(7);
outCommitmentHasher[tx].inputs[0] <== outAmountHasher[tx].out;
outCommitmentHasher[tx].inputs[1] <== outPubkey[tx];
outCommitmentHasher[tx].inputs[2] <== outBlinding[tx];
outCommitmentHasher[tx].inputs[3] <== outAssetHasher[tx].out;
outCommitmentHasher[tx].inputs[4] <== outInstructionType[tx];
outCommitmentHasher[tx].inputs[5] <== outPoolType[tx];
outCommitmentHasher[tx].inputs[6] <== outVerifierPubkey[tx];
outCommitmentHasher[tx].out === outputCommitment[tx];
Sum Check
This is educational pseudo code look here for the implementation.
var sumIn = [0;2]
for (a in assets) {
for (utxo in input utxos) ( sum[a]+=utxo.amount[a])
var sumOut = [0;2]
for (a in assets) {
for (utxo in output utxos) ( sum[i]+=utxo.amount[i])
// actual sumcheck
sumIn[0] = sumOut[0] + feeAmount
sumIn[1] = sumOut[1] + publicAmount
the variables publicAmount and feeAmount are public and tell the verifier program what amounts are deposited to or withdrawn from the liquidity pools
Example calculation deposit:
SumOutputUtxosSol = 1_000_000_000
SumInputUtxosSol = 0
bn254ScalarFieldSize = 21888242871839275222246405745257275088548364400416034343698204186575808495617
public Amount Sol = SumOutputUtxosSol - SumInputUtxosSol + bn254ScalarFieldSize mod bn254ScalarFieldSize
public Amount Sol = 1_000_000_000
Example calculation withdrawal: For the withdrawal calculation we can use the modulo arithmetic of the circuit to express a subtraction in the same equation as an addition.
SumOutputUtxosSol = 999_900_000
SumInputUtxosSol = 1_000_000_000
bn254ScalarFieldSize = 21888242871839275222246405745257275088548364400416034343698204186575808495617
public Amount Sol = SumOutputUtxosSol - SumInputUtxosSol + bn254ScalarFieldSize mod bn254ScalarFieldSize
public Amount Sol = 21888242871839275222246405745257275088548364400416034343698204186575808395617
Code to validate example:
const anchor = require("@coral-xyz/anchor");
const bn254ScalarFieldSize = new anchor.BN("21888242871839275222246405745257275088548364400416034343698204186575808495617")
const relayerFee = new anchor.BN(100_000)
const sumInputAmount = new anchor.BN(1_000_000_000)
const sumOutputAmount = sumInputAmount.sub(relayerFee)
const publicAmountSol = sumOutputAmount.sub(sumInputAmount).add(bn254ScalarFieldSize).mod(bn254ScalarFieldSize);
console.log(sumOutputAmount.toString() === sumInputAmount.add(publicAmountSol).mod(bn254ScalarFieldSize).toString());
Nullifier Check
Nullifiers are computed in the proof and exposed as public input. This public input is checked by the executing verifier program.
Spl Mint Check
The mint variable is public as well and specifies the asset for deposits/withdrawals determined by the public amount variable.
Integrity Hash Check
The IntegrityHash, is computed onchain to include inputs which are not part of utxos in the proof. This way these inputs can be enforced by a verifier program while a relayer can submit a transaction on our behalf. Because all public parameters are included in the proof we do not need to trust the relayer. If the relayer changes any parameter no system verifier will verify the proof successfully, in other words the protocol will reject the transaction.
IntegrityHash = Keccak256 Hash(
relayer publickey, // signer/relayer public key
relayer fee,
encrypted utxos // encrypted utxos or other data to be stored onchain
Connecting Hash Check (only if application utxos are enabled)
The connecting hash compresses all input utxos, output utxo and tx integrity hash into one hash. This hash is a public input to both the application proof and system proof. The verifier programs can check the hash and this way enforce that both proofs have been generated with the same private inputs.
// hash commitment
component inputHasher = Poseidon(nIns);
for (var i = 0; i < nIns; i++) {
inputHasher.inputs[i] <== inCommitmentHasher[i].out;
component outputHasher = Poseidon(nOuts);
for (var i = 0; i < nOuts; i++) {
outputHasher.inputs[i] <== outCommitmentHasher[i].out;
component connectingHasher = Poseidon(3);
connectingHasher.inputs[0] <== inputHasher.out;
connectingHasher.inputs[1] <== outputHasher.out;
connectingHasher.inputs[2] <== extDataHash;
connectingHash === connectingHasher.out;