Smart Contractsadvancedarticle40 min read

Debugging ZK: Tại sao bằng chứng ZK thất bại và tìm lỗi constraint — A debug zk circuit guide

Hướng dẫn debug zk circuit: vì sao bằng chứng ZK thất bại, ba gia đình lỗi (constraint sai, public inputs mismatch, witness tràn field), và quy trình 5 bước zk debugging từ revert đến nguyên nhân gốc.

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

Duc là một developer ở Hà Nội, đội của cậu đang ship một ứng dụng đổi coupon với một circuit zero-knowledge: khách hàng chứng minh họ sở hữu một voucher hợp lệ mà không tiết lộ cái nào. Circuit biên dịch, witness tạo ra, bằng chứng xác minh — locally. Rồi đội deploy verifier Groth16 lên testnet, và mỗi lần đổi đều revert. Lỗi vô dụng hết mức: Pairing check failed. Đây là khoảnh khắc mọi developer ZK sẽ gặp: bây giờ bạn đang debug zk circuit, và thông báo lỗi gần như không nói gì về điều gì sai. Bài viết này là phương pháp zk debugging biến "bằng chứng thất bại" thành một nguyên nhân đặt tên và có thể sửa, hoạt động trên circom, snarkjs, arkworks và Foundry.

Sự thật khó chịu khiến việc này khó: bằng chứng thất bại không nói tại sao. Verifier chạy một tập các phép toán cố định và trả về boolean. Dù circuit của bạn có constraint sai, witness bị tràn field, hay caller truyền public inputs sai thứ tự, triệu chứng giống hệt. Vậy bạn cần một khung phân loại trước khi cần công cụ. Trong bài này bạn sẽ học ba gia đình lỗi đằng sau mọi INVALID_PROOF, một quy trình gồm 5 bước debug từ revert đến nguyên nhân gốc, và các chuỗi lỗi circom/snarkjs/arkworks cụ thể ánh xạ đến từng cách sửa.

"Bằng chứng thất bại" thực sự nghĩa là gì

Trước khi debug zk circuit, bạn phải biết bằng chứng là gì. Một zk-SNARK proof không phải là một tin nhắn mô tả phép tính; nó là một khẳng định rằng phép tính đã chạy đúng. Verifier kiểm tra khẳng định đó bằng cách đánh giá các đa thức identity mã hóa các constraint. Trong hệ thống R1CS mà circom, snarkjs và arkworks dùng, mỗi constraint là một quan hệ bậc hai trên scalar field:

L * R - O = 0

với L, R, O là tổ hợp tuyến tính của các signals. Witness là sự gán giá trị cho mỗi signal khiến tất cả phương trình này đúng. Một bằng chứng hợp lệ chính xác khi witness thỏa mọi constraint và public inputs khớp với những gì verifier nhận. Bất cứ điều gì khác — witness vi phạm constraint, public input khác một bit, giá trị signal ngoài field — đều tạo ra bằng chứng thất bại khi xác minh.

Đó là lý do thất bại mờ nhạt. Verifier không phải trình thông dịch có thể nói "constraint 17 sai." Nó là một tập các phép toán elliptic-curve trả false nếu bất kỳ kiểm tra nào sai. Từ bên ngoài, constraint sai và nhầm thứ tự trong public inputs của caller trông giống nhau: cả hai đều trả về Pairing check failed.

Đây là cách nhìn lại giúp zk debugging khả thi. Thay vì hỏi "tại sao bằng chứng thất bại?", hãy hỏi "thành phần nào trong ba thành phần bị hỏng?" Một bằng chứng cần ba thứ đồng nhất:

  1. Circuit — hệ constraint, biên dịch thành verification key.
  2. Witness — inputs riêng tư cộng mọi giá trị trung gian, do prover tạo.
  3. Public inputs — các signals được cam kết trong bằng chứng, caller đưa cho verifier.

Khi bằng chứng thất bại, chính xác một trong ba cái này sai, và mỗi cái có dấu hiệu riêng. Toàn bộ nghệ thuật debug zk circuit là phân biệt chúng mà không cần debugger nhìn thấy bên trong bằng chứng.

Giải phẫu constraint hỏng: Circuit R1CS, Witness inputs riêng tư, Proof, với constraint vi phạm và ba nguyên nhân gốc

Hình 1: Vì sao bằng chứng ZK thất bại — ít nhất một constraint không đúng

Gia đình lỗi 1: constraint sai

Gia đình đầu tiên là khi circuit tự nó biểu diễn điều sai. Hệ constraint đúng về mặt toán học — mỗi phương trình có thể thỏa bởi một witness nào đó — nhưng nó không mô tả thuộc tính bạn muốn. Những lỗi này yên lặng nhất vì bằng chứng có thể xác minh: verifier đang làm việc đúng, kiểm tra chính xác những gì bạn bảo nó kiểm tra. Lỗi xuất hiện sau, khi một input xấu hoặc nhầm lẫn tạo ra bằng chứng "hợp lệ" cho thứ bạn không muốn cho phép.

Ví dụ kinh điển là thiếu range check. Giả sử circuit chứng minh "số này là một mã giảm giá." Thiếu constraint ép mã phải là số nguyên nhỏ, witness bị wrap quanh scalar field — phần tử field p - 1 chẳng hạn, bằng -1 về toán học nhưng về mặt ngữ nghĩa vô nghĩa. Hệ constraint có thể thỏa hoàn toàn trong khi ý nghĩa bị hỏng. Num2Bits của circom và enforce_leq/enforce_in_field của arkworks tồn tại chính để thêm các constraint này: chúng ép witness là một số nguyên giới hạn thực sự chứ không phải phần tử field tùy ý.

Lỗi constraint sai cũng hiện ra như thất bại xác minh thực sự, mặc dù. Trong circom, constraint viết sai trong template — phép nhân sai, signal đổi chỗ, lệch một trong vòng lặp xây constraint — tạo ra hệ không thể thỏa với input thực tế của bạn. Lỗi xuất hiện lúc tạo witness hoặc chứng minh. circom báo cáo chúng dạng Assert Failed trong template khi dùng cú pháp assert, hoặc lỗi solver khi đồ thị constraint mâu thuẫn. Dấu hiệu là lỗi phụ thuộc input: một số witness thỏa được, của bạn thì không, và thông báo chỉ vị trí template chứ không phải caller.

Trong arkworks, gia đình lỗi tương tự hiện ra dạng ConstraintSynthesisError từ ark-groth16::create_random_proof khi witness sinh ra không thỏa hệ, hoặc panic bên trong gadget phương thức enforce_* phát hiện giá trị xung đột. arkworks thường hữu ích hơn circom ở đây vì lỗi constraint-synthesis mang theo chỉ số constraint và giá trị thất bại.

Gia đình lỗi 2: public inputs không khớp

Gia đình thứ hai phá vỡ developer đang port bằng chứng snarkjs hoạt động sang Solidity. Circuit đúng, witness đúng, bằng chứng hợp lệ — nhưng caller đưa verifier các public inputs không khớp với cái được cam kết trong bằng chứng. Verifier tính lại các commitment của public inputs và chúng không khớp, nên trả về false.

Groth16 khiến việc này dễ sai theo ba cách:

  1. Thứ tự. Public signals trong bằng chứng phải khớp thứ tự verifier mong đợi. circom cam kết public signals theo thứ tự khai báo trong template. snarkjs trả chúng trong publicSignals, và verifier Solidity sinh ra đánh số chúng theo vị trí. Nếu bạn đổi thứ tự khi gọi on-chain — chẳng hạn đặt timestamp 256-bit trước voucher ID — xác minh thất bại dù mọi giá trị đúng.
  1. Biểu diễn. Public signals là phần tử field. Trong Solidity chúng vào dạng uint256. Nếu bạn tính giá trị trong JavaScript bằng float hoặc số nguyên vượt prime field, số bạn truyền on-chain không phải là phần tử field prover đã cam kết. Ca điển: số 256-bit có bit cao vượt modulus scalar field của BN254 (21888242871839275222246405745257275088548364400416034343698204186575808495617). Prover wrap nó theo modulo prime; caller Solidity truyền giá trị chưa wrap; xác minh thất bại.
  1. Bỏ sót. Một số công cụ cho phép bạn để public input ở giá trị mặc định zero on-chain trong khi prover cam kết giá trị khác không. Commitment sau đó không khớp.

Dấu hiệu của gia đình này là bằng chứng xác minh off-chain. Nếu groth16.verify của snarkjs trả true nhưng contract Solidity revert, circuit và witness gần như chắc chắn ổn — caller là thành phần hỏng. Đây là nguyên nhân "chạy được trong test, fail trong production" phổ biến nhất cho Groth16.

Gia đình lỗi 3: witness tràn field

Gia đình thứ ba nằm giữa hai cái kia: các constraint đúng và caller đúng, nhưng witness prover tạo ra không phải là gán hợp lệ. Thường do giá trị vượt scalar field, hoặc tạo witness phi bắt định.

Mọi giá trị trong circuit R1CS sống trong scalar field — với BN254, prime p ở trên. Nếu chương trình tính giá trị trung gian lớn hơn p, phần tử field wrap. Hệ constraint có thể vẫn thỏa trong field — nhưng thỏa cho một giá trị toán học khác ý định ứng dụng. Witness wrap một số tiền lớn thành phần tử field nhỏ sẽ pass qua các constraint chỉ kiểm tra "số tiền vừa 32 bit" nếu các constraint đó tự thân sai. Đó là lý do range checks là constraint, không phải validation: Num2Bits(n) trong circom ép giá trị là số nguyên n-bit thực sự, vì nó ràng buộc phân rã nhị phân. Thiếu nó, "vừa field element" là thứ duy nhất được kiểm tra — và mọi phần tử field đều vừa field.

Phi bắt định là witness failure khác. Nếu tạo witness dùng nguồn ngẫu nhiên (Fr::rand() trong arkworks, crypto.random trong witness generator) trong khi constraint system kỳ vọng giá trị xác định, bằng chứng đôi khi xác minh được đôi khi không. Tái tạo với input chính xác và không tái tạo được lỗi là dấu hiệu cho thấy.

Quy trình zk debugging: tái tạo, cô lập, quan sát, sửa, kiểm lại

Đủ phân loại. Đây là phương pháp 5 bước — quy trình zk debugging giải quyết cả ba gia đình — chạy ngay khi bạn thấy Pairing check failed. Công cụ trung lập, hoạt động cho circom, snarkjs, arkworks và mọi verifier Solidity.

Bước 1 — Tái tạo. Lấy chính xác inputs gây lỗi và chạy lại toàn bộ: tạo witness, chứng minh, xác minh. Lưu witness, public signals và hash vkey. Lỗi không tái tạo được là lỗi prover (phi bắt định), không phải lỗi circuit.

Bước 2 — Cô lập. Quyết định gia đình nào bằng chạy xác minh sạch off-chain. Nếu groth16.verify snarkjs (hoặc verify_groth16 arkworks) trả true nhưng contract revert, bạn ở gia đình 2 (public inputs) — nhảy thẳng đến Bước 4. Nếu xác minh fail off-chain luôn, bạn ở gia đình 1 hoặc 3: bisect circuit. Bỏ comment nhóm constraint hoặc chứng minh sub-circuit tối thiểu với cùng inputs đến khi constraint lỗi được đặt tên. Lỗi arkworks thường mang chỉ số; Assert Failed của circom đặt tên template.

Bước 3 — Quan sát witness. Witness là sự thật gốc. Xuất nó và so sánh từng giá trị trung gian với bạn tính tay. Trong circom, thêm assert tạm ở signals đáng ngờ hoặc log từ witness calculator. Trong arkworks, in giá trị SatisfyingAssignment. Tìm hai thứ: giá trị bất ngờ lớn (lỗi range/wrap) và giá trị không khớp số học dự định (constraint sai). Bước này biến "một constraint lỗi" thành "signal amount có giá trị X khi nó nên là Y."

Bước 4 — Sửa. Áp dụng sửa theo gia đình:

  • Gia đình 1 (constraint sai): viết lại constraint, sửa bound vòng lặp, hoặc tái suy luận số học. Nếu hệ không thể thỏa, thêm constraint ép hành vi dự định — thường là range check. Tạo verification key mới: đổi circuit vô hiệu hóa key cũ, và bằng chứng phải sinh ra trên hệ mới.
  • Gia đình 2 (public inputs): sửa caller. Khớp thứ tự signal với thứ tự khai báo template, đảm bảo mọi public input là phần tử field chính xác (dùng BigInt, không float; giảm về field), truyền mọi signal khác không. Trong Solidity, so publicSignals từ snarkjs với inputs hàm nhận, từng chỉ số một.
  • Gia đình 3 (witness tràn): thêm range checks vào circuit (Num2Bits, enforce_leq) để witness bị reject từ cấu trúc, và làm tạo witness bắt định.

Bước 5 — Kiểm lại đầu cuối. Biên dịch lại, tạo vkey mới, chứng minh lại với witness đã sửa, deploy verifier nếu key thay đổi. Rồi xác minh on-chain lần nữa — lần này kiểm tra cả những cái dễ bỏ qua: hash vkey contract giữ, thứ tự public input, gas gửi. Đổi constraint system vô hiệu hóa verifier deploy cũ; nếu contract vẫn revert sau khi sửa circuit, xác nhận key deploy thực sự khớp zkey mới.

Quy trình zk debugging: tái tạo, cô lập, quan sát, sửa, kiểm lại, với ghi chú mỗi bước

Hình 2: Quy trình zk debugging — tái tạo, cô lập, quan sát, sửa, kiểm lại

Các chuỗi lỗi thường gặp và ý nghĩa

Để thông báo lỗi chỉ ra gia đình trước khi bạn bắt đầu quan sát. Đây là các chuỗi bạn sẽ thực sự thấy, ánh xạ đến cách sửa.

circom: Assert Failed trong witnesscalc — witness vi phạm constraint, thường gia đình 1 hoặc 3; kiểm template được chỉ định. Error in template ... inconsistent constraints — hệ không thể thỏa với input; bisect. signal ... does not have a value — signal chưa từng được gán.

snarkjs: Error: invalid proof từ groth16.verify — gia đình 1 hoặc 3 (hoặc bằng chứng hỏng); invalid zkey — key không khớp; Cannot read properties of undefined (reading '1') khi xây publicSignals — bạn truyền sai shape, thường là thứ tự gia đình 2.

Solidity verifier: Pairing check failed / INVALID_PROOF — kiểm tra pairing Groth16 fail; gia đình 1, 2 hoặc 3, không phân biệt được từ bên ngoài — chạy pipeline. Point not on curve — caller truyền điểm sai encoding, gia đình 2. Invalid public key / revert hết gas không thông báo — thường thiếu public input hoặc sai số lượng argument, gia đình 2.

arkworks: ConstraintSynthesisError với chỉ số — gia đình 1 hoặc 3; thông báo gồm giá trị lỗi. the constraint is not satisfied từ gadget — kiểm nội bản gadget phát hiện giá trị xung đột.

Mẫu cần nhớ: lỗi circom và arkworks xảy ra lúc chứng minh và nghiêng về gia đình 1 hoặc 3; lỗi snarkjs và Solidity xảy ra lúc xác minh và có thể là mọi gia đình. Công cụ báo lỗi nói bạn nhìn ở đâu trước, không phải cái gì sai.

Phân loại lỗi: Nhóm 1 constraint sai, Nhóm 2 inputs sai, Nhóm 3 witness tràn, ánh xạ đến cách sửa

Hình 3: Phân loại lỗi bằng chứng ZK — ánh xạ thông báo lỗi thành cách sửa

Khi witness tự nói dối

Đôi khi chạy pipeline và witness có thật vi phạm constraint — còn số học trông đúng. Đây là lúc bài học field từ nền tảng thư viện ZK trở thành công cụ debug: kiểm tra field nào số học thực sự chạy trên đó. Nếu circuit so sánh giá trị với hằng số, hằng được hiểu theo modulo prime của scalar field. Cạm bẫy thực sự là mất chính xác bên JS: giá trị tính dạng Number (double, mất chính xác nguyên trên 2^53) không phải phần tử field. Witness sau đó gần đúng — lệch 1 bit thấp — và mọi constraint pass trừ những cái phát hiện. Triệu chứng: chạy được với giá trị nhỏ, hỏng với giá trị lớn. Đó là gia đình 3 mặc gia đình 2, sửa bằng BigInt từ input trở đi.

Witness nói dối thứ hai là thời gian. Nếu circuit hash giá trị bao gồm timestamp block hiện tại (hoặc Fr::rand() trong arkworks cho giá trị circuit coi bắt định), bằng chứng có thể sinh ra witness khác bạn vừa quan sát. Tái tạo với witness đã lưu, không phải tạo mới, và sự khác biệt hiện ra.

Ngăn ngừa constraint hỏng từ đầu

Debug rẻ hơn chứng minh lại, nhưng ngăn ngừa rẻ hơn cả. Năm thói quen xóa phần lớn lỗi trên:

  1. Range-check mọi signal rời field. Signal nào được hiểu số nguyên — tiền, mã, timestamp — thêm constraint Num2Bits/enforce_leq, không phải comment.
  2. Kẹp thứ tự public signal trong template và caller. Khai báo public signals một lần, một thứ tự, viết Solidity interface theo đó. Ghi vào header circuit.
  3. Verify off-chain trước khi deploy. groth16.verify là discriminator nhanh nhất giữa lỗi caller và circuit — chạy trên mọi bằng chứng, kể cả thất bại.
  4. Lưu hash vkey trong test harness. Đổi circuit tạo key mới phá verifier deploy; hash check bắt được.
  5. Dùng BigInt mọi nơi. Không float, không Number qua biên JS/Solidity — field element là integer và chỉ có thế.

Ý nghĩa cho developer ZK Việt Nam

Cộng đồng developer Việt Nam nhỏ nhưng tăng nhanh trong blockchain, và kỹ năng zk debugging chính xác là kiến thức gộp lãi phân biệt đội ship được với đội stall. Các revert Pairing check failed làm junior hoảng hiện là checklist 5 bước đến chẩn đoán. lỗi constraint zk bạn sẽ gặp — constraint lỗi, public input sai thứ tự, witness tràn field — đều thuộc ba gia đình trên. Khi đội bạn gặp bằng chứng "đơn giản fail," hỏi câu phân loại trước: circuit, caller, hay witness? Bạn sẽ ngạc nhiên bao nhiêu lần đáp án là caller, và nhanh thế nào pipeline chứng minh được.

Với đội xây use case Việt — thông tin xác thực, loyalty/coupon, KYC riêng tư — lời khuyên thực tiễn giống lý thuyết: build harness verify off-chain ngày đầu, range-check mọi public-facing signal, coi bằng chứng fail là triệu chứng cần định vị, không phải phán xử.

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

Tại sao bằng chứng verify được snarkjs nhưng fail on-chain?

Chín lần mười là public inputs không khớp: sai thứ tự, sai biểu diễn (float vs field element), hoặc bỏ sót signal. Chạy pipeline — snarkjs pass còn contract revert là dấu vân tay gia đình 2.

"Pairing check failed" nghĩa là gì?

Verifier Groth16 báo rằng bộ nhân pairing thất bại — một trong ba thành phần (constraint, witness, public inputs) bất đồng với bằng chứng. Thông báo không nói cái nào; đó là lý do bạn phân loại.

Làm sao tìm constraint hỏng?

Bisect circuit: chứng minh sub-circuit tối thiểu với cùng input, hoặc bỏ comment nhóm constraint đến khi cái lỗi được đặt tên. Lỗi arkworks mang chỉ số; Assert Failed của circom đặt tên template. Rồi quan sát witness.

Tại sao range checks quan trọng khi debug?

Thiếu range check, mọi phần tử field đều là witness hợp lệ — kể cả giá trị wrap scalar field. Giá trị wrap có thể thỏa constraint trong khi ý nghĩa hoàn toàn sai. Range checks (Num2Bits, enforce_leq) ép witness là số nguyên giới hạn thực sự, biến lỗi "đôi khi chạy" thành hành vi bắt định.

Bằng chứng trông hợp lệ có thể sai không?

Có — đây là lỗi nguy hiểm. Nếu hệ constraint không ép thuộc tính bạn nghĩ (thiếu range, phép nhân sai), prover tạo bằng chứng xác minh cho input bạn không muốn. Test invalid inputs quan trọng như test valid.

Nên sửa caller hay circuit trước?

Chạy off-chain/on-chain split trước. Nếu snarkjs/arkworks verify pass mà contract revert, sửa caller (gia đình 2). Nếu fail mọi nơi, circuit hoặc witness là vấn đề (1 và 3) — bisect và quan sát trước khi đổi gì.

Kết luận

Circuit coupon của Duc không hỏng. Hệ constraint đúng, witness trung thực, bằng chứng hợp lệ — và caller truyền public inputs sai thứ tự. Revert Pairing check failed, trông như thảm họa mật mã, thực ra là lỗi vị trí trong lời gọi Solidity. Đây là bài học thực về debug zk circuit: thông báo lỗi không nói lý do thất bại, nhưng cấu trúc hệ thống luôn nói. Bằng chứng là ba thành phần đồng nhất — constraint system, witness, public inputs — và thất bại nghĩa là chính xác một cái bất đồng. Quy trình 5 bước (tái tạo, cô lập, quan sát, sửa, kiểm lại) biến phát biểu trừu tượng thành thủ tục chạy lúc 2 sáng. Các gia đình phân loại — constraint sai, public inputs mismatch, witness tràn — cho bạn từ vựng mô tả điều tìm thấy. Học phương pháp một lần, Pairing check failed tiếp theo thành checklist thay vì hoảng loạn. Khi đội ZK Việt tăng trưởng, kỹ năng này là ranh giới giữa ship và stall.


Tham chiếu nhanh

Ba gia đình lỗi

Gia đìnhTriệu chứngỞ đâuLỗi thườngSửa
Constraint saiFail lúc tạo witness/chứng minh, hoặc verify cho input saiCircuitAssert Failed (circom); ConstraintSynthesisError (index)Viết lại constraint; thêm range check; tạo vkey mới
Public inputs mismatchVerify pass off-chain, revert on-chainCaller / SolidityPairing check failed, INVALID_PROOFKhớp thứ tự, truyền đúng field elements, truyền hết signals
Witness tràn fieldGián đoạn; hỏng giá trị lớnProver / biên JSOverflow → field element bị wrapRange-check signals; dùng BigInt; witness bắt định

Pipeline zk debugging

BướcHành độngCâu hỏi trả lời
1Tái tạo với inputs + witness đã lưu"Có thể tái tạo được không?"
2Verify off-chain; nếu pass → lỗi caller"Thuộc gia đình nào?"
3Quan sát witness (xuất giá trị)"Signal nào bất đồng, lệch bao nhiêu?"
4Sửa constraint, caller, hoặc range check"Thay đổi tối thiểu đúng là gì?"
5Biên dịch lại, tạo vkey, verify on-chain"Giữ đầu-cuối không?"

Tra chuỗi lỗi

Chuỗi lỗiCông cụGia đình
Assert Failed (tên template)circom1 hoặc 3
inconsistent constraintscircom1
invalid proofsnarkjs1 hoặc 3
Cannot read properties of undefinedsnarkjs2
Pairing check failed / INVALID_PROOFSolidity1, 2 hoặc 3
Point not on curveSolidity2
ConstraintSynthesisError (index)arkworks1 hoặc 3

Checklist ngăn ngừa

  • Range-check mọi signal hiểu số nguyên (Num2Bits / enforce_leq).
  • Kẹp thứ tự public signal trong template và ghi cho Solidity caller.
  • Chạy groth16.verify off-chain trước khi deploy verifier.
  • Lưu hash verification key; kiểm tra sau mỗi đổi circuit.
  • Dùng BigInt mọi nơi qua biên JS/Solidity.

Tags:

debug zk circuitlỗi constraint zkzk debugging