写电路而不是写循环:circom 与零知识应用的建模方式 图 1
写电路而不是写循环:circom 与零知识应用的建模方式 · 图 1

从代码到约束

普通程序里,a = b + c 是让机器算一次赋值;在零知识电路里,你真正要表达的是约束:存在一组秘密值,使得 a、b、c 这些信号永远满足这个等式。circom 就是为这种建模写的语言——用声明式的信号和组件描述计算图,编译器把整个图展开成约束系统(默认输出 R1CS 格式),同时生成一个计算见证(witness)的 wasm 程序,供证明者先跑一遍、给每个内部信号填上具体数值。

语言层面它很克制。基本单元是信号:signal inputsignal output 声明组件边界,内部信号由赋值推导。核心语法是一条条等式约束 <====:前者在连线时自动补乘法约束,后者要求已有信号相等。分支不是 if-else,而是用多选一组件按位选择,因为电路必须在运行时保持每个门都有确定值,哪怕它逻辑上被”跳过”。循环展开成实例阵列,for 是编译期模板,不是运行时结构。

编译之后发生什么

一个 circom 项目通常产三样东西:一份 .r1cs,存约束系统与符号表的二进制;一个 witness calculator,wasm 或 C++ 程序,输入公共与私有参数、吐出完整信号赋值向量;一个验证密钥对,来自后续的 Trusted Setup。把 R1CS 转成具体证明系统的格式——比如 Groth16 的 .ptau/.zkey 流程,或者 PLONKish 的约束表——由 snarkjs、halo2 这类工具接手,circom 本身不绑定证明系统,只要约束能导出成中间表示。这一层分离让同一份电路可以针对不同成本曲线换后端:追求最小证明走 Groth16,想免每项目一仪式走通用设置,想递归验证走另一套多项式承诺。

建模时真正难的地方

第一难是位级思维。电路里没有整型溢出,只有约束缺失;写 signal a < b 这类比较得自己拆成位分解加范围检查,一个比较动辄几十个约束,约束预算直接翻几倍。第二难是内存。电路里访问一个 2 的二十次方大小的数组意味着二十层的默克尔路径选择器,所以 circom 社区把 bigdir、互联存储这类模式做成了通用库,本质都是用哈希换取可证明的索引。第三难是调试。运行时错误只表现为某个约束不满足,没有堆栈,工具链靠 wasm 里的日志与符号表回推哪个组件的哪条等式在见证阶段炸开。

把三者合起来看,circom 项目的成本曲线和智能合约很不同:上链成本大头不在部署,而在验证密钥的准备与电路约束规模,约束数决定 prover 的时间。这也是为什么实际产品的电路往往把大额计算搬到链下,链上只验一个聚合证明。

一条学习线

上手顺序建议这样排:先用 Rust 或 Python 把业务逻辑写成纯函数,再翻成约束;从小例子起——证明知道一个哈希的原像——走通 circom 编译、witness 计算、Groth16 生成与链上验证的全链路;然后才碰状态树与签名方案。读别人的电路比写自己的更值钱:Semaphore 的组成员证明、RSAV 的金额证明,都是同一批约束模式的重复。写零知识的人最后都会同意:难的不是密码学,是把业务问题切成”能被证明的陈述”的那层抽象。

一笔约束预算的直觉算术

把上面三种难处折成数字最直观。一次字节级比较按八次位分解加一组范围检查粗算,约束数轻松过百;一个标准的哈希轮函数在素数域上写出来往往是几百到几千条约束的量级;签名验证则按曲线标量乘法的窗口数线性增长。产品电路动辄十万到百万条约束,证明时间大致随约束数线性走,硬件与并行优化改变斜率不改变形状。因此做方案评审时,与其争论算法选型的玄学,不如先让工程团队报三行账:约束总量、见证生成时间、证明生成时间,这三个数几乎决定用户等待和节点成本的全部。数字对不上的方案,讲再多叙事都危险。

风险提示:本文仅作技术科普,零知识实现存在实现缺陷与审计风险,不构成投资建议。