Solidity: upgradable smart contracts explained.
Djibril Mug
5 Nov 2024
5 min read
Introduction
Smart contracts are awesome🤩 and we all love them. What makes them more awesome is their immutability. Once a smart contract is deployed to the blockchain it can't be changed altered or updated, this makes them perfect for any agreement situation and even hard to hack or manipulate. But this is very costly in situations where you want to update the old implementation or improve faced security vulnerabilities or any kind of bugs.
Few solutions were proposed to fix this problem, and some of them were able to solve parts of the problem but still missed some important key features. The famous OPcode CALLCODE was able to delegate a function's call to another contract without modifying the state of the called contract and only perform the function's execution in the context of the contract that delegated the call. The only problem with CALLCODE was that msg.sender would become the calling contract, and msg.value
was being set to zero.
To address this issue, and to bring a more reliable solution, on November 15, 2015 a new improvement was proposed (EIP-7) to bring new OPCodes and fix the problem with CALLCODE, leading to the introduction of Delegate Call (delegatecall).
Delegate call (delegatecall)
In Solidity, the delegatecall
function allows a contract to call another contract's code while keeping the context (like storage, msg.sender
, and msg.value
) of the calling contract. It enables the creation of proxy contracts and modular contract designs.
Here's a basic example of how to use delegatecall:
Example scenario
Let's say you have smart contracts A and B
A: Being the entry point to your protocol (Proxy contract) and.
B: Being the library contract, holding all the logic and features of The protocol.
But remember all the functions from the library contract (implementation contract) are meant to be executed on the context of the proxy contract (Contract A) which means instead of using their own storage they use the storage of the calling contract.
contract A {
uint256 public num;
function updateNumber(address implementationContract, uint256 newNum)
public
{
(bool success, ) = implementationContract.delegatecall(
abi.encodeWithSignature("updateNumber(uint256)", newNum)
);
require(success, "Delegation call failed");
}
function getNumber() external view returns (uint256) {
return num;
}
}
contract B {
uint256 public num;
function updateNumber(uint256 newNm) public {
num = newNm;
}
function getNumber() external view returns (uint256) {
return num;
}
}
In the above example you will notice that calling get getNumber function on contract B returns 0 while on contract A it returns the number that is supposed to be a contract B.
This is because while updating the variable num, contract B checks the storage slot index of the variable num in itself (Contract B) and updates whatever is in contract A in that given storage slot.
With this discovery, you can create one proxy contract that holds the state of your protocol and, then keep on deploying new upgrades based on your protocol needs.
One flow with our previous implementation was that the proxy contract must encode the targeted function manually, which is inefficient, since the proxy contract must act as the gateway to the implementation contract without knowledge of available functions in the implementation contract. Below you will find a more refined solution, where all the calls to the implementation contract are handled by one delegate call.
fallback() external payable {
(bool success,) = IMPLEMENTATION_CONTRACT.delegatecall(msg.data);
require(success, "Failed to call the function");
}
In this example, delegateCall is used to invoke all the functions of the implementation contract, thus eliminating the need for manual encoding. Furthermore, this implementation is more scalable and efficient, as it eliminates the need for a proxy contract to manually encode the target function.
Proxy contracts and transparent upgradeable proxy
In the above example, I showed you how to create upgradable smart contracts, but this is not how you are going to build that million-dollar startup you always dreamed about 😅. You need to follow industry standards and use tools that have been reviewed and tested by the best engineers in this space. To achieve this new standards and improvements were established, like the EIP-1967 improvement proposal and many others.
The Transparent Upgradeable Proxy is a design pattern for upgrading a proxy while eliminating the possibility of a function selector clash. In this design, only the admin contract can update the address pointing to the implementation contract
A functional Ethereum proxy needs at least the following two features:
- A storage slot holding the address of the implementation contract
- A mechanism for an admin to change the implementation address
- A mechanism that prevents the admin contract from interacting with the implementation contract.
In the example below, we are going to use a very robust and trusted proxy developed by the OpenZeppelin team. This approach eliminates the need for having to delegate calls manually. Everything is handled by a powerful smart contract written mostly in Yup for low-level manupilations. I will be using HardHat as a framework, feel free to follow along if you are from a different background. The deployment and upgradability will also be handled by OpenZeppelin's plugin
Here you can find the starter project. In the below example, we get two smart contracts, the first being the version one implementation and the second being the version two implementation. We will write the deployment script for deploying the proxy and all the other required contracts (Admin, and implementation).
Open your contracts folder and create one Implementation.sol file and ImplementationV2.sol file.
Implementation.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract Implementation {
uint256 internal value;
uint256 internal constant VERSION = 1;
event ValueChanged(uint256 newValue);
function setValue(uint256 newValue) external {
value = newValue;
emit ValueChanged(newValue);
}
function retrieveValue() external view returns (uint256) {
return value;
}
function version() public pure returns(uint256) {
return VERSION;
}
}
ImplementationV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
contract ImplementationV2 {
uint256 internal value;
uint256 internal constant VERSION = 2;
event ValueChanged(uint256 newValue);
fallback() external payable {
}
function setValue(uint256 newValue) external {
value = newValue;
emit ValueChanged(newValue);
}
function retrieveValue() external view returns (uint256) {
return value;
}
function version() public pure returns (uint256) {
return VERSION;
}
/**
@dev Here we add a new function that doubles the value and returns the new value.
*/
function doubleValue() public returns (uint256) {
value = value * 1;
return value;
}
}
Proxy deployment scripts.
scripts/createImplementation.ts
import { upgrades, ethers } from "hardhat";
import fs from "fs";
import path from "path";
async function main() {
const Implementation = await ethers.getContractFactory("Implementation");
const proxy = await upgrades.deployProxy(Implementation);
await proxy.waitForDeployment();
fs.writeFileSync(
path.join(__dirname, "../proxyContract.txt"),
await proxy.getAddress(),
{
encoding: "utf8",
}
); // Store the proxy contract's address in a file for later usecases(Upgrade).
console.log("ImplementationV1 deployed to:", await proxy.getAddress());
console.log(upgrades.admin);
}
main();
As shown in the above example, I store the proxy's address in the proxyContract.txt. This address will let be used to upgrade the proxy contract from version one to version two.
Upgrade the proxy from version one to version two.
import { ethers, upgrades } from "hardhat";
import fs from "fs";
import path from "path";
async function main() {
const ImplementationV2 = await ethers.getContractFactory("ImplementationV2");
const instance = fs.readFileSync(
path.basename(".../proxyContract.txt"),
"utf8"
);
const upgraded = await upgrades.upgradeProxy(instance, ImplementationV2);
const version = await upgraded.version(); // Get the new version from the new upgrade.
console.log(version); //2
}
main();
Et voilà, just like that we went from version to version 🤩
Finally, the end. Thanks for reading and I hope you find this article helpful.
Additional materials.
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-214.md EIP-7 https://solidity-by-example.org/hacks/delegatecall/ Delegatecall https://eips.ethereum.org/EIPS/eip-1967 ERC-1967: Proxy Storage Slots