Deploy an Upgradeable ERC20 Token on Berachain
Introduction to Upgradeable Contracts
Smart contracts on blockchains like Berachain are typically immutable once deployed. While this provides certainty, it can pose challenges for developers who need to update contracts to fix bugs, add features and otherwise adapt to a fast-changing environment. Upgradeable contracts offer a solution between immutability and flexibility.
Proof-of-Liquidity and Upgradeability
Protocols participating in Berachain's novel Proof-of-Liquidity (PoL) consensus mechanism typically require users to stake ERC20 tokens representing deposits in their protocol to earn Berachain's native $BGT
rewards.
A protocol might start off with the staking model, but what if we wanted to get a little creative with rewards, without changing the functioning of their protocol? We explore how upgradeable contracts can facilitate this.
Upgradeable Contracts Guide - Overview
This guide walks you through the process of creating, deploying and upgrading an ERC20 token on Berachain, using Foundry and OpenZeppelin's upgradeable contracts. Specifically, we're going to accomplish the following:
- Deploy an ERC20 contract (the
v1 Implementation
) - Deploy a Proxy contract, which inherits the logic of the
v1 Implementation
- Deploy a modified ERC20 contract (the
v2 Implementation
) with new functions for implementing time-based boosts for PoL rewards - Upgrade Proxy to use the logic of the
v2 Implementation
How Upgradeable Smart Contracts Work
The words "Proxy" or "Implementation" may sound foreign, so let's clear up some terminology and concepts before diving into the code.
Proxy
is the contract that users interact with. It is responsible for storing contract data and state. However, it acts merely as a shell, and doesn't contain any functionality or logic; that's the job of the...
Implementation
contract. The Implementation
hosts all the contract logic for the user-facing Proxy
contract, but does not store any data at the contract address.
As illustrated in the above diagram, the Proxy
and Implementation
contracts work in tandem:
- a User first makes a
call
to theProxy
, - the request is routed to the relevant
Implementation
contract usingdelegatecall
- a permissioned owner of the
Proxy
contract is able to switch between differentImplementation
contracts - hence, upgradeable!
There are different flavors of upgradeable contracts. The type used in this tutorial is the UUPS (Universal Upgradeable Proxy Standard), which embeds upgrade logic into the Implementation
contract itself. This design simplifies the structure of the contracts, eliminating the need for additional admin contracts to manage Proxy
upgrades. To learn more about different upgradeable contracts, consult this OpenZeppelin guide.
📋 Requirements
- Node
v20.11.0
or greater - pnpm
- A wallet configured with Berachain bArtio Network
$BERA
or Berachain Testnet Tokens in that wallet — see Berachain Faucet
Install Foundry
This guide requires Foundry to be installed. In a terminal window, run:
curl -L https://foundry.paradigm.xyz | bash;
foundryup;
# foundryup installs the 'forge' and 'cast' binaries, used later
For more installation instructions, see Foundry’s installation guide. For more details using Foundry on Berachain, see this guide.
👨💻 Creating the Upgradeable Contract Project
Step 1: Project Setup
Let's set up your development environment by initializing Foundry (which creates a new project folder):
forge init pol-upgrades --no-git --no-commit;
cd pol-upgrades;
# We observe the following basic layout
# .
# ├── foundry.toml
# ├── script
# │ └── Counter.s.sol
# ├── src
# │ └── Counter.sol
# └── test
# └── Counter.t.sol
Delete all the existing Solidity contracts:
# FROM: ./pol-upgrades
rm script/Counter.s.sol src/Counter.sol test/Counter.t.sol;
Now, install the necessary OpenZeppelin dependencies:
# FROM: ./pol-upgrades
forge install OpenZeppelin/openzeppelin-contracts openzeppelin-contracts-upgradeable OpenZeppelin/openzeppelin-foundry-upgrades foundry-rs/forge-std --no-commit --no-git;
Step 2: Foundry Configurations
Create a remappings.txt
file in the root of your project with the following content:
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
Next, update your foundry.toml
file:
[profile.default]
ffi = true
ast = true
build_info = true
evm_version = "cancun"
libs = ["lib"]
extra_output = ["storageLayout"]
Step 3: Create the Initial Token Contract
Create a file src/DeFiTokenV1.sol
with the following content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract DeFiToken is ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__ERC20_init("DeFi Token", "DFT");
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
_mint(initialOwner, 1000000 * 10 ** decimals());
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}
You'll notice a couple quirks in this smart contract that you might not have seen elsewhere:
- Proxied contracts do not use a constructor, so the constructor logic is moved into the
initialize
function. It executes functions that would be executed in the constructor of a regular contract, such as setting the token name, and owner - the inherited
ERC20
andOwnable
contracts are special "upgradeable" versions which facilitate initialization (outside of the constructor), as well as re-initialization when upgrading
Step 4: Create the Deployment Script
Create a file script/DeployProxy.s.sol
with the following content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "../src/DeFiTokenV1.sol";
import "forge-std/Script.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
contract DeployProxy is Script {
function run() public {
vm.startBroadcast();
address proxy = Upgrades.deployUUPSProxy(
"DeFiTokenV1.sol:DeFiToken",
abi.encodeCall(DeFiToken.initialize, (msg.sender))
);
vm.stopBroadcast();
console.log("Proxy Address:", address(proxy));
console.log("Token Name:", DeFiToken(proxy).name());
}
}
Step 5: Set Up Environment Variables
Before we move onto deployment, create a .env
file in the root of your project and add your wallet private key:
PK=your_private_key_here
Then, source the environment variables:
# FROM: ./pol-upgrades
source .env;
Step 6: Deploy Token and Proxy
This is where things get interesting. In the above DeployProxy
script, the call to OpenZeppelin's Upgrades.deployUUPSProxy
actually does two things under the hood:
- Deploy the
DeFiToken
implementation contract - Deploy the
UUPSUpgradeable
proxy contract and hook it up to theDeFiToken
implementation
Start by compiling the contracts:
# FROM: ./pol-upgrades
forge build;
Next, run the deployment script (we pin the Solidity version for consistency):
# FROM: ./pol-upgrades
forge script script/DeployProxy.s.sol --broadcast --rpc-url https://bartio.rpc.berachain.com/ --private-key $PK --use 0.8.25;
Step 7: Verify the Contract (Optional)
If you would like to verify your contract on Beratrail explorer:
# FROM: ./pol-upgrades
forge verify-contract IMPLEMENTATION_ADDRESS ./src/DeFiTokenV1.sol:DeFiToken --verifier-url 'https://api.routescan.io/v2/network/testnet/evm/80084/etherscan' --etherscan-api-key "verifyContract" --num-of-optimizations 200 --compiler-version 0.8.25 --watch;
Remember to replace the IMPLEMENTATION_ADDRESS
with the one from your script output. Don't worry about verifying the proxy, because the explorer already knows about this contract.
Now, when you navigate to your proxy contract on Beratrail, you will see the ERC20 token attributes hooked up to the proxy contract (see deployed proxy contract for example).
Step 8: Create the Upgraded Token Contract
If you only want to follow the code examples and don't care about the PoL logic we're implementing, then skip to the next code block
PoL Innovation 💡
Here's where we can start getting creative with upgradeable contracts. Say we want to reward users who have deposited in your protocol the longest with more $BGT
rewards. However, the deposit token already has an active Reward Vault and we don't want users to have to migrate tokens.
Upgradeability fixes this! Take an example where a user has 100 tokens in a traditional staking paradigm, and we want to migrate to a time-boosted system. You still notice that after 60 days of holding the position, the PoL earning rates surpass that of traditional staking.
Create a file src/DeFiTokenV2.sol
with the following content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
interface IBerachainRewardsVault {
function delegateStake(address account, uint256 amount) external;
function delegateWithdraw(address account, uint256 amount) external;
function getTotalDelegateStaked(
address account
) external view returns (uint256);
}
/// @custom:oz-upgrades-from DeFiToken
contract DeFiTokenV2 is ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
IBerachainRewardsVault public rewardsVault;
uint256 public constant BONUS_RATE = 50; // 50% bonus per 30 days
uint256 public constant BONUS_PERIOD = 30 days;
mapping(address => uint256) public lastBonusTimestamp;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize() public reinitializer(2) {
__ERC20_init("DeFi Token V2", "DFTV2");
}
function setRewardsVault(address _rewardsVault) external onlyOwner {
rewardsVault = IBerachainRewardsVault(_rewardsVault);
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function applyBonus(address account) external {
uint256 newBonusAmount = calculateBonus(account);
require(newBonusAmount > 0, "No bonus to apply");
// Mint new bonus tokens to this contract
_mint(address(this), newBonusAmount);
// Delegate new bonus stake
rewardsVault.delegateStake(account, newBonusAmount);
lastBonusTimestamp[account] = block.timestamp;
}
function calculateBonus(address account) public view returns (uint256) {
uint256 userBalance = balanceOf(account);
uint256 timeSinceLastBonus = block.timestamp -
lastBonusTimestamp[account];
return
(userBalance * BONUS_RATE * timeSinceLastBonus) /
(100 * BONUS_PERIOD);
}
function getBonusBalance(address account) public view returns (uint256) {
return rewardsVault.getTotalDelegateStaked(account);
}
function removeBonus(address account) internal {
uint256 bonusToRemove = getBonusBalance(account);
if (bonusToRemove > 0) {
rewardsVault.delegateWithdraw(account, bonusToRemove);
_burn(address(this), bonusToRemove);
lastBonusTimestamp[account] = 0;
}
}
function transfer(
address to,
uint256 amount
) public override returns (bool) {
removeBonus(msg.sender);
lastBonusTimestamp[to] = block.timestamp;
return super.transfer(to, amount);
}
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}
Whew, there are quite a few changes in DeFiTokenV2
. We'll break the new content down in two parts. In the first, we point out the pieces related to upgradeability. In the second, we discuss the smart contract functions for supporting our new PoL mechanism.
Upgradeability changes
- the contract contains a reference to the original
DeFiToken
contract, required by OpenZeppelin - The contract's
initialize
function has thereinitializer(2)
modifier, indicating this is a re-initialization, with the2
referring to the version number of the contract
If you only care about upgradeability, skip to the next section
PoL changes
You may be wondering: how does my balance in the Reward Vault contract increase, if it's tied to an ERC20 position that shouldn't change?
Good question! To achieve time-boosted rewards, we leverage the Reward Vault's delegateStake
functionality, whereby a smart contract can stake on behalf of users. This is useful for a number of different use cases, like time-boosted rewards here, or for integrating virtual/non-ERC20 positions with PoL.
To apply the time-based logic, the DeFiTokenV2
contract now handles the staking logic for the user, while they simply hold the tokens in their wallet. Let's dive in!
setRewardsVault
allows the protocol to set the Reward Vault address where users' boosts are staked to earn$BGT
calculateBonus
calculates what additional balance is owed to a user, since the last time a bonus was applied for themapplyBonus
mints new tokens and are staked usingdelegateStake
on behalf of a particular user, based on the bonus they have accruedgetBonusBalance
queries the Rewards Vault contract for a user's bonus balanceremoveBonus
invokes the Reward Vault'sdelegateWithdraw
function to withdraw/nullify a user's bonus balance and burns those tokens, to reflect the loss of bonus when they transfer their tokens away
Hopefully, this example gives you an idea of what innovative alternatives to traditional PoL are possible. Keep in mind this code is incomplete and not suitable for production use.
Step 9: Test our Contracts
With all our smart contracts written, let's test the behavior of our upgradeable PoL-integrated token contract.
We'll be testing the following functionality:
- Checking that the contract upgrade is performed successfully
- Checking that the PoL bonus logic works as expected
Create a file test/DeFiToken.t.sol
with the following content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "forge-std/Test.sol";
import "../src/DeFiTokenV1.sol";
import "../src/DeFiTokenV2.sol";
import "forge-std/console.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
contract MockRewardsVault {
mapping(address => uint256) public delegatedStakes;
function delegateStake(address account, uint256 amount) external {
delegatedStakes[account] += amount;
}
function delegateWithdraw(address account, uint256 amount) external {
require(
delegatedStakes[account] >= amount,
"Insufficient delegated stake"
);
delegatedStakes[account] -= amount;
}
function getTotalDelegateStaked(
address account
) external view returns (uint256) {
return delegatedStakes[account];
}
}
contract DeFiTokenTest is Test {
DeFiToken deFiToken;
DeFiTokenV2 deFiTokenV2;
ERC1967Proxy proxy;
address owner;
address user1;
MockRewardsVault mockRewardsVault;
function setUp() public {
DeFiToken implementation = new DeFiToken();
owner = vm.addr(1);
user1 = vm.addr(2);
vm.startPrank(owner);
proxy = new ERC1967Proxy(
address(implementation),
abi.encodeCall(implementation.initialize, owner)
);
deFiToken = DeFiToken(address(proxy));
vm.stopPrank();
mockRewardsVault = new MockRewardsVault();
}
function testBoostedStakingFunctionality() public {
testUpgradeToV2();
vm.startPrank(owner);
deFiTokenV2.setRewardsVault(address(mockRewardsVault));
deFiTokenV2.mint(user1, 1000 * 1e18);
vm.stopPrank();
// Fast forward 15 days
vm.warp(block.timestamp + 15 days);
// Apply bonus for user1
vm.prank(user1);
deFiTokenV2.applyBonus(user1);
// Check bonus balance (should be 25% of user's balance after 15 days)
uint256 expectedBonus = (1000 * 1e18 * 25) / 100;
assertApproxEqAbs(
deFiTokenV2.getBonusBalance(user1),
expectedBonus,
1e15
);
// Fast forward another 30 days
vm.warp(block.timestamp + 30 days);
// Apply bonus again (should be 75% of user's balance)
vm.prank(user1);
deFiTokenV2.applyBonus(user1);
expectedBonus = (1000 * 1e18 * 75) / 100;
assertApproxEqAbs(
deFiTokenV2.getBonusBalance(user1),
expectedBonus,
1e15
);
// Test bonus removal on transfer
vm.prank(user1);
deFiTokenV2.transfer(owner, 500 * 1e18);
// Check that bonus is removed
assertEq(deFiTokenV2.getBonusBalance(user1), 0);
}
function testUpgradeToV2() public {
vm.startPrank(owner);
Upgrades.upgradeProxy(
address(proxy),
"DeFiTokenV2.sol:DeFiTokenV2",
abi.encodeCall(DeFiTokenV2.initialize, ())
);
vm.stopPrank();
deFiTokenV2 = DeFiTokenV2(address(proxy));
assertTrue(address(deFiTokenV2) == address(proxy));
}
}
Whew, the test code is long, but hopefully the comments allow you to understand what is happening within the test cases.
# FROM: ./pol-upgrades
forge clean && forge test;
# [EXAMPLE OUTPUT]:
# Ran 2 tests for test/DeFiToken.t.sol:DeFiTokenTest
# [PASS] testBoostedStakingFunctionality() (gas: 2032978)
# [PASS] testUpgradeToV2() (gas: 1904385)
# Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 5.62s (11.14s CPU time)
# Ran 1 test suite in 5.66s (5.62s CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
Step 10: Create the Upgrade Script
Now that we're satisfied our contracts work, let's get ready to upgrade. Create a file script/DeployUpgrade.s.sol
with the following content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "../src/DeFiTokenV2.sol";
import "forge-std/Script.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
contract DeployAndUpgrade is Script {
function run() public {
// Replace with your proxy address
address proxy = 0x0000000000000000000000000000000000000000;
vm.startBroadcast();
Upgrades.upgradeProxy(
proxy,
"DeFiTokenV2.sol:DeFiTokenV2",
abi.encodeCall(DeFiTokenV2.initialize, ())
);
vm.stopBroadcast();
console.log("Token Name:", DeFiTokenV2(proxy).name());
}
}
Replace the proxy
variable with the proxy address from Step 6
This script pieces together the upgrade process by doing the following:
- Deploy the upgraded
DeFiTokenV2
contract - Swap the proxy implementation to the new
DeFiTokenV2
- Call
initialize
again to change our token's name
Step 11: Perform the Upgrade
Clean the build artifacts and then run the upgrade script:
# FROM: ./pol-upgrades
forge clean;
forge script script/DeployUpgrade.s.sol --broadcast --rpc-url https://bartio.rpc.berachain.com/ --private-key $PK --use 0.8.25;
Now check your proxy contract on Beratrail explorer. Behold, at the same address, you will see that the token name (a normally immutable property) has changed!
Recap
Congratulations! If you've gotten this far, you will have learned the basics of how upgradeable contracts function, and how they can work to your advantage. If you were paying extra close attention, you'll also have learned about an innovative way to work with Proof-of-Liquidity.
By following the guide, you'll have successfully deployed an upgradeable ERC20 token on Berachain. You will then have upgraded your proxy contract's implementation to make some really cool changes to its functioning 🎉
🐻 Full Code Repository
If you want to see the final code and see other guides, check out Berachain Upgradeable Contracts Guide Code.
🛠️ Want To Build More?
Want to build more on Berachain and see more examples. Take a look at our Berachain GitHub Guides Repo for a wide variety of implementations that include NextJS, Hardhat, Viem, Foundry, and more.
If you’re looking to dive deeper into the details, take a look at our Berachain Docs.
Looking For Dev Support?
Make sure to join our Berachain Discord server and check out our developer channels to ask questions.
❤️ Don’t forget to show some love for this article 👏🏼