简介

Circom是一种新颖的领域特定语言,用于定义可用于生成零知识证明的算术电路。Circom compiler是一个用 Rust 编写的 circom 语言编译器,可用于生成带有一组关联约束的 R1CS 文件和一个程序(用 C++ 或 WebAssembly 编写),以有效计算对电路所有线路的有效分配。它的主要特点之一circom是它的模块化,它允许程序员定义称为模板的可参数化电路,可以将其实例化以形成更大的电路。利用小型独立组件构建电路的想法使得测试、审查、审计或正式验证大型复杂circom电路变得更加容易。在这方面,circom用户可以创建自己的自定义模板或从circomLib实例化模板,这是一个公开可用的库,带有数百个电路,例如比较器、哈希函数、数字签名、二进制和十进制转换器等等。Circcomlib 向从业者和开发人员公开提供。

证明系统的实现也可以在我们的库中找到,包括用 Javascript 和 Pure Web Assembly 编写的snarkjs、用本机 Web Assembly 编写的wasmsnark 、用 C++ 和 Intel Assembly 编写的rapidSnark 。

Circcom 旨在为开发人员提供一个整体框架,通过易于使用的接口构建算术电路,并抽象化证明机制的复杂性。

信号和变量

信号和变量

使用circom构建的算术电路对包含Z/pZ域中的信号进行操作。信号可以使用标识符命名,也可以存储在数组中并使用关键字signal进行声明。信号可以定义为输入或输出,否则被视为中间信号。

#![allow(unused)]
fn main() {
signal input in;
signal output out[N];
signal inter;
}

这个小例子声明了一个带有标识符的输入信号in、一个带有标识符的输出信号的N维数组out以及一个带有标识符的中间信号inter。

信号始终被认为是私有的。只有在定义主要组件时,通过提供公共输入信号列表,程序员才能区分公共信号和私有信号。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template Multiplier2(){
   //Declaration of signals
   signal input in1;
   signal input in2;
   signal output out;
   out <== in1 * in2;
}

component main {public [in1,in2]} = Multiplier2();
}

从circom2.0.4开始,还允许在声明后立即初始化中间和输出信号。那么,前面的例子可以重写如下:

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template Multiplier2(){
   //Declaration of signals
   signal input in1;
   signal input in2;
   signal output out <== in1 * in2;
}

component main {public [in1,in2]} = Multiplier2();
}

此示例将主要组件的输入信号in1和声明为公共信号。in2

在这种情况下,主组件的所有输出信号都是公共的(并且不能设为私有),如果没有另外声明,则主组件的输入信号是私有的,使用上面的关键字public。其余信号都是私有的,不能公开。

因此,从程序员的角度来看,只有公共输入和输出信号从电路外部可见,因此不能访问中间信号。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template A(){
   signal input in;
   signal outA; //We do not declare it as output.
   outA <== in;
}

template B(){
   //Declaration of signals
   signal output out;
   component comp = A();
   out <== comp.outA;
}

component main = B();
}

此代码会产生编译错误,因为signal outA未声明为输出信号,因此无法访问它并将其分配给signal out。

信号是不可变的,这意味着一旦为它们分配了值,该值就不能再更改。因此,如果一个信号被分配两次,就会产生编译错误。这可以在下一个示例中看到,其中信号out被分配两次,从而产生编译错误。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template A(){
   signal input in;
   signal output outA; 
   outA <== in;
}

template B(){
   //Declaration of signals
   signal output out;
   out <== 0;
   component comp = A();
   comp.in <== 0;
   out <== comp.outA;
}

component main = B();
}

在编译时,信号的内容始终被视为未知(请参阅Unknowns),即使已经为其分配了常量。这样做的原因是为了提供一个精确的定义哪些结构是允许的,哪些结构是不允许的,而不依赖于编译器检测信号是否始终具有常量值的能力。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template A(){
   signal input in;
   signal output outA; 
   var i = 0; var out = 0;
   while (i < in){
    out++; i++;
   }
   outA <== out;
}

template B(){
 component a = A();
 a.in <== 3;
}

component main = B();
}

此示例会产生编译错误,因为signal的值outA取决于signal的值in,尽管该值是常量3。

信号只能使用 <-- 或 <==(见基本运算符)进行赋值,信号位于左侧;使用 --> 或 ==>(见基本运算符)进行赋值,信号位于右侧。安全的选项是 <== 和 ==>,因为它们在赋值的同时也会产生约束。一般来说,使用 <-- 和 --> 是危险的,只有在赋值表达式不能包含在约束中时才能使用,例如下面的例子。

#![allow(unused)]
fn main() {
out[k] <-- (in >> k) & 1;
}

变量和可变性

变量是保存非信号数据且可变的标识符。变量使用关键字var声明,如下所示:

#![allow(unused)]
fn main() {
var x;
}

当它们用于构建约束时,它们保存字段的数值或算术表达式(请参阅约束生成)。它们可以使用变量标识符来命名,也可以存储在数组中。

变量赋值是使用=。声明还可以包括初始化,如以下示例所示:

#![allow(unused)]
fn main() {
var x;
x = 234556;
var y = 0;
var z[3] = [1,2,3];
}

赋值是一个语句,不返回任何值,因此它不能成为表达式的一部分,这避免了误导性使用=。任何在表达式内部的使用=都会导致编译错误。

下面的两个例子会导致编译错误:

#![allow(unused)]
fn main() {
a = (b = 3) + 2;

}
#![allow(unused)]
fn main() {
var x;
if (x = 3) {
   var y = 0;
}
}

模版

模板和组件

模板

在 Circcom 中创建通用电路的机制就是所谓的模板。

它们通常是在使用模板时必须实例化的某些值的参数。模板的实例化是一个新的电路对象,它可以用来组成其他电路,从而作为更大电路的一部分。由于模板通过实例化来定义电路,因此它们有自己的信号。

#![allow(unused)]
fn main() {
template tempid ( param_1, ... , param_n ) {
 signal input a;
 signal output b;

 .....

}
}

模板不能包含本地函数或模板定义。

在定义该值的同一模板内为输入信号赋值也会生成错误“Exception caused by invalid assignment”,如下一个示例所示。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template wrong (N) {
 signal input a;
 signal output b;
 a <== N;
}

component main = wrong(1);

}

模板的实例化是使用关键字组件并提供必要的参数来完成的。

#![allow(unused)]
fn main() {
component c = tempid(v1,...,vn);
}

参数的值在编译时应该是已知的常量。下一个代码会生成以下编译错误消息:“Every component instantiation must be resolved during the constraint generation phase”。

#![allow(unused)]

fn main() {
pragma circom 2.0.0;

template A(N1,N2){
   signal input in;
   signal output out; 
   out <== N1 * in * N2;
}


template wrong (N) {
 signal input a;
 signal output b;
 component c = A(a,N); 
}

component main {public [a]} = wrong(1);
}

成分

一个组件定义了一个算术电路,因此它接收 N 个输入信号并产生 M 个输出信号和 K 个中间信号。此外,它还可以产生一组约束。

为了访问组件的输入或输出信号,我们将使用点表示法。组件外部看不到其他信号。

#![allow(unused)]
fn main() {
c.a <== y*z-1;
var x;
x = c.b;
}

在所有输入信号都分配给具体值之前,不会触发组件实例化。因此,实例化可能会被延迟,因此组件创建指令并不意味着组件对象的执行,而是意味着当所有输入都设置好后将完成实例化过程的创建。仅当设置了所有输入时才能使用组件的输出信号,否则会生成编译器错误。例如,下面的代码会导致错误:

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template Internal() {
   signal input in[2];
   signal output out;
   out <== in[0]*in[1];
}

template Main() {
   signal input in[2];
   signal output out;
   component c = Internal ();
   c.in[0] <== in[0];
   c.out ==> out;  // c.in[1] is not assigned yet
   c.in[1] <== in[1];  // this line should be placed before calling c.out
}

component main = Main();
}

组件是不可变的(如信号)。可以首先声明组件,然后在第二步中初始化组件。如果有多个初始化指令(在不同的执行路径中),它们都需要是同一模板的实例化(可能具有不同的参数值)。

#![allow(unused)]
fn main() {
template A(N){
   signal input in;
   signal output out;
   out <== in;
}

template C(N){
   signal output out;
   out <== N;
}
template B(N){
  signal output out;
  component a;
  if(N > 0){
     a = A(N);
  }
  else{
     a = A(0);
  }
}

component main = B(1);
}

如果将指令a = a(0);替换为a = C(0),则编译失败,并显示下一条错误消息:“Assignee and assigned types do not match”。

我们可以按照之前给出的大小限制来定义组件数组。此外,不允许在组件数组的定义中进行初始化,并且只能逐个组件进行实例化,访问数组的位置。数组中的所有组件都必须是同一模板的实例,如下一个示例所示。

#![allow(unused)]
fn main() {
template MultiAND(n) {
    signal input in[n];
    signal output out;
    component and;
    component ands[2];
    var i;
    if (n==1) {
        out <== in[0];
    } else if (n==2) {
          and = AND();
        and.a <== in[0];
        and.b <== in[1];
        out <== and.out;
    } else {
        and = AND();
        var n1 = n\2;
        var n2 = n-n\2;
        ands[0] = MultiAND(n1);
        ands[1] = MultiAND(n2);
        for (i=0; i<n1; i++) ands[0].in[i] <== in[i];
        for (i=0; i<n2; i++) ands[1].in[i] <== in[n1+i];
        and.a <== ands[0].out;
        and.b <== ands[1].out;
        out <== and.out;
    }
}
}

当组件是独立的(输入不依赖于彼此的输出)时,这些部分的计算可以使用标签并行完成parallel,如下一行所示。

#![allow(unused)]
fn main() {
template parallel NameTemplate(...){...}
}

如果使用此标签,生成的 C++ 文件将包含计算见证的并行代码。在处理大型电路时,并行化变得尤为重要。

请注意,前面的并行性是在模板级别声明的。有时,声明每个组件的并行性可能很有用。从版本 2.0.8 开始,并行标签也可以在组件级别使用,并行标签在调用模板之前指示。

#![allow(unused)]
fn main() {
component comp = parallel NameTemplate(...){...}
}

用例的一个真实示例是汇总代码中的以下代码:

#![allow(unused)]
fn main() {
component rollupTx[nTx];
for (i = 0; i < nTx; i++) {
        rollupTx[i] = parallel RollupTx(nLevels, maxFeeTx);
}
}

需要再次强调的是,这种并行性只能在 C++ 见证生成器中利用。

自定义模板

从2.0.6版本开始,该语言允许定义一种新类型的模板,即自定义模板。这种新结构的工作方式与标准模板类似:它们的声明方式类似,只需在;custom之后的声明中添加关键字即可。template并以完全相同的方式实例化。即Example定义一个自定义模板,然后实例化如下:

#![allow(unused)]
fn main() {
pragma circom 2.0.6; // note that custom templates are only allowed since version 2.0.6
pragma custom_templates;

template custom Example() {
   // custom template's code
}

template UsingExample() {
   component example = Example(); // instantiation of the custom template
}
}

然而,它们的计算编码方式与标准模板的编码方式不同。snarkjs将在稍后阶段处理每个定义的自定义模板的使用,以生成和验证 zk 证明,而不是生成 r1cs 约束,在本例中使用 PLONK 方案(并使用自定义模板的定义作为 PLONK 的自定义门,请参阅这里如何)。有关自定义模板的定义和用法的信息将导出到文件中.r1cs(请参阅此处的第 4 节和第 5 节)。这意味着自定义模板不能在其主体内引入任何约束,也不能声明任何子组件。

编译指示

版本编译指示

所有具有 .circom 扩展名的文件都应以pragma指定编译器版本的第一条指令开头,如下所示:

#![allow(unused)]
fn main() {
pragma circom xx.yy.zz;
}

这是为了确保电路与指令后指示的编译器版本兼容pragma。否则,编译器会抛出警告。

如果文件不包含此指令,则假定该代码与最新编译器版本兼容,并抛出警告。

自定义版本编译指示

从 circom 2.0.6 开始,该语言允许定义自定义模板(有关更多信息,请参阅此)。此编译指示允许 circom 程序员轻松判断它是否使用自定义模板:如果任何声明自定义模板的文件或包含声明任何自定义模板的文件不使用此编译指示,编译器将产生错误。此外,它还会通知程序员哪些文件应包含此编译指示。

要使用它,只需在需要在.circom文件的开头(以及版本编译指示之后)添加以下指令:

#![allow(unused)]
fn main() {
pragma custom_templates;
}

函数

在circom中,函数定义了通用的抽象代码片段,可以执行一些计算以获得要返回的值或表达式。

#![allow(unused)]
fn main() {
function funid ( param1, ... , paramn ) {

 .....

 return x;
}
}

函数计算数值(或数组)值或表达式。函数可以是递归的。考虑circom 库中的下一个函数。

#![allow(unused)]
fn main() {
/*
    This function calculates the number of extra bits 
    in the output to do the full sum.
 */

function nbits(a) {
    var n = 1;
    var r = 0;
    while (n-1<a) {
        r++;
        n *= 2;
    }
    return r;
}
}

函数不能声明信号或生成约束(如果需要,请使用模板)。下面这个个函数会生成错误消息:“Template operator found”。

#![allow(unused)]
fn main() {
function nbits(a) {
    signal input in; //This is not allowed.
    var n = 1;
    var r = 0;
    while (n-1<a) {
        r++;
        n *= 2;
    }
    r === a; //This is also not allowed.
    return r;
}
}

通常,可以有许多返回语句,但是每个执行跟踪必须以返回语句结束(否则将产生编译错误)。return语句的执行将控制返回给函数的调用者。

#![allow(unused)]
fn main() {
function example(N){
     if(N >= 0){ return 1;}
//   else{ return 0;}
}
}

编译example函数会产生下一条错误消息:“In example there are paths without return”。

导入

与其他代码一样,模板可以在其他文件(例如库)中找到。为了使用其他文件中的代码,我们必须使用关键字 include 以及相应的文件名(默认扩展名为 .circom)将它们包含在我们的程序中。

#![allow(unused)]
fn main() {
include "montgomery.circom";
include "mux3.circom";
include "babyjub.circom";
}

这段代码包含了circom库中的三个文件:montgomery.circom、mux3.circom、babyjub.circom。

从 circom 2.0.8 开始,可以使用-l命令选项来指示搜索要包含的文件的路径。

main入口

为了开始执行,必须给出一个初始组件。默认情况下,该组件的名称是“main”,因此需要使用某个模板实例化组件main。

这是创建电路所需的特殊初始组件,它定义了电路的全局输入和输出信号。因此,与其他组件相比,它有一个特殊的属性:公共输入信号列表。创建main组件的语法是:

#![allow(unused)]
fn main() {
component main {public [signal_list]} = tempid(v1,...,vn);
}

其中{public [signal_list]}是可选的。未包含在列表中的模板的任何输入信号都被视为私有。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template A(){
    signal input in1;
    signal input in2;
    signal output out;
    out <== in1 * in2;
}

component main {public [in1]}= A();
}

在此示例中,我们有两个输入信号in1和in2。让我们注意,它in1已被声明为电路的公共信号,而由于in2它没有出现在列表中,因此被视为私有信号。最后,输出信号始终被视为公共信号。

不仅在正在编译的文件中,而且在程序中包含的任何其他 circom文件中,只能定义一个main组件,否则,编译失败并显示一条消息:“Multiple main components in the project structure”

语法

注释

在 circom 中,您可以在源代码中添加注释。这些注释行将被编译器忽略。注释可以帮助程序员阅读源代码以更好地理解它。强烈推荐向代码添加注释。

circom 2.0 中允许的注释行与其他编程语言(如 C 或 C++)类似。

单行注释

您可以使用以下命令在单行上编写注释:

#![allow(unused)]
fn main() {
//Using this, we can comment a line.
}

您还可以使用以下命令在代码行末尾编写注释:

#![allow(unused)]
fn main() {
template example(){
    signal input in;   //This is an input signal.
    signal output out; //This is an output signal.
}
}

多行注释

您可以使用和编写跨多行的注释:

#![allow(unused)]
fn main() {
/*
All these lines will be 
ignored by the compiler.
*/
}

标识符

任何以任意数量的“_ ”开头,后跟 ASCII 字母字符,再后跟任意数量的字母或数字字符、“_”或“$”的非保留关键字都可以用作标识符。标识符的示例如下:

#![allow(unused)]
fn main() {
signal input _in; 
var o_u_t;
var o$o;
}

保留关键字

保留关键字列表如下:

  • signal:声明一个新信号。
  • input:将信号声明为输入。
  • output:将信号声明为输出。
  • public:将信号声明为公共。
  • template:定义一个新电路。
  • component:实例化模板。
  • var:声明一个新的整型变量。
  • function:定义一个新函数。
  • return:函数返回。
  • if:根据条件表达式的结果进行分支。
  • else:if控制流程的守护。
  • for:根据表达式的结果有条件地循环。
  • while:根据表达式的结果有条件地循环。
  • do:根据表达式的结果有条件地循环。
  • assert:检查构造时的条件。
  • include:包含指定文件的代码。
  • parallel:使用并行组件或模板生成 C 代码。
  • pragma circom:检查编译器版本的指令。
  • pragma custom_templates:指示自定义模板的使用的指令。

运算符

约束生成

控制流程

类型

作用域

Circcom 与 C 和 Rust 一样具有静态作用域。然而,我们认为信号和组件必须具有全局作用域,因此它们应该在定义它们模板的顶级块中。

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template Multiplier2 (N) {
   //Declaration of signals.
   signal input in;
   signal output out;

   //Statements.
   out <== in;
   signal input x;
   if(N > 0){
    signal output out2;
    out2 <== x;
   }
}

component main = Multiplier2(5);
}

信号out2必须在顶级块中声明。产生一个编译错误:“out2 is outside the initial scope”。

从 circom 2.1.5 开始,信号和组件现在可以在if块内声明,但前提是在编译时是已知条件。

#![allow(unused)]
fn main() {
pragma circom 2.1.5;
template A(n){
   signal input in;
   signal output outA;
   var i = 0;
   if(i < n){
    signal out <== 2;
    i = out;
   } 
   outA <== i;
}
component main = A(5);
}

在前面的示例中,i < n条件在编译时已知,然后允许声明out信号。如果条件 in < n,在编译时是未知的,我们会输出错误,因为这种情况下的声明是不允许的。

关于可见性,组件c的信号x在声明了c的模板t中也是可见的,使用符号c.x,不允许访问嵌套子组件的信号。例如,如果c是使用另一个组件d构建的,则不能从t访问d的信号。这可以在下面的代码中看到:

#![allow(unused)]
fn main() {
pragma circom 2.0.0;

template d(){
  signal output x;
  x <== 1;
}

template c(){
  signal output out2;
  out2 <== 2;
  component comp2 = d();
}

template t(){
  signal out;
  component c3 = c();
  out <== c3.comp2.x;
}

component main = t();
}

此代码会产生编译错误,因为我们无法访问组件c3的comp2。

var可以在任何块中定义,并且它的可见性会降低到像 C 或 Rust 中那样的块。

Circom2.1.0新特性

元组和匿名组件

标签

代码质量

断言

assert(bool_expression)

该声明介绍了要检查的条件。在这里,我们根据bool_expression在编译时是否未知来区分两种情况:

  • 如果断言语句依赖于仅具有已知条件的控制流(请参阅Unknowns)并且bool_expression已知(例如,如果它仅依赖于模板参数或字段常量的值),则在编译时评估断言。如果评估结果为 false,则编译失败。考虑下一段代码:
#![allow(unused)]
fn main() {
template A(n) {
  signal input in;
  assert(n>0);
  in * in === n;
}

component main = A(0);

}

这里,可以在编译期间评估断言,并且评估结果为假。因此,编译结束时会抛出错误error[T3001]: False assertreach。如果主要组件被定义为component main = A(2),则编译正确完成。

  • 否则,编译器会在最终见证生成代码中添加一个断言,该断言必须在见证生成期间得到满足。在下面的示例中,如果in作为参数传递的用于生成见证的输入不满足断言,则不会生成见证。
#![allow(unused)]
fn main() {
template Translate(n) {
  signal input in;  
  assert(in<=254);
  . . .
}
}

试想,当使用 === 引入类似 in * in === n; 这样的约束时,在见证生成代码中会自动添加一个 assert。在这种情况下类似于,assert(in * in == n)。

调试

检查

Contributors

Here is a list of the contributors who have helped improving mdBook. Big shout-out to them!

If you feel you're missing from this list, feel free to add yourself in a PR.