Circom 是一种用于创建一阶约束系统(R1CS)并填充该 R1CS 见证向量(witness vector)的编程语言。
R1CS 格式之所以备受关注,是因为该格式在构建 SNARKs(尤其是 Groth16)方面非常有用。借助 SNARKs,我们能够实现可验证计算,从而允许我们证明计算的正确性。在验证时,利益相关方为了确认正确性所消耗的计算量,要比他们亲自执行该计算所需的计算量少得多。而且还可以生成证明而不泄露底层数据,在这种情况下,我们将其称为 zkSNARKs。
我们 ZK 书籍的第一部分重点介绍了如何证明给定 R1CS 的见证(witness)的有效性。而本资源则侧重于如何以编程方式生成 R1CS,以及如何设计它们以模拟真实的算法,例如虚拟机或密码学哈希函数。
前置知识
我们希望读者已经熟悉我们 ZK 书籍中的以下章节:
- https://rareskills.io/post/p-vs-np
- https://rareskills.io/post/arithmetic-circuit
- https://rareskills.io/post/finite-fields
- https://rareskills.io/post/rank-1-constraint-system
我们将假设读者知道 R1CS 是什么及其代表的含义。上述四章已对此进行了全面解释。
使用 Circom 并不需要完全理解 ZK 背后的数学原理,但必须完全掌握某些核心原则,否则 Circom 将变得难以理解。
尽管如此,如果读者确实希望在 ZK 领域发展职业生涯,学习 ZK 的基础知识是必不可少的。为此,我们强烈建议通读 ZK book 的前两部分,并从零开始构建 Groth16 证明系统以巩固所学。
然而,如果读者的目标是快速了解 ZK 应用程序,那么我们建议先阅读上面列出的四章内容,然后再使用本资源。
Circom 为什么存在
Circom 的创建是为了解决为 SNARKs 开发约束系统时的两个主要问题。
- 手动设计约束系统既繁琐又容易出错,尤其是在处理大规模或重复性约束时。
- 填充见证(witness)同样具有挑战性,需要手动计算原本可以通过编程方式推导出的中间值。
因此,Circom 实现了:1) 简化约束设计;2) 自动填充见证。
1. 设计约束系统十分繁琐
手动设计一组(正确的)约束然后将它们转换为 R1CS 的任务不仅繁琐且容易出错。Circom 通过以编程方式生成约束,旨在让这项任务变得不再那么困难和枯燥。
例如,如果要表达值 x 只能取值 ,我们可以用以下约束来表示:
但是,R1CS 在每个约束中只能包含一次非恒定乘法(non-constant multiplication),因此我们必须将上述约束拆分为两个约束:
对于小型系统来说,这种手动转换尚可处理。但是,如果我们需要为 100 个甚至 1000 个变量创建这种约束,手工完成将非常令人抓狂。如果我们有数以千计的非常相似的约束,最好的办法是为约束创建一个“模板(template)”,并通过 for 循环生成这些约束。Circom 允许我们以编程的方式创建这些约束。
例如,假设我们想要将 1000 个变量约束为只能取值 。Circom 可以在循环中生成这些约束,如下所示:
template Constrain1000Example() {
signal input in[1000];
for (var i = 0; i < 1000; i++) {
0 === in[i] * (in[i] - 1);
}
}
component main = Constrain1000Example();
我们将在后面的章节中进一步解释该语法,但其核心思想是我们定义了一个约束 0 === in[i] * (in[i] - 1) 并将其重复了 1000 次。
2. 填充见证十分繁琐
在 ZK 的上下文中,见证(witness)是对变量的赋值,该赋值满足算术电路中的所有约束。
正如我们在有关算术电路的文章中所看到的那样,证明一个数字小于另一个数字需要将两个数字都转换为二进制,因为在有限域中数字会循环折返,“大于”是没有意义的。
将数字 表示为二进制(假设它可以用四位二进制表示),需要 满足以下约束:
在这里, 是最低有效位, 是最高有效位。证明者必须提供 (即 的二进制位),以及 本身。
在这种情况下,证明 是一个四位数字变得繁琐了五倍,因为除了 之外,我们还必须提供 的二进制值,尽管它们是可以以简单、确定性的方式推导出来的。Circom 使这一过程自动化,并允许我们编写代码以基于其他变量来填充见证中的变量。例如,为了填充这些二进制变量,我们可以编写以下 Circom 代码(以下代码缺乏一些必要的安全特性——请勿盲目复制):
b_0 <-- x & 1; // get the first bit of x via bitmask
b_1 <-- (x >> 1) & 1; // get the second bit of x
b_2 <-- (x >> 2) & 1; // get the third bit of x
b_3 <-- (x >> 3) & 1; // get the fourth bit of x
上面的代码 生成 了见证,但并未在我们的公式中创建约束:
将上述电路翻译成 Circom 如下(语法将在稍后进一步解释):
template BinaryConstraint() {
// assign the values to b_0,...,b_3
x === b_0 + 2*b_1 + 4*b_2 + 8*b_3;
0 === b_0*(b_0 - 1);
0 === b_1*(b_1 - 1);
0 === b_2*(b_2 - 1);
0 === b_3*(b_3 - 1);
}
Circom 的一大便利之处在于其代码类似于算术电路中的数学公式,因此将方程组转换为 Circom 非常容易。
其核心思路是,我们不再向电路提供 ,而是只提供 。Circom 会为我们计算这些二进制值,然后用计算得出的值来填充约束。
除了自动生成约束外,Circom 还通过其“赋值并约束(assign and constrain)”操作符 <== 改进了填充见证的过程。
Circom 中 <== 赋值并约束的优势
Circom 通过其“赋值并约束”操作符 <== 进一步简化了见证的填充。假设我们有以下约束:
z === x * y
如果我们提供了 x 和 y 的值,那么还必须提供 z 的值就会显得有些多此一举,因为 z 只有一个可能解。
在 Circom 中,我们按如下方式使用 <==:
z <== x * y
有了这种语法,变量 z 不再需要作为输入提供,因为 Circom 会替我们填充它,并且它的值在电路的其余部分中将被锁定为 。
因此,Circom 免除了用户为见证中的每一个元素显式提供值的麻烦,这也是体现 Circom 便利性的一大卖点。
Circom 既是 DSL,也是一种编程语言
使用 Circom 编程时最大的混淆来源在于,它既是一种编程语言(类似于 Javascript),又是一种编译为 R1CS 的领域特定语言(DSL)。从这个意义上说,它有点像 Solidity。Solidity 可以通过转移 Ether 来影响底层区块链状态,但它也可以表现得像普通的编程语言。Circom 的“编程语言”部分如前所述,是为了辅助自动填充见证。然而,对于新手来说,往往不清楚 Circom 的哪些部分会真正影响底层的 R1CS。
例如,以下是一段计算数字幂的有效 Circom 代码:
function power(base, exp) {
return base ** exp;
}
template Power() {
signal input base;
signal input exp;
signal output out;
out <-- power(base, exp);
}
component main = Power();
/* INPUT = {
"base": "3",
"exp": "2"
} */
然而,上面的代码并没有生成任何约束(所以它对于证明任何事情都没有用)。正如我们稍后将要了解的,<-- 操作符的唯一目的是生成见证,而不是生成约束。
为什么要学习 Circom
作为 ZK 领域最古老的领域特定语言(DSL)之一,Circom 拥有最丰富的可用库和项目供你学习,并且它是**经过实战检验(battle-tested)**的。
我们认为,如果我们先教授 Circom,那么学习更现代的 ZK DSL(例如 Halo2 和 Plonky3)将会容易得多,所以我们采取了这种方式。
想知道原因,可以看看在 Halo2 中计算斐波那契数列的代码 和在 Plonky3 中计算斐波那契的代码。只需粗略看一眼这些示例,读者就能确信对于初学者来说,这些 DSL 可能不是最好的起点。以下是用于证明 out 是正确的第 n 个斐波那契数的 Circom 代码。相比之下,它要容易理解得多:
pragma circom 2.1.6;
// proves `out` is the nth
// fibonnaci number
template Fibonacci(n) {
var offset = n + 1;
assert(n > 2);
signal fib[offset];
signal output out;
fib[0] <== 0;
fib[1] <== 1;
for (var i = 2; i < offset; i++) {
fib[i] <== fib[i-1] + fib[i - 2];
}
out <== fib[n];
}
// 5th fibonnaci number is 5
// 0 1 1 2 3 5
component main = Fibonacci(5);
相比之下,对于初涉 ZK 开发的初学者而言,Circom 的学习曲线相对平缓。
Noir、Cairo 和 Leo 难道没有把学习约束编写的必要性给抽象掉吗?
你可以使用类似 Rust 的语言(如 Noir、Cairo 和 Leo)在 ZK 区块链或二层网络(Layer 2)上编写智能合约,这些语言旨在对程序员“隐藏”约束的生成过程。如果你的目标仅仅是为这些区块链编写应用程序,那么深入了解 ZK 约束的底层工作原理并非绝对必要。
但是请想一想,每个严谨的 Solidity 程序员都对以太坊虚拟机(EVM)的工作原理有相当程度的掌握,并能编写基本的汇编代码。了解幕后发生的事情将帮助你编写出更高效的代码,而本资源正致力于实现这一目标。
此外,由于底层的 ZK 执行模型,这些执行环境中会暴露出许多 Bug。理解到底什么是隐私的、控制流可能存在哪些限制、使用域(fields)时的常见错误,或者获得在 Noir 中安全使用无约束函数(unconstrained functions)或在 o1js 中安全使用自定义约束的能力,全都需要对底层运作有深刻的理解。
本系列的目标
尽管如此,高级 ZK 语言并没有让约束编写成为过去式——实际上——它们反而增加了对真正理解其运作机制的专家的需求。本资源的目的是引导更多高级开发者和安全审计员入门,使他们能够开发并保护这些高级 ZK 语言所依赖的底层区块链、虚拟机和编译器环境。
本资源的结构
本资源分为两个主要部分:
-
第一部分教授 Circom 的语法。具体而言,我们将教授如何编写约束,以及如何编程以让 Circom 为我们填充大部分的见证值。
-
本资源的第二部分将全面介绍如何为一般性的 ZK 应用程序设计约束。我们将使用 Circom 作为示例,但这些内容同样适用于其他 ZK DSL(如 Halo2 或 Plonky3)。
我们还会在整个内容中穿插讨论 ZK 应用程序中的安全问题。
学习不仅在于钻研,更在于实践
许多章节包含了明确的练习或一些“留给读者作为练习”的未完成代码。如果你能解决这些问题,你的学习之旅将更加有效。我们设计这些问题是为了复习你刚刚阅读的内容并巩固学习效果。如果你正确理解了文中的内容,解决它们并不需要任何特殊的“洞察力”或“小聪明”。我们希望在阅读完材料后,你会觉得最后的练习有些“显而易见”(如果并非如此,请在练习的仓库中提交 Issue 或发起 Pull Request!)
安装 Circom
安装 Circom 的指南在此:https://docs.circom.io/getting-started/installation/#installing-dependencies
这里还有一个 Circom 在线 IDE:https://zkrepl.dev/
附录:Circom 的 Plonk 与 Groth16 对比
对于熟悉 Plonk 证明系统的读者,值得注意的是,无论是为 Plonk 证明系统还是 Groth16 证明系统,我们编写的电路都是相同的。
Groth16 允许每个约束进行无限次数的加法操作,但只能进行一次非恒定乘法(考虑到一阶约束系统每行只能有一次乘法)。相比之下,Plonk 每个约束只允许一次乘法或一次加法,二者不可兼得。随着我们深入探索 Circom,“每个约束一次乘法”的限制将会变得非常明显。
然而,与 Groth16 兼容的 Circom 电路同样适用于 Plonk。以一阶约束系统(Rank 1 Constraint Systems)作为输入的 snarkjs 库,可以根据开发者的需要将其转换为 Plonk 约束系统。
因此,Circom 对于预期的底层证明系统是 Groth16 还是 Plonk 是不可知(agnostic)的。只要电路与 Groth16 兼容,它就同样能与 Plonk 兼容,开发者无需进行任何额外的修改。
作者与致谢
Calnix 撰写了本书的第一部分,并对整体结构产生了深远的影响。请 在 X 上关注 Calnix 并向他表达感谢。
我们非常感谢 Veridise、Privacy Scaling Explorations、来自 zkSecurity 的 Marco Besier 以及 Chainlight 对本工作提供的宝贵审阅意见。