moon

moon

Build for builders on blockchain
github
twitter

Writing Scalable Smart Contracts

Once deployed, smart contracts are immutable. Therefore, if your contract code has a bug or you want to add new features, you cannot modify it; instead, you can only:

  • Redeploy a new contract
  • Manually migrate data from the old contract to the new contract
  • Update all contracts interacting with the old contract, including using the new contract's address, etc.
  • Notify and persuade the community to use the new contract address

Since upgrading contracts is so cumbersome, a unified solution is needed. This allows us to change the contract code while retaining the data.

Proxy Pattern#

The proxy pattern separates contract data and logic, storing them in different contracts:

  • Proxy Contract: The smart contract that interacts with users and holds the data. It is an EIP1967 standard proxy contract.
  • Implementation Contract: The contract that the proxy contract delegates to, providing functionality and logic.

The proxy contract stores the address of the implementation contract as a state variable. Users do not directly call the implementation contract. Instead, all calls go through the proxy contract, which delegates the call to the implementation contract and returns any data received from the implementation contract back to the caller, or reverts on errors.

proxy-upgrade.png

The proxy contract calls the implementation contract using the delegatecall function. Based on the characteristics of delegatecall, the state variables of the implementation contract are stored by the proxy contract.

As shown below:

contract Robo {
  uint public count;

  function setCount(uint _count) public {
    count = _count;
  }
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {
    // Proxy method: delegatecall executes the implementation contract method
  }

  function _implementation() internal view virtual returns (address) {
    // Returns the address of the implementation contract Robo
  }

  fallback() external {
    _delegate(_implementation());
  }
}

When the setCount method of the Robo contract needs to be called, we do not interact with that contract directly in the proxy pattern; instead, we call through the proxy contract. That is, we call the setCount method of RoboProxy, but RoboProxy does not have a setCount method. Instead, it will execute its fallback function (which is triggered when a non-existent contract method is called). The fallback function will internally execute the _delegate method.

The _delegate method is the actual proxy method, which will execute the setCount method of the Robo contract via delegatecall.

Based on the characteristics of delegatecall, even if the Robo contract defines a variable count, its value will not be stored by the Robo contract. Instead, the RoboProxy contract stores that value.

At this point, you might be confused about how the RoboProxy stores the variable count since it does not define it. This actually involves the storage layout of the EVM.

In fact, the values of state variables for every smart contract running in the EVM are permanently stored on-chain, and these values are stored in storage slots. Each contract has 22562^{256} storage slots, each of which is 32 bytes in size. However, not all of these large storage slots exist; they only occupy space when used.

----------------------------------
|               0                |     # slot 0
----------------------------------
|               1                |     # slot 1
----------------------------------
|               2                |     # slot 2
----------------------------------
|              ...               |     # ...
----------------------------------
|              ...               |     # Each slot is 32 bytes
----------------------------------
|              ...               |     # ...
----------------------------------
|            2^256-1             |     # slot 2^256-1

So when executing the proxy method, the value of the count variable will be stored in the storage slot of the proxy contract.

Due to this special storage structure, potential storage collision issues can easily arise.

Storage Collision#

Storage Collision Between Proxy and Implementation Contracts#

|Proxy                   |Implementation |
|------------------------|---------------|
|address implementation  |address var1   | <- Storage Collision
|                        |mapping var2   |
|                        |uint256 var3   |
|                        |...            |

Since both var1 and implementation are the first variables of the contract, it means their values are stored in the first storage slot slot 0. Moreover, having the same type means they occupy the same storage space. When assigning a value to var1 through the proxy method delegatecall, it will actually write the value into the storage space of the proxy contract, precisely where the implementation variable occupies. Thus, the value of the implementation variable becomes the value set for var1.

To solve the storage collision issue between the proxy contract and the implementation contract, random storage slots are used, meaning a random storage slot is selected from the vast storage space to store the implementation variable.

|Proxy                   |Implementation |
|------------------------|---------------|
|    ..                  |address var1   |
|    ..                  |mapping var2   |
|    ..                  |uint256 var3   |
|    ..                  |    ..         |
|    ..                  |    ..         |
|address implementation  |    ..         | <- Random Slot

According to EIP-1967, the storage slot position for storing the implementation contract address is:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));
// Value is 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Every time the implementation address needs to be accessed or modified, this storage slot will be read and written.

Storage Collision Between Different Versions of Implementation Contracts#

When upgrading to a new implementation contract, if a new state variable is added to the implementation contract, it should be appended to the storage layout. It should not be arbitrarily inserted into the original storage layout.

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address baz     | <- Storage Collision
|mapping bar      |address foo     | 
|                 |mapping bar     |
|                 |...             |

Due to the insertion of baz, the original value of foo becomes corrupted.

The correct approach is to not change the original variable positions but to append variables.

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address foo     | 
|mapping bar      |mapping bar     | 
|                 |address baz     | <- Appended
|                 |...             |

Contract Initialization#

Since the proxy contract holds all the data, the initialization code also needs to be executed. However, the initialization code is often written in the constructor of the implementation contract, and the constructor is only executed once during deployment, which leads to the proxy contract being unable to execute the initialization code, and the data remains uninitialized.

The solution from OpenZeppelin is to move the initialization code from the constructor of the implementation contract to the initialize function.

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

contract RoboToken is Initializable {
    // The `initializer` modifier ensures that this method is executed only once
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // Initialization code...
    }
}

Then the initialize function is executed by the proxy contract.

Proxy Method Implementation#

The proxy contract executes methods in the implementation contract via delegatecall.

_implementation.delegatecall(abi.encodeWithSignature("setValue()", 10));

Or

_implementation.delegatecall(abi.encodeWithSelector(Robo.setValue.selector, 10));

However, both methods require knowledge of the specific method names of the implementation contract, making them not generic.

Thus, inline assembly calling methods were introduced, implemented as follows:

function _delegate(address implementation) internal virtual {
  assembly {
    // Copy calldata to memory 
    // The first 4 bytes of calldata are the function selector, followed by the function parameters. The function selector can match the function to be called.
    calldatacopy(0, 0, calldatasize())

    // delegatecall calls the method of implementation
    let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

    // Copy return data
    returndatacopy(0, 0, returndatasize())
    
    // Return 0 indicates an error
    switch result
    case 0 {
      revert(0, returndatasize())
    }
    default {
      return(0, returndatasize())
    }
  }
}

The implementation of the proxy contract uses several inline assembly functions:

  • calldatacopy(t, f, s): Copies msg.data from position f to memory location t with a length of calldatasize().
  • delegatecall(g, a, in ,insize, out, outsize): Calls address a, with input data from memory position [in...in+insize], and outputs the result to memory [out...out+outsize].
  • returndatacopy(t, f, s): Copies output data from position f to memory location t with a length of returndatasize().
  • revert(p, s): Terminates execution and rolls back, returning data from memory position [p...p+s].
  • return(p, s): Returns results normally, returning data from memory position [p...p+s].

Transparent Proxy and UUPS Proxy#

Transparent proxies (Transparent) and UUPS proxies are just different implementations of the proxy pattern, and there is fundamentally not much difference between them; they use the same upgrade interface and delegate to the implementation contract.

Transparent Proxy#

In the transparent proxy pattern (EIP-1967), upgrades are handled by the proxy contract. Essentially, it changes the implementation contract address variable in the proxy contract to the new implementation contract address. For example, calling a function like upgradeTo(address newImpl) to upgrade to a new implementation contract. However, since this logic is placed in the proxy contract, the cost of deploying such proxies is very high.

contract Robo {
  uint public value;
  function setValue(uint _value) public {}
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {}

  function getImplementationAddress() public view returns (address) {}
  fallback() external {}

  upgradeTo(address newImpl) external {
     // Get the storage slot where implementation is located and assign the value
  }
}

UUPS Proxy#

The UUPS pattern was first proposed in EIP1822. Unlike the transparent pattern, in UUPS, the upgrade logic is handled by the implementation contract itself. The implementation contract includes methods for upgrade logic, as well as the usual business logic. You can make any implementation contract conform to the UUPS standard by having it inherit a generic standard interface that includes upgrade logic, such as inheriting OpenZeppelin's UUPSUpgradeable interface:

contract Robo is UUPSUpgradeable {
  uint private value;

  function setValue(uint _value) public {}

  upgradeTo(address newImpl) external {
  	// The proxy contract calls the upgradeTo method via delegatecall, saving the value of newImpl in the proxy contract
  }
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {  }
  function getImplementationAddress() public view returns (address) {  }
  fallback() external {  }
}

Using OpenZeppelin to Write Upgradeable Smart Contracts#

When using OpenZeppelin to write upgradeable smart contracts in hardhat, you can save a lot of work. You do not need to worry about how the proxy contract is implemented, nor how to execute the initialization method; all you need to do is focus on writing the logic of your implementation contract well.

Initialization#

After initializing the project using hardhat, install the OpenZeppelin related packages:

$ yarn add @openzeppelin/contracts @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades

Configure the hardhat configuration file:

import { HardhatUserConfig } from 'hardhat/config'
import '@nomicfoundation/hardhat-toolbox'
import '@openzeppelin/hardhat-upgrades'

const config: HardhatUserConfig = {
  solidity: '0.8.15'
}

export default config

Writing Contracts#

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

contract Robo is Initializable {
  uint public value;
  
  // Initialization method
  function initialize(uint _value) public initializer {
    value = _value;
  }

  function increaseValue() external {
    ++value;
  }
}

Deployment Script#

Use the @openzeppelin/hardhat-upgrades library to deploy the contract.

import { ethers, upgrades } from 'hardhat'

// yarn hardhat run scripts/deploy_robo.ts --network localhost
async function main () {
  const Robo = await ethers.getContractFactory('Robo')
  
  // Deploy the contract and call the initialization method
  const robo = await upgrades.deployProxy(Robo, [10], {
    initializer: 'initialize'
  })
  
  // Proxy contract address
  const proxyAddress = robo.address
  // Implementation contract address
  const implementationAddress = await upgrades.erc1967.getImplementationAddress(robo.address)
  // ProxyAdmin contract address
  const adminAddress = await upgrades.erc1967.getAdminAddress(robo.address)

  console.log(`proxyAddress: ${proxyAddress}`)
  console.log(`implementationAddress: ${implementationAddress}`)
  console.log(`adminAddress: ${adminAddress}`)
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

Run in the local terminal:

$ yarn hardhat node

Then in another terminal, run the following command to deploy the contract on the local network:

$ yarn hardhat run scripts/deploy.ts --network localhost

In fact, three contracts are deployed:

  • Proxy Contract
  • Implementation Contract
  • ProxyAdmin Contract

The ProxyAdmin contract is used to manage the proxy contract, including upgrading contracts and transferring contract ownership.

The steps to upgrade a contract are:

  • Deploy a new implementation contract,
  • Call the upgrade-related methods in the ProxyAdmin contract to set the new implementation contract address.

The contracts deployed via upgrades.deployProxy by default use the transparent proxy pattern. If you want to use the UUPS proxy pattern, you need to specify it explicitly.

// Deploy the contract and call the initialization method
const robo = await upgrades.deployProxy(Robo, [10], {
  initializer: 'initialize',
  kind: 'uups'
})

At the same time, the contract must inherit UUPSUpgradeable.

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

contract Robo is Initializable, UUPSUpgradeable {
  uint public value;

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

  function increaseValue() external {
    ++value;
  }
  
  function _authorizeUpgrade(address) internal override {}
}

When compiling and deploying the UUPS proxy pattern contract, only two contracts will actually be deployed:

  • Proxy Contract
  • Implementation Contract

At this point, the steps to upgrade the contract are:

  • Deploy a new implementation contract,
  • Call the upgradeTo method in the proxy contract to set the new implementation contract address.

Upgrading Contracts#

When implementing a new version of the contract, you do not need to call deployProxy again as you did the first time; you just need to call upgradeProxy.

async function main () {
  // The address of the proxy contract during the first deployment
  const proxyAddress = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'

  const RoboV2 = await ethers.getContractFactory('RoboV2')
  // upgradeProxy to upgrade the contract
  await upgrades.upgradeProxy(proxyAddress, RoboV2)
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.