EIP-1167:带初始化的最小代理标准(克隆模式)

RareSkills 发布于 2023-02-23 阅读 6509

本文详细介绍了EIP-1167标准,即最小代理合约,用于廉价创建代理克隆。文章深入解析了其工作原理、字节码结构、初始化函数及实际应用示例,帮助开发者理解如何高效部署相似合约。

clones
图片来自 <https://pixabay.com/photos/stormtrooper-star-wars-lego-storm-2899993/>

介绍

EIP-1167,也被称为最小代理合约,是一种常用的 solidity 模式,用于廉价创建代理克隆。

如果用例需要重复部署相同(或非常相似)的合约,这是一种更为节省 gas 的方式。

例如, gnosis safe 在创建新安全合约时使用克隆模式。当你与 gnosis safe 互动时,实际上你是在与它的一个克隆进行互动。

克隆合约像是一个无法升级的代理。由于代理合约相对于实现合约是非常小的,因此部署成本较低。

与代理模式类似,克隆将所有调用委派给实现合约,但将状态保留在自己的存储中。

不同于常规的代理模式,多个克隆可以指向同一个实现合约。克隆无法升级。

实现合约的地址存储在字节码中。与存储相比,这节省了 gas,并防止克隆指向另一个实现。

这种设计使得部署变得相当便宜,因为克隆代理的字节码通常比实现合约的字节码小得多。事实上,EIP-1167 的大小仅为 55 字节(运行时占 45 字节),包括初始化代码。然而,在执行期间调用的成本会更高,因为总是会增加一个 delegatecall。

本文将描述 EIP 和用于初始化相当于构造函数参数的初始化函数。

作者

本文由 Jesse Raymond (LinkedInTwitter) 作为 RareSkills 技术写作项目的一部分共同撰写。

EIP-1167 的工作原理

作为一种典型的代理,它通过调用接收事务数据,将数据转发到实现智能合约,获得外部调用的结果。如果外部调用成功,则返回结果;如果出现错误,则回滚。

最小代理的字节码

最小代理合约的字节码简洁,仅有 55 字节。该字节码由以下组成:

  • 初始化代码
  • 包含接收事务 calldata 指令的运行时代码
  • 20 字节的实现合约地址
  • 执行 delegate call 的命令
  • 返回结果,或在出现错误时触发 revert。

以下是最小代理的字节码:

3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

虚拟的地址: 0xbebebebebebebebebebebebebebebebebebebebe 将被实现合约地址替代。

让我们来拆解一下。

克隆合约字节码,其中字节码的区域被高亮显示

初始化代码部分

字节码的前 10 字节包含仅运行一次的初始化代码,用于部署最小代理。

要了解有关智能合约创建和部署的更多信息,请参见我们有关 以太坊智能合约创建代码 的文章。

以下是在 EVM 中执行的命令。

// 复制最小代理的运行时字节码 
// 从偏移量 10 开始,并将其保存到区块链上

[00] RETURNDATASIZE	
[01] PUSH1    2d
[03] DUP1	

// 将 10 - 偏移量推入,以从中复制运行时代码
[04] PUSH1    0a     
[06] RETURNDATASIZE	

// 复制运行时代码并将其保存到区块链上
[07] CODECOPY	
[08] DUP2	
[09] RETURN

复制 calldata

初始化代码部署合约并将运行时字节码从偏移量 10(提取 calldata 部分)开始存储到链上。

在最小代理部署并接收到调用后,它复制事务 calldata 到内存,推送实现合约的 20 字节地址,并对实现合约执行 delegatecall。

此 calldata 复制通过以下操作码完成。

// 将事务 calldata 复制到内存
[0a] CALLDATASIZE	
[0b] RETURNDATASIZE // 这是一个黑客方式,以更低的 gas 将 0 推送到栈中,而不是使用 PUSH 0
[0c] RETURNDATASIZE
[0d] CALLDATACOPY	

[0e] RETURNDATASIZE	
[0f] RETURNDATASIZE	
[10] RETURNDATASIZE	
[11] CALLDATASIZE	
[12] RETURNDATASIZE	

// 将实现合约的 20 字节地址推送到栈中
[13] PUSH20	

实现合约地址

在将事务 calldata 复制到内存后,栈已准备好进行 delegatecall,并将实现合约的 20 字节地址推送到栈顶。在上一部分中,我们看到它以 PUSH20 结束。接下来是实现合约的地址。

// 将实现合约的地址推送到栈中。此处的地址只是一个虚拟地址
[13] PUSH20 bebebebebebebebebebebebebebebebebebebebe

Delegatecall 部分

在将事务 calldata 复制到内存并将实现合约地址置于栈顶之后,最小代理准备对实现合约执行 delegatecall。

如果你需要关于 delegatecall 工作原理的回顾,请阅读我们的教程 delegatecall

执行 delegatecall 后,最小代理会返回调用的结果,如果成功则返回结果,如果出现错误则回滚。

delegatecall 部分的操作码如下。

// 对实现合约执行 delegate call,并转发所有可用的 gas
[28] GAS 
[29] DELEGATECALL  

// 将调用的返回数据复制到内存
[2a] RETURNDATASIZE	
[2b] DUP3	
[2c] DUP1
[2d] RETURNDATACOPY   

// 为条件跳转准备栈
[2e] SWAP1	
[2f] RETURNDATASIZE	
[30] SWAP2	
[31] PUSH1    2b

// 如果调...

剩余50%的内容订阅专栏后可查看

该文章收录于
代理模式与 Delegatecall 规范手册
37 订阅 19 篇内容

1 条评论

谢谢大佬,请教一下: 下面是openzeppelin的最小代理合约的创建代码 ,这个代码只有请求转发到逻辑合约,并返回执行结果的功能。现在我想给逻辑最小代理合约增加一个owner功能,就是把在最小代理合约的0槽位上保存一个地址,比如caller() 假设逻辑合约对应的零槽位也保留一个地址来对齐最小行业的owner),那么需要在下面的字节码上如何修改? 我的想法是在下面字节码的基础上增加一个构造函数的逻辑,在构造函数中调用store(0, caller()) , 但是我不知道证明用字节码来实现,请大佬们帮忙看看怎么搞? 需要说明的是,希望用字节码来实现,保持合约的size足够小,用户汇编实现就不用考虑了,大小会大不少。谢谢!

function clone(address implementation, uint256 value) internal returns (address instance) {
    if (address(this).balance &lt; value) {
        revert Errors.InsufficientBalance(address(this).balance, value);
    }
    assembly ("memory-safe") {
        // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes
        // of the `implementation` address with the bytecode before the address.
        mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000))
        // Packs the remaining 17 bytes of `implementation` with the bytecode after the address.
        mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3))
        instance := create(value, 0x09, 0x37)
    }
    if (instance == address(0)) {
        revert Errors.FailedDeployment();
    }
}
2025-02-27 16:55