Skip to main content

Overview

Shielded transfers allow users to send funds privately within the pool without revealing sender, recipient, or amount. The transaction consumes input UTXOs and creates output UTXOs, all verified by zero-knowledge proofs.

Transfer Characteristics

A shielded transfer has these properties:
  • extAmount = 0 (no funds enter or leave the pool)
  • Input UTXOs are consumed (nullifiers created)
  • Output UTXOs are created (commitments added)
  • All details hidden by ZK proofs

Transaction Flow

1

Select input UTXOs

Choose one or more of your existing UTXOs to spend (up to 16 inputs).
2

Create output UTXOs

Generate output UTXOs for the recipient and change (exactly 2 outputs).
3

Build Merkle proof

Construct Merkle proofs for each input UTXO to prove ownership.
4

Generate ZK proof

Create a zero-knowledge proof validating the transaction.
5

Submit transaction

Call transact to execute the shielded transfer.

Zero ExtAmount

For internal transfers, extAmount equals zero:
src/index.js
let extAmount = BigNumber.from(fee)
  .add(outputs.reduce((sum, x) => sum.add(x.amount), BigNumber.from(0)))
  .sub(inputs.reduce((sum, x) => sum.add(x.amount), BigNumber.from(0)))
Balance equation:
inputs.sum() = outputs.sum() + fee
extAmount = outputs.sum() + fee - inputs.sum() = 0
When extAmount = 0, the transact function skips token transfers and only processes the proof.

Building the Merkle Tree

Before creating a transfer, build the current Merkle tree state:
src/index.js
async function buildMerkleTree({ tornadoPool }) {
  const filter = tornadoPool.filters.NewCommitment()
  const events = await tornadoPool.queryFilter(filter, 0)

  const leaves = events.sort((a, b) => a.args.index - b.args.index).map((e) => toFixedHex(e.args.commitment))
  return new MerkleTree(MERKLE_TREE_HEIGHT, leaves, { hashFunction: poseidonHash2 })
}
The Merkle tree contains all commitments from previous transactions, which is required to prove ownership of input UTXOs.

Generating the Proof

The proof generation requires detailed transaction information:
src/index.js
async function getProof({
  inputs,
  outputs,
  tree,
  extAmount,
  fee,
  recipient,
  relayer,
  isL1Withdrawal,
  l1Fee,
}) {
  inputs = shuffle(inputs)
  outputs = shuffle(outputs)

  let inputMerklePathIndices = []
  let inputMerklePathElements = []

  for (const input of inputs) {
    if (input.amount > 0) {
      input.index = tree.indexOf(toFixedHex(input.getCommitment()))
      if (input.index < 0) {
        throw new Error(`Input commitment ${toFixedHex(input.getCommitment())} was not found`)
      }
      inputMerklePathIndices.push(input.index)
      inputMerklePathElements.push(tree.path(input.index).pathElements)
    } else {
      inputMerklePathIndices.push(0)
      inputMerklePathElements.push(new Array(tree.levels).fill(0))
    }
  }

  const extData = {
    recipient: toFixedHex(recipient, 20),
    extAmount: toFixedHex(extAmount),
    relayer: toFixedHex(relayer, 20),
    fee: toFixedHex(fee),
    encryptedOutput1: outputs[0].encrypt(),
    encryptedOutput2: outputs[1].encrypt(),
    isL1Withdrawal,
    l1Fee,
  }

  const extDataHash = getExtDataHash(extData)
  let input = {
    root: tree.root(),
    inputNullifier: inputs.map((x) => x.getNullifier()),
    outputCommitment: outputs.map((x) => x.getCommitment()),
    publicAmount: BigNumber.from(extAmount).sub(fee).add(FIELD_SIZE).mod(FIELD_SIZE).toString(),
    extDataHash,

    // data for 2 transaction inputs
    inAmount: inputs.map((x) => x.amount),
    inPrivateKey: inputs.map((x) => x.keypair.privkey),
    inBlinding: inputs.map((x) => x.blinding),
    inPathIndices: inputMerklePathIndices,
    inPathElements: inputMerklePathElements,

    // data for 2 transaction outputs
    outAmount: outputs.map((x) => x.amount),
    outBlinding: outputs.map((x) => x.blinding),
    outPubkey: outputs.map((x) => x.keypair.pubkey),
  }

  const proof = await prove(input, `./artifacts/circuits/transaction${inputs.length}`)

  const args = {
    proof,
    root: toFixedHex(input.root),
    inputNullifiers: inputs.map((x) => toFixedHex(x.getNullifier())),
    outputCommitments: outputs.map((x) => toFixedHex(x.getCommitment())),
    publicAmount: toFixedHex(input.publicAmount),
    extDataHash: toFixedHex(extDataHash),
  }

  return {
    extData,
    args,
  }
}
Inputs and outputs are shuffled to prevent linkability analysis. Empty inputs are padded with dummy UTXOs.

Nullifiers

Each input UTXO is consumed by revealing its nullifier:
src/utxo.js
getNullifier() {
  if (!this._nullifier) {
    if (
      this.amount > 0 &&
      (this.index === undefined ||
        this.index === null ||
        this.keypair.privkey === undefined ||
        this.keypair.privkey === null)
    ) {
      throw new Error('Can not compute nullifier without utxo index or private key')
    }
    const signature = this.keypair.privkey ? this.keypair.sign(this.getCommitment(), this.index || 0) : 0
    this._nullifier = poseidonHash([this.getCommitment(), this.index || 0, signature])
  }
  return this._nullifier
}
Nullifiers can only be computed with the private key. Once a nullifier is revealed, the UTXO cannot be spent again.

Contract Validation

The contract performs critical validations:
contracts/TornadoPool.sol
function _transact(Proof memory _args, ExtData memory _extData) internal nonReentrant {
  require(isKnownRoot(_args.root), "Invalid merkle root");
  for (uint256 i = 0; i < _args.inputNullifiers.length; i++) {
    require(!isSpent(_args.inputNullifiers[i]), "Input is already spent");
  }
  require(uint256(_args.extDataHash) == uint256(keccak256(abi.encode(_extData))) % FIELD_SIZE, "Incorrect external data hash");
  require(_args.publicAmount == calculatePublicAmount(_extData.extAmount, _extData.fee), "Invalid public amount");
  require(verifyProof(_args), "Invalid transaction proof");

  for (uint256 i = 0; i < _args.inputNullifiers.length; i++) {
    nullifierHashes[_args.inputNullifiers[i]] = true;
  }
  
  // ... handle withdrawals and fees ...

  lastBalance = token.balanceOf(address(this));
  _insert(_args.outputCommitments[0], _args.outputCommitments[1]);
  emit NewCommitment(_args.outputCommitments[0], nextIndex - 2, _extData.encryptedOutput1);
  emit NewCommitment(_args.outputCommitments[1], nextIndex - 1, _extData.encryptedOutput2);
  for (uint256 i = 0; i < _args.inputNullifiers.length; i++) {
    emit NewNullifier(_args.inputNullifiers[i]);
  }
}

Encrypted Outputs

Output UTXOs are encrypted for the recipient:
src/utxo.js
encrypt() {
  const bytes = Buffer.concat([toBuffer(this.amount, 31), toBuffer(this.blinding, 31)])
  return this.keypair.encrypt(bytes)
}
The recipient can decrypt using their keypair:
src/utxo.js
static decrypt(keypair, data, index) {
  const buf = keypair.decrypt(data)
  return new Utxo({
    amount: BigNumber.from('0x' + buf.slice(0, 31).toString('hex')),
    blinding: BigNumber.from('0x' + buf.slice(31, 62).toString('hex')),
    keypair,
    index,
  })
}
Encrypted outputs are included in the NewCommitment events, allowing recipients to scan and decrypt their UTXOs.

Transaction Execution

To execute a shielded transfer:
src/index.js
async function transaction({ tornadoPool, ...rest }) {
  const { args, extData } = await prepareTransaction({
    tornadoPool,
    ...rest,
  })

  const receipt = await tornadoPool.transact(args, extData, {
    gasLimit: 2e6,
  })
  return await receipt.wait()
}

Input/Output Constraints

src/index.js
if (inputs.length > 16 || outputs.length > 2) {
  throw new Error('Incorrect inputs/outputs count')
}
while (inputs.length !== 2 && inputs.length < 16) {
  inputs.push(new Utxo())
}
while (outputs.length < 2) {
  outputs.push(new Utxo())
}
  • Maximum 16 inputs (use 2-input or 16-input circuit)
  • Exactly 2 outputs (pad with dummy UTXOs if needed)
  • Empty slots filled with zero-amount UTXOs

Public Amount Calculation

The public amount is computed for the ZK circuit:
contracts/TornadoPool.sol
function calculatePublicAmount(int256 _extAmount, uint256 _fee) public pure returns (uint256) {
  require(_fee < MAX_FEE, "Invalid fee");
  require(_extAmount > -MAX_EXT_AMOUNT && _extAmount < MAX_EXT_AMOUNT, "Invalid ext amount");
  int256 publicAmount = _extAmount - int256(_fee);
  return (publicAmount >= 0) ? uint256(publicAmount) : FIELD_SIZE - uint256(-publicAmount);
}
For transfers:
publicAmount = extAmount - fee = 0 - fee = -fee (mod FIELD_SIZE)

Build docs developers (and LLMs) love