进入未知

post image

对构建 zkSync 2.0 挑战的详细技术见解

在这篇文章中,我们将分享尝试的不是一个,而是两个以前从未做过的事情的研发过程,zkSync 2.0 的重新设计如何更高效和兼容,以及使用具有独特架构的 LLVM 的挑战作为 zk EVM。

zkSync 2.0 的设计目标

zkSync 2.0的设计涉及到两件以前从未做过的事情:

  • zk EVM:对 SNARK 友好的 EVM 堆栈操作操作码(例如 popN、dup)准备数据并且不执行有意义的计算,但它们通常构成交易的相当大的成本。这种语义对于 zkSNARKs 来说是低效的,所以我们的 zkEVM 使用了不同的操作数寻址方法,但是从一个到另一个的转换需要在编译器端进行额外的工作。

通过编译成对 SNARK 友好的 zkEVM,我们可以为操作码提供更通用的操作数寻址,降低交易成本,减少证明的延迟,并提供额外的好处,例如一个块中许多交易的摊销公共数据使用。

  • 不同的每账户数据可用性政策 在同一个地址空间中为 zkRollup 和 zkPorter 账户进行交互设计可以比作 ETH 2.0 分片但具有同步执行。此外,如果其中一个“碎片”不可用,则两者之间的交互必须可证明无法交互。

除了高效执行 Solidity 智能合约之外,让协议看起来像以太坊也是一项非常复杂的任务。

重新设计以提高效率和兼容性 我们之前讨论过我们的新设计更高效并且与以太坊兼容。以下是我们所做的一些更改:

现在可以仅使用我们的 Web3+ API 和 Ethers+ SDK 与我们的网络进行交互: 对于读取请求:

  • 任何语言的任何兼容 web3 的框架都可以开箱即用,我们还将提供额外的 zkSync L2 特定功能 对于写请求:由于 L1 和 L2 之间的根本区别,您将不得不编写一些额外的代码(例如,zkSync 支持以任何代币支付费用,因此发送交易将涉及选择代币支付费用)

  • 更新了内存实现,允许我们在 zkEVM 的每个周期访问更多内存。此外,每个区块对同一合约代码的所有访问现在都被“摊销”了。

  • 增加动态定价模型:zkSync 中的 gas 很大程度上是由价格固定来证明的(例如 1 个周期 = 1 个单位),但如果用户使用公共数据,则它以单位为单位动态定价,以反映以太坊 gas 价格和以太币价格。

LLVM 的挑战和好处

Yul 前端聚焦:让 Yul 与 zk EVM 的内存模型一起工作

为了支持 Solidity 智能合约,我们将 Yul IR 转换为 LLVM IR。我们通过使所有 Yul 指令与 LLVM 框架和目标虚拟机兼容来构建 Yul 前端:zk EVM。主要挑战是我们必须按原样翻译 Yul(并在以后优化结果!);我们不能以任何方式改变它

虽然 Yul 语法最小且易于解析,但一些指令和模式需要变通方法才能与我们的 zkEVM 兼容。让我们深入研究几个有趣的例子:

  1. 未对齐的内存访问

  • 特例 A:处理呼叫数据

  • 特例 B:适配构造函数

2. 保留执行流程

未对齐的内存访问

在 EVM 中,内存使用字节偏移量进行寻址:

  • mload(12):从区域 12..44 加载 32 个字节

  • mstore(16, X):将 X 存储在区域 16..48

  • calldataload(4):加载第一个 calldata 参数

另一方面,在 zkEVM 中,内存是通过单元偏移来寻址的,其中每个单元仍然由 32 个字节组成,但每个单元是原子的,只能作为一个整体进行读取或写入:

  • mov r1, 4:将寄存器r1的值存储到单元格4,即字节128..160

  • mov 4, r1:将单元格 4 中的值加载到寄存器 r1

为了翻译像 mload(4) 这样的指令,我们必须从单元 1 加载 28 个字节,然后从单元 2 加载 4 个字节,并使用按位运算将它们合并成一个 32 字节的值。

幸运的是,Yul 很少使用不是 32 倍数的偏移量。通常情况如下:

  • calldata 布局:每个参数使用签名哈希移动 4 个字节

  • 错误消息编码:代码和描述以小于 32 字节的块形式出现

  • 作为映射键的字符串文字通常不会占用整个 32 字节块

上述所有情况都需要单元拆分和合并,对于非常量地址,每条内存访问指令的复杂度都会增加数倍。有一些建议可以针对我们的 zk EVM 需求优化 Yul 生成器的行为,例如,将外部合约条目签名哈希存储在其自己的 32 字节块中,以及在将错误消息块写入内存之前在 Yul 中合并它们。

特例 A:处理呼叫数据

在 Solidity 合约 ABI 中,calldata 的前 4 个字节是入口签名哈希。然后,每个参数存储在偏移量 4+32n 处。

在 zkEVM 合约 ABI 中,我们向 calldata 添加了一个 252 字节的标头以及一些附加信息:

  • 单元格中的调用数据大小

  • 返回单元格中的数据大小

然后,我们将条目签名哈希和参数从区域 252.. 开始,并在偏移量 256、288、320 等处使用对齐的内存访问加载每个参数。

这是通过检查 calldataload(A) 指令中的地址来实现的。如果地址为零,我们从区域 224..256 加载条目签名哈希,否则,我们从区域 A+252..A+252+32 加载参数。

特例 B:适配构造函数

在 Solidity 中,构造函数参数与最后的合约字节码一起部署。在 zk EVM 中,构造函数是一个普通的合约入口,只能调用一次;在第一次执行结束时会在存储中设置一个保护标志。

保留执行流程

许多 Yul 指令已被存根以保留预期的执行流程。例如,Yul 在进行外部调用之前使用 `extcodesize` 指令检查外部合约大小。我们在调用之前不做这样的检查,所以我们总是返回 MAX_VALUE 来通过代码大小检查。其他存根指令包括 pc、callvalue、msize、balance 等。

zkEVM 后端:支持 LLVM 中的 EVM 和 zk EVM 的特性

我们的架构有一些特性,我们不得不花一些时间将其集成到 LLVM 框架中:

  • zkEVM 使用 256 位宽的寄存器,但 LLVM 在许多情况下本身并不支持 256 位整数

  • 256 位是内存中的最小寻址区域,但 LLVM 硬编码假设内存寻址为 8 位字节

  • 我们在堆栈中缺少绝对地址:所有地址都必须与堆栈指针相关,并且 LLVM 中没有这样的架构,因此我们不得不引入自定义通道来处理它(但是,我们计划在初始版本之后引入绝对地址)

  • VM 不提供显式读取或写入堆栈指针的能力,只能通过 push 和 pop 指令

  • 出于安全原因,zkEVM 不支持跳转到任意目的地,仅支持代码中的静态已知地址

  • zkEVM 不支持有符号算术,我们为它实现了自定义降低

测试,测试,测试!

使用 LLVM 的主要优势之一是我们受益于他们庞大的测试套件存储库:

  • 回归测试检查编译器生成合理的输出和优化是否按预期工作。核心 LLVM 存储库中有 36,000 个公共测试可用,我们从其中的 20,000 个中受益。(其他 16,000 个检查不同的 LLVM 后端)

  • 可执行测试或集成测试运行编译器,但也运行编译器生成的二进制文件。在我们使用的 LLVM 测试套件存储库中,至少有 3,000 个这样的测试是公开可用的。

  • 为了检查我们的整个编译器工作流程,我们实现了自己的可执行测试套件,目前有 1,000 个测试,使用所有支持的语言编写,包括 IR:Solidity、Zinc、Yul、LLVM IR 和 zkEVM 程序集。

结论

我们希望您喜欢这个窗口,了解我们在编译器和 zkSync 2.0 的研发过程中所面临的挑战!

如果您对此感兴趣,我们正在招聘!如果您有任何问题,请在我们的Discord中提问!