创建合约 create2

本章讲解在 Solidity 中,如何使用 create2 创建合约。

官网binschool.app
推特@BinSchool    DiscordBinDAO   微信:bkra50 

在以太坊虚拟机 EVM 中,有两条指令都可以创建合约,分别是:CREATECREATE2

其中,指令 CREATE2 是在 2019 年 “君士坦丁堡” 升级时引入的,目的是为了更好地控制新创建合约的地址。

那么,在 Solidity 代码中,如何调用 CREATE2 指令创建合约呢?

共有两种方式:使用 new 操作符,或者调用内联汇编的 create2 操作码。

1. 使用 new 创建合约

new 操作符语法

ContractType instance = new ContractType{salt: _salt, value: _value}(param1, param2, ...)

其中,ContractType 是新建合约的类型;

_salt 是进行 hash 运算时加入的盐值;

_value 是创建合约时,存入合约以太币的数量;

param1, param2 是新建合约的构造函数的参数;

返回值 instance 是新建合约的实例。

new 操作符示例

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

// 被创建合约
contract Callee {
  string value1;
  string value2;

  // 构造函数有两个参数
  constructor(string memory _value1,string memory _value2) {
    value1 = _value1;
    value2 = _value2;
  }
}

// 合约创建者
contract ContractCreator {
  // 新合约地址
  address public contractAddress;

  // 创建合约
  function newContract(string memory value1,string memory value2) external {
    // 生成盐值
    bytes32 salt = keccak256(abi.encodePacked(value1,value2));
    // 创建 Callee 合约实例,参数为 _salt, value1, value2
    Callee callee = new Callee{salt:salt}(value1, value2);
    // 设置新建合约的地址
    contractAddress = address(callee);
  }
}

其中,盐值可以是任意数值。我们在上面的例子中,是先将参数拼接,然后取其哈希值作为盐值。

将合约 ContractCreator 部署到 Remix 上。

首先点击 contractAddr,由于还没有创建 Callee 合约,所以值为 0。

 

输入字符串 "a","b",点击 newContract,创建 Callee 合约成功。再点击 contractAddr,它的值已经变成新合约的地址。

 

2. 使用内联汇编 create2 创建合约

内联汇编 create2 语法

assembly {
    instance := create2(value, codeOffset, codeLength, salt)
}

其中,参数 value 是创建合约时,存入合约以太币的数量;

codeOffset 是合约字节码的位置偏移;

codeLength 是合约字节码的长度;

salt 是进行 hash 运算时加入的盐值;

返回值 instance 是新建合约的地址。

内联汇编 create 示例

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

// 被创建合约
contract Callee {
  string value1;
  string value2;

  // 构造函数有两个参数
  constructor(string memory _value1,string memory _value2) {
    value1 = _value1;
    value2 = _value2;
  }
}

// 合约创建者
contract ContractCreator {
  // 新合约地址
  address public contractAddress;

  // 创建合约
  function createContract(string memory value1,string memory value2) external {
    // 生成盐值
    bytes32 salt = keccak256(abi.encodePacked(value1,value2));
    // 将合约字节码和参数 value1, value2 打包编码
    bytes memory bytecode = abi.encodePacked(
        type(Callee).creationCode,
        abi.encode(value1, value2)
    );

    address addr;
    assembly {
       // 创建 Callee 合约实例
       addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
    }
    // 设置新建合约的地址
    contractAddress = addr;
  }
}

其中,type(Callee).creationCode 是获取 Callee 合约的字节码。

mload(bytecode) 获取智能合约字节码中执行代码的长度。在智能合约字节码中,前 32 字节为执行代码的长度。

add(bytecode, 0x20) 获取智能合约字节码中执行代码的偏移位置。在智能合约字节码中,32 字节后为执行代码。

salt 是进行 hash 运算时加入的盐值,越随机越好,防止出现 hash 后得到的相同结果。

将合约 ContractCreator 部署到 Remix 上。

首先点击 contractAddr,由于还没有创建 Callee 合约,所以值为 0。

 

输入字符串 "a","b",点击 createContract,创建 Callee 合约成功。再点击 contractAddr,它的值已经变成新合约的地址。

 

3. create2 计算合约地址

create2create 相比,在创建合约时,为部署者提供了更多的控制权来决定合约的地址,常用于合约工厂、可升级合约等场景中。比如 Uniswap 中就使用了 create2 来创建 pair 合约。

使用 create 创建的合约,它的生成地址由部署者地址和 nonce 计算获得,其中的 nonce 是不可控的,所以,新创建合约的地址也是不可控的。

使用 create2 创建的合约,它的生成地址与 nonce 无关,但与部署者提供的 salt 有关,所以,新创建合约的地址是可控的。部署者可以预先计算新创建合约的地址,这一点,对于提高 DApp 前端用户的体验非常有用。

比如,在 Uniswap 中创建新的交易对合约时,前端界面需要显示它的地址。如果等待合约部署完成,再返回交易对地址的话,那么界面会有几秒到十几秒的停顿,这非常影响用户的体验。如果使用 create2 来创建交易对合约,那么就可以立刻计算出交易对的地址,无需等待。

那么,使用 create2 创建的合约,如何提前计算它的合约地址呢?

它的计算公式如下:

keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12:]

先将常量 0xff、创建者地址 address、盐值 salt 和 合约字节码的哈希值,进行一次 abi.encodePacked 编码。

然后对编码结果进行一次 keccak256 哈希,得到一个 32 字节的值,最后取其后 20 个字节。

下面合约中的 predictCreate2Address 函数用于计算新创建合约的地址:

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

// 被创建合约
contract Callee {
  string value1;
  string value2;

  // 构造函数有两个参数
  constructor(string memory _value1,string memory _value2) {
    value1 = _value1;
    value2 = _value2;
  }
}

// 合约创建者
contract ContractCreator {
  // 新合约地址
  address public contractAddress;

  // 创建合约
  function newContract(string memory value1,string memory value2) external {
    // 生成盐值
    bytes32 salt = keccak256(abi.encodePacked(value1,value2));
    // 创建 Callee 合约实例,参数为 _salt, value1, value2
    Callee callee = new Callee{salt:salt}(value1, value2);
    // 设置新建合约的地址
    contractAddress = address(callee);
  }

  // 计算合约地址
  function predictCreate2Address(string memory value1,string memory value2) external view returns (address) {
    // 生成盐值
    bytes32 salt = keccak256(abi.encodePacked(value1,value2));
    // 将合约字节码和参数 value1, value2 打包编码
    bytes memory bytecode = abi.encodePacked(
        type(Callee).creationCode,
        abi.encode(value1, value2)
    );

    // 计算新合约所有参数的 hash 值
    bytes32 hash = keccak256(abi.encodePacked(
        bytes1(0xff),
        address(this),
        salt,
        keccak256(bytecode)
    ));

    // 截取 hash 值右边的160位,作为新合约地址
    address predictedAddress = address(uint160(uint(hash)));
    return predictedAddress;
  }
}

predictCreate2Address 函数中,我们可以看到,用 create2 创建的合约,生成的地址由4个部分决定:

  • 0xFF
    一个固定常数,避免和 create 创建的合约地址冲突。
  • owner
    合约创建者地址。
  • salt
    合约创建者给定的数值,在哈希函数充当盐值。
  • bytecode
    待部署合约的字节码。

我们把合约部署到 Remix 上。 输入字符串 "a","b",点击 createContract,创建 Callee 合约成功。再点击 contractAddr,它的值已经变成新合约的地址。

然后再点击 predictCreate2Address,返回预测的合约地址。

我们可以对比一下,预测地址和已部署合约的地址是相同。