The SuperchainERC20 standard is ready for production deployments. Please note that the OP Stack interoperability upgrade, required for crosschain messaging, is currently still in active development.
Contract upgrade
Overview
This guide explains how to upgrade an ERC20 to a SuperchainERC20
(opens in a new tab) that can teleport across the Superchain interop cluster when the original ERC20 contract was placed behind a proxy to enable future upgrades.
About this tutorial
What you'll learn
- How to upgrade an ERC20 token to enable Superchain interoperability when it was deployed with a proxy.
Prerequisite knowledge
- You should already know how to deploy SuperchainERC20 tokens with custom code.
The code on the documentation site is sample code, not production code. This means that we ran it, and it works as advertised. However, it did not pass through the rigorous audit process that most Optimism code undergoes. You're welcome to use it, but if you need it for production purposes you should get it audited first.
What you'll do
- Upgrade an existing ERC20 that uses the proxy pattern (opens in a new tab) to comply with interop requirements (with the proper authority).
How beacon proxies work
A beacon proxy (opens in a new tab) uses two contracts.
The UpgradeableBeacon
(opens in a new tab) contract holds the address of the implementation contract.
The BeaconProxy
(opens in a new tab) contract is the one called for the functionality, the one that holds the storage.
When a user (or another contract) calls BeaconProxy
, it asks UpgradeableBeacon
for the implementation address and then uses delegatecall
(opens in a new tab) to call that contract.
To upgrade the contract, an authorized address (typically the Owner
) calls UpgradeableBeacon
directly to specify the new implementation contract address.
After that happens, all new calls are sent to the new implementation.
Instructions
Some steps depend on whether you want to deploy on supersim or on the development network.
Install and run Supersim
If you are going to use Supersim, follow these instructions to install and run Supersim.
Make sure to run Supersim with autorelay on.
./supersim --interop.autorelay true
Setup the ERC20 token on chain A
Download and run the setup script.
curl https://docs.optimism.io/tutorials/setup-for-erc20-upgrade.sh > setup-for-erc20-upgrade.sh
chmod +x setup-for-erc20-upgrade.sh
./setup-for-erc20-upgrade.sh
If you want to deploy to the development networks, provide setup-for-erc20-upgrade.sh
with the private key of an address with ETH on both devnets.
./setup-for-erc20-upgrade.sh <private key>
Store the addresses
Execute the bottom two lines of the setup script output to store the ERC20 address and the address of the beacon contract.
BEACON_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
export ERC20_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
Specify environment variables
Specify these variables, which we use later:
Set these parameters for Supersim.
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
USER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
URL_CHAIN_A=http://127.0.0.1:9545
URL_CHAIN_B=http://127.0.0.1:9546
INTEROP_BRIDGE=0x4200000000000000000000000000000000000028
Create a Foundry project
We create a Foundry (opens in a new tab) project and import the OpenZeppelin (opens in a new tab) contracts, which were used for the original ERC20 and proxy deployment.
mkdir proxy-upgrade
cd proxy-upgrade
forge init
forge install OpenZeppelin/openzeppelin-contracts
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
forge install ethereum-optimism/interop-lib
Create and run the deployment script
-
Create an
script/LabSetup.s.sol
file with this content:script/LabSetup.s.sol// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; import {Script, console} from "forge-std/Script.sol"; import {UpgradeableBeacon} from "../lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {BeaconProxy} from "../lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/beacon/BeaconProxy.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; contract MyToken is Initializable, ERC20Upgradeable, OwnableUpgradeable { function initialize(string memory name, string memory symbol, uint256 initialSupply) public initializer { __ERC20_init(name, symbol); __Ownable_init(msg.sender); _mint(msg.sender, initialSupply); } } contract LabSetup is Script { function setUp() public {} function run() public { vm.startBroadcast(); MyToken token = new MyToken(); console.log("Token address:", address(token)); console.log("msg.sender:", msg.sender); UpgradeableBeacon beacon = new UpgradeableBeacon(address(token), msg.sender); console.log("UpgradeableBeacon:", address(beacon)); BeaconProxy proxy = new BeaconProxy(address(beacon), abi.encodeCall(MyToken.initialize, ("Test", "TST", (block.chainid == 901) || (block.chainid == 420120000) ? 10**18 : 0)) ); console.log("Proxy:", address(proxy)); vm.stopBroadcast(); } }
This is the same deployment script used for the original deployment on chain A.
-
Run this command to deploy the same contracts on chain B.
forge script script/LabSetup.s.sol --rpc-url $URL_CHAIN_B --broadcast --private-key $PRIVATE_KEY --tc LabSetup
Scroll up and see the Logs section of the output:
== Logs == Token address: 0x5FbDB2315678afecb367f032d93F642f64180aa3 msg.sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 UpgradeableBeacon: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 Proxy: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
Verify that the proxy address is the same as
$ERC20_ADDRESS
, and that the beacon address is the same as$BEACON_ADDRESS
.What to do when the values are not the same
This can happen when the nonce values of
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
(or your address in the case of using devnet) on chain A and chain B are different.You can see the nonce values using these commands:
cast nonce $USER_ADDRESS --rpc-url $URL_CHAIN_A cast nonce $USER_ADDRESS --rpc-url $URL_CHAIN_B
The easiest solution is to send transactions to the chain with the lower nonce until the nonces are equal, and then deploy to both chains.
forge script script/LabSetup.s.sol --rpc-url $URL_CHAIN_A --broadcast --private-key $PRIVATE_KEY --tc LabSetup forge script script/LabSetup.s.sol --rpc-url $URL_CHAIN_B --broadcast --private-key $PRIVATE_KEY --tc LabSetup
If you do this, remember to update
$ERC20_ADDRESS
and$BEACON_ADDRESS
.If the nonce on chain B is already higher than the nonce was on chain A when the original proxy contract was deployed this method is not available and you have to either create a special bridge or use a lockbox.
Deploy ERC7802 contracts
We need to replace the ERC20 contracts with contracts that:
- Support ERC7802 (opens in a new tab) and ERC165 (opens in a new tab).
- Have the same storage layout as the ERC20 contracts they replace.
These contracts do not need to be deployed to the same address. The address that needs to be the same is not the address of the ERC20 contract itself, but of the proxy.
-
Create a file,
src/InteropToken.sol
:src/InteropToken.solpragma solidity ^0.8.28; import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IERC7802, IERC165} from "lib/interop-lib/src/interfaces/IERC7802.sol"; import {PredeployAddresses} from "lib/interop-lib/src/libraries/PredeployAddresses.sol"; contract InteropToken is Initializable, ERC20Upgradeable, OwnableUpgradeable, IERC7802 { function initialize(string memory name, string memory symbol, uint256 initialSupply) public initializer { __ERC20_init(name, symbol); __Ownable_init(msg.sender); _mint(msg.sender, initialSupply); } /// @notice Allows the SuperchainTokenBridge to mint tokens. /// @param _to Address to mint tokens to. /// @param _amount Amount of tokens to mint. function crosschainMint(address _to, uint256 _amount) external { require(msg.sender == PredeployAddresses.SUPERCHAIN_TOKEN_BRIDGE, "Unauthorized"); _mint(_to, _amount); emit CrosschainMint(_to, _amount, msg.sender); } /// @notice Allows the SuperchainTokenBridge to burn tokens. /// @param _from Address to burn tokens from. /// @param _amount Amount of tokens to burn. function crosschainBurn(address _from, uint256 _amount) external { require(msg.sender == PredeployAddresses.SUPERCHAIN_TOKEN_BRIDGE, "Unauthorized"); _burn(_from, _amount); emit CrosschainBurn(_from, _amount, msg.sender); } /// @inheritdoc IERC165 function supportsInterface(bytes4 _interfaceId) public view virtual returns (bool) { return _interfaceId == type(IERC7802).interfaceId || _interfaceId == type(IERC20).interfaceId || _interfaceId == type(IERC165).interfaceId; } }
Detailed explanation
pragma solidity ^0.8.28; import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
Most of the code is identical to the original
MyToken
.import {IERC7802, IERC165} from "lib/interop-lib/src/interfaces/IERC7802.sol"; import {PredeployAddresses} from "lib/interop-lib/src/libraries/PredeployAddresses.sol";
These are the imports needed for ERC7802 support. We need
IERC165
for documentation purposes, andIERC7802
for the ERC7802 events.contract InteropToken is Initializable, ERC20Upgradeable, OwnableUpgradeable, IERC7802 {
We also implement ERC165 (opens in a new tab), but we don't need to import anything from there.
function initialize(string memory name, string memory symbol, uint256 initialSupply) public initializer { __ERC20_init(name, symbol); __Ownable_init(msg.sender); _mint(msg.sender, initialSupply); }
This function is identical to the one in
MyToken
./// @notice Allows the SuperchainTokenBridge to mint tokens. /// @param _to Address to mint tokens to. /// @param _amount Amount of tokens to mint. function crosschainMint(address _to, uint256 _amount) external { require(msg.sender == PredeployAddresses.SUPERCHAIN_TOKEN_BRIDGE, "Unauthorized"); _mint(_to, _amount); emit CrosschainMint(_to, _amount, msg.sender); } /// @notice Allows the SuperchainTokenBridge to burn tokens. /// @param _from Address to burn tokens from. /// @param _amount Amount of tokens to burn. function crosschainBurn(address _from, uint256 _amount) external { require(msg.sender == PredeployAddresses.SUPERCHAIN_TOKEN_BRIDGE, "Unauthorized"); _burn(_from, _amount); emit CrosschainBurn(_from, _amount, msg.sender); }
Standard ERC7802 (opens in a new tab) behavior.
/// @inheritdoc IERC165 function supportsInterface(bytes4 _interfaceId) public view virtual returns (bool) { return _interfaceId == type(IERC7802).interfaceId || _interfaceId == type(IERC20).interfaceId || _interfaceId == type(IERC165).interfaceId; }
Standard ERC165 (opens in a new tab) behavior.
💡Copying the original ERC20 token code with minimal differences is one method to keep the storage layout identical. Alternatively, if you want to use a different contract, such as
SuperchainERC20
, you can modify the storage layout to match the old one using the Solidity docs (opens in a new tab). -
Deploy this contract on both chains, and store the addresses (which may or may not be the same).
ERC7802_A=`forge create InteropToken --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A --broadcast | awk '/Deployed to:/ {print $3}'` ERC7802_B=`forge create InteropToken --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B --broadcast | awk '/Deployed to:/ {print $3}'`
Update proxies
Notify the beacon contracts of the new implementation contracts.
cast send $BEACON_ADDRESS --private-key $PRIVATE_KEY "upgradeTo(address)" $ERC7802_A --rpc-url $URL_CHAIN_A
cast send $BEACON_ADDRESS --private-key $PRIVATE_KEY "upgradeTo(address)" $ERC7802_B --rpc-url $URL_CHAIN_B
Verification
-
See your balance on chain A.
cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei
-
See your balance on chain B.
cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei
-
Transfer 0.1 token.
AMOUNT=`echo 0.1 | cast to-wei` cast send $INTEROP_BRIDGE --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY "sendERC20(address,address,uint256,uint256)" $ERC20_ADDRESS $USER_ADDRESS $AMOUNT `cast chain-id --rpc-url $URL_CHAIN_B`
-
See the new balances. The A chain should have 0.9 tokens, and the B chain should have 0.1 tokens.
cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei
Next steps
- Deploy a SuperchainERC20 to the Superchain
- Learn more about SuperchainERC20
- Build a revolutionary app that uses multiple blockchains within the Superchain