Detailed explanation of contract initialization in openzeppelin upgradeable template library
We know that in the upgradeable template library provided by openzeppelin, contract initialization generally involves the following three elements: initializer,initialize,onlyInitializing. Their functions are top-level initialization modifiers, agreed initialization functions, and internal initialization modifiers. But do you really know them? This article will take you to seriously study the three elements.
We know that in an Ethereum smart contract written in Solidity, the code is the law, which means that the code cannot be changed. However, the change here means that the bytecode deployed after the code is compiled cannot be changed, not the storage content or the code execution logic can never be changed.
For example, if we reset a parameter, it may also change the execution logic of the code. Assuming that this parameter is an external contract, the address of the external contract is different, and the logic of the external contract executed is also different.
Using this feature and the delegated call in Solidity, the proxy/implementation model can be used in smart contracts to achieve contract upgrades. Exhaustive examples of various implementations of different functionalities are provided in the openzeppelin template library. However, no matter what function each example is for, it is essentially a contract, and the contract needs to be initialized, although initialization may not do anything. This article describes in detail several ways and related elements of data initialization in the proxy/implementation pattern.
1. Constructor and initialize
The constructor is often used to initialize the contract state when the contract is deployed, and it will only be called once. In the proxy/implementation model, the constructor that implements the contract is useless, because the proxy contract and the implementation contract are two different contracts, and the proxy contract only calls the logic of the implementation contract, not the data of the implementation contract. So then the constructor is useless, so how to implement initialization?
By convention, openzeppelin uses a function called initialize to initialize contracts in the proxy/implementation mode. Why is it called a convention? Because you can define another function to initialize, such as init, which is all possible.
Because the constructor can only be called once, so our initialization function initialize can only be called once. Some say it's easy, set an initialization state in the contract, such as a boolean value. Check the state when initializing, if it has not been initialized, the value is false, and the value is set to true when initializing. Then the second initialization will fail because the state check fails. Exactly, it's really that simple. However, considering the flexibility and compatibility of the contract, openzeppelin implements it in a modifier called initializer, and it is specially used to modify the initialize function so that it can only be called once, so that similar constructors can only be called one time effect.
2. Two modes of implementing contracts.
In the proxy/implementation pattern, the implementation contract is a separate contract, which has two different application scenarios:
- Applied in the proxy/implementation, it only provides logic at this time, and its own data does not participate in any calls. (But there must be data, otherwise you can't write code, just as you can't write a setX function without the variable x). During initialization, it is implemented by calling the initialize function of the proxy contract. At this time, the proxy contract delegate calls the initialize function of the implementation contract to initialize the data of the proxy contract.
- Not used in proxy/implementation scenarios, it exists as a separate instance contract. At this point, the contract is a normal contract that operates its own data, but the initialization is changed from the constructor to calling its own initialize function.
As a developer, you are free to choose between these two modes. But generally in order not to be confused, if you use the proxy/implementation pattern, please don't write a constructor, although there is no side effect if you write a constructor. If a single instance is not upgradeable, try not to use openzeppelin's upgrade template library, but to use ordinary contracts and constructors. openzeppelin provides @openzeppelin/contracts library and @openzeppelin/contracts-upgradeable library respectively to correspond to different scenarios.
One thing to note here: contracts in @openzeppelin/contracts-upgradeable are basically applicable to both formats.
3. Inheritance and onlyInitializing
When initialization involves inheritance, it is slightly different. In order to ensure that the parent class contract initialization function can only be initialized once and can only be called during initialization, openzeppelin uses an onlyInitializing modifier for verification.
ps: The initialization of our parent contract (internal, non-top-level initialization) you can also use the initializer, although it is not recommended and may cause some conflicts. But the purpose of our study is to do whatever we want without breaking the rules. Appropriately flexible applications are also possible.
4. Detailed explanation of initializer modifier
Let's look at the definition of this modifier first. Here is @openzeppelin/contracts-upgradeable 4.6.7 as an example. The specific code is in the @openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol contract source file:
/** * @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); } }
Let's first look at the relevant variables involved:
- _initialized represents whether the contract has been initialized. Note that it is of uint8 type, and its possible value is 1. Because openzeppelin also implements a reinitialization function after version upgrade, in order to record different version numbers, the uin8 type is used.
- _initializing, represents whether the contract is being initialized, obviously, it is a boolean type.
Let's take a look at the code snippet for a normal contract usage initialization:
function initialize() external initializer {}
Let's look at the calling process step by step.
When the user calls the initialize function, it first executes the initializer modifier. In this modifier, the specific execution is:
- bool isTopLevelCall = !_initializing; Constructs a temporary variable named whether the top-level call. Obviously, when there is no initialization, it must be a top-level call, and only a top-level call can call the initialize function to initialize.
- Do a require authentication. Condition 1 is that when the top-level call is made, the number of initializations must be less than 1 (that is, it has not been initialized); Condition 2 is that when the number of initializations is 1, it must be in the constructor. Where does this have to be in the constructor? !AddressUpgradeable.isContract(address(this)) , this line of code means that this address is a non-contract, then under what circumstances is this address a non-contract? Only in the constructor isContract will return false. Reference article: https://despos1to.medium.com/carefully-use-openzeppelins-address-iscontract-msg-sender-4136cc6ff66d. One of these two conditions is satisfied. Wait a minute, didn't I just say that the initialization of the proxy/implementation pattern does not call the constructor but uses the initialize function? So what the hell is condition 2 here? Don't worry about this, in the proxy/implementation mode, a constructor is not required to implement a contract, and a proxy contract can have a constructor. And in order to maximize the initialization at one time, the initialize function of the implementation contract can be called in the constructor of the proxy contract for initialization. Condition 2 here corresponds to this situation.
- _initialized = 1 is easy to understand, the initialization has started, and the initialized version number is set to 1, which means it has started. Note that after setting it to 1, the first condition of require will no longer be satisfied, that is, the next call must be made from the constructor.
- The next three lines are also easy to understand. If it is a top-level call, then initializing is set to true. Here we can understand the top-level call as an external call, which may be easier to understand.
- The next line of code: _; is critical, it means to execute the initialize function body, in our example above, the function body is empty, that is, do nothing.
- Next, if it is a top-level call, set the initializing to false, which is the end of the initializing state, and throw an event to track. So if it's a non-top-level (outer) call doesn't it have to end the initializing state? The answer is that. If it's a non-top-level call, then it turns out to be an internal call. At this time, the outermost call is not over, so it is still in the initialization. The top-level call ends after all internal calls are finished, and the initializing state ends at this time.
- The top-level call and the internal call here are similar to the function call level. The top-level call must be initiated externally, and it can call multiple levels of internal calls. The call is actually a function call, that is, it follows the general rule of pushing and popping the stack, so the top-level call is the first call and the last exit.
- If we call the initialize function a second time after the initialization is over. At this point isTopLevelCall is true, and _initialized is 1. If not in the constructor (no one gets bored with doing the same initialization multiple times in the constructor). Both conditions of require will not be met, so an exception is thrown and an Initializable: contract is already initialized error is given. So we can only initialize it once (if a transaction counts once).
5. Detailed explanation of onlyInitializing modifier
After reading the initializer, let's look at onlyInitializing, the code snippet is still in the file, as follows:
/** * @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"); _; }
It's very simple here. As mentioned in the comments, the function used to protect an initialization function can only be called once by the function modified by the initializer or reinitializer modifier. The code is also very simple, there is only one judgment condition,
_initializing is true, that is, it is being initialized. This can be seen above, only in the initializer modifier, _initializing may be set to true. And after the top-level call exits, the value is reset to false, that is, it cannot be initialized again.
ps: The reinitializer is mentioned here. You can see by querying its comments in the same source file that it is used to reinitialize after a version upgrade. Because our initializer is initialized with a version number of 1, it can upgrade the version number and reinitialize. Note that it is a modifier not a function, so it is not normally used. It is only used in very special cases, and we will ignore it for now. We only consider the case of initialization once.
Seeing this, we have already understood that the outermost initialization call usually uses the initializer modifier, and the internal initialization (such as the initialization of the parent contract) generally uses the onlyInitializing modifier to ensure that the internal initialization is only called once under the scope of the initializer. Is it So Easy!
But things are often more complicated than imagined because of the following situations:
- Internal initialization you can also use the initializer modifier (though not recommended),
- And we sometimes need to initialize in a non-constructor, for example, the initialization parameters have not been determined when the proxy contract is deployed.
- Sometimes we do not adopt the proxy/implementation pattern, but initialize as a single instance contract.
These three modes are combined with each other to get at least 6 scenarios, let's test a few of them.
6. An example contract
Let's take a look at a simple example contract, through which we can demonstrate the 6 combinations mentioned above with unit tests.
We use hardhat for unit testing.
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) { // Here, the last data parameter passed in by the parent constructor is actually equivalent to executing the following code: // _logic.delegatecall(data); } } contract A is Initializable { uint public x; uint public y; // Not recommended, the initializer has many restrictions. If it is used for internal initialization, // There may be multiple initializer s that work at the same time and cause conflicts. In this case, initialization must be performed in the constructor of the proxy contract function __init_X() internal initializer { x = 5; } // Recommended, compatible with many situations, such as not using the proxy/implementation pattern and not initializing in the constructor 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; } // Demonstrates that onlyInitializing can only be called by functions decorated with initializer function failedCall() external { __init_Y(); } // Demonstrate a bad usage, you can call the function decorated with the inner initializer in any outer function // It's easy to get confused function initCall() external { __init_X(); } } // Recommended practice, so that it can be initialized both from within the constructor, from outside the constructor, and as an instance by itself. contract C is A { uint public z; function initialize(uint _z) external initializer { __init_Y(); z = _z; } }
The contract code is very simple. CustomProxy is a proxy contract. It inherits the TransparentUpgradeableProxy contract but does not add any code. The reason is that we just want to use the TransparentUpgradeableProxy contract conveniently, otherwise hardhat will not compile it.
Contract A defines two initialization functions, the difference is that one uses initializer as a modifier and the other uses onlyInitializing as a modifier. We recommend using onlyInitializing for the reasons mentioned at the end.
Contract B inherits contract A and defines a normal initialize initialization function and several test functions.
Contract C inherits contract A and only defines a normal initialize initialization function.
Note: A general contract only has one internal initialization function. For demonstration, we define two initialization functions in contract A, __init_X and __init_Y.
7. Unit testing
The detailed unit test is as follows, please pay attention to the comments.
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", () => { // Calling the inner initialization function with initializer in the constructor can succeed 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); }); // When initializing outside the constructor, if the parent class contract initialization contains an initializer, the conflict will fail. 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); // Uninitialized expect(await proxy.x()).to.be.equal(0); // The reason for the failure here is that __init_X also uses the initializer, which will repeatedly open the initialization state twice without being in the constructor, so it fails. await expect(proxy.initialize(100)).to.be.revertedWith("Initializable: contract is already initialized") }); // Without the proxy/implementation pattern, if the inner initialization also includes an initializer, the outer initialize will fail for the same reason as above it("should be failed", async () => { await expect(impl.initialize(100)).to.be.revertedWith("Initializable: contract is already initialized"); }); // Calling the onlyInitializing decorated inner function in a normal function will fail because it is not in the initialization process. 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"); }); // The normal function calls the inner function of 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); }); // Calling the inner onlyInitializing function from the initialize function will succeed regardless of whether the initialization happens in the constructor or not 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); }); // If not in proxy/upgrade mode, as a separate contract, 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 plugin
As we can see from the unit tests above, the general steps for the proxy/implementation pattern are:
- Deploy the implementation contract
- Deploy the proxy contract
- call the initialization function
Since this step is pretty much fixed, there is a hardhat-upgrades library to help us with this. It packages these three steps into one step, we only need to provide initialization parameters.
We can give a simple example:
- Install @openzeppelin/hardhat-upgrades Not much to say here using npm installation
- Add this line at the top of the hardhat.config.js configuration file: require('@openzeppelin/hardhat-upgrades');.
- Write a unit test file with the following content:
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); }); });
It can be seen that we only need one deployProxy operation to complete the above three steps. In fact, there is also a step to set the administrator, which is not mentioned here.