Circom 的主要目的是编译为一阶约束系统(R1CS),但其次要目的是填充 witness。
对于大多数电路而言,少数信号的值决定了其余信号的值。
例如,在下面的模板中,将 c 作为输入提供似乎有些多余,因为它的值完全依赖于 a 和 b:
template Mul() {
signal input a;
signal input b;
signal input c;
c === a * b;
}
接下来看一个更有启发性的例子。
拆分非二次约束
假设我们想为 a * b * c === d 创建一个 R1CS。由于 R1CS 允许每个约束只包含一次乘法,我们必须创建另一个信号 s 和一个额外的约束来拆分该乘法:
template Mul3() {
signal input a;
signal input b;
signal input c;
signal input d;
signal input s;
s === a * b;
d === s * c;
}
每次进行多次乘法时都提供额外的输入将极其繁琐,尤其是在包含大量乘法的大型电路中。此外,上述示例中 s 的值是完全由 a 和 b 决定(确定性依赖)的。
中间信号与赋值
为了避免提供 s 带来的麻烦,Circom 提供了 ==> 和 <== 运算符,将 s 的值赋给 Circom 来计算(请记住,Circom 的部分功能是生成 witness)。因此,不需要将 s 的值作为输入提供。==> 和 <== 运算符的(确切)含义是“赋值并约束”:
template Mul3() {
signal input a;
signal input b;
signal input c;
signal input d;
// no longer an input
signal s;
a * b ==> s;
s * c === d;
}
Circom 对箭头的方向很灵活,a * b ==> s 的含义与 s <== a * b 相同。
在上面的代码中,s 被称为中间信号(intermediate signal)。中间信号是指仅使用 signal 关键字而没有 input 关键字定义的信号。因此,signal s 是一个中间信号,而 signal input a 则不是。
上述两个模板底层的 R1CS 是完全相同的。==> 仅仅是省去了我们将 s 的值作为输入提供这一麻烦。
假设 witness 向量 表示为 [1, a, b, c, d, s],底层的 R1CS 如下所示:
这可以看作是将 witness [1, a, b, c, d, _] 传递给 Circom,然后 Circom 根据输入计算出完整的 witness [1, a, b, c, d, s]。
对 s 的赋值发生在 R1CS 之外。R1CS 仅检查 witness 向量 是否满足矩阵方程。R1CS 期望由外部提供 witness,而不会去计算其任何值。这种方法在保持 R1CS 结构不变的同时,简化了电路设计并减少了手动工作量。
无法使用 <== 重新分配信号值
信号代表 witness 向量中的一个具体条目。因此,一旦设置了值,就不能再更改。正因如此,以下代码将无法编译:
template CannotReassign() {
signal input a;
signal input b;
signal c;
c <== a * b;
// not allowed
// c already set
c <== a * a;
}
实际示例:检查数组的乘积
电路中的乘法越多,==> 运算符就越方便,因为它省去了提供额外输入信号的麻烦。
假设我们想强制输入信号 k 为数组 in[n] 中所有信号乘积的结果。换句话说,我们要检查:
这将引入大量的中间信号。为了保持代码整洁,我们可以将所有中间信号分配给一个单独的数组,如下所示:
template KProd(n) {
signal input in[n];
signal input k;
// intermediate signal array
signal s[n];
s[0] <== in[0];
for (var i = 1; i < n; i++) {
s[i] <== s[i - 1] * in[i];
}
k === s[n - 1];
}
基于上面的代码,s[n - 1] 保存了以下值
然后我们可以将其约束为等于 k。
将 Circom 拆分为模板
既然我们了解了 <== 运算符,就可以理解 Circom 如何使用模板使代码更加模块化了。
与我们的 Mul3 示例类似,假设我们有一个接收 3 个输入的电路,并强制它们的乘积等于第 4 个输入(此处重现了代码):
template Mul3() {
signal input a;
signal input b;
signal input c;
signal input d; // d === a * b * c
// no longer an input
signal s;
a * b ==> s;
s * c === d;
}
但假设我们必须对 8 个输入执行两次此操作。在这种情况下,可能会让人想将针对输入 (a,b,c,d) 和 (x,y,z,u) 的代码复制粘贴两次,但这会让代码变得很丑陋。
template Mul3x2() {
signal input a;
signal input b;
signal input c;
signal input d; // d === a * b * c
signal input x;
signal input y;
signal input z;
signal input u; // u === x * y * z
// ugly code here
}
相反,我们可以将 Mul3 放入一个单独的模板中,如下所示:
// separate template
template Mul3() {
signal input a;
signal input b;
signal input c;
signal input d; // d === a * b * c
// no longer an input
signal s;
a * b ==> s;
s * c === d;
}
// main component
template Mul3x2() {
signal input a;
signal input b;
signal input c;
signal input d; // d === a * b * c
signal input x;
signal input y;
signal input z;
signal input u; // u === x * y * z
component m3_1 = Mul3();
m3_1.a <== a;
m3_1.b <== b;
m3_1.c <== c;
m3_1.d <== d;
component m3_2 = Mul3();
m3_2.a <== x;
m3_2.b <== y;
m3_2.c <== z;
m3_2.d <== u;
}
需要注意的是:
- 我们使用
component m3_1 = Mul3();语法声明组件。这与我们声明主组件的语法相同。 - 我们使用
<==运算符“连接”信号。 - 上面的代码完全等同于将
Mul3的核心逻辑复制粘贴两次。
从模板传递结果返回
在某些情况下,如果子组件可以将“结果传递回”创建它的组件,那将非常方便。
例如,下面的 main 组件使用了一个子组件 Square 来赋值并约束 out 为 in 的平方。
template Square() {
signal input in;
signal output out;
out <== in * in;
}
template Main() {
signal input a;
signal input b;
signal input sumOfSquares;
component a2 = Square();
component b2 = Square();
a2.in <== a;
b2.in <== b;
// assert that a^2 + b^2 === sum of Squares
a2.out + b2.out === sumOfSquares;
}
component main = Main();
在子组件的上下文中,输出信号是一个预期通过 <== 运算符赋值的信号,可用于将值传递回创建它的组件。
在 main 组件的上下文中,输出信号的含义则完全不同——我们将在后面的章节中对此进行解释。
示例:二进制转数字
circomlib library 是一个包含各种常见操作的 Circom 模板库。其中一项操作是将二进制数组转换为信号。我们之前已经看到这可以通过 来实现。以下是在单独的组件中实现此功能的方法。下面的模板可以在 Circom 库的 bitify.circom 文件中找到:
template Bits2Num(n) {
signal input in[n];
signal output out;
// lc is short for "linear combination"
// it serves as an accumulator variable
var lc1=0;
var e2 = 1;
for (var i = 0; i<n; i++) {
lc1 += in[i] * e2;
e2 = e2 + e2; // could also be e2 *= 2;
}
lc1 ==> out;
}
我们不需要从库中复制粘贴代码——它可以被“包含”进来,这与其他语言导入其他文件的方式类似:
include "circomlib/bitify.circom";
template Main(n) {
signal input in[n];
signal input v;
// instantiate the Bits2Num component
component b2n = Bits2Num(n);
// loop over each binary value
// and assign and constrain it to the
// b2n input array
for (var i = 0; i < n; i++) {
b2n.in[i] <== in[i];
}
b2n.out === v;
}
component main = Main(4);
/* INPUT = {"in": [1, 0, 0, 1], "v": 9} */
上述组件可以在 zkrepl 中进行测试,但如果在本地运行,则需要根据目录配置方式设置导入路径。通常,Circomlib 是通过 yarn 或 npm installed 的。
单行组件示例
与其将输入信号分别赋给组件,不如将其作为参数提供。这被称为“anonymous component”。考虑以下示例:
template Mul() {
signal input in[2];
signal output out;
out <== in[0] * in[1];
}
template Example() {
signal input a;
signal input b;
signal output out;
// one line instantiation
out <== Mul()([a, b]);
}
component main = Example();
输出信号不应被忽略
输出信号必须是实例化它的组件中约束的一部分。如果输出信号处于“悬空”状态,那么在某些情况下,恶意的证明者可以为其分配任意值。有关此内容的更多信息,将在 hacking underconstrained circuits 中进行介绍。
总结
<==和==>让我们省去了在 input.json 中显式提供信号值的麻烦。- 当一个信号的值由另一个信号的值直接决定时,我们可以使用
<==或==>。 <==等同于==>。仅仅是参数反过来了,但效果相同。- 组件可以实例化其他子组件,并使用
<==或==>向其输入信号发送值。 - 子组件的
output信号应该被约束为等于实例化它的组件中的其他信号。