本文详细介绍了EIP-1167标准,即最小代理合约,用于廉价创建代理克隆。文章深入解析了其工作原理、字节码结构、初始化函数及实际应用示例,帮助开发者理解如何高效部署相似合约。
图片来自 <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 (LinkedIn,Twitter) 作为 RareSkills 技术写作项目的一部分共同撰写。
作为一种典型的代理,它通过调用接收事务数据,将数据转发到实现智能合约,获得外部调用的结果。如果外部调用成功,则返回结果;如果出现错误,则回滚。
最小代理合约的字节码简洁,仅有 55 字节。该字节码由以下组成:
以下是最小代理的字节码:
3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
虚拟的地址: 0xbebebebebebebebebebebebebebebebebebebebe
将被实现合约地址替代。
让我们来拆解一下。
字节码的前 10 字节包含仅运行一次的初始化代码,用于部署最小代理。
要了解有关智能合约创建和部署的更多信息,请参见我们有关 以太坊智能合约创建代码 的文章。
以下是在 EVM 中执行的命令。
// 复制最小代理的运行时字节码
// 从偏移量 10 开始,并将其保存到区块链上
[00] RETURNDATASIZE
[01] PUSH1 2d
[03] DUP1
// 将 10 - 偏移量推入,以从中复制运行时代码
[04] PUSH1 0a
[06] RETURNDATASIZE
// 复制运行时代码并将其保存到区块链上
[07] CODECOPY
[08] DUP2
[09] RETURN
初始化代码部署合约并将运行时字节码从偏移量 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
在将事务 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工作。
在某些情况下,我们希望参数化克隆的创建。例如,如果我们克隆一个 ERC20 Token,每个克隆将具有相同的 totalSupply
,这可能并不理想。
为了能够配置此参数,可以使用“带初始化的克隆模式”。
让我们看看如何使用 EIP-1167 创建带有初始化函数的代理克隆。这个过程遵循一系列简单的步骤:
让我们用下面的示例逐步了解这些步骤。
要克隆的实现合约
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;
// 如前所述,最小代理有以下字节码
// <3d602d80600a3d3981f3363d3d373d3d3d363d73><实现合约的地址><5af43d82803e903d91602b57fd5bf3>
// <3d602d80600a3d3981f3> == 创建设备符号代码,该代码将运行时代码复制到内存并部署
// <363d3d373d3d3d363d73> <实现合约的地址> <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<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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!