Smart Contractsintermediatearticle40 min read

SnarkJS Tutorial: Quy Trình Groth16 Từ R1CS Đến Proof và Verify

SnarkJS tutorial hướng dẫn trọn vẹn groth16 workflow từ R1CS đến proof và verify trong Node.js: circom biên dịch circuit, powers of tau và zkey tạo khóa, snarkjs prove, Solidity hoặc Node verify trong mili giây.

Vietcardano teamTeam

Đội ngũ biên tập VietCardanoCommunity — các chuyên gia blockchain và Cardano, cung cấp nội dung giáo dục và nghiên cứu chuyên sâu cho cộng đồng.

Published 31 tháng 8, 2026Updated 30 tháng 8, 2026

Minh là lập trình viên backend ở Hà Nội, đã ship các API Node.js suốt năm năm. Một khách hàng nhờ anh làm kiểm tra chứng chỉ zero-knowledge: chứng minh một người dùng nằm trong danh sách được phép mà không cần gửi danh sách đó, hoặc chứng minh số dư tài khoản vượt ngưỡng nào đó mà không tiết lộ con số. SnarkJS tutorial này đi theo đúng con đường anh ấy đang cần — từ mô tả circuit đến một proof đã được verify — dùng công cụ đã trở thành lựa chọn mặc định cho lập trình viên JavaScript. Đến cuối bài, bạn sẽ hiểu R1CS và witness thực chất là gì, vì sao groth16 workflow bị tách thành hai ceremony setup, và cách chạy một zk proof nodejs trọn vẹn: circom biên dịch circuit, snarkjs tạo khóa, sinh proof, rồi một contract Solidity nhỏ — hoặc một script Node — verify chỉ trong vài mili giây.

Mọi proof rời khỏi máy của bạn đều đi qua cùng sáu artifact: file constraint .r1cs, bộ tính witness .wasm, một witness.wtns, file ptau từ ceremony dùng chung, một zkey riêng cho circuit, và cuối cùng là proof.json kèm verification_key.json tương ứng. Bài này gắn từng artifact với đúng lệnh tạo ra nó, để các bước ceremony không còn như phép thuật mà trở thành công việc ghi sổ quen thuộc.

Quy trình SnarkJS: circuit.circom → compile (.r1cs + .wasm) → witness.wtns ở hàng 1, trusted setup (zkey) → prove (proof.json) → verify ở hàng 2, sáu lệnh tạo ra sáu artifact

Hình 1: Quy trình snarkjs — mọi proof Groth16 đi cùng một đường sáu bước từ circuit đến proof đã verify

R1CS và witness thực chất là gì

Trước khi nói công cụ, hãy nói khái niệm. Một zk-SNARK không verify "phép tính" — nó verify rằng một witness (input riêng tư cộng với mọi giá trị trung gian phép tính sinh ra) thỏa mãn một bộ phương trình gọi là constraint system. Khi bạn viết circuit bằng Circom, trình biên dịch biến các khai báo signal và các phép kiểm tra == thành một Rank-1 Constraint System (R1CS): một danh sách phương trình dạng A * B = C, trong đó A, B và C là các tổ hợp tuyến tính của các dây dẫn, được tính trong một trường nguyên tố lớn. Mọi thứ một circuit có thể biểu diễn — một hàm hash, một Merkle proof, một phép so sánh, một kiểm tra chữ ký — đều trở thành hàng trăm hoặc hàng nghìn constraint dạng phép nhân đơn lẻ này.

Ví dụ kinh điển là bộ nhân. Một circuit với component main { public [c] }, hai signal riêng tư a và b, và một signal công khai c bị ràng buộc a * b === c sẽ biên dịch thành đúng một constraint R1CS. Witness là bộ gán giá trị cụ thể {a: 3, b: 11, c: 33}. Để chứng minh "tôi biết hai thừa số của 33", bạn tính witness từ input riêng của mình, giữ bí mật a và b, công bố c, rồi sinh một proof chứng tỏ constraint đó đúng với witness của bạn. Verifier chỉ biết c = 33 và biết rằng bạn nắm một bộ gán thỏa mãn — không biết gì hơn.

Có hai con số đáng nhớ. Thứ nhất, số constraint chính là cái giá của một circuit: một SHA-256 đơn giản trên một block tốn khoảng 25.000-30.000 constraint, đó là lý do các circuit trong tutorial dùng Poseidon thay vì SHA-256. Thứ hai, file R1CS là dạng nhị phân; snarkjs r1cs info cho bạn biết số constraint còn snarkjs r1cs export json giúp bạn đọc được nội dung. Chạy cả hai trên circuit đầu tiên của bạn — thấy chương trình của mình hóa thành các phương trình chính là khoảnh khắc mô hình tinh thần "vỡ" ra đúng nghĩa.

Groth16 workflow: từ circuit đến proof đã verify

Groth16 workflow là hệ thống proving mặc định trong snarkjs và là hệ thống hầu hết tutorial dùng, vì proof của nó nhỏ và việc verify có chi phí cố định. Groth16 yêu cầu trusted setup cho từng circuit, và setup đó được cố ý tách thành hai phase để phần lớn công việc có thể dùng chung. Phase 1 là ceremony powers of tau: mang tính phổ quát, độc lập với circuit, chạy một lần cho cả hệ sinh thái. Phase 2 gắn riêng từng circuit: nó buộc các tham số phổ quát vào .r1cs cụ thể của bạn. Đầu ra của cả hai phase là một file .zkey chứa proving key, verification key, và nhật ký của mọi contribution vào ceremony.

Toàn bộ luồng trông như sau. Bạn biên dịch circuit (circom), tính witness từ input, tạo hoặc tái sử dụng một ptau, chạy snarkjs groth16 setup với .r1cs của mình, góp thêm ngẫu nhiên vào zkey vừa có, rồi prove và verify. Tóm gọn trong một câu: constraints vào, proof ra, sự thật được kiểm tra.

Phase 1: ceremony powers of tau

snarkjs powersoftau new bn128 14 pot14_0000.ptau khởi động một ceremony hỗ trợ circuit tới 2^14 = 16.384 constraint; công suất tối đa là 28, đủ cho khoảng 268 triệu constraint. Mỗi người tham gia chạy snarkjs powersoftau contribute để trộn entropy của họ vào transcript, sau đó một beacon cuối cùng tạo contribution ngẫu nhiên công khai có thể kiểm chứng từ một nguồn như hash blockchain, và snarkjs powersoftau prepare phase2 tạo ra pot_final.ptau — thứ bạn thực sự đưa vào groth16 setup.

Điều quan trọng với một đội nhỏ: bạn gần như không bao giờ cần tự chạy phase 1. Vì ceremony mang tính phổ quát, cả hệ sinh thái duy trì các file ptau công khai dùng chung (file "power of 28" phổ biến trong hầu hết tutorial bắt nguồn chính từ tập quán này), và các dự án fork một vòng của cùng một ceremony — mô hình gọi là perpetual powers of tau. Ceremony Zcash Sapling là lần dùng quy mô lớn đầu tiên của giao thức này; Aztec, Filecoin, Semaphore, Loopring, Tornado Cash và nhiều dự án khác nối tiếp. Với circuit sản xuất, bạn vẫn phải tự chạy phase 2 — phần đó thực sự là của bạn — nhưng phase 1 tốn kém và nhạy cảm bảo mật là một tài sản chung của cộng đồng.

Phase 2: zkey riêng cho circuit

snarkjs groth16 setup circuit.r1cs pot_final.ptau circuit_0000.zkey tạo zkey gốc. Tài liệu snarkjs nói rõ: đừng dùng file này trong sản xuất. Nó có zero contribution, nghĩa là một kẻ điều hành setup độc ác duy nhất có thể làm giả proof. Toàn bộ an ninh của hệ thống dựa trên quy tắc "ít nhất một người đóng góp trung thực": chỉ cần một người tham gia giữ kín ngẫu nhiên bí mật của họ, tham số cuối cùng vẫn lành mạnh. Vì vậy bạn gọi snarkjs zkey contribute ít nhất một lần (lý tưởng là vài lần, bởi những người khác nhau, tốt nhất trên máy tách biệt mạng), rồi snarkjs zkey beacon để thêm ngẫu nhiên công khai, rồi snarkjs zkey verify để xác nhận mọi contribution được nối đúng chuỗi. Cuối cùng snarkjs zkey export verificationkey circuit_final.zkey verification_key.json cho bạn file công khai nhỏ mà ai cũng có thể verify.

Các giai đoạn proving Groth16: phase 1 powers of tau (dùng chung) và phase 2 zkey ceremony (riêng circuit) chạy chuỗi đóng góp và kết thúc bằng pot_final.ptau và circuit_final.zkey

Hình 2: Hai ceremony setup Groth16 — phase 1 phổ quát dùng chung, phase 2 gắn tham số vào circuit của bạn

Proving và verifying trong Node.js: hành trình zk proof nodejs

Khi đã có khóa, việc thực sự sinh proof là bước ngắn nhất trong toàn bộ pipeline. Cài snarkjs (npm install snarkjs), và toàn bộ trải nghiệm zk proof nodejs gom lại còn hai lệnh gọi hàm. Đây chính là phần Minh thực sự đưa lên server.

`javascript const snarkjs = require("snarkjs"); const fs = require("fs");

async function main() { // 1. Prove: witness được tính từ input (riêng tư) của bạn, // từ file .wasm, và proving key nằm trong zkey. const { proof, publicSignals } = await snarkjs.groth16.fullProve( { a: 3, b: 11 }, // input riêng + công khai "circuit.wasm", // từ circom --wasm "circuit_final.zkey" // proving key );

// 2. Verify cục bộ bằng verification key. const vKey = JSON.parse(fs.readFileSync("verification_key.json", "utf8")); const ok = await snarkjs.groth16.verify(vKey, publicSignals, proof); console.log(ok ? "Verification OK" : "Invalid proof"); } main(); `

fullProve làm cả việc tính witness và sinh proof trong một bước, nên bạn không bao giờ phải chạm witness.wtns trong code trừ khi cần debug. Hàm trả về proof (một Groth16 proof là ba điểm — pi_a, pi_b, pi_c — và luôn cùng kích thước, khoảng 512 byte, bất kể circuit to hay nhỏ) và public signals (các input và output công khai, ví dụ c = 33). verify nhận verification key, public signals và proof, rồi trả về một boolean đơn giản. Lật một bit trong proof là nó trả về false — phép thử một dòng đó là cách nhanh nhất để cảm nhận một proof thực sự "ràng buộc" điều gì.

Hai lệnh gọi tương tự chạy được ngay trong trình duyệt. Vì snarkjs là bản triển khai thuần JavaScript + WebAssembly, fullProve có thể chạy ở phía client — đúng thứ các ứng dụng chứng chỉ riêng tư mong muốn: witness (bí mật) không bao giờ rời thiết bị; chỉ có proof đi tới API của bạn. Đó là lý do kiến trúc zk proof nodejs và proving phía trình duyệt dùng chung một đường code — không có gì mang tính server-side nơi prover trừ khi bạn tự đặt nó ở đó.

Verify trên chuỗi: từ snarkjs đến Solidity

Lý do một proof "succinct" trở nên cụ thể ngay khi bạn đặt verifier lên blockchain. Một lệnh duy nhất — snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol — sinh ra một file Solidity chứa hai contract: một thư viện Pairing và một contract Verifier. Bạn chỉ deploy Verifier, rồi gọi hàm view verifyProof với proof và public signals. Hàm trả về true khi và chỉ khi proof hợp lệ với đúng các giá trị công khai đó. snarkjs generatecall in calldata đã định dạng sẵn để bạn dán vào Remix, hoặc nối contract vào một bài test Hardhat:

`javascript const verifier = await ethers.getContractFactory("Verifier"); const v = await verifier.deploy(); // proof + publicSignals từ snarkjs.groth16.fullProve expect(await v.verifyProof(proof.pi_a, proof.pi_b, proof.pi_c, publicSignals)) .to.equal(true); `

Đây là nơi sự bất đối xứng prover/verifier phát huy. Proving chạy trên phần cứng của bạn và tỉ lệ theo circuit; verification là một phép tính cố định, rất nhỏ. Một benchmark năm 2025 trên các circuit circom-snarkjs đo được thời gian sinh witness khoảng 58 ms, sinh proof trong khoảng 832 ms đến 1,15 giây, và verification trong khoảng 741 ms đến 884 ms — với thời gian verification gần như hoàn toàn không phụ thuộc số constraint. Trên chuỗi, điều đó tương đương vài trăm nghìn gas cho mỗi lần verify, bất kể circuit của bạn có 100 hay 1.000.000 constraint. Server của bạn làm phép toán nặng một lần cho mỗi user; contract tái kiểm tra kết quả với chi phí rẻ ở mọi lần nộp.

So sánh prover vs verifier: prover Node.js chạy groth16.fullProve với full zkey (nặng, một lần), verifier trên chuỗi kiểm tra 2-3 pairing chỉ với verification key (rẻ, mỗi lần)

Hình 3: Prover vs verifier — prover làm việc nặng một lần, verifier kiểm tra rẻ mỗi lần

Những lỗi thường gặp và cách debug

Hầu hết thất bại lần đầu không phải do mật mã học — chúng là lỗi vòng đời file. Bốn lỗi dưới đây là những thứ Minh sẽ gặp, và mỗi lỗi có một câu chẩn đoán:

  1. "zkey is not safe / has no contributions." Bạn đã bỏ qua các contribution phase 2. Chạy zkey contribute ít nhất một lần và zkey verify trước khi export bất cứ thứ gì.
  2. groth16 setup báo lỗi về công suất ptau. Công suất ptau phải đủ lớn cho số constraint của bạn. snarkjs r1cs info circuit.r1cs in ra số constraint; hãy chọn lũy thừa của hai lớn hơn con số đó (power 14 đủ 16.384; power 20 đủ 1.048.576).
  3. Proof verify thành công cục bộ nhưng thất bại trên chuỗi. Verifier trên chuỗi và hàm verify trong Node kiểm tra cùng một phát biểu Groth16, nên nguyên nhân thường là artifact lệch nhau — bạn export verifier.sol từ một zkey khác với cái bạn dùng để prove, hoặc bạn deploy một Verifier cũ sau khi ceremony cập nhật. Hãy export và redeploy cùng nhau.
  4. fullProve treo với circuit lớn. WASM proving ngốn bộ nhớ; với circuit trên ~100 nghìn constraint hãy cân nhắc bộ tính witness C++ của snarkjs hoặc một prover bản địa, và luôn giữ zkey trong bộ nhớ một lần cho mỗi tiến trình thay vì đọc lại theo từng request.

Không lỗi nào ở trên là mật mã học thất bại. Ceremony và chuỗi file là hai phần duy nhất của workflow dễ làm sai, đó là lý do các sơ đồ trong bài đối xử với chúng như những bước hạng nhất.

Điều này có ý nghĩa gì với lập trình viên Việt Nam

Việt Nam âm thầm xây dựng một trong những cộng đồng lập trình viên ZK có tổ chức nhất Đông Nam Á. ZKP Labs — một trung tâm "ZK hub" phi lợi nhuận trong khu vực — tổ chức workshop, hội nghị và một lộ trình đào tạo nhằm tạo ra các kỹ sư có thể đóng góp vào chính hệ thống proof thay vì chỉ gọi thư viện; tổ chức này đồng tổ chức Vietnam Rust Hackathon, đội thắng cuộc đã xây một zk-rollup CKB và mang đi giới thiệu quốc tế. Kỹ sư Việt hiện làm việc trong các đội ZK tại ZKM, GOAT Network và Untangled.Finance, và các đội trong nước đã thắng các hạng mục trong chainlink hackathon với các dự án privacy DAO và prediction-market. Điểm chung nối tất cả họ: điểm khởi đầu là một công cụ như snarkjs trong một ngôn ngữ họ đã biết.

Điều đó quan trọng vì stack web phổ biến nhất của Việt Nam — Node.js phía backend, React hoặc Vue phía frontend — ánh xạ trực tiếp vào câu chuyện snarkjs: cùng một npm install snarkjs cho bạn fullProve trong API và verify trong trình duyệt hoặc trong một contract Verifier.sol được deploy từ Hardhat. Các dự án thực tế đầu tiên thường là những dự án nhàm nhưng giá trị: kiểm tra tư cách thành viên danh sách riêng tư (một club NFT khép kín hoặc airdrop mà không lộ ai đang giữ), bằng chứng số dư tài khoản cho cho vay, hoặc kiểm tra tuổi/khu vực có thể xác minh cho một nền tảng loyalty. Không dự án nào trong số đó cần một hệ thống proving thiết kế riêng — chúng cần một ceremony phase 2 chạy tốt, một witness cẩn thận, và kỷ luật verify artifact, vốn là 80% công việc.

Các câu hỏi thường gặp

Tôi có phải tự chạy ceremony powers of tau không?

Không. Phase 1 mang tính phổ quát và dùng chung; dự án sản xuất tái sử dụng một file ptau công khai được duy trì tốt (như file power-28 phổ biến) hoặc fork một vòng của ceremony perpetual powers of tau. Bạn vẫn phải chạy phase 2 riêng cho circuit — groth16 setup cộng ít nhất một zkey contribute và một beacon — vì đó là thứ buộc các tham số phổ quát vào .r1cs của bạn.

Sự khác biệt giữa Groth16, PLONK và FFLONK trong snarkjs là gì?

Groth16 cho proof nhỏ nhất và verification rẻ nhất nhưng đòi hỏi trusted setup riêng cho từng circuit. PLONK và FFLONK bỏ ceremony riêng cho circuit — chỉ cần powers of tau phổ quát — với cái giá là công việc verification lớn hơn (và với PLONK, proof lớn hơn). Với một tutorial, Groth16 là lựa chọn mặc định đúng; với dự án ship nhiều circuit, PLONK hoặc FFLONK loại bỏ gánh nặng duy trì ceremony.

Vì sao proof luôn cùng kích thước bất kể circuit to cỡ nào?

Groth16 proofs là ba điểm trên đường cong (một trên G2, hai trên G1), và con số đó không phụ thuộc vào circuit. Nghiên cứu benchmark đã xác nhận proof 512 byte cố định trên các circuit có kích thước rất khác nhau. Thứ tăng theo circuit là proving key và thời gian proving — không bao giờ là proof.

Làm sao để chạy một zk proof nodejs verification mà không cần blockchain?

Dùng trực tiếp snarkjs.groth16.verify(vKey, publicSignals, proof) trong tiến trình Node của bạn; hàm trả về boolean và không cần contract. Điều này lý tưởng cho các kiểm tra phía server nơi "server đáng tin" được chấp nhận. Chỉ dùng verifier Solidity khi một bên thứ ba phải kiểm tra proof mà không được tin tưởng server của bạn.

Nếu một người đóng góp ceremony là kẻ xấu thì sao?

Không sao, miễn là có ít nhất một người đóng góp khác trung thực. Groth16 setup được thiết kế sao cho một contribution bí mật duy nhất chưa bị tiết lộ khiến tham số cuối cùng vẫn lành mạnh kể cả khi mọi người tham gia khác cấu kết. Đây là bảo đảm an ninh cốt lõi của toàn bộ thiết kế ceremony.

Chi phí verification trên chuỗi tính theo gas là bao nhiêu?

Verification có chi phí cố định bất kể kích thước circuit: contract Verifier.sol tính hai hoặc ba phép pairing và trả về boolean. Trong thực tế con số đó vào cỡ vài trăm nghìn gas mỗi lần gọi, đó là lý do các ca sử dụng proof-of-computation trở nên kinh tế — công việc nặng xảy ra ngoài chuỗi, một lần, còn kiểm tra trên chuỗi là rất nhỏ.

Kết luận

Groth16 workflow trông đáng sợ vì ceremony, nhưng thực ra nó là một pipeline sáu bước: viết một circuit, biên dịch thành R1CS và bộ tính witness, tính witness, chuẩn bị một ptau phổ quát (hoặc tái sử dụng một cái dùng chung), buộc nó vào circuit trong một ceremony zkey, rồi prove và verify. Proof có kích thước cố định, verification có chi phí cố định, và đường zk proof nodejs chỉ cần đúng hai lệnh gọi hàm khi khóa đã sẵn sàng. Dự án kiểm tra chứng chỉ của Minh nằm trong tầm tay một cuối tuần: circom một circuit nhỏ, chạy phase 2 với ba người đóng góp và một beacon, prove trên server hoặc trong trình duyệt, rồi để một contract Verifier.sol — hoặc một lệnh gọi verify() đơn giản — làm việc kiểm tra mãi mãi về sau. Ceremony là phần cần sự cẩn thận, không cần sự can đảm; phần mật mã học làm phần còn lại. Với một lập trình viên Việt Nam đã quen Node.js, snarkjs tutorial này là con đường ngắn nhất từ "zk-proof là gì" đến "tôi đã ship một cái".


Quick Reference

BướcLệnhArtifactMục đích
1. Compilecircom circuit.circom --r1cs --wasm --symcircuit.r1cs, circuit.wasm, circuit.symConstraints + bộ tính witness
2. Witnesssnarkjs wtns calculate (hoặc fullProve)witness.wtnsBộ gán thỏa mãn cho input của bạn
3. Phase 1snarkjs powersoftau new/contribute/beacon/prepare phase2pot_final.ptauSetup phổ quát (tái dùng file chung)
4. Phase 2snarkjs groth16 setup + zkey contribute + zkey beacon + zkey verifycircuit_final.zkeyProving + verification key riêng circuit
5. Provesnarkjs groth16 prove (hoặc fullProve)proof.json, public.jsonProof zero-knowledge
6. Verifysnarkjs groth16 verify hoặc Verifier.solverification_key.jsonKiểm tra chi phí cố định (Node hoặc trên chuỗi)
Số liệu kích thước—proof ≈ 512 byte; vkey ≈ 1 KBKích thước cố định, không phụ thuộc circuit
Hiệu năng—witness ~58 ms; prove ~0,8-1,2 s; verify ~0,7-0,9 sThời gian verify không phụ thuộc số constraint
Bảo mật—một người góp trung thựcToàn bộ ceremony dựa trên quy tắc này

Tags:

snarkjs tutorialgroth16 workflowzk proof nodejs