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.
Lockboxes for permissionless interop
Overview
The lockbox is a smart contract that accepts deposits of the original ERC-20 and issues an equivalent amount of tokens that are Superchain interop compatible. Users can unwrap their Superchain interop token at any time by returning it to the contract, which burns the Superchain interop tokens and releases the corresponding original ERC-20 from the lockbox.
About this tutorial
What you'll learn
- how to take permissionlessly create a lockbox contract to enable Superchain interoperability.
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
Create a lockbox SuperchainERC20
contract to enable interoperability for an ERC20 contract without permission from the original ERC20 deployer.
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 ERC-20 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 ERC-20 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
-
Regardless of whether you use Supersim or Devnet, specify these variables.
INTEROP_BRIDGE=0x4200000000000000000000000000000000000028 export ERC20_CHAINID=`cast chain-id --rpc-url $URL_CHAIN_A` ORIGINAL_TOKEN_NAME=`cast call $ERC20_ADDRESS "name()" --rpc-url $URL_CHAIN_A | cast to-ascii` export NEW_TOKEN_NAME="$ORIGINAL_TOKEN_NAME Lockbox" ORIGINAL_TOKEN_SYMBOL=`cast call $ERC20_ADDRESS "symbol()" --rpc-url $URL_CHAIN_A | cast to-ascii` export NEW_TOKEN_SYMBOL="$ORIGINAL_TOKEN_SYMBOL-L" export TOKEN_DECIMALS=`cast call $ERC20_ADDRESS "decimals()" --rpc-url $URL_CHAIN_A | cast to-dec`
Update the deployment utilities
The new SuperchainERC20
variant is called LockboxSuperchainERC20
, and it requires different constructor parameters.
To be able to deploy it, we need to modify some of the deployment utilities.
-
Download the SuperchainERC20 starter kit, and install libraries, etc.
git clone https://github.com/ethereum-optimism/superchainerc20-starter.git cd superchainerc20-starter pnpm install pnpm init:env
-
Replace
packages/contracts/package.json
with this code:packages/contracts/package.json{ "name": "@superchainerc20-starter/contracts", "main": "index.js", "scripts": { "deploy:dev": "env-cmd -f .env cross-env-shell 'wait-port http://:8420/ready && forge script scripts/SuperchainERC20Deployer.s.sol --broadcast --private-key $DEPLOYER_PRIVATE_KEY'", "deploy:token": "env-cmd -f .env cross-env-shell 'forge script scripts/LockboxDeployer.s.sol --broadcast --private-key $DEPLOYER_PRIVATE_KEY'", "update:rpcs": "cd ../.. && ./scripts/fetch-superchain-rpc-urls.sh", "install": "forge install", "build": "forge build", "test": "forge test", "init:env": "cp .env.example .env" }, "dependencies": { "viem": "^2.21.37" } }
-
Create a new file,
packages/contracts/scripts/LockboxDeployer.s.sol
:packages/contracts/scripts/LockboxDeployer.s.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.25; import {Script, console} from "forge-std/Script.sol"; import {Vm} from "forge-std/Vm.sol"; import {LockboxSuperchainERC20} from "../src/LockboxSuperchainERC20.sol"; contract LockboxDeployer is Script { string deployConfig; uint256 timestamp; constructor() { string memory deployConfigPath = vm.envOr("DEPLOY_CONFIG_PATH", string("/configs/deploy-config.toml")); string memory filePath = string.concat(vm.projectRoot(), deployConfigPath); deployConfig = vm.readFile(filePath); timestamp = vm.unixTime(); } /// @notice Modifier that wraps a function in broadcasting. modifier broadcast() { vm.startBroadcast(msg.sender); _; vm.stopBroadcast(); } function setUp() public {} function run() public { string[] memory chainsToDeployTo = vm.parseTomlStringArray(deployConfig, ".deploy_config.chains"); address deployedAddress; for (uint256 i = 0; i < chainsToDeployTo.length; i++) { string memory chainToDeployTo = chainsToDeployTo[i]; console.log("Deploying to chain: ", chainToDeployTo); vm.createSelectFork(chainToDeployTo); address _deployedAddress = deployLockboxSuperchainERC20(); deployedAddress = _deployedAddress; } outputDeploymentResult(deployedAddress); } function deployLockboxSuperchainERC20() public broadcast returns (address addr_) { string memory name = vm.envString("NEW_TOKEN_NAME"); string memory symbol = vm.envString("NEW_TOKEN_SYMBOL"); uint256 decimals = vm.envUint("TOKEN_DECIMALS"); require(decimals <= type(uint8).max, "decimals exceeds uint8 range"); address originalTokenAddress = vm.envAddress("ERC20_ADDRESS"); uint256 originalChainId = vm.envUint("ERC20_CHAINID"); bytes memory initCode = abi.encodePacked( type(LockboxSuperchainERC20).creationCode, abi.encode(name, symbol, uint8(decimals), originalTokenAddress, originalChainId) ); address preComputedAddress = vm.computeCreate2Address(_implSalt(), keccak256(initCode)); if (preComputedAddress.code.length > 0) { console.log( "There is already a contract at %s", preComputedAddress, "on chain id: ", block.chainid ); addr_ = preComputedAddress; } else { addr_ = address(new LockboxSuperchainERC20{salt: _implSalt()}( name, symbol, uint8(decimals), originalTokenAddress, originalChainId)); console.log("Deployed LockboxSuperchainERC20 at address: ", addr_, "on chain id: ", block.chainid); } } function outputDeploymentResult(address deployedAddress) public { console.log("Outputting deployment result"); string memory obj = "result"; string memory jsonOutput = vm.serializeAddress(obj, "deployedAddress", deployedAddress); vm.writeJson(jsonOutput, "deployment.json"); } /// @notice The CREATE2 salt to be used when deploying the token. function _implSalt() internal view returns (bytes32) { string memory salt = vm.parseTomlString(deployConfig, ".deploy_config.salt"); return keccak256(abi.encodePacked(salt, timestamp)); } }
Explanation of the modified functions
For the most part, this is the standard
SuperchainERC20Deployer.s.sol
that comes with the SuperchainERC20 starter kit. Some functions are modified, as explained below.function deployLockboxSuperchainERC20() public broadcast returns (address addr_) { string memory name = vm.envString("NEW_TOKEN_NAME"); string memory symbol = vm.envString("NEW_TOKEN_SYMBOL"); uint256 decimals = vm.envUint("TOKEN_DECIMALS"); require(decimals <= type(uint8).max, "decimals exceeds uint8 range"); address originalTokenAddress = vm.envAddress("ERC20_ADDRESS"); uint256 originalChainId = vm.envUint("ERC20_CHAINID");
Get the majority of the configuration from the environment. Mostly of it is derived from the configuration of the original ERC-20 token.
Note that there is no
owner
here. ThisSuperchainERC20
contract does not need an owner, because minting and burning are handled by the users themselves (by locking and unlocking the original tokens).bytes memory initCode = abi.encodePacked( type(LockboxSuperchainERC20).creationCode, abi.encode(name, symbol, uint8(decimals), originalTokenAddress, originalChainId) ); address preComputedAddress = vm.computeCreate2Address(_implSalt(), keccak256(initCode)); if (preComputedAddress.code.length > 0) { console.log( "There is already a contract at %s", preComputedAddress, "on chain id: ", block.chainid ); addr_ = preComputedAddress; } else { addr_ = address(new LockboxSuperchainERC20{salt: _implSalt()}( name, symbol, uint8(decimals), originalTokenAddress, originalChainId)); console.log("Deployed LockboxSuperchainERC20 at address: ", addr_, "on chain id: ", block.chainid); } }
"Manually" calculate the address that
CREATE2
(opens in a new tab) will give us.
If there is already a contract there, we have a problem. Otherwise, deployLockboxSuperchainERC20
./// @notice The CREATE2 salt to be used when deploying the token. function _implSalt() internal view returns (bytes32) { string memory salt = vm.parseTomlString(deployConfig, ".deploy_config.salt"); return keccak256(abi.encodePacked(salt, timestamp)); }
I modified this salt function to include a timestamp (obtained using
vm.unixTime()
in the constructor). This is not necessary, but I consider it a developer experience improvement. During development you redeploy slightly modified code a lot of times. It is easier if you don't need to manually change the salt every time.⚠️Remove this before deploying to production. Otherwise, as new blockchains join the Interop cluster, you may not be able to deploy your contract at the same address.
Create and deploy the new contract
-
Create this file in
packages/contracts/src/LockboxSuperchainERC20.sol
:packages/contracts/src/LockboxSuperchainERC20.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.25; import {SuperchainERC20} from "./SuperchainERC20.sol"; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; contract LockboxSuperchainERC20 is SuperchainERC20 { string private _name; string private _symbol; uint8 private immutable _decimals; address immutable _originalTokenAddress; uint256 immutable _originalChainId; constructor( string memory name_, string memory symbol_, uint8 decimals_, address originalTokenAddress_, uint256 originalChainId_) { require(originalTokenAddress_ != address(0), "Invalid token address"); require(originalChainId_ != 0, "Invalid chain ID"); _name = name_; _symbol = symbol_; _decimals = decimals_; _originalTokenAddress = originalTokenAddress_; _originalChainId = originalChainId_; } 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 originalTokenAddress() public view returns (address) { return _originalTokenAddress; } function originalChainId() public view returns (uint256) { return _originalChainId; } function lockAndMint(uint256 amount_) external { IERC20 originalToken = IERC20(_originalTokenAddress); require(block.chainid == _originalChainId, "Wrong chain"); bool success = originalToken.transferFrom(msg.sender, address(this), amount_); // Not necessariy if the ERC-20 contract reverts rather than reverting. // However, the standard allows the ERC-20 contract to return false instead. require(success, "No tokens to lock, no mint either"); _mint(msg.sender, amount_); } function redeemAndBurn(uint256 amount_) external { IERC20 originalToken = IERC20(_originalTokenAddress); require(block.chainid == _originalChainId, "Wrong chain"); _burn(msg.sender, amount_); bool success = originalToken.transfer(msg.sender, amount_); require(success, "Transfer failed, this should not happen"); } }
Explanation
address immutable _originalTokenAddress; uint256 immutable _originalChainId;
The lockbox contract needs to know the contract for which it is a lockbox. This requires not just the address, but also to know what chain has it.
} function lockAndMint(uint256 amount_) external { IERC20 originalToken = IERC20(_originalTokenAddress); require(block.chainid == _originalChainId, "Wrong chain"); bool success = originalToken.transferFrom(msg.sender, address(this), amount_); // Not necessariy if the ERC-20 contract reverts rather than reverting. // However, the standard allows the ERC-20 contract to return false instead. require(success, "No tokens to lock, no mint either");
Users call this function to transfer original tokens to the contract and mint themselves an equivalent number of lockbox tokens. This function has several tests to make sure it can be called.
- Check the chain ID. Locking and redeeming tokens can only be done on the original token's chain.
- Use
transferFrom
(opens in a new tab) to transfer the tokens to ourselves. This call typically reverts when it fails, but it can also returnfalse
. In that case, we revert. There are two reasons it may fail.- The user (in this case, the
LockboxSuperchainERC20
contract) does not have the allowance (opens in a new tab) to spend that amount of tokens from the original owner (msg.sender
). - The original owner (
msg.sender
) does not have enough tokens to transfer.
- The user (in this case, the
If the tests are successful, mint the requested amount for
msg.sender
.} function redeemAndBurn(uint256 amount_) external { IERC20 originalToken = IERC20(_originalTokenAddress); require(block.chainid == _originalChainId, "Wrong chain"); _burn(msg.sender, amount_); bool success = originalToken.transfer(msg.sender, amount_);
Users call this function to redeem their existing lockbox tokens and replace them with the original tokens. It also has multiple tests.
- Again, check chain ID.
- Try to
_burn
the amount of lockbox tokens. The solady_burn
function (opens in a new tab), the one we inherit fromSuperchainERC20
, reverts if the user does not have enough tokens to burn. - Transfer the amount of the original ERC-20 redeemed to the caller. This should never fail, because lockbox ERC-20 tokens are supposed to always be backed by an equal number of the original tokens. However, if it does fail for some reason, revert.
-
Actually deploy the contract.
pnpm contracts:deploy:token
-
Get the new token address and store it in an environment variable.
NEW_TOKEN_ADDRESS=`cat packages/contracts/broadcast/multi/LockboxDeployer.s.sol-latest/run.json | awk '/contractAddress/ {print $2}' | head -1 | sed 's/[",]//g'`
Verification
-
Check that the user has a single token of the original ERC-20.
cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei
-
Lock a quarter token in the lockbox ERC-20 contract. To do this we first need to give the lockbox ERC-20 contract an allowance and then call it.
QUARTER_TOKEN=`echo 0.25 | cast to-wei` cast send $ERC20_ADDRESS "approve(address,uint256)" $NEW_TOKEN_ADDRESS $QUARTER_TOKEN --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A cast send $NEW_TOKEN_ADDRESS "lockAndMint(uint256)" $QUARTER_TOKEN --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A
-
See the balances of the user, both original and lockbox, and the balance of the lockbox contract itself.
cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei cast call $NEW_TOKEN_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei cast call $ERC20_ADDRESS "balanceOf(address)" $NEW_TOKEN_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei
-
Transfer 0.1 token to chain B.
TENTH_TOKEN=`echo 0.1 | cast to-wei` cast send $INTEROP_BRIDGE --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY "sendERC20(address,address,uint256,uint256)" $NEW_TOKEN_ADDRESS $USER_ADDRESS $TENTH_TOKEN `cast chain-id --rpc-url $URL_CHAIN_B`
-
See the user's balances on both chains.
cast call $NEW_TOKEN_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei cast call $NEW_TOKEN_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei
-
Specify the configuration for another user.
USER_ADDRESS_2=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC PRIVATE_KEY_2=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
-
Transfer new tokens to the new user (on chain B) and see that they were actually transferred.
cast send $NEW_TOKEN_ADDRESS "transfer(address,uint256)" $USER_ADDRESS_2 $TENTH_TOKEN --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B cast call $NEW_TOKEN_ADDRESS "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei cast call $NEW_TOKEN_ADDRESS "balanceOf(address)" $USER_ADDRESS_2 --rpc-url $URL_CHAIN_B | cast from-wei
-
As the new user, transfer tokens back to chain A and redeem them.
cast send $INTEROP_BRIDGE --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY_2 "sendERC20(address,address,uint256,uint256)" $NEW_TOKEN_ADDRESS $USER_ADDRESS_2 $TENTH_TOKEN `cast chain-id --rpc-url $URL_CHAIN_A` cast send $NEW_TOKEN_ADDRESS --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY_2 "redeemAndBurn(uint256)" $TENTH_TOKEN
-
See that the second user does not have any more of the new tokens, but does have the original token.
cast call $NEW_TOKEN_ADDRESS "balanceOf(address)" $USER_ADDRESS_2 --rpc-url $URL_CHAIN_A | cast from-wei cast call $ERC20_ADDRESS "balanceOf(address)" $USER_ADDRESS_2 --rpc-url $URL_CHAIN_A | 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