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
Select input UTXOs
Choose one or more of your existing UTXOs to spend (up to 16 inputs).
Create output UTXOs
Generate output UTXOs for the recipient and change (exactly 2 outputs).
Build Merkle proof
Construct Merkle proofs for each input UTXO to prove ownership.
Generate ZK proof
Create a zero-knowledge proof validating the transaction.
Submit transaction
Call transact to execute the shielded transfer.
Zero ExtAmount
For internal transfers, extAmount equals zero:
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:
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:
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:
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:
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:
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:
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()
}
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)