本文档详细介绍了 ZKEVM 的设计和实现,包括状态证明和 EVM 证明两个主要部分。状态证明负责验证状态操作的正确性,而 EVM 证明则验证 EVM 指令的正确执行,以及状态证明与 EVM 指令的一致性。此外,文章还深入探讨了Bus Mapping、存储、内存、堆栈等关键概念,并提供了一些伪代码示例,清晰阐述了 ZKEVM 的运作原理。
[TOC]
目前,以太坊上的每个节点都必须验证以太坊虚拟机中的每笔交易。这意味着每笔交易都会增加每个人验证以太坊历史所需的工作量。更糟糕的是,每笔交易都需要由每个新节点验证。这意味着新节点同步网络所需的工作量在不断增长。我们希望为以太坊区块构建有效性证明以避免这种情况。
这意味着为 EVM + 状态读取/写入 + 签名创建一个有效性证明。
为了简化,我们将证明分为两个部分。
只有在验证了两个证明之后,我们才能确信该以太坊区块已正确执行。
TODO:这两个电路如何连接的高级描述,即它们消耗相同的总线映射 [name=ying tong]
L1 合约 :是在以太坊主网上部署的合约。
zkevm 验证器 :是验证 zkevm 证明的 L1 合约。
L2 合约 :是部署在我们的 zkrollup 内部的合约,即它使用 zkevm 验证器来证明调用该智能合约的交易是正确的。
`l1tx` :是用户发送的用于与 L1 合约交互的交易。
`l2tx` :是用户发送的用于部署或与 L2 合约交互的交易
协调者 :为以太坊区块生成有效性证明的一方。协调者从用户那里接收 `l2tx`,验证它们,生成见证和证明,并将其发送给 zkevm 验证器。他们使用 `l1tx` 向 zkevm 验证器提供这些证明。
用户 :部署和/或与部署在 l2 上的智能合约交互的一方
Opcodes :EVM 操作码
[name=barryWhiteHat] 如果我们可以使用自定义约束条件,则可能会删除 slot
Slot (槽) :这是我们分配的用于证明每个 EVM opcode 有效性的约束数量。例如,我们可以有一个包含 100 个约束的 slot。这意味着我们将使用 100 个约束来证明 Mul opcode,但也包括 Add、Jump、Sload。每个 opcode 的成本为 100 个约束,即使它使用的约束少于 100 个。
自我提醒:“slot”=“行数” [name=ying tong]
[name=barryWhiteHat] 由于每个 opcode 都是多个 slot,因此可能会删除 multislot opcodes 的表示法。
MultiSlot opcodes (多槽操作码) :某些 opcode 可以使用多个 slot。这意味着我们只是增加 `slot_counter`,而不增加程序计数器。
是否每个 opcode 都使用一个 slot,并且每个 slot 使用可变数量的行? [name=ying tong] 我认为不是。在我看来,Slot 是一组用于实现固定功能的约束。对于正常的 opcode,一个 Slot 就足够了。对于复杂的 opcode,应该组合更多的 slot。 [name=Star.LI]
Program Counter (程序计数器) :要在 EVM 中执行的当前 opcode 的位置。这会在每个 opcode 之后更新;在 $\texttt{JUMP}$ 的情况下,它设置为 $\texttt{JUMP}$ 的位置。对于每个新的合约调用,它都初始化为零。
Slot Counter (槽计数器) :对于 multislot opcode,我们使用 slot 计数器来跟踪已执行了多少次执行。这会在每个 slot 之后递增。此变量用于跟踪 multislot opcode 何时终止。
Global Counter (全局计数器) :对于每个 opcode 都会递增的计数器。
准确地说,全局计数器是否计算读取和写入的次数? [name=ying tong] 是的,但它也用于区分你在 multi slot opcode 中的位置。对于调用数据复制,你可以使用全局计数器在该步骤中移动到下一步。 我觉得全局计数器非常重要,并且在许多地方用于保证许多事情。 我们在关于安全性的论证中使用了它 https://hackmd.io/Hy\_nqH4yTOmjjS9nbOArgw#Proof [name=barryWhiteHat]
Bus Mapping (总线映射) :在传统的计算机中,总线用于将数据从内存和存储传输到 CPU。在这里,我们重用该范例将数据从我们的状态证明传输到我们的 EVM 证明。
TODO:类型 [name=ying tong]
与传统计算机中的总线类似,`bus_mapping` 用于将信息从存储、内存和堆栈操作传输到 EVM 证明。
一个全局计数器,对于 EVM 证明的每个 slot 都会递增。
我们当前正在执行的交易/单独调用上下文的 ID
是要验证的 opcode 的 `program_index` 的值。
L2 合约的字节码中 opcode 的位置。
一个内存键值映射,从 `global_counter` -> `index` -> `value`
定义此内存操作的类型。例如,0 是读取,1 是写入。
用于存储读取/写入的键值映射。`global_counter` -> `index` -> `value`
定义要执行的存储操作的类型。
用于每个堆栈操作的键值映射。
定义堆栈操作的类型
这是要执行的当前 opcode。
构建用于验证 VM 的 snark 的一个巨大挑战是,你需要能够随时读取随机值/opcode。为此,我们使用键值映射。键值映射基本上是所有元素的随机线性组合
在 plookup 中,你可以按如下方式构建键值映射
``` python def build_mapping(): keys = [1,3,5] values = [2,4,6]
randomness = hash(keys, values)
mappings = []
for key , value in zip(keys,values): mappings.append(key + randomness*value) return(mappings) ```
可以以 3 个 plookup 为代价打开它
``` python def open_mapping(mappings, keys, values): randomness = hash(keys,values) index = 1
mapping = plookup(mappings) key = plookup(keys) value = plookup(values)
require(mappings[index] == key[index] + randomness*value[index])
```
EVM 证明和状态证明都使用总线映射。
``` bus_mapping[global_counter] = { mem: [op, key, value], stack: [op, index, value], storage: [op, key, value], index: opcode, call_id: call_id, prog_counter: prog_counter } ```
总线映射由证明者见证。在 EVM 证明中,我们必须确保证明者没有包含额外的变量。为此,我们限制其在 L1 EVM 中的度数,然后检查 EVM 证明中的每个元素。
总线映射的承诺是状态证明和 EVM 证明的公共输入。
一个区块有一堆常量,我们创建一个映射来支持这些常量。 对于每个这些 opcode,都可以在我们的 EVM 证明中查找此映射。
``` block = [blockhash, coinbase, timestamp, number, difficiulty, gaslimit, chainID] ```
[name=CPerezz] 如果任何区块常量需要在任何证明中用作公共输入,那么我认为它们都应该表示为标量,对吗?除了:`blockhash` 之外,所有元素都可以,因为 IIRC 的 repr 将不适合标量。 有什么想法吗?我们是否应该以不同的方式比较它,但将其保持在此格式中?使用不同的格式来存储该常量?
[name=barryWhiteHat] 不确定应该是哪个 这是由 l1 evm 创建并作为公共输入传递的 要么 这是作为证明者的见证变量传递并在证明中检查的 想要等待一些 gas 成本估算来找出
opcode | 名称 | 描述 |
---|---|---|
0x40 | BLOCKHASH | 获取 256 个最新完整区块之一的哈希 - 20 |
0x41 | COINBASE | 获取区块的受益人地址 |
0x42 | TIMESTAMP | 获取区块的时间戳 |
0x43 | NUMBER | 获取区块的编号 |
0x44 | DIFFICULTY | 获取区块的难度 |
0x45 | GASLIMIT | 获取区块的 gas 限制 |
0x46 | CHAINID | 返回当前链的 EIP-155 唯一标识符 (EIP1344) |
一个定义我们当前正在执行哪个调用的变量
我们的调用堆栈的当前深度,当前最大值为 1024 个调用
此合约的当前调用者
此交易的发起地址
如果此调用/l2tx 恢复,我们需要能够撤消所有存储写入。我们存储 storage_root_before,以便我们可以轻松地执行此操作。
用户为 gas 支付的 gas 价格
此程序拥有的 gas 量
TODO:添加有关构成状态证明中的 slot 的约束的详细信息。
^ 例如,完成条件 [name=ying tong]
有各种各样的 opcode 会导致读取和写入存储。它们包括
Flag | Opcode | 描述 |
---|---|---|
0x54 | SLOAD | 读取变量 |
0x55 | SSTORE | 写入变量 |
0xf0 | CREATE | 部署合约 |
0x31 | BALANCE | 获取用户的余额 |
0xf1 | CALL | 调用另一个帐户 |
0xf2 | CALLCODE | 使用另一个帐户的代码消息呼叫此帐户 |
0xf4 | DELEGATECALL | 使用另一个帐户的代码消息呼叫此帐户并保留更改。 |
0xfa | STATICCALL | 类似于调用,但更改不是持久的 |
0xfd | REVERT | 停止执行并恢复存储更改 |
获取当前存储根 | 使用它来强制执行静态调用和恢复 |
我们使用映射中的标志变量来定义在状态证明中正在检查这些 opcode 中的哪一个。
状态读取 opcode 将一个元素从存储复制到堆栈。
存储操作发生在状态证明中,并在 EVM 证明中验证。证明者能够在状态证明中选择他们想要的任何存储读取/写入。他们必须使用 `global_counter` 变量来提交已完成的读取和写入的顺序。
TODO:添加有关 merkle 证明的详细信息。
内存操作在 EVM 证明和状态证明中以不同的顺序执行。在状态证明中,内存操作按 `index`(内存中的位置)排序,然后按 `global_counter` 排序。在 EVM 证明中,它们按 `global_counter` 排序检查。
我们让我们的证明者提交他们想要的任何内存读取/写入。我们将检查正确的操作是否在正确的 `global_counter` 执行,并将其推迟到我们的 EVM 证明。
index | global_counter | value | memory_flag | 评论 |
---|---|---|---|---|
0 | 0 | 0 | 1 | 在 0 处初始化 |
0 | 12 | 12 | 1 | 写入 12 |
0 | 24 | 12 | 0 | 读取 12 |
1 | 0 | 0 | 1 | 在 0 处初始化 |
1 | 17 | 32 | 1 | 写入 32 |
1 | 89 | 32 | 0 | 读取 32 |
注意:这只是部分总线映射。实际上,我们还将在总线映射中包含堆栈、存储和 opcode 详细信息
global_counter | memory_flag | memory_index | memory_value |
---|---|---|---|
12 | 1 | 0 | 12 |
17 | 1 | 1 | 32 |
24 | 0 | 0 | 12 |
89 | 0 | 1 | 32 |
``` python
def memory(rands): bus_mapping = {{}} index = [0,0,0,1,1,1]
value = [0,12,12,0,32,32] memory_flag = [1,1,0,1,1,0] global_counter = [0,12,24,0,27,89]
prev_index = p-1
for i in range(10000):
for value, memory_flag, global_counter, index in zip(value, memory_flag, global_counter, index):
require(memory_flag == 1 or memory_flag == 0)
if index != prev_index:
prev_index = index require(value == 0) require(memory_flag == 1)
if (memory_flag == 0): require(current_value == value)
else (memory_flag == 1):
current_value = value
require(global_counter > previous_global_counter) previous_global_counter = global_counter
if(global_counter!=0):
global_counter = plookup(global_counter, valid_glbal_counters) mapping = plookup(mapping, valid_mappings) memory = "memory" memory_flag = memory_flag index = index value = value
require(mapping == global_counter + rand[0] * memory + rand[1] * memory_flag + rand[2] * index)
```
我们的堆栈有 16 个可以推送、弹出、复制和交换的元素。但它也包含必须存储的 1024 个元素。例如,你可以将 `3` 推送到堆栈 1024 次,然后弹出 1024 次。因此,我们需要能够支持这 1024 个存储元素。
为了证明我们的堆栈的有效性,我们做了与内存相同的事情。我们创建一个包含 $1024$ 个元素的内存表(索引为 0-1023)。我们还创建一个堆栈指针(`stack_pointer`),它初始化为 1024。这指向堆栈顶部的索引。
我们的 EVM 证明跟踪 `stack_pointer`,并在每次推送时递减它,并在每次弹出时递增它。
在我们的状态证明中,我们的证明者必须将每个堆栈元素初始化为零。然后,他们可以执行他们想要的任何更新。然后,他们将其提交到我们的总线映射。
在 EVM 证明中,对于检查堆栈的每个 opcode,我们从总线映射中获取与该堆栈关联的堆栈操作。我们检查 push、pop 的索引是否与 `stack_pointer` 匹配。然后,我们根据我们是弹出还是推送来调整 `stack_pointer` (`++`/`--`)。
与推送/弹出的情况类似,我们可以这样做。但是我们必须检查两个索引:目标和源;然后将该值推送到堆栈上。这需要一次读取,然后进行一次推送。
在这里,我们读取两次要交换的两个索引,并对每个索引执行两次写入以获取另一个值
注意:现在不允许在堆栈中的前 16 个元素之外推送弹出复制交换。为避免这种情况,我们必须对 stack_pointer 执行范围证明 - 索引小于 16。
^ 即,我们必须检查 `index` 是否为 4 位值。这肯定可以通过 4 位查找表来实现(无论如何,我们很可能在电路中有更大的查找表用于其他目的)。 [name=ying tong] [name=barryWhiteHat] 嗯,它是 stack_pointer - index 对吗?因为如果我在堆栈上推送 1016 个东西,我只能推送弹出复制和交换元素 1016 到 1000。
TODO:如果你只使用几个元素,则可能不需要在堆栈中包含 1024 个元素。我们可以让证明者调整这些
[name=barryWhiteHat] 也许我们可以删除 slot,而只支持必须应用的每个约束的列表。
可以将 slot 定义为单个自定义约束。此约束可以由 opcode 程序员定义以执行该操作。大多数 opcode 可能会执行的一些操作是
我们的 opcode 可以是多个唯一的自定义约束,我们只需要确保我们的选择器使用 slot_counter 来选择正确的自定义约束。
``` python
def slot(): opcode = plookup(t_opcodes) mapping = plookup(t_mappings)
assert global_counter + program_counter * r + opcode * r**2 == mapping
if slot_counter == fin_condition: slot_counter = 0 program_counter ++
else: slot_counter ++
gas_used += gas_cost
if gas_used > gas_limit:
oog = True program_counter = oog_opcode
global_counter ++ ```
slot 使用一堆变量来确保它在总线映射中查找正确的元素。这些变量是从上一个 slot 传递的。我们可以只查看上一个电路的连线,而不是使用复制约束来确保这些是正确的。
这些变量包括
这意味着我们的 evm 证明将有 5 根电线进入每个自定义约束,以及几个 advice 电线
(TODO?) [name=ying tong] [name=barryWhiteHat] 简而言之:我们使用 jump opcode 将程序计数器调整到它应该是什么,这就是我们处理循环和其他事情的方式。
我们的字段元素大小为 253 位。因此,我们无法在单个字段元素中表示 256 位(evm 字大小)的字。所有算术都以 p ~ 253 位为模完成,这也与 evm 不兼容。
为了使两者都与 EVM 兼容,我们将堆栈和内存元素编码为分解为 8 位的字的随机线性组合。
w 是我们的字,w[0:7] 是我们字的前 8 位。
word = w[0:7] + w[7:15]r + w[15:23] r^2 .. + w[247:255]r^31
注意:r 计算为所有可能的 8 位字的哈希值
[name=barryWhiteHat] 这里是否存在一些攻击?看起来有 2^8 个可能的元素,并且当我们将其增加到 32 个元素的总和时,在某个时候会出现一些“冲突” [name=barryWhiteHat] 如果这是一个问题,我们可以将 r 作为该轮中所有元素的列表。 如果这不是问题,那么我们可以将编码后的变量放入存储中。
这使我们通过在查找表中查找每个字来非常容易地进行算术运算,该查找表映射到该运算。
我们的堆栈和内存不需要为了适应这一点而进行调整。但是当我们在获取调用数据并读取或写入存储时,我们需要应用此编码/解码。
回想一下,总线映射的构造如下:
从总线映射中获取三个元素
``` value1 , value2 , value3 = bus_mapping[global_counter][call_id][prog_counter][stack][op_id][index] ```
^ 这里的 `bus_mapping` 是否与此对应(从上面复制)? ``` bus_mapping[global_counter] = { mem: [op, key, value], stack: [op, index, value], storage: [op, key, value], index: opcode, call_id: call_id, prog_counter: prog_counter } ``` `value1, value2, value3` 是否应该分别具有不同的 `op_id` 和 `index`?
[name=ying tong] 我打算删除 value2、value 3 并将 value 1 重命名为 value。认为我们不需要多个值,我们可以在我们的自定义 opcode 中包含 3 个堆栈操作。 我正在向 Han 询问这件事,因为我认为他对这件事进行了一些思考,所以很想听听他的想法。 如果总线映射的大小不是恒定的,我们会付出什么代价? [name=barryWhiteHat]
`value1`、 `value2` 和 `value3` 定义为 32 个 8 位字。
``` python carry_bit = 0 for i in range(len(value1)): result, carry_bit = plookup_add(value1[i], value2[i] carry_bit) require(result == value2[i]) ```
从总线映射中获取两个元素
``` value1 , value2 = bus_mapping[global_counter][call_id][prog_counter][stack][op_id][index] ```
`value1`、 `value2` 定义为 32 个 8 位字。
``` python carry_bit = 0
for i in range(len(value1),0):
old_carry_bit = carry_bit result, carry_bit = plookup_greater_than(value1[i], value2[i], carry_bit)
result == value3 ```
调用应更新 evm 证明中的 `call_id` 。这将允许证明者创建一个新的内存和堆栈,该内存和堆栈在状态证明中没有任何元素,并将其用于此调用上下文。
我们还跟踪 `calldatacallid`、 `calldata_start`、 `calldatasize` 以及 `returndatacallid`、 `returndata_size`、 `returndata_start`。如果使用 calldatacopy 或 return data copy opcode,我们要求证明者使用 callid 进行数据复制或 returndatacopy 将数据复制到 hte 当前 call_id 的内存中。
我们将 call_id 设置为我们想要跟踪的有关调用上下文的所有内容的随机线性组合
``` call_id -> rlc(calldata_callid, call_data_start_addr, call_data_size, return_data_callid, return_data_start_addr, return_data_size, origin, caller, call_value, call_stack_depth) ```
此映射针对每个调用进行更新。但不需要跟踪每一步。相反,它作为总线映射 RLC 的元素传递。
opcode | 名称 | 描述 |
---|---|---|
0x32 | ORIGIN | 获取执行发起地址 |
0x33 | CALLER | 获取调用者地址 |
0x34 | CALLVALUE | 使用此 tx 发送了多少 eth |
0x36 | CALLDATASIZE | 多少调用数据 |
0x35 | CALLDATALOAD | 什么是调用数据 |
0x3d | RETURNDATASIZE | 多少返回数据 |
返回数据 | 什么是返回数据 | |
调用堆栈深度 | ||
此调用之前的存储根 | 使用它来恢复并强制执行静态调用 |
到目前为止,我们已经讨论了具有恒定工作量的 opcode。例如 $\texttt{mul}$ 采用两个数字并将它们相乘。无论在计算时为 $\texttt{mul}$ 提供什么参数,证明 $\texttt{mul}$ 已正确执行的工作量仍然相同。
还有另一类 evm opcode 与它们相关联具有可变的工作量。这些 opcode 倾向于从内存中加载可变长度的项目。例如, `calldatacopy` 将调用数据复制到堆栈上。此调用数据的长度由智能合约程序员定义,并且是可变的。
这提出了一个困难的挑战。如果我们使我们的“opcode slots”足够长以支持每个可能的 `calldatalength` ,这将使我们的 opcode slot 变得非常大,从而使我们的其余 opcode 过于昂贵。相反,我们将这些 opcode 分隔在多个 opcode slots 上,以便我们继续处理它们,直到它们成功完成。
这些 opcode 包括
`calldatacopy`、 `returndatacopy`
我们通过定义一个必须满足才能递增 `program_counter` 的条件来对这些 opcode 进行算术化。然后,我们可以为同一 opcode 使用多个 global_counters。例如, `calldatacopy` 在我们递增程序计数器之前,必须将元素从 `call_details.calldata` 复制到堆栈 `calldatalength` 次。
类似的方法可用于 `returndatacopy` 和 `return` 。
我们有一个键值映射,它将智能合约字节码中的每个 opcode 映射到其字节偏移量(也称为 `program_index` )。此 `program_index` 是执行该 opcode 所需的 `program_counter` 值。
我们的证明者能够选择任何 `opcode`:`program_index` 对。我们想要确保证明者只能选择有效的执行分支。更改 opcode 的顺序、添加 opcode 或删除 opcode 等操作会破坏这一点。
IndexUniqueness :`program_index` 列表仅包含唯一值(无重复)。
GlobalCounterUniqueness :在 EVM 证明中,Opcodes 以递增的 `global_counter` 值执行,即。global_counter $0,1,2,3$
StartZero :`call_id` 的第一个 `program_counter` 变量为 0。
StopAtTermination :`call_id` 的最后一个 `program_counter` 变量为 opcode $\texttt{stop}$ 或 $\texttt{return}$
ProgramCounterTransition :每个 opcode 根据其规则调整 `program_counter` :除了执行当前 opcode 之外,没有其他方法可以更新 `program_counter` 。例如,$\texttt{mul}$ 将其递增 1。$\texttt{jump}$ 从堆栈中弹出一个元素并将程序计数器设置为等于该元素。
TODO:添加有关部署和加载字节码的推理
注意:这更多是关于部署的证明。
我们使用我们的总#### 证明
因为有了 $\texttt{Global_counterUniqueness}$ 假设,我们知道对于每个 global_counter 只能有一个元素。
在 EVM 证明中,我们确认每个元素都是正确的。
opcode 正确性论证 表明,我们以正确的顺序执行 opcode,直到到达存储读取/写入的 opcode。
$\texttt{ProgramCounterTransition}$ 假设对于每个 opcode(包括存储/堆栈/内存),我们正确地更新程序计数器。
我们还需要确认,在我们的状态证明中,我们没有执行任何额外的读取/写入操作,而这些操作没有在我们的 EVM 证明中进行检查。为此,我们使用 $\texttt{DegreeBound}$ 假设,该假设限制了 `bus_mapping` 的大小。然后,我们在 EVM 证明中确保检查每个 global_counter 值,直到达到我们的 degreeBound。
总有可能一个总线映射包含一个内存读取、一个存储写入和一个堆栈推送。这是无效的,因为没有 opcode 需要这 3 个操作。在这里,攻击者试图将存储写入隐藏在内存读取的 opcode 中。我们添加了 $\texttt{OpcodeFidelity}$ 假设,以确保 opcode 仅包含它们关心的变量的映射,其余变量都为零。例如,$\texttt{mul}$ opcode 的总线映射应该只包含堆栈的一个元素。
- 原文链接: hackmd.io/Hy_nqH4yTOmjjS...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!