爆改!都是科技与狠活
October 12th, 2022

通过阅读本文你可以学到:

  1. 如何应用 EIP1167 来低成本的创建大量的代理合约以及其需要注意的点

  2. immutable 的妙用

所有代码均在代码仓库中

找到欢迎大家 clone & 玩耍 & star

在文章

中,我们实现了一个简单的通用型薅羊毛合约。但是在后续的测试过程中发现其有一个致命问题: 一次无法创建大量子合约地址。比如如果我们试图创建 100 个地址,则会报错(即使将 gasLimit 设置为 3000w):

子合约太长导致报错这搞毛啊,说好的批量撸羊毛的,100 个地址都做不到,那还怎么玩?好在天无绝人之路,在各个 web3 技术群中发现了一个叫做 mini proxy 的神器,具体文档参考下面链接:

限于篇幅,本文不会仔细的分析 mini proxy 的原理,大家只需要记住几个关键点:

  1. mini proxy 合约非常短,只有 45 个字节

  2. mini proxy 合约创建后没有存储任何数据(也就是未指定 owner),所以可以被 EOA 地址直接调用

  3. 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 合约。目前的调用时序图如下:

调用情况
调用情况

现在我们来对上述合约的改动进行逐步分析:

  1. createProxies 里面就是 MiniProxy 的创建代码,更为细致的解释可以参考上面的 github 链接。同时注意我们这里使用了 create2 来创建确定性地址合约。

  2. callback 很简单,就是执行传来的任意 tx将部署 proxy 数量改为 100,

  3. fallback 函数没有了,取而代之的是 execute 函数,其职能基本与之前的 fallback 一样,就是将调用转发到每一个 mini proxy 合约。有两点需要着重强调下:

    1. BatcherV2(proxy).callback(target, data); 这行代码值得详细说一下:因为 Batcher 是 MiniProxy 的 logic 合约,所以其实对 MiniProxy 的调用,最终都会落到 Batcher 自己身上,所以我们可以直接将 proxy 转换为一个 BatcherV2 调用 callback ,但是请务必注意,这个 fallback 是 MiniProxy 通过 delegatecall 调用的!

    2. proxy 的创建是使用 create2,所以我们在 proxyFor 处,动态的计算每个 MiniProxy 合约的地址

在仓库中运行

npx hardhat test

可以得到测试通过的输出

搞定了吗?
搞定了吗?

成功,非常好!除了我们的合约还存在一个极大的安全隐患!前面提到过 MiniProxy 刚创建时并为指定 owner,所以任何人都可以直接对其调用 claimMintRewardAndShare 方法。看下面的具体测试代码:

小心黑客!
小心黑客!

黑客直接调用 proxy对应的测试输出如下:

还我 XEN
还我 XEN

一个黑客直接获取到了我们一个(当然也可以全部拿走) 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.");
	}

看起来好像没问题,但是测试结果却给我们当头一棒,输出结果如下:

claim 也因为断言失败了
claim 也因为断言失败了

黑客确实偷不走了,合约也报废了!

这是为什么呢?其实接触过 proxy 模式的同学想必已经发现了问题所在:因为 delegatecall 会保留当前执行的上下文,即虽然 callback 是 Batcher 中的代码,但是执行的时候,却在 MiniProxy 的存储上下文:msg.sender == original 其实会被翻译成 msg.sender == slot0,而很不幸, MiniProxy 什么都没存(可以通过检查 MiniProxy 的 slot 来确定)。所以这个断言永远不可能成立。这怎么办呢?

一个最直观的办法是在 logic 合约(也就是本文的 Batcher)里面提供一个设置 owner 的接口,在每个 mini proxy 创建之后再调用一下设置 owner 的接口即可。

但是本人无意间发现了一个神奇的修饰词: immutable,其与 const 有一个重要特性:

immutable & const 不占用存储空间!
immutable & const 不占用存储空间!

太神奇了,immutable 变量在初始化之后,后续的使用都会被直接替换而不需要存储在 slot 中。msg.sender == original 的判断,将不再需要读取 storage,退化成类似和一个常量的比较!将 original 改为 immutable 之后,重新测试,终于完美通过:

乌拉!
乌拉!

最后让我们总结一下技术要点:

  1. mini proxy 可以低成本创建大量的 proxy 合约,但是需要额外的机制保证不被黑客直接调用 proxy 合约

  2. 代理模式下一定要注意权限的控制,以及上下文切换时一些违反直觉的情况

  3. immutable 变量不占用任何 storage slot,可以与 mini proxy 完美配合,实现对 proxy 使用权的控制

Subscribe to NealZhu
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from NealZhu

Skeleton

Skeleton

Skeleton