图片来源 https://pixabay.com/photos/stormtrooper-star-wars-lego-storm-2899993/
简介
EIP-1167,通常被称为最小代理合约(minimal proxy contract),是一种常用的 solidity 模式,用于低成本地创建代理 Clone。
如果某个用例需要重复部署相同的合约(或非常相似的合约),这是一种更节省 gas 的方法。
例如,gnosis safe 在创建新的 safe 时使用了 Clone 模式。当你与 gnosis safe 交互时,你实际上是在与它的一个 Clone 进行交互。
Clone 合约就像一个无法升级的代理。由于代理相对于实现合约来说非常小,因此部署它们的成本很低。
与代理模式类似,Clone 将所有调用委托给实现合约,但在自己的 storage 中保留状态。
与常规的代理模式不同,多个 Clone 可以指向同一个实现合约。Clone 是无法升级的。
实现合约的地址存储在 bytecode 中。与存储在 storage 中相比,这节省了 gas,并防止 Clone 指向另一个实现。
这种设计使其部署成本大大降低,因为 Clone 代理的 bytecode 通常比实现合约的 bytecode 小得多。事实上,包含 init code 在内,EIP-1167 的大小只有 55 字节(runtime code 为 45 字节)。然而,在执行期间的调用将花费更多,因为总是增加了一个 delegatecall。
本文将介绍该 EIP 以及用于初始化等同于构造函数参数的 initializer 函数。
作者
本文由 Jesse Raymond(LinkedIn,Twitter)共同撰写,是 RareSkills Technical Writing Program 的一部分。
EIP-1167 是如何工作的
作为一个典型的代理,它通过 call 接收交易数据,将此数据转发给实现智能合约,获取外部调用的结果,如果外部调用成功则返回结果,如果发生错误则 revert。
最小代理的字节码
最小代理合约具有非常简洁的 bytecode,只有 55 字节。该 bytecode 包含:
- init code
- runtime code,其中包含接收交易 calldata 的指令
- 20 字节的实现合约地址
- 以及执行 delegatecall 的指令,和
- 返回结果,或在发生错误时触发 revert 的指令。
以下是最小代理的 bytecode:
3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
虚拟地址:0xbebebebebebebebebebebebebebebebebebebebe 将被替换为实现合约地址。
让我们对其进行分解。

init code 部分
bytecode 的前 10 个字节包含 init code,它只运行一次,用于部署最小代理。
要了解有关智能合约创建和部署的更多信息,请参阅我们关于 Ethereum smart contract creation code 的文章。
以下是在 EVM 中执行的指令。
// copy the runtime bytecode of the minimal proxy
// starting from offset 10, and save it to the blockchain
[00] RETURNDATASIZE
[01] PUSH1 2d
[03] DUP1
//push 10 - offset to copy runtime code from
[04] PUSH1 0a
[06] RETURNDATASIZE
// copy the runtime code and save it to the blockchain
[07] CODECOPY
[08] DUP2
[09] RETURN
复制 calldata
init code 会部署合约并在链上保存 runtime bytecode,从偏移量 10(复制 calldata 的部分)开始直到 bytecode 结束。
在部署了最小代理并向其发送 call 之后,它会将交易 calldata 复制到 memory 中,将 20 字节的实现合约地址 push 到栈中,并在实现合约上执行 delegatecall。
这个 calldata 的复制是通过以下操作码(opcodes)完成的。
//copy the transaction calldata to memory
[0a] CALLDATASIZE
[0b] RETURNDATASIZE // this is a hack to push 0 onto the stack with less gas than doing PUSH 0
[0c] RETURNDATASIZE
[0d] CALLDATACOPY
[0e] RETURNDATASIZE
[0f] RETURNDATASIZE
[10] RETURNDATASIZE
[11] CALLDATASIZE
[12] RETURNDATASIZE
//pushes the 20 bytes address of the implementation contract
[13] PUSH20
实现合约地址
将交易 calldata 复制到 memory 后,栈(stack)已准备好进行 delegatecall,并将 20 字节的实现合约地址 push 到栈顶。在上一节中,我们可以看到它以 PUSH20 结束。接下来紧跟着的就是实现合约的地址。
//push the address of the implementation contract to the stack. The address here is just a dummy address
[13] PUSH20 bebebebebebebebebebebebebebebebebebebebe
delegatecall 部分
在将交易 calldata 复制到 memory 并在栈顶获取实现合约地址后,最小代理已准备好对实现合约执行 delegatecall。
如果你需要复习 delegatecall 的工作原理,请阅读我们关于 delegatecall 的教程。
执行 delegatecall 之后,如果调用成功,最小代理将返回调用的结果,如果发生错误,则 revert。
delegatecall 部分包含以下 opcodes:
//perform a delegate call on the implementation contract, and forward all available gas
[28] GAS
[29] DELEGATECALL
//copy the return data of the call to memory
[2a] RETURNDATASIZE
[2b] DUP3
[2c] DUP1
[2d] RETURNDATACOPY
// set up the stack for the conditional jump
[2e] SWAP1
[2f] RETURNDATASIZE
[30] SWAP2
[31] PUSH1 2b
//jump to line 35 and return the result of the call if it was successful, else revert on line 34
[33] JUMPI
[34] REVERT
[35] JUMPDEST
[36] RETURN
这就是 EIP-1167 及其工作原理的概述。
想象一下实现合约是一个 ERC20 代币。在这种情况下,Clone 的行为将与 ERC20 代币完全一样。
带有初始化的 EIP-1167 智能合约实现
在某些情况下,我们希望对 Clone 的创建进行参数化。例如,如果我们 Clone 一个 ERC20 代币,每个 Clone 都将具有相同的 totalSupply,这可能并不是我们想要的。
为了能够配置这个参数,可以使用“带有初始化的 Clone 模式”。
让我们看看如何使用 EIP-1167 创建带有初始化函数的代理 Clone。这遵循一系列简单的步骤:
- 创建一个实现合约
- 使用 EIP-1167 标准 Clone 该合约
- 部署 Clone 并调用初始化函数,该函数只能被调用一次。
必须限制只能调用一次,否则有人可能会在部署后更改我们设置的关键参数,例如更改总供应量(total supply)。
让我们通过下面的示例来走一遍这些步骤。
要被 Clone 的实现合约
contractImplementationContract{
boolprivate isInitialized; //initializer function that will be called once, during
deployment.
functioninitializer() external {
require(!isInitialized);
isInitialized =true;
} // rest of the implementation functions go here
}
我们使用以下代码来部署 Clone
contract MinimalProxyFactory {
address[] public proxies;
function deployClone(address _implementationContract) external returns (address) {
// convert the address to 20 bytes
bytes20 implementationContractInBytes = bytes20(_implementationContract);
//address to assign a cloned proxy
address proxy;
// as stated earlier, the minimal proxy has this bytecode
// <3d602d80600a3d3981f3363d3d373d3d3d363d73><address of implementation contract><5af43d82803e903d91602b57fd5bf3>
// <3d602d80600a3d3981f3> == creation code which copies runtime code into memory and deploys it
// <363d3d373d3d3d363d73> <address of implementation contract> <5af43d82803e903d91602b57fd5bf3> == runtime code that makes a delegatecall to the implementation contract
assembly {
/*
reads the 32 bytes of memory starting at the pointer stored in 0x40
In solidity, the 0x40 slot in memory is special: it contains the "free memory pointer"
which points to the end of the currently allocated memory.
*/
let clone := mload(0x40)
// store 32 bytes to memory starting at "clone"
mstore(
clone,
0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
)
/*
| 20 bytes |
0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
^
pointer
*/
// store 32 bytes to memory starting at "clone" + 20 bytes
// 0x14 = 20
mstore(add(clone, 0x14), implementationContractInBytes)
/*
| 20 bytes | 20 bytes |
0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe
^
pointer
*/
// store 32 bytes to memory starting at "clone" + 40 bytes
// 0x28 = 40
mstore(
add(clone, 0x28),
0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
)
/*
| 20 bytes | 20 bytes | 15 bytes |
0x3d602d80600a3d3981f3363d3d373d3d3d363d73b<implementationContractInBytes>5af43d82803e903d91602b57fd5bf3 == 45 bytes in total
*/
// create a new contract
// send 0 Ether
// code starts at the pointer stored in "clone"
// code size == 0x37 (55 bytes)
proxy := create(0, clone, 0x37)
}
// Call initialization
ImplementationContract(proxy).initializer();
proxies.push(proxy);
return proxy;
}
}
使用 MinimalProxyFactory 合约,可以部署无数个 EIP-1167 Clone,但对于这个例子,我们将部署上面的实现合约。
这是一个简单的 hardhat 脚本,用于部署合约并与已部署的 Clone 进行交互。
const hre = require("hardhat");
async function main() {
const ImplementationContract = await hre.ethers.getContractFactory(
"ImplementationContract"
);
// deploy the implementation contract
const implementationContract = await ImplementationContract.deploy();
await implementationContract.deployed();
console.log("Implementation contract ", implementationContract.address);
const MinimalProxyFactory = await hre.ethers.getContractFactory(
"MinimalProxyFactory"
);
// deploy the minimal factory contract
const minimalProxyFactory = await MinimalProxyFactory.deploy();
await minimalProxyFactory.deployed();
console.log("Minimal proxy factory contract ", minimalProxyFactory.address);
// call the deploy clone function on the minimal factory contract and pass parameters
const deployCloneContract = await minimalProxyFactory.deployClone(
implementationContract.address
);
deployCloneContract.wait();
// get deployed proxy address
const ProxyAddress = await minimalProxyFactory.proxies(0);
console.log("Proxy contract ", ProxyAddress);
// load the clone
const proxy = await hre.ethers.getContractAt(
"ImplementationContract",
ProxyAddress
);
console.log("Proxy is initialized == ", await proxy.isInitialized()); // get initialized boolean == true
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
我们现在已经在 goerli 网络上部署了我们的合约,以下是这 3 个合约的交易详情。
请注意,Etherscan 会识别出该代理合约不仅仅是另一个智能合约,而是将调用委托给实现合约。
为了方便起见,我们的代码保存了已部署 Clone 的列表,但这并不是一个必要的功能。
结论
EIP-1167 最小代理标准是部署镜像了另一个合约实现的合约的有效方法。initializer 模式允许我们在部署 Clone 时,就好像它有一个带有参数的构造函数一样。
这种模式的代价是每次执行都会有 delegatecall 的开销。
了解更多
查看我们高级的 blockchain bootcamp 以了解我们丰富的课程体系。
首次发布于 2023年2月21日