Upgradable Smart Contracts: Enhancing Flexibility and Security

The need for upgradeability and the approaches to achieving it in smart contracts.

Dhrumil Dalwadi
Simform Engineering

--

Smart contracts, the building blocks of blockchain, are computer programs or protocols that work using predetermined conditions. They carry out transactions and keep track of events based on the contract terms. Once deployed on a blockchain, they automatically execute and enforce these rules without the need for intermediaries. This removes the need to trust a single party.

Smart contracts and blockchain technology are decentralized, which means they promote transparency and immutability. Once a smart contract is deployed on the blockchain, it becomes a permanent entry on the ledger and cannot be changed. This immutability enhances the security and dependability of smart contracts.

Need for Upgradeability in Smart Contract

Upgradeability in smart contracts is crucial for fixing bugs, addressing security vulnerabilities, adapting to changing business requirements, improving efficiency, incorporating industry standards, and future-proofing the contract. It enables developers to release updated versions that enhance functionality, security, and performance over time. Additionally, upgradability allows for community governance, ensuring transparency and inclusivity in shaping the evolution of smart contracts. Overall, upgradability provides the relevance, security, and flexibility of smart contracts in the dynamic blockchain ecosystem.

Let’s delve into a detailed exploration of upgradeable smart contracts, including their definition, the approaches used to implement them, and the best practices to follow while designing upgradeable intelligent contracts.

Approaches to Achieving Upgradeability

There are several approaches to implementing upgradeable smart contracts, each with its pros and cons. Here are three common approaches:

1. Proxy Pattern

Proxy pattern involves separating the contract’s storage and logic into two different agreements: a proxy contract and an implementation contract. The proxy contract acts as a facade and delegates all calls to the implementation contract. To upgrade the contract, a new implementation contract is deployed, and the proxy contract is updated to delegate calls to the new implementation.

Pros:

  • Separation of concerns: Logic and storage are separated, allowing for easier upgrades.
  • Minimal disruption: Upgrades can be performed without changing the contract address, minimizing disruption for users.
  • Reduced gas costs: Only the proxy contract needs to be redeployed during upgrades, saving gas costs.

Cons:

  • Complexity: Managing the proxy contract and its interaction with the implementation contract can add complexity to the development process.
  • Limited storage access: Upgraded contracts may have limited access to the storage of previous versions, requiring additional migration logic.

Snippet:

// Proxy contract
contract Proxy {
address private implementation;

function upgrade(address _newImplementation) public {
implementation = _newImplementation;
}

fallback() external {
address _impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}

// Implementation contract
contract MyContract {
uint public value;

function setValue(uint _newValue) public {
value = _newValue;
}
}

2. Eternal Storage Pattern

Eternal storage pattern separates the contract’s storage from its logic, similar to the proxy pattern. However, instead of using a proxy contract, the storage is stored in a separate contract called “eternal storage”. The logic contract references the storage contract, allowing for upgrades by deploying a new logic contract and redirecting the storage contract to the new logic contract.

Pros:

  • Separation of concerns: Logic and storage are separated, making upgrades easier.
  • Enhanced storage access: Upgraded contracts have full access to the storage of previous versions without requiring migration logic.
  • Reduced deployment costs: Only the logic contract needs to be redeployed during upgrades, reducing deployment costs.

Cons:

  • Increased complexity: The separation of storage and logic can add complexity to the codebase.
  • Storage contract trust: The eternal storage contract needs to be carefully audited and secured, as it holds all the contract’s data.

Snippet:

// Storage contract
contract EternalStorage {
mapping(bytes32 => uint) private uintStorage;

function getUint(bytes32 _key) public view returns (uint) {
return uintStorage[_key];
}

function setUint(bytes32 _key, uint _value) public {
uintStorage[_key] = _value;
}
}

// Logic contract
contract MyContract {
EternalStorage private storageContract;

constructor(address _storageContract) {
storageContract = EternalStorage(_storageContract);
}

function getValue() public view returns (uint) {
return storageContract.getUint(keccak256("value"));
}

function setValue(uint _newValue) public {
storageContract.setUint(keccak256("value"), _newValue);
}
}

3. Upgradeable Libraries

Upgradeable libraries involve separating the contract’s logic into separate libraries, which can be upgraded independently. The main contract references the libraries and delegates calls to them. Upgrades are performed by deploying new library versions and updating the main contract to use the new versions.

Pros:

  • Modularity: Libraries can be independently upgraded, allowing for more flexible and granular upgrades.
  • Code reuse: Libraries can be shared across multiple contracts, promoting code reuse.
  • Simplified migration: Libraries can have access to the storage of previous versions without requiring additional migration logic.

Cons:

  • Deployment complexity: Upgrades require deploying new library versions and updating the main contract, which can be more complex than other approaches.
  • Increased gas costs: Delegating calls to libraries can incur additional gas costs compared to directly executing the logic within the contract.

Snippet:

// Library contract
library MyLibrary {
function getValue(uint _input) public pure returns (uint) {
// Implementation logic
return _input * 2;
}
}

// Main contract
contract MyContract {
using MyLibrary for uint;
uint public value;

function setValue(uint _newValue) public {
value = _newValue.getValue();
}
}

These are three approaches to implementing upgradeable smart contracts, each with its trade-offs. The choice depends on the specific requirements and constraints of your project.

Best Practices When Designing Upgradeable Smart Contracts

When designing upgradeable smart contracts, there are several essential considerations and best practices to keep in mind. Upgradability introduces complexity and potential risks, so it’s crucial to follow these guidelines to ensure the security and smooth functioning of your smart contract system. Here’s an outline of the essential considerations and best practices:

Variable Declaration:

  1. Minimize Stateful Variables: Limit the use of stateful variables within the upgradeable contract. Instead, consider separating the contract state from the contract logic and storing it in a separate contract or library. This separation helps to avoid data loss or inconsistencies during upgrades.
  2. Declare Variables as Immutable: Whenever possible, declare variables as immutable to ensure they cannot be modified once initialized. Immutable variables enhance contract security and reduce the risk of unintended modifications during upgrades.
  3. Be Mindful of Variable Compatibility: When introducing new state variables, ensure compatibility with existing variable structures. Changing variable types or layouts in an upgrade can lead to issues with data retrieval or manipulation.
  4. Plan for Variable Expansion: Design contracts with future expansion in mind. Consider potential future requirements and allow for the addition of new variables without disrupting the existing contract structure or data storage.

Defining New Functions:

  1. Use External Function Modifiers: Consider marking functions as external instead of public or private when possible. External functions are more gas-efficient since they do not create an additional context for the function call, which can be important when deploying upgrades.
  2. Document Function Interfaces: Document the interfaces of all functions, including their parameters, return values, and expected behaviour. This documentation helps maintain consistency during upgrades and assists developers or auditors in understanding the contract’s intended usage.
  3. Avoid Changing Existing Function Signatures: Once a function is part of the contract’s interface, avoid changing its signature in subsequent upgrades. Changing the signature can break compatibility with existing code or interactions and may require manual updates or migrations.
  4. Follow Semantic Versioning: Use a semantic versioning scheme to indicate the compatibility of new functions or changes introduced in upgrades. Semantic versioning helps users understand the impact of upgrades and manage dependencies effectively.
  5. Comprehensive Testing: Thoroughly test new functions introduced in upgrades, including both unit testing and integration testing. Testing helps ensure that the new functions function as intended and do not introduce vulnerabilities or unexpected behaviours.
  6. Security Audits: Engage independent security auditors to review new functions and their integration within the upgradeable smart contract. Audits provide an external perspective and help identify potential security flaws or vulnerabilities.
  7. Consider External Function Libraries: If possible, consider utilizing external libraries for frequently used or complex functions. This approach allows you to upgrade your smart contract logic while keeping the library contracts separate and upgradeable independently.

Remember, the best practices mentioned here are meant to provide general guidance, but the specific requirements of your project or platform may necessitate additional cons.

Demo: Upgrading a Smart Contract

In this section, I will provide you with a concise, step-by-step procedure to create and deploy a simple upgradeable smart contract. Additionally, I will guide you on how to effectively upgrade the contract.

Before we begin, please ensure that you have the following prerequisites installed:

  • Node.js (version 12 or higher)
  • Hardhat (version 2 or higher)

Let’s begin!

Step 1: Set up the project

  • Create a new directory for your project and navigate to it.
  • Initialize a new Node.js project by running the following command in the terminal:
npm i -y
  • Install Hardhat and the necessary plugins by running:
npm i --save-dev hardhat @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades dotenv
  • Create a hardhat project by running the following command and selecting your preferred choices:
npx hardhat

Step 2: Write the initial contract

  • Create a new file called ‘MyContract.sol’ in the contracts directory (create one if it doesn’t exist).
  • Write your initial contract in ‘MyContract.sol’. For example, I will be creating an upgradeable ERC20 contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";

contract MyContract is ERC20Upgradeable {
uint256 public CONSTANT;
uint256[50] __gap;

function initialize(
string memory name,
string memory symbol
) public initializer {
__ERC20_init(name, symbol);
CONSTANT = 30;
}
}

In the above code, we can observe two things. First, I’ve declared a variable named ‘__gap’. In upgradeable smart contracts, the ‘__gap’ array is used to reserve storage space for future variables, ensuring a consistent storage layout across contract versions. It prevents storage collisions and unexpected behaviour when adding or modifying variables during upgrades. New variables can be added after the __gap array, utilizing the reserved space without affecting existing variables.

Secondly, I’ve used initialize instead of a constructor. In upgradeable smart contracts, the contract logic can be upgraded while preserving the contract’s storage. This means that during an upgrade, the constructor of the new contract logic would not be called, and using it could lead to unintended consequences or inconsistencies in the contract state.

To address this, an initialize function is commonly used in upgradeable contracts. The initialize function acts as a replacement for the constructor and is called explicitly after the contract is deployed or upgraded. It allows for the initialization of state variables or any other necessary setup specific to the upgraded version.

Step 3: Deploy the initial contract

  • Create a new file called my-contract.js in the task folder.
  • Add the following code in that file to deploy the initial contract:
task('deploy:my-contract', 'Deploy MyContract', async () => {
const accounts = await ethers.getSigners();
const signer = accounts[0];

console.log('Deploying contract...');

const contractFactory = await ethers.getContractFactory('MyContract');
const myContract = await upgrades.deployProxy(contractFactory, [
'My Token',
'MTK'
]);

await myContract.deployed();

console.info('Contract Deployed at ', myContract.address);
});
  • Set up your .env file.
PRIVATE_KEY=

ETHERSCAN_API_KEY=

POLYSCAN_API_KEY=
MUMBAI_ALCHEMY_API=
SEPOLIA_ALCHEMY_API=
MAINNET_ALCHEMY_API=

DEPLOY_NETWORK=
  • Configure your hardhat.config.js. You can take reference from the following:
require('@nomicfoundation/hardhat-toolbox');
require('@openzeppelin/hardhat-upgrades');
require('dotenv').config();

require('./task/my-contract');

const PRIVATE_KEY = process.env.PRIVATE_KEY;
if (!PRIVATE_KEY) {
console.error('Please add PRIVATE_KEY to .env');
process.exit(1);
}
const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY;
if (!ETHERSCAN_API_KEY) {
console.error('Please add ETHERSCAN_API_KEY to .env');
process.exit(1);
}

module.exports = {
solidity: {
version: '0.8.18',
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
hardhat: {
chainId: 1337,
},
localhost: {
chainId: 1337,
},
sepolia: {
url: `https://eth-sepolia.g.alchemy.com/v2/${process.env.SEPOLIA_ALCHEMY_API}`,
accounts: [PRIVATE_KEY],
chainId: 11155111,
gas: 'auto',
},
mainnet: {
url: `https://eth-mainnet.g.alchemy.com/v2/${process.env.MAINNET_ALCHEMY_API}`,
accounts: [PRIVATE_KEY],
chainId: 1,
gas: 'auto',
},
},
etherscan: {
apiKey: {
sepolia: process.env.ETHERSCAN_API_KEY,
mainnet: process.env.ETHERSCAN_API_KEY,
},
},
};
  • Now that everything’s set up, let us go ahead and deploy our contract by running the following command. (NOTE: I am deploying in the Sepolia network, you can deploy in the network which is defined by you in the hardhat config.)
npx hardhat deploy:my-contract --network sepolia

You should see that your contract is deployed, and the contract address is displayed in the terminal.

After deploying it, we also need to verify our contract.

  • Add the verify task in my-contract.js present in the task folder.
task('verify:my-contract', 'Verify MyContract', async () => {
await run('verify:verify', {
address: '0x71AD25405e47c5605d74999f569ED1eCc0C2c4eF',
constructorArguments: [],
});
});
  • Run the verify task:
npx hardhat verify:my-contract --network sepolia

With this, your contract has been deployed as well as verified.

Step 4: Prepare the upgraded contract

  • Update your contract in the contracts directory.
  • Ensure it has the same name and inheritance as the previous contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";

contract MyContract is ERC20Upgradeable {
uint256 public CONSTANT;
uint256 public MULTIPLIER;
uint256[49] __gap;

function initialize(
string memory name,
string memory symbol
) public initializer {
__ERC20_init(name, symbol);
CONSTANT = 30;
MULTIPLIER = 2;
}

function multiply() public view returns (uint256) {
return CONSTANT * MULTIPLIER;
}
}
  • Add the upgrade task in my-contract.js present in the task folder.
task('upgrade:my-contract', 'Upgrade MyContract', async () => {
const contractFactory = await ethers.getContractFactory('MyContract');
const asset = await upgrades.upgradeProxy(
'Your Contract Address',
contractFactory,
);

await asset.deployed();
console.info('Your contract is successfully upgraded');
});
  • Run the upgrade task to deploy and link your new implementation contract with the proxy.
npx hardhat upgrade:my-contract --network sepolia
  • You will also need to verify the new implementation contract to be correctly linked with the proxy.

That’s it! You’ve successfully upgraded a smart contract.

Wrapping Up

So upgradeable smart contracts revolutionize blockchain technology by providing flexibility and adaptability. They allow for continuous improvement, bug fixes, and the addition of new features while maintaining security and immutability. This opens up endless possibilities for developers and users, shaping the future of decentralized applications.

For more updates on the latest tools and technologies, follow the Simform Engineering blog.

Follow Us: Twitter | LinkedIn

--

--