过去的一年出现了很多零知识证明应用,在这个教程中, 我们将首先学习零知识证明的基本概念、使用circom搭建算术电路、 使用snarkjs实现零知识证明的全过程,并利用这些知识实现 二层扩容方案zk rollup。
1、算术电路:零知识证明核心
零知识程序和其他程序的实现不太一样。首先,你要解决的问题需要 先转化成多项式,再进一步转化成电路。例如,多项式x³ + x +5 可以 表示成如下的电路:
1 2 3 4 |
sym_1 = x * x // sym_1 = x² sym_2 = sym_1 * x // sym_2 = x³ y = sym_2 + x // y = x³ + x ~out = y + 5 |
Circom编译器将逻辑转换为电路。通常我们不需要自己设计基础电路。 如果你需要一个哈希函数或签名函数,可以在circomlib 找到。
2、证据的生成与验证:零知识证明的流程
在运行零知识证明程序之前,我们需要创建一个可信的设置,这需要 一个电路以及一些随机数。一旦设置完成就会生成一个证明密钥和一个 验证密钥,分别用于生成证据和执行验证。
一旦创建了证明/验证密钥对,就可以生成证据了。
有两种类型的输入:公开输入和私有输入。例如,A向B转账但是不希望 公开账户余额,那么A的账户余额就是私有输入,也被称为见证(Witness)。 公开输入可以是A和B的地址或者转账金额,这完全取决于你的具体设计。
接下来证明人就可以利用证明密钥、公开输入和见证来生成证据:
最后一步是验证。验证方使用公开输入、证据和验证密钥来验证证据。
公开输入、见证(私有输入)、证明密钥、验证密钥、电路、证据这些 基本概念以及相互之间的关系,就是我们继续下面的教程之前需要理解 的零知识证明的基本概念。
3、Circom基本概念:算术电路语言
首先我们先了解下Circom的语法。Circom的语法类似javascript和C, 提供一些基本的数据类型和操作,例如for、while、>>、array等。
让我们看一个具体的实例。
假设x、y是保密的(即witness),我们不想暴露x和y的具体值,但是 希望证明 (x y) + z == out,其中z,out是公开输入。我们假设 out = 30, z = 10, 那么显然 (xy) = 20,但是这不会暴露x和y的具体值。
circom提供了如下这些关键字用于描述算术电路:
- signal:信号变量,要转换为电路的变量,可以是private或public
- template:模板,用于函数定义,就像Solidity中的function或golang中的func
- component:组件变量,可以把组件变量想象成对象,而信号变量是对象的公共成员
Circom也提供了一些操作符用于操作信号变量:
- <==, ==>:这两个操作符用于连接信号变量,同时定义约束
- ←, →:这些操作符为信号变量赋值,但不会生成约束条件
- ===:这个操作符用来定义约束
好了,这些就是我们继续零知识证明实践需要了解的circom关键字。
4、用circom和snarkjs实现零知识证明应用的全流程
STEP 1:编译电路文件,生成circuit.json:
1 |
circom sample1.circom |
STEP 2:创建可信设置,使用groth协议生成proving_key.json和verification_key.json
1 |
snarkjs setup — protocol groth |
STEP 3:生成见证(私有输入)。这一步需要输入,因此应当将你的输入 存入input.json,就像下面这样:
1 2 |
// input.json {“x”:3, “y”:5, “z”: 100} |
使用下面的命令生成见证文件witness.json:
1 |
snarkjs calculatewitness |
STEP 4:使用如下的snarkjs命令生成证据:
1 |
snarkjs proof |
结果是得到proof.json、public.json。在public.json中包含了公开输入,例如:
1 2 3 4 5 |
// public.json { “115”, // → out “100” // → z:100 } |
STEP 5:使用如下snarkjs命令进行验证:
1 |
snarkjs verify |
5、零知识证明实践案例:zk rollup实现
zk rollup是一个二层解决方案,不过它和其他的二层方案不同。zk roolup 将所有数据放在链上,使用zk-snark进行验证。因此,不需要复杂的挑战游戏。 在zk rollup中,用户的地址记录在智能合约的merkle树上,使用3字节的索引 来表征用户的地址(地址的原始大小是20字节),因此zk rollup可以通过减小 数据大小来增加交易吞吐量。
为了便于理解,在下面的zk rollup实现中,我们有意忽略一些细节,原始 的zk rollup教程可以参考 ZKRollup Tutorial。
首先,有一个记录账号的merkle树,账号记录的内容是(公钥,余额)。每个交易 的内容是(发送方索引、接收方索引、金额)。流程如下:
1、检查发送方账号是否在merkle树上 2、验证发送方的签名 3、更新发送方的余额并验证中间merkle根 4、更新接收方的余额并更新merkle根
circom电路程序的变量定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// account tree signal input account_root; signal private input account_pubkey[2]; signal private input account_balance; // new account root after sender's balance is updated signal private input new_sender_account_root; // tx signal private input tx_sender_pubkey[2] signal private input tx_sender_balance signal private input tx_amount signal private input tx_sender_sig_r[2] signal private input tx_sender_sig_s signal private input tx_sender_path_element[levels] signal private input tx_sender_path_idx[levels] signal private input tx_receiver_pubkey[2] signal private input tx_receiver_balance signal private input tx_receiver_path_element[levels] signal private input tx_receiver_path_idx[levels] // output new merkle root signal output new_root; |
在这个案例中几乎所有的变量都是私有的,不管是公钥、账户余额还是签名等等, 只有merkle根和更新后的merkle根是公开的。path_element是构建merkle根的中间值, path_idx是一个索引数组,用于保存merkle树每一层的索引 —— 这时一个二叉树,因此 只有左右两个分支,0表示左,1表示右。最终的路径像一个二进制字符串:001011。
下面的circom代码检查发送方是否存在:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//__1. verify sender account existence component senderLeaf = HashedLeaf(); senderLeaf.pubkey[0] <== tx_sender_pubkey[0]; senderLeaf.pubkey[1] <== tx_sender_pubkey[1]; senderLeaf.balance <== account_balance; component senderExistence = GetMerkleRoot(levels); senderExistence.leaf <== senderLeaf.out; for (var i=0; i<levels; i++) { senderExistence.path_index[i] <== tx_sender_path_idx[i]; senderExistence.path_elements[i] <== tx_sender_path_element[i]; } senderExistence.out === account_root; |
上面的代码也比较简单,哈希发送方的公钥和账户余额,用merkle树 的中间值计算,然后得到merkle根(senderExistence.out)。检查 计算得到的merkle根和输入是否一致(account_root)。
出于简化考虑,我们省略了merkle树和哈希函数的实现,你可以查看 HashedLeaf 和GetMerkleRoot。
下面的circom代码检查发送方的签名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//__2. verify signature component msgHasher = MessageHash(5); msgHasher.ins[0] <== tx_sender_pubkey[0]; msgHasher.ins[1] <== tx_sender_pubkey[1]; msgHasher.ins[2] <== tx_receiver_pubkey[0]; msgHasher.ins[3] <== tx_receiver_pubkey[1]; msgHasher.ins[4] <== tx_amount component sigVerifier = EdDSAMiMCSpongeVerifier(); sigVerifier.enabled <== 1; sigVerifier.Ax <== tx_sender_pubkey[0]; sigVerifier.Ay <== tx_sender_pubkey[1]; sigVerifier.R8x <== tx_sender_sig_r[0]; sigVerifier.R8y <== tx_sender_sig_r[1]; sigVerifier.S <== tx_sender_sig_s; sigVerifier.M <== msgHasher.out; |
就像区块链交易需要验证发送方的签名一样,在上面的代码中, 我们首先哈希消息然后进行签名,然后调用不同的封装函数。
更新发送方余额并检查新的merkle根。
1 2 3 4 5 6 7 8 9 10 11 12 |
//__3. Check the root of new tree is equivalent component newAccLeaf = HashedLeaf(); newAccLeaf.pubkey[0] <== tx_sender_pubkey[0]; newAccLeaf.pubkey[1] <== tx_sender_pubkey[1]; newAccLeaf.balance <== account_balance - tx_amount; component newTreeExistence = GetMerkleRoot(levels); newTreeExistence.leaf <== newAccLeaf.out; for (var i=0; i<levels; i++) { newTreeExistence.path_index[i] <== tx_sender_path_idx[i]; newTreeExistence.path_elements[i] <== tx_sender_path_element[i]; } newTreeExistence.out === new_sender_account_root; |
前面的两个步骤从发送方的角度检查信息,然后更新发送方的余额 并计算新的merkle根。最下面一行:newTreeExistence.out === new_sender_account_root;
作用是检查计算得到的merkle根和输入(new_sender_account_root)是否一致。 通过这个检查,可以避免伪造或不正确的输入。
下面的代码更新接收方余额以及merkle树:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//__5. update the root of account tree component newReceiverLeaf = HashedLeaf(); newReceiverLeaf.pubkey[0] <== tx_receiver_pubkey[0]; newReceiverLeaf.pubkey[1] <== tx_receiver_pubkey[1]; newReceiverLeaf.balance <== tx_receiver_balance + tx_amount; component newReceiverTreeExistence = GetMerkleRoot(levels); newReceiverTreeExistence.leaf <== newReceiverLeaf.out; for (var i=0; i<levels; i++) { newReceiverTreeExistence.path_index[i]<==tx_receiver_path_idx[i]; newReceiverTreeExistence.path_elements[i] <==tx_receiver_path_element[i]; } new_root <== newReceiverTreeExistence.out; |
最后一步更新接收方余额,计算并输出新的merkle根。一旦电路构建好, 就像一个黑盒子。如果你输入正确的值,那么输出一定是正确的,因此 用户容易检查以避免恶意中间人。这就是为什么我们需要在电路最后输出 一些东西的原因 —— 在这个案例里我们输出的是merkle根。
zk rollup聚合了很多上述交易并生成单一证据来所见数据大小。在这个 教程中为了便于理解,我们仅处理单一交易,点击这里 查看完整代码。
原文链接:Hands-on Your first ZK application