EIP-1167 最小代理(Clone)详解:55 字节的克隆魔法
目录
- 一、什么是最小代理
- 二、最小代理字节码全貌
- 三、克隆的执行流程
- 四、Gas 经济学:部署便宜、调用稍贵
- 五、初始化模式(没有构造函数怎么办)
- 六、工厂合约:汇编创建克隆
- 七、典型应用场景
- 八、与其他模式对比
- 九、总结
- 十、动手练习项目:代币克隆工厂 TokenCloneFactory
一、什么是最小代理
EIP-1167 最小代理(也叫 Clone,克隆)是一种极省 Gas 的部署模式,用于反复部署相同或相似的合约。
核心特征:
- 不可升级:和传统代理不同,实现地址硬编码进字节码,不存在状态里,所以不能改;
- 多克隆共享一份实现:每个克隆有自己独立的存储,但代码逻辑都 delegatecall 到同一个实现合约;
- 部署极便宜:整个克隆字节码只有 55 字节(其中运行时 45 字节)。
代价:每次调用都多一次 delegatecall,调用时 Gas 略高——用部署省的钱换运行时的小开销。
二、最小代理字节码全貌
2.1 完整字节码
3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
其中 bebebe...bebe(20 字节假地址)会被替换成真实实现合约地址。
2.2 字节码逐段拆解
55 字节分三大部分:
① 初始化代码(10 字节,偏移 00–09):3d602d80600a3d3981f3
RETURNDATASIZE ; 压入 0
PUSH1 2d ; 45 = 运行时代码长度
DUP1
PUSH1 0a ; 10 = 运行时代码偏移
RETURNDATASIZE ; 0
CODECOPY ; 把运行时代码拷到内存
DUP2
RETURN ; 返回(部署)运行时代码
作用:部署时运行一次,把从偏移 10 开始的 45 字节运行时代码部署上链。
② 运行时——拷贝 calldata(10 字节,偏移 0a–13):363d3d373d3d3d363d73
CALLDATASIZE
RETURNDATASIZE ; 用 RETURNDATASIZE 代替 PUSH 0,省 1 gas 的小技巧
RETURNDATASIZE
CALLDATACOPY ; 把交易 calldata 拷到内存
RETURNDATASIZE
RETURNDATASIZE
RETURNDATASIZE
CALLDATASIZE
RETURNDATASIZE ; 为 delegatecall 准备栈
PUSH20 ; 准备压入 20 字节地址
③ 实现地址(20 字节,偏移 14–27):bebebe...bebe → 真实地址。
④ delegatecall 段(15 字节,偏移 28–36):5af43d82803e903d91602b57fd5bf3
GAS ; 转发全部可用 gas
DELEGATECALL ; 调用实现,在克隆的上下文执行
RETURNDATASIZE
DUP3
DUP1
RETURNDATACOPY ; 拷贝返回数据
SWAP1
RETURNDATASIZE
SWAP2
PUSH1 2b
JUMPI ; 成功则跳转
REVERT ; 失败则回滚
JUMPDEST
RETURN ; 返回结果
作用:delegatecall 到实现合约,把返回值原样返回,失败则 revert。注意它用 RETURNDATASIZE(2 gas)代替 PUSH1 0(3 gas)这种到处抠 gas 的手法。
三、克隆的执行流程
- 调用到达克隆合约;
- calldata 被拷到内存;
- 从字节码里压入实现地址;
- delegatecall 到实现,用克隆自己的存储上下文执行;
- 返回结果或 revert。
状态隔离:每个克隆有独立存储,代码却在实现合约逻辑下执行——多个克隆共享代码、互不干扰数据。
四、Gas 经济学:部署便宜、调用稍贵
- 部署省钱:克隆只有 55 字节,比部署一份完整实现便宜得多,所以部署上千个相似合约才economically可行;
- 调用稍贵:每次调用都多一次 delegatecall(基础开销 + 栈操作开销)。
所以最小代理适合”部署多、相对而言调用没那么极端高频”的场景。如果是超高频合约,几百次调用后省下的部署费会被 delegatecall 开销追平(这也是 Uniswap V2 不用克隆而直接部署 Pair 的原因,见 Module 5)。
五、初始化模式(没有构造函数怎么办)
普通合约用构造函数初始化,但克隆是预制字节码部署的,没法用构造函数(构造函数逻辑在部署时运行,克隆部署时不会执行实现的构造函数)。
解法:部署后单独调一个 initialize 函数,用布尔标志防止重复初始化:
contract ImplementationContract {
bool private isInitialized;
function initializer() external {
require(!isInitialized);
isInitialized = true;
// 初始化逻辑(设 owner、参数等)
}
}
⚠️ 安全要点:必须用 initializer 守卫,否则任何人都能重复调用 initialize 篡改参数。生产中用 OpenZeppelin 的 Initializable(initializer 修饰符)更稳妥。还要注意把实现合约自身也初始化锁死,防止有人直接初始化实现合约。
六、工厂合约:汇编创建克隆
contract MinimalProxyFactory {
address[] public proxies;
function deployClone(address _implementation) external returns (address proxy) {
bytes20 impl = bytes20(_implementation);
assembly {
let clone := mload(0x40)
// 前 20 字节:init code + 前缀
mstore(clone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
// 偏移 0x14 处写入实现地址
mstore(add(clone, 0x14), impl)
// 偏移 0x28 处写入剩余字节码
mstore(add(clone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
// CREATE 部署:0 ether,55 字节(0x37)
proxy := create(0, clone, 0x37)
}
// 同一笔交易里立刻初始化
ImplementationContract(proxy).initializer();
proxies.push(proxy);
}
}
关键步骤:取空闲内存指针 → 分三段把字节码写进内存(中间塞入真实地址)→ CREATE 部署 → 立刻调 initializer()。
生产中直接用 OpenZeppelin Clones 库(Clones.clone(impl) 和 Clones.cloneDeterministic(impl, salt),后者用 CREATE2 得到确定性地址),无需手写汇编。
七、典型应用场景
- Gnosis Safe:每次创建新 Safe 都是部署一个克隆——你交互的其实是 Safe 的克隆;
- 批量发行逻辑相同、参数不同的 ERC20;
- 给每个用户部署独立的钱包/账户/金库合约(多租户,存储隔离);
- NFT 集合、代币发射台等工厂模式。
八、与其他模式对比
| 方面 | Clone(最小代理) | 传统可升级代理 |
|---|---|---|
| 可升级 | 否 | 是 |
| 实现地址存哪 | 字节码(不可变) | 存储(可变) |
| 多实例共享实现 | 是 | 通常单个 |
| 单实例部署成本 | 极低 | 较高 |
九、总结
- EIP-1167 用 55 字节字节码 + delegatecall 实现超便宜克隆;
- 实现地址硬编码进字节码 → 不可升级、但部署极省;
- 每个克隆存储独立、共享实现代码;
- 没有构造函数,用
initialize + initializer 守卫初始化; - 工厂 + CREATE(或 Clones 库的 CREATE2)批量部署;
- 适合大量相似实例(Gnosis Safe 是经典案例)。
十、动手练习项目:代币克隆工厂 TokenCloneFactory
项目目标
实现一个用 EIP-1167 克隆批量发行 ERC20 的工厂,亲手处理 initialize 模式和初始化守卫,对比克隆 vs 完整部署的 Gas,部署到 Sepolia。
合约要求
1. CloneableERC20.sol(实现合约,无构造函数初始化)
- 继承一个最简 ERC20(或自写);
- 不在构造函数里设参数,改用
initialize(string name, string symbol, uint256 supply, address owner); - 用 OZ
Initializable的initializer修饰符(或自写 bool 守卫)防重复; - 构造函数里调
_disableInitializers()把实现合约本身锁死,防止有人直接初始化它。
2. TokenCloneFactory.sol
- 用 OZ
Clones.clone(implementation)创建克隆(或手写第六节汇编); createToken(name, symbol, supply) external returns (address):克隆 + 调 initialize(owner = msg.sender)+ 记录到address[] public allTokens;- 提供
predictAddress(bytes32 salt)(用Clones.predictDeterministicAddress)和createTokenDeterministic(salt, ...)体验 CREATE2 确定性克隆。
测试要求(Foundry)
test_CloneByteSize:用proxy.code.length断言克隆运行时字节码为 45 字节;test_CloneIsInitialized:createToken 后克隆的 name/symbol/totalSupply 正确,owner 是调用者;test_CannotReinitialize:再次调克隆的 initialize 应 revert;test_ImplementationLocked:直接对实现合约调 initialize 应 revert(_disableInitializers 生效);test_ClonesShareLogic_IsolatedStorage:创建两个代币,转账互不影响,但都走同一份实现逻辑;test_DeterministicAddress:predictAddress(salt)==createTokenDeterministic(salt)实际地址;test_GasComparison:用vm.snapshot/gas 计量对比”克隆部署”vs”直接 new CloneableERC20”的 Gas,断言克隆便宜得多。
Sepolia 部署与验证步骤
- 部署 CloneableERC20(实现)和 TokenCloneFactory;
- 调
createToken("Alpha","ALP",1e24)和createToken("Beta","BTA",5e23)创建两个克隆; - 在 Etherscan 上查看克隆地址——字节码极短,应被识别为 Minimal Proxy 并显示指向实现;
- 用
predictAddress预测一个确定性地址,再createTokenDeterministic验证地址一致; - 对比工厂创建交易的 Gas 和直接部署一份 ERC20 的 Gas。
进阶挑战(可选)
- 手写第六节的汇编版
deployClone替换 Clones 库,确认部署出的字节码与库一致; - 写注释回答:为什么必须在实现合约构造函数里
_disableInitializers()?不锁会有什么攻击(提示:未初始化实现 + selfdestruct/任意 delegatecall)?