Khám phá cách Tornado Cash bảo vệ quyền riêng tư trên blockchain bằng công nghệ zk-SNARK, Merkle Tree và Nullifier. Bài viết phân tích chi tiết cơ chế trộn tiền, quy trình nạp – rút ẩn danh, đồng thời so sánh Tornado Classic và Nova. Từ lý thuyết đến kỹ thuật, đây là hành trình giải mã một trong những giao thức bảo mật nổi bật của Ethereum.
Mục lục
Tổng quan về Tornado Cash
Dự án Tornado Cash
Được thành lập vào năm 2019, Tornado Cash là giao thức bảo mật lớn nhất được triển khai trên Ethereum. Về nguyên tắc của nó, Tornado Cash là một máy trộn trộn tiền xu. Giao thức này có thể tổng hợp và trộn một số lượng lớn các giao dịch với nhau, do đó ngăn chặn các giao dịch bị theo dõi trên chuỗi và đạt được 100% ẩn danh. Tornado Cash sẽ tạo ra cơ chế để cắt đứt liên kết on-chain giữa địa chỉ gửi và địa chỉ nhận.
Chúng ta có thể hiểu đơn giản Tornado sẽ tạo ra các THÙNG chứa, mỗi thùng này quy ước chỉ được gửi 1 số tiền nhất định. Nhiều người dùng bỏ tài sản của họ vào “THÙNG” này. Sau một thời gian, khi người dùng lấy tài sản ra khỏi “THÙNG”, họ không còn phân biệt được chủ sở hữu ban đầu của tài sản. Chúng tôi có thể biết số tiền mà ai đó đã gửi hoặc rút từ “THÙNG”, nhưng chúng tôi không thể khớp các giao dịch này với nhau. Đồng thời, càng có nhiều tiền và người tham gia vào “THÙNG”, và thời gian “NGÂM” trong đó càng lâu thì tình hình càng hỗn loạn và hiệu quả trộn tiền càng cao.

So sánh hai phiên bản: Tornado Classic và Tornado Nova
Tornado Cash có triển khai hai phiên bản:
- Tornado Classic (phiên bản truyền thống ra mắt năm 2019)
- Tornado Nova (phiên bản cải tiến ra mắt năm 2021) là hai giải pháp trộn tiền có cơ chế hoạt động và trải nghiệm người dùng hoàn toàn khác nhau.
Bảng dưới so sánh sự khác biệt:
| Tiêu chí | Tornado Classic | Tornado Nova |
| Mệnh giá nạp/rút | Cố định (0.1, 1, 10, 100 ETH). | Tùy chọn linh hoạt (Ví dụ: nạp/rút 0.4 ETH hay 1.23 ETH). |
| Cơ chế nạp tiền | Mỗi lần nạp là 1 giao dịch riêng độc lập tương ứng với 1 tờ phiếu “Note” bí mật. | Khởi tạo Số dư ẩn danh (Shielded Balance) cho tài khoản. |
| Chuyển tiền ẩn danh (Shielded Transfer) | Không hỗ trợ. Chỉ có thể Nạp -> Rút ra ví khác. | Có hỗ trợ. Cho phép chuyển tiền ẩn danh trực tiếp từ tài khoản Nova này sang tài khoản Nova khác mà không cần rút ra On-chain. |
| Mạng lưới triển khai | Ethereum Mainnet, BSC, Polygon, Arbitrum, Optimism, Avalanche… | Chạy chủ yếu trên Gnosis Chain (Sử dụng L2/Sidechain để giảm tối đa phí Gas). |
| Công nghệ zk-SNARKs | Dựa trên UTXO / Merkle Tree đơn giản (mỗi deposit = 1 leaf). | Dựa trên công nghệ UTXO nâng cấp (tương tự Zcash). |
Chi tiết khác biệt như sau:
- Nạp/Rút tùy chỉnh số tiền
- Classic: Nếu bạn muốn gửi 0.4 ETH, bạn phải chia làm 4 giao dịch nạp 0.1 ETH vào Pool 0.1 ETH. Điều này gây tốn phí gas và tạo ra 4 Note bí mật cần lưu trữ
- Nova: Bạn có thể nạp chính xác 0.4 ETH trong đúng 1 giao dịch.
- Số dư ẩn danh & Chuyển tiền nội bộ
- Classic: Tiền luôn chảy theo mô hình Ví A -> Pool -> Ví B.
- Nova: Hoạt động giống như một ngân hàng ẩn danh nội bộ. Khi nạp tiền vào, số dư của bạn nằm trong trạng thái được bảo vệ. Từ số dư này, bạn có thể:
- Chuyển một phần tiền cho người khác (Ví dụ: gửi 0.05 ETH cho Ví C ngay trong hệ thống Nova).
- Rút một lượng tiền bất kỳ về ví Ethereum/Gnosis công khai.
- Phí Gas & Tốc độ
- Nova được thiết kế chạy trên Layer-2 / Gnosis Chain nên phí giao dịch rẻ hơn rất nhiều so với việc tương tác trực tiếp với các Pool của Classic trên Ethereum Mainnet.
- Đánh giá về tính riêng tư:
- Tornado Classic: Nhờ có các pool cố định (ví dụ Pool 100 ETH hay Pool 1 ETH), lượng người nạp/rút chung một mệnh giá rất đông, tạo ra “tập hợp ẩn danh” rất lớn, khiến việc theo dõi dòng tiền gần như bất khả thi.
- Tornado Nova: Do cho phép rút số tiền lẻ tùy chỉnh, nếu bạn nạp 1.2345 ETH rồi lập tức rút đúng 1.2345 ETH ra một ví mới, công cụ phân tích On-chain có thể dễ dàng suy đoán liên kết giữa 2 ví dựa trên con số đặc trưng đó. Vì vậy, dùng Nova đòi hỏi người dùng phải chia nhỏ hoặc rút số tiền chẵn để giữ tính riêng tư.
Website và mã nguồn chính thức của Tornado Cash
Trang web chứng thức của dự án là tornado.cash, nhưng đã bị đưa vào lệnh trừng phạt và tắt từ năm 2022 , và ngay cả sau khi OFAC gỡ bỏ lệnh trừng phạt vào tháng 3/2025, tên miền đó cũng không được khôi phục lại. Thay vào đó, cộng đồng duy trì một giao diện theo hướng phi tập trung hoàn toàn: https://tornadocash.eth.limo => Link này là link chính thức từ trang Ethereum. Tài liệu liên quan tới Tornado Cash bạn có thể xem tại: Introduction to Tornado Cash hoặc Tornado Cash Docs on Github hoặc trên Gitbook.
Toàn bộ mã nguồn của Tornado được công khai trên github tại địa chỉ: https://github.com/tornadocash. Tất cả repo đều cập nhật cuối cùng vào năm 2022, vì thế hãy cẩn thận nếu có repo nào cập nhật thời gian từ năm 2023 trở đi. Nhiều khi không phải do người phát triển gốc của Tornado đẩy nên mà có thể tài khoản github bị h.a.c.k và kể tấn công đẩy code nguy hiểm lên.
Tổng hợp thông tin:
- Website: https://tornadocash.eth.limo
- Relayer: https://relayers-network.tornadocash.eth.limo => Nơi đăng ký làm Relayer
- Tài liệu: https://docs.tornadocash.eth.limo hoặc https://github.com/tornadocash/docs hoặc https://tornadocash-docs.gitbook.io/tornado.cash
- Mã nguồn: https://github.com/tornadocash trong đó quan trọng nhất là tornado-core.
Thông tin contract của Tornado Cash
Chi tiết thông tin contract xem tại Tornado Cash Smart Contracts. Tornado Cash hỗ trợ các mạng lưới sau:
- Phiên bản Classic:
- Ethereum Mainnet
- ETH: Hỗ trợ 4 pool là 0.1 ETH – 1 ETH – 10 ETH – 100 ETH
- DAI: 100 DAI – 1000 DAI – 10,000 DAI – 100,000 DAI
- cDAI: 5,000 cDAI – 50,000 cDAI – 500,000 cDAI – 5,000,000 cDAI
- USDC: 100 USDC – 1,000 USDC
- USDT: 100 USDT – 1,000 USDT
- WBTC: 0.1 WBTC – 1 WBTC – 10 WBTC
- Contract hỗ trợ: Đây là contract đứng vai trò là trung gian giữa người dùng và các pool.
- Tornado Router: 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b
- Lớp contract hiện hành đang được sử dụng bởi giao diện người dùng
- Khi nạp tiền hỗ trợ mã hóa Note bằng khóa công khai của người dùng và có event EncryptedNote => Nên người dùng có thể khôi phục lại Note nếu không may bị mất tệp lưu trữ cục bộ
- Quản lý danh sách các pool hỗ trợ chính thức
- Tornado Proxy: 0x722122df12d4e14e13ac3b6895a86e84145b6967 => Contract đời đầu tích hợp TornadoTrees để ghi nhận deposit/withdraw phục vụ tính thưởng mining => Giờ ít được sử dụng
- Tornado Router: 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b
- Arbitrum + Optimism: Chỉ hỗ trợ ETH gồm 0.1 ETH – 1 ETH – 10 ETH – 100 ETH
- BSC: Chỉ hỗ trợ BNB gồm 0.1 BNB – 1 BNB – 10 BNB – 100 BNB
- Gnosis Chain: Chỉ hỗ trợ xDAI gồm 100 xDAI – 1,000 xDAI – 10,000 xDAI – 100,000 xDAI (Trước là xDAI chain => 11/2021 cộng đồng xDAI Team và GnosisDAO đã bỏ phiếu thông qua thương vụ sáp nhập giữa xDAI Chain và hệ sinh thái Gnosis)
- POLYGON: 100 POL – 1,000 POL – 10,000 POL – 100,000 POL (Trước là Matic)
- AVAXC: 10 AVAX – 100 AVAX – 500 AVAX
- Ethereum Mainnet
- Tornado Cash Nova
- Gnosis Chain: 0xD692Fd2D0b2Fbd2e52CFa5B5b9424bC981C30696 (Cũ là xDAI chain)
Chi tiết kỹ thuật và cơ chế ẩn danh tron Tornado Cash
Tornado Cash là một trong những giao thức trộn tiền trên Ethereum. Mục tiêu cốt lõi của giao thức là cắt đứt liên kết dấu vết on-chain giữa địa chỉ nạp tiền (Depositor) và địa chỉ rút tiền (Withdrawer). Về mặt kỹ thuật, Tornado Cash hoạt động dựa trên sự kết hợp của 3 thành phần chính: Mã hóa Bằng chứng Không tiết lộ Thông tin (zk-SNARKs), Cây Merkle (Merkle Tree), và Nullifier (Mã triệt tiêu).
Cơ chế Ẩn danh Hoạt động Như thế nào?
Để hiểu cách Tornado Cash giấu vết, ta cần nhìn vào nguyên lý bể thanh khoản chung (Liquidity Pool) và cách chia nhỏ giao dịch:
- Số tiền cố định (Fixed Denomination):Tornado Cash chia các hợp đồng thông minh thành các pool chứa số tiền cố định (ví dụ: 0.1 ETH, 1 ETH, 10 ETH, 100 ETH). Nếu Alice nạp 1 ETH và Bob nạp 1 ETH, cả hai đều nạp đúng một lượng như nhau vào cùng một “chiếc hũ”. Điều này loại bỏ khả năng theo dõi giao dịch dựa vào số lẻ/giá trị tiền.
- Tập hợp ẩn danh (Anonymity Set):Khi hàng ngàn người cùng nạp 1 ETH vào bể, tất cả khoản nạp đều trông giống hệt nhau. Khi một địa chỉ mới rút 1 ETH ra, quan sát viên on-chain chỉ biết tiền được lấy ra từ bể đó, chứ không thể biết nó thuộc về khoản nạp nào trong số hàng ngàn khoản đã nạp trước đó.
Cơ chế quản lý Merkle Tree
Tornado Cash khởi tạo cây Merkle Tree ngay từ đầu với độ sâu 32 tầng, tức là có 2^32 ~ 4.29 tỷ nút Lá (Leaf), tương ứng với việc hỗ trợ khoảng 4.29 tỷ lệnh Deposit. Mỗi Leaf sẽ ứng với một con số từ 0 đến 2^32-1.
- Ngay từ khi deploy hợp đồng, tất cả 2^32 lá này đều chứa một giá trị rỗng cố định:
Zero_0 = Hash(“Tornado”) - Mỗi nút cha sẽ được tính bằng Hash của hai nút con, tính ngược lại cho đến Root. Như vậy ở thời điểm khởi tạo thì:
- Nút cha cấp 1: Zero_1 = Hash(Zero_0, Zero_0)
- Nút cha cấp 2: Zero_2 = Hash(Zero_1, Zero_0)
- Tương tự tính cho đến nút gốc Root.
- Theo cách quản lý như trên thì nếu biết vị trí một Leaf thì dễ dàng xác định được nút anh em của nó:
- Nếu chỉ số lá là số chẵn 2k → Nút anh em là lá bên phải 2k + 1
- Nếu chỉ số lá là số lẻ 2k + 1 → Nút anh em là lá bên trái 2k
Do cây độ sâu 32 quá lớn khó thể hiện trực quan được, nên chúng ta sẽ sử dụng một cây có độ sâu 2 như dưới để dễ minh họa:
[ Root_rỗng ]
/ \
[Zero_1] [Zero_1]
/ \ / \
[Zero_0] [Zero_0] [Zero_0] [Zero_0]
▲ ▲ ▲ ▲
Lá 0 Lá 1 Lá 2 Lá 3
Trạng thái 1: Alice nạp C_0 vào vị trí Lá 0
Bây giờ Alice thực hiện nạp C0 vào nút Lá 0 => C_0 sẽ thay thế cho Zero_0, các nút Lá còn lại giữ nguyên, các nút cha liên quan tới nút Lá 0 sẽ thay đổi:
Hash 0-1 = Hash(C_0, Zero_0)
ROOT_1 = Hash(Hash 0-1, Zero_1)
[ MERKLE ROOT_1 ] <── (Root mới được cập nhật)
/ \
[Hash 0-1] [Zero_1] <── (Nút này vẫn rỗng)
/ \
(C_0) [Zero_0] <── (Lá 1 vẫn rỗng!)
▲ ▲
Alice Nút anh em
(Lá số 0) của Alice
Nếu Alice thực hiện rút tiền thì mạch ZK tính toán:
Hash(Hash(C_0, Zero_0), Zero_1) ≟ ROOT_1
Phép tính này trả về ĐÚNG → Alice rút tiền thành công!
Trạng thái 2: Sau đó khi Bob nạp tiền vào Lá 1 (C1)
Nút Zero_0 tại Lá 1 sẽ được thay bằng C_1: Cây lại tiếp tục thay đổi.
Hash 0-1 = Hash(C_0, C_1)
ROOT_2 = Hash(Hash 0-1, Zero_1)
[ MERKLE ROOT_2 ] <── (Root tiếp tục đổi)
/ \
[Hash 0-1] [Zero_1]
/ \
(C_0) (C_1) <── (Bob nạp vào đây)
Nếu lúc này Alice thực hiện rút tiền thì mạch ZK tính toán mới sẽ là:
Hash(Hash(C_0, C1_0), Zero_1) ≟ ROOT_2
Như vậy chỉ cần biết chỉ số của nút Lá thì ta sẽ tính được mạch ZK ở thời điểm hiện tại.
Nếu bạn đang rút tiền mà có người thực hiện nạp tiền thì sao?
Giả sử bạn muốn thực hiện rút tiền, bạn sẽ tính toán thông tin của mạch ZK, sau đó tạo Proof để rút tiền. Nhưng trong quá trình này, lại có người thực hiện nạp tiền, khi đó MERKLE ROOT thay đổi và Mạch ZK của bạn cũng đã thay đổi. Sau đó bạn thực hiện rút tiền thì lệnh này sẽ lỗi à? Nếu giao dịch nhiều, luôn có nhiều giao dịch Nạp thì bạn sẽ không bao giờ Rút tiền được?
Đây chính là bài toán Xung đột giao dịch đồng thời (Race Condition / State Contention) – một vấn đề cực kỳ thực tế mà các kỹ sư thiết kế Tornado Cash đã dự tính và giải quyết triệt để từ đầu. Nếu Smart Contract chỉ cho phép kiểm tra bằng chứng dựa trên duy nhất 1 Root hiện tại, hệ thống sẽ rơi vào tình trạng “nghẽn mạng khóa”: khi có nhiều người nạp tiền liên tục, Root thay đổi liên tục khiến mọi lệnh rút tiền gửi lên đều bị Revert (lỗi).
Tornado Cash giải quyết bài toán này bằng cơ chế Danh sách Lịch sử Các Root, được cài đặt trong MerkleTreeWithHistory.sol. Mỗi khi có người NẠP TIỀN mới, Smart Contract tính toán ra Root mới, và Root mới này được thêm vào một danh sách, danh sách này chứa tối đa 30 Root, nếu quá 30 thì Root cũ nhất sẽ bị ghi đè.
Khi bạn RÚT TIỀN, ZK Proof của bạn được tạo từ thời điểm T0 dựa trên Root_A, nhưng khi giao dịch của bạn đến được miner/validator thì đã ở thời điểm T1, lúc này đã có 3 người khác nạp tiền trước bạn, làm cho Root hiện tại chuyển thành Root_D. Smart Contract không so sánh trực tiếp Root_A với Root_D mà nó sẽ kiểm tra xem Root_A có nằm trong danh sách các Root hợp lệ không thông qua hàm isKnowRoot(Root_A).
Trên Ethereum, mỗi block khoảng 12s, 30 Root sẽ tương đương khoảng 6 phút, đủ để bạn hoàn thành lệnh rút tiền.
Nếu dùng hết 2^32 thì phải tạo contract mới?
Nếu một hợp đồng của Tornado Cash lấp đầy toàn bộ 2^32 lá (tương đương khoảng 4,29 tỷ lượt nạp tiền), hợp đồng đó sẽ rơi vào trạng thái “Full” (đầy) và không thể tiếp nhận thêm bất kỳ khoản nạp nào mới nữa. Lúc này, giải pháp bắt buộc là phải triển khai một Smart Contract mới để bắt đầu một Cây Merkle rỗng mới.
Nhưng đây chỉ là trên Lý thuyết, trong thực tế thì rất khó để đạt tới con số này. Hiện tại, tổng số giao dịch nạp tiền trên tất cả các pool của Tornado Cash trong nhiều năm qua mới chỉ đạt tới quy mô hàng trăm nghìn cho đến vài triệu giao dịch — tức là chưa dùng hết 0.1% dung lượng của cây.
Cơ chế “Chứng minh Quyền Rút tiền”
Làm sao để Smart Contract xác nhận bạn đã từng nạp tiền và chưa từng rút tiền đó trước đây, mà bạn không cần công khai địa chỉ ban đầu hay khoản nạp nào là của bạn? Để hiểu sâu hơn, hãy đọc hai bài viết sau:
Đây là quy trình kỹ thuật 3 bước giải quyết bài toán trên:
Bước 1: Quy trình Nạp tiền (Deposit)
Khi người dùng (Alice) muốn nạp 1 ETH:
- Tạo bí mật off-chain: Trình duyệt/Client tạo ra 2 chuỗi ngẫu nhiên bí mật:
- s (Secret – Chuỗi bí mật)
- n (Nullifier – Số ngẫu nhiên)
- Tạo Cam kết (Commitment): Client tính toán một hash gọi là Commitment (C):
C = Hash(n, s)
Tornado Cash sử dụng hàm hash Pedersen hoặc MiMC để tối ưu chi phí gas cho zk-SNARKs. - Gửi tiền & Lưu Cây Merkle: Alice gửi 1 ETH kèm theo giá trị C vào Smart Contract. Smart Contract lấy C chèn vào làm một lá (leaf) mới trong một Cây Merkle (Merkle Tree) có độ sâu 32. Root của cây này đại diện cho tập hợp tất cả các khoản tiền đã nạp.
- Lưu giữ Note: Alice lưu lại chuỗi Note chứa (n, s) được sinh off-chain ở trên.
Lúc này, mọi người chỉ thấy Alice gửi 1 ETH đến contract và để lại 1 chuỗi hash C. Không ai biết (n, s) là gì.
Bước 2: Quy trình Tạo Bằng chứng Không tiết lộ (Generating Zero-Knowledge Proof)
Sau một thời gian (để bể ẩn danh đủ lớn), Alice muốn rút 1 ETH sang một địa chỉ ví hoàn toàn mới (Bob).
Thay vì gửi (n, s) cho Smart Contract (vì nếu gửi công khai thì mọi người sẽ tra ra C và biết Alice là ai), Alice tạo một bằng chứng zk-SNARK (Groth16) ngay trên máy tính của mình.
Alice chứng minh toán học cho Circuit (Mạch zk-SNARK) rằng:
“Tôi biết một cặp bí mật (n, s) sao cho C = Hash(n, s) nằm ở một vị trí nào đó trên cây Merkle hiện tại, và n tạo ra giá trị Nullifier Hash = Hash(n).”
Các tham số đầu vào cho mạch zk-SNARK bao gồm:
- Thông tin công khai:
- Merkle Root: Root hiện tại của cây
- Nullifier Hash: Mã băm của số ngẫu nghiên n
- Địa chỉ nhận tiền: Ví Bob
- Private Inputs / Witness: Phần này được giữ bí mật tuyệt đối
- Chuỗi s, chuỗi n
- Và đường dẫn Merkle (Merkle Path/Proof) chứng minh lá C thuộc về Cây
- Rõ ràng nếu biết C thì thông qua dữ liệu on-chain (event) sẽ biết được leafIndex => Dựng lại đường dẫn Merkle => Vậy tại sao nó lại là Private Input
- Thực tế trên dữ liệu onchain có hàng nghìn, hàng triệu giá trị C: C0, C1, C2,…, C1000,… => Nhưng chỉ bạn biết giá trị C nào là của chính bạn mà thôi.
Vậy cách tạo Proof được thực hiện thế nào?
Khi bạo kích nút tạo Proof, hệ thống sẽ tự động kiểm tra 3 điều kiện toán học:
- Điều kiện 1 (Tính hợp lệ của Note): Lấy n và s từ dữ liệu bí mật, tính Hash(n, s). Kết quả có đúng bằng Cam kết C không?
- Điều kiện 2 (Sự tồn tại trong Cây): Lấy C kết hợp với đường dẫn Merkle bí mật, tính ngược lên đỉnh cây xem có ra đúng Merkle Root công khai hay không? (Nếu C thực sự nằm trên cây, toán học đảm bảo tính lên sẽ khớp với Root).
- Điều kiện 3 (Khớp Nullifier): Lấy n tính Hash(n) xem có đúng bằng Nullifier Hash công khai hay không?
Nếu cả 3 điều kiện trên đều đúng, sẽ xuất ra một Bằng chứng toán học (Proof) – thực chất chỉ là một đoạn mã/chuỗi số ngắn. Bằng chứng này có 2 đặc tính kỳ diệu của Zero-Knowledge (Không tiết lộ thông tin):
- Tính Đúng đắn (Soundness): Nếu bạn không có (n, s) thật, hoặc khoản nạp C không có trên cây, bạn không bao giờ tạo ra được một Bằng chứng hợp lệ.
- Tính Ẩn danh (Zero-Knowledge): Nhìn vào Bằng chứng đó, Smart Contract hay bất kỳ ai cũng chỉ biết rằng: “Có ai đó sở hữu (n, s) hợp lệ trên cây”, chứ hoàn toàn không thể biết (n, s) là gì, hay C nằm ở vị trí thứ mấy trong hàng ngàn khoản nạp.
Để hiểu chi tiết phần Proof này thì bắt buộc bạn phải tìm hiểu về cơ chế toán học của zk-SNARKs.
Bước 3: Quy trình Rút tiền (Withdraw) & Chống Rút hai lần (Double-Spending)
Alice (hoặc một bên trung gian gọi là Relayer) gửi các dữ liệu sau đến Smart Contract:
- Bằng chứng zk-SNARK (π)
- Nullifier Hash = Hash(n)
- Địa chỉ nhận tiền (Ví của Bob)
Smart Contract sẽ thực hiện xác minh:
- Kiểm tra Bằng chứng (π): Chạy hàm VerifyProof. Nếu hợp lệ, toán học đảm bảo rằng người gửi bằng chứng thực sự sở hữu một Note có chứa khoản nạp nằm trong Cây Merkle, mà không cần công khai vị trí lá hay thông tin người nạp.
- Kiểm tra Nullifier (Chống rút tiền trùng lặp):
- Smart Contract tra cứu danh sách nullifiers[Nullifier Hash].
- Nếu Nullifier Hash đã tồn tại, giao dịch bị Revert ngay lập tức (vì khoản tiền này đã được rút trước đó).
- Nếu Nullifier Hash chưa tồn tại, Smart Contract đánh dấu nullifiers[Nullifier Hash] = true để vô hiệu hóa Note này cho các lần sau, sau đó chuyển 1 ETH đến ví Bob.
Vấn đề Phụ phụ thuộc: Gas Fee & Relayer
Nếu ví mới (Bob) chưa có ETH làm phí Gas để thực hiện lệnh rút, việc chuyển ETH từ ví cũ (Alice) sang ví Bob để trả phí sẽ làm lộ mối liên kết. Tornado Cash giải quyết vấn đề này bằng mạng lưới Relayer (Người chuyển tiếp):
- Alice tạo Bằng chứng zk-SNARK với Địa chỉ nhận = Ví Bob và chỉ định một khoản phí tip cho Relayer.
- Alice gửi Bằng chứng này tới Relayer thông qua mạng off-chain.
- Relayer dùng ví của họ để trả phí gas và gửi giao dịch rút tiền lên Smart Contract.
- Smart Contract xác minh bằng chứng, chuyển 1 ETH cho Bob và tự động trừ ra một khoản phí trả cho Relayer.
- Dấu vết giữa ví Alice và ví Bob hoàn toàn bị xóa bỏ.
Quy trình từng bước chi tiết thực hiện nạp / rút tiền trên Tornado Cash
Các bước như sau:
- B1: Khi deposit, giao diện Tornado Cash tự sinh cặp (nullifier, secret) ngẫu nhiên trong trình duyệt, rồi đóng gói thành một chuỗi gọi là note (dạng tornado-eth-1-1-0x…). Note này chính là bằng chứng sở hữu duy nhất — mất note là mất luôn quyền rút tiền, không ai khôi phục được vì không server nào lưu nó.
- B2: Người dùng nên đợi càng lâu càng tốt và không rút ngay sau khi gửi, vì thời điểm gửi/rút gần nhau là một manh mối (timing correlation) giúp người quan sát đoán được liên kết, dù không chứng minh được 100%. Đợi lâu để nhiều người khác cũng deposit vào cùng pool, tăng sự ẩn danh.
- B3: Ở một phiên trình duyệt khác (khuyến nghị dùng Tor hoặc VPN, ví khác với ví đã deposit), người dùng dán note đã lưu vào ô withdraw. Ứng dụng đọc note để lấy lại nullifier và secret.
- B4: Người dùng nhập một địa chỉ ví bất kỳ để nhận tiền — thường là một ví mới, hoàn toàn không liên quan tới ví đã dùng để deposit, để tránh lộ liên kết qua lịch sử giao dịch.
- B5: Toàn bộ phần kỹ thuật phức tạp (witness generation + tạo Groth16 proof) chạy ngầm trong trình duyệt bằng WebAssembly, mất vài giây đến vài chục giây. Người dùng không cần biết gì về circuit hay toán zk-SNARK — chỉ thấy thanh loading.
- B6: Nếu ví nhận đã có sẵn ETH để trả gas, người dùng gửi thẳng transaction. Nếu ví nhận trống (0 ETH, để tránh lộ dấu vết funding), người dùng chọn relayer — một bên thứ ba trả gas hộ, lấy phí bằng một phần nhỏ của khoản rút, và relayer không có quyền đổi địa chỉ nhận hay ăn cắp tiền vì proof đã ràng buộc cứng recipient.
- B7: Contract nhận proof cùng root, nullifierHash, recipient, kiểm tra proof hợp lệ và nullifierHash chưa từng dùng, rồi chuyển tiền thẳng tới địa chỉ nhận. Toàn bộ quá trình từ lúc bấm withdraw tới lúc nhận tiền chỉ mất một giao dịch on-chain.
Tìm hiểu về Tornado Cash dưới góc độ Tài chính
Dự án Tornado Cash có phát hành token riêng là TORN. Đến thời điểm hiện tại thì hầu như toàn bộ TORN đều đã được unlock. Giá hiện tại là $6.63 với FDV khoảng hơn $66M. Giá cao nhất khoảng $436.16 vào khoảng ngày 13/02/2021, giá thấp nhất khoảng $1.29-1.31, cuối 2023, ngay sau khi lệnh trừng phạt siết chặt nhất.
Đây là đồng rất rủi ro về mặt pháp lý nên rất ít sàn lớn hỗ trợ đồng này. Một số lùm xùm pháp lý của đồng này:
- 8/2022: OFAC (Bộ Tài chính Mỹ) đưa Tornado Cash vào SDN sanctions list, cáo buộc bị Lazarus Group (Triều Tiên) dùng để rửa hơn 455 triệu USD tiền mã hoá đánh cắp — sau này cáo buộc tổng cộng lên tới hơn 7 tỷ USD qua protocol.
- 11/2024: Tòa Phúc thẩm Liên bang khu vực 5 (Fifth Circuit) ra phán quyết mang tính bước ngoặt: smart contract bất biến của Tornado Cash không thể bị coi là “property” theo IEEPA vì thiếu các đặc trưng sở hữu, kiểm soát và độc quyền — một khi đã deploy thì không ai thay đổi/xoá/giới hạn được.
- 21/3/2025: OFAC chính thức gỡ bỏ lệnh trừng phạt với Tornado Cash — đảo ngược một trong những hành động enforcement crypto gây tranh cãi nhất.
- Vụ án hình sự Roman Storm (đồng sáng lập): Song song với vụ sanctions, chính phủ Mỹ truy tố hình sự cá nhân Roman Storm. Sau phiên tòa kéo dài 4 tuần tại SDNY, ngày 6/8/2025 bồi thẩm đoàn tuyên có tội với tội danh “âm mưu điều hành doanh nghiệp chuyển tiền không có giấy phép” (tối đa 5 năm tù), nhưng không đạt đồng thuận (deadlock) về 2 tội danh nặng hơn là rửa tiền và vi phạm lệnh trừng phạt. Phiên xét xử lại cho 2 tội danh còn treo đã bị hoãn đến 26/4/2027, motion xin tha bổng của Storm vẫn chưa có phán quyết cuối. Đồng sáng lập còn lại, Roman Semenov, vẫn đang bị FBI truy nã và chưa hầu tòa.
Với cá nhân tôi không khuyên khích đầu tư đồng tiền này nhưng phần kỹ thuật khá thú vị nên tôi mới tìm hiểu mà thôi.
Tìm hiểu triển khai Tornado Cash trên máy cá nhân
Tự chạy giao diện trên máy cá nhân để truy cập các chức năng của Tornado Cash
Hiện tại mã nguồn có chia sẻ 2 bản UI:
- Cho phiên bản Classic:
- ui-minified: Đây là bản UI đã được biên dịch sẵn thành các file tĩnh. Bạn không cần làm gì, chỉ cần đẩy lên một host bất kỳ là chạy, hoặc dùng một web server đơn giản là có thể chạy được. Ưu tiên dùng bản này nếu chỉ cần giao diện.
- tornado-classic-ui: Đây là mã nguồn gốc dạng React/Vue app chưa nén. Dùng bản này nếu bạn muốn đọc code, học cách giao diện kết nối với ZK Circuit, hoặc tự tùy chỉnh giao diện. Bạn cần cài để build và chạy local dev server.
- Cho phiên bản Nova:
- nova-ui-minified: Giao diện web đã được nén/đóng gói của Tornado Cash Nova — phiên bản nâng cấp thế hệ thứ 2 (v2) của giao diện Tornado Cash truyền thống.
Chạy bản ui-minified cho phiên bản classic:
// Tải về
git clone https://github.com/tornadocash/ui-minified.git
cd ui-minified
// Chạy bằng 1 trong các lệnh sau
python3 -m http.server 8080
npx http-server . -p 8080
Chạy bản nova-ui-minified cho phiên bản nova:
// Tải về
git clone https://github.com/tornadocash/nova-ui-minified.git
cd nova-ui-minified
// Chạy bằng 1 trong các lệnh sau
python3 -m http.server 8080
npx http-server . -p 8080
Mình thấy chạy okie, khi vào lần đầu nhớ kích nút Settings ở phía trên bên phải, rồi kích nút “Change RPC” để cập nhật RPC mới.
Triển khai phiên bản Tornado Cash trên Sepolia
TornadoCash trước có phiên bản chạy trên testnet Goerli, nhưng mạng lưới này đã dừng. Bây giờ muốn chuyển khai trên Sepolia thì quy trình khá phức tạp. Thêm nữa, bản mã nguồn đã từ rất lâu, giờ các thư viện đã cập nhật nhiều, nên xung đột thư viện dễ xảy ra.
B1: Chuẩn bị môi trường
Bạn cần có Sepolia ETH và Sepolia RPC. Đồng thời bạn cần clone các repo sau về máy:
- Smart Contracts & Circuits: Repo tornado-core (chứa hợp đồng Solidity và circuit circom).
- Frontend UI: Repo tornado-classic-ui
B2: Biên dịch ZK Circuits & Sinh Verification Keys
Tải repo tornado-core:
git clone https://github.com/tornadocash/tornado-core.git
cd tornado-core
npm install
Thực hiện biên dịch Circuit bằng lệnh sau:
# Lệnh build circuit, mất tầm 5 đến 10 phút
# Tạo ra các tệp:
# - build/circuits/withdraw.json
# - build/circuits/withdraw_proving_key.json
# - build/circuits/withdraw_verification_key.json
# - build/circuits/withdraw_proving_key.bin
# - build/circuits/Verifier.sol
# - build/circuits/withdraw_verification_key.json
npm run build:circuit
B3: Biên dịch contracts
Thực hiện biên dịch contracts bằng lệnh:
# Build contracts
npm run build:contract
Khi build contract sẽ báo lỗi:
> circuits@1.0.0 build:contract
> npx truffle compile
Compiling your contracts...
===========================
✖ Fetching solc version list from solc-bin. Attempt #1
✖ Fetching solc version list from solc-bin. Attempt #2
✖ Fetching solc version list from solc-bin. Attempt #3
Error: Could not find a compiler version matching 0.7.6. Please ensure you are specifying a valid version, constraint or build in the truffle config. Run `truffle compile --list` to see available versions.
Phải cài trực tiếp solc v0.7.6:
npm install --save-dev solc@0.7.6
Sau đó sửa tệp truffle-config.js như sau:
# Thêm dòng này vào đầu file
const path = require("path");
# Tìm dòng:
version: '0.7.6',
# Sửa dòng này thành code dưới
version: path.resolve(__dirname, "node_modules/solc/soljson.js"),
Sau đó chạy lại lệnh build contracts. Lúc này sẽ báo build thành công và bạn sẽ thấy các tệp .json trong thư mục build/contracts
B4: Cập nhật cấu hình trước khi triển khai
Mở tệp truffle-config.js, xóa hết các cấu hình cho kovan, goerli, rinkeby, mainnet, và thêm vào cấu hình cho sepolia, sửa một chút cấu hình cho development. Toàn bộ file cấu hình như sau:
require('dotenv').config()
const HDWalletProvider = require('@truffle/hdwallet-provider')
const utils = require('web3-utils')
const path = require("path");
module.exports = {
/**
* Networks define how you connect to your ethereum client and let you set the
* defaults web3 uses to send transactions. If you don't specify one truffle
* will spin up a development blockchain for you on port 9545 when you
* run `develop` or `test`. You can ask a truffle command to use a specific
* network from the command line, e.g
*
* $ truffle test --network <network-name>
*/
networks: {
development: {
host: '127.0.0.1', // Localhost (default: none)
port: 9545, // Standard Ethereum port (default: none)
network_id: '*', // Any network (default: none)
},
sepolia: {
provider: () =>
new HDWalletProvider(
process.env.PRIVATE_KEY,
'https://ethereum-sepolia-rpc.publicnode.com',
),
network_id: 11155111,
gas: 6000000,
gasPrice: utils.toWei('1', 'gwei'),
// confirmations: 0,
// timeoutBlocks: 200,
skipDryRun: true,
},
},
mocha: {
// timeout: 100000
},
// Configure your compilers
compilers: {
solc: {
//version: '0.7.6',
version: path.resolve(__dirname, "node_modules/solc/soljson.js"),
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
external: {
command: 'node ./scripts/compileHasher.js',
targets: [
{
path: './build/Hasher.json',
},
],
},
},
plugins: ['solidity-coverage'],
}
Sao chép tệp .env.example thành tệp .env, sau đó cập nhật lại cấu hình trong tệp. Quan trọng nhất là cấu hình PRIVATE_KEY.
Hãy tạo ví mới hoàn toàn rồi faucet một ít Sepolia ETH (SETH), khoảng trên 0.2 SETH là được. Sau đó xuất Private Key để cấu hình vào tệp .env
B5: Thực hiện chạy test
Đầu tiên ta chạy node local bằng lệnh sau:
npx truffle develop
Lệnh này sẽ tạo node local có rpc là http://127.0.0.1:9545, trên màn hình console cũng hiển thị thông tin 10 ví, hãy copy private key của ví đầu tiên thay tạm biến PRIVATE_KEY trong tệp .env ở trên.
Sau đó chạy test bằng lệnh sau:
npm run test
Kết quả chạy thử thấy báo:
14 passing (53s)
3 pending
9 failing
Chủ yếu ở lỗi:
revert Invalid withdraw proof -- Reason given: Invalid withdraw proof
Gặp lỗi này tôi quyêt định xóa thư mục build đi và chạy lại từ đầu:
npm run build
npm run test
Vẫn lỗi tương tự, chưa rõ nguyên nhân, khi nào tìm được sẽ cập nhật tiếp.
B5: Chạy thử ở chế độ phát triển
Ta sẽ chạy thử ở chế độ dev trước. Đầu tiên cho node local bằng lệnh sau:
npx truffle develop
Lệnh này sẽ tạo node local có rpc là http://127.0.0.1:9545, trên màn hình console cũng hiển thị thông tin 10 ví, hãy copy private key của ví đầu tiên thay tạm biến PRIVATE_KEY trong tệp .env ở trên.
Đánh lệnh sau để triển khai contract:
npm run migrate:dev
Contract được triển khai thành công, bạn sẽ thấy log như dưới:
> circuits@1.0.0 migrate:dev
> npx truffle migrate --network development --reset
Compiling your contracts...
===========================
> Artifacts written to /data/git-data/github/tornado-cash/testnet/tornado-core/build/contracts
> Compiled successfully using:
- external: undefined
Starting migrations...
======================
> Network name: 'development'
> Network id: 5777
> Block gas limit: 6721975 (0x6691b7)
2_deploy_hasher.js
==================
Deploying 'Hasher'
------------------
> transaction hash: 0x6abaedc4659679530a5a9a97b3eb6726059b907b7201381704706dc061de369c
> Blocks: 0 Seconds: 0
> contract address: 0xE62a6AcD6EDdCfA24b747EADA1Cbc72fe6eBF56E
> block number: 1
> block timestamp: 1790320717
> account: 0x718A97418D18756917255dFd2EdCc586FB90FE18
> balance: 99.95123572
> gas used: 2438214 (0x253446)
> gas price: 20 gwei
> value sent: 0 ETH
> total cost: 0.04876428 ETH
> Saving artifacts
-------------------------------------
> Total cost: 0.04876428 ETH
3_deploy_verifier.js
====================
Deploying 'Verifier'
--------------------
> transaction hash: 0xe622d455cb92ce72ffdd89c39e160d04b28734e15451291bc081766cf2349cb3
> Blocks: 0 Seconds: 0
> contract address: 0xf930644BD27D00aF9C65931E92c05CfC7d2ba442
> block number: 2
> block timestamp: 1790320717
> account: 0x718A97418D18756917255dFd2EdCc586FB90FE18
> balance: 99.93453084
> gas used: 835244 (0xcbeac)
> gas price: 20 gwei
> value sent: 0 ETH
> total cost: 0.01670488 ETH
> Saving artifacts
-------------------------------------
> Total cost: 0.01670488 ETH
4_deploy_eth_tornado.js
=======================
Deploying 'ETHTornado'
----------------------
> transaction hash: 0x3999b057d3740f291a5b6e38e73eae5a0d923f45f79ea41cf9ff31b86314ca14
> Blocks: 0 Seconds: 0
> contract address: 0x4A73ECc880e907ED38D5C04A00E108f092177549
> block number: 3
> block timestamp: 1790320717
> account: 0x718A97418D18756917255dFd2EdCc586FB90FE18
> balance: 99.89502808
> gas used: 1975138 (0x1e2362)
> gas price: 20 gwei
> value sent: 0 ETH
> total cost: 0.03950276 ETH
ETHTornado address 0x4A73ECc880e907ED38D5C04A00E108f092177549
> Saving artifacts
-------------------------------------
> Total cost: 0.03950276 ETH
5_deploy_erc20_tornado.js
=========================
Deploying 'ERC20Mock'
---------------------
> transaction hash: 0xf571859e151cada3ecc32615054b08b28c8a4db7c0ab9ad9e95695f7f426c5b3
> Blocks: 0 Seconds: 0
> contract address: 0x931Cf605B875048ff6DC4587f1Cfa93088397641
> block number: 4
> block timestamp: 1790320717
> account: 0x718A97418D18756917255dFd2EdCc586FB90FE18
> balance: 99.88027108
> gas used: 737850 (0xb423a)
> gas price: 20 gwei
> value sent: 0 ETH
> total cost: 0.014757 ETH
Deploying 'ERC20Tornado'
------------------------
> transaction hash: 0x051dbb73a3668180e90a1cd4eb1a8d017029fad5cfb53a59d1a495c2a704636f
> Blocks: 0 Seconds: 0
> contract address: 0x9067c699b0b5950eAAD52EAa62023d3381E9C69B
> block number: 5
> block timestamp: 1790320717
> account: 0x718A97418D18756917255dFd2EdCc586FB90FE18
> balance: 99.83703422
> gas used: 2161843 (0x20fcb3)
> gas price: 20 gwei
> value sent: 0 ETH
> total cost: 0.04323686 ETH
ERC20Tornado address 0x9067c699b0b5950eAAD52EAa62023d3381E9C69B
> Saving artifacts
-------------------------------------
> Total cost: 0.05799386 ETH
Summary
=======
> Total deployments: 5
> Final cost: 0.16296578 ETH
Bây giờ mở tệp src/minimal-demo.js, sửa lại một số cấu hình:
const MERKLE_TREE_HEIGHT = 20
const RPC_URL = 'http://127.0.0.1:9545'
const PRIVATE_KEY = ''
const CONTRACT_ADDRESS = '0x4A73ECc880e907ED38D5C04A00E108f092177549'
const AMOUNT = '0.1'
PRIVATE_KEY giống với PRIVATE_KEY trong tệp .env, CONTRACT_ADDRESS chính là địa chỉ “ETHTornado address” mà bạn triển khai ở trên.
Bây giờ bạn chạy lệnh sau xem thử:
node src/minimal-demo.js
Nguồn:
Mình là lập trình viên, mặc dù cũng đã khá lớn tuổi nhưng vẫn thích Lập trình. Gần đây mình tập trung tìm hiểu nhiều hơn về Lĩnh vực Blockchain. Với kiến thức tìm hiểu được, mình muốn viết ra để lưu lại cũng như để chia sẻ cho những người quan tâm. Mong mọi người góp ý và có thể cùng mình chia sẻ nhiều kiến thức hơn cho cộng đồng.







Trả lời