本文档详细描述了 Matter Labs 的 ZKsync Era 的系统合约和启动加载器的架构与功能。
在标准的以太坊客户端上,执行区块的工作流程如下:
然而,在 ZKsync 上采用这样的流程(即,逐个处理交易)效率太低,因为我们必须为每个单独的交易运行整个证明工作流程。这就是我们需要 启动加载器 的原因:与其分别运行 N 个交易,不如我们将整个批次(一组区块,更多内容可以在这里找到)作为一个单独的程序运行,该程序接受交易数组以及一些其他的批次元数据,并在一个大的“交易”中处理它们。思考 bootloader 的最简单方式是考虑 EIP4337 中的 EntryPoint:它也接受交易数组并促进账户抽象协议。
启动加载器的代码哈希存储在 L1 上,并且只能作为系统升级的一部分进行更改。请注意,与系统合约不同,启动加载器的代码没有存储在 L2 上的任何地方。这就是我们有时将启动加载器的地址称为形式地址的原因。它仅仅是为了向 this
/ msg.sender
/ 等提供一些值而存在。当有人调用启动加载器地址(例如,支付费用)时,实际上调用的是 EmptyContract 的代码。
虽然大多数原始的 EVM 操作码可以开箱即用(即,零值调用、加法/乘法/内存/存储管理等),但有些操作码默认情况下不受 VM 支持,它们是通过“系统合约”实现的——这些合约位于特殊的 内核空间 中,即地址空间在 [0..2^16-1]
范围内,并且它们具有用户合约不具备的一些特殊权限。这些合约在创世之初就已预部署,并且只能通过从 L1 管理的系统升级来更新其代码。
每个系统合约的用途将在下面解释。
某些合约需要在创世之初进行预部署,但它们不需要内核空间权限。为了给它们最小的权限,我们将它们预部署在紧随 2^16
之后的连续地址上。这些将在以下各节中描述。
zkEVM 的完整规范超出了本文档的范围。然而,本节将为你提供理解 L2 系统智能合约以及 EVM 和 zkEVM 之间基本差异所需的大部分细节。
在 EVM 上,在交易执行期间,可以使用以下内存区域:
memory
本身。calldata
父内存的不可变切片。returndata
最近一次调用另一个合约返回的不可变切片。stack
存储局部变量的地方。与堆栈机 EVM 不同,zkEVM 有 16 个寄存器。zkEVM 不是从 calldata
接收输入,而是通过在其第一个寄存器中接收一个 指针 开始(基本上是一个包含 4 个元素的压缩结构体:内存页 id,切片的开始和长度,它指向父级的 calldata 页)。类似地,交易可以在程序开始时在其寄存器中接收一些其他的附加数据:交易是否应该调用构造函数(更多关于部署的信息在这里),交易是否具有 isSystem
标志等。每个标志的含义将在本节中进一步扩展。
指针 是 VM 中的单独类型。只有以下操作是可能的:
return
指向当前帧内存或当前帧的子调用返回的指针之一的指针。对于每个帧,分配以下内存区域:
memory
扮演相同的角色)。此外,如前一节所述,合约接收指向 calldata 的指针。
每当合约完成其执行时,父帧会收到一个 指针 作为 returndata
。此指针可能指向子帧的堆/辅助堆,甚至可能是子帧从其某些子帧收到的同一个 returndata
指针。
calldata
也是如此。每当合约开始执行时,它会收到指向 calldata 的指针。父帧可以提供任何有效的指针作为 calldata,这意味着它可以是指向父帧内存(堆或辅助堆)切片的指针,也可以是父帧之前作为 calldata/returndata 接收的某个有效指针。
合约只是记住执行帧开始时的 calldata 指针(这是编译器的设计),并记住最近收到的 returndata 指针。
其中一些重要的含义是,现在可以执行以下调用而无需任何内存复制:
A → B → C
其中 C 接收 B 接收的 calldata 的切片。
返回数据也是如此:
A ← B ← C
如果 B 返回 C 返回的 returndata 的切片,则无需复制返回的数据。
请注意,你 不能 使用你通过 calldata 收到的指针作为 returndata(即,在执行帧结束时返回它)。否则,returndata 可能会指向活动帧的内存切片并允许编辑 returndata
。这意味着在上面的示例中,C 无法在没有内存复制的情况下返回其 calldata 的切片。
请注意,上面的规则是通过“不可能返回内存页面的 id 低于当前堆的内存页面 id 的数据切片”的原则来实现的,因为 id 较小的内存页面只能在调用之前创建。这就是为什么用户合约通常可以安全地返回先前返回的 returndata 的切片(因为它保证具有更高的内存页面 id)。但是,系统合约可以免除上述规则。特别是需要 CodeOracle
系统合约的正确功能。你可以在此处阅读更多相关信息。因此,经验法则是永远不要传递来自 CodeOracle
的 returndata。
其中一些内存优化可以在 EfficientCall 库中看到,该库允许在重用帧已经拥有的 calldata 切片的同时执行调用,而无需内存复制。
以太坊上的一些操作是操作码,但在 ZKsync 上已成为对某些系统合约的调用。最值得注意的例子是 Keccak256
、SystemContext
等。请注意,如果天真地完成,以下代码行将在 ZKsync 和以太坊上以不同的方式工作:
pop(call(...))
keccak(...)
returndatacopy(...)
因为调用 keccak 预编译会修改 returndata
。为了避免这种情况,我们的编译器不会在调用此类类似操作码的预编译后覆盖最新的 returndata
指针。
虽然某些以太坊操作码不直接支持,但添加了一些新的操作码以方便系统合约的开发。
请注意,此列表的目的不是具体说明内部原理,而是解释 SystemContractHelper.sol 中的方法
这些操作码仅允许内核空间(即系统合约)中的合约使用。如果在其他地方执行,它们会导致 revert(0,0)
。
mimic_call
。与普通 call
相同,但它可以更改交易的 msg.sender
字段。to_l1
。向以太坊发送一个系统 L2→L1 日志。可以在此处查看此日志的结构。event
。向 ZKsync 发出一个 L2 日志。请注意,L2 日志不等同于以太坊事件。每个 L2 日志可以发出 64 字节的数据(实际大小为 88 字节,因为它包括发射器地址等)。单个以太坊事件由多个 event
日志构成。此操作码仅由 EventWriter
系统合约使用。precompile_call
。这是一个接受两个参数的操作码:表示其打包参数的 uint256 以及要燃烧的 ergs。除了预编译调用本身的价格外,它还会燃烧提供的 ergs 并执行预编译。它执行的操作取决于执行期间的 this
:
ecrecover
系统合约的地址,它将执行 ecrecover 操作sha256
/keccak256
系统合约的地址,它将执行相应的哈希操作。setValueForNextFarCall
为下一个 call
/mimic_call
设置 msg.value
。请注意,这并不意味着该值将真正转移。它只是设置相应的 msg.value
上下文变量。ETH 的转移应通过使用此参数的系统合约以其他方式完成。请注意,此方法对 delegatecall
没有影响,因为 delegatecall
继承了先前帧的 msg.value
。increment_tx_counter
递增 VM 中交易的计数器。交易计数器主要用于 VM 内部跟踪事件。仅在每个交易结束后在启动加载器中使用。decommit
将返回一个指向具有相应字节码哈希原像的切片的指针。如果此字节码之前已解压缩,则将重用已解压缩它的内存页面。如果它从未解压缩过,它将被解压缩到当前堆中。请注意,目前我们无法在 VM 中访问 tx_counter
(即,目前可以递增它,它将自动用于诸如 event
之类的日志以及由 to_l1
生成的系统日志,但我们无法读取它)。我们需要读取它以发布 用户 L2→L1 日志,因此 increment_tx_counter
始终伴随对 SystemContext 合约的相应调用。
有关系统日志和用户日志之间差异的更多信息,请参见此处。
以下是任何合约通常可以访问的操作码。请注意,虽然 VM 允许访问这些方法,但这并不意味着它很容易:编译器可能尚未对某些用例提供方便的支持。
near_call
。它基本上是一个“成帧的”跳转到你的合约代码的某个位置。near_call
和普通跳转之间的区别在于:
far_call
”s(即,合约之间的调用)不同,63/64 规则不适用于它们。getMeta
。返回 ZkSyncMeta 结构的 u256 打包值。请注意,这不是紧密打包。该结构由以下 rust 代码形成。getCodeAddress
- 接收执行代码的地址。这与 this
不同,因为在 delegatecall 的情况下,this
被保留,但 codeAddress
没有。除了 calldata 之外,还可以在执行 call
、mimic_call
、delegate_call
时向被调用方提供其他信息。被调用合约将在执行开始时在其前 12 个寄存器中收到以下信息:
isConstructor
标志。此标志只能由系统合约设置,并表示该帐户是否应执行其构造函数逻辑。请注意,与以太坊不同,构造函数和部署字节码之间没有分隔。有关更多信息,请参见此处。第 1 位:isSystem
标志。该调用是否打算调用系统合约的功能。虽然大多数系统合约的功能相对无害,但仅使用 calldata 访问某些功能可能会破坏以太坊的不变量,例如,如果系统合约使用 mimic_call
:没有人期望通过调用合约,可能会以调用者的名义执行某些操作。只有在被调用方在内核空间中时,才能设置此标志。isSystem
标志时才非空。可能会传递任意值,我们称之为 extraAbiParams
。编译器实现是,这些标志由合约记住,并且可以通过特殊的模拟在执行期间稍后访问。
如果调用方提供不适当的标志(即,尝试在被调用方不在内核空间时设置 isSystem
标志),则将忽略这些标志。
onlySystemCall
修饰符某些系统合约可以代表用户行事,或者对帐户的行为产生非常重要的影响。这就是我们想要明确用户不能通过执行简单的类似 EVM 的 call
来调用潜在危险的操作的原因。每当用户想要调用我们认为危险的某些操作时,他们都必须提供“isSystem
”标志。
onlySystemCall
标志检查调用是否已通过提供的“isSystemCall”标志完成,或者该调用是否由另一个系统合约完成(因为 Matter Labs 完全了解系统合约)。
将来,我们计划推出我们的“扩展”版本的 Solidity,其中包含比原始版本更多的受支持的操作码。但是,目前团队的能力还不足以做到这一点,因此为了表示访问 ZKsync 特定的操作码,我们使用带有某些常量参数的 call
操作码,这些参数将由编译器自动替换为 zkEVM 本地操作码。
例:
function getCodeAddress() internal view returns (address addr) {
address callAddr = CODE_ADDRESS_CALL_ADDRESS;
assembly {
addr := staticcall(0, callAddr, 0, 0xFFFF, 0, 0)
}
}
在上面的示例中,编译器将检测到静态调用已完成到常量 CODE_ADDRESS_CALL_ADDRESS
,因此它将使用用于获取当前执行的代码地址的操作码替换它。
操作码模拟的完整列表可以在此处找到。
我们还使用类似 verbatim 的语句来访问启动加载器中 ZKsync 特定的操作码。
我们 Solidity 代码中所有模拟的用法都在 SystemContractHelper 库和 SystemContractsCaller 库中实现。
near_call
(仅限 Yul)为了使用 near_call
,即调用一个本地函数,同时提供此函数可以使用的 ergs(gas)限制,使用以下语法:
该函数应在其名称中包含 ZKSYNC_NEAR_CALL
字符串,并且至少接受 1 个输入参数。第一个输入参数是 near_call
的打包 ABI。目前,它等于要与 near_call
一起传递的 ergs 数量。
每当 near_call
发生 panic 时,都会调用 ZKSYNC_CATCH_NEAR_CALL
函数。
重要提示: 编译器的行为方式是,如果启动加载器中存在 revert
,则不会调用 ZKSYNC_CATCH_NEAR_CALL
并且父帧也会被回滚。仅回滚 near_call
帧的唯一方法是触发 VM 的 panic(可以通过无效的操作码或 gas 不足错误触发)。
重要提示 2: 63/64 规则不适用于 near_call
。此外,如果为 near call 提供了 0 gas,那么实际上所有可用的 gas 都将用于它。
为防止意外替换,编译器需要传递 --system-mode
标志才能使上述替换工作。
请注意,在较新版本的编译器中,
--system-mode
已重命名为enable_eravm_extensions
(这可以在例如我们的 foundry.toml 中看到)
在 ZKsync 上,字节码哈希以以下格式存储:
0
,对于正在构造的合约代码为 1
。字节以小端顺序排序(即,与 bytes32
的方式相同)。
如果字节码满足以下条件,则它是有效的:
请注意,它不必仅由正确的操作码组成。如果 VM 遇到无效的操作码,它将简单地回滚(类似于 EVM 处理它们的方式)。
无法证明对具有无效字节码的合约的调用。这就是为什么 至关重要 的是,任何具有无效字节码的合约都不能部署在 ZKsync 上。这是 KnownCodesStorage 的工作,以确保系统中所有允许的字节码都是有效的。
ZKsync 的另一个重要功能是支持帐户抽象。强烈建议在此处阅读有关我们的 AA 协议的文档:https://docs.zksync.io/zk-stack/concepts/account-abstraction
每个帐户还可以指定他们支持哪个版本的帐户抽象协议。这是需要在未来允许对协议进行重大更改。
目前,支持两个版本:None
(即,它是一个简单的合约,它永远不应该用作交易的 from
字段)和 Version1
。
帐户还可以向操作员发出信号,表明它应该期望从这些帐户中获取哪个 nonce 排序:Sequential
或 Arbitrary
。
Sequential
表示 nonce 的排序方式应与 EOA 中的排序方式相同。这意味着,例如,操作员将始终等待 nonce 为 X
的交易,然后再处理 nonce 为 X+1
的交易。
Arbitrary
表示 nonce 可以按任意顺序排序。服务器目前支持它,即,如果存在具有任意 nonce 排序的合约,则其交易很可能会因 nonce 不匹配而被拒绝或卡在内存池中。
请注意,这不会以任何方式由系统合约强制执行。可能存在一些健全性检查,但允许帐户做任何他们喜欢的事情。这更多的是向操作员建议如何管理内存池。
现在,帐户和 paymaster 都需要在验证时返回一个特定的 magic value。在主网上将强制执行此 magic value 的正确性,但在费用估算期间将忽略它。与以太坊不同,签名验证 + 费用收取/nonce 递增不包含在交易的内在成本中。这些费用作为执行的一部分支付,因此需要作为交易成本估算的一部分进行估算。
通常,建议帐户执行尽可能多的正常验证期间的操作,但最终只返回无效的 magic。这将允许正确(或至少尽可能正确)地估算帐户验证的价格。
启动加载器是接受交易数组并执行整个 ZKsync 批次的程序。本节将扩展其不变量和方法。
为了方便起见,我们在主网批次和模拟 ethCall 或其他测试活动中使用相同的启动加载器实现。只有 已证明的 启动加载器才用于构建批次,因此本文档仅描述它。
ZKPs 强制执行启动加载器的状态等同于具有空 calldata 的合约交易的状态。唯一的区别是它以预先分配所有可能的内存(以避免内存扩展的成本)开始。
为了提高效率(以及为了我们的方便),启动加载器在其内存中接收其参数。这是非确定性的唯一点:启动加载器 以预先填充了操作员想要的任何数据的内存开始。这就是它负责验证其正确性的原因,并且它不应依赖于内存的初始内容是正确且有效的。
例如,对于每个交易,我们都会检查它是否已正确 ABI 编码,并且交易一个接一个地进行。我们还确保交易不超过允许交易使用的内存空间的限制。
虽然主要交易格式是内部 Transaction
format,但它是一个用于表示各种交易类型的结构体。它包含大量 reserved
字段,这些字段可用于取决于未来交易类型,而无需 AA 更改其合约的接口。
交易的确切类型由交易类型的 txType
字段标记。目前支持 6 种类型:
txType
:0。这意味着此交易属于旧版交易类型。强制执行以下限制:
maxFeePerErgs=getMaxPriorityFeePerErg
,因为它是 pre-EIP1559 tx 类型。reserved1..reserved4
以及 paymaster
均为 0。paymasterInput
为零。reserved0
字段可以设置为非零值,表示此旧版交易与 EIP-155 兼容,并且其 RLP 编码(以及签名)应包含系统的 chainId
。txType
:1。这意味着该交易的类型为 1,即交易访问列表。ZKsync 不以任何方式支持访问列表,因此不会提供满足此列表的任何好处。假定访问列表为空。强制执行与类型 0 相同的限制,但 reserved0
也必须为 0。txType
:2。它是 EIP1559 交易。应用与类型 1 相同的限制,但现在 maxFeePerErgs
可能不等于 getMaxPriorityFeePerErg
。txType
:113。它是 ZKsync 交易类型。此交易类型旨在支持 AA。适用于此交易类型的唯一限制:字段 reserved0..reserved4
必须等于 0。txType
:254。它是一种用于升级 L2 系统的交易类型。这是唯一允许以内核空间中合约的名义启动交易的交易类型。txType
:255。它是来自 L1 的交易。对此类交易几乎没有明确施加任何限制,因为启动加载程序在其执行结束时会发送已执行的优先级交易的滚动哈希。L1 合约确保哈希确实与 L1 上优先级交易的哈希匹配。你还可以在此处阅读有关 L1->L2 交易和升级交易的更多信息。
但是,如前所述,启动加载程序的内存是不确定的,操作员可以自由地将任何其想要的内容放入其中。对于上述所有交易类型,以下限制均强制执行(method),它在开始处理交易之前被调用。
启动加载器期望以下内存结构(这里我们用字表示 32 个字节,与 EVM 上的机器字相同):
前 8 个字是为操作员提供的批次信息保留的。
0
字 — 操作员的地址(交易的受益人)。1
字 — 上一个批次的哈希。其验证将在后面解释。2
字 — 当前批次的时间戳。其验证将在后面解释。3
字 — 新批次的编号。4
字 — 公平的 pubdata 价格。有关如何计算我们的 pubdata 的更多信息,请参见此处。5
字 — L2 gas 的“公平”价格,即批次的 baseFee
不应低于的价格。目前,它由操作员提供,但在未来可能会变得硬编码。6
字 — 操作员期望的批次的基本费用。虽然基本费用是确定的,但它仍然提供给启动加载器,只是为了确保操作员拥有的数据与启动加载器提供的数据一致。7
字 — 保留字。在已证明的批次中未使用。批次信息槽在批次开始时使用。一旦读取,这些槽可用于临时数据。
[8..39]
– 用于调试目的的保留槽[40..72]
– 用于保存当前交易的 paymaster 上下文数据的槽。paymaster 上下文的作用类似于 EIP4337 的作用。你可以在帐户抽象文档中阅读有关它的更多信息。[73..74]
– 当前处理的 L2 交易的签名交易和资源管理器交易哈希的槽。[75..142]
– 68 个用于 KnownCodesContract 调用的 calldata 的槽。[143..10142]
– 10000 个用于交易退款的槽。[10143..20142]
– 10000 个用于交易的批次开销的槽。此开销由操作员建议,即启动加载程序仍将仔细检查操作员是否未向用户收取过高费用。[20143..30142]
– 操作员提供的“可信” gas 限制的槽。用户的交易将拥有 min(MAX_TX_GAS(), trustedGasLimit)
,其中 MAX_TX_GAS
是系统保证的常量。目前,它等于 8000 万 gas。将来,此功能将被删除。[30143..70146]
– 用于存储每个交易的 L2 区块信息的槽。你可以在此处阅读有关 L2 区块和批次之间差异的更多信息。[70147..266754]
– 用于压缩字节码的槽,每个字节码都采用以下格式:
BytecodeCompressor
的 publishCompressedBytecode
函数的 4 字节选择器)[266755..266756]
– 存储当前优先级操作的哈希和数量的槽。有关更多信息,请参见优先级操作section。[266757..1626756]
– 最终批次 pubdata 的提供槽,由 L2DAValidator 验证。简而言之,此空间用于 L1Messenger 的 publishPubdataAndClearState
函数的 calldata,该函数接受 L2DAValidator 的地址以及要检查的 pubdata。L2DAValidator 是一个负责确保 在处理 pubdata 时提高效率的合约。通常,calldata L2DAValidator
会包括字节码、L2->L1 消息、L2->L1 日志等的未压缩原像请注意,虽然可以在一个批次中发布的实际 加粗 pubdata 数量约为 780kb,但由于 L1Messenger 的 calldata 大小可能要大得多,因为它也接受原始的未压缩状态差异条目。这些数据不会发布到 L1,但会用于验证压缩的正确性。
批次中状态差异数量的“最坏情况”之一是,当 780kb 的 pubdata 用于重复写入时,这些写入都会被清零。在这种情况下,差异数量为 780kb / 5 = 156k。这意味着它们将容纳 42432000 字节的 calldata 用于未压缩的状态差异。在此基础上增加 780kb,我们得到大约需要 43212000 字节的 calldata。需要 1350375 个插槽来容纳这些数据。为了以防万一,我们将其向上舍入到 1360000 个插槽。
但在理论上,可以使用更多的 calldata(例如,如果 1 个字节用于枚举索引)。 运营商有责任确保它可以为 L1Messenger 形成正确的 calldata。
[1626756..1646756]
words — 10000 个交易元描述的 20000 个插槽(它们的结构在下面解释)。出于与引导加载程序内存某些内容相关的零知识证明未来可能集成的内部原因,交易数组不是作为交易数组的 ABI 编码传递的,而是:
struct BootloaderTxDescription {
// 存储 ABI 编码的交易数据的偏移量
uint256 txDataOffset;
// 交易执行的辅助数据。在我们内部版本的引导加载程序中
// 它可能有一些特殊的含义,但对于
// 主网上使用的引导加载程序,它只有一个含义:是否执行
// 交易。如果为 0,则不应再执行交易。如果为 1,那么
// 我们应该执行此交易,并可能尝试执行下一个交易。
uint256 txExecutionMeta;
}
[1646756..1646795]
words — 40 个插槽,可用于编码支付方的 postOp 方法的调用。为了避免为帐户抽象的调用额外复制交易,我们保留了一些插槽,这些插槽可以用来为帐户抽象的 postOp
调用形成 calldata,而无需复制整个交易的数据。
[1646796..1967599]
从 487312 字开始,实际的交易描述开始。(该结构可以通过此 链接 找到)。引导加载程序强制执行:
Transaction
的内容来保持效率。[1967600..1967602]
这些是纯粹用于调试目的的内存插槽(当 VM 写入这些插槽时,服务器端可以捕获这些调用,并为调试问题提供重要的见解信息)。
[1967602..1977602]
这些是用于跟踪交易成功状态的内存插槽。如果编号为 i
的交易成功,则插槽 937499 - 10000 + i
将标记为 1,否则为 0。
execute
字段。如果未设置,则结束交易的处理并结束批处理的执行。如果 execute 字段非零,则将执行该交易并转到步骤 3。在 ZKsync 上,每个地址都是一个合约。用户可以从他们的 EOA 帐户开始交易,因为每个在其上没有部署任何合约的地址都隐式地包含在 DefaultAccount.sol 文件中定义的代码。每当有人调用不在内核空间内的合约(即地址 ≥ 2^16)并且没有任何合约代码部署在其上时,DefaultAccount
的代码将用作合约的代码。
请注意,如果你调用一个在内核空间内且没有在那里部署任何代码的帐户,那么现在该交易将还原。
我们根据我们的帐户抽象协议处理 L2 交易:https://docs.zksync.io/build/developer-reference/account-abstraction。
然后,我们根据 EIP1559 规则计算这些交易的 gasPrice。
block.baseFee
上下文变量,因此它们无法知道要支付的确切金额。这就是为什么帐户通常首先发送 tx.maxFeePerErg * tx.ergsLimit
,并且引导加载程序退还任何发送的超额资金。我们执行交易。请注意,如果发送方是 EOA,则 tx.origin 设置为等于交易的 from
值。在交易执行期间,会发布压缩的字节码:对于每个尚未发布的工厂依赖项,并且其哈希值当前指向引导加载程序的压缩字节码区域,则会调用字节码压缩器。此外,在结束时,会对 KnownCodeStorage 进行调用,以确保所有字节码确实已发布。
我们退还用户在交易中花费的任何超额资金:
postTransaction
操作。我们通知操作员已授予用户的退款。它将用于在资源管理器中正确显示交易的 gasUsed。
L1->L2 交易是在 L1 上启动的交易。我们假设 from
已经授权了 L1→L2 交易。它还在 L1 上设置了其 L1 pubdata 价格以及 ergsPrice。
省略了 L2 交易执行中的大多数步骤,我们将 tx.origin
设置为 from
,并将 ergsPrice
设置为交易提供的价格。之后,我们使用 mimicCall 以发送方帐户的名义提供操作本身。
请注意,对于 L1→L2 交易,reserved0
字段表示应在此交易后在 L2 上铸造的 ETH 数量。reserved1
是退款接收者地址,即接收交易退款以及 msg.value 的地址(如果交易失败)。
有两种 L1->L2 交易:
255
)。254
)。你可以在相应的文档中阅读有关这些差异的更多信息。
在批处理结束时,我们将 tx.origin
和 tx.gasprice
上下文变量设置为零,以节省 L1 gas 在 calldata 上,并将整个引导加载程序余额发送给操作员,从而有效地将费用发送给他。
此外,我们设置了虚构的 L2 区块数据。然后,我们调用系统上下文以确保它发布 L2 区块以及 L1 批处理的时间戳。我们还重置 txNumberInBlock
计数器,以避免将其状态差异发布到 L1 上。你可以在 此处 阅读有关 ZKsync 上区块处理的更多信息。
之后,我们发布此批处理中的哈希以及优先级操作的数量。有关此的更多信息,请参见此处。
然后,我们调用 L1Messenger 系统合约,以便它组成要发布在 L1 上的 pubdata。你可以在 此处 阅读有关 pubdata 处理的更多信息。
有关系统合约执行的实现和要求的更多详细信息,可以在其各自代码库的 doc-comments 中找到。本章仅作为此类合同的高级概述。
所有系统合约的代码(包括 DefaultAccount
)都是协议的一部分,只能通过 L1 的系统升级进行更改。
此合约用于支持 VM 默认未包含的各种系统参数,即 chainId
、origin
、ergsPrice
、blockErgsLimit
、coinbase
、difficulty
、baseFee
、blockhash
、block.number
、block.timestamp.
重要的是要注意,genesis 时不为此合约运行构造函数,即在 genesis 时显式设置常量上下文值。值得注意的是,如果将来我们想升级合约,我们将通过 ContractDeployer 来完成,因此将运行构造函数。
此合约还负责确保批处理、L2 区块的有效性和一致性。实现本身非常简单,但为了更好地理解此合约,请查看有关 ZKsync 上区块处理的页面。
帐户的代码哈希存储在此合约的存储中。每当 VM 调用地址为 address
的合约时,它都会检索此系统合约的存储槽 address
下的值,如果此值为非零,则将其用作帐户的代码哈希。
每当调用合约时,VM 都会要求操作员提供帐户代码哈希的 preimage。这就是代码哈希的数据可用性至关重要的原因。
为了防止合约在其构建期间能够调用合约,我们将标记(即帐户字节码哈希的第二个字节)设置为 1
。这样,VM 将确保每当在没有 isConstructor
标志的情况下调用合约时,将替换默认帐户(即 EOA)的字节码,而不是原始字节码。
此合约包含一些纯粹为引导加载程序功能所需的函数,但为了方便起见,已从引导加载程序本身移出,而不在 Yul 中编写此逻辑。
每当合约不同时满足以下条件时:
AccountCodeStorage
中相应存储槽下存储的值为零)将使用默认帐户的代码。此合约的主要目的是为钱包用户和调用它的合约提供类似 EOA 的体验,即它与 Ethereum 上的 EOA 帐户没有什么区别(除了花费的 gas)。
ecrecover 预编译的实现。它预计会经常使用,因此使用纯 yul 和自定义内存布局编写。
该合约接受与 EVM 预编译格式相同的 calldata,即前 32 个字节是哈希,接下来的 32 个字节是 v,接下来的 32 个字节是 r,最后 32 个字节是 s。
它还使用与 EVM 预编译相同的规则验证输入:
之后,它进行预编译调用,如果调用失败,则返回空字节,否则返回恢复的地址。
某些合约被认为具有类似 EOA 的行为,即它们可以始终被调用并获得成功的返回值。此类地址的一个示例是 0 地址。我们还要求引导加载程序可调用,以便用户可以将 ETH 转移到该地址。
对于这些合约,我们在 genesis 时插入 EmptyContract
代码。它基本上是一个 noop 代码,不执行任何操作并返回 success=1
。
请注意,与 Ethereum 不同,keccak256 是 ZKsync 上的预编译(不是操作码)。
这些系统合约充当其各自加密预编译实现的包装器。预计它们会经常使用,尤其是 keccak256,因为 Solidity 在其帮助下计算映射和动态数组的存储槽。这就是为什么我们使用纯 yul 编写合约来优化短输入情况。过去,sha256
和 keccak256
都在智能合约中执行填充,但现在情况并非如此,sha256
在智能合约中执行填充,而 keccak256
在 zk 电路中执行填充。然后,在 zk 电路中完成两者的哈希处理。
重要的是要注意,sha256
预编译的加密部分希望处理填充数据。这意味着应用填充中的错误可能会导致无法证明的交易。
这些预编译模拟 EVM 的 EcAdd 和 EcMul 预编译的行为,并且完全在 Yul 中实现,没有电路对应部分。你可以在 此处 阅读有关它们的更多信息。
与 Ethereum 不同,zkEVM 没有任何特殊原生Token的概念。这就是为什么我们必须通过两个合约 L2BaseToken
和 MsgValueSimulator
模拟使用原生Token(收取费用)的操作。
L2BaseToken
是一个为用户保存原生Token余额的合约。此合约不提供 ERC20 接口。转移原生Token的唯一方法是 transferFromTo
。它仅允许某些系统合约代表用户进行转移。这是为了确保接口尽可能接近 Ethereum,即转移原生Token的唯一方法是通过调用具有某些 msg.value
的合约。这就是 MsgValueSimulator
系统合约的用途。
每当有人想要进行非零值调用时,他们需要使用以下内容调用 MsgValueSimulator
:
value
以及调用是否应标记为 isSystem
。有关 extraAbiParams 的更多信息,请参见 此处。
.send/.transfer
的支持在 Ethereum 上,每当完成非零值调用时,都会从调用者的帧中收取一些额外的 gas,并向被调用者的帧提供 2300
gas 津贴。此津贴通常足以发出一个小事件,但强制规定不能在这些 2300
gas 中更改存储。这也意味着在实践中,一些用户可能会选择提供 0 gas 来进行 call
,并依赖于传递给被调用者的 2300
津贴。.call/.transfer
就是这种情况。
虽然通常不建议使用 .send/.transfer
,但作为迈向更好 EVM 兼容性的一步,自 vm1.5.0 以来,ZKsync Era 中存在对这些函数的部分支持。它是通过以下方式完成的:
MsgValueSimulator
系统合约时,都会从调用者的帧中扣除 27000
gas,并在用户最初提供的任何 gas 之上将其传递给 MsgValueSimulator
。选择该数字是为了支付转移余额以及 MsgValueSimulator
的其他固定大小操作的执行费用。请注意,由于实际上将是 MsgValueSimulator
的帧调用被调用者,因此常量还必须包括取消提交被调用者代码的成本。解码任何大小的字节码都将非常昂贵,因此我们仅支持大小高达 100000
字节的被调用者。MsgValueSimulator
确保来自上述津贴的不超过 2300
到达被调用者,从而确保这些函数的重入保护不变量成立。请注意,与 EVM 不同,此类调用的任何未使用 gas 都将退还。
系统保留以下关于 .send/.transfer
的保证:
2300
。请注意,可能传递一个较小但接近的数量。2300
gas 来强制执行的。此外,冷写入成本始终必须在执行存储写入时预付。有关它的更多信息,请参见此处。100000
的被调用者都可以工作。系统不保证以下内容:
100000
的被调用者可以工作。请注意,恶意操作员可能会导致对具有大字节码的被调用者的任何调用失败,即使它以前已取消提交。有关它的更多信息,请参见此处。总而言之,通常应避免使用 .send/.transfer
,但如果无法避免,则应与小型被调用者一起使用,例如实现 DefaultAccount 的 EOA。
此合约用于存储某个代码哈希是否“已知”,即是否可用于部署合约。在 ZKsync 上,L2 存储合约的代码哈希,而不是代码本身。因此,必须成为协议的一部分,以确保永远不会部署具有未知字节码(即具有未知 preimage 的哈希)的合约。
用户为每个交易提供的工厂依赖项字段包含要标记为已知的合约字节码哈希的列表。我们不能简单地相信操作员“知道”这些字节码哈希,因为操作员可能是恶意的并且会隐藏 preimage。我们通过以下方式确保字节码的可用性:
ContractDeployer 系统合约有责任仅部署那些已知的代码哈希。
KnownCodesStorage 合约还负责确保所有“已知”的字节码哈希也是有效的。
ContractDeployer
是一个负责在 ZKsync 上部署合约的系统合约。最好通过合约部署在 ZKsync 上的工作方式来理解它的工作原理。与 Ethereum 不同,在 ZKsync 上,create
/create2
是操作码,这些操作码由编译器通过调用 ContractDeployer 系统合约来实现。
为了提高安全性,我们还区分了普通合约和帐户的部署。这就是用户将使用的主要方法是 create
、create2
、createAccount
、create2Account
的原因,这些方法分别模拟 CREATE-like 和 CREATE2-like 行为,用于部署普通合约和帐户合约。
每个支持 L1→L2 通信的 rollup 都需要确保 L1 和 L2 上合约的地址在此类通信期间不会重叠(否则 L1 上的一些恶意代理可能会改变 L2 合约的状态)。通常,rollup 通过两种方式解决此问题:
你可以在 ContractDeployer 的 getNewAddressCreate2
/ getNewAddressCreate
方法中看到我们地址派生的规则。
请注意,我们仍然在 L1→L2 通信期间将某个常量添加到地址,以便我们将来能够以某种方式支持 EVM 字节码。
在 Ethereum 上,相同的 nonce 用于帐户和 EOA 钱包的 CREATE。在 ZKsync 上并非如此,我们使用一个单独的 nonce,称为“deploymentNonce”来跟踪帐户的 nonce。这样做主要是为了与自定义帐户保持一致,并在将来具有多重调用功能。
msg.value
设置为等于此值。mimic_call
从帐户名称中调用合约的构造函数。ImmutableSimulator
以设置要用于已部署合约的不可变项。请注意它与 EVM 方法的不同之处:在 EVM 上,部署合约时,它会执行 initCode 并返回 deployedCode。在 ZKsync 上,合约只有已部署的代码,并且可以将不可变项设置为构造函数返回的存储变量。
在 Ethereum 上,构造函数只是 initCode 的一部分,该 initCode 在合约部署期间执行并返回合约的部署代码。在 ZKsync 上,已部署代码和构造函数代码之间没有分离。构造函数始终是合约部署代码的一部分。为了保护它免受调用,编译器生成的合约仅在提供 isConstructor
标志时才调用构造函数(该标志仅适用于系统合约)。你可以在 此处 阅读有关标志的更多信息。
执行后,构造函数必须返回一个数组:
struct ImmutableData {
uint256 index;
bytes32 value;
}
基本上表示传递给合约的不可变项数组。
不可变项存储在 ImmutableSimulator
系统合约中。每个不可变项的 index
的定义方式是编译器规范的一部分。此合约仅将其视为每个特定地址从索引到值的映射。
每当合约需要访问某些不可变项的值时,它们都会调用 ImmutableSimulator.getImmutable(getCodeAddress(), index)
。请注意,在 ZKsync 上,可以获取当前执行地址(你可以在 此处 阅读有关 getCodeAddress()
的更多信息)。
如果调用成功,则返回已部署合约的地址。如果部署失败,则错误会冒泡。
默认帐户抽象的实现。这是默认用于所有不在内核空间中且没有在其上部署合约的地址的代码。该地址:
success = 1, returndatasize = 0
。一种用于从 ZKsync 向 L1 发送任意长度 L2→L1 消息的合约。虽然 ZKsync 本机支持相当有限数量的 L1→L2 日志,这些日志一次只能传输大约 64 字节的数据,但我们允许使用以下技巧发送几乎任意长度的 L2→L1 消息:
L1 messenger 接收消息,对其进行哈希处理,并通过 L2→L1 日志仅发送其哈希以及原始发送者。然后,L1 智能合约有责任确保操作员已在批处理的承诺中提供此哈希的完整 preimage。
请注意,L1Messenger 调用 L2DAValidator 并在促进 DA 验证协议中发挥重要作用。
用作我们帐户的 nonces 存储。除了使操作员更轻松地对交易进行排序(即通过读取帐户的当前 nonces)之外,它还具有单独的用途:确保对(地址,nonce)始终是唯一的。
它提供了一个函数 validateNonceUsage
,引导加载程序使用该函数来检查 nonce 是否已用于某个帐户。引导加载程序强制在交易的验证步骤之前将 nonce 标记为未使用,并在之后标记为已使用。该合约确保一旦标记为已使用,nonce 就不会设置回复到“未使用”状态。
请注意,nonces 不一定必须是单调的(需要这样做才能支持帐户抽象的更有趣的应用,例如可以自行启动交易的协议、类似于 tornado-cash 的协议等)。这就是有两种方法将某个 nonce 设置为“已使用”的原因:
minNonce
(从而使所有低于 minNonce
的 nonces 都已使用)。setValueUnderNonce
。这样,此键将被标记为已使用,并且不再允许将其用作帐户的 nonce。这样也很有效,因为这 32 个字节可用于存储一些有价值的信息。创建时,帐户还可以提供他们想要的 nonce 排序类型:顺序(即应期望 nonces 逐个增长,就像 EOA 一样)或任意,nonces 可能具有任何值。系统合约不会以任何方式强制执行此排序,但这更多是向操作员建议如何在 mempool 中对交易进行排序。
一个负责发出事件的系统合约。
它接受其第 0 个 extra abi data param 中的主题数。在其余的 extraAbiParams 中,他接受要发出的事件的主题。请注意,实际上,事件的第一个主题包含帐户的地址。一般来说,用户不应直接与此合约交互,而只能通过 emit
-ing 新事件的 Solidity 语法来交互。
rollup 最昂贵的资源之一是数据可用性,因此为了降低用户的成本,我们通过以下几种方式压缩已发布的 pubdata:
该合约提供两种方法:
publishCompressedBytecode
,用于验证字节码压缩的正确性,并以消息的形式将其发布到 DA 层。verifyCompressedStateDiffs
,用于验证我们的标准状态差异压缩的正确性。此方法可由常见的 L2DAValidator 使用,例如,它被 RollupL2DAValidator 使用。你可以在 [此处](https://github.com/matter-labs/era-contracts/blob/cc1619cfb03cc19adb21a2071c89415cab1479e8/docs/l2_system_该合约负责将 pubdata 分成多个块,每个块都适合一个 4844 blob,并计算所述 blob 的 preimage 的哈希值。如果一个块的大小小于 blob 的总字节数,我们会在右侧用零填充它,因为电路会要求该块具有精确的大小。
该合约可以被 L2DAValidator 使用,例如 RollupL2DAValidator 使用它将 pubdata 压缩成 blob。
它是一个接受 bytecode 的版本化哈希并返回其 preimage 的合约。它类似于 Ethereum 上的 extcodecopy
功能。
它的工作方式如下:
它接受一个版本化的哈希,并仔细检查它是否被标记为“已知”,即 operator 必须知道该哈希的 preimage。
之后,它使用 decommit
操作码,该操作码接受版本化的哈希和要花费的 ergs 数量,这与 preimage 的长度成正比。如果 preimage 之前已经 decommit 过,则请求的成本将退还给用户。
请注意,decommit 过程不仅使用 decommit
操作码发生,而且在调用合约期间也会发生。每当调用合约时,其代码都会 decommit 到专用于合约代码的内存页中。我们永远不会两次 decommit 相同的 preimage,无论它是通过显式操作码 decommit 的,还是在调用另一个合约期间 decommit 的,之前解包的 bytecode 内存页都将被重用。在 CodeOracle
合约中执行 decommit
时,将首先向用户预先收取最大可能的费用,如果 bytecode 之前已经 decommit 过,则会退还该费用。
decommit
操作码返回 decommit 的 bytecode 的切片。请注意,返回的指针始终具有 2^21 字节的长度,而与实际 bytecode 的长度无关。因此,CodeOracle
系统合约的工作是缩小返回数据的长度。
该合约的行为与 RIP-7212 中的 P256Verify 预编译相同。请注意,由于 Era 具有不同的 gas 调度,因此我们不遵守 gas 成本,但除此之外,接口是相同的。
这不是一个系统合约,但它将被预先部署在一个固定的用户空间地址上。该合约允许用户设置一个子调用可以占用的 pubdata 量的上限,而与每个 pubdata 的 gas 无关。有关 ZKsync 上 pubdata 如何工作的更多信息,请点击此处阅读。
请注意,这是一个经过慎重考虑的决定,不将此合约部署在内核空间中,因为它可能会将调用中继到任何合约,因此可能会打破对所有系统合约都可信的假设。
通常升级是通过调用 FORCE_DEPLOYER
常量地址的 ContractDeployer 的 forceDeployOnAddresses
函数来执行的。但是,某些升级可能需要更复杂的交互,例如,从合约查询某些内容以确定要进行哪些调用等。
对于这种情况,已经创建了 ComplexUpgrader
合约。假设升级的实现已预先部署,并且 ComplexUpgrader
将 delegatecall 到它。
请注意,虽然
ComplexUpgrader
甚至在之前的升级中就已存在,但它缺乏forceDeployAndUpgrade
函数。这导致了一些严重的限制。有关 gateway 升级过程的更多信息,请点击此处阅读。
有些合约需要预先部署,但拥有内核空间权限是不可取的。此类合约通常从 2^16
开始按顺序地址预先部署。
只是一个内置的 Create2Factory。它允许在多个链上确定性地将合约部署到相同的地址。
一个负责促进新创建的链的初始化的合约。这是链创建流程的一部分。
L2Bridgehub
、L2AssetRouter
、L2NativeTokenVault
以及 L2MessageRoot
。
这些合约用于促进跨链通信以及价值桥接。你可以在资产路由器规范中阅读有关它们的更多信息。
请注意,L2AssetRouter 和 L2NativeTokenVault 具有唯一的代码,L2Bridgehub 和 L2MessageRoot 与其 L1 预编译共享相同的源代码,即 L2Bridgehub 具有此代码,L2MessageRoot 具有此代码。
在 L2GatewayUpgrade 期间,系统合约将需要读取一些其他合约的存储,即使这些合约缺少 getter。有关如何实现的信息,请参阅 SystemContractHelper 合约的 forcedSload
函数。
虽然它仅用于升级,但决定将其保留为预先部署的合约,以供将来使用。
虽然尚不支持桥接包装的基础代币(例如 WETH)。它的地址被铭刻在原生代币 vault 中(L1 和 L2 )。为了与其他网络保持一致,我们的 WETH 代币被部署为 TransparentUpgradeableProxy。为了使部署过程更容易,我们预先部署了实现。
该协议在概念上是完整的,但包含一些已知问题,这些问题将在短期到中期内得到解决。
- 原文链接: github.com/matter-labs/e...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!