
Ứng dụng ZK tinh tế nhất: Phân tích sơ lược nguyên lý và logic nghiệp vụ của Tornado Cash
Tuyển chọn TechFlowTuyển chọn TechFlow

Ứng dụng ZK tinh tế nhất: Phân tích sơ lược nguyên lý và logic nghiệp vụ của Tornado Cash
TornadoCash có thể che giấu mối liên hệ giữa người rút tiền và người gửi tiền, trong trường hợp số lượng người dùng rất lớn, giống như một khu phố đông đúc, khi tội phạm lẫn vào đám đông thì cảnh sát sẽ khó lòng truy vết.
Tác giả: Faust, Geeker web3
Lời mở đầu: Gần đây, Vitalik và một số học giả đã đồng ký tên phát hành một bài báo khoa học mới, trong đó đề cập đến cách Tornado Cash thực hiện phương án chống rửa tiền (thực chất là cho phép người rút tiền chứng minh rằng giao dịch gửi tiền của họ thuộc về một tập hợp không chứa tiền đen). Tuy nhiên bài viết lại thiếu phần giải thích chi tiết về logic hoạt động và nguyên lý của Tornado Cash, khiến người đọc cảm thấy khó hiểu, nửa vời.
Ngoài ra, cần nhấn mạnh rằng các dự án bảo mật như đại diện bởi Tornado mới thật sự tận dụng tính chất "biết không kiến thức" (zero-knowledge) của thuật toán ZK-SNARK, còn đa số các Rollup mang danh ZK chỉ sử dụng tính ngắn gọn (succinctness) của ZK-SNARK. Người ta thường xuyên nhầm lẫn giữa Validity Proof và ZK, trong khi đó Tornado lại là một ví dụ điển hình để hiểu rõ ứng dụng của ZK.
Tác giả bài viết này từng viết một bài phân tích về nguyên lý Tornado vào năm 2022 trên Web3Caff Research. Hôm nay, tác giả trích xuất một phần đoạn văn và mở rộng, sắp xếp lại thành bài viết nhằm giúp mọi người hệ thống hóa việc hiểu rõ Tornado Cash.
Nguyên lý của “Cuộn lốc”
Tornado Cash là một giao thức trộn tiền sử dụng bằng chứng biết không kiến thức (zero-knowledge proof), phiên bản cũ được đưa vào sử dụng từ năm 2019, phiên bản mới khởi chạy bản beta vào cuối năm 2021. Phiên bản cũ của Tornado cơ bản đã đạt được tính phi tập trung, hợp đồng trên chuỗi mã nguồn mở, không có kiểm soát đa chữ ký, mã giao diện phía trước cũng mở mã và sao lưu trên mạng IPFS. Vì cấu trúc tổng thể của Tornado phiên bản cũ đơn giản và dễ hiểu hơn, nên bài viết này sẽ tập trung phân tích phiên bản cũ.
Ý tưởng chính của Tornado là: Trộn lẫn hàng loạt hành vi gửi và rút tiền với nhau — người gửi tiền sau khi nạp Token vào Tornado, sẽ trình bày bằng chứng ZK để chứng minh mình đã từng gửi tiền, rồi dùng một địa chỉ mới để rút tiền, từ đó cắt đứt mối liên kết giữa địa chỉ gửi và rút tiền.

Tóm lại cụ thể hơn, Tornado giống như một chiếc hộp kính, bên trong chứa đầy những đồng Coin do nhiều người bỏ vào. Chúng ta có thể nhìn thấy ai đã bỏ Coin vào, nhưng những đồng Coin này cực kỳ đồng nhất, nếu một người xa lạ lấy đi một đồng Coin từ hộp kính, chúng ta rất khó xác định được đồng Coin đó ban đầu là do ai bỏ vào.

Tình huống tương tự này dường như rất phổ biến: Khi chúng ta SWAP vài ETH từ bể Uniswap, chúng ta hoàn toàn không thể biết được ETH vừa nhận được là do ai cung cấp, vì đã có quá nhiều người cung cấp thanh khoản cho Uniswap. Nhưng điểm khác biệt ở đây là, mỗi lần dùng Uniswap để nhận token, chúng ta phải trả một lượng token tương đương làm phí; mặt khác, không thể chuyển tiền một cách "riêng tư" cho người khác. Trong khi đó, bộ trộn tiền chỉ yêu cầu người rút tiền cung cấp bằng chứng gửi tiền là đủ.
Để hành vi gửi và rút tiền trông giống nhau, địa chỉ gửi tiền vào pool Tornado và địa chỉ rút tiền ra khỏi pool luôn giữ số tiền gửi và rút cố định, ví dụ: 100 người gửi và 100 người rút trong một pool, dù công khai nhìn thấy, nhưng giữa họ dường như không hề có liên hệ nào, và số tiền mỗi người gửi vào hay rút ra đều bằng nhau. Lúc này, việc nhận dạng trở nên rối loạn, không thể suy luận theo số tiền gửi/rút để tìm ra mối liên hệ, từ đó xóa dấu vết chuyển tiền. Rõ ràng, điều này tạo điều kiện thuận lợi tự nhiên cho hành vi rửa tiền.

Tuy nhiên, có một vấn đề then chốt: Làm thế nào người rút tiền chứng minh họ đã từng gửi tiền? Địa chỉ rút tiền từ bộ trộn không liên kết với bất kỳ địa chỉ gửi tiền nào, vậy làm sao xác định được quyền rút tiền của họ? Cách trực tiếp nhất dường như là người rút tiết lộ rõ giao dịch gửi tiền nào là của mình, nhưng như vậy sẽ lộ ngay danh tính. Chính lúc này, bằng chứng biết không kiến thức (zero-knowledge proof) phát huy tác dụng.
Người rút tiền cung cấp một bằng chứng ZK, chứng minh rằng họ có ghi nhận gửi tiền trong hợp đồng Tornado, và khoản tiền gửi đó chưa bị rút, thì có thể thực hiện thao tác rút tiền. Bản thân bằng chứng ZK đã đảm bảo bảo mật thông tin, bên ngoài chỉ biết: Người rút quả thực đã từng gửi tiền vào pool, nhưng không biết anh ta là ai trong số những người gửi.

Việc chứng minh "Tôi đã từng gửi tiền vào Tornado" có thể được chuyển đổi thành "Ghi nhận gửi tiền của tôi có thể được tìm thấy trong hợp đồng Tornado". Nếu gọi Cn là bản ghi gửi tiền, vấn đề được khái quát như sau:
Cho tập hợp ghi nhận gửi tiền của Tornado là {C1, C2, ..., C100...}, người rút Bob chứng minh rằng bằng khóa bí mật của mình, anh ta đã tạo ra một ghi nhận Cn nào đó trong tập hợp, nhưng qua ZK thì không tiết lộ Cn cụ thể là gì.
Ở đây cần dùng đến đặc tính đặc biệt của Merkle Proof. Bởi vì tất cả các ghi nhận gửi tiền của Tornado đều được lưu trữ dưới dạng lá (leaf node) ở tầng thấp nhất của một cây Merkle được xây dựng trên chuỗi, tổng số lá khoảng 2^20 > 1 triệu, phần lớn trong số đó đang ở trạng thái trống (được gán giá trị khởi tạo). Mỗi khi có một giao dịch gửi tiền mới, hợp đồng sẽ ghi giá trị đặc trưng tương ứng – Commitment – vào một lá, sau đó cập nhật root của cây Merkle.

Ví dụ, thao tác gửi tiền của Bob là giao dịch thứ 10.000 trong lịch sử Tornado, vậy giá trị đặc trưng Cn liên quan đến giao dịch này sẽ được ghi vào lá thứ 10.000 của cây Merkle, tức là C10000 = Cn. Sau đó, hợp đồng sẽ tự động tính ra Root mới và cập nhật nó. (Ghi chú: Để tiết kiệm tài nguyên tính toán, hợp đồng Tornado sẽ lưu tạm dữ liệu của các nút đã thay đổi trước đó, ví dụ như Fs1, Fs2, Fs0 trong hình dưới đây)

Bản thân Merkle Proof rất ngắn gọn và nhẹ, nhờ vào tính chất hiệu quả trong việc tra cứu và truy xuất dữ liệu của cấu trúc cây. Nếu muốn chứng minh một giao dịch TD tồn tại trong cây Merkle, chỉ cần cung cấp Merkle Proof tương ứng với Root (phần bên phải trong hình dưới), điều này rất súc tích. Ngay cả khi cây Merkle rất lớn, với 2^20 lá (tức khoảng 1 triệu bản ghi gửi tiền), Merkle Proof cũng chỉ cần chứa 21 giá trị nút — cực kỳ ngắn.

Nếu muốn chứng minh giao dịch H3 thực sự nằm trong cây Merkle, ta cần chứng minh rằng bằng cách dùng H3 cùng với các dữ liệu phụ trợ từ cây Merkle, có thể sinh ra được Root. Tập dữ liệu phụ trợ này (bao gồm cả Td) chính là Merkle Proof.
Khi Bob rút tiền, anh ta cần chứng minh rằng thông tin xác thực mà mình sở hữu tương ứng với một bản ghi gửi tiền Cn nào đó có trong cây Merkle. Nói cách khác, anh ta cần chứng minh hai điều:
· Cn tồn tại trong cây Merkle được lưu trữ trong hợp đồng Tornado trên chuỗi, có thể xây dựng một Merkle Proof chứa Cn;
· Cn có liên hệ với thông tin xác thực mà Bob đang nắm giữ.

Phân tích chi tiết logic nghiệp vụ của Tornado
Mã nguồn giao diện người dùng (frontend) của Tornado đã được lập trình sẵn nhiều chức năng. Khi một người gửi mở trang web Tornado Cash và nhấn nút gửi tiền, chương trình tích hợp trong frontend sẽ tạo ra hai số ngẫu nhiên K và r trên máy cục bộ, sau đó tính toán giá trị Cn = Hash(K, r), rồi truyền Cn (chính là commitment trong hình dưới) vào hợp đồng Tornado, chèn vào cây Merkle mà hợp đồng này đang quản lý. Nói thẳng ra, K và r đóng vai trò như khóa riêng. Chúng rất quan trọng, hệ thống sẽ nhắc người dùng lưu giữ cẩn thận. Hai giá trị này vẫn sẽ được dùng đến khi rút tiền sau này.

(encryptedNote là tùy chọn, cho phép người dùng mã hóa thông tin xác thực K và r bằng khóa riêng, lưu lên chuỗi để tránh quên mất)
Điều đáng chú ý là tất cả các bước trên đều diễn ra ngoài chuỗi (off-chain), nghĩa là: Hợp đồng Tornado và các bên quan sát bên ngoài đều không biết K và r. Nếu K và r bị rò rỉ, tương tự như việc khóa ví bị đánh cắp.

Sau khi hợp đồng Tornado nhận được tiền gửi của người dùng và giá trị Cn = Hash(K, r) do người dùng gửi đến, nó sẽ chèn Cn vào tầng thấp nhất của cây Merkle làm một lá mới, đồng thời cập nhật giá trị Root. Như vậy, Cn và hành động gửi tiền của người dùng là liên kết một-một. Bên ngoài có thể biết mỗi Cn tương ứng với người dùng nào, biết ai đã gửi Token vào bộ trộn, và biết bản ghi gửi tiền Cn tương ứng với mỗi người gửi.
Trong bước rút tiền, người rút nhập thông tin xác thực/khóa riêng (hai số ngẫu nhiên K và r được tạo ra khi gửi tiền) vào trang web frontend, chương trình tích hợp trong frontend Tornado Cash sẽ sử dụng K, r, Cn = Hash(K, r), và Merkle Proof tương ứng với Cn làm tham số đầu vào để tạo ra bằng chứng ZK, chứng minh rằng Cn là một bản ghi gửi tiền tồn tại trên cây Merkle, và K, r là thông tin xác thực tương ứng với Cn.
Bước này tương đương với việc chứng minh: Tôi biết khóa bí mật tương ứng với một bản ghi gửi tiền nào đó trên cây Merkle. Khi bằng chứng ZK được gửi đến hợp đồng Tornado, cả bốn tham số trên đều được ẩn đi, bên ngoài (kể cả hợp đồng Tornado) không thể biết được, nhờ đó bảo vệ được quyền riêng tư.
Các tham số khác liên quan đến việc tạo ZKProof bao gồm: root của cây Merkle trong hợp đồng Tornado tại thời điểm rút tiền, địa chỉ nhận tiền A do người rút tự đặt, và định danh ngăn chặn tấn công phát lại nf (sẽ giải thích sau). Ba tham số này sẽ được công bố công khai lên chuỗi, bên ngoài có thể biết, nhưng không ảnh hưởng đến quyền riêng tư.

Có một chi tiết nhỏ: khi tạo Cn trong thao tác gửi tiền, người ta dùng hai số ngẫu nhiên K và r, chứ không phải một số ngẫu nhiên duy nhất. Lý do là vì một số ngẫu nhiên đơn lẻ không đủ an toàn, có khả năng xảy ra va chạm (collision). Ví dụ, nếu chỉ dùng một số ngẫu nhiên, có thể dẫn đến trường hợp hai người gửi khác nhau vô tình dùng cùng một số ngẫu nhiên, gây ra trùng lặp Cn.
Còn về A trong hình trên, đây là địa chỉ nhận tiền khi rút, do người rút tự điền. nf là một định danh ngăn chặn tấn công phát lại, giá trị nf = Hash(K), với K là một trong hai số ngẫu nhiên được dùng để tạo Cn khi gửi tiền (K và r). Như vậy, nf được liên kết với Cn, nói cách khác, mỗi Cn đều có một nf tương ứng, liên kết một-một.
Tại sao cần ngăn chặn tấn công phát lại? Do đặc điểm thiết kế của bộ trộn tiền, khi rút tiền, hệ thống không biết đồng tiền người rút lấy ra tương ứng với lá nào trong cây Merkle, cũng không biết người rút liên quan đến những người gửi nào, hay người này đã gửi bao nhiêu lần. Người rút có thể lợi dụng đặc điểm này để rút tiền nhiều lần, phát động tấn công phát lại, rút Token từ pool liên tục cho đến khi cạn sạch.

Ở đây, vai trò của định danh nf tương tự như bộ đếm giao dịch nonce của mỗi địa chỉ Ethereum, đều nhằm mục đích ngăn chặn việc một giao dịch bị phát lại. Khi một giao dịch rút tiền xảy ra, người rút cần nộp một nf, hệ thống kiểm tra xem nf này đã từng được sử dụng chưa (có ghi chép hay không): Nếu có, giao dịch rút tiền này vô hiệu. Nếu chưa, nghĩa là nf chưa được dùng, giao dịch hợp lệ, và nf tương ứng sẽ được ghi lại. Lần tới nếu có ai đó nộp lại nf này, hành động rút tiền sẽ bị coi là vô hiệu ngay lập tức.

Liệu có thể tùy tiện tạo ra một nf mà hợp đồng chưa từng ghi nhận được không? Dĩ nhiên là không được, bởi vì khi người rút tạo ZK Proof, họ phải đảm bảo nf = Hash(K), trong khi số ngẫu nhiên K lại liên quan đến bản ghi gửi tiền Cn, nghĩa là nf phải liên kết với một bản ghi gửi tiền Cn nào đó đã được ghi nhận. Nếu tự ý bịa ra một nf, thì nf này sẽ không khớp với bất kỳ bản ghi gửi tiền nào trong hệ thống, không thể tạo ra ZK Proof hợp lệ, các bước tiếp theo không thể hoàn thành, và giao dịch rút tiền sẽ thất bại.
Có người cũng có thể hỏi: Không dùng nf được không? Vì người rút khi rút tiền cần nộp bằng chứng ZK để chứng minh mối liên hệ với một Cn nào đó, vậy tại sao mỗi khi rút tiền, không tra cứu xem bằng chứng ZK tương ứng đã từng được nộp lên chuỗi chưa?
Nhưng thực tế, cách làm này tốn kém rất cao, bởi vì hợp đồng Tornado Cash sẽ không lưu trữ vĩnh viễn các bằng chứng ZK đã nộp trước đó — vì sẽ lãng phí nghiêm trọng dung lượng lưu trữ. Thay vì so sánh từng ZKProof mới nộp với tất cả các bằng chứng đã tồn tại, thì tốt hơn hết là thiết lập một định danh nf chiếm rất ít dung lượng và lưu trữ vĩnh viễn — cách này hiệu quả hơn nhiều.
Theo mẫu mã hàm rút tiền, các tham số và logic nghiệp vụ cần thiết như sau:
Người dùng nộp ZKProof, nf (NullifierHash) = Hash(K), tự đặt một địa chỉ nhận tiền recipient, ZKProof ẩn đi giá trị Cn, K, r, khiến bên ngoài không thể xác định danh tính người dùng. Địa chỉ recipient thường là một địa chỉ mới sạch sẽ, cũng không tiết lộ thông tin cá nhân.

Tuy nhiên, ở đây có một vấn đề nhỏ: khi người dùng rút tiền nhằm mục đích ẩn dấu vết, họ thường dùng một địa chỉ mới tạo để thực hiện giao dịch rút tiền, nhưng địa chỉ mới này lại không có ETH để trả phí gas. Do đó, khi địa chỉ rút tiền thực hiện giao dịch rút, cần khai báo rõ một relayer (trung gian) để trả phí gas hộ, sau đó hợp đồng trộn tiền sẽ trực tiếp khấu trừ một phần tiền rút để chuyển cho relayer như phần thưởng.

Tóm lại, TornadoCash có thể che giấu mối liên hệ giữa người rút và người gửi, trong điều kiện số lượng người dùng lớn, giống như một khu phố đông đúc, tội phạm hòa lẫn vào đám đông khiến cảnh sát khó truy đuổi. Quá trình rút tiền cần dùng đến ZK-SNARK, phần witness (nhân chứng) bị ẩn đi chứa đựng thông tin then chốt của người rút — đây là điểm mấu chốt nhất của toàn bộ bộ trộn tiền. Hiện tại, Tornado có lẽ là một trong những dự án ứng dụng thông minh nhất liên quan đến ZK.
Chào mừng tham gia cộng đồng chính thức TechFlow
Nhóm Telegram:https://t.me/TechFlowDaily
Tài khoản Twitter chính thức:https://x.com/TechFlowPost
Tài khoản Twitter tiếng Anh:https://x.com/BlockFlow_News














