掘金 后端 ( ) • 2024-04-16 15:35

theme: smartblue

在我们进行合约开发时有一个痛点是,升级部署到链上后不能再更改,但如果了解Solidity比较深的小伙伴就知道,Solidity有个delegate方法,可以实现通过代理合约调用逻辑合约,我们的数据存储在代理合约中,执行的逻辑在逻辑合约中,我们想要升级合约时只需要部署新的逻辑合约即可。具体执行逻辑如下图:

image.png

一、简单可升级合约

// SPDX-License-Identifier: MIT
// wtf.academy
pragma solidity ^0.8.4;

// 简单的可升级合约,管理员可以通过升级函数更改逻辑合约地址,从而改变合约的逻辑。
// 教学演示用,不要用在生产环境
contract SimpleUpgrade {
    address public implementation; // 逻辑合约地址
    address public admin; // admin地址
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 构造函数,初始化admin和逻辑合约地址
    constructor(address _implementation){
        admin = msg.sender;
        implementation = _implementation;
    }

    // fallback函数,将调用委托给逻辑合约
    fallback() external payable {
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }

    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }
}

// 旧逻辑合约
contract Logic1 {
    // 状态变量和proxy合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,选择器: 0xc2985578
    function foo() public{
        words = "old";
    }
}

// 新逻辑合约
contract Logic2 {
    // 状态变量和proxy合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,选择器:0xc2985578
    function foo() public{
        words = "new";
    }
}

代码中包含了3个合约:

  • SimpleUpgrade: 代理合约
  • Logic1: 旧逻辑合约
  • Logic2: 新逻辑合约

1. 代理合约SimpleUpgrade

代理合约包含3个变量:

  • implementation: 逻辑合约地址
  • admin: 合约管理员地址
  • words: 字符串,通过调用逻辑合约函数来改变

也包含了3个函数:

  • 构造函数: 初始化adminimplementation地址
  • fallback函数: 委托函数,会将函数调用委托给逻辑合约执行,需要通过函数选择器calldata来调用
  • upgrade函数: 升级函数,只能由admin调用,改变逻辑合约地址

2. 旧逻辑合约

旧逻辑合约中变量和代理合约保持一致(防止函数执行时插槽错误),通过代理合约调用时改变的状态变量是代理合约中的,有一个函数foo,将代理合约中的words值改为old

3. 新逻辑合约

和旧逻辑合约逻辑一直,foo将代理合约中的words改为new

4. 部署测试

  1. Remix中首先部署旧逻辑合约(Logic1)新逻辑合约(Logic2)
  2. 再部署代理合约(SimpleUpgrade),构造函数中填入旧逻辑合约(Logic1)的地址
  3. 都部署好后可以通过代理合约去调用旧逻辑合约foo函数,需要通过低级调用的方式填入函数签名在calldata中,这里填入c2985578

image-3.png

函数签名可以通过https://abi.hashex.org/,来生成

image-2.png

  1. 此时查看代理合约中的words就被改成了old
  2. 再调用代理合约的upgrade函数,填入新逻辑合约地址,实现逻辑合约的升级
  3. 最后再次通过低级调用的方式填入函数签名在calldata中调用新逻辑合约的foo函数,就可以看到代理合约中的words改变为了new

到此,我们就完成了可升级合约的开发和部署,但可升级合约还有可能产生选择器冲突问题。

二、透明代理和通用可升级代理(UUPS)

大家可以看到我们上面填的两个foo函数的函数签名其实是foo哈希后取的前 4 个字节,4 个字节这个范围其实很少,两个不同的函数很有可能造成hash的前 4 个字节一样,这就造成了选择器冲突。

如果选择器冲突出现在同一个合约中,那么合约是无法编译成功的,但是可升级合约会部署两个合约,比如代理合约的升级函数和逻辑合约中其中一个函数有选择器冲突,那么管理人在调用逻辑合约中的函数就可能将代理合约升级成黑洞合约,有严重的安全问题。

解决的方法一般有两种:

  • 透明代理
  • 通用可升级代理

1. 透明代理

// SPDX-License-Identifier: MIT
// wtf.academy
pragma solidity ^0.8.4;

// 透明可升级合约的教学代码,不要用于生产。
contract TransparentProxy {
    address implementation; // logic合约地址
    address admin; // 管理员
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 构造函数,初始化admin和逻辑合约地址
    constructor(address _implementation){
        admin = msg.sender;
        implementation = _implementation;
    }

    // fallback函数,将调用委托给逻辑合约
    // 不能被admin调用,避免选择器冲突引发意外
    fallback() external payable {
        require(msg.sender != admin);
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }

    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        if (msg.sender != admin) revert();
        implementation = newImplementation;
    }
}

// 旧逻辑合约
contract Logic1 {
    // 状态变量和proxy合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,选择器: 0xc2985578
    function foo() public{
        words = "old";
    }
}

// 新逻辑合约
contract Logic2 {
    // 状态变量和proxy合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,选择器:0xc2985578
    function foo() public{
        words = "new";
    }
}

透明代理是通过限制管理员的权限,管理员只能调用代理合约中的升级函数,不能调用逻辑合约中函数,其他用户只能调用逻辑合约中的函数不能调用代理合约的升级函数来解决选择器冲突问题。

2. 通用可升级代理(UUPS)

// SPDX-License-Identifier: MIT
// wtf.academy
pragma solidity ^0.8.4;

contract UUPSProxy {
    address public implementation; // 逻辑合约地址
    address public admin; // admin地址
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 构造函数,初始化admin和逻辑合约地址
    constructor(address _implementation){
        admin = msg.sender;
        implementation = _implementation;
    }

    // fallback函数,将调用委托给逻辑合约
    fallback() external payable {
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }
}

// UUPS逻辑合约(升级函数写在逻辑合约内)
contract UUPS1{
    // 状态变量和proxy合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,选择器: 0xc2985578
    function foo() public{
        words = "old";
    }

    // 升级函数,改变逻辑合约地址,只能由admin调用。选择器:0x0900f010
    // UUPS中,逻辑函数中必须包含升级函数,不然就不能再升级了。
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }
}

// 新的UUPS逻辑合约
contract UUPS2{
    // 状态变量和proxy合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    string public words; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,选择器: 0xc2985578
    function foo() public{
        words = "new";
    }

    // 升级函数,改变逻辑合约地址,只能由admin调用。选择器:0x0900f010
    // UUPS中,逻辑函数中必须包含升级函数,不然就不能再升级了。
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }
}

通用可升级代理(UUPS)是通过把升级函数也放在逻辑合约中,代理合约只存储状态变量和调用逻辑合约中的所有函数(升级函数和其他逻辑函数)来解决选择器冲突问题,因为通过代理合约来调用逻辑合约的升级函数时,改变的也是代理合约中存储的逻辑合约的地址,这样我们其实升级也是没有任何问题的。

三、使用Hardhat+OpenZeppelin开发生产环境的可升级合约

用上面的办法开发的可升级合约,虽然可以实现可升级功能,但对于一些安全问题没有很好的处理,所以我们一般在实际项目开发中会使用如HardhatOpenZeppelin等工具来开发可升级合约。

1. 初始化一个Hardhat项目

npm init
npm install --save-dev hardhat
npx hardhat init
$ npx hardhat init
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.22.2 👷‍

? What do you want to do? …
> Create a JavaScript project
  Create a TypeScript project
  Create a TypeScript project (with Viem)
  Create an empty hardhat.config.js
  Quit

2. 安装OpenZeppelin可升级合约的hardhat插件依赖和合约依赖

npm install --save-dev @openzeppelin/hardhat-upgrades
npm install --save-dev @openzeppelin/contracts-upgradeable

3. 合约编写

contracts/Box.sol:

// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract Box is Initializable {
    uint256 private _value;

    function initialize(uint256 value) public initializer {
        _value = value;
    }

    // Emitted when the stored value changes
    event ValueChanged(uint256 value);

    // Stores a new value in the contract
    function store(uint256 value) public {
        _value = value;
        emit ValueChanged(value);
    }

    // Reads the last stored value
    function retrieve() public view returns (uint256) {
        return _value;
    }
}

合约很简单,就是存储了一个_value值,并通过store来修改这个值,并通过retrieve来读取这个值。

其中有个很关键的initialize函数,这是合约的初始化函数,在以前我们写构造函数是通过constructor,但在OpenZeppelin可升级合约中需要使用initialize函数。并通过继承Initializable合约,并在initialize函数上添加initializer函数修饰器来确保这个初始化函数只能执行一次。

4. 部署合约

script/deploy.js

const { ethers, upgrades } = require("hardhat");

async function main() {
  const Box = await ethers.getContractFactory("Box");
  console.log("Deploying Box...");
  const box = await upgrades.deployProxy(Box, [70]);
  console.log("Box deployed to:", box.target);
}

main();

其中deployProxy的第二参数为初始化函数需要的参数,通过数组的形式传进去,然后通过运行下面命令来部署,--network为我自己添加的本地ganache网络,也可以改成其他网络或者不写,不写会部署到hardhat的本地测试网络。

npx hardhat run script/deploy.js --network ganache

image-4.png

5. 可以通过hardhat提供的console来测试

npx hardhat console --network ganache
Welcome to Node.js v12.22.1.
Type ".help" for more information.
> const Box = await ethers.getContractFactory('Box');
undefined
> const box = await Box.attach('0xC707173c04105676B7AbadEA745A1cc04f3A5b3A');
undefined
> (await box.retrieve()).toString();
'70'

其中Box.attach函数需要填入我们上面部署好的Box合约地址。

6. 编写新的BoxV2合约

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

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BoxV2 is Initializable {
    uint256 private _value;

    function initialize(uint256 value) public initializer {
        _value = value;
    }

    // Emitted when the stored value changes
    event ValueChanged(uint256 value);

    // Stores a new value in the contract
    function store(uint256 value) public {
        _value = value;
        emit ValueChanged(value);
    }

    // Reads the last stored value
    function retrieve() public view returns (uint256) {
        return _value;
    }

    // Increments the stored value by 1
    function increment() public {
        _value = _value + 1;
        emit ValueChanged(_value);
    }
}

BoxV2合约中我们新增了一个increment函数,用来增加_value的值。

7. 编写升级脚本

script/upgrade.js

const { ethers, upgrades } = require("hardhat");

async function main() {
  const BoxV2 = await ethers.getContractFactory("BoxV2");
  console.log("Upgrading Box...");
  await upgrades.upgradeProxy(
    "0xC707173c04105676B7AbadEA745A1cc04f3A5b3A",
    BoxV2
  );
  console.log("Box upgraded");
}

main();

其中upgradeProxy函数需要填入我们上面部署好的Box合约地址,并在命令行执行下面命令来升级

npx hardhat run .\scripts\upgrade.js --network ganache

最后在通过hardhatconsole来测试发现就多了一个increment函数了。

npx hardhat console --network ganache
Welcome to Node.js v12.22.1.
Type ".help" for more information.
> const BoxV2 = await ethers.getContractFactory('BoxV2');
undefined
> const box = await BoxV2.attach('0xC707173c04105676B7AbadEA745A1cc04f3A5b3A');
undefined
> await box.increment();
...
> (await box.retrieve()).toString();
'71'

至此我们就通过HardhatOpenZeppelin来实现了一个生产环境可用的可升级合约,大家开发自己的可升级合约时就可以参考这个形式开发即可。