Openzeppelin可升级模板库合约初始化详解

Posted MateZero

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Openzeppelin可升级模板库合约初始化详解相关的知识,希望对你有一定的参考价值。

openzeppelin可升级模板库中合约初始化详解

我们知道,在openzeppelin提供的可升级模板库中,合约初始化一般会涉及到下面三个元素:initializer,initialize,onlyInitializing 。它们的功能分别为顶级初始化修饰符,约定的初始化函数和内部初始化修饰符。可是你真的了解他们吗?本文就带你认真学习三这个元素。

我们知道,在使用Solidity编写的以太坊智能合约中,代码即法律,意思是代码不能更改了。但是这里的更改是指代码编译后部署的字节码无法更改了,并不是存储内容或者代码执行逻辑绝对无法更改。

例如我们重新设置某个参数,它也可能更改代码的执行逻辑。历为假定这个参数是个外部合约的话,外部合约的地址不同,执行的外部合约逻辑也不相同的。

利用这个特性和Solidity中委托调用delegatcall,在智能合约中可以采用代理/实现模式来实现合约的可升级。在openzeppelin模板库中提供了详尽的不同功能实现的多种实现示例。然而不管每种示例是为了实现什么功能,它本质还是一个合约,是合约就需要初始化,虽然初始化可能什么都不做。本文详细讲述了代理/实现模式时数据初始化的几种方式及相关元素。

1、构造器与initialize

构造器常用于合约部署时初始化合约状态,它只会调用一次。在代理/实现模式中,实现合约的构造器是没有任何用处的,因为代理合约和实现合约是两个不同的合约,代理合约调用的只是实现合约的逻辑,而非采用实现合约的数据。所以那么构造器没有用,那怎么实现初始化呢?

openzeppelin 就约定俗成采用了一个函数叫initialize来进行代理/实现模式中的合约初始化。为什么叫约定俗成呢?因为你完全可以定义一个别的函数来进行初始化,比如叫init,这都是可以的。

因为构造器只能调用一次,因此我们的初始化函数initialize也只有调用一次。有人说这很容易,合约里设置一个初始化状态,例如一个布尔值。初始化时检查该状态,如果没有初始化过,该值为false,初始化时将值设置为true。那么第二次初始化时会因为状态检查通不过而失败。完全正确,的确就是这么简单。但考虑合约的灵活性和兼容性,openzeppelin 把它放在一个叫initializer的修饰符里进行实现,并且专门用来修饰initialize函数,使之只能调用一次,这样就达到了类似构造器只能调用一次的效果。

2、实现合约的两种模式。

在代理/实现模式中,实现合约为一个单独的合约,它有两种不同的应用场景:

  1. 应用在代理/实现中,此时它仅提供逻辑,自身数据不参与任何调用。(但是必须有数据,否则无法编写代码,正如变量x不存在你就无法写一个setX函数那样)。在初始化时,是调用代理合约的initialize函数进行实现的,此时代理合约委托调用了实现合约的initialize函数,将代理合约的数据进行初始化。
  2. 不应用在代理/实现场景中,作为一个独立的实例合约存在。此时,该合约就是一个正常的合约,操作的是自己的数据,只不过将初始化由构造器改为调用自己的initialize函数来实现。

作为开发者,你可以自由的选择这两种模式。但一般为了不混淆,如果采用代理/实现模式,请不要写构造器,虽然写了构造器也没有什么副作用。如果采用单独实例不可升级,尽量不要采用openzeppelin的升极模板库,而是采用普通合约加构造器的方式。openzeppelin分别提供了@openzeppelin/contracts库和@openzeppelin/contracts-upgradeable库来对应不同的场景。

这里有一点要注意:@openzeppelin/contracts-upgradeable中的合约基本上都可以应用于这两种格式中。

3、继承与onlyInitializing

当初始化涉及到继承时,又稍有不同。为了保证父类合约初始化函数只能初始化一次并且只能在初始化的时候调用,openzeppelin使用了一个onlyInitializing修饰符来进行验证。

ps:我们父合约的初始化(内部,非顶级初始化)你也可以使用 initializer,虽然不推荐并且有可能引发一些冲突。但我们学习的目的是为了随心所欲,不逾矩。适当灵活应用也是可行的。

4、initializer修饰符详解

我们先看该修饰符的定义,这里以@openzeppelin/contracts-upgradeable 4.6.7 为例,具体代码在@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol合约源文件中:

/**
* @dev Indicates that the contract has been initialized.
* @custom:oz-retyped-from bool
*/
uint8 private _initialized;

/**
* @dev Indicates that the contract is in the process of being initialized.
*/
bool private _initializing;

/**
 * @dev A modifier that defines a protected initializer function that can be invoked at most once. In its scope,
 * `onlyInitializing` functions can be used to initialize parent contracts. Equivalent to `reinitializer(1)`.
 */
modifier initializer() 
    bool isTopLevelCall = !_initializing;
    require(
      (isTopLevelCall && _initialized < 1) ||
      (!AddressUpgradeable.isContract(address(this)) && _initialized == 1),
       "Initializable: contract is already initialized"
    );
    _initialized = 1;
    if (isTopLevelCall) 
        _initializing = true;
    
    _;
    if (isTopLevelCall) 
        _initializing = false;
        emit Initialized(1);
    

我们首先来看涉及到的相关变量:

  • _initialized,代表合约是否初始化过,注意它是uint8类型,其可能的值为1,因为openzeppelin这里还实现了一个版本升级后重新初始化功能,所以为了记录不同的版本号,采用了uin8类型。
  • _initializing,代表合约是否正在初始化,很显然,它是个布尔类型。

我们来看一个正常的合约使用初始化的代码片断:

function initialize() external initializer 

我们来一步一步查看调用过程 。

当用户调用initialize函数时,它首先执行initializer修饰符。在该修饰符里,具体执行为:

  1. bool isTopLevelCall = !_initializing; 构造了一个临时变量,名字叫是否顶级调用。显然,没有初始化时肯定是顶级调用,只有顶级调用才能调用initialize函数来初始化。
  2. 进行一个require认证。条件1是顶级调用时,必须初始化次数小于1(也就是未初始化过);条件2是初始化次数为1时,必须在构造器中。这里必须在构造器中是哪得来的呢?!AddressUpgradeable.isContract(address(this)) ,这行代码的意思为本地址为非合约,那么本地址在什么情况下为非合约呢?只有在构造器中isContract才会返回false。参考文章:https://despos1to.medium.com/carefully-use-openzeppelins-address-iscontract-msg-sender-4136cc6ff66d 。这两个条件满足一个就行。等一等,不是刚才说代理/实现模式这种初始化不是不调用构造器而是使用initialize函数么?那么这里条件2是什么鬼?这个不要急,代理/实现模式里是实现合约不需要构造器,代理合约是可以有构造器的。并且为了将初始化一次做到极致,可以在代理合约的构造器里调用实现合约initialize函数进行初始化的。这里的条件2就是对应的这种情况。
  3. _initialized = 1很好理解,初始化开始了,将初始化的版本号设置为1,代表开始了。注意设置为1后,require的第一个条件就不会再满足了,也就是接下来的再次调用必须是从构造器中进行了。
  4. 接下来三行也好理解,如果是顶级调用的话,那么将正在初始化设置为true。这里我们可以将顶级调用理解为外部调用,这样可能更容易理解一些。
  5. 接下来一行代码:_;很关键,它的意思是执行initialize函数体,在我们上面的示例里,函数体为空,也就是什么都不做。
  6. 接下来,如果是顶级调用,将正在初始化设置为false,也就是结束了正在初始化状态,并抛出一个事件进行追踪。 那么如果是非顶级(外部)调用就不用结束正在初始化状态吗?答案是就是这样。如果是非顶级调用,那么证明是内部调用。此时最外部的调用并未结束,因此还是在初始化中。所有的内部调用结束后,顶级调用才会结束,此时才能结束正在初始化状态。
  7. 这里的顶级调用和内部调用类似函数调用层级,顶级调用必然是外部发起的,它可以调用多级内部调用。调用其实为函数调用,也就是遵循入栈出栈的一般规律,所以顶级调用是最先调用最后退出的。
  8. 如果我们初始化结束之后第二次调用initialize函数。此时isTopLevelCalltrue,而_initialized1。如果不在构造器里的话(没有人无聊到在构造器里多次相同初始化)。require的两个条件均不会满足,因此会抛出异常,并给出Initializable: contract is already initialized错误。所以我们只能初始化一次(如果一个交易算一次的话)。

5、onlyInitializing修饰符详解

看完了initializer我们再看onlyInitializing,代码片断仍然在该文件中,如下:

/**
 * @dev Modifier to protect an initialization function so that it can only be invoked by functions with the
 * initializer and reinitializer modifiers, directly or indirectly.
 */
modifier onlyInitializing() 
    require(_initializing, "Initializable: contract is not initializing");
    _;

这里很简单,注释中提到,用来保护某个初始化函数只能被initializer或者reinitializer修饰符修饰的函数调用一次。代码也很简单,只有一个判断条件,

_initializingtrue,也就是正在初始化中。这个在上面可以看到,只有在initializer修饰符中,_initializing才可能设置为true。并且顶级调用退出后,该值重置为false,也就是无法再次初始化了。

ps:这里提到了reinitializer,通过在相同的源文件中查询它的注释可以看到,它用来版本升级后重新初始化。因为我们initializer初始化时版本号为1,所以它可以升级版本号并重新初始化。 注意它是一个修饰符而非函数,所以正常情况下是用不到它的。只有在极特殊的情况下才会用到,我们这里暂时先略过它。我们只考虑初始化一次的情况。

看到这里我们都已经明白了,通常最外部初始化调用使用initializer修饰符,在内部的初始化(比如父合约的初始化)一般使用onlyInitializing修饰符,保证内部的初始化只在initializer作用域下调用一次。是不是So Easy!

但是事情往往比想象的要复杂,因为有以下几种情况:

  1. 内部初始化你也可以使用initializer修饰符(虽然不推荐),
  2. 并且我们有时也需要在非构造器中进行初始化,例如代理合约部署时还未决定好初始化参数等。
  3. 有时我们不采用代理/实现模式,而作为一个单独的实例合约初始化。

这三种模式相互组合,可以得到 至少6种场景,让我们测试其中几种场景。

6、一个示例合约

我们来看一个简单的示例合约,通过该合约及单元测试我们来演示上面提到的6种组合。

我们使用hardhat来做单元测试。

pragma solidity ^0.8.0;

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

contract CustomProxy is TransparentUpgradeableProxy 
    constructor(address _logic,address admin_, bytes memory data) TransparentUpgradeableProxy(_logic,admin_,data) 
        // 这里父构造器最后传入的data参数其实相当于执行了如下代码:
        // _logic.delegatecall(data);
    


contract A is Initializable 
    uint public x;
    uint public y;
    // 不推荐,initializer限定条件较多,如果用于内部初始化,
    // 可能存在多个 initializer 同时起作用而引发冲突,此时必须在代理合约的构造器中执行初始化
    function __init_X() internal initializer 
        x = 5;
    
		
	// 推荐,可兼容多种情况,例如不采用代理/实现模式和不在构造器中初始化
    function __init_Y() internal onlyInitializing 
        y = 10;
    


contract B is A 
    uint public z;
    function initialize(uint _z) external initializer 
        __init_X();
        __init_Y();
        z = _z;
    
    // 演示 onlyInitializing 只能由 initializer修饰的函数调用
    function failedCall() external 
        __init_Y();
    
    // 演示一种不好用法,可以在外部任意函数中调用内部 initializer 修饰的函数
    // 这样容易混淆出错
    function initCall() external 
        __init_X();
    


// 推荐做法,这样即可以从构造器中初始化,又可以从构造器外初始化,还可以单独作为一个实例。
contract C is A 
    uint public z;
    function initialize(uint _z) external initializer 
        __init_Y();
        z = _z;
    

合约代码很简单,CustomProxy为代理合约,它继承了TransparentUpgradeableProxy合约但是没有增加任何代码,原因是我们只想方便的使用TransparentUpgradeableProxy合约,否则hardhat不编译它。

合约 A 定义了两个初始化函数,不同的是一个使用了initializer作为修饰符一个使用了onlyInitializing作修饰符。我们推荐使用onlyInitializing,原因最后讲。

合约B继承了合约A,定义了一个正常的initialize初始化函数和几个测试函数。

合约C继承了合约A,只定义了一个正常的initialize初始化函数。

注意:一般合约只会有一个内部初始化函数,我们为了演示,所以在合约A中定义了两个初始化函数,__init_X__init_Y

7、单元测试

详细的单元测试如下,大家注意看注释。

const  expect  = require("chai");
const  ethers  = require("hardhat");

describe("Proxy/Impl init test", function () 
  let impl;
  let proxy;
  let owner,user1,user2,users;

  beforeEach(async () => 
    [owner, user1, user2,...users] = await ethers.getSigners();
    const B = await ethers.getContractFactory("B");
    impl = await B.deploy();
  );

  describe("initializer in internal call test", () => 
    // 在构造器中调用带有initializer的内部初始化函数可以成功
    it("Should be successful while call initialize  in constructor while it revoke an inner function with initializer", async () => 
        let CustomProxy = await ethers.getContractFactory("TransparentUpgradeableProxy");
        let data = impl.interface.encodeFunctionData("initialize",[100]);
        proxy = await CustomProxy.deploy(impl.address,user1.address,data);
        await proxy.deployed();
        proxy = impl.attach(proxy.address);
        //check state
        expect(await proxy.x()).to.be.equal(5);
        expect(await proxy.y()).to.be.equal(10);
        expect(await proxy.z()).to.be.equal(100);
    );

    // 在构造器外进行初始化时,如果父类合约初始化包含有 initializer,则会冲突失败。
    it("should be failed while call initialize out constructor while it revoke an inner function with initializer", async () => 
        let CustomProxy = await ethers.getContractFactory("TransparentUpgradeableProxy");
        proxy = await CustomProxy.deploy(impl.address,user1.address,"0x");
        await proxy.deployed();
        proxy = impl.attach(proxy.address);
        // 未初始化
        expect(await proxy.x()).to.be.equal(0);
        // 这里失败的原因是 __init_X 也使用了initializer, 这里会重复开启两次初始化状态而又不在构造器中,所以失败。
        await expect(proxy.initialize(100)).to.be.revertedWith("Initializable: contract is already initialized")
    );

    // 在不采用代理/实现模式的情况下,如果内部初始化也包含 initializer ,那么外部initialize 会失败,原因同上
    it("should be failed", async () => 
        await expect(impl.initialize(100)).to.be.revertedWith("Initializable: contract is already initialized");
    );

    // 正常函数里调用 onlyInitializing 修饰的内部函数会失败,因为不在初始化过程中。
    it("should be failed while call an inner function with onlyInitializing", async () => 
        let CustomProxy = await ethers.getContractFactory("TransparentUpgradeableProxy");
        proxy = await CustomProxy.deploy(impl.address,user1.address,"0x");
        await proxy.deployed();
        proxy = impl.attach(proxy.address);
        await expect(proxy.failedCall()).to.be.revertedWith("Initializable: contract is not initializing");
    );

    // 正常函数调用 initializer 的内部函数。
    it("should be successful while call a inner function with initializer", async () => 
        let CustomProxy = await ethers.getContractFactory("TransparentUpgradeableProxy");
        proxy = await CustomProxy.deploy(impl.address,user1.address,"0x");
        await proxy.deployed();
        proxy = impl.attach(proxy.address);
        await proxy.initCall();
        //check state
        expect(await proxy.x()).to.be.equal(5);
        // still zero
        expect(await proxy.y()).to.be.equal(0);
        expect(await proxy.z()).to.be.equal(0);
    );

    // 从 initialize 函数中调用内部  onlyInitializing 函数,无论初始化是否发生在构造器中,均可以成功
    it("should be successful ",async () => 
        let C = await ethers.getContractFactory("C");
        let c = await C.deploy();
        let CustomProxy = await ethers.getContractFactory("CustomProxy");
        proxy = await CustomProxy.deploy(c.address,user1.address,"0x");
        await proxy.deployed();
        proxy = c.attach(proxy.address);

        // call initialize
        await proxy.initialize(100);
        //check state
        expect(await proxy.x()).to.be.equal(0);
        expect(await proxy.y()).to.be.equal(10);
        expect(await proxy.z()).to.be.equal(100);
    );

    // 如果不采用代理/升级模式,作为单独的合约,
    it("should be successful while implement as a instance", async () => 
        let C = await ethers.getContractFactory("C");
        let c = await C.deploy();

        await c.initialize(100);
        //check state
        expect(await proxy.x()).to.be.equal(0);
        expect(await proxy.y()).to.be.equal(10);
        expect(await proxy.z()).to.be.equal(100);
    );
  );
);

8、@openzeppelin/hardhat-upgrades 插件

我们从上面的单元测试可以看到,代理/实现模式的一般步骤就是:

  1. 部署实现合约
  2. 部署代理合约
  3. 调用初始化函数

因为这个步骤几乎是固定的,所以有一个hardhat-upgrades库来帮我们这件事。它把这三个步骤打包成一步了,我们只需要提供初始化参数即可。

我们可以简单示例一下:

  1. 安装 @openzeppelin/hardhat-upgrades 这里使用npm安装就不多说了
  2. hardhat.config.js配置文件中在顶部添加这么一行:require('@openzeppelin/hardhat-upgrades');
  3. 编写单元测试文件,内容如下:
const  expect  = require("chai");
const  ethers,upgrades  = require("hardhat");

describe("hardhat upgrades test", function () 
    it("can depoly proxy/impl by hardhat upgrades module", async () => 
        const B = await ethers.getContractFactory("B");
        let proxy = await upgrades.deployProxy(B,[100]);
        await proxy.deployed();
        proxy = B.attach(proxy.address);
        //check state
        expect(await proxy.x()).to.be.equal(5);
        expect(await proxy.y()).to.be.equal(10);
        expect(await proxy.z()).to.be.equal(100);
    );
);

可以看到,我们只需要一个deployProxy操作就完成了上面三个步骤,其实还有个设置管理员的步骤,这里没有讲。

以上是关于Openzeppelin可升级模板库合约初始化详解的主要内容,如果未能解决你的问题,请参考以下文章

Openzeppelin可升级模板库合约初始化详解

区块链入门教程openzeppelin库详解

openzeppelin-solidity

[Contract] openzeppelin/cli 开发, 部署, 升级智能合约

第116篇 部署可代理升级的智能合约

RemixIDE连接本地并导入OpenZeppelin合约库