Lan là một lập trình viên backend tại Thành phố Hồ Chí Minh, vừa tham gia workshop ZK tại một hacker house ở Quận 1. Giảng viên vẽ hai hình vuông lên bảng trắng: một bên ghi "prover," một bên ghi "verifier." Giữa chúng là một dòng đơn: y = x * x. "Hãy chứng minh với verifier rằng bạn biết một giá trị x có bình phương bằng y — mà không tiết lộ x là gì," anh nói. "Tối nay, bạn sẽ viết circuit đó bằng Circom." Lan viết template đầu tiên của mình, biên dịch nó, tạo ra một proof, và xác minh nó — toàn bộ chỉ trong khoảng ba mươi phút trên laptop. Circom tutorial này đưa bạn qua đúng hành trình đó: circuit là gì, cách viết zk circuit đầu tiên (write your first ZK circuit), vì sao compiler phàn nàn, và file r1cs zk sinh ra thực sự nghĩa là gì. Đến cuối bài, bạn sẽ có một proof chạy thật trong terminal cho x² = y — zero-knowledge proof thực sự nhỏ nhất có thể.
Sự thật đáng nói là zero-knowledge proof không phải phép màu. Chúng là số học: một phép tính được biến thành các phương trình, nơi prover chứng minh họ biết lời giải bí mật của các phương trình đó mà không tiết lộ lời giải. Circom là ngôn ngữ làm cho điều này trở nên khả thi với lập trình viên. Nó là ngôn ngữ circuit tiêu chuẩn của hệ sinh thái ZK — được Tornado Cash dùng cho giao dịch riêng tư và hàng trăm dự án production — và nó biên dịch circuit của bạn thành R1CS (Rank-1 Constraint System) cùng với một witness generator mà bạn có thể chạy cục bộ.
Circuit ZK thực chất là gì
Circuit theo nghĩa ZK không liên quan gì đến điện tử. Nó là một chương trình chỉ làm số học — cộng và nhân — trên các signal, là những con số trong một trường hữu hạn lớn (trên curve bn128 mặc định, các số khoảng 254 bit). Circuit nhận signal đầu vào, tính các signal trung gian, và sinh ra signal đầu ra. Điều làm nó đặc biệt là nó cũng sinh ra constraint: những phương trình mà prover phải thỏa mãn.
Cách kinh điển để giải thích constraint là một câu chuyện. Bạn muốn chứng minh với bạn mình rằng bạn biết cách phân tích nhân tử của số n, mà không tiết lộ các thừa số. Đó chính xác là phát biểu mà ví dụ kinh điển đầu tiên của Circom mã hóa: một template tên Multiplier2 khai báo hai input riêng tư và một output, rồi viết một constraint duy nhất, c <== a b. Phiên bản của chúng ta còn nhỏ hơn — một input x, một output y, và constraint y <== x x.
Viết zk circuit đầu tiên: template đầu tiên của bạn
Mở terminal, tạo một thư mục, và lưu file này thành circuit.circom:
pragma circom 2.0.0;
template XSquare() { signal input x; // private theo mặc định signal output y; // output luôn public
y <== x * x; // gán giá trị VÀ thêm constraint }
component main = XSquare();
Có ba điều đang diễn ra ở đây.
Thứ nhất, pragma circom 2.0.0 chốt phiên bản compiler, giống trường version trong package.json. Compiler sẽ cảnh báo nếu phiên bản đã cài không khớp, ngăn chặn hành vi khác nhau thầm lặng trên máy người khác.
Thứ hai, template là bản thiết kế, không phải circuit. Giống class trong Java hay function trong Solidity, template mô tả một hình dạng; bạn tạo instance bằng component main = XSquare();. Mọi file circuit phải khai báo một main component — đó là thứ compiler biên dịch.
Thứ ba, các signal. signal input x là một con số chảy vào circuit. Input private theo mặc định — đó chính là nơi tính ẩn giấu của zero-knowledge sống. signal output y là kết quả, và output của main luôn public, vì verifier cần chúng để biết phát biểu nào đang được chứng minh. Ở giữa, một signal không có input cũng không có output được gọi là intermediate signal — một giá trị hỗ trợ được tính dọc đường. Trong circuit một dòng của chúng ta không có intermediate nào, nhưng khoảnh khắc bạn viết y <== x x x bạn sẽ gặp một (chi tiết bên dưới).
Trái tim của file là toán tử <==. Nó làm hai việc cùng lúc: nó gán cho signal y giá trị x x (để witness generator tính được), và nó thêm một constraint y === x x vào R1CS. Circom cung cấp toán tử thứ hai, ===, chỉ thêm constraint mà không gán — hữu ích khi giá trị đã được đặt — và một cặp toán tử "mũi tên đơn", <-- và -->, gán mà không thêm constraint. Những mũi tên đơn đó là công cụ nguy hiểm nhất trong ngôn ngữ, và chúng ta sẽ quay lại ở phần lỗi thường gặp. Giờ chỉ cần nhớ: <== là mặc định an toàn vì giá trị được gán là nghiệm duy nhất của constraint.
Hình 1: Giải phẫu một circuit Circom — ba signal và một constraint là toàn bộ R1CS cho x² = y
Quy tắc chỉ-quadratic: vì sao x x x thất bại
Biên dịch những gì bạn vừa viết và bạn sẽ thấy một constraint. Giờ đổi thân hàm thành y <== x x x và biên dịch lại — Circom từ chối: "Non quadratic constraints are not allowed!" Lỗi này là cú sốc ngày đầu tiên phổ biến nhất cho lập trình viên circom mới, và đáng để hiểu thay vì vật lộn.
Mỗi constraint trong R1CS phải có dạng linear × linear = linear, trong đó "linear" nghĩa là một tổng các signal với hệ số hằng. x x là quadratic — được phép. x x x là cubic — không được, vì ba thừa số không thể tách thành đúng hai biểu thức linear. Phép chia và lũy thừa cũng bị cấm vì cùng lý do: y <== x / 2 và y <== x * 3 không quadratic.
Giải pháp là chia phép tính thành các gate, giống hệt cách kỹ sư phần cứng chia mạch thành các chip. Để tính x³, bạn tạo một intermediate signal:
signal sq;
sq <== x * x; // constraint 1
y <== sq * x; // constraint 2
Giờ bạn có hai constraint quadratic, và circuit biên dịch thành công. Chú ý điều này hé lộ: số constraint chính là "kích thước" thực của circuit bạn. Mỗi gate nhân thêm một; phép cộng gộp miễn phí; và cả thời gian prove lẫn kích thước proving key tăng tuyến tính theo số constraint. Đó là chỉ số tinh thần quan trọng. Một lập trình viên nội hóa "một phép nhân mỗi constraint" sẽ ngừng đánh nhau với compiler và bắt đầu thiết kế circuit theo cách máy mong đợi — cũng là cách bạn viết ZK circuit nhanh và rẻ.
Cài đặt công cụ: circom và snarkjs
Circom biên dịch circuit; snarkjs biến đầu ra đã biên dịch thành proof. Cài cả hai bằng Node.js:
npm install -g circom
npm install -g snarkjs
Biên dịch circuit của bạn:
circom circuit.circom --r1cs --wasm --sym
Ba file được sinh ra. circuit.r1cs là R1CS dạng binary — bản thân constraint system, sẽ được đưa vào trusted setup. circuit.wasm là một witness generator bằng WebAssembly: một chương trình nhỏ chạy circuit của bạn trên một input nhất định và ghi lại mọi signal trung gian. circuit.sym là bản đồ ký hiệu dùng để debug, giống debug symbols -g.
Trước khi đi xa hơn, hãy hỏi compiler nó đã tạo ra gì:
snarkjs r1cs info circuit.r1cs
Đầu ra cho bạn biết curve (bn-128), số wires, số constraint, và có bao nhiêu input là riêng tư. Với XSquare bạn nhận được một constraint duy nhất. Toàn bộ phát biểu zero-knowledge của bạn là một phương trình. Mọi thứ ở hạ nguồn — witness, proving key, proof — đều tỷ lệ theo con số đó.
Tính witness: biên lai của phép tính
Proof không được xây từ hư không. Trước tiên bạn phải chạy circuit trên một input cụ thể và thu thập witness — phép gán mọi signal thỏa mãn các constraint. File wasm làm việc này. Viết một file input và chạy generator:
echo '{"x": 9}' > input.json
cd circuit_js
node generate_witness.js circuit.wasm ../input.json ../witness.wtns
witness.wtns là bản ghi binary: x = 9, y = 81, và hằng số 1 mà R1CS luôn mang theo. Bạn có thể kiểm tra nó:
cd ..
snarkjs wtns export json witness.wtns witness.json
Witness là biên lai của prover — nó chứng minh phép tính thực sự đã được làm. Nó cũng là bí mật: bất kỳ ai giữ witness đều biết x. Toàn bộ ý nghĩa của bước proof là thuyết phục verifier về sự tồn tại của witness mà không gửi witness đi.
Trusted setup và pipeline Groth16
Trước khi sinh ra proof nào, circuit cần keys, và Groth16 — hệ thống proving được triển khai rộng nhất, hệ thống mà Tornado Cash dùng — yêu cầu một trusted setup: một buổi lễ nơi một giá trị bí mật "chất thải độc" được trộn vào các tham số. Buổi lễ gồm hai phase. Phase 1, powers of tau, là phổ quát (chia sẻ cho mọi circuit trên curve). Phase 2 là riêng cho từng circuit.
snarkjs powersoftau new bn128 12 pot12_0000.ptau
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First" -v
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau
snarkjs groth16 setup circuit.r1cs pot12_final.ptau circuit_0000.zkey
snarkjs zkey contribute circuit_0000.zkey circuit_final.zkey --name="Second" -v
snarkjs zkey export verificationkey circuit_final.zkey verification_key.json
Với một tutorial thì chạy một mình là ổn, nhưng trong production buổi lễ là một multi-party computation: hàng chục người đóng góp độc lập mỗi người thêm một phần ngẫu nhiên, và tham số chỉ an toàn nếu ít nhất một người đóng góp hủy bí mật của họ. Bước contribute chính là thứ tạo ra khác biệt giữa "chạy được" và "an toàn."
Giờ hãy sinh và xác minh một proof:
snarkjs groth16 prove circuit_final.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
Hai file đi ra từ prove. proof.json là zk-SNARK proof thực — với Groth16, đúng ba điểm trên elliptic curve, khoảng 192 byte dữ liệu. public.json giữ các signal public, với circuit của chúng ta chỉ là y = 81. Verifier đọc proof và các input public, kiểm tra ba phương trình pairing, và trả về OK trong vài mili giây — chi phí xác minh không phụ thuộc vào kích thước circuit chút nào. Ngược lại, prover trả cho mỗi constraint.
Để biến điều này thành hiện thực trên blockchain, xuất một Solidity verifier, triển khai nó, và gọi hàm verifyProof của nó — cùng mẫu mà hợp đồng rút tiền của Tornado Cash dùng:
snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol
Hình 2: Từ circuit đến proof đã kiểm chứng — circom biên dịch, snarkjs chứng minh, contract xác minh
R1CS zk: thứ compiler thực sự sinh ra
Đây là khoảnh khắc "zero knowledge" trở nên cụ thể. Circom đã biến y <== x * x thành một Rank-1 Constraint System, và R1CS chỉ là ba ma trận A, B, C cùng một vector witness s sao cho, với mỗi dòng constraint,
⟨aᵢ, s⟩ · ⟨bᵢ, s⟩ = ⟨cᵢ, s⟩
Nghĩa là: lấy tích vô hướng của dòng aᵢ với witness, nhân với tích vô hướng của dòng bᵢ với witness, và kết quả phải bằng tích vô hướng của dòng cᵢ với witness.
Với x² = y, witness là rất nhỏ — ba phần tử: s = [1, x, y]. Hằng số 1 luôn hiện diện theo quy ước, để các phương trình có thể biểu đạt "bằng đúng giá trị này" thay vì chỉ "bằng không." Constraint duy nhất của chúng ta được mã hóa bằng ba dòng:
A = [0, 1, 0] → ⟨A, s⟩ = x
B = [0, 1, 0] → ⟨B, s⟩ = x
C = [0, 0, 1] → ⟨C, s⟩ = y
Nhân hai kết quả đầu: x · x = y. Đó là toàn bộ constraint system, và witness [1, 9, 81] thỏa mãn nó. Một prover gian dối thử s = [1, 9, 82] sẽ sinh ra 9 · 9 = 82, là sai — proof sẽ bị từ chối.
Đây là lý do bạn nên đọc đầu ra --r1cs như bản hợp đồng chữ viết của circuit. Mỗi === hay <== bạn viết trở thành một dòng trong các ma trận này. Kích thước của một hệ ZK — thời gian prove, kích thước key, chi phí gas — được đo bằng số dòng. Khi một dự án nói "circuit của chúng tôi có 5,000 constraint," nghĩa là các ma trận A, B và C có 5,000 dòng mà mọi prover phải xử lý.
Hình 3: Một constraint dạng ma trận — mỗi === trong circom trở thành một dòng của R1CS
" (value tone). Row 2 (3 cols): "A row = [0, 1, 0] → ⟨A,s⟩ = x" (neutral) → "B row = [0, 1, 0] → ⟨B,s⟩ = x" (neutral) → "C row = [0, 0, 1] → ⟨C,s⟩ = y" (neutral). Row 3: full-width equation band "⟨A,s⟩ · ⟨B,s⟩ = x · x = y = ⟨C,s⟩ ✓" (highlight tone). Bottom callout (neutral tone): "every === in circom becomes one row of these matrices — proving time scales with the row count". Title: "R1CS zk: One Constraint as Matrices", subtitle "x² = y — the smallest possible real zero-knowledge proof"]
Điều gì làm nó zero-knowledge
Giờ lùi lại một bước và nhìn toàn cảnh. Prover giữ witness [1, 9, 81] — và quan trọng là, input riêng tư x = 9. Verifier chỉ giữ y = 81 và proof. Trong lúc proving, zk-SNARK "che mắt" các phần riêng tư của witness bằng cách trộn các phần tử ngẫu nhiên trong trường, nên ba điểm trên elliptic curve trong proof.json không mang thông tin nào về x. Bạn có thể đổi x từ 9 sang 5 và thấy public.json cập nhật trong khi proof.json không lộ điều gì để người quan sát tái dựng x. Đó là định nghĩa của tính chất: verifier biết rằng prover nắm một x nào đó với x² = y, và không gì khác.
Hai tính chất an toàn đi kèm theo. Soundness nghĩa là prover không thể làm giả một proof cho phát biểu sai (họ sẽ phải phá discrete log trên elliptic curve — bất khả thi về tính toán). Completeness nghĩa là proof của prover trung thực luôn xác minh được. Cùng với zero-knowledge, ba tính chất này làm nên một zk-SNARK đủ tin cậy để một hợp đồng nắm hàng triệu đô la — chính xác là điều Tornado Cash làm: circuit rút tiền của nó chứng minh sự hiểu biết về một bí mật và một nullifier gắn với một cam kết Merkle tree, và contract verifier chỉ thấy proof và các giá trị public.
Lỗi thường gặp: ba sai lầm thầm lặng phá vỡ circuit
Hệ sinh thái ZK có phần "chạy được trong demo" gây ám ảnh, và các thất bại tập trung quanh vài mẫu. Đầu tiên là dùng <-- thay vì <==. Một phép gán mũi tên đơn bảo witness generator tính giá trị gì, nhưng không viết constraint nào. Nếu lập trình viên viết y <-- x x và quên phần y === x x đi kèm, thì R1CS không thực sự ép buộc gì — một prover độc hại có thể đặt y bằng bất kỳ giá trị nào họ muốn mà vẫn sinh ra proof, vì dòng constraint chưa từng tồn tại. Một nghiên cứu bảo mật trên circuit thực tế tìm thấy đúng lớp lỗi này trong các dự án production: signal không bị ràng buộc để witness giả lọt qua xác minh. Cảnh báo của compiler tồn tại để ngăn điều này.
Sai lầm thứ hai là khai báo input public khi đáng lẽ phải private. Input private theo mặc định, nhưng lập trình viên có thể viết component main { public [x] } = XSquare();, và khi đó x xuất hiện trong public.json — "zero knowledge" thầm lặng trở thành "revealed knowledge." Với x² = y, công bố x khiến proof vô nghĩa, vì verifier chỉ cần bình phương x là xong. Chỉ giữ public cho những giá trị verifier đáng lẽ phải thấy.
Sai lầm thứ ba là quên rằng signal bất biến. Gán y hai lần và compiler từ chối. Điều này làm vấp lập trình viên viết vòng lặp kiểu imperative gán lại signal — cách sửa là dùng var có thể thay đổi cho biến đếm vòng lặp và dành signal cho các giá trị cuối.
Mở rộng: từ một constraint đến circuit thật
Circuit x² = y là proof nhỏ nhất có thể, nhưng pipeline chính xác — biên dịch, witness, setup, prove, verify — chạy được cho circuit hàng nghìn constraint. Các con số tăng theo dự đoán được: proof Groth16 luôn khoảng 192 byte và luôn xác minh trong vài mili giây, nhưng thời gian prove và kích thước proving key tăng theo số constraint (circuit một triệu constraint cần proving key khoảng 500 MB). Đó là lý do tối ưu hóa circuit — nén một phép tính vào càng ít gate nhân càng tốt — là kỹ năng cốt lõi cho kỹ sư ZK. Poseidon hash, ví dụ, được thiết kế có chủ đích rẻ về constraint, đó là lý do giao thức quyền riêng tư chọn nó thay vì SHA-256.
Với lập trình viên Việt Nam, kỹ năng này được nối thẳng vào hệ sinh thái địa phương. ETH Vietnam và các hacker house của Vietnam Blockchain Week có track ZK; Thành phố Hồ Chí Minh từng tổ chức một ZK hacker house chuyên dụng vào năm 2025 nơi 18 builder cho ra 13 dự án hướng quyền riêng tư — giao dịch riêng tư, stealth address, và demo machine-learning-to-circuit. Điểm cốt yếu là bạn không cần phần cứng đặc biệt, GPU, hay vốn để bước vào lĩnh vực này: một laptop, Node.js, và circuit một-constraint này là đủ để đi từ số không đến một proof đã xác minh, và các bước tiếp — Poseidon hash, một Merkle proof, một tín hiệu ẩn danh kiểu Semaphore — đều là mở rộng được tài liệu hóa của cùng một template. ZK là một trong số ít track kỹ thuật trong crypto nơi rào cản ràng buộc là sự hiểu biết, không phải vốn.
Câu hỏi thường gặp
Vì sao Circom từ chối y <== x x x?
Vì mỗi constraint R1CS phải quadratic — dạng linear × linear = linear. x x là quadratic; x x x là cubic. Sửa bằng cách thêm một intermediate signal: sq <== x x; y <== sq * x; giờ là hai constraint quadratic, và circuit biên dịch thành công.
Khác biệt giữa <==, ===, và <-- là gì?
<== gán một giá trị và thêm một constraint (an toàn, mặc định). === thêm constraint mà không gán. <-- (và -->) gán mà không thêm constraint — chỉ dùng khi giá trị không thể biểu đạt quadratic, và ngay sau đó phải có một === để trói giá trị lại. Một phép gán không có constraint đi kèm là cách kinh điển để tạo ra circuit hỏng.
Groth16 có phải hệ thống proving duy nhất trong snarkjs không?
Không. snarkjs cũng hỗ trợ PLONK, chỉ cần lễ powers-of-tau phổ quát và bỏ qua phase 2 riêng cho circuit — nhưng đổi lại proof lớn hơn. Groth16 vẫn là lựa chọn mặc định cho xác minh on-chain vì proof nhỏ nhất (ba điểm nhóm, ~192 byte) và xác minh chỉ vài mili giây.Witness của circuit tôi lớn bao nhiêu?
Đúng bằng số signal cộng hằng số1. Với XSquare đó là [1, x, y] — ba phần tử. Mỗi intermediate signal bạn thêm làm witness dài hơn, và độ dài witness (không phải số constraint) chặn dữ liệu riêng tư mà prover phải giấu.
Tôi có cần trusted setup để kiểm thử không?
Không. Với kiểm thử, mộtcontribute là đủ. Với production, buổi lễ phải gồm nhiều người đóng góp độc lập và một transcript có thể xác minh — và lưu ý PLONK tránh lễ riêng cho circuit hoàn toàn. Hãy coi bất kỳ zkey nào chưa có contribution nào là đồ chơi không an toàn.
Tôi nên đi đâu tiếp sau x² = y?
Ba bước tự nhiên, đều trong hệ sinh thái circom: hash một giá trị bằng template Poseidon (rẻ về constraint), chứng minh tư cách thành viên Merkle tree (nền tảng của private pool và bỏ phiếu ẩn danh), và triển khai một Solidity verifier trên testnet. Mỗi bước đều dựa trên cùng pipeline biên dịch → witness → setup → prove → verify bạn vừa chạy.Kết luận
Ba mươi phút của Lan bao trùm toàn bộ cung của ZK thực dụng: một template, một constraint quadratic, một witness, một trusted setup, và một proof Groth16 mà verifier kiểm tra trong mili giây. Thủ thuật làm nên tất cả nhỏ hơn hầu hết mọi người tưởng — một phương trình đơn, y <== x * x, trở thành một dòng trong các ma trận r1cs zk. Mọi thứ khác — witness generator wasm, lễ powers-of-tau, proof 192 byte — là cỗ máy tồn tại để biến constraint đơn đó thành thứ dùng được trong thế giới thực. Viết zk circuit đầu tiên là con đường nhanh nhất vào lĩnh vực, vì nó là con đường một giao thức production đi, chỉ là số constraint bằng một. Compiler, buổi lễ, và contract verifier đều mở rộng cùng cách; chỉ số dòng thay đổi. ZK là lĩnh vực nơi rào cản vào thấp thật sự và nhu cầu về người viết circuit giỏi cao thật sự — và bạn giờ cầm biên lai cho proof thật nhỏ nhất bạn có thể xây. Constraint tiếp theo là của bạn.
Quick Reference
| Bước | Lệnh | Kết quả |
|---|---|---|
| Viết circuit | template XSquare() { signal input x; signal output y; y <== x * x; } | 1 constraint: y = x·x |
| Biên dịch | circom circuit.circom --r1cs --wasm --sym | circuit.r1cs, circuit.wasm, circuit.sym |
| Kiểm tra | snarkjs r1cs info circuit.r1cs | curve, #wires, #constraints, #private inputs |
| Witness | node generate_witness.js circuit.wasm input.json witness.wtns | phép gán đầy đủ signal (biên lai) |
| Setup phase 1 | snarkjs powersoftau new bn128 12 ... + contribute + prepare phase2 | pot12_final.ptau (phổ quát) |
| Setup phase 2 | snarkjs groth16 setup + zkey contribute + zkey export verificationkey | circuit_final.zkey, verification_key.json |
| Prove | snarkjs groth16 prove circuit_final.zkey witness.wtns proof.json public.json | proof.json (~192 byte), public.json |
| Verify | snarkjs groth16 verify verification_key.json public.json proof.json | OK trong vài mili giây |
| Deploy | snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol | hợp đồng verifier Solidity |
| Mặc định quyền riêng tư | component main = XSquare(); | x private, y public |
| Toán tử an toàn | <== (gán + constraint), === (constraint) | dòng constraint trong R1CS |
| Toán tử nguy hiểm | <-- (chỉ gán) | không có constraint — circuit hỏng sống ở đây |
| Quy tắc kích thước | một phép nhân mỗi constraint | thời gian prove & kích thước key tăng theo số dòng |
