CCT - getCCIPAdmin() token with Burn and Mint Pool in forked environments

This tutorial walks you through testing the procedure of enabling your own tokens in CCIP 2.0, using the CCIP Foundry Starter Kit's Example 06 (CCT 01). You will deploy a CrossChainToken (a CCT-compatible ERC-20 that exposes getCCIPAdmin()) with a Burn & Mint pool on forks of Ethereum Sepolia and Polygon Amoy, register it in the live TokenAdminRegistry, link the pools, opt them into Fast Transfers, and send a transfer across the CCIP 2.0 lane with CCIPLocalSimulatorFork routing the message.

The kit runs this exact flow as a fork test (test/fork/Example06.fork.t.sol), so every step below is verified against pinned forks of the live testnets.

Prerequisites

Before we start with this guide, let's recap parts of the CCT standard that we will need for it.

Requirements for Cross-Chain Tokens

Before enabling an ERC20-compatible token in CCIP, it's important to understand the requirements it must fulfill to integrate with CCIP.

  • Recommended permissionless token administrator address registration methods: A token can use either of these supported function signatures to register permissionlessly:

    • owner(): This function returns the token contract owner's address.

    • getCCIPAdmin(): This function returns the token administrator's address and is recommended for new tokens, as it allows for abstraction of the CCIP Token Administrator role from other common roles, like owner().

  • Requirements for CCIP token transfers: The token's smart contract must meet minimum requirements to integrate with CCIP.

    • Burn & Mint Requirements:
      • The token smart contract must have the following functions:
        • mint(address account, uint256 amount): This function is used to mint the amount of tokens to a given account on the destination blockchain.
        • burn(uint256 amount): This function is used to burn the amount of tokens on the source blockchain.
        • decimals(): Returns the token's number of decimals.
        • balanceOf(address account): Returns the current token balance of the specified account.
        • burnFrom(address account, uint256 amount): This function burns a specified number of tokens from the provided account on the source blockchain. Note: This is an optional function. We generally recommend using the burn function, but if you use a tokenPool that calls burnFrom, your token contract will need to implement this function.
      • On the source and destination blockchains, the token contract must support granting mint and burn permissions. The token developers or another role (such as the token administrator) will grant these permissions to the token pool.

If you don't have an existing token, the kit deploys a CrossChainToken for you: a BaseERC20 with getCCIPAdmin(), mint and burn roles, and a pre-mint, configured in one constructor call.

Understanding the Procedure

It is also important first to understand the overall procedure for enabling your tokens in CCIP. This procedure involves deploying tokens and token pools, registering administrative roles, and configuring token pools to enable secure token transfers using CCIP. The steps in the diagram below highlight the flow of actions needed to enable a token for cross-chain transfers. Whether you're working with an Externally Owned Account (EOA) or a Smart Account (such as one using a multisig scheme), the overall logic remains the same. You'll follow the same process to enable cross-chain token transfers, configure pools, and register administrative roles.

The diagram below outlines the entire process:

Process for enabling a token in CCIP.

Before You Begin

  1. Install Foundry >= 1.5.1: If you haven't already, follow the instructions in the Foundry documentation.

  2. Clone the starter kit and initialize the submodules one level deep:

    git clone https://github.com/smartcontractkit/ccip-starter-kit-foundry
    cd ccip-starter-kit-foundry
    git submodule update --init
    
  3. Set up your environment: Copy .env.example to .env and fill in archive-capable RPC endpoints for both chains:

    ETHEREUM_SEPOLIA_RPC_URL=<ethereum_sepolia_rpc_url>
    POLYGON_AMOY_RPC_URL=<polygon_amoy_rpc_url>
    
  4. Import a keystore account for script execution:

    cast wallet import myAccount --interactive
    

    This tutorial assumes --account myAccount.

Step 1: Deploy the token and pool on Ethereum Sepolia (source)

Run the DeployCCTBurnMintTokenAndPool script. It deploys the CrossChainToken (name TestToken, symbol TEST, 18 decimals, 1_000_000e18 pre-mint to the broadcaster, 100_000_000e18 max supply), the BurnMint pool, grants the pool mint and burn roles on the token, registers the token admin via getCCIPAdmin(), accepts the admin role, and sets the pool in the TokenAdminRegistry:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:DeployCCTBurnMintTokenAndPool \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address)" \
  <SEPOLIA_TOKEN_ADMIN_REGISTRY> \
  <SEPOLIA_REGISTRY_MODULE_OWNER_CUSTOM> \
  <SEPOLIA_ARM_PROXY> \
  <SEPOLIA_ROUTER>

Read the four addresses from the CCIP Directory (or from Register.getNetworkDetails(11155111)). Save from the logs:

  • <SEPOLIA_TOKEN_ADDRESS>
  • <SEPOLIA_POOL_ADDRESS>

Step 2: Deploy the token and pool on Polygon Amoy (destination)

Repeat the same script on Amoy:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:DeployCCTBurnMintTokenAndPool \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address)" \
  <AMOY_TOKEN_ADMIN_REGISTRY> \
  <AMOY_REGISTRY_MODULE_OWNER_CUSTOM> \
  <AMOY_ARM_PROXY> \
  <AMOY_ROUTER>

Save <AMOY_TOKEN_ADDRESS> and <AMOY_POOL_ADDRESS>.

Step 3: Configure the Sepolia pool with the Amoy remote

The Example06 script links a pool to its remote chain: it sets the remote token, the remote pool, and (in this example) a disabled rate limiter. Re-running it for the same remote chain selector replaces the existing config:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <SEPOLIA_POOL_ADDRESS> \
  <AMOY_CHAIN_SELECTOR> \
  <AMOY_TOKEN_ADDRESS> \
  <AMOY_POOL_ADDRESS>

Step 4: Configure the Amoy pool with the Sepolia remote

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06 \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <AMOY_POOL_ADDRESS> \
  <SEPOLIA_CHAIN_SELECTOR> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <SEPOLIA_POOL_ADDRESS>

Step 5: (Optional) Enable Fast Transfers on both pools

New pools allow full finality only (0x00000000): every transfer waits for full finality. To opt a pool into Faster Than Finality, set its allowed finality config to a minimum block depth:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06SetPoolAllowedFinalityConfig \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,bytes4)" \
  <SEPOLIA_POOL_ADDRESS> \
  <ALLOWED_FINALITY_CONFIG>

Repeat on Amoy with --rpc-url polygonAmoy and <AMOY_POOL_ADDRESS>. 0x00000000 disables Faster Than Finality; values 0x00000001-0x0000ffff set the minimum block depth. Read each pool back with the Example06GetPoolAllowedFinalityConfig script.

Before an FTF send, check the lane's finality gates (pool, executor and CCV settings) with the read-only Example06CheckFinalityGates script. A getFee quote alone does not prove ccipSend will succeed for a non-IPoolV2 pool.

Step 6: Send the transfer across the lane

Send with default finality (blockConfirmations = 0):

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT> uses token decimals (1e18 is 1 token for an 18-decimal token).
  • <GAS_LIMIT>: set to 0 for a token-only transfer to an EOA receiver.
  • <FEE_TOKEN_ADDRESS>: the LINK token address on Sepolia to pay in LINK, or 0x0000000000000000000000000000000000000000 to pay in native.

For the Faster Than Finality version (after Step 5), use SendCCTTokenWithExtraArgsV3CustomFinality with the same signature plus <BLOCK_CONFIRMATIONS_GT_ZERO>.

The script logs the message ID. Monitor the message on CCIP Explorer.

Run the same flow as a fork test

The kit's test/fork/Example06.fork.t.sol runs this entire procedure against pinned forks of the live testnets, with CCIPLocalSimulatorFork routing each message to the Amoy OffRamp. The core of the test:

// Deploy on both forks through the real script
(sepoliaToken, sepoliaPool) = _deploy(sepoliaFork, sepolia);
(amoyToken, amoyPool) = _deploy(amoyFork, amoy);

// Link the pools through the real script
vm.selectFork(sepoliaFork);
configureLaneScript.run(sepoliaPool, amoy.chainSelector, amoyToken, amoyPool);
vm.selectFork(amoyFork);
configureLaneScript.run(amoyPool, sepolia.chainSelector, sepoliaToken, sepoliaPool);

// Send on Sepolia and route to the Amoy OffRamp
vm.selectFork(sepoliaFork);
sendDefaultFinality.run(
    sepolia.routerAddress, amoy.chainSelector, receiver, sepoliaToken, TRANSFER_AMOUNT,
    EOA_RECEIVER_GAS_LIMIT, address(0)
);
simulator.switchChainAndRouteMessage(amoyFork);

// Burn & Mint: supply shrinks on the source and grows on the destination
assertEq(_balanceOnFork(amoyFork, amoyToken, receiver), TRANSFER_AMOUNT);
assertEq(_supplyOnFork(amoyFork, amoyToken), destinationSupplyBefore + TRANSFER_AMOUNT);

Run it with:

FOUNDRY_PROFILE=fork forge test --match-contract Example06ForkTest -j 1

The fork test also covers the Faster Than Finality gates: it asserts the pool rejects block-depth requests until setAllowedFinalityConfig opts it in, then finds the smallest depth the live lane accepts and delivers the transfer at that depth.

Verify the result on the live testnets

After the transfer finalizes, verify the registration and the lane mapping:

cast call <SEPOLIA_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <SEPOLIA_TOKEN_ADDRESS> --rpc-url ethereumSepolia
cast call <SEPOLIA_POOL_ADDRESS> "getRemoteToken(uint64)(bytes)" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia
cast call <SEPOLIA_POOL_ADDRESS> "getRemotePools(uint64)(bytes[])" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia

Verify the Burn & Mint behavior (burn on source, mint on destination):

cast call <SEPOLIA_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <SOURCE_EOA_ADDRESS> --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <RECEIVER_ON_AMOY> --rpc-url polygonAmoy
cast call <SEPOLIA_TOKEN_ADDRESS> "totalSupply()(uint256)" --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADDRESS> "totalSupply()(uint256)" --rpc-url polygonAmoy

Next steps

Get the latest Chainlink content straight to your inbox.