通过阅读本文你可以学到:
如何应用 EIP1167 来低成本的创建大量的代理合约以及其需要注意的点
immutable 的妙用
所有代码均在代码仓库中
找到欢迎大家 clone & 玩耍 & star
在文章
中,我们实现了一个简单的通用型薅羊毛合约。但是在后续的测试过程中发现其有一个致命问题: 一次无法创建大量子合约地址。比如如果我们试图创建 100 个地址,则会报错(即使将 gasLimit 设置为 3000w):
子合约太长导致报错这搞毛啊,说好的批量撸羊毛的,100 个地址都做不到,那还怎么玩?好在天无绝人之路,在各个 web3 技术群中发现了一个叫做 mini proxy 的神器,具体文档参考下面链接:
限于篇幅,本文不会仔细的分析 mini proxy 的原理,大家只需要记住几个关键点:
mini proxy 合约非常短,只有 45 个字节
mini proxy 合约创建后没有存储任何数据(也就是未指定 owner),所以可以被 EOA 地址直接调用
mini proxy 会将所有调用,通过 delegatecall 转发到 logic 合约中
看看如何集成 miniproxy,代码(有 BUG 版本)如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
import "hardhat/console.sol";
contract BatcherV2 {
// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1167.md
bytes32 byteCode;
uint n;
address private immutable deployer;
constructor(uint _n) {
deployer = msg.sender;
n = _n;
createProxies(_n);
}
function createProxies(uint _n) internal {
bytes memory miniProxy = bytes.concat(bytes20(0x3D602d80600A3D3981F3363d3d373d3D3D363d73), bytes20(address(this)), bytes15(0x5af43d82803e903d91602b57fd5bf3));
byteCode = keccak256(abi.encodePacked(miniProxy));
address proxy;
for(uint i=0; i<_n; i++) {
bytes32 salt = keccak256(abi.encodePacked(msg.sender, i));
assembly {
proxy := create2(0, add(miniProxy, 32), mload(miniProxy), salt)
}
}
}
function execute(address target, bytes memory data) external {
require(msg.sender == deployer, "Only deployer can call this function.");
for(uint i=0; i<n; i++) {
address proxy = proxyFor(msg.sender, i);
BatcherV2(proxy).callback(target, data);
}
}
function callback(address target, bytes memory data) external {
(bool success, ) = target.call(data);
require(success, "Transaction failed.");
}
function proxyFor(address sender, uint i) public view returns (address proxy) {
bytes32 salt = keccak256(abi.encodePacked(sender, i));
proxy = address(uint160(uint(keccak256(abi.encodePacked(
hex'ff',
address(this),
salt,
byteCode
)))));
}
}
关于这次的代码,架构要比之前的更加复杂,BatcherV2 不但是我们的门面合约,而且还作为 mini proxy 的 logic 合约。目前的调用时序图如下:
现在我们来对上述合约的改动进行逐步分析:
createProxies 里面就是 MiniProxy 的创建代码,更为细致的解释可以参考上面的 github 链接。同时注意我们这里使用了 create2 来创建确定性地址合约。
callback 很简单,就是执行传来的任意 tx将部署 proxy 数量改为 100,
fallback 函数没有了,取而代之的是 execute 函数,其职能基本与之前的 fallback 一样,就是将调用转发到每一个 mini proxy 合约。有两点需要着重强调下:
BatcherV2(proxy).callback(target, data); 这行代码值得详细说一下:因为 Batcher 是 MiniProxy 的 logic 合约,所以其实对 MiniProxy 的调用,最终都会落到 Batcher 自己身上,所以我们可以直接将 proxy 转换为一个 BatcherV2 调用 callback ,但是请务必注意,这个 fallback 是 MiniProxy 通过 delegatecall 调用的!
proxy 的创建是使用 create2,所以我们在 proxyFor 处,动态的计算每个 MiniProxy 合约的地址
在仓库中运行
npx hardhat test
可以得到测试通过的输出
成功,非常好!除了我们的合约还存在一个极大的安全隐患!前面提到过 MiniProxy 刚创建时并为指定 owner,所以任何人都可以直接对其调用 claimMintRewardAndShare 方法。看下面的具体测试代码:
黑客直接调用 proxy对应的测试输出如下:
一个黑客直接获取到了我们一个(当然也可以全部拿走) mini proxy 的地址,然后直接在 mini proxy 合约上调用 callback() 取走了我们的 XEN。
那这个问题如何修复呢?一个很直接的想法就是,添加断言,只允许 Batcher 调用 MiniProxy,对应修复代码如下
:...skip
// 添加变量到第一个位置, 记录 batcher 地址
address private original;
constructor(uint _n) {
original = address(this);
deployer = msg.sender;
n = _n;
createProxies(_n);
}
function callback(address target, bytes memory data) external {
require(msg.sender == original, "Only original can call this function.");
(bool success, ) = target.call(data);
require(success, "Transaction failed.");
}
看起来好像没问题,但是测试结果却给我们当头一棒,输出结果如下:
黑客确实偷不走了,合约也报废了!
这是为什么呢?其实接触过 proxy 模式的同学想必已经发现了问题所在:因为 delegatecall 会保留当前执行的上下文,即虽然 callback 是 Batcher 中的代码,但是执行的时候,却在 MiniProxy 的存储上下文:msg.sender == original 其实会被翻译成 msg.sender == slot0,而很不幸, MiniProxy 什么都没存(可以通过检查 MiniProxy 的 slot 来确定)。所以这个断言永远不可能成立。这怎么办呢?
一个最直观的办法是在 logic 合约(也就是本文的 Batcher)里面提供一个设置 owner 的接口,在每个 mini proxy 创建之后再调用一下设置 owner 的接口即可。
但是本人无意间发现了一个神奇的修饰词: immutable,其与 const 有一个重要特性:
太神奇了,immutable 变量在初始化之后,后续的使用都会被直接替换而不需要存储在 slot 中。msg.sender == original 的判断,将不再需要读取 storage,退化成类似和一个常量的比较!将 original 改为 immutable 之后,重新测试,终于完美通过:
最后让我们总结一下技术要点:
mini proxy 可以低成本创建大量的 proxy 合约,但是需要额外的机制保证不被黑客直接调用 proxy 合约
代理模式下一定要注意权限的控制,以及上下文切换时一些违反直觉的情况
immutable 变量不占用任何 storage slot,可以与 mini proxy 完美配合,实现对 proxy 使用权的控制