Skip to main content

Overview

The TornadoPool contract is the heart of Tornado Nova, enabling privacy-preserving deposits, shielded transfers, and withdrawals. It implements a UTXO (Unspent Transaction Output) model to manage user funds privately.
The contract supports arbitrary deposit amounts, shielded transfers between registered users, and secure withdrawals - all while maintaining transaction privacy through zero-knowledge proofs.

Contract Architecture

contract TornadoPool is MerkleTreeWithHistory, IERC20Receiver, ReentrancyGuard, CrossChainGuard
The contract inherits from:
  • MerkleTreeWithHistory - Manages the commitment tree
  • IERC20Receiver - Handles token receipts from bridge
  • ReentrancyGuard - Prevents reentrancy attacks
  • CrossChainGuard - Enables cross-chain operations

Key Data Structures

ExtData

External data structure containing transaction metadata:
struct ExtData {
    address recipient;        // Withdrawal recipient address
    int256 extAmount;        // External amount (positive for deposits, negative for withdrawals)
    address relayer;         // Relayer address for fee payment
    uint256 fee;             // Relayer fee
    bytes encryptedOutput1;  // Encrypted output note 1
    bytes encryptedOutput2;  // Encrypted output note 2
    bool isL1Withdrawal;     // Whether withdrawal is to L1
    uint256 l1Fee;           // L1 withdrawal fee
}
recipient
address
The address that will receive withdrawn funds. Must not be zero address for withdrawals.
extAmount
int256
Positive values indicate deposits into the pool, negative values indicate withdrawals. Must be within the range -2^248 to 2^248.
fee
uint256
Fee paid to the relayer for submitting the transaction. Maximum value is 2^248.

Proof

Zero-knowledge proof data structure:
struct Proof {
    bytes proof;                    // The zkSNARK proof
    bytes32 root;                   // Merkle root
    bytes32[] inputNullifiers;      // Nullifiers for spent inputs (2 or 16)
    bytes32[2] outputCommitments;   // Commitments for new outputs
    uint256 publicAmount;           // Public amount derived from extAmount and fee
    bytes32 extDataHash;            // Hash of external data
}
inputNullifiers
bytes32[]
Array of nullifiers proving ownership of inputs. Length must be either 2 or 16, corresponding to the verifier used.
outputCommitments
bytes32[2]
Always contains exactly 2 output commitments that will be added to the Merkle tree.

Account

User account structure for registration:
struct Account {
    address owner;      // Account owner address
    bytes publicKey;    // Encryption public key
}

Core Functions

transact()

Main function for deposits, transfers, and withdrawals.
function transact(Proof memory _args, ExtData memory _extData) public
This function performs multiple critical validations:
  • Verifies the Merkle root is known
  • Checks that input nullifiers haven’t been spent
  • Validates the zero-knowledge proof
  • Ensures external data hash matches
Process Flow:
  1. Deposit (if extAmount > 0):
    token.transferFrom(msg.sender, address(this), uint256(_extData.extAmount));
    require(uint256(_extData.extAmount) <= maximumDepositAmount, "amount is larger than maximumDepositAmount");
    
  2. Proof Verification:
    • Validates Merkle root: require(isKnownRoot(_args.root), "Invalid merkle root")
    • Checks nullifiers: require(!isSpent(_args.inputNullifiers[i]), "Input is already spent")
    • Verifies zkSNARK proof: require(verifyProof(_args), "Invalid transaction proof")
  3. Nullifier Recording:
    for (uint256 i = 0; i < _args.inputNullifiers.length; i++) {
        nullifierHashes[_args.inputNullifiers[i]] = true;
    }
    
  4. Withdrawal (if extAmount < 0):
    if (_extData.isL1Withdrawal) {
        token.transferAndCall(omniBridge, uint256(-_extData.extAmount), ...);
    } else {
        token.transfer(_extData.recipient, uint256(-_extData.extAmount));
    }
    
  5. Commitment Insertion:
    _insert(_args.outputCommitments[0], _args.outputCommitments[1]);
    

register()

Registers a user’s encryption public key.
function register(Account memory _account) public
Registration allows other users to encrypt outputs for the registered address. Only the account owner can register their public key.
Usage:
Account memory account = Account({
    owner: msg.sender,
    publicKey: "0x..."
});
tornadoPool.register(account);

registerAndTransact()

Convenience function combining registration and transaction:
function registerAndTransact(
    Account memory _account,
    Proof memory _proofArgs,
    ExtData memory _extData
) public
Useful for first-time users to register and make their initial transaction in one call.

verifyProof()

Verifies zero-knowledge proofs using the appropriate verifier.
function verifyProof(Proof memory _args) public view returns (bool)
Implementation:
if (_args.inputNullifiers.length == 2) {
    return verifier2.verifyProof(
        _args.proof,
        [
            uint256(_args.root),
            _args.publicAmount,
            uint256(_args.extDataHash),
            uint256(_args.inputNullifiers[0]),
            uint256(_args.inputNullifiers[1]),
            uint256(_args.outputCommitments[0]),
            uint256(_args.outputCommitments[1])
        ]
    );
} else if (_args.inputNullifiers.length == 16) {
    return verifier16.verifyProof(_args.proof, [...]);
}
The function automatically selects verifier2 or verifier16 based on the number of input nullifiers.

isSpent()

Checks whether a nullifier has been used.
function isSpent(bytes32 _nullifierHash) public view returns (bool)
Once a nullifier is spent, it cannot be used again. This prevents double-spending.
Usage:
bool spent = tornadoPool.isSpent(nullifierHash);
if (spent) {
    revert("Note already spent");
}

calculatePublicAmount()

Calculates the public amount from external amount and fee.
function calculatePublicAmount(int256 _extAmount, uint256 _fee) public pure returns (uint256)
Formula:
int256 publicAmount = _extAmount - int256(_fee);
return (publicAmount >= 0) ? uint256(publicAmount) : FIELD_SIZE - uint256(-publicAmount);

Events

NewCommitment

Emitted when new commitments are added to the tree.
event NewCommitment(bytes32 commitment, uint256 index, bytes encryptedOutput);
commitment
bytes32
The commitment hash added to the Merkle tree
index
uint256
Position in the Merkle tree
encryptedOutput
bytes
Encrypted note data for the recipient

NewNullifier

Emitted when inputs are spent.
event NewNullifier(bytes32 nullifier);
This event is emitted for each input nullifier in the transaction.

PublicKey

Emitted when a user registers their encryption key.
event PublicKey(address indexed owner, bytes key);

Constants

int256 public constant MAX_EXT_AMOUNT = 2**248;
uint256 public constant MAX_FEE = 2**248;
uint256 public constant MIN_EXT_AMOUNT_LIMIT = 0.5 ether;
MAX_EXT_AMOUNT and MAX_FEE are set to 2^248 to ensure they fit within the BN254 curve’s scalar field while maintaining a safety margin.

Cross-Chain Operations

onTokenBridged()

Called by the OmniBridge when tokens are bridged from L1.
function onTokenBridged(
    IERC6777 _token,
    uint256 _amount,
    bytes calldata _data
) external override
Security checks:
require(_token == token, "provided token is not supported");
require(msg.sender == omniBridge, "only omni bridge");
require(_amount >= uint256(_extData.extAmount), "amount from bridge is incorrect");
require(token.balanceOf(address(this)) >= uint256(_extData.extAmount) + lastBalance, "bridge did not send enough tokens");
If the transaction fails, bridged tokens are sent to the multisig for manual handling.

Admin Functions

configureLimits()

Updates the maximum deposit amount (multisig only).
function configureLimits(uint256 _maximumDepositAmount) public onlyMultisig

rescueTokens()

Rescues accidentally sent tokens (multisig only).
function rescueTokens(
    IERC6777 _token,
    address payable _to,
    uint256 _balance
) external onlyMultisig
Cannot rescue the pool’s main token asset to prevent fund theft.

Security Features

  1. Reentrancy Protection: Uses OpenZeppelin’s ReentrancyGuard
  2. Nullifier Tracking: Prevents double-spending via nullifierHashes mapping
  3. Root Validation: Only accepts proofs for known Merkle roots
  4. Amount Limits: Enforces maximumDepositAmount for deposits
  5. External Data Hash: Validates integrity of transaction metadata

Example Usage

Making a Deposit

// Approve tokens
token.approve(address(tornadoPool), depositAmount);

// Prepare proof and external data
Proof memory proof = Proof({
    proof: zkProof,
    root: currentRoot,
    inputNullifiers: new bytes32[](2),
    outputCommitments: [commitment1, commitment2],
    publicAmount: calculatedPublicAmount,
    extDataHash: keccak256(abi.encode(extData))
});

ExtData memory extData = ExtData({
    recipient: address(0),
    extAmount: int256(depositAmount),
    relayer: address(0),
    fee: 0,
    encryptedOutput1: encrypted1,
    encryptedOutput2: encrypted2,
    isL1Withdrawal: false,
    l1Fee: 0
});

// Execute transaction
tornadoPool.transact(proof, extData);

Making a Withdrawal

ExtData memory extData = ExtData({
    recipient: recipientAddress,
    extAmount: -int256(withdrawAmount),
    relayer: relayerAddress,
    fee: relayerFee,
    encryptedOutput1: encrypted1,
    encryptedOutput2: encrypted2,
    isL1Withdrawal: false,
    l1Fee: 0
});

tornadoPool.transact(proof, extData);

Build docs developers (and LLMs) love