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
}
The address that will receive withdrawn funds. Must not be zero address for withdrawals.
Positive values indicate deposits into the pool, negative values indicate withdrawals. Must be within the range -2^248 to 2^248.
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
}
Array of nullifiers proving ownership of inputs. Length must be either 2 or 16, corresponding to the verifier used.
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:
-
Deposit (if
extAmount > 0):
token.transferFrom(msg.sender, address(this), uint256(_extData.extAmount));
require(uint256(_extData.extAmount) <= maximumDepositAmount, "amount is larger than maximumDepositAmount");
-
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")
-
Nullifier Recording:
for (uint256 i = 0; i < _args.inputNullifiers.length; i++) {
nullifierHashes[_args.inputNullifiers[i]] = true;
}
-
Withdrawal (if
extAmount < 0):
if (_extData.isL1Withdrawal) {
token.transferAndCall(omniBridge, uint256(-_extData.extAmount), ...);
} else {
token.transfer(_extData.recipient, uint256(-_extData.extAmount));
}
-
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);
The commitment hash added to the Merkle tree
Position in the Merkle tree
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
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
- Reentrancy Protection: Uses OpenZeppelin’s
ReentrancyGuard
- Nullifier Tracking: Prevents double-spending via
nullifierHashes mapping
- Root Validation: Only accepts proofs for known Merkle roots
- Amount Limits: Enforces
maximumDepositAmount for deposits
- 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);