零知识证明正变得越来越流行,但作为一个开发人员,寻找比较好的学习资源很困难, 在花了一些时间研究这个话题之后,我在这里收集了我学到的东西,希望它能帮助其他人。 请注意,这是一本初学者指南,因为它的作者(我!)是 ZKP 编程的初学者,所以如果你在本文中发现任何不对劲的地方,请与我联系!
什么是 ZKP? #
ZKP 的一个经典例子是地图染色问题,我(证明者)可以向你(验证者)证明我知道的有效染色方案,而无需透露它。
另一个可以被视为 ZKP 的著名例子是数字签名:我可以通过签署消息来证明我持有一个秘密字符串(我的私钥), 该字符串对应于一个公钥(我的公钥),并且该签名不会泄露我的私钥中的任何 bit 位。
什么是 zkSNARK? #
SNARK 代表简洁(succinct)和非交互(non-interactive)。这里重点关注简洁和非交互。
所谓简洁表示如果证明很小,那么证明就是简洁的。这意味着,无论我们正在证明的事实有多复杂,我们都可以保持小而快速的验证证明。这就是 ZKP 对构建 rollup 的吸引力所在:可以生成一个简洁的证明, 证明 L2 链上发生的所有操作都是有效的,简洁到可以在 L1 上的合约中验证它。像 Mina 这样的协议通过递归证明将这一点发挥到了极致:他们用恒定大小 的证明来链的整个历史的有效性。
交互性意味着每次验证时都需要签名者在线,并且还需要多个签名者,这样处理签名就很麻烦,在上面提到的三色问题中,图形染色证明是交互式的, 需要证明者和验证者之间的在线游戏。幸运的是,zkSNARK 是非交互式的。
ZKP flavors #
除了zkSNARKs之外,还有其他有趣的ZKP方法,例如 STARKs(由 Starkware开创并 用于 Starknet)和 Bulletproofs( 用于 Monero)。它们在生成和验证证明的运行时间、证明大小以及是否需要可信设置方面存在差异。 MatterLabs在 这里对它们进行了很好的比较。
此外,还可以选择不同的椭圆曲线或使用不同的多项式承诺方案。Consensys Gnark在这里提供了关于SNARK证明方案和曲线的很好的解释。
可信设置 (Trusted setup) #
通常情况下,选择要使用的ZKP语言将决定最终使用的ZK后端,尽管像Circom或Gnark这样的 工具 支持多个证明系统。
某些证明系统的变种需要所谓的可信设置(trusted setup)。可信设置是一个一次性的仪式,由多个参与者运行,生成用于驱动ZK证明系统的一组随机值。
这些仪式非常重要,因为如果所有参与者串通一气,他们可以破坏证明系统。在这里,破坏证明系统意味着能够为无效的声明生成一个被验证者接受的证明。 幸运的是,只要有一个诚实的参与者,问题就能解决:这就是为什么你会发现 呼吁尽可能多的社区成员参与这样的仪式。
某些变种,如Groth16,对每个电路都需要一个可信设置:这意味着,对于你编写的每个程序,你都需要运行一个新的仪式。其他变种,如PLONK,只需要一个通用的可信设置, 可以在使用该变种构建的任何程序上重复使用。还有一些变种,如STARKs,根本不需要任何可信设置。
约束 #
关于零知识证明(ZKP)内部的主要要点是,证明是基于数学约束系统进行的。换句话说,证明引擎不处理像 $x := y * z$ 这样的代码, 而是处理形如 $x - y * z = 0$ 的约束条件。根据你使用的语言和证明后端,这可能会影响你编写代码的结构。
例如,Circom只允许在变量上编写 二次表达式,而Halo2允许你编写任意次数的 多项式约束。 正如我们将在后面看到的那样,编译ZKP语言中的程序的一个输出将是约束集合。
这意味着某些操作在ZKP中更容易表示:加法可以用一个约束表示,而位操作可能需要数百个约束,因为每个位可能需要表示为单独的变量。这就是为什么在处理ZKP时通常选择特定的密码原语的原因。 例如, Pedersen哈希是可以在算术电路中更高效地表示的哈希函数, 因此它们经常出现在ZKP中。
有限域(Finite fileds) #
另一个需要了解的重要内容是,约束在由底层椭圆曲线确定的有限域上操作。这意味着所有的算术操作都是在模某个值的情况下计算的,也就是说它们会在该值上进行循环。不过不用担心, 这个值通常足够大。例如,在Circom中:
21888242871839275222246405745257275088548364400416034343698204186575808495617
这还意味着,如果你想为整数变量定义特定的阈值,你需要为此添加额外的约束。有些语言像Noir会 自动处理这个问题, 而其他语言则需要你 手动添加。
为什么这么热闹? #
近来,零知识证明(ZKP)引起了很多关注。主要原因是它们可以作为一种廉价验证任何计算的手段,有效地成为通用的密码构建块。
Gubsheep将ZKP描述为 可编程密码学, 意味着你可以使用它们为一般问题编写密码学证明。例如,你可以使用专门用于解决该问题的 环签名来证明集合成员资格,或者只需为其编写一个zkSNARK电路。
在Zero Knowledge的Devcon6 小组讨论中,有一个非常重要的观点是它们在实现不同系 统之间的互操作性方面的强大能力:一旦你有一个能够验证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信号。
创建和验证证明 #
使用ZKP电路的工作流程与常规程序不同,因为我们有两个不同的参与者:证明者和验证者。即使在证明者内部,生成证明也涉及多个步骤。让我们逐步进行说明:
- 首先,我们编写并编译电路。这将输出一些工件,例如电路定义的约束集合,以及在下一步中使用的脚本或二进制文件。
- 根据使用的SNARK类型,可能需要进行可信设置仪式,以生成证明和验证密钥,这些是证明和验证过程所必需的密码材料,正如它们的名称所示。
- 在与我们的zk-app进行交互时,用户输入电路的私有和公共输入。第一步是使用编译过程中生成的脚本或二进制文件,将电路**“执行”为一个程序**。这将根据输入计算出所有中间变量和输出变量的值。 在上面的例子中,给定a = 2和b = 3,它将计算出ab = 6和c = 36。将每个信号分配给其具体值的过程称为证据或追踪。
- 给定步骤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 <– ab$指令意味着在执行阶段,将$ab$赋值给ab,并在编译约束时被忽略。另一方面,$ab === ab$ 表示添加一个约束,强制ab等于ab,在执行阶段被忽略。 换句话说,当编写电路时,你实际上是在编写两个不同的程序,它们属于两种不同的编程范式,但在同一个程序中。
虽然通常你会编写等效的赋值和约束,但有时你需要将它们拆分开来。一个很好的例子是IsZero电路。
IsoZer
电路
#
写一个电路,在输入信号为零时输出1,否则输出0,当限制在二次表达式时,这实际上是非常困难的。如果你考虑使用类似 $x === 0$ 的表达式,注意这将不会返回一个布尔值, 指示x是否为零,而是将在电路中将信号x约束为零。
幸运的是,Circom拥有一套实用的
构建模块库,其中包括一个
IsZero
电路,根据输入是否为零返回0或1,代码如下:
// Template for an IsZero circuit
template IsZero() {
// Define the input signal
signal input in;
// Define the output signal
signal output out;
// Define an intermediate signal
signal inv;
// Calculate inv as 1/in if in is not zero, otherwise 0
inv <-- in != 0 ? 1/in : 0;
// Calculate out as -in * inv + 1
out <-- -in * inv + 1;
// Add a constraint that ties out to the expression -in * inv + 1
out === -in * inv + 1;
// Add a constraint that indicates either in or out is zero
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适当地将这种类型的表达式称为
hints。
请注意,这里我们使用的操作比二次表达式复杂得多:有条件语句、比较和除法。这是因为执行阶段与约束无关,因此不受生成ZKP的限制。因此,在执行阶段时, 我们可以像进行常规编程一样,使用额外的运算符、控制流语句甚至 辅助函数。
由约束不足的产生的计算错误 #
然而,执行和约束生成阶段之间的差异可能会导致一种非常严重的bug。回到IsZero
的例子,如果我们忘记编写其中一个约束,比如in*out = 0
,会发生什么呢?
让我们按照我们所知的流程进行,假设输入in = 3:
- 电路将会顺利编译,因为没有错误。
- 生成证据的过程将按照执行指令进行,正确地计算出
inv=3^-1和out=0
。 - 证明者将根据证据和公共输出生成证明,因为约束
out = -in*inv + 1
成立。 - 验证者会接受该证明为有效,并相信证明者拥有一个非零的私有值。
到目前为止一切都很好。但是,如果一个恶意的证明者想要说服我们他们拥有零值,而实际上他们的值是in = 3
,会发生什么呢?请记住,在这个思想实验中我们没有in*out = 0
这个约束。
这种问题被称为 欠约束计算(under-constrained computation),非常难以捕捉。 任何将电路视为黑盒的单元测试都无法触发这些问题,因为它们使用电路的执行逻辑来构建证据,从而满足约束条件。要重现这些问题, 只能 手动组装具有自定义逻辑的见证人,这要求你确切地知道你要寻找的攻击。
另外,还有一些努力用于验证你编写的电路与你想要编码的功能的正确性,例如Ecne,但我还没有尝试过。
在编写执行代码和约束生成代码时,应该格外小心,因为这会导致欠约束的零知识程序的出现。
真实的例子 #
Tornado Cash是一个非常好的学习资源,可以教授如何使用Circom电路构建真实世界的零知识应用程序。我强烈推荐阅读这份长达 3页的白皮书,以及0xparc课程中 Gubsheep对Tornado Cash的解析和 Porter对 白皮书的概述。
如果你对Tornado Cash不熟悉,简单来说,它允许你将以离散金额存入ETH(有0.1、1、10和100 ETH的纸币),然后以匿名方式将每张纸币提取到其他地址。这样做的效果是将你的纸币与其他人的纸币混合在一起, 只要使用足够频繁,就能实现匿名性。
如何工作的? #
为了说明起见,我们将简化协议的版本,并按照 Gubsheep的版本进行, 而不是实际实现的版本。
每当用户想要存入一张纸币时,他们会生成一个秘密值s
,并将其1 ETH纸币与秘密哈希H(s)
一起提交到合约中。合约将所有提交的秘密哈希收集
到一个默克尔树中,并存储其根节点R。
现在,当用户想要提取他们的纸币时,他们需要证明他们拥有与H(s)
对应的秘密。为了做到这一点,用户提交一个零知识证明(ZKP),证明他们
知道一个私有值s,使得H(s)
在根节点为R的默克尔树中。由于ZKP的公共输出只有R
,所以无法揭示用户的H(s)
是树中的哪个叶子节点,从而
保护了匿名性。默克尔证明由ZKP中的电路进行检查。
我们还需要一种方法来确保用户不能重复提取相同的纸币。这就是nullifier
的概念发挥作用的地方。nullifier
是从原始秘密值确定性地生成的值,
用于防止双重消费。
因此,除了证明属于根节点为R
的默克尔树之外,ZKP还输出一个值G(s)
,用作nullifier
。其中,G
是另一个与H
不同的哈希函数。然后,每当提取
一张纸币时,它的nullifier
被添加到智能合约中的nullifier
集合中,如果已经存在,则拒绝提取操作。需要注意的是,G
必须与H不同,否则ZKP
将会揭示H(s)
,可以追溯回原始存款并破坏匿名性。
换句话说,每当用户想要提取纸币时,他们提交一个ZKP,声明他们知道一个秘密值,该秘密值在根节点为R的默克尔树中,并公开该秘密值的nullifier
。
然后,合约可以检查默克尔根是否匹配,并且nullifier
是否已被使用。
如果你阅读了Tornado的白皮书,你会注意到
nullifier
的概念是他们的实现与我们在这里描述的不同之处。Tornado实际上使用了两个不同的秘密值, 称为随机值r
和nullifier
k
,他们存储的公共值被称为nullifier
哈希。Tornado还使用了单个哈希函数(Pedersen哈希),并将默克尔树叶子 计算为H(r || k)
,将nullifier
哈希计算为H(k)
。重要的是,nullifier
无法追溯到默克尔树叶子,并且它是从秘密值确定性地生成的。你可以 在 这里查看Tornado在Circom中的完整电路。
ZKP 编程语言 #
在编写零知识证明电路时,可以选择多种不同的语言和框架,包括通用语言(如Circom)和特定于某个平台的语言(如Cairo)。我尝试了三种不同的语言, 包括Circom、Halo2和Noir,并且在这些语言中实现了相同的电路,以进行比较。这个电路证明了用户知道一组私密的剪刀石头布比赛的结果, 使得总分与公开输出相匹配。得分的计算方式与 advent of code 2022 day 2的定义相符,我从中获得 了灵感来定义这个问题。
总分是每一轮比赛的得分之和。每一轮的得分由玩家选择的形状得分(石头得1分,布得2分,剪刀得3分)和比赛结果得分(如果你输了得0分, 打平得3分,赢了得6分)组成。
以下是每种语言的概述,以及我对它们的个人印象以及剪刀石头布电路的实现。请记住,我对所有这些语言都是初学者,所以可能会有错误。 如果你发现任何错误,请与我联系,我会修复这篇文章。
我还需要尝试的其他语言和框架包括 Zokrates、 Cairo、 SnarkyJS、 Gnark和 Leo。
Circom #
Circom是学习零知识证明(ZKP)的最佳语言之一,正如 CyberPorter所建议的那样。 对我来说,Circom具有理想的抽象级别,不会过于高级以至于掩盖了一些ZKP的细节,也不会过于低级以至于让你陷入烦人的细节中。Circom已经存在了 一段时间(尽管在ZK领域,一切都很新!),因此你可以找到更多的文档和示例,以及来自0xparc的令人惊叹的Circom研讨会。还有一个名为 circomlib的库,其中包含了Circom的常见构建模块。
Circom附带了一个基于Rust的编译器CLI,以及一个用于可信设置和证明生成与验证的npm包。拥有npm包的好处是你可以在浏览器中生成证明,为构建零 知识应用程序打开了大门。你还可以自动生成用于在链上验证证明的Solidity合约。Circom还允许你在Groth16和PLONK之间选择你的证明引擎。
如前所述,在Circom中,你可以组装电路,每个电路都有一组输入、内部和输出 信号。电路 被定义为 模板,可以使用在编译时已知的值进行参数化,这类似于C++ 的 函数模板。Circom还引入 了 函数的概念,作为执行阶段中可重用的构建块。
主要的挑战是理解何时编写约束生成代码和何时编写见证生成代码,并追踪哪些值在编译时已知,而哪些值仅在运行时已知。许多语言结构仅适用于见证 生成,例如 布尔或比较运算符,或者只能依赖于在编译时已知的值,比 如 控制流语句。此外,了解约束生成和见证生成时间之间的差异可以防止出现不完整 的计算错误。
用 Circom 实现的剪刀石头布游戏 #
在 Circom 中编写 剪刀石头布问题的电路非常有趣。 因为无法在约束中编写条件表达式,所以需要使用基于数学的技巧。作为一个简单的例子,第一个电路用于验证玩家的输入是否为0、1或2,代码如下:
template AssertIsRPS() {
signal input x;
signal isRP <== (x-0) * (x-1);
isRP * (x-2) === 0;
}
计算单个回合得分的代码
使用类似的结构,还使用了 CircomLib 中的
IsEqual
电路:
// 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;
}
Halo2 #
Halo2是一个基于Rust的框架,由ZCash团队维护。Halo2专门针对 PLONKish, 并且可以直接控制在算术化中如何表示电路。这使得Halo2非常底层,但非常适合编写高度优化的电路。
对我来说,Halo2的学习曲线远远超过其他框架。不仅因为需要理解PLONKish算术化的工作原理来构建电路,而且因为我发现Halo2的API相当复杂, 文档也很难找到。关于学习Halo2的资源也很少:我找到的最好的资源是 0xparc课程,它提供了一些非常 有价值的代码示例,还有 主存储库中的示例。你还可以查 看 awesome-halo2获取最新资源。
在开始使用Halo2时,请记住有两种版本的框架可供选择:由ZCash维护的 原始版本,以及 由 Ethereum隐私和扩展研究团队维护的 分支版本, 它使用了不同的多项式承诺( KZG而不是 IPA),对于以太坊来说更加友好。
PLONKish 算术 #
PLONKish算术化是在Halo2中构建电路的关键。在PLONKish中,电路是在你直接 定义的矩阵上定义的。 该矩阵由多个列组成,其中一列可以表示公共输出(称为instance),私有见证值(称为advice),常量值(称为"ixed),约束是否启用的 标志(称为selector,稍后会详细介绍),或者用于查找表的"查找"值(用于查找表,这是一种高级功能)。
一旦设置好矩阵,就可以使用"门"来定义多项式约束。在Halo2中,门定义了一组在矩阵中的单元格上应用的约束。每个门都由一个"选择器"列控制: 如果在某一行上选择器被启用,则该门相对于该行施加的约束将被强制执行。
门可以在多个行之间定义约束。例如,下面是一个L形乘法门的示例( 摘自Halo2书中的简单示例):
meta.create_gate("mul", |meta| {
// 要实现乘法,我们需要三个advice单元和一个selector单元。我们将它们排列如下:
//
// | a0 | a1 | s_mul |
// |-----|-----|-------|
// | lhs | rhs | s_mul |
// | out | | |
//
// 门可以引用任何我们想要的相对偏移量,但每个不同的偏移量都会增加证明的成本。最常见的偏移量是0(当前行)、1(下一行)
// 和-1(前一行),对应于`Rotation`具有特定的构造函数。
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);
// 最后,我们返回约束该门的多项式表达式。对于我们的乘法门,我们只需要一个多项式约束。
//
// 从`create_gate`返回的多项式表达式将被证明系统约束为等于零。我们的表达式具有以下属性:
// - 当s_mul = 0时,lhs、rhs和out可以取任何值。
// - 当s_mul != 0时,这约束了lhs * rhs = out。
vec![s_mul * (lhs * rhs - out)]
});
在上面的代码示例中,a0
和a1
是advice列,s_mul
是selector,它定义了是否要强制执行mul
乘法门。如果是,
则在下一行上的a0
的值将被约束为等于当前行上a0
和a1
的乘积。
此外,Halo2允许你定义 相等约束, 其中你要求矩阵中的特定单元格必须等于另一个单元格。这对于在矩阵中copying值或将公共的instance单元格约束为等于特定的私有advice单元格以公开 一个值是有用的。
作为另一个例子,你可以为
证明第N个斐波那契数定义一个电路,
其中矩阵的第i
行具有值x[i-2],x[i-1],x[i]
。这意味着首先需要三个advice列,我们将它们称为
a, b, c
。
然后,你需要使用一个gate来将c
约束为a + b
,在同一行中实现这一点,这需要添加一个
selector列
来
控制它。你还需
要
设置等式约束,
以使a
和b
等于上一行的b
和c
。最后,为了将第N个数
公开为公共值,
你需要一个
instance列,
并将其约束为
等于最后一行的c
的值。
Chips、gadgets 和 regions #
Halo2 中电路的主要构建块是 gadgets 和 chips。 Chips 是最低级的单元。一个 chip 通常会公开一种配置其门的方法,以及在合成过程中为其单元分配值的多种方法(关于此阶段的更多信息请参见下文)。 你还可以构建由其他 chip 组成的 chip。
Gadgets 则在更高的抽象级别上操作,隐藏了底层 chip 的实现细节, 尽管你可以直接使用 chip 构建电路,而无需使用 gadgets。
为了促进可重用性,你会注意到 chip 总是基于相对偏移进行操作。这允许多个 chip 被组合成电路中的 regions。 一旦所有的 regions 及其形状被定义, floor planner 就负责在矩阵上安排这些 regions,因此你不需要直接定义每个 chip 的实际位置。然而,根据电路的结构方式,完全可以将所有内容打包到一个单独的 region 中,而不将放置的任务委托给 planner。
Halo2 Rust API #
在 Halo2 中,与 Circom 类似,需要牢记的主要事项是你的代码将在不同情况下多次调用:无论是配置矩阵、生成约束、创建证明还是计算见证。
你的电路需要实现一个
特定的 Circuit
trait,该 trait
定义了在整个生命周期中将被调用的方法,无论是使用具体的还是未知的
Values
:
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]
进行操作。例如,尽管可能看起来有些反直觉,
但是在configuration期间定义了门中的多项式约束,而等式约束则在合成期间设置。
Halo2 书中的示例详细介绍了如何使用单个芯片实现一个简单电路, 如果你需要一个参考框架,可以按照该示例进行操作。
Halo2 中的剪刀石头布游戏 #
在 Halo2 中,剪刀石头布游戏的实现比 Circom 更加冗长,所以我们只在这里复制一部分内容。首先,使用以下列设置矩阵:
- 为每个玩家
xs
和ys
创建一个建议列,其中行i
中的值表示玩家在第i
轮选择的动作。 - 第三个建议列
accum
跟踪累积总得分,因此第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抱歉,我给出的代码片段可能与 Halo2 中剪刀石头布游戏的实现不完全匹配。因为 Halo2 是一个开源项目,它的代码库可能会发生变化,我只能提供一些示例代码来帮助你理解 Halo2 的使用方法。
如果你想要了解更多关于在 Halo2 中实现剪刀石头布游戏的详细信息,我建议你查阅 Halo2 的官方文档和示例代码,以获得更准确和最新的信息。你可以在 Halo2 的 GitHub 存储库中找到官方文档和示例代码。
- Halo2 GitHub 存储库: https://github.com/zkcrypto/halo2
官方文档和示例代码将为你提供使用 Halo2 构建剪刀石头布游戏电路的详细说明和示例。希望这可以帮助你开始使用 Halo2 构建你的游戏电路。
Noir #
Noir,由
Aztec开发,是最新的语言之一,目前正在积极开发中。
它以nargo
形式分发,这是一个基于Rust的CLI工具,并且还提供了一组
npm包。
npm包的发布似乎略有滞后,而Rust crate具有最新的功能,并且仅在几天前(2月初)发布了
第一个稳定版。
Rust crate和npm包都可以用于编译Noir程序、生成和验证证明,并创建验证器Solidity合约。
Noir在很大程度上受到Rust的启发,因此它几乎与Rust具有相同的语法。它支持整数、布尔值、元组、结构体和数组,以及一种称为 field类型 的基本类型,它表示证明后端的本地字段中的元素(类似于将无符号整数限制在约254位的大素数范围内),并且比常规整数更高效。
与Circom不同,Noir不允许你定义仅用于生成witness的代码(也称为hints)。Noir默认支持控制流语句和运算符,并在需要时将它们转换为等效
的约束。这有助于防止任何未受限制的计算错误,但会影响性能。然而,你可以使用特殊的
constrain
关键字
定义与witness生成无关的约束。
在Noir中实现石头剪刀布游戏 #
与其他语言相比,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编写的程序。
资源 #
如果你已经到了这一步,想要继续学习,这里是一些我用来快速了解零知识证明的资源。我鼓励你阅读它们,如果你想直接从专家那里学习的话!
- CyberPorter 制作了 一套包含6个课程,对零知识证明提供了很好的概述,包括动机、领域的映射以及谁在开展相关工作,还有对 TornadoCash、Circom、Cairo、SnarkyJS 和 Noir 的深入介绍,以及 Groth16 背后的数学原理的讲解。
- 0xPARC 在线上有两门完整的课程,一门是关于 Circom 的,另一门是关于 Halo2 的。我强烈推荐先从 Circom 的课程开始,主要由伟大的 Gubsheep 主讲,他是 Dark Forest 游戏背后的人。这些课程包括实践性的讲习班,逐步指导你如何构建多个电路。如果你想尝试 Halo2,我会说这些课程是必看的,因为其他关于它的资源并不多。
- Vitalik 在他的博客上写了多篇文章,几乎涵盖了你需要了解 SNARKs 和 STARKs 的所有密码概念,以及有关这项技术的一些 有趣应用。如果你想更深入地了解零知识证明的工作原理,首先在他的博客上搜索一下。
- Scroll 团队正在构建一个 ZK-EVM,在零知识证明方面运营着一个优秀的教育博客。特别是关于 证明生成的文章,提供了一个完整的、逐步的工作流程,介绍了创建零知识证明时涉及的所有步骤。
- Awesome lists 是非常棒的资源,而 Matter Labs,zkSync 团队背后的团队,维护着一个名为 awesome-zero-knowledge-proofs 的列表,其中包含了各种资源的链接。
希望这些资源对你的学习有所帮助!