零知识证明编码入门
零知识证明正日益流行,但对于开发者来说,入门并寻找相关资源可能并非易事。经过一段时间的研究,我在此整理了一些经验,希望能对大家有所帮助。请注意,这是一篇面向初学者的指南,因为作者(我!)本人也是零知识证明编程的新手,如果您发现本文有任何不妥之处,请随时联系我们!
本文将介绍零知识证明的概念、从开发者的角度如何使用它,并介绍几种编写零知识证明的语言。本文不会深入探讨其背后的数学原理:我们将零知识证明视为一个密码学黑匣子。
什么是零知识证明?
零知识证明(简称 ZKP)是一种关于事实的加密证明,它不会泄露有关该事实的任何信息。
引入零知识证明 (ZKP) 的一个经典示例是,我(证明者)可以向你(验证者)证明我知道一个图的有效着色方案,而无需透露其含义。为此,我们玩一个游戏:我用一组随机颜色为图着色,然后覆盖它,你随机选择一条边。如果我的着色有效,那么该边连接的两个节点必须具有不同的颜色。然后我们重复此过程,每次使用不同的颜色,直到你满意为止。由于我在每一轮之前都会重新绘制图,因此你不会了解任何有关着色的信息,但你可以每次验证它是否有效。
另一个可以被视为 ZKP 的众所周知的例子是数字签名:我可以通过签署一条消息来证明我持有与公共字符串(我的公钥)相对应的秘密字符串(我的私钥),并且该签名不会泄露我的私钥中的任何位。
什么是zkSNARK?
SNARK 代表简洁的非交互式知识论证。这里重要的是前两个词:简洁和非交互式。
如果一个证明足够简洁,那么它就是简洁的。这意味着,无论我们要证明的事实有多复杂,我们都可以保持证明的简短和快速验证。这正是 ZKP 对于构建 rollups有吸引力的原因:你可以生成一个简洁的证明,证明在 L2 链上发生的所有操作都是有效的,简洁到可以在 L1 链上的合约中验证它。像Mina这样的协议通过递归证明将这一点发挥到了极致,这使得它们能够用一个大小恒定的证明来证明整条链历史的有效性。
至于非交互性,试想一下,如果每次有人需要验证签名时都需要签名者在线,处理签名会多么繁琐。在上面的例子中,图着色证明是交互式的,需要证明者和验证者之间进行在线博弈。幸运的是,zkSNARKs 没有这个要求。
ZKPs 的特色
事实证明,zkSNARK 并非唯一有趣的零知识证明 (ZKP)。此外,还有STARK (由Starkware首创,用于支持Starknet)和Bulletproofs(用于门罗币)。它们的区别在于生成和验证证明的运行时间,以及最终证明的大小,或者对可信设置的需求(下一节将详细介绍)。MatterLabs在这里对它们进行了详细的比较。
即使在 SNARK 中,你也会发现它有多种版本。Groth16是最受欢迎的版本之一。PLONK系列(包括TurboPLONK和UltraPLONK )可以消除对特定电路可信设置的需要,但会牺牲证明生成和验证时间,或者增加对新类型操作的支持。
此外,还有多种椭圆曲线或多项式承诺方案可供选择。Consensys Gnark提供了关于 SNARK 证明方案和曲线的精彩解释,请点击此处。
通常,选择一种 ZKP 语言将决定您最终使用哪种 ZK 后端,尽管有些像 Circom 或 Gnark 这样的语言支持多个证明系统。
受信任的设置
某些类型的证明系统需要所谓的“可信设置”。可信设置是由多个参与者运行的一次性仪式,用于生成一组随机值,为零知识证明系统提供动力。
这些仪式至关重要,因为如果所有参与者串通一气,他们就有可能破坏证明系统。在这里,破坏证明系统意味着能够为无效的声明生成被验证者接受的证明。幸运的是,只需一位诚实的参与者就能做到这一点:这就是为什么你会发现呼吁尽可能多的社区成员参与这样的仪式。
某些版本,例如 Groth16,要求每个电路都进行可信设置:这意味着,对于您编写的每个程序,您都需要运行一个新的仪式。其他版本,例如 PLONK,只需要一个通用的可信设置,可以在使用该版本构建的任何程序上重复使用。而其他版本,例如 STARK,则根本不需要任何可信设置。
约束
关于零知识证明 (ZKP) 的内部机制,你需要了解的重点是,证明机制基于一个数学约束系统。换句话说,证明引擎处理的不是 这样的代码x := y * z
,而是 形式的约束x - y * z = 0
。根据你使用的语言和证明器后端,这些约束可能会影响你的代码结构。
例如,Circom 只允许你在变量上写二次表达式,而 Halo2 允许你写任意次数的多项式约束。正如我们稍后会看到的,用 ZKP 语言编译程序的输出之一将是一组约束。
这意味着某些操作在零知识证明 (ZKP) 中比其他操作更容易表示:加法可以用单个约束表示,而位操作可能需要数百个约束,因为每个位可能需要表示为单独的变量。这就是为什么在零知识证明 (ZKP) 中通常会选择特定的密码原语的原因。例如,Pedersen 哈希是一种比 keccak 更高效地在算术电路中表示的哈希函数,因此在零知识证明 (ZKP) 中很常见。
有限域
另一个需要了解的重要信息是,约束在由底层椭圆曲线决定大小的有限域中运行。这意味着所有算术运算都是以某个值作为模来计算的,也就是说,它们会环绕该值。不过不用担心,这个值通常足够大。例如,在 Circom 中,它是:
21888242871839275222246405745257275088548364400416034343698204186575808495617
这也意味着,如果你想为整数变量定义特定的阈值,则需要为其添加额外的约束。有些语言(例如 Noir)会为你完成此操作,而其他语言则需要你手动执行。
为什么会引起如此大的轰动?
零知识证明 (ZKP) 最近备受关注。主要原因是它们可以作为一种低成本验证任何计算的手段,有效地成为一种通用的密码学构建块。
Gubsheep将零知识证明 (ZKP) 描述为可编程密码学,这意味着你可以用它们来编写密码学证明来解决一般问题。例如,你可以使用专门为解决该问题而构建的环签名来证明集合成员资格,或者直接编写一个 zkSNARK 电路来解决这个问题。
在Devcon6 的零知识证明 (ZKP) 专题讨论会上,有人就其在跨不同系统互操作性方面的强大功能提出了一个很好的观点:一旦你拥有一个可以验证零知识证明 (ZKP) 的系统,那么你就可以验证任何其他系统在任何计算环境中的行为是否符合预期,只要你能将其计算表达为零知识证明 (ZKP)。这正是 zk-rollups 的强大之处:如果你能将你的链规则编写为零知识证明 (ZKP),那么你就可以将该证明推送到以太坊并在 EVM 上进行验证——即使你的 rollup 不使用 EVM 作为执行环境!
电路是什么样的?
现在让我们来看看这一切是如何运作的。第一步是用你选择的 ZK 语言或框架(例如 Circom、Halo2 或 Noir)编写一个程序。这些程序通常被称为电路,它表达了变量集(或信号)之间的约束。
例如,我们可以在 Circom 中构建一个电路,让我们证明我们知道两个数字a
和,b
并且c = (a*b)^2
,而无需透露它们:
template MultiplierSq() {
signal input a;
signal input b;
signal ab;
signal output c;
ab <== a * b;
c <== ab * ab;
}
这里我们定义了两个私有输入信号、一个中间变量和一个输出值。需要注意的是,由于 Circom 不允许我们写出比二次约束更复杂的表达式,所以我们不能直接写成c <== (a*b)^2
,这就是为什么我们需要中间ab
信号。
创建并验证证明
使用零知识证明电路的工作流程与常规程序不同,因为我们有两个不同的代理:证明者和验证者。即使在证明者内部,也需要多个步骤来生成证明。让我们逐一了解一下:
- 我们首先编写并编译电路。这将输出一些成果,例如电路定义的约束集,以及下一步要使用的脚本或二进制文件。
- 根据所使用的 SNARK 风格,可能需要运行可信设置仪式来生成证明和验证密钥,正如其名称所暗示的那样,这些密钥是证明和验证过程所需的加密材料。
- 与我们的 zk-app 交互时,用户输入电路的私有和公共输入。第一步是像执行程序一样“执行”电路,使用编译期间生成的脚本或二进制文件。这会根据输入计算所有中间变量和输出变量的值。在上面的例子中,给定
a = 2
和b = 3
,它会计算ab = 6
和c = 36
。将每个信号赋值给其具体值的过程称为见证(witness)或迹线(trace)。 - 给定步骤 3 中的轨迹和步骤 2 中的证明密钥,证明者现在可以生成一个零知识证明,证明电路中定义的所有约束均成立,并且仅显示输出值
c
。现在可以将此证明发送给验证者。 - 验证者现在可以使用提交的证明和验证密钥来验证该证明对于其公开输出而言是否正确。在我们的示例中,验证者可以通过密码学方式验证证明者是否知道三个值(
a
,b
,ab
),使得a * b = ab
和ab * ab = c
,其中c = 36
。
请记住,验证步骤成本低廉,因此验证器可以作为 EVM 智能合约运行。这允许以无需信任的方式验证任何可以表示为零知识证明 (ZKP) 的链下计算。
如果您想亲自尝试上述工作流程,我强烈推荐Circom 的入门指南,它将指导您完成必要的步骤。在 Circom 中,编译电路将输出一个用于计算见证的 WASM 或 C++ 程序,以及一个R1CS 表示,该表示与可信设置仪式一起输出证明密钥和验证密钥。然后,为了生成证明,您需要提供计算出的见证密钥和证明密钥。要验证它,只需使用生成的证明以及验证密钥即可。您还可以自动生成 Solidity 合约以在链上运行验证。
执行与约束
在上面的工作流中,你可能已经注意到,证明者使用了两次电路:第一次是生成见证,第二次是推导证明所涵盖的约束。这两次运行完全不同,理解它们的区别是理解零知识证明 (ZKP) 工作原理的关键之一。
让我们回到之前的 Circom 示例,其中我们有两条指令:
ab <== a * b;
c <== ab * ab;
在 Circom 中,粗箭头只是两个不同指令(赋值和约束)的语法糖,因此上述内容可以扩展为:
ab <-- a*b
ab === a*b
c <-- ab * ab
c === ab * ab
指令a <-- a*b
表示在执行阶段将 赋值a*b
给ab
,并且在编译约束时会被忽略。另一方面,ab === a*b
表示添加一个强制等于的约束ab
a*b
,该约束在执行期间会被忽略。换句话说,在编写电路时,您实际上是在一个程序中编写两个属于两种不同编程范式的不同程序。
虽然你通常会写出等效的分配和约束,但有时你需要将它们分开。电路就是一个很好的例子IsZero
。
电路IsZero
当你受限于(此处似有双关)二次表达式时,编写一个当输入信号为零时输出 1,否则输出 0 的电路会出奇地困难。如果你考虑使用类似 的表达式x === 0
,请注意,它不会返回 是否x
为零的布尔值,而是会将本电路中的信号约束x
为零。
幸运的是,Circom 有一个有用的构建块库IsZero
,其中包括一个根据输入是否为零返回0
或的电路1
,如下所示:
template IsZero() {
signal input in;
signal output out;
signal inv;
inv <-- in!=0 ? 1/in : 0;
out <-- -in*inv +1;
out === -in*inv +1;
in*out === 0;
}
这是在电路中解耦执行和约束的一个很好的例子。让我们开始分析约束。约束in * out = 0
告诉我们in
或out
为零。如果in
为零,则为out = -in * inv + 1 = 1
,所以没问题。如果out
为零,则为in * inv = 1
,这意味着in
不能为零。太棒了!
但请注意,的实际值inv
从未出现在我们的推理中,因为我们不需要它。这对于编写约束条件来说是正确的,但在填充轨迹时,它就显得不足了。我们需要告诉 Circom 如何inv
在见证生成阶段计算 的值:这时inv <-- in!=0 ? 1/in : 0
表达式就派上用场了。Cairo 恰当地将这类表达式称为“提示”。
请注意,我们在这里使用的运算比二次表达式复杂得多:我们有一个条件、一个比较和一个除法。这是因为执行阶段与约束无关,因此不受生成零知识证明 (ZKP) 的限制。因此,在执行阶段,我们回归到非常常见的编程方式,可以使用其他运算符、控制流语句,甚至辅助函数。
约束不足的计算错误
然而,执行阶段和约束生成阶段之间的这种差异可能会导致一些非常严重的错误。回到之前的IsZero
例子,如果我们忘记写某个约束,比如说in*out = 0
某个约束,会发生什么?
让我们遵循已知的工作流程,假设in=3
输入如下:
- 由于没有错误,因此电路可以顺利编译。
- 见证生成将遵循执行指令,并正确计算值
inv=3^-1, out=0
。 - 由于约束成立,证明者将根据证人和公开输出生成证明
out = -in*inv +1
。 - 验证者会接受它是有效的,相信证明者具有私有的非零值。
到目前为止一切都很好。但是,如果一个恶意的证明者想让我们相信他们的值为零,而实际上他们的值为零,会发生什么呢?记住,在这个思想实验中,in=3
我们没有。in*out = 0
- 恶意用户可以手动制作见证人
in=3, inv=0, out=1
。 - 由于证人满足约束
out = -in*inv +1
,证明者可以为其生成证明。 - 验证者很高兴地接受了它,并认为用户具有零值,但实际上他们没有。
这类问题被称为“欠约束计算”,尤其难以捕捉。任何将电路视为黑匣子的单元测试都无法触发这些问题,因为它们使用电路的执行逻辑来构建见证,而见证将绕过约束。重现这些问题的唯一方法是手动组装一个带有自定义逻辑的见证,这需要你确切地了解你正在寻找的攻击。
或者,您也可以尝试验证您所编写的电路是否符合您想要编码的功能,例如Ecne,我仍需要尝试一下。
这里的底线是,当您编写与约束生成代码分开的执行代码时,您应该特别小心,因为它为约束不足的 ZK 程序打开了大门。
一个真实的例子
Tornado Cash是一个很好的学习资源,教你如何使用 Circom 电路构建真实的零知识证明应用。我强烈建议你仔细阅读长达三页的白皮书,以及 Gubsheep在 0xparc 课程中对Tornado Cash 的详细讲解,以及 Porter对白皮书的概述。
如果你还不熟悉 Tornado,它的原理是:你可以将 ETH 以离散的金额(有0.1、1、10 和 100 ETH 的票据)存入合约,之后可以匿名地将每张票据提取到其他地址。这样做的效果是,你的票据会与其他人持有的票据混合,只要使用量足够,就能保证匿名性。
它是如何工作的?
为了解释起见,我们将介绍该协议的稍微简化版本,并遵循Gubsheep 的版本,而不是实际实现的版本。
每当用户想要存入票据时,他们都会生成一个秘密值s
,并将其 1 ETH 票据连同秘密的哈希值一起提交H(s)
给合约。合约将所有提交的秘密哈希值收集到一棵 merkle 树中,并存储其根R
。
现在,当用户想要提取他们的票据时,他们需要证明自己拥有与 对应的秘密H(s)
。为此,用户提交一个零知识证明 (ZKP),表明他们知道一个私有值,s
使得H(s)
位于根节点 的默克尔树中。由于零知识证明 (ZKP) 的唯一公开输出是,因此R
不会透露树上所有叶子节点中哪一个是用户的 ,从而保持了匿名性。默克尔证明由零知识证明(ZKP) 中的电路进行检查。H(s)
R
我们还需要一种方法来确保用户无法两次提取同一张票据。这时,“无效符”的概念就应运而生了。无效符是一个由原始密钥确定性生成的值,可以进行跟踪以防止双重支付。
因此,除了证明根 的默克尔树的成员身份外R
,ZKP 还会输出一个值G(s)
用作无效符,其中G
是另一个不同于 的哈希函数H
。然后,每当提取票据时,其无效符都会被添加到智能合约中的无效符集中,如果已经存在,则拒绝提取。注意,G
需要不同于H
,否则 ZKP 会泄露H(s)
,从而可以追溯到原始存款并破坏匿名性。
换句话说,每当用户想要提现时,他们都会提交一个零知识证明 (ZKP),声明他们知道根为 的默克尔树中的一个秘密值R
,并公开该秘密值的无效符。然后,合约可以检查默克尔树根是否匹配,以及无效符是否未被使用。
如果您看过 Tornado 的白皮书,您会注意到,他们的实现与我们在此处描述的不同之处在于无效器概念。Tornado 实际上使用两个不同的秘密值,称为随机性
r
和无效器k
,他们存储的公共值称为无效器哈希。Tornado 还使用单个哈希函数(Pedersen 哈希)而不是两个不同的哈希函数,并且他们将默克尔树叶计算为H(r || k)
,将无效器哈希计算为H(k)
。重要的是,无效器不能链接回默克尔树叶,并且它是从秘密中确定性生成的。您可以在此处查看 Tornado 在 Circom 中的完整电路。
ZKP 的语言
编写零知识证明电路有多种语言和框架可供选择,既有通用的(例如 Circom),也有特定于特定平台的(例如 Cairo)。我尝试了三种不同的语言:Circom、Halo2 和 Noir,并实现了相同的电路来比较它们。该电路证明用户知道一组私密的石头剪刀布比赛的结果,使得总分与公开的输出相匹配。分数的计算方式与2022 年代码降临第 2 天的定义一致,我曾以此为灵感来解决这个问题。
总分是你每轮得分的总和。单轮得分等于你所选形状的得分(石头得1分,布得2分,剪刀得3分)加上该轮结果的得分(输得0分,平局得3分,赢得6分)。
接下来是对每种语言的概述、我个人的印象以及其中的石头剪刀布电路。请注意,我对每种语言都是初学者,因此可能会有错误。如果您发现任何错误,请与我联系,以便我修改本文。
我还需要尝试的其他语言和框架包括Zokrates、Cairo、SnarkyJS、Gnark和Leo。我希望在尝试完它们之后更新这篇文章!
环通
正如CyberPorter所建议的,我发现Circom是入门学习零知识证明 (ZKP) 的最佳语言。对我来说,Circom 的抽象程度非常适合学习:既不会太高以至于抽象掉 ZKP 的一些怪癖,也不会太低以至于让你陷入烦人的细节。它已经存在了相当长一段时间(考虑到 ZK 的一切都很新!),因此你可以找到更多文档和示例,以及来自0xparc的精彩 Circom 研讨会。最后但同样重要的是,还有circomlib,一个 Circom 常用构建块库。
Circom 附带一个基于 Rust 的编译器 CLI,以及一个用于可信设置以及生成和验证证明的npm 包。拥有 npm 包的好处是您可以在浏览器中生成证明,从而为构建零知识证明应用打开大门。您还可以自动生成 Solidity 合约,用于链上验证证明。Circom 还允许您在 Groth16 和 PLONK 之间选择您的证明引擎。
如前所述,在 Circom 中,您可以组装电路,每个电路都有一组输入、内部和输出信号。电路被定义为模板,可以使用编译时已知的值进行参数化,类似于 C++函数模板。Circom 还具有函数的概念,作为执行阶段可重用的构建块。
Circom 的主要挑战在于理解您何时编写约束生成代码,何时编写见证生成代码,并追踪哪些值在编译时已知,哪些值仅在运行时已知。许多语言结构仅适用于见证生成,例如布尔运算符或比较运算符,或者只能依赖于编译时已知的值,例如控制流语句。此外,牢记约束生成时间和见证生成时间之间的差异可以防止出现约束不足的计算错误。
Circom 中的石头剪刀布
用 Circom 编写石头剪刀布问题的电路很有趣。由于无法在约束中编写条件表达式,因此需要借助基于数学的技巧。举一个简单的例子,第一个电路通过检查输入是 0、1 还是 2 来验证游戏的输入,如下所示:
template AssertIsRPS() {
signal input x;
signal isRP <== (x-0) * (x-1);
isRP * (x-2) === 0;
}
用于计算单轮得分的代码使用类似的结构,以及IsEqual
来自 CircomLib 的电路:
// Returns the score for a single round, given the plays by x and y
template Round() {
signal input x, y;
signal output out;
// ensure that each input is within 0,1,2
AssertIsRPS()(x);
AssertIsRPS()(y);
// check if match was a draw
signal isDraw <== IsEqual()([x, y]);
signal diffYX <== (y+3)-x;
// y wins if y-x = 1 mod 3
signal yWins1 <== (diffYX-1) * (diffYX-4);
signal yWins <== IsZero()(yWins1);
// x wins if y-x = 2 mod 3
signal xWins1 <== (diffYX-2) * (diffYX-5);
signal xWins <== IsZero()(xWins1);
// check that exactly one of xWins, yWins, isDraw is true
// we probably can do without these constraints
signal xOrYWins <== (xWins - 1) * (yWins - 1);
xOrYWins * (isDraw - 1) === 0;
xWins + yWins + isDraw === 1;
// score is 6 if y wins, 3 if draw, 0 if x wins
// plus 1, 2, 3 depending on the choice of RPS
out <== yWins * 6 + isDraw * 3 + y + 1;
}
最后,最外层电路循环执行可参数化的轮数n
,并汇总所有得分。注意,这里可以使用循环,因为它依赖于n
,而 是编译时已知的模板参数。
template Game(n) {
signal input xs[n];
signal input ys[n];
signal scores[n];
signal output out;
var score = 0;
for (var i = 0; i < n; i++) {
scores[i] <== Round()(xs[i], ys[i]);
score += scores[i];
}
out <== score;
}
光晕2
Halo2并非一门语言,而是一个基于 Rust 的框架,由 ZCash 团队维护。Halo2 特定于PLONKish,让您能够非常直接地控制电路在算术运算中的表示方式。这使得 Halo2 非常底层,但却是编写高度优化电路的理想选择。
对我来说,Halo2 的学习曲线迄今为止最为陡峭。这不仅是因为必须理解 PLONKish 算法的工作原理才能构建电路,更主要的是我发现 Halo2 的 API 相当复杂,而且很难找到相关文档。学习 Halo2 的资源也很少:我发现最好的资源是0xparc 课程,它提供了一些非常有价值的代码示例,以及主仓库中的示例。你也可以查看awesome-halo2获取更新的资源。
当开始使用 Halo2 时,请记住该框架有两种风格:由 ZCash 维护的原始框架,以及由以太坊的隐私和扩展探索团队开发的分叉框架,后者使用不同的多项式承诺(KZG而不是IPA),对以太坊更加友好。
PLONKish 算术
在 Halo2 中构建电路的关键在于首先设计底层 PLONKish 矩阵的结构。在 PLONKish 中,电路定义在一个你直接定义的矩阵上。该矩阵由多列组成,每列可以表示公共输出(称为“实例”)、私有见证值(称为“建议”)、常量值(称为“固定”)、指示是否启用约束的标志(称为“选择器”,下文将详细介绍)或查找值(用于查找表,这是一项高级功能)。
矩阵设置完成后,就该使用门来定义多项式约束了。Halo2 中的门定义了一个或多个应用于矩阵中一组单元格的约束。每个门都通过一个选择器列进行控制:如果在某一行启用了选择器,则该门施加的与该行相关的约束将被强制执行。
门可以定义跨多行的约束。例如,这是一个直接取自《光晕2》的L 形乘法门的例子:
meta.create_gate("mul", |meta| {
// To implement multiplication, we need three advice cells and a selector
// cell. We arrange them like so:
//
// | a0 | a1 | s_mul |
// |-----|-----|-------|
// | lhs | rhs | s_mul |
// | out | | |
//
// Gates may refer to any relative offsets we want, but each distinct
// offset adds a cost to the proof. The most common offsets are 0 (the
// current row), 1 (the next row), and -1 (the previous row), for which
// `Rotation` has specific constructors.
let lhs = meta.query_advice(advice[0], Rotation::cur());
let rhs = meta.query_advice(advice[1], Rotation::cur());
let out = meta.query_advice(advice[0], Rotation::next());
let s_mul = meta.query_selector(s_mul);
// Finally, we return the polynomial expressions that constrain this gate.
// For our multiplication gate, we only need a single polynomial constraint.
//
// The polynomial expressions returned from `create_gate` will be
// constrained by the proving system to equal zero. Our expression
// has the following properties:
// - When s_mul = 0, any value is allowed in lhs, rhs, and out.
// - When s_mul != 0, this constrains lhs * rhs = out.
vec![s_mul * (lhs * rhs - out)]
});
在上面的代码示例中,a0
和a1
是建议列,并且s_mul
是定义是否强制执行乘法门的选择器。如果是,则下一行的mul
值将被限制为等于当前行和的乘积。a0
a0
a1
此外,Halo2 允许您定义相等约束,要求矩阵中的特定单元格必须等于另一个单元格。这对于在矩阵中“复制”值,或限制公共实例单元格等于特定的私有建议单元格以显示值非常有用。
再举一个例子,您可以定义一个电路来证明第 N 个斐波那契数,其中i
矩阵的行具有值。这意味着您首先x[i-2], x[i-1], x[i]
需要三个建议a, b, c
列,我们将它们称为。然后,您将使用门约束c
为等于a + b
同一行中的,这需要添加一个选择器列来控制它。并且您必须设置相等约束,以便和等于前一行的和。最后,为了将第 N 个数字公开为公共值,您需要一个实例列,约束为等于最后一行的值。a
b
b
c
c
芯片、设备和区域
Halo2 中电路的主要构建块是小工具和芯片。芯片是最低级别的单元。芯片通常会公开一种配置其门的方法,以及多种在合成过程中为其单元赋值的方法(详见下文)。你也可以构建由其他芯片组成的芯片。另一方面,小工具的运行方式更抽象,隐藏了底层芯片的实现细节,不过你可以直接用芯片构建电路,完全跳过小工具。
为了提高可重用性,您会注意到芯片始终以相对偏移量运行。这允许将多个芯片分组到电路中的区域中。一旦定义了所有区域及其形状,布局规划器就负责在矩阵上排列这些区域,因此您无需直接定义每个芯片的实际位置。但是,根据您的电路结构,完全可以将所有内容打包到一个区域中,而无需将布局委托给规划器。
Halo2 Rust API
在 Halo2 中,与 Circom 类似,要记住的主要一点是,您的代码将在不同情况下被多次调用:无论是配置矩阵、生成约束、创建证明还是计算见证。
您的电路需要实现一个特定的Circuit
特征,该特征定义将在整个生命周期中调用的方法,无论是具体的还是未知Value
的:
pub trait Circuit<F: Field> {
type Config: Clone;
type FloorPlanner: FloorPlanner;
fn without_witnesses(&self) -> Self;
fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config;
fn synthesize(
&self,
config: Self::Config,
layouter: impl Layouter<F>
) -> Result<(), Error>;
}
这里重点是configure
和synthesize
。简而言之,configure
会设置矩阵形状及其门,synthesize
会计算见证并用相应的值填充矩阵。然而,synthesize
也可能在其他阶段被调用并传入未知值,因此你需要始终使用包装好的Value
s。例如, 和 ,虽然这看起来可能违反直觉,但门中的多项式约束是在配置期间定义的,而等式约束是在综合 时设置的。
Halo2 书中的示例逐步介绍了如何使用单个芯片实现简单电路,以防您需要遵循脚手架。
Halo2 中的石头剪刀布
Halo2 中的 RPS 实现比Circom 中的要冗长得多,因此我们在这里只复现其中的一部分。首先,矩阵由以下列组成:
- 为两位玩家各列一个建议,
xs
和ys
,其中行中的值i
代表玩家在第 轮中选择了什么动作i
。 - 第三个建议列
accum
跟踪累计总分,因此行i
包含所有轮次的分数总和i
。 - 一个公共实例列,其中最后一个单元格被限制为等于的值
accum
,因此只显示总分而不是中间分。 - 用于启用验证输入并计算每轮得分的单个门的选择器。
主芯片定义了一个自定义门,用于打包每轮的所有约束:验证输入是否在范围内,计算本轮得分并将其添加到累计总得分中。该芯片使用一行中的 、 和 的值xs
作为ys
“输入”,并在下一行的列accum
中“输出”新的得分。accum
meta.create_gate("round", |meta| {
let s = meta.query_selector(selector);
let x = meta.query_advice(col_x, Rotation::cur());
let y = meta.query_advice(col_y, Rotation::cur());
let accum = meta.query_advice(col_accum, Rotation::cur());
// We store the output in the accum column in the next row
let out = meta.query_advice(col_accum, Rotation::next());
// Constraints for each round
vec![
// out = y_wins * 6 + is_draw * 3 + y + 1 + accum
s.clone() * (out - (y_wins.expr() * F::from(6) + is_draw.expr() * F::from(3) + y.clone() + const_val(1) + accum)),
// x in (0,1,2)
s.clone() * x.clone() * (x.clone() - const_val(1)) * (x.clone() - const_val(2)),
// y in (0,1,2)
s.clone() * y.clone() * (y.clone() - const_val(1)) * (y.clone() - const_val(2)),
]
});
和上面的y_wins
芯片定义如下。请注意,我们可以对所有约束复用相同的选择器列,因为我们不会希望在某一行中启用某些约束而禁用其他约束。is_draw
IsZero
// yWins <==> (y+2-x) * (y-1-x) == 0;
let y_wins = IsZeroChip::configure(
meta,
|meta| meta.query_selector(selector),
|meta| {
let x = meta.query_advice(col_x, Rotation::cur());
let y = meta.query_advice(col_y, Rotation::cur());
(y.clone() + const_val(2) - x.clone()) * (y - const_val(1) - x)
}
);
在综合电路时,我们循环遍历每一轮的输入,计算累积分数,并将计算值赋值给矩阵。需要注意的是,对于“执行”模式,我们可以直接使用条件表达式来计算分数。
// Assign one row per round
for row in 0..N {
let [x, y] = [xs[row], ys[row]];
// Enable the selector for the round gate
self.config.selector.enable(&mut region, row)?;
// This is requiring us to add a constant column to the chip config just with zeros
if row == 0 {
region.assign_advice_from_constant(|| "zero", col_accum, 0, F::ZERO)?;
}
// Set x and y advice columns to the input values
region.assign_advice(|| format!("x[{}]", row),col_x,row,|| x)?;
region.assign_advice(|| format!("y[{}]", row),col_y,row,|| y)?;
// Assign the is_zero chips to the same expressions defined in the gates
// yWins <==> (y+2-x) * (y-1-x) == 0;
let y_wins_chip = IsZeroChip::construct(y_wins.clone());
let y_wins_value = (y + Value::known(F::from(2)) - x) * (y - Value::known(F::ONE) - x);
let y_wins = y_wins_chip.assign(&mut region, row, y_wins_value)?;
// isDraw <==> y-x == 0;
let is_draw_chip = IsZeroChip::construct(is_draw.clone());
let is_draw_value = y - x;
let is_draw = is_draw_chip.assign(&mut region, row, is_draw_value)?;
// Calculate the score of this round
let round_score = y_wins.zip(is_draw).and_then(|(y_wins, is_draw)| {
let partial_score = if y_wins { 6 } else if is_draw { 3 } else { 0 };
Value::known(F::from(partial_score)) + y + Value::known(F::ONE)
});
// Assign the col_accum *in the next row* to the new score
accum_value = accum_value + round_score;
out_cell = region.assign_advice(
|| format!("out[{}]", row),
col_accum,
row + 1,
|| accum_value
);
};
最后一点是将总分作为电路的公共输出公开,通过约束实例列以匹配accum
矩阵最后一行中的列:
layouter.constrain_instance(out_cell.cell(), self.config.instance, N-1)
黑色
Noir由Aztec构建,是该语言包中最新的语言,目前正在积极开发中。它以基于 Rust 的命令行界面 (CLI) 和一组 npm 软件包的形式发布nargo
。npm软件包的发布似乎略微落后于 crate,后者拥有前沿功能,并且仅在几天前(2 月初)发布了第一个稳定版本。rust crate 和 npm 软件包均可用于编译 Noir 程序、生成和验证证明,以及创建验证器 Solidity 合约。
Noir 深受 Rust 的启发,因为它们几乎共享相同的语法。它支持整数、布尔值、元组、结构体和数组,以及表示证明后端原生字段元素的基本field
类型(想象一下,一个无符号整数,其上限为约 254 位的大素数),并且比常规整数性能更高。
与 Circom 不同,Noir 不允许您定义仅用于见证生成(又称提示)的代码。Noir 支持开箱即用的控制流语句和运算符,并根据需要将它们转换为等效约束。这有助于避免任何不受约束的计算错误,但会牺牲性能。也就是说,您可以使用特殊constrain
关键字定义与见证生成无关的约束。
黑色电影中的石头剪刀布
与其他语言相比,Noir 感觉有点像作弊,因为它抽象出了与零知识证明相关的大部分复杂性,感觉就像编写普通的 Rust 代码一样。整个程序只有 30 行,读起来就像一个普通程序:
global N: Field = 3;
#[builtin(arraylen)]
fn len<T>(_input : [T]) -> comptime Field {}
fn round(x: Field, y: Field) -> Field {
constrain (x as u8) <= 2;
constrain (y as u8) <= 2;
let mut score = 0;
let diffYX = (y + 3 - x);
if x == y {
score = 3;
}
if (diffYX == 4) | (diffYX == 1) {
score = 6;
}
score + y + 1
}
fn main(xs: [Field; N], ys: [Field; N]) -> pub Field {
let mut result = 0;
for i in 0..len(xs) {
result = result + round(xs[i], ys[i]);
}
result
}
值得一提的是,我没有对每种语言生成的约束数量进行任何分析,但考虑到 Noir 更高的抽象级别,预计会产生更多约束,这会影响证明时间。然而,随着编译器的成熟,应该会添加更多优化,并自动将其合并到用 Noir 编写的程序中。
资源
如果你已经了解了这一点,并且想继续学习,以下是我用来快速掌握零知识证明 (ZKP) 的一些资源。如果你想直接向大师学习,我鼓励你仔细阅读这些资源!
- CyberPorter制作了一组 6 节课,对 ZKP 进行了很好的概述,包括动机、空间映射和正在使用它的人、对 TornadoCash 的深入研究、对 Circom、Cairo、SnarkyJS 和 Noir 的概述,以及对 Groth16 背后的数学原理的演练。
- 0xPARC有两门完整的在线课程,一门是关于 Circom 的,另一门是关于 Halo2 的。我强烈建议从 Circom 的课程开始,主要由《黑暗森林》游戏背后的开发者Gubsheep主讲。这些课程包含动手实践工作坊,会逐步指导你如何搭建多个电路。如果你想尝试 Halo2,我觉得这门课程不容错过,因为目前关于 Halo2 的其他资源并不多。
- Vitalik发表了多篇博客文章,涵盖了理解 SNARK 和 STARK 所需的几乎所有加密概念,以及该技术的有趣用途。如果您想深入了解零知识证明 (ZKP) 的工作原理,请先搜索他的博客。
- 正在构建 ZK-EVM 的Scroll 团队运营着一个关于零知识证明 (ZKP) 的优秀教育博客。其中关于证明生成的文章,详细讲解了创建零知识证明的整个工作流程。
- 很棒的列表很棒,zkSync 背后的团队Matter Labs保留了一个很棒的零知识证明,其中包含指向各种资源的链接。