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

本文详细介绍了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

// 如果调用成功则跳转到 35 行返回结果,否则在 34 行回滚
[33] JUMPI  
[34] REVERT 
[35] JUMPDEST   
[36] RETURN

这就是 EIP-1167 的概述及其工作原理。

假设实现合约是一个 ERC20 Token。在这种情况下,克隆将完全像一个 ERC20 Token工作。

EIP-1167 智能合约实现与初始化

在某些情况下,我们希望参数化克隆的创建。例如,如果我们克隆一个 ERC20 Token,每个克隆将具有相同的 totalSupply,这可能并不理想。

为了能够配置此参数,可以使用“带初始化的克隆模式”。

让我们看看如何使用 EIP-1167 创建带有初始化函数的代理克隆。这个过程遵循一系列简单的步骤:

  1. 创建实现合约
  2. 使用 EIP-1167 标准克隆合约
  3. 部署克隆并调用初始化函数,该函数只能被调用一次。
    这种仅能调用一次的限制是必要的,否则可能会有人在部署后更改我们设置的关键参数,例如更改总供应量。

让我们用下面的示例逐步了解这些步骤。

要克隆的实现合约

contract ImplementationContract {
    bool private isInitialized;      // 初始化函数将在部署时调用一次。

    function initializer() external {              
        require(!isInitialized);
        isInitialized = true;     
    }          // 其余实现函数在此处
}

我们用以下代码来部署克隆

contract MinimalProxyFactory {

    address[] public proxies;

    function deployClone(address _implementationContract) external returns (address) {
        // 将地址转换为 20 字节
        bytes20 implementationContractInBytes = bytes20(_implementationContract);
        // 分配给克隆代理的地址
        address proxy;

        // 如前所述,最小代理有以下字节码
        // &lt;3d602d80600a3d3981f3363d3d373d3d3d363d73>&lt;实现合约的地址>&lt;5af43d82803e903d91602b57fd5bf3>

        // &lt;3d602d80600a3d3981f3> == 创建设备符号代码,该代码将运行时代码复制到内存并部署

        // &lt;363d3d373d3d3d363d73> &lt;实现合约的地址> &lt;5af43d82803e903d91602b57fd5bf3> == 运行时代码,它对实现合约进行 delegatecall

        assembly {
            /*
            从存储在 0x40 处的指针开始读取 32 字节的内存
            在 solidity 中,0x40 插槽在内存中是特殊的:它包含“空闲内存指针”
            指向当前已分配内存的末尾。
            */
            let clone := mload(0x40)
            // 从“clone”开始存储 32 字节到内存
            mstore(
                clone,
                0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
            )

            /*
              |              20 字节                |
            0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
                                                      ^
                                                      指针
            */
            // 从“clone” + 20 字节的起始位置存储 32 字节到内存
            // 0x14 = 20
            mstore(add(clone, 0x14), implementationContractInBytes)

            /*
              |               20 字节               |                 20 字节              |
            0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe
                                                                                              ^
                                                                                              指针
            */
            // 从“clone” + 40 字节的起始位置存储 32 字节到内存
            // 0x28 = 40
            mstore(
                add(clone, 0x28),
                0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
            )

            /*
            |                 20 字节                  |          20 字节          |           15 字节          |
            0x3d602d80600a3d3981f3363d3d373d3d3d363d73b&lt;implementationContractInBytes>5af43d82803e903d91602b57fd5bf3 == 45 字节总共
            */

            // 创建一个新合约
            // 发送 0 以太币
            // 代码从存储在“clone”中的指针开始
            // 代码大小 == 0x37 (55 字节)
            proxy := create(0, clone, 0x37)
        }

        // 调用初始化
        ImplementationContract(proxy).initializer();
        proxies.push(proxy);
        return proxy;
    }
}

使用 MinimalProxyFactory 合约,可以部署无限数量的 EIP-1167 克隆,但在此示例中我们将部署上述实现合约。

以下是一个简单的 Hardhat 脚本,部署合约并与已部署的克隆进行交互。

const hre = require("hardhat");

async function main() {
  const ImplementationContract = await hre.ethers.getContractFactory(
    "ImplementationContract"
  );
  // 部署实现合约
  const implementationContract = await ImplementationContract.deploy();
  await implementationContract.deployed();

  console.log("实现合约地址 ", implementationContract.address);

  const MinimalProxyFactory = await hre.ethers.getContractFactory(
    "MinimalProxyFactory"
  );
  // 部署最小工厂合约
  const minimalProxyFactory = await MinimalProxyFactory.deploy();
  await minimalProxyFactory.deployed();

  console.log("最小代理工厂合约地址 ", minimalProxyFactory.address);

  // 在最小工厂合约上调用部署克隆函数并传递参数
  const deployCloneContract = await minimalProxyFactory.deployClone(
    implementationContract.address
  );
  deployCloneContract.wait();

  // 获取部署的代理地址
  const ProxyAddress = await minimalProxyFactory.proxies(0);
  console.log("代理合约地址 ", ProxyAddress);

  // 加载克隆合约
  const proxy = await hre.ethers.getContractAt(
    "ImplementationContract",
    ProxyAddress
  );

  console.log("代理是否已初始化 == ", await proxy.isInitialized()); // 获取已初始化布尔值 == true
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

我们现在已在 goerli 网络上部署了合约,这里是三个合约的交易详情。

请注意,Etherscan 确认代理合约不仅仅是另一个智能合约,而是委派调用到实现合约。

为了方便起见,我们的代码保留了一个已部署克隆的列表,但这并不是必要的特性。

结论

EIP-1167 最小代理标准是部署与另一个合约实现镜像的合约的有效方法。初始化模式允许我们将克隆部署为具有接受参数的构造函数。

这种模式的成本是每次执行都有一个 delegate call 的开销。

了解更多

请参阅我们的高级 区块链训练营,以查看我们详细的课程。

原文发表于 2023 年 2 月 21 日

  • 原文链接: rareskills.io/post/eip-1...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/