Your resource for web content, online publishing
and the distribution of digital products.
«  
  »
S M T W T F S
 
 
 
 
 
1
 
2
 
3
 
4
 
5
 
6
 
7
 
8
 
9
 
 
 
 
 
 
 
16
 
17
 
18
 
19
 
20
 
21
 
22
 
23
 
24
 
25
 
26
 
27
 
28
 
29
 
30
 
31
 
 
 
 
 
 
 

Why I Built NZ’s First Co-Living Space — July 15,2025

DATE POSTED:July 28, 2025
A Practical Guide to deploying upgradeable contracts using Foundry and OpenZeppelinUpgradeable ERC20 Smart Contracts

Smart contracts are, by design, immutable. Once deployed, they cannot be changed or modified. This immutability ensures trust: everyone knows the rules from the beginning, and those rules cannot be altered later. However, this feature also imposes significant limitations on how smart contracts can evolve over time. New features are required to deploy a new smart contract, resulting in the loss of previous interactions and states.

In this article, we will explore how to overcome those limitations by creating an upgradeable smart contract using OpenZeppelin’s plugins. And see a real example using an ERC20Upgradeable smart contract.

Proxy Pattern Explained: Making Smart Contracts Upgradeable

Before diving into how we can create our ERC20 upgradeable contract, let’s take a few seconds to understand how the proxy pattern works. As we said, when we deploy a new smart contract on chain, it is immutable. Meaning that we cannot modify the code. In that case, can we think of an architecture where, instead, we are modifying the target execution address? Allowing us to update or modify the original code execution.

This is precisely what the proxy pattern does. It introduces an intermediate contract, acting as a proxy, that will be in charge of calling the right implementation contract based on the address it has. Thus, we can update our contract and modify the proxy implementation address, allowing us to redirect next transactions to the right implementation while keeping the same contract address for interaction. This is illustrated in the diagram below.

Diagram showing the Proxy Pattern

Another point to mention is the way to keep the storage variables. Indeed, if we are updating to a new fresh contract, we are kind of losing all the previous state of the last implementation, right?

This is where the proxy contract will rely on the delegatecall() bytecode which allows you to execute another smart contract code while keeping your own context environment from your initial smart contract. In another way, it allows you to access your own storage, state variables, and functions while still being able to execute the code of another contract. With this approach, even if the code changes through different upgrades, you still keep your context.

Project Setup and Dependencies

Enough theory, let’s see how we can define and use a Proxy pattern contract. For this project, we will use foundry, but feel free to use hardhat on your side if you are more comfortable with. You can check the OpenZeppelin repository for more details. And, in this guide, we will also use the OpenZeppelin plugins allowing us to secure upgrade actions.

Let’s move to the project initialization. Let’s start by creating a new project with forge and installing the OpenZeppelin dependencies that we need:

forge init
forge install OpenZeppelin/openzeppelin-foundry-upgrades
forge install OpenZeppelin/openzeppelin-contracts-upgradeable

We can now update our foundry.toml by filling the remappings attribute with:

remappings = [
"@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/",
"@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/"
]

Let’s continue modifying our foundry.toml file to allow upgrade safety validation. For that we will need to add the following profile information

[profile.default]
ffi = true
ast = true
build_info = true
extra_output = ["storageLayout"]

Alright, now let’s move on to our upgradeable ERC20 contract. Let’s start by creating a simple ERC20 with an increase() function that will mint a token and increase a local variable each time it is called.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyERC20 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ERC20Upgradeable {
uint256 public counter;
uint256[49] __gap;

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function initialize() public initializer {
__Ownable_init(msg.sender);
__ERC20_init("UpgradableSmartContract", "USC");
}

function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}

function increase() public {
_mint(msg.sender, 1);
counter++;
}
}

Let’s take some time to see the difference in the implementation between the standard ERC20 and an upgradeable one. First, let’s take a look at the different inherited contracts. Here it inherits from Initializable and UUPSUpgradeable which define the logic we need for an upgradable contract. We are also using an OwnableUpgradeable as we need to have a way to store an access mechanism for the contract upgrade. Indeed, we only want a whitelisted person to perform an upgrade. Finally, we are using an ERC20Upgradeable which contains the logic we want for an upgradeable ERC20.

Once those contracts are inherited, we can then initialize them. For that, if you notice in the code, we are not using a constructor(). Instead, we are defining an initialize() function. In this one, we need to call all the initializers for our different inherited contracts. Notice that the order here needs to follow the one set in the inheritance.

__Ownable_init(msg.sender);
__ERC20_init("UpgradableSmartContract", "USC");

Notice that the initialize() function needs to be protected in a way that no one should be able to initialize() again this function. Else, it will reset the state of the contract, which could have devastating consequences. To protect the initialize() to be called another time, we can use the constructor called the_disableInitializers() function, which will allow only a one-time call to initialize().

Another point to notice here is that the deployment phase works in multiple steps. The first will be to deploy a proxy, then deploy our implementation contract then call our initialize function. Notice that if you are doing your own implementation for the deployment, please make sure when deploying, you are calling, in the same transaction the initialize directly. Else, a malicious actor can simply front-run the initialization and become the owner of your contract. This is the reason I encourage you to use existing solutions such as the one proposed by OpenZeppelin to manage and deploy your contracts.

To come back to our implementation, in order to define who can upgrade our smart contract, we need to implement the _authorizeUpgrade() function. Here, we have overridden this function and set the onlyOwner to indicate that only the owner can upgrade our contract.

Finally, in our contract, we have defined the logic for our variables and functions we wanted. Notice that contract variables order have an impact when doing an upgrade! We will talk about this point later on. Notice also that we have defined a __gap variable. This is a convention allowing us to keep future space in our storage in case we want to add new variables in future versions.

Deploy our smart contract with forge

Once our contract is ready, let’s see how we can deploy it. First, let’s create a script in ./script/DeployMyERC20.s.sol. In this script we will first load our private key, then use the Upgrades module from OpenZeppelin that will manage the proxy deployment and initialization, then get back the proxy address and the implementation one.

When deploying our contract, we are, under the hood, deploying an UUPS proxy (Universal Upgradeable Proxy Standard) which follows the ERC-1822. The idea is to have one contract that will be a proxy and will be our entry point for all the actions we want to make. Thus, all the calls from the proxy will be delegated to the implementation.

If we want to change and upgrade our contract logic, instead of modifying the code, we are rather changing the contract address implementation. It is transparent for us, as we are still calling the proxy contract that will be in charge of calling the right logic for us. Regarding the storage, it is managed in the proxy contract, which means that even when we are upgrading our contract, we still have access to our state variable.

Now, if we go back to our script, in the end we would need only the proxy address. However, retrieving the implementation address is interesting in case you want to verify your smart contract, which is what we are going to do here.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";

import {MyERC20} from "../src/MyERC20.sol";

contract DeployMyERC20Script is Script {
MyERC20 public erc20;

function setUp() public {}

function run() public returns (address, address) {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(privateKey);

address proxy = Upgrades.deployUUPSProxy(
"MyERC20.sol",
abi.encodeCall(MyERC20.initialize, ())
);

// Get the implementation address
address implementationAddress = Upgrades.getImplementationAddress(
proxy
);

vm.stopBroadcast();

console.log("Implementation Address:", implementationAddress);
console.log("Proxy Address:", proxy);

return (implementationAddress, proxy);
}
}

To test our script, we can try to simulate it first. To easily handle our commands and our environment variable, we can create a Makefile

include .env

build:
forge clean && forge build

test: build
forge test

simulate-deploy: build
forge script script/DeployMyERC20.s.sol:DeployMyERC20Script \
--fork-url sepolia

deploy: build
forge script script/DeployMyERC20.s.sol:DeployMyERC20Script \
--rpc-url sepolia \
--broadcast \
--verify

And to allow the on-chain interaction and smart contract verification, we can define in our foundry.toml some parameters as:


[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"

[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }

Now, to test our script, we can run make simulate-deploy command, or deploy on-chain with make deploy.

Note: In case you are not using a .env you can prefix your command in your Makefile by a @. It will disable the command to be displayed in the log. It is particularly useful when you are relying on environment variables in your command and you do not want to display them. Here, if I used the following command forge script script/DeployMyERC20.s.sol:DeployMyERC20Script --fork-url ${SEPOLIA_RPC_URL} when doing a make, it will print in my console the RPC URL.Interact with your contract on Etherscan

After deploying your smart contract, you may want to check and verify it on Etherscan. If you go to the proxy address of your contract and click on the Contract view, you may have the following message

This contract may be a proxy contract. Click on More Options and select Is this a proxy? to confirm and enable the “Read as Proxy” & “Write as Proxy” tabs.

By doing so, you will see 4 new options: “Read contract”, “Write contract”, “Read as proxy” and “Write as proxy”. In case you want to interact with your contract implementation, you can call your contract using “Read as proxy” or “Write as proxy”.

Upgrade our on-chain ERC20 contract

Now that we have deployed our contract, let’s work on another version. Let’s say for the sake of the demo that I want to double the amount when I am doing an increase(). Also, I would like to add a variable to track the last user who has called this function.

Let’s create a new contract in ./src/MyERC20v2.sol, using the logic of our first version and updating the function and parameter we want.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

/// @custom:oz-upgrades-from MyERC20
contract MyERC20v2 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ERC20Upgradeable {
uint256 public counter;
address public lastUser;
uint256[48] __gap;

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function initialize() public initializer {
__Ownable_init(msg.sender);
__ERC20_init("UpgradableSmartContract", "USC");
}

function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}

function increase() public {
_mint(msg.sender, 1);
lastUser = msg.sender;
counter++;
counter++;
}
}

Here, you can notice that we have set an annotation on our contract with

/// @custom:oz-upgrades-from MyERC20

This annotation is here to control and verify our upgrade logic. This is especially important as variable orders have an impact on our smart contract and we can have storage collision. This is why we have defined the new variable lastUser after the counter one.

Finally, we have also updated our increase() function by changing the previous logic.

Storage collision

Something that you need to consider on upgrade will be the order of the state variables as it has an impact. Under the hood, each variable will be a pointer to the storage. If you switch the order, the pointer will be attributed to the wrong slot. Let’s illustrate that by an example:

// V1
contract MyERC20 is Initializable, ERC20Upgradeable {
uint256 public counter;

// ...
}

// V2
contract MyERC20v2 is Initializable, ERC20Upgradeable {
uint256 public lastUpdated;
uint256 public counter;

// ...
}

Here in our first contract’s version, we have defined a counter variable. However, now in the second version, we have created a new variable called lastUpdated but defined at the place of the counter one, which is an issue. In the second version, lastUpdated will have the value of the counter variable from the first version as it points to the same reference. While, in the second version, the counter will be set to 0.

Test our new upgradeable implementation

As each step of our development testing plays a crucial part, especially on smart contracts as it can have huge impacts. Let’s write some tests to verify our v1 implementation and test our upgrade.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {Test, console} from "forge-std/Test.sol";

import {MyERC20} from "../src/MyERC20.sol";
import {MyERC20v2} from "../src/MyERC20v2.sol";

contract MyERC20Test is Test {
address proxyAddress;
address owner = makeAddr("owner");
address user = makeAddr("user");

function setUp() public {
// Deploy our MyERC20 v1
vm.prank(owner);
proxyAddress = Upgrades.deployTransparentProxy(
"MyERC20.sol",
owner,
abi.encodeCall(MyERC20.initialize, ())
);
}

function testDeployment() public view {
MyERC20 proxy = MyERC20(proxyAddress);
assertEq(proxy.name(), "UpgradableSmartContract");
assertEq(proxy.symbol(), "USC");
}

function testIncrement() public {
MyERC20 proxy = MyERC20(proxyAddress);

vm.prank(user);
proxy.increase();

assertEq(
proxy.counter(),
1,
"Counter does not have the expected value"
);
}

function testUpgradeContract() public {
MyERC20 proxy = MyERC20(proxyAddress);
proxy.increase(); // Increase by 1
assertEq(proxy.counter(), 1);

// Upgrade our smart contract
vm.startPrank(owner);
Upgrades.upgradeProxy(proxyAddress, "MyERC20v2.sol", "");
vm.stopPrank();

// Verify the state of our v2
MyERC20v2 proxyV2 = MyERC20v2(proxyAddress);
uint256 beforeCounterValue = proxyV2.counter();
assertEq(beforeCounterValue, 1); // Still the same amount

// Call our new 'increase' function
vm.prank(user);
proxyV2.increase(); // Increase by 2
assertEq(
proxyV2.counter(),
beforeCounterValue + 2,
"Counter does not have the expected value"
);
assertEq(proxyV2.lastUser(), user, "Last user does not match");
}
}

Something to notice in our test is that we are keeping only the proxy address and using the right contract logic based on the version we have. This is why you can see we are doing MyERC20(proxyAddress) or MyERC20v2(proxyAddress).

Handle modification in contract or testing

If you are modifying and directly using the forge test command, you may run into this issue:

Recompile all contracts with one of the following commands and try again:
If using Hardhat: npx hardhat clean && npx hardhat compile
If using Foundry: forge clean && forge buildNote: each time you are modifying your contract or your test, OpenZeppelin ‘ safety plugin is relying on the full compiler metadata from out/build-info/*.json, and if Forge hasn't done a full rebuild, the required data is incomplete or outdated.

So each time you want to run your tests, you will have to do a forge clean before doing a forge test. This is why I would simply recommend you to create a Makefile and a custom make clean command in it, as we did earlier.

build:
forge clean && forge build

test: build
forge testDeploy our v2 contract

Finally, it is the time. After modifying and testing that everything works as expected, we can now upgrade our first smart contract on-chain. For that, let’s create a script in ./script/UpgradeMyERC20.s.sol. We will also define a new environment variable called PROXY_ADDRESS that we get on the first deployment.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {Script, console} from "forge-std/Script.sol";

import {MyERC20v2} from "../src/MyERC20v2.sol";

contract UpgradeMyERC20Script is Script {

function setUp() public {}

function run() public {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address proxyAddress = vm.envAddress("PROXY_ADDRESS");

vm.startBroadcast(privateKey);

Upgrades.upgradeProxy(proxyAddress, "MyERC20v2.sol", "");

vm.stopBroadcast();
}
}

Here we are again relying on the Upgrades plugins from OpenZeppelin and set the new implementation for the proxy address.

We can then update our Makefile by adding the following command

simulate-upgrade: build
forge script script/UpgradeMyERC20.s.sol:UpgradeMyERC20Script \
--fork-url sepolia

upgrade: build
forge script script/UpgradeMyERC20.s.sol:UpgradeMyERC20Script \
--rpc-url sepolia \
--broadcast \
--verify

And now we can upgrade our contract by first simulating the execution with make simulate-upgrade. Once it is tested and ready, you can call the make upgrade.

You will notice, after the upgrade, that the proxy address is still the same while the implementation one has now changed and refers to the new smart contract.

Limitation of contract upgrade

Upgradeable smart contracts are a great way to modify our smart contract logic. It can allow you to fix some vulnerabilities or add new features while keeping your protocol address. However, this logic introduces another layer of trust. Indeed, when a smart contract is immutable, we know for sure its execution for a given time and for future ones. However, with an upgradeable contract, which has the possibility to evolve and modify its own logic, we do not. This adds another layer of trust in the process. The entity that deployed the smart contract has control over the contract logic and its evolution.

Conclusion

In this article, we have seen how we can create an upgradeable smart contract using forge and OpenZeppelin plugins. We have also seen some points to take into consideration when deploying our own smart contract, such as the initialization, the variable storage order… and also some considerations to take into account when upgrading it.

If you are interested in this kind of article, feel free to follow me on Medium or to follow my newsletter. Lastly, if you want to reach out, feel free to connect with me on LinkedIn.

Building Upgradeable Smart Contracts with Foundry & OpenZeppelin: An ERC20 Step-by-Step Guide was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.