通用升级合约(UUPS)

通用升级合约(UUPS)相比透明代理合约,UUPS 代理合约最终可以移出其升级能力,使代码真正不可变,而且 UUPS 在技术上部署成本稍低一些。

OpenZeppelin 的文档中详细介绍了这两种代理的区别: https://docs.openzeppelin.com/contracts/5.x/api/proxy#transparent-vs-uups

构建一个可升级合约

构建 V1 合约,V2 合约

构建一个 UUPS 合约,可以引入 OpenZeppelin 的标准库,对于 Foundry 开发环境:

forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit

即可安装 Foundry 到项目中,使用时,导入即可(记得 Foundry.toml 重映射)

import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

UUPSUpgradeable 是一个抽象合约,需要我们在我们自己的合约中重写_authorizeUpgrade函数:

function _authorizeUpgrade(address newImplementation) internal override {}

关于重写该函数的原因,在 OpenZeppelin 库中给出了原因:

/**
* @dev Function that should revert when `msg.sender` is not authorized to upgrade the contract. Called by
* {upgradeToAndCall}.
*
* Normally, this function will use an xref:access.adoc[access control] modifier such as {Ownable-onlyOwner}.
*
* ```solidity
* function _authorizeUpgrade(address) internal onlyOwner {}
* ```
*/
function _authorizeUpgrade(address newImplementation) internal virtual;

当需要需要构造函数的功能时(即在合约部署时的一些初始化操作时),就需要导入另一个库:Initializable。注意:在代理合约(Proxy)中不使用构造函数(constructor

因为:状态变量存储在 Proxy 合约中,而不是 implementation (实现合约)中,Proxy 只是借用了 Implementation 中的一些函数,所以,代理合约或者打算通过代理使用的合约,不使用构造函数,满足下面的流程:

Proxy ---> deploy implementation ---> call some "initializable" function

所以,很多可升级合约做的第一步是调用这个已禁用的 initializers 函数:

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
	_disableInitializers();
}

而当我们确实需要使用构造函数时,我们之秀添加一个初始化器:

function initialize() public initializer {
	__Ownable_init(msg.sender); // sets owner
	__UUPSUpgradeable_init();
}

完整的 V1 合约如下:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract BoxV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 internal number;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize() public initializer {
        __Ownable_init(msg.sender); // sets owner
        __UUPSUpgradeable_init();
    }

    function getNumber() external view returns (uint256) {
        return number;
    }

    function version() external pure returns (uint256) {
        return 1;
    }

    function _authorizeUpgrade(address newImplementation) internal override {}
}

V2 合约:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract BoxV2 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 internal number;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize() public initializer {
        __Ownable_init(msg.sender); // sets owner
        __UUPSUpgradeable_init();
    }

    function getNumber() external view returns (uint256) {
        return number;
    }

    function version() external pure returns (uint256) {
        return 2;
    }

    function setNumber(uint256 _number) external {
        number = _number;
    }

    function _authorizeUpgrade(address newImplementation) internal override {}
}

部署 V1 合约

接下来完成部署 V1 合约的脚本。

这里又回到了最开始的点,实现可升级(不改变合约地址),实际上不改变的是 Proxy 合约的地址,也就是说,部署 V1 合约实际上是部署了两个合约:一个是Implementation(Logic)合约,一个是 Proxy(代理合约)。而这里使用的代理合约是 ERC1967 代理合约(还是,从 OpenZeppelin 标准合约库中引用即可)。

实际上部署完 V1 合约后,所有的交互都是和 Proxy 交互,Proxy 通过 Delegatecall 将 Implementation 合约中的逻辑引用过来,所有的状态变量也是存储在 Proxy 合约的 Storage 中。

完整的部署 V1 合约的脚本合约如下:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import {Script} from "forge-std/Script.sol";
import {BoxV1} from "../src/BoxV1.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract DeployBox is Script {
    function run() external returns (address) {
        address proxy = deployBox();
        return proxy;
    }

    function deployBox() public returns (address) {
        vm.startBroadcast();
        BoxV1 box = new BoxV1(); // implementation (Logic)
        // here we will use ERC1967 Proxy Contract
        ERC1967Proxy proxy = new ERC1967Proxy(address(box), "");
        vm.stopBroadcast();
        return address(proxy);
    }
}

将 V1 合约升级到 V2

对合约进行升级时,涉及到这么一个过程:

deploy V2 -> call function upgradeToAndCall(V2address, "") (in Proxy) -> 升级成功。

完整脚本:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import {Script} from "forge-std/Script.sol";
import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol";
import {BoxV2} from "../src/BoxV2.sol";
import {BoxV1} from "../src/BoxV1.sol";

contract UpgradeBox is Script {
    function run() external returns (address) {
        address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment("ERC1967Proxy", block.chainid);

        vm.startBroadcast();
        BoxV2 newBox = new BoxV2();
        vm.stopBroadcast();
        address proxy = upgradeBox(mostRecentlyDeployed, address(newBox));
        return proxy;
    }

    function upgradeBox(address proxyAddress, address newBox) public returns (address) {
        vm.startBroadcast();
        BoxV1 proxy = BoxV1(proxyAddress);
        proxy.upgradeToAndCall(address(newBox), "");
        vm.stopBroadcast();
        return address(proxy);
    }
}

一些关于 UUPS 的测试:

不多赘述,直接上源代码:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import {Test} from "forge-std/Test.sol";
import {DeployBox} from "../script/DeployBox.s.sol";
import {UpgradeBox} from "../script/UpgradeBox.s.sol";
import {BoxV1} from "../src/BoxV1.sol";
import {BoxV2} from "../src/BoxV2.sol";

contract DeployAndUpgradeTest is Test {
    DeployBox public deployer;
    UpgradeBox public upgrader;
    address public OWNER = makeAddr("owner");

    address public proxy;

    function setUp() public {
        deployer = new DeployBox();
        upgrader = new UpgradeBox();
        proxy = deployer.run(); // right now, Proxy point to V1
    }

    function testProxyStartAsBoxV1() public {
        vm.expectRevert();
        BoxV2(proxy).setNumber(7);
    }

    function testUpgrades() public {
        BoxV2 box2 = new BoxV2();

        upgrader.upgradeBox(proxy, address(box2));

        uint256 expectValue = 2;
        assertEq(expectValue, BoxV2(proxy).version());

        BoxV2(proxy).setNumber(7);
        assertEq(7, BoxV2(proxy).getNumber());
    }
}