Deploy an Upgradeable ERC20 Token on Berachain

Beary Cucumber
Beary Cucumber
12 min read
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.

What is Proof-of-Liquidity
What is Berachain’s Proof-of-Liquidity & How Does it Work?

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.

💡
Change your token's PoL staking mechanism without migrating tokens

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:

  1. Deploy an ERC20 contract (the v1 Implementation)
  2. Deploy a Proxy contract, which inherits the logic of the v1 Implementation
  3. Deploy a modified ERC20 contract (the v2 Implementation) with new functions for implementing time-based boosts for PoL rewards
  4. 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 the Proxy,
  • the request is routed to the relevant Implementation contract using delegatecall
  • a permissioned owner of the Proxy contract is able to switch between different Implementation 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

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 and Ownable 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:

  1. Deploy the DeFiToken implementation contract
  2. Deploy the UUPSUpgradeable proxy contract and hook it up to the DeFiToken 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;
Example Deployment Output

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.

Traditional (V1) vs Time-boosted (V2) Rewards

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 the reinitializer(2) modifier, indicating this is a re-initialization, with the 2 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 them
  • applyBonus mints new tokens and are staked using delegateStake on behalf of a particular user, based on the bonus they have accrued
  • getBonusBalance queries the Rewards Vault contract for a user's bonus balance
  • removeBonus invokes the Reward Vault's delegateWithdraw 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:

  1. Deploy the upgraded DeFiTokenV2 contract
  2. Swap the proxy implementation to the new DeFiTokenV2
  3. 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!

The token name 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.

guides/apps/openzeppelin-upgrades at main · berachain/guides
A demonstration of different contracts, languages, and libraries that work with Berachain EVM. - berachain/guides

🛠️ 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.

GitHub - berachain/guides: A demonstration of different contracts, languages, and libraries that…
A demonstration of different contracts, languages, and libraries that work with Berachain EVM. - berachain/guides

If you’re looking to dive deeper into the details, take a look at our Berachain Docs.

Berachain Docs
Berachain protocol core docs for learning how the blockchain works, developer guides, and understanding how to manage…

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 👏🏼