EIP-1167 详解

概述

EIP-1167,又称 Minimal Proxy Contract,提供了一种低成本复制合约的方法。它有什么意义呢?我们先来看个例子:

function createPair(address tokenA, address tokenB) external returns (address pair) {
    require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
    (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
    require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
    require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
    bytes memory bytecode = type(UniswapV2Pair).creationCode;
    bytes32 salt = keccak256(abi.encodePacked(token0, token1));
    // 通过原始 bytecode 创建合约
    assembly {
        pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
    }
    IUniswapV2Pair(pair).initialize(token0, token1);
    getPair[token0][token1] = pair;
    getPair[token1][token0] = pair; // populate mapping in the reverse direction
    allPairs.push(pair);
    emit PairCreated(token0, token1, pair, allPairs.length);
}

在 Uniswap v2 的工厂合约中,需要创建交易对合约,这里的方法是直接使用交易对合约,即 UniswapV2Pair 合约的 creationCode 进行创建。由于是创建原始的合约内容,因此有一个缺点就是耗费的 gas 取决于 Pair 合约的大小,Pair 合约的内容越多,耗费的 gas 越多。

那么有没有什么改进方法呢?答案是有的,就是通过代理合约来转发调用。从创建原始合约变为创建代理合约,而代理合约只需要负责将调用转发给原始合约就行了,实际执行的逻辑仍然使用最原始的合约。接下来的内容需要大家了解内存布局和 delegatecall 相关的内容,不了解的朋友可以看看这里这里

原理

提到代理,可能大家想到的第一反应就是合约升级,但是我们今天说的代理并不涉及合约升级,它仅仅负责合约调用的转发。

我们先来看看可升级合约的代理合约架构:

可升级合约架构
可升级合约架构

整个架构中存在一个代理合约和 N 个逻辑合约,只有一套数据,需要升级时则替换逻辑合约,同一时间只能存在一个逻辑合约。

再来看看今天提到的 Minimal Proxy Contract 架构:

Minimal Proxy Contract 架构
Minimal Proxy Contract 架构

整个架构中存在 N 个代理合约和一个逻辑合约,有多套数据分别存储在不同的代理合约中,所有代理合约共享逻辑合约的执行逻辑,同一时间存在多个代理合约。Minimal Proxy Contract 的原理就是将代理合约作为逻辑合约的复制品,各个代理合约存储各自的数据,需要多少份复制品就创建多少个代理合约即可。而代理合约本身只负责请求转发,因此其内容很少,从而耗费更少的 gas。

我们来看一个例子(注意这里是为了简单起见直接使用了构造函数,实际应用中不应该使用构造函数,因为这部分不会在代理合约中运行,即不会被初始化。因此如果有初始化逻辑,需要放在 initialize 函数中额外调用):

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

contract Demo {
    uint256 public a;

    constructor() {
        a = 1000;
    }

    function setA(uint256 _a) external {
        a = _a;
    }
}

现在我们要对这个 Demo 合约进行复制,可以借助 OZ 的 Clones 合约来完成:

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts v4.4.1 (proxy/Clones.sol)

pragma solidity ^0.8.0;
library Clones {
    /**
     * @dev Deploys and returns the address of a clone that mimics the behaviour of `implementation`.
     *
     * This function uses the create opcode, which should never revert.
     */
    function clone(address implementation) internal returns (address instance) {
        assembly {
            let ptr := mload(0x40)
            mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
            mstore(add(ptr, 0x14), shl(0x60, implementation))
            mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
            instance := create(0, ptr, 0x37)
        }
        require(instance != address(0), "ERC1167: create failed");
    }
    ///....
}

clone 方法需要接受一个原始逻辑合约的地址,然后返回新生成的克隆合约(也就是我们上面说的代理合约)地址。

我们来看看上面代码是什么意思,首先 let ptr := mload(0x40) 获取内存中空闲内存指针的位置,下一行的 mstore(ptr, 0x3d602d80600a3d3981f33…),将该数据存入内存中,接下来的 shl(0x60, implementation) 将地址左移 0x60,即 96 位。由于 address 类型实际只占用 20 个字节,而传入的参数是通过 0 补齐到 32 字节的。假设传入的地址是

0xbebebebebebebebebebebebebebebebebebebebe,则补齐的内容为:

0x000000000000000000000000bebebebebebebebebebebebebebebebebebebebe

左移 96 位之后恰好获得原始的地址数据。

接下来将地址数据 mstore 储存到内存中,内存位置为 0x14,即 20。这个数据恰好是上面一行 0x3d602d80600a3d39 中截断后面零值的长度。

第三个 mstore 再将 0x5af43d82803e903d… 拼接到前面的数据所在内存位置后面。三个 mstore 操作下来,此时内存中的数据为:

3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

即三部分数据拼接到一起的结果。但是在合约地址前后拼接的这两部分是什么意思呢?

首先我们将前 10 个字节 3d602d80600a3d3981f3 反编译一下得到:

contract Contract {
    function main() {
        var var0 = returndata.length;
        memory[returndata.length:returndata.length + 0x2d] = code[0x0a:0x37];
        return memory[var0:var0 + 0x2d];
    }
}

即克隆合约的构造方法,内容是将整个克隆合约的字节码返回给 EVM。

再将后面的 45 字节数据 363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3 反编译:

contract Contract {
    function main() {
        var temp0 = msg.data.length;
        memory[returndata.length:returndata.length + temp0] = msg.data[returndata.length:returndata.length + temp0];
        var temp1 = returndata.length;
        var temp2;
        temp2, memory[returndata.length:returndata.length + returndata.length] = address(0xbebebebebebebebebebebebebebebebebebebebe).delegatecall.gas(msg.gas)(memory[returndata.length:returndata.length + msg.data.length]);
        var temp3 = returndata.length;
        memory[temp1:temp1 + temp3] = returndata[temp1:temp1 + temp3];
        var var1 = temp1;
        var var0 = returndata.length;
    
        if (temp2) { return memory[var1:var1 + var0]; }
        else { revert(memory[var1:var1 + var0]); }
    }
}

这部分内容是利用 delegatecall 将调用进行转发的逻辑。

clone 方法的最后一行使用了 create 方法创建克隆合约,长度 0x3755 是内存中克隆合约字节码的长度。

实践

现在我们来实际操作一下,首先编写克隆工厂合约:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "./Clones.sol";

contract MyClonesFactory {
    using Clones for address;

    event ProxyGenerated(address proxy);

    function clone(address implementation) external {
        address proxy = implementation.clone();
        emit ProxyGenerated(proxy);
    }
}

接下来先部署原始 Demo 合约,然后部署 MyClonesFactory 合约,随后调用 clone 方法,传入 Demo 合约地址,即可创建克隆合约。

创建原始 Demo 合约时耗费了 14 万 gas:

创建原始合约
创建原始合约

clone 方法创建克隆合约只花费了 6 万 gas:

创建克隆合约
创建克隆合约

注意克隆合约是通过内部交易创建的:

内部交易创建克隆合约
内部交易创建克隆合约

Etherscan 也已经支持了 EIP-1167 的合约验证,只要原始合约代码已经验证,那么克隆合约代码自动验证。

原始合约验证代码之前的克隆合约:

克隆合约的字节码
克隆合约的字节码

可以看到克隆合约的字节码就是去除构造方法字节码之后其余的部分,然后我们验证一下原始合约的代码,这时克隆合约就已经自动验证了,并且标注了 Minimal Proxy Contract

注意需要直接验证原始逻辑合约,如果验证克隆合约会失败。

总结

EIP-1167 提供了一种低成本克隆合约的方法,并且 Etherscan 也已经提供了支持,在需要创建多个合约的场景下,不失为一种好方法。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
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.