diff --git a/pages/interop/tutorials/_meta.json b/pages/interop/tutorials/_meta.json
index 67816f137..169ee9cd7 100644
--- a/pages/interop/tutorials/_meta.json
+++ b/pages/interop/tutorials/_meta.json
@@ -8,5 +8,6 @@
"relay-messages-viem": "Relaying interop messages using `viem`",
"contract-calls": "Making crosschain contract calls (ping pong)",
"event-reads": "Making crosschain event reads (tic-tac-toe)",
- "event-contests": "Deploying crosschain event composability (contests)"
+ "event-contests": "Deploying crosschain event composability (contests)",
+ "upgrade-to-superchain-erc20": "Upgrade to SuperchainERC20"
}
diff --git a/pages/interop/tutorials/upgrade-to-superchain-erc20/_meta.json b/pages/interop/tutorials/upgrade-to-superchain-erc20/_meta.json
new file mode 100644
index 000000000..4bada62e7
--- /dev/null
+++ b/pages/interop/tutorials/upgrade-to-superchain-erc20/_meta.json
@@ -0,0 +1,3 @@
+{
+ "custom-bridge": "Building a custom bridge"
+}
diff --git a/pages/interop/tutorials/upgrade-to-superchain-erc20/custom-bridge.mdx b/pages/interop/tutorials/upgrade-to-superchain-erc20/custom-bridge.mdx
new file mode 100644
index 000000000..4262d38d6
--- /dev/null
+++ b/pages/interop/tutorials/upgrade-to-superchain-erc20/custom-bridge.mdx
@@ -0,0 +1,348 @@
+---
+title: Building a custom bridge
+lang: en-US
+description: Tutorial on how to create a custom interoperability bridge. The example is a bridge when the addresses of the ERC20 contracts are not the same.
+topic: Interoperability
+personas: [Developer]
+categories: [Tutorial, Interop]
+content_type: article
+---
+
+import { Steps, Callout, Tabs } from 'nextra/components'
+
+# Building a custom bridge
+
+## Overview
+
+Sometimes the address of an ERC20 contract is not available on a different chain.
+This means that the [SuperchainTokenBridge](/interop/superchain-erc20) is not an option.
+However, if the original ERC20 contract is behind a proxy (so we can add [ERC7802](https://eips.ethereum.org/EIPS/eip-7802) support), we can still use interop by writing our own bridge.
+
+
+ About this tutorial
+
+ **What you'll learn**
+
+ * How to use [interop message passing](/interop/tutorials/message-passing) to create a custom bridge.
+
+ **Prerequisite knowledge**
+
+ * How to [deploy SuperchainERC20 tokens with custom code](/interop/tutorials/custom-superchain-erc20).
+ * How to [transfer interop messages](/interop/tutorials/message-passing).
+
+
+
+ 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.
+
+
+{/*
+
+I put this warning here, when we don't have it on most pages, because this tutorial
+has code that is a lot more likely to be used in production. It doesn't just
+show what is possible, it does the exact job needed.
+
+*/}
+
+## How beacon proxies work
+
+```mermaid
+sequenceDiagram
+ Actor User
+ User->>BeaconProxy: transfer(
, )
+ BeaconProxy->>UpgradeableBeacon: What is the implementation address?
+ UpgradeableBeacon->>BeaconProxy: It is 0xBAD0...60A7
+ BeaconProxy->>0xBAD0...60A7: transfer(, )
+```
+
+A [beacon proxy](https://docs.openzeppelin.com/contracts/3.x/api/proxy#BeaconProxy) uses two contracts.
+The [`UpgradeableBeacon`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/beacon/UpgradeableBeacon.sol) contract holds the address of the implementation contract.
+The [`BeaconProxy`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/beacon/BeaconProxy.sol) 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`](https://www.evm.codes/?fork=cancun#f4) to call that contract.
+
+```mermaid
+sequenceDiagram
+ Actor User
+ Actor Owner
+ Participant BeaconProxy
+ Participant 0x600D...60A7
+ Owner->>UpgradeableBeacon: Your new implementation address is 0x600D...60A7
+ User->>BeaconProxy: transfer(, )
+ BeaconProxy->>UpgradeableBeacon: What is the implementation address?
+ UpgradeableBeacon->>BeaconProxy: It is 0x600D...60A7
+ BeaconProxy->>0x600D...60A7: transfer(, )
+```
+
+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](/interop/tools/supersim) or on the [development network](/interop/tools/devnet).
+
+
+ ### Install and run Supersim
+
+ If you are going to use Supersim, [follow these instructions](/app-developers/tutorials/supersim/getting-started/installation) to install and run Supersim.
+
+
+ Make sure to run Supersim with autorelay on.
+
+ ```sh
+ ./supersim --interop.autorelay true
+ ```
+
+
+ ### Set up the ERC20 token on chain A
+
+ Download and run the setup script.
+
+ ```sh
+ 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](/interop/tools/devnet), provide `setup-for-erc20-upgrade.sh` with the private key of an address with ETH on both devnets.
+
+ ```sh
+ ./setup-for-erc20-upgrade.sh
+ ```
+
+ ### 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.
+
+ ```sh
+ BEACON_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
+ export ERC20_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
+ ```
+
+ ### Specify environment variables
+
+ Specify these variables, which we use later:
+
+
+
+ Set these parameters for Supersim.
+
+ ```sh
+ PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
+ USER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
+ URL_CHAIN_A=http://127.0.0.1:9545
+ URL_CHAIN_B=http://127.0.0.1:9546
+ ```
+
+
+
+ For Devnet, specify in `PRIVATE_KEY` the private key you used for the setup script and then these parameters.
+
+ ```sh
+ USER_ADDRESS=`cast wallet address --private-key $PRIVATE_KEY`
+ URL_CHAIN_A=https://interop-alpha-0.optimism.io
+ URL_CHAIN_B=https://interop-alpha-1.optimism.io
+ ```
+
+
+
+ ### 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.
+
+ ```sh
+ 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](https://book.getfoundry.sh/) project and import the [OpenZeppelin](https://www.openzeppelin.com/solidity-contracts) contracts used for the original ERC20 and proxy deployment.
+
+ ```sh
+ 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](https://en.wikipedia.org/wiki/Chicken_or_the_egg) 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](https://specs.optimism.io/protocol/predeploys.html) we can use for this purpose.
+
+ ```sh
+ 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](https://eips.ethereum.org/EIPS/eip-7802) and [ERC165](https://eips.ethereum.org/EIPS/eip-165).
+ * 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`.
+
+ ```solidity file=/public/tutorials/InteropToken.sol hash=5e728534c265028c94d60dcb6550699d filename="src/InteropToken.sol"
+ ```
+
+ 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`.
+
+ ```solidity
+ require(msg.sender == bridgeAddress, "Unauthorized");
+ ```
+
+ * Add these lines anywhere in the contract:
+
+ ```solidity
+ address public immutable bridgeAddress;
+
+ constructor(address bridgeAddress_) {
+ bridgeAddress = bridgeAddress_;
+ }
+ ```
+
+ 3. Deploy `InteropToken` on both chains, with the bridge address.
+
+ ```sh
+ 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.
+
+ ```sh
+ 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`](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/SuperchainTokenBridge.sol).
+
+ ```solidity file=/public/tutorials/CustomBridge.sol hash=fc6cb08b40b7cf5bfb2b0c5793e4795e filename="src/CustomBridge.sol"
+ ```
+
+
+ Explanation
+
+ These are the main differences between the generic bridge and our implementation.
+
+ ```solidity file=/public/tutorials/CustomBridge.sol#L14-L18 hash=fd42623685f26046337ea6d105f27e4a
+ ```
+
+ The configuration is [`immutable`](https://docs.soliditylang.org/en/latest/contracts.html#immutable).
+ 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.
+
+ ```solidity file=/public/tutorials/CustomBridge.sol#L48-L65 hash=87edfdbb21fe11f573eff932d6f36d82
+ ```
+
+ The constructor writes the configuration parameters.
+
+ ```solidity file=/public/tutorials/CustomBridge.sol#L72-L75 hash=01ac207764b0d944ec4a005c7c3f01a8
+ ```
+
+ We don't need to specify the token address, or the chain ID on the other side, because they are hardwired in this bridge.
+
+ ```solidity file=/public/tutorials/CustomBridge.sol#L86 hash=d4ccbac2c6ce01bce65bc75242741f6e
+ ```
+
+ Emit the same log entry that would be emitted by the standard bridge.
+
+ ```solidity file=/public/tutorials/CustomBridge.sol#L100-L101 hash=142a487e877cc2cbeba8e07fce451c88
+ ```
+
+ Make sure any relay requests come from the correct contract on the correct chain.
+
+
+ 2. Get the chainID values.
+
+ ```sh
+ 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.
+
+ ```sh
+ 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.
+
+ ```sh
+ 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.
+
+ ```sh
+ cast call $ERC20_ADDRESS_A "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_A | cast from-wei
+ ```
+
+ 2. See your balance on chain B.
+
+ ```sh
+ cast call $ERC20_ADDRESS_B "balanceOf(address)" $USER_ADDRESS --rpc-url $URL_CHAIN_B | cast from-wei
+ ```
+
+ 3. Transfer 0.1 token.
+
+ ```sh
+ 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.
+
+ ```sh
+ 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
+
+* Deploy a [SuperchainERC20](/interop/tutorials/deploy-superchain-erc20) to the Superchain
+* [Learn more about SuperchainERC20](/interop/superchain-erc20)
+* Build a [revolutionary app](/app-developers/get-started) that uses multiple blockchains within the Superchain
diff --git a/public/tutorials/CustomBridge.sol b/public/tutorials/CustomBridge.sol
new file mode 100644
index 000000000..a9c47996a
--- /dev/null
+++ b/public/tutorials/CustomBridge.sol
@@ -0,0 +1,107 @@
+// 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);
+ }
+}
\ No newline at end of file
diff --git a/public/tutorials/InteropToken.sol b/public/tutorials/InteropToken.sol
new file mode 100644
index 000000000..e48ed7311
--- /dev/null
+++ b/public/tutorials/InteropToken.sol
@@ -0,0 +1,44 @@
+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;
+ }
+}
+
diff --git a/public/tutorials/setup-for-erc20-upgrade.sh b/public/tutorials/setup-for-erc20-upgrade.sh
new file mode 100644
index 000000000..fdfcd0c37
--- /dev/null
+++ b/public/tutorials/setup-for-erc20-upgrade.sh
@@ -0,0 +1,77 @@
+#! /bin/sh
+
+rm -rf upgrade-erc20 || { echo "Failed to remove directory"; exit 1; }
+mkdir upgrade-erc20 || { echo "Failed to create directory"; exit 1; }
+cd upgrade-erc20 || { echo "Failed to change directory"; exit 1; }
+
+if [ -z $1 ]
+then
+ echo Supersim
+ # This is a well-known development private key – never use in production
+ PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
+ URL_CHAIN_A=http://localhost:9545
+ URL_CHAIN_B=http://localhost:9546
+else
+ echo Devnet
+ PRIVATE_KEY=$1
+ URL_CHAIN_A=https://interop-alpha-0.optimism.io
+ URL_CHAIN_B=https://interop-alpha-1.optimism.io
+fi
+
+USER_ADDRESS=`cast wallet address --private-key $PRIVATE_KEY`
+
+forge init
+forge install OpenZeppelin/openzeppelin-contracts-upgradeable
+
+cat > script/LabSetup.s.sol <