Skip to Content

Foundry

In this tutorial, you'll create, deploy, and interact with a cross-chain token using the Analog OATS standard. You will write and deploy a smart contract that burns tokens on Arbitrum Sepolia and, using the OATS protocol, mint an equivalent amount on Ethereum Sepolia.

Learning Objectives

  • Set up a Foundry project for development on Arbitrum and Ethereum Sepolia.
  • Install and manage Analog GMP & OATS as project dependencies.
  • Implement a burn-and-mint model for cross-chain token transfers.
  • Deploy and test your token on live testnets.

Prerequisites

Before you begin, you will need:

  • Foundry: Follow these steps  to install it.
  • Node.js: Required for managing project dependencies.
  • Wallet & Testnet Funds: A wallet like MetaMask funded with both Arbitrum Sepolia ETH and Ethereum Sepolia ETH from public faucets.
  • RPC URLs: Endpoints for Arbitrum Sepolia and Ethereum Sepolia from a node provider like Alchemy or Infura.

Step 1: Initialize Your Foundry Project

Create a new directory for your project and initialize it with Foundry.

mkdir analog-oats-tutorial cd analog-oats-tutorial forge init

This creates the standard Foundry project structure (src/, lib/, test/).


Step 2: Install Dependencies

Next, install the required OATS and OpenZeppelin contracts using forge.

# Install OpenZeppelin contracts forge install OpenZeppelin/openzeppelin-contracts # Install Analog OATS and GMP contracts forge install Analog-Labs/oats forge install Analog-Labs/analog-gmp

If the forge install commands fail, you can use git submodules as a fallback: git submodule add https://github.com/Analog-Labs/oats lib/oats git submodule add https://github.com/Analog-Labs/analog-gmp lib/analog-gmp

After installing the libraries, open foundry.toml and add the following remappings so the compiler can locate the dependencies:

[profile.default] src = "src" out = "out" libs = ["lib"] remappings = [ '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/', 'oats/=lib/oats/src/', 'gmp/=lib/analog-gmp/src/' ]

Step 3: Create the Smart Contract

Create a new file at src/CrossChainOats.sol. This contract will:

  • Inherit from OpenZeppelin's ERC20Burnable and ERC20Capped.
  • Implement the ISender and IGmpReceiver interfaces.
  • Burn tokens to initiate a cross-chain transfer.
  • Receive messages to mint tokens on the destination chain.

Copy the following code into src/CrossChainOats.sol:

CrossChainOats.sol

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {ISender, Utils} from "oats/IOATS.sol"; import {IGateway} from "gmp/IGateway.sol"; import {IGmpReceiver} from "gmp/IGmpReceiver.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; /// @title CrossChainOats /// @notice An OATS-compliant token for cross-chain transfers using a burn/mint model. contract CrossChainOats is ISender, IGmpReceiver, Ownable, ERC20Burnable, ERC20Capped { IGateway private immutable _gateway; mapping(uint16 => address) public networks; uint64 private constant GAS_LIMIT = 100_000; struct TransferCmd { address from; address to; uint256 amount; } constructor(string memory name, string memory symbol, uint256 cap, address gateway) ERC20(name, symbol) Ownable(msg.sender) ERC20Capped(cap) { _gateway = IGateway(gateway); } /// @notice Links this contract to its counterpart on another network. function setNetwork(uint16 networkId, address token) public onlyOwner { networks[networkId] = token; } /// @inheritdoc ISender function cost(uint16 networkId) external view returns (uint256) { // Corresponds to the encoded TransferCmd struct (32 * 3 = 96 bytes). return _gateway.estimateMessageCost(networkId, 96, GAS_LIMIT); } /// @inheritdoc ISender /// @notice Burns tokens and sends a message to mint on the destination chain. function send(uint16 networkId, address recipient, uint256 amount) external payable returns (bytes32 msgId) { address targetToken = networks[networkId]; if (targetToken == address(0)) { revert Utils.UnknownNetwork(networkId); } _burn(msg.sender, amount); bytes memory message = abi.encode(TransferCmd({from: msg.sender, to: recipient, amount: amount})); // `msg.value` covers the GMP fee. return _gateway.submitMessage{value: msg.value}(targetToken, networkId, GAS_LIMIT, message); } /// @inheritdoc IGmpReceiver /// @notice Called by the Gateway to mint tokens when a cross-chain message is received. function onGmpReceived(bytes32 id, uint128 networkId, bytes32 source, uint64, bytes calldata data) external payable returns (bytes32) { require(msg.sender == address(_gateway), "Unauthorized: caller is not the Gateway"); address sourceToken = address(uint160(uint256(source))); require(networks[uint16(networkId)] == sourceToken, "Unauthorized: unknown source token"); TransferCmd memory cmd = abi.decode(data, (TransferCmd)); _mint(cmd.to, cmd.amount); return id; } /// @inheritdoc ERC20Capped function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Capped) { super._update(from, to, value); } }

Step 4: Compile the Contract

Run the build command to compile your contract and generate ABI artifacts.

forge build

Step 5: Set Up Environment Variables

Create a .env file in your root directory to securely store your private key and RPC URLs. Never commit this file to version control.

# .env ETH_SEPOLIA_RPC_URL="<YOUR_RPC_URL>" ARBITRUM_SEPOLIA_RPC_URL="<YOUR_RPC_URL>" PRIVATE_KEY="<YOUR_WALLET_PRIVATE_KEY>"

Next, add .env to your .gitignore file.

echo ".env" >> .gitignore

Step 6: Deploy to Networks

Before deploying, you must get the official Gateway addresses & Network IDs. See Gateway Addresses & Network IDs for more details.

  1. Load your environment variables into your current shell session.
source .env
  1. Deploy to Arbitrum Sepolia
forge create --rpc-url $ARBITRUM_SEPOLIA_RPC_URL --private-key $PRIVATE_KEY \ src/CrossChainOats.sol:CrossChainOats \ --constructor-args "My OmniChain Token" "MOT" 1000000000000000000000000 "<ARB_GATEWAY_ADDRESS>" \ --verify

Save the deployed contract address. Let's call it ARB_CONTRACT_ADDRESS.

  1. Deploy to Ethereum Sepolia.
forge create --rpc-url $ETH_SEPOLIA_RPC_URL --private-key $PRIVATE_KEY \ src/CrossChainOats.sol:CrossChainOats \ --constructor-args "My OmniChain Token" "MOT" 1000000000000000000000000 "<ETH_GATEWAY_ADDRESS>" \ --verify

Save the deployed contract address. Let's call it ETH_CONTRACT_ADDRESS.


Step 7: Configure and Test the Cross-Chain Transfer

Now you will link the two contracts and perform a transfer from Arbitrum to Ethereum.

1. Link the Contracts. You need to tell each contract about its counterpart on the other network.

  • On Arbitrum, link to the Ethereum contract:

    cast send $ARB_CONTRACT_ADDRESS "setNetwork(uint16,address)" 11155111 $ETH_CONTRACT_ADDRESS \ --rpc-url $ARBITRUM_SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
  • On Ethereum, link to the Arbitrum contract:

    cast send $ETH_CONTRACT_ADDRESS "setNetwork(uint16,address)" 421614 $ARB_CONTRACT_ADDRESS \ --rpc-url $ETH_SEPOLIA_RPC_URL --private-key $PRIVATE_KEY

2. Initiate the Transfer (Arbitrum → Ethereum) First, estimate the fee required for the GMP message by calling the cost function on the Arbitrum contract.

cast call $ARB_CONTRACT_ADDRESS "cost(uint16)" 11155111 --rpc-url $ARBITRUM_SEPOLIA_RPC_URL

This command will output the required fee in wei. Copy this value for the next step.

Now, execute the send function to burn 100 tokens. You must pass the fee you just retrieved as the transaction's value. Replace <RECIPIENT_ADDRESS> with the destination wallet and <GMP_FEE> with the value you copied.

# We are sending 100 CCT (100 * 10^18) cast send $ARB_CONTRACT_ADDRESS "send(uint16,address,uint256)" 11155111 <RECIPIENT_ADDRESS> 100000000000000000000 \ --value <GMP_FEE> \ --rpc-url $ARBITRUM_SEPOLIA_RPC_URL --private-key $PRIVATE_KEY

3. Verify the Results

  • Check Balance on Arbitrum: Your token balance on Arbitrum Sepolia should have decreased by 100.

    Bash

    cast call $ARB_CONTRACT_ADDRESS "balanceOf(address)" <YOUR_WALLET_ADDRESS> --rpc-url $ARBITRUM_SEPOLIA_RPC_URL
  • Wait for OATS Processing: The Analog network will process the message, which may take a few minutes.

  • Check Balance on Ethereum: Once the message is delivered, the onGmpReceived function mints 100 tokens to the recipient on Ethereum Sepolia.

    cast call $ETH_CONTRACT_ADDRESS "balanceOf(address)" <RECIPIENT_ADDRESS> --rpc-url $ETH_SEPOLIA_RPC_URL

Congratulations! You have successfully built and tested a dApp that performs a cross-chain token transfer using OATS.