Great article on initialization methods used by upgradeable smart contracts.

Great article on initialization methods used by upgradeable smart contracts.
mudgen
0
0

Upgradeable Smart Contracts and Initialization

My previous article highlighted a new initialization process used by EIP-2535: Diamonds as “A better way to initialize smart contracts.” This article will cover upgradeable smart contracts in more detail to defend my claim and provide insights. After reviewing upgradeability more generally, the following focus points will be discussed:

OpenZeppelin upgradeable initialization Common initialization bugs and security concerns Comparison to the EIP-2535 initialization method

Upgradeability

The community sentiment toward smart contract upgradeability is divided. Those who do not support upgradability argue that it compromises the immutability of the blockchain. On the other hand, those who support upgradeability argue it helps create a safer environment by allowing bugs to be fixed and exploits prevented or contained.

Smart contracts are immutable, meaning the code can’t be changed during deployment, which provides benefits and challenges. The trustlessness of the blockchain relies on immutability, yet exploitable bugs discovered on a smart contract can not be repaired without upgradeability. It is essential to understand upgradeability doesn’t change the immutability of smart contracts; Instead, it introduces a new smart contract to replace the previous one. There are a variety of different upgradeable strategies, varying in levels of transparency and complexity —

Social Convention

The social convention approach attempts to “upgrade “through community convention. Developers introduce a new smart contract and urge/motivate/incentivize the community to use it as a replacement. This is the most transparent approach since it relies on users’ willingness to change.

Parameterization

The parameterization approach does not necessarily introduce “upgrades” but allows behavior to be modified. Parameterized variables on the smart contract accept injected values and change the system’s behavior accordingly. This approach is less transparent than the previous one since unique values can be injected but still transparent since the implications are clear and immutable.

Proxy

The proxy approach uses a proxy contract to manage state variables while pointing to an implementation contract to provide the logic. This approach allows upgrades to occur without compromising the underlying data. This approach is the least transparent of the group since users could be unaware of underlying logic changes. These, however, can be identified upon inspection. The proxy method is the standard and will be focused on for this article.

The Proxy Approach

The proxy approach is the most common approach for structuring upgradeable contracts. There are three main styles for architecting a proxy system —

Transparent Transparent proxies manage access control and upgradability in the proxy. Admins and users are restricted to logic that exists in their scope of access. For example, admins can only call admin functions and vice versa for users. Universal Upgradeable Proxy Standard (UUPS) UUPS removes the admin and upgrade interface from the proxy. As a result, the proxy is not upgradeable by itself; it depends on the implementation contract to include the logic necessary to perform an upgrade. Diamonds Diamonds add a layer of granularity by including a mapping that routes function requests to the respective implementation contract containing the logic. Logic, including the upgrade interface, is separated into implementation contracts (facets) with the individual contract addresses contained in the mapping. Transparent and UUPS Proxies

Transparent proxies were the original proxy style supported by OpenZeppelin; however, OpenZeppelin now recommends using UUPS proxies which are just as versatile as transparent proxies but cheaper to deploy since the upgrade logic is not in the proxy. UUPS proxies will be compared to Diamonds, but both transparent and UUPS proxies share the same interface for upgrades.

Upgrades OpenZeppelin UUPS Upgrades

UUPS proxies are implemented using an ERC1967Proxy. As noted earlier, the proxy contract is not responsible for the upgrade logic; upgrade logic resides in the implementation contract. Therefore, the proxy is not upgradeable until the upgrade logic is added by connecting an implementation contract. To create an upgradeable deployment, the function deployProxy from the OpenZeppelin upgrades plugin can be used.

await upgrades.deployProxy(MyTokenV1);

This function executes a series of actions in the following order —

Check implementation contract for unsafe patterns. If the contract passes the check, deploy the implementation contract Deploy proxy contract connected to the implementation contract.

The result is a proxy contract and an implementation contract that can be upgraded using upgrades.upgradeProxy.

Initialization

Upgradeable contracts do not use the constructor to initialize the contract state. Constructors are made to be executed once on the deployment of contracts and are not included in the deployed contract bytecode. Implementation contracts do not contain the state variables that need to be initialized; state variables reside on the proxy. A constructor executed on the implementation contract at deployment would not be able to initialize state variables in the proper context of the proxy. This means that when using a contract with the OpenZeppelin Upgrades, the constructor must be changed to a regular function, typically named initialize(), where the setup logic will be handled.

While Solidity ensures a constructor can only be called once; Solidity does not limit the number of calls that can be made to a regular function. To prevent an upgradeable contract from being initialized multiple times, the initialize() function must include a check that allows the function only to be called once:

contract MyContract { uint256 public x; bool private initialized; function initialize(uint256 _x) public { require(!initialized, "Contract instance has already been initialized"); initialized = true; x = _x; }}

OpenZeppelin provides an Initializable base contract with an initializer modifier that can be used for this:

contract MyContract is Initializable { uint256 public x; function initialize(uint256 _x) public initializer { x = _x; }}

The implementation contract is deployed then the proxy is connected to it. Once completed, the initialize function must be called from the proxy to initialize the state variables.

Diamond Upgrades — code

EIP-2535 Diamond standard introduces a generic proxy contract, the Diamond, which includes an internal diamondCut() function and exposes a fallback function, which dynamically dispatches function calls to the facets called from the Diamond.

contract Diamond { constructor(IDiamondCut.FacetCut[] memory _diamondCut, DiamondArgs memory _args) payable { LibDiamond.setContractOwner(_args.owner); LibDiamond.diamondCut(_diamondCut, _args.init, _args.initCalldata); // Code can be added here to perform actions and set state variables. } // Find facet for function that is called and execute the // function if a facet is found and return any value. fallback() external payable { LibDiamond.DiamondStorage storage ds; bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION; // get diamond storage assembly { ds.slot := position } // get facet from function selector address facet = ds.facetAddressAndSelectorPosition[msg.sig].facetAddress; if(facet == address(0)) { revert FunctionNotFound(msg.sig); } // Execute external function from facet using delegatecall and return any value. assembly { // copy function selector and any arguments calldatacopy(0, 0, calldatasize()) // execute function call using the facet let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0) // get any return value returndatacopy(0, 0, returndatasize()) // return any return value or error back to the caller switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } receive() external payable {}}

The diamondCut() function can execute an external function using delegatecall during an upgrade. delegatecall is a low-level function that routes function calls from a proxy to an implementation contract. delegatecall executes external functions in the context of the proxy, making it appear as if the functionality exists on the proxy while it actually resides in the implementation. This external function call is used to initialize state variables on the proxy and make necessary changes during an upgrade.

function diamondCut( FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata )external;

The _diamondCut param contains a mapping array of function selectors and implementation (facet) contract addresses. The second and third arguments of the diamondCut() function are used for initializing the state after an upgrade.

Initialization

The _init argument holds the contract address of a function call to initialize the state of the Diamond. The _calldata argument has a function call to send to the contract at _init. After adding/replacing/removing functions, the _calldata argument is executed with delegatecallon _init.

const diamondCut = await ethers.getContractAt('IDiamondCut', diamond.address) let tx let receipt // call to init function let functionCall = diamondInit.interface.encodeFunctionData('init') tx = await diamondCut.diamondCut(cut, diamondInit.address, functionCall) receipt = await tx.wait()

Diamonds use a separate initialization contract, DiamondInit, to provide a function call after a Diamond is upgraded for initializing state variables. DiamondInit functions can have parameters and can be reused as needed once deployed. During the execution of an upgrade, using the _initand _calldataarguments allow state initialization to occur in the same transaction.

Workflow Comparison The UUPS initialization workflow:

Replace the constructor on the implementation contract with the initialize() function. Check the implementation contract for any unsafe patterns. Deploy the implementation contract. Deploy the proxy contract Connect the proxy to the implementation contract. Execute the initialize() function (separate transaction). Proxy state variables are updated. The UUPS deployment is complete.

The Diamond initialization workflow:

Deploy the initialization contract, DiamondInit. Deploy the implementation contracts. Deploy the Diamond. Execute diamondCut() function. Mapping is upgraded by adding function selectors and facet addresses. The _calldata argument is executed with delegatecall on DiamondInit (same transaction) Diamond state variables are initialized. The Diamond deployment is complete.

Key Differences

Both workflows are similar, but the key differences include the following:

UUPS

delegatecall routes the initialize() call to the implementation contract to initialize the state. initialize() can only be called once and must be included in the implementation contract. Upgrades occur in a separate transaction from initialize() function call. Function selector clashes are avoided by removing upgrade logic from the proxy

Diamond

delegatecall routes the _calldata argument to the DiamondInit contract to initialize the state. DiamondInit functions can have parameters and can be reused as needed once deployed. Upgrades and initialization occur in the same transaction. The implementation of diamondCut() prevents function selector clashes.

Bugs and Security Considerations

While there are similarities between UUPS and Diamond proxies, the differences introduce notable implications.

Constructors

UUPS replaces the constructor with an initialize() function, which requires additional code since regular functions are not designed only to execute once. Regular functions are included in bytecode and are not intended to limit the number of calls being made to them innately.

Risk: logic allowing initialize() function to only be called once must be included to prevent multiple initialization calls from being made. Initializing

Solidity automatically invokes the constructors of all ancestors of a contract; when using a UUPS proxy setup, this must be handled manually for the initializers of parent contracts.

Risk: An attacker can take over an uninitialized implementation contract, which may impact the proxy. Mitigation: To prevent the implementation contract from being used, invoke the _disableInitializers function in the constructor to lock it when it is deployed automatically.

Storage Clash

If the order of arguments, representing the state variables in the proxy, is changed in the implementation contract initialize() function, a storage clash will occur.

Risk: Unsafe upgrade pattern can result in a storage clash that incorrectly overwrites variables on the proxy. Self destruct function Do not use selfdestruct or delegatecall in your contracts.

An implementation contract that triggers a selfdestruct operation will destroy the implementation contract.

Risk: all contract instances will end up delegating to an address without any code, and the proxy can not be upgraded since the logic existed on the implementation contract.

A proxy contract that executes delegatecall with an implementation contract selfdestruct function would be destroyed.

Risk: proxy contract, including all state data, is destroyed.

Conclusion

The Diamond upgrade helps avoid risks introduced by the UUPS upgrade approach.

Diamond Cut allows an external function call to be made after an upgrade.

Benefit: An external function is used to call a separate contract with initialization logic allowing initialization logic to be abstracted from the proxy and implementation contracts.

Diamond Cut wraps upgrade and initialization execution into the same transaction.

Benefit: Add and remove state variables as needed without leaving new state variables uninitialized or exposed.

Diamond initialization logic is separate from the implementation and proxy contract.

Benefit: Initialization logic is not exposed to malicious actors on proxy or implementation contracts.

Diamond initialization contract can be parameterized and reused after deployment.

Benefit: Deployed initialization contract reusability helps avoid mistakes and user errors on upgrades and initialization.

DiamondMultiInit allows multiple initialization functions in the diamondCut() function call.

Benefit: Execute upgrade with all required state variables initialized in the same transaction.