Interoperability
Upgrade to SuperchainERC20
Building a custom bridge

Building a custom bridge

Overview

Sometimes the address of an ERC20 contract is not available on a different chain. This means that the SuperchainTokenBridge is not an option. However, if the original ERC20 contract is behind a proxy (so we can add ERC7802 (opens in a new tab) support), we can still use interop by writing our own bridge.

About this tutorial

What you'll learn

Prerequisite knowledge

⚠️

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.

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

Set up 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

Advance the user's nonce on chain B

This solution is necessary when the nonce on chain B is higher than it was on chain A when the proxy for the ERC-20 contract was installed. To simulate this situation, we send a few meaningless transactions on chain B and then see that the nonce on B is higher than the nonce on A.

cast send $USER_ADDRESS --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B
cast send $USER_ADDRESS --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B
cast send $USER_ADDRESS --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B
cast send $USER_ADDRESS --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B
echo -n Nonce on chain A:
cast nonce $USER_ADDRESS --rpc-url $URL_CHAIN_A
echo -n Nonce on chain B:
cast nonce $USER_ADDRESS --rpc-url $URL_CHAIN_B

Create a Foundry project

Create a Foundry (opens in a new tab) project and import the OpenZeppelin (opens in a new tab) contracts used for the original ERC20 and proxy deployment.

mkdir custom-bridge
cd custom-bridge
forge init
forge install OpenZeppelin/openzeppelin-contracts
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
forge install ethereum-optimism/interop-lib

Deploy proxies

We need two contracts on each chain: an ERC20 and a bridge, and to enable future upgrades, we want to install each of those contracts behind a proxy. You already have one contract—the ERC20 on chain A—but need to create the other three.

There's an interesting chicken-and-egg (opens in a new tab) issue here. To create a proxy, we need the address of the implementation contract, the one with the actual code. However, the bridge and ERC20 code needs to have the proxy addresses. One possible solution is to choose a pre-existing contract, and use that as the implementation contract until we can upgrade. Every OP Stack chain has predeploys (opens in a new tab) we can use for this purpose.

DUMMY_ADDRESS=0x4200000000000000000000000000000000000000
UPGRADE_BEACON_CONTRACT=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/beacon/UpgradeableBeacon.sol:UpgradeableBeacon
PROXY_CONTRACT=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/beacon/BeaconProxy.sol:BeaconProxy
BRIDGE_BEACON_ADDRESS_A=`forge create $UPGRADE_BEACON_CONTRACT --broadcast --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --constructor-args $DUMMY_ADDRESS $USER_ADDRESS | awk '/Deployed to:/ {print $3}'`
BRIDGE_ADDRESS_A=`forge create $PROXY_CONTRACT --broadcast --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --constructor-args $BRIDGE_BEACON_ADDRESS_A 0x | awk '/Deployed to:/ {print $3}'`
BRIDGE_BEACON_ADDRESS_B=`forge create $UPGRADE_BEACON_CONTRACT --broadcast --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY --constructor-args $DUMMY_ADDRESS $USER_ADDRESS | awk '/Deployed to:/ {print $3}'`
BRIDGE_ADDRESS_B=`forge create $PROXY_CONTRACT --broadcast --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY --constructor-args $BRIDGE_BEACON_ADDRESS_B 0x | awk '/Deployed to:/ {print $3}'`
ERC20_BEACON_ADDRESS_A=$BEACON_ADDRESS
ERC20_ADDRESS_A=$ERC20_ADDRESS
ERC20_BEACON_ADDRESS_B=`forge create $UPGRADE_BEACON_CONTRACT --broadcast --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY --constructor-args $DUMMY_ADDRESS $USER_ADDRESS | awk '/Deployed to:/ {print $3}'`
ERC20_ADDRESS_B=`forge create $PROXY_CONTRACT --broadcast --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY --constructor-args $ERC20_BEACON_ADDRESS_B 0x | awk '/Deployed to:/ {print $3}'`  

Deploy ERC7802 contracts

Replace the ERC20 contracts with contracts that:

  • Support ERC7802 (opens in a new tab) and ERC165 (opens in a new tab).
  • Allow the bridge address to mint and burn tokens. Normally this is PredeployAddresses.SUPERCHAIN_TOKEN_BRIDGE, but in our case it would be the bridge proxy address, which we'll store in bridgeAddress.
  • Have the same storage layout as the ERC20 contracts they replace (in the case of chain A).
  1. Create a file, src/InteropToken.sol.

    src/InteropToken.sol
    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";
    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;
        }
    }
     
  2. This src/InteropToken.sol is used for contract upgrades when the ERC20 contracts are at the same address. Here we need to edit it to allow our custom bridge to mint and burn tokens instead of the predeployed superchain token bridge.

    • On lines 20 and 31 replace PredeployAddresses.SUPERCHAIN_TOKEN_BRIDGE with bridgeAddress.

      require(msg.sender == bridgeAddress, "Unauthorized");
    • Add these lines anywhere in the contract:

          address public immutable bridgeAddress;
       
          constructor(address bridgeAddress_) {
              bridgeAddress = bridgeAddress_;
          }
  3. Deploy InteropToken on both chains, with the bridge address.

    INTEROP_TOKEN_A=`forge create InteropToken --private-key $PRIVATE_KEY --broadcast --rpc-url $URL_CHAIN_A --constructor-args $BRIDGE_ADDRESS_A | awk '/Deployed to:/ {print $3}'`
    INTEROP_TOKEN_B=`forge create InteropToken --private-key $PRIVATE_KEY --broadcast --rpc-url $URL_CHAIN_B --constructor-args $BRIDGE_ADDRESS_B | awk '/Deployed to:/ {print $3}'`
  4. Update the proxies to the new implementations.

    cast send $ERC20_BEACON_ADDRESS_A "upgradeTo(address)" $INTEROP_TOKEN_A --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A
    cast send $ERC20_BEACON_ADDRESS_B "upgradeTo(address)" $INTEROP_TOKEN_B --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B

Deploy the actual bridge

  1. Create a file, src/CustomBridge.sol. This file is based on the standard SuperchainERC20 SuperchainTokenBridge.sol (opens in a new tab).

    src/CustomBridge.sol
    // SPDX-License-Identifier: MIT
    pragma solidity 0.8.25;
     
    // Libraries
    import { PredeployAddresses } from "interop-lib/src/libraries/PredeployAddresses.sol";
     
    // Interfaces
    import { IERC7802, IERC165 } from "interop-lib/src/interfaces/IERC7802.sol";
    import { IL2ToL2CrossDomainMessenger } from "interop-lib/src/interfaces/IL2ToL2CrossDomainMessenger.sol";
     
    /// @custom:proxied true
    /// @title CustomBridge
    contract CustomBridge {
        // Immutable configuration
        address public immutable tokenAddressHere;
        address public immutable tokenAddressThere;
        uint256 public immutable chainIdThere;
        address public immutable bridgeAddressThere;
     
        error ZeroAddress();
        error Unauthorized();
     
        /// @notice Thrown when attempting to relay a message and the cross domain message sender is not the
        /// SuperchainTokenBridge.
        error InvalidCrossDomainSender();
     
        /// @notice Emitted when tokens are sent from one chain to another.
        /// @param token         Address of the token sent.
        /// @param from          Address of the sender.
        /// @param to            Address of the recipient.
        /// @param amount        Number of tokens sent.
        /// @param destination   Chain ID of the destination chain.
        event SendERC20(
            address indexed token, address indexed from, address indexed to, uint256 amount, uint256 destination
        );
     
        /// @notice Emitted whenever tokens are successfully relayed on this chain.
        /// @param token         Address of the token relayed.
        /// @param from          Address of the msg.sender of sendERC20 on the source chain.
        /// @param to            Address of the recipient.
        /// @param amount        Amount of tokens relayed.
        /// @param source        Chain ID of the source chain.
        event RelayERC20(address indexed token, address indexed from, address indexed to, uint256 amount, uint256 source);
     
        /// @notice Address of the L2ToL2CrossDomainMessenger Predeploy.
        address internal constant MESSENGER = PredeployAddresses.L2_TO_L2_CROSS_DOMAIN_MESSENGER;
     
        // Setup the configuration
        constructor(
            address tokenAddressHere_,
            address tokenAddressThere_,
            uint256 chainIdThere_,
            address bridgeAddressThere_        
        ) {
            if (
                tokenAddressHere_ == address(0) ||
                tokenAddressThere_ == address(0) ||
                bridgeAddressThere_ == address(0)
            ) revert ZeroAddress();
     
            tokenAddressHere = tokenAddressHere_;
            tokenAddressThere = tokenAddressThere_;
            chainIdThere = chainIdThere_;
            bridgeAddressThere = bridgeAddressThere_;
        }    
     
        /// @notice Sends tokens to a target address on another chain.
        /// @dev Tokens are burned on the source chain.
        /// @param _to       Address to send tokens to.
        /// @param _amount   Amount of tokens to send.
        /// @return msgHash_ Hash of the message sent.
        function sendERC20(
            address _to,
            uint256 _amount
        )
            external
            returns (bytes32 msgHash_)
        {
            if (_to == address(0)) revert ZeroAddress();
     
            IERC7802(tokenAddressHere).crosschainBurn(msg.sender, _amount);
     
            bytes memory message = abi.encodeCall(this.relayERC20, (msg.sender, _to, _amount));
            msgHash_ = IL2ToL2CrossDomainMessenger(MESSENGER).sendMessage(chainIdThere, bridgeAddressThere, message);
     
            emit SendERC20(tokenAddressHere, msg.sender, _to, _amount, chainIdThere);
        }
     
        /// @notice Relays tokens received from another chain.
        /// @dev Tokens are minted on the destination chain.
        /// @param _from    Address of the msg.sender of sendERC20 on the source chain.
        /// @param _to      Address to relay tokens to.
        /// @param _amount  Amount of tokens to relay.
        function relayERC20(address _from, address _to, uint256 _amount) external {
            if (msg.sender != MESSENGER) revert Unauthorized();
     
            (address crossDomainMessageSender, uint256 source) =
                IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageContext();
     
            if (crossDomainMessageSender != bridgeAddressThere) revert InvalidCrossDomainSender();
            if (source != chainIdThere) revert InvalidCrossDomainSender();
     
            IERC7802(tokenAddressHere).crosschainMint(_to, _amount);
     
            emit RelayERC20(tokenAddressHere, _from, _to, _amount, chainIdThere);
        }
    }
    Explanation

    These are the main differences between the generic bridge and our implementation.

        // Immutable configuration
        address public immutable tokenAddressHere;
        address public immutable tokenAddressThere;
        uint256 public immutable chainIdThere;
        address public immutable bridgeAddressThere;
     

    The configuration is immutable (opens in a new tab). We are deploying the contract behind a proxy, so if we need to change it we can deploy a different contract.

    These parameters assume there are only two chains in the interop cluster. If there are more, change the There variables to array.

        // Setup the configuration
        constructor(
            address tokenAddressHere_,
            address tokenAddressThere_,
            uint256 chainIdThere_,
            address bridgeAddressThere_        
        ) {
            if (
                tokenAddressHere_ == address(0) ||
                tokenAddressThere_ == address(0) ||
                bridgeAddressThere_ == address(0)
            ) revert ZeroAddress();
     
            tokenAddressHere = tokenAddressHere_;
            tokenAddressThere = tokenAddressThere_;
            chainIdThere = chainIdThere_;
            bridgeAddressThere = bridgeAddressThere_;
        }    
     

    The constructor writes the configuration parameters.

        function sendERC20(
            address _to,
            uint256 _amount
        )
     

    We don't need to specify the token address, or the chain ID on the other side, because they are hardwired in this bridge.

            emit SendERC20(tokenAddressHere, msg.sender, _to, _amount, chainIdThere);
     

    Emit the same log entry that would be emitted by the standard bridge.

            if (crossDomainMessageSender != bridgeAddressThere) revert InvalidCrossDomainSender();
            if (source != chainIdThere) revert InvalidCrossDomainSender();
     

    Make sure any relay requests come from the correct contract on the correct chain.

  2. Get the chainID values.

    CHAINID_A=`cast chain-id --rpc-url $URL_CHAIN_A`
    CHAINID_B=`cast chain-id --rpc-url $URL_CHAIN_B`
  3. Deploy the bridges with the correct configuration.

    BRIDGE_IMPLEMENTATION_ADDRESS_A=`forge create CustomBridge --broadcast --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --constructor-args  $ERC20_ADDRESS_A $ERC20_ADDRESS_B $CHAINID_B $BRIDGE_ADDRESS_B | awk '/Deployed to:/ {print $3}'`
    BRIDGE_IMPLEMENTATION_ADDRESS_B=`forge create CustomBridge --broadcast --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY --constructor-args  $ERC20_ADDRESS_B $ERC20_ADDRESS_A $CHAINID_A $BRIDGE_ADDRESS_A | awk '/Deployed to:/ {print $3}'`      
  4. Inform the proxy beacons about the new addresses of the bridge implementation contracts.

    cast send $BRIDGE_BEACON_ADDRESS_A "upgradeTo(address)" $BRIDGE_IMPLEMENTATION_ADDRESS_A --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A
    cast send $BRIDGE_BEACON_ADDRESS_B "upgradeTo(address)" $BRIDGE_IMPLEMENTATION_ADDRESS_B --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B

Verification

  1. See your balance on chain A.

    cast call $ERC20_ADDRESS_A "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei
  2. See your balance on chain B.

    cast call $ERC20_ADDRESS_B "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei
  3. Transfer 0.1 token.

    AMOUNT=`echo 0.1 | cast to-wei`
    cast send $BRIDGE_ADDRESS_A --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY "sendERC20(address,uint256)" $USER_ADDRESS $AMOUNT
  4. 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_A "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei
    cast call $ERC20_ADDRESS_B "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei

Next steps