OP Stack
Custom SuperchainERC20 tokens
💡

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.

Custom SuperchainERC20 tokens

Overview

This guide explains how to upgrade an ERC20 to a SuperchainERC20 (opens in a new tab) that can then teleport across the Superchain interop cluster quickly and safely using the SuperchainTokenBridge (opens in a new tab) contract. For more information on how it works, see the explainer.

To ensure fungibility across chains, SuperchainERC20 assets must have the same contract address on all chains. This requirement abstracts away the complexity of cross-chain validation. Achieving this requires deterministic deployment methods. There are many ways to do this (opens in a new tab). Here we will use the SuperchainERC20 Starter Kit.

What you'll do

What you'll learn

How does this work?

To benefit from Superchain Interoperability, an ERC-20 token has to implement ERC-7802. You can either use the SuperchainERC20 implementation, or write your own.

At a high level you will:

Create a basic ERC20 contract

The following code implements a basic ERC20 contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
 
contract MyERC20 is ERC20, Ownable {
    constructor(address owner, string memory name, string memory symbol) ERC20(name, symbol) Ownable(owner) {}
 
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}
 

Add the new SuperchainERC20 interface

The first step is simply to implement IERC7802 and IERC165. Note that we've renamed the contract at this point:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
import {IERC7802} from "@openzeppelin/contracts/token/ERC20/IERC7802.sol";
 
contract MySuperchainERC20 is ERC20, Ownable, IERC7802 {
    error NotSuperchainERC20Bridge();
 
    constructor(address owner, string memory name, string memory symbol) ERC20(name, symbol) Ownable(owner) {}
 
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
 
 
    function supportsInterface(bytes4 _interfaceId) public view virtual returns (bool) {
        return _interfaceId == type(IERC7802).interfaceId || 
               _interfaceId == type(ERC20).interfaceId 
               || _interfaceId == type(ERC165).interfaceId;
    }
}
 

Implement burn and mint functions

There are two functions we need to implement: CrosschainMint and CrosschainBurn. These two functions allow two chains to bridge a token by burning them on one chain and mint them on another. Read more about these functions in our SuperchainERC20 docs.

Here's what our contract looks like once we've implemented the functions:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
import {IERC7802} from "@openzeppelin/contracts/token/ERC20/IERC7802.sol";
 
contract MySuperchainERC20 is ERC20, Ownable, IERC7802 {
    error NotSuperchainERC20Bridge();
 
    constructor(address owner, string memory name, string memory symbol) ERC20(name, symbol) Ownable(owner) {}
 
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
 
    /// @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 {
        if (msg.sender != 0x4200000000000000000000000000000000000028) revert NotSuperchainERC20Bridge();
 
        _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 {
        if (msg.sender != 0x4200000000000000000000000000000000000028) revert NotSuperchainERC20Bridge();
        
        _burn(_from, _amount);
 
        emit CrosschainBurn(_from, _amount, msg.sender);
    }
 
    function supportsInterface(bytes4 _interfaceId) public view virtual returns (bool) {
        return _interfaceId == type(IERC7802).interfaceId || 
               _interfaceId == type(ERC20).interfaceId 
               || _interfaceId == type(ERC165).interfaceId;
    }
}

For more details see the explainer.

Prerequisites

Before starting this tutorial, ensure your development environment meets the following requirements:

Technical knowledge

Development environment

  • Unix-like operating system (Linux, macOS, or WSL for Windows)
  • Git for version control

Required tools

The tutorial uses these primary tools:

  • Foundry: For sending transactions to blockchains.

Step by step explanation

Prepare for deployment

  1. Follow the setup steps in the SuperchainERC20 Starter Kit. Don't start the development environment (step 5).

  2. Follow the deployment preparations steps in the issuing new assets page. Don't deploy the contracts yet.

    Note: Make sure to specify a previously unused value for the salt, for example your address and a timestamp. This is necessary because if the same constructor code is used with the same salt when using the deployment script, it gets the same address, which is a problem if you want a fresh deployment.

Create the custom contract

The easiest way to do this is to copy and modify the L2NativeSuperchainERC20.sol contract. Use this code, for example, as packages/contracts/src/CustomSuperchainToken.sol.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
import {SuperchainERC20} from "./SuperchainERC20.sol";
import {Ownable} from "@solady/auth/Ownable.sol";
 
contract CustomSuperchainToken is SuperchainERC20, Ownable {
    string private _name;
    string private _symbol;
    uint8 private immutable _decimals;
 
    constructor(address owner_, string memory name_, string memory symbol_, uint8 decimals_) {
        _name = name_;
        _symbol = symbol_;
        _decimals = decimals_;
 
        _initializeOwner(owner_);
    }
 
    function name() public view virtual override returns (string memory) {
        return _name;
    }
 
    function symbol() public view virtual override returns (string memory) {
        return _symbol;
    }
 
    function decimals() public view override returns (uint8) {
        return _decimals;
    }
 
    function mintTo(address to_, uint256 amount_) external onlyOwner {
        _mint(to_, amount_);
    }
 
    function faucet() external {
        _mint(msg.sender, 10**_decimals);
    }
}
Explanation
    function faucet() external {
        _mint(msg.sender, 10**_decimals);
    }

This function lets users get tokens for themselves. This token is for testing purposes, so it is useful for users to get their own tokens to run tests.

Deploy the new token

  1. Edit packages/contracts/scripts/SuperchainERC20Deployer.s.sol:

    • Change line 6 to import the new token.

      import {CustomSuperchainToken} from "../src/CustomSuperchainToken.sol";
    • Update lines 52-54 to get the CustomSuperchainToken initialization code.

      bytes memory initCode = abi.encodePacked(
            type(CustomSuperchainToken).creationCode, abi.encode(ownerAddr_, name, symbol, uint8(decimals))
      );
    • Modify line 62 to deploy a CustomSuperchainToken contract.

        addr_ = address(new CustomSuperchainToken{salt: _implSalt()}(ownerAddr_, name, symbol, uint8(decimals)));
  2. Deploy the token contract.

pnpm contracts:deploy:token
Sanity check
  1. Set TOKEN_ADDRESS to the address where the token is deployed. You can also play with the token I created, which is at address 0xF3Ce0794cB4Ef75A902e07e5D2b75E4D71495ee8 (opens in a new tab).

    TOKEN_ADDRESS=0xF3Ce0794cB4Ef75A902e07e5D2b75E4D71495ee8
  2. Source the .env file to get the private key and the address to which it corresponds.

    . packages/contracts/.env
    MY_ADDRESS=`cast wallet address $DEPLOYER_PRIVATE_KEY`
  3. Set variables for the RPC URLs.

    RPC_DEV0=https://interop-alpha-0.optimism.io
    RPC_DEV1=https://interop-alpha-1.optimism.io
  4. Get your current balance (it should be zero).

    cast call --rpc-url $RPC_DEV0 $TOKEN_ADDRESS "balanceOf(address)" $MY_ADDRESS | cast --from-wei
  5. Call the faucet to get a token and check the balance again.

    cast send  --private-key $DEPLOYER_PRIVATE_KEY --rpc-url $RPC_DEV0 $TOKEN_ADDRESS "faucet()"
    cast call --rpc-url $RPC_DEV0 $TOKEN_ADDRESS "balanceOf(address)" $MY_ADDRESS | cast --from-wei

For more details see the explainer.

Next steps