本文主要学习文章:Circom language tutorial with circomlib walkthrough。基本把大致的内容都学习了一遍并记录在本文。
上述文章基本地介绍了Circom语言和如何使用它,以及介绍常见的陷阱。还解释了circomlib库中的Comparators.circom的部分template。
安装circom和snarkjs
安装install circom: 官网 https://docs.circom.io/getting-started/installation/
安装snarkjs : npm install -g snarkjs@latest
我使用的是circom 2.1.6版本以及snarkjs@0.7.1。
简单的乘法电路a*b
构建一个简单的乘法电路
使用命令 circom multiply.circom --r1cs --sym 会打印如下信息
template instances: 1 //模版实例化个数
non-linear constraints: 1 //非线性约束
linear constraints: 0 //线性约束,目前所编译到的约束都是非线性约束,类似A*B=C
public inputs: 0 //公开输入个数
public outputs: 1 //公开输出个数
private inputs: 2 //私有输入个数
private outputs: 0 //私有输出个数
wires: 4 //导线
labels: 4 //标签 ,
// 导线和标签数量不知道跟什么有关,猜测是跟后面的witness.json中的元素个数是相等。
编译后会生成multiply.r1cs 和 multiply.sym文件。但是multiply.r1cs文件打不开,需要使用命令snarkjs r1cs print multiply.r1cs 来打印r1cs文件,最后的输出结果是
上述一长串的数字是相当于“-1”。因为在circom中的所有运算都在Zp中,其中p=21888242871839275222246405745257275088548364400416034343698204186575808495617
因此上述输出结果等价于 main.a * main.b = main.out
而 multiply.sym文件保留上述的变量名。multiply.sym文件内容为
1,1,0,main.out
2,2,0,main.a
3,3,0,main.b
非二次约束错误
有效的R1CS中的每个约束有且只有一个乘法。一个约束指的是R1CS中的每一行的等式。 如果一个约束有两个乘法就会报错。例如下图,试图使用两个乘法来构成一个约束就会报错。
如何实现上述两个乘法?加入一个中间信号,将两个乘法变成两个约束,而不是一个约束。我们重新生成对应circom文件的 r1cs文件并打印r1cs就会有如下输出:
等价于
a * b = s1 s1 * c = out
计算 witness
文件存放位置的关系
- 根据上面的输入,我们首先要制作一个input.json文件,文件内容: {"a": "2","b": "3","c": "5"}。在 input.json文件中的name : value中的name,对应电路中的 signal input name。
- 我们还需要generate_witness.js,multiply.wasm文件(由命令circom multiply.circom --wasm 生成一个multiply_js的目录,目录中有multiply.wasm, generate_witness.js和witness_calculator.js 共三个文件。注意生成的js文件是CommonJS文件)。
- 之后, 在multiply_js目录下执行命令: node generate_witness.js multiply.wasm input.json witness.wtns,会在multiply_js目录下生成witness.wtns文件。
- 我们可以使用命令 snarkjs wtns export json witness.wtns 来导出 witness 形成 witness.json文件。我们查看witness.json文件。里面的内容是["1","30","2","3","5","6"]。这就计算出来的witness,其中1是固定的,30是输出,2,3,5是输入,6是中间信号。因此wintness.json生成的形式如[1,out,a,b,c,s1]。
公开输入
如果我们想要让某些input是公开的。使用关键字public来让输入a,c是公开的。
数组
创建一个component用来计算输入的n次方。代码如下。注意下面例子中,template参数化为n. 但是r1cs系统必须是固定且不可变的。所以在最后一行中输入一个整数。
pragma circom 2.1.6;
template Powers(n) {
signal input a;
signal output powers[n];
powers[0] <== a;
for (var i = 1; i < n; i++) {
powers[i] <== powers[i - 1] * a;
}
}
component main = Powers(6);
Signal vs variable
信号是immutable,使用<--,<==,===操作符应用在Signal。使用=, ==, >=, <= !=, ++, -- 操作符应用在variable上面。
如果操作相反,则会出现右边错误。
=== vs <==
上述两个电路是相等的。 第一电路中先计算a*b然后赋值给c, 之后要求a*b是等于c(相等于添加了一个约束)。第二个电路实际上就是做先赋值后添加约束这种事情。
接下来,来看一下 === 在circuit中的用法。比如我们希望prover 提供部分的输入(i.e. 公共输入)。这时候就可以使用===符号。 Circom中可以不要求一个输出信号,即使全部都是私有输入信号也可以。但是这样的template无法向别人证明你知道某个东西。虽然语法满足,但是并没有任何实际意义。因此在写template时,还是要求至少有一个公共输入或者输出。
我们打印对应的r1cs文件,得到的结果是
[INFO] snarkJS: [ 21888242871839275222246405745257275088548364400416034343698204186575808495616main.a ] * [ main.b ] - [ 21888242871839275222246405745257275088548364400416034343698204186575808495616main.c ] = 0 等价于 a *b =c。
template调用和组合
template是可重用和可组合的,如下例所示. 这里,Square 是 SumOfSquares使用的template。 请注意输入 a 和 b 如何“连接”到组件 Square()。
pragma circom 2.1.6; template Square() { signal input in; signal output out; out <== in * in; } template SumOfSquares() { signal input a; signal input b; signal output out; component sq1 = Square(); component sq2 = Square(); // wiring the components together sq1.in <== a; //注意Square()中的输入名字是什么,这里对应输入名称也是什么。 sq2.in <== b; //例如Square()中的输入名为a,则这里改为sq1.a,sq2.a 。 out <== sq1.out + sq2.out; } component main = SumOfSquares();
您可以将 <== 运算符视为通过引用组件的输入或输出将组件“连接”在一起。
谨慎使用 <--
当遇到二次约束问题时,很容易使用 <-- 运算符来使编译器保持silence。 下面的代码可以编译,并且似乎完成了与我们之前的 power 示例相同的任务。
之前, 现在,
。但是,当我们编译后面一个电路时,结果是
只有一个约束,打印r1cs结果是[INFO] snarkJS: [ 21888242871839275222246405745257275088548364400416034343698204186575808495616main.powers[0] ] * [ main.powers[0] ] - [ 21888242871839275222246405745257275088548364400416034343698204186575808495616main.powers[1] ] = 0。
You cannot trust proofs that come out of a circuit like this!
约束不足是零知识应用程序中安全错误的主要来源,因此请仔细检查约束是否确实按照您期望的方式在 R1CS 中生成! 这就是为什么我们强调在学习 Circcom 语言之前,需要事先了解 Rank 1 约束系统,否则有一整类的 bug 你将很难发现!
Circomlib库中的comparators.circom文件中的template
当使用电路而不是自然代码描述算法时,要采用的思维方式是“先计算,然后约束”。 有些操作仅用纯粹的约束来建模是极其困难的。
Iden3 维护着一个名为 circomlib 的有用 circom 模板存储库。 我们已经有了足够的必要 Circcom 知识来开始研究这些模板,现在是介绍一个简单但有用的模板来演示 <-- 用法的好时机。
IsZero
如果输入信号为零,则 IsZero template 返回 1;如果输入信号非零,则返回 0。
如果您花一些时间思考如何仅使用乘法来测试数字是否为零,您会发现自己陷入困境; 这将是一个极其困难的问题。
template IsZero() { signal input in; signal output out; signal inv; inv <-- in!=0 ? 1/in : 0; out <== -in*inv +1; in*out === 0; //为什么最后一行要强制相等?这个约束确保了当in=0时,out=1;当in!=0时,out=0的情况 }
在上述例子中,inv是一个辅助输入信号。我们计算inv 要么是0,要么是in的逆元。最后将计算结果赋值给inv。然后作为约束的一部分强制 inv 正确。但是为什么要让 inv 成为信号而不是变量呢? 如果我们将 inv 设置为 var 并将 <-- 替换为 =,我们将得到非二次约束错误,因为我们将信号 in 与变量相乘,该变量是从一些非平凡的计算中得出的 - 这肯定涉及一次以上的乘法。
in: {0, non-zero}
inv: {0, in^{-1}}
out: {1,0}
只有当 in 为 0 时将 out 设置为 1,而当 in 非零时将 out 设置为 0,才能满足约束。 这说明了“先计算,后约束”的模式。
在上面的代码中,out 被限制为等于 -in*inv + 1。但是,最后一行并未将零赋值给 in*out。 而是强制 in*out 等于 0。
使用 IsEqual template 而不使用 ===
当比较相等的组件时,您可以使用 IsEqual 电路模版,该模版将输入in[1],in[0]相减 之后将计算结果输入到 IsZero component 中。 如果输入相等,则IsZero输出结果为0,如果输入不等,则IsZero输出结果输出为1。
template IsEqual() { signal input in[2]; signal output out; component isz = IsZero(); in[1] - in[0] ==> isz.in; isz.out ==> out; }
### no constraints ###
template FactorOfFiveFootgun() { signal input in; signal output out; out <== in * 5; } component main = FactorOfFiveFootgun();
上述template功能主要实现是Prover知道 x,使得 5*x == out,其中 out 是public。 但是如果我们编译template,我们会发现实际上没有创建任何约束!### no constraints ### 。 因为只有形如A*B+C=0 的等式才能产生约束,其中A,B,C是任意信号的线性组合。具体可以查看官方给的约束产生的规则:Constraint Generation - Circom 2 Documentation 。
解决方案是连接 IsEqual 模板以强制相等。
计算一组输入信号的的平均值
假设我们想要计算一组信号的平均值。 在一般的编程语言中执行此项操作是容易,但是仅使用二次约束来执行是较难的。
解决方案是使用常规编程计算平均值,然后限制输出正确。n 个信号的平均值是它们的总和除以信号数量。 有限域中的除法与乘以倒数相同,因此我们需要将信号相加,然后乘以数组长度的倒数.
以下看起来正确,然而却没有实现预期的效果。
pragma circom 2.1.6;
include "node_modules/circomlib/circuits/comparators.circom";
template AverageWrong(n) {
signal input in[n];
signal denominator_inv;
signal output out;
// n个输入和
var sum;
for (var i = 0; i < n; i++) {
sum += in[i];
}
denominator_inv <-- 1 / n; // 求n的倒数
1 === denominator_inv *n;
out <== sum * denominator_inv;
}
component main = AverageWrong(5);
上述编译后的结果是。也就是并没有产生任何约束,这是因为这是因为n,sum,denominator_inv都不是signal。
pragma circom 2.1.6;
include "node_modules/circomlib/circuits/comparators.circom";
template Average(n) {
signal input in[n];
signal denominator_inv;
signal output out;
var sum;
for (var i = 0; i < n; i++) {
sum += in[i];
}
denominator_inv <-- 1 / n;
component eq = IsEqual();
eq.in[0] <== 1;
eq.in[1] <== denominator_inv * n;
out <== sum * denominator_inv;
}
component main = Average(5);
上述方法会正确的生成约束。
查询是一个人年龄否大于21岁的template
template IsOver21() { signal input age; signal output oldEnough; if (age >= 21) { oldEnough <== 1; } else { oldEnough <== 0; } } 如果编译上述代码,则会发生错误。 这是编写 circom 代码时容易犯的另一个错误:信号不能作为 if 语句的输入或for循环的输入。
那么我们如何查询一个人是否已经超过21岁呢?
这实际上比看起来更难,因为比较有限域中的数字相当棘手。
在 Circcom 中比较数字的陷阱 如果 1 - 2 = 21888242871839275222246405745257275088548364400416034343698204186575808495616,那么这个巨大的数字实际上是大于 1 还是小于 1? 您无法比较有限域中的数字,因为说一个数字大于另一个数字是没有意义的。 然而,我们仍然希望能够比较数字!
在进行任何比较之前,第一个要求是它们必须小于字段大小(例如,8位数的大小),并且我们会将字段中的任何数字视为正整数,并小心防止下溢和溢出。 只要当我们强制数字保持在字段顺序内,那么我们对它们才能进行有意义的比较。 不可能将 > 运算符转换为一组二次约束。 但是,如果我们将字段元素转换为数字的二进制表示形式,则可以进行比较。 现在我们可以再引入两个 circomlib 库中的comparators.circom文件中的模版:Num2Bits 和 LessThan,其动机是比较整数。
Num2Bits
我们如何在不使用普通 < 和 > 运算符且仅使用一种二进制转换的情况下比较两个 9,999 或更小的数字? 如果我们允许进行两次二进制转换,这很容易,但它会导致更大的电路。
这是一个棘手的问题,但 circomlib 是如何做到这一点的。 假设我们正在比较 x 和 y。 由于我们的输入可以采用 9,999 个最大值,因此我们将 10,000 加到 x 上,并从 x 和 y 的总和中减去 y.
z = (10,000 + x) - y
无论如何,即使 y 是 9,999 并且 x 是 0,z 也将是正数。由于我们在这里处理字段元素,因此我们不希望发生任何下溢,并且在这种情况下,不能发生下溢。这是关键部分。 如果 x ≥ y,则 z 将在 10,000 到 19,999 之间,如果 x < y,则 z 将在 [0-9,999] 范围内。要知道一个数字是否在[10,000-19,999]范围内,我们只需要查看第10,000位的数字,即1x,xxx。 如果第1000位的数字是1,那么我们知道 z 在 [10,000-19,999] 范围内;否则第1000位的数字是0,则z在[0,9999]之间。
Circcom 只是以二进制而不是十进制表示形式做同样的事情。 以下 Circcomlib Num2Bits 模板显示了 circom 如何将信号转换为保存二进制表示形式的信号数组。
template Num2Bits(n) {
//将in进行位比特分解
signal input in;
signal output out[n];
var lc1=0;
// this serves as an accumulator to "recompute" in bit-by-bit
var e2=1;
for (var i = 0; i<n; i++) {
out[i] <-- (in >> i) & 1;
//in>>i 右移符号,将in的二进制位右移i位。之后再跟1进行 AND运算。
out[i] * (out[i] -1 ) === 0; // 强制out[i]等于1或者等于0
lc1 += out[i] * e2; //累加如果out[i]等于1,再累加的结果赋值给lc1。
e2 = e2+e2; // e2= 1,2,4,8,16,32,64,128,256,512,1024,2028.....
}
lc1 === in;
}
LessThan
现在我们已经掌握了 Num2Bits,理解 LessThan 模板应该很简单.
template LessThan(n) {
assert(n <= 252);
signal input in[2];
signal output out;
component n2b = Num2Bits(n+1);
n2b.in <== in[0] + (1<<n) - in[1];
out <== 1-n2b.out[n];
}
输出1 表示 in[0] < in[1]
输出0 表示 in[0] > in[1]
考虑n=8, in[0]=21,in[1]=32
1<<8 的结果是256
n2b会将输入值看做9个比特的数,然后分解输出其二进制的位数。
21+256-32= 256-11 = 245,最高位是0。因此最后的out是1,表示in[1]>in[0]
如果 in[0]=32,in[1]=21
则 32+256-21= 256+11 = 267,最高位是1。因此最后的out是0,表示in[1]<in[0]
最后查询年龄是否大于21岁的 template
pragma circom 2.1.6;
include "node_modules/circomlib/circuits/comparators.circom";
template Over21() {
signal input age;
signal input ageLimit;
signal output oldEnough;
// 8 bits is plenty to store age
component gt = GreaterThan(8);
gt.in[0] <== age;
gt.in[1] <== 21;
oldEnough <== gt.out;
}
component main = Over21();
ForceEqualIfEnabled template
template ForceEqualIfEnabled() { signal input enabled; signal input in[2]; component isz = IsZero(); in[1] - in[0] ==> isz.in; (1 - isz.out)*enabled === 0; }
ForceEqualIfEnabled 模板的行为类似于断言语句,它包含一个约束,即如果启用标志非零,则输入必须相等。 与其根据相等性返回 true 或 false(此处没有输出信号返回 0 或 1),否则如果输入不相等,则会导致电路无法满足。
Boolean operators
如果将 a 和 b 约束为 0 和 1,则可以使用乘法实现相同的效果
template And() { signal input in[2]; signal output c; // force inputs to be zero or one in[0] === in[0] * in[0]; in[1] === in[1] * in[1]; // c will be 1 iff in[0] and in[1] are 1 c <== in[0] * in[1]; }
对于读者来说,思考如何完成其他布尔运算(not、or、nand、nor、xor 和 xnor)是一个有用的练习。 尽管每个布尔门都可以由与非门构建,但这将使电路变得比所需的更大,因此不要过度重复使用门。 解决方案位于 circomlib 的 gates.circom 文件中。 请注意,它们并不将输入限制为 0 或 1,这取决于电路设计人员的选择。