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 initThis 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
ERC20BurnableandERC20Capped. - Implement the
ISenderandIGmpReceiverinterfaces. - 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
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 buildStep 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" >> .gitignoreStep 6: Deploy to Networks
Before deploying, you must get the official Gateway addresses & Network IDs. See Gateway Addresses & Network IDs for more details.
- Load your environment variables into your current shell session.
source .env- 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.
- 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_URLThis 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_KEY3. 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
onGmpReceivedfunction 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.