合约中创建新的合约
以太网区块链上,外部账户(Externally Owned Account,EOA)可以创建智能合约,智能合约同样可以创建新的合约。去中心化交易所Uniswap 就是利用工厂合约(PairFactory)创建了无数个币对合约(Pair)。
基于CREATE 操作码生成合约
在以太网虚拟机(EVM)中,在合约中创建新合约设计两个操作码:
CREATE和CREATE2
基于CREATE操作码生成合约,在Solidity中只需要用new操作符创建一个新合约,并传入合约的构造函数所需要的参数:
Conrtract x = new Contract{value: _value}(params)
Contract是要创建的合约名称,x 合约对象(合约地址),params 新合约的构造函数所需要的参数类型和值,逗号分隔。如果构造函数声明了payable关键字,可以创建时传入_value数量的ETH
例子:一个简化版的Uniswap
Uniswap V2 是一个通过工厂合约的方式创建新合约的框架。框架核心时两个合约:
(1)UniswapV2Pair:币对合约,用于管理币对地址、流动性、具体交易流程。
(2)UniswapV2Factory:工厂合约,用于创建新的币对,并管理币对地址。
利用CREATE实现一个简化版的Uniswap,包含负责管理币对地址的Pair合约和负责创建新的币对、管理币对地址的PairFactory
- Pair 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract Pair{
address public factory;// 工厂合约地址
address public token0;//代币1
address public token1;//代币2
constructor() payable {
factory = msg.sender;
}
function initialize(address _token0,address _token1) external {
require(msg.sender == factory,'UniswapV2: FORBIDDON');
token0 = _token0;
token1 = _token1;
}
}
为什么Uniswap V2 不再构造函数中直接更新token0和token1?答案是Uniswap V2 基于CREATE2操作码创建合约,限制了构造函数不能有参数。而CREATE操作码生成,允许合约构造函数有参数,讲token0和token1 更新。我们还是用initialize函数进行更新,目的是比较容易迁移到CREATE2的操作。
- PairFactory 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "./Pair.sol";
contract PairFactory {
mapping(address => mapping(address => address)) public getPair;// 通过两个代币地址查询Pair地址
address[] public allPairs; // 保存所有Pair地址
function createPair(address tokenA, address tokenB) external returns(address pairAddr) {
Pair pair = new Pair();
pair.initialize(tokenA, tokenB);
pairAddr = address(pair);
allPairs.push(pairAddr);
getPair[tokenA][tokenB] = pairAddr;
getPair[tokenB][tokenA] = pairAddr;
}
}
部署好合约,看看创建币对的地址
(1)部署好PairFactory 合约后,使用两个部署好的合约地址,作为参数调用createPair函数。
每次生成的地址不一样
(2)将上衣步骤生成的地址填入“AtAddress”,找到创建Pair合约并部署,查看其中的变量
(3)为使用new的操作语句打上断电,让人年后打开调试器模式,调用createPair函数,在编译生成的EVM操作码,不难找到用于创建合约的CREATE操作码
CREATE2 操作码
CREATE2 时以太坊虚拟机EVM引入的一个新的操作码,是我们在部署以太网之前就能与测试合约的地址。Uniswap v2框架创建Pair合约就是基于CREATE2,取代了基于CREATE的操作码的方法。
- CREATE 操作码如何计算新合约地址
智能合约可以由其他合约和普通账户利用CREATE操作码创建。
在这两种情况下,新合约的地址都以相同的方式计算:
获取创建者的地址_CreateorAddress(通常是钱包地址或者合约地址)和发送交易的总数nonce(对于合约账户,nonce是创建的合约总数,每创建一个合约nonce 加1)
然后计算hash值,得到新的地址_Address.
_Address = hash(_CreatorAdress, nonce)
CREATE 操作码的问题是:创建者的地址_CreatorAddress不会变,但nonce可能会随时间而改变,因此用CREATE创建的合约地址难以预测。
- CREATE2 操作码如何计算新合约地址
CREATE2的目的是让合约地址独立于未来的事件。不管未来区块链上发生了什么,用户都可以把合约部署在事先计算好的地址上。用CREATE2创建的合约地址由4个参数决定:
(1)0xFF:一个常数,目的是避免和CREATE创建的地址冲突
(2)_CreatorAddress:调用CREATOR2的当前合约(创建合约)的地址。
(3)salt:一个创建者指定的uint256类型的值,主要目的是用来影响新创建的合约地址
(4)initcode:新合约的初始代码。
CREATE2操作码汇总以上参数并计算哈希值,获得新合约的地址:
_Address = hash(0FF, _CreatorAddress, salt, initcode)
CREATE2确保了,如果创建者提供指定的salt和合约的initcode,通过CREATE2操作码将获得一个固定的地址_Address,不会因为后续区块链的变化而变化。
简化版的Uniswap2 ,升级上面的代码
- Pair合约,和上面一样
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract Pair{
address public factory;// 工厂合约地址
address public token0;//代币1
address public token1;//代币2
constructor() payable {
factory = msg.sender;
}
function initialize(address _token0,address _token1) external {
require(msg.sender == factory,'UniswapV2: FORBIDDON');
token0 = _token0;
token1 = _token1;
}
}
- PairFactory2 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "./Pair.sol";
contract PairFactory2{
mapping(address => mapping(address => address)) public getPair;// 通过两个代币地址查询Pair地址
address[] public allPairs; // 保存所有Pair地址
function createPair2(address tokenA, address tokenB) external returns(address pairAddr) {
require(tokenA != tokenB, 'IDENTICAL_ADDRESS');
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// CREATE2 创建新合约
Pair pair = new Pair{salt: salt}();
// 调用新合约的initialize方法
pair.initialize(tokenA, tokenB);
pairAddr = address(pair);
allPairs.push(pairAddr);
getPair[tokenA][tokenB] = pairAddr;
getPair[tokenB][tokenA] = pairAddr;
}
}
PairFactory2 合约有两个状态变量:getPair 是两个代币地址到币对地址的映射,方便根据代币找到币对地址;allPairs是币对地址的数组,存储了所有币对地址
PairFactory2 合约只有一个createPair2函数,基于CREATE2 操作码根据输入的两代币地址tokenA和tokenB 来创建新的Pair合约。其中创建合约的核心代码:
Pair pair = new Pair{salt:salt}();
与基于CREATE 操作码创建的方式相比,我们需要提供salt作为参数。在PairFactory2 合约中,我们利用tokenA和tokenB的值计算hash值,充当salt:
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
- 实现计算Pair合约的地址
编写一个calculateAddr 函数来事先计算通过tokenA和tokenB生成的Pair的地址。通过它,可以验证我们计算的地址和实际地址是否相同。
function caculateAddr(address tokenA, address tokenB) public view
returns(address predictedAddress){
require(tokenA != tokenB, 'IDENTICAL_ADDRESS');
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// 计算合约地址的方法 hash()
predictedAddress = address(uint160(uint(
keccak256(abi.encodePacked(
bytes1(0xff), address(this), salt,
keccak256(type(Pair).creationCode))
))
));
}
注意⚠️:如果要部署的合约构造函数中存在参数
Pair pair = new Pair{salt:salt}(address(this));
那么计算合约地址predictedAddress时,就应当把代入构造函数时的参数和Pair合约的bytecode一起打包,也就是改成
// 计算合约地址的方法 hash()
predictedAddress = address(uint160(uint(
keccak256(abi.encodePacked(
bytes1(0xff), address(this), salt,
keccak256(type(Pair).creationCode)),
abi.encode(address(this)) // ⚠️这里
))
));
测试,验证下地址
tokenA: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
tokenB:0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
先预测一下地址caculatedAddr:
address: predictedAddress 0xC74cB7d5B0803050533F19A040AA452732ff7E60

getPair:address: 0xC74cB7d5B0803050533F19A040AA452732ff7E60
验证结果一致。
CREATE2操作码实际场景
CREATE2 操作码出现,许多场景逐步替代CREATE操作码
(1)交易所为新用户创建并预留的钱包合约地址
(2)在UniswapV2,基于CREATE2操作码的工厂合约完成,这样做的好处是:
它可以得到一个确定的交易对地址,通过Router函数就可以由币对地址(tokenA,tokenB)计算出交易对地址,不再需要通过调用工厂合约函数Factory.getPari(tokenA,tokenB)获得交易对地址,省去了跨合约调用。
CREATE2让我们在部署合约前确定他的合约地址,这也是一些基于以太坊Layer2方案的合约项目的基础
删除合约
selfdestruct命令
用来删除智能合约,并将该合约中剩余的ETH转到指定地址。selfdestruct为了应对合约出错的极端情况而设计的。最早命名suicide,但是这个词过于敏感,后来改为selfdestruct
selfdestruct命令的格式:
selfdestruct(_addr);
_addr是目标合约或者钱包地址,用来接收合约中剩余的ETH。
例子 DeleteConstract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract DeleteContract {
uint public value = 10;
constructor() payable {}
receive() external payable { }
function getBalance() external view returns(uint balance){
balance =address(this).balance;
}
function deleteConstract() external{
selfdestruct(payable (msg.sender));
}
}
测试:
(1)部署合约DeleteConstract之前设置转入金额1ETH,因为合约具有payagble 的构造函数
(2)部署后调用合约getBalance()函数返回余额是1ETH
(3)查看原来钱包地址中的余额,减少的金额略多于1ETH,因为转入合约时候只出了gas,从钱包扣除
(4)调用deleteContract()函数后,合约竟被删除,此时试图调用合约函数将会报错
(5)查看钱包地址中的余额,删除合约时,钱包回收了剩余的1ETH,但支出了额外的gas