Migrate your CCT token pools from CCIP v1 to v2 (Burn & Mint) using Foundry
Guide Versions
This guide is available in multiple versions. Choose the one that matches your needs.
This migration guide helps you upgrade a deployed Burn & Mint Cross-Chain Token (CCT) setup from CCIP v1 (1.5.x / 1.6.x) to CCIP v2, while keeping your existing token addresses and minimizing disruption.
This guide uses Foundry scripts inside our docs-cct-foundry repository.
In this guide you will:
- Confirm prerequisites and record your existing CCIP v1 deployment details.
- Deploy new CCIP v2
Burn Minttoken pools on each chain. - Configure the v2 pools to accept messages from both the old v1 pools and the new v2 pools.
- Cut over CCIP routing by calling
TokenAdminRegistry.setPool on each chain. - Validate transfers end-to-end, then optionally clean up legacy configuration.
Before You Begin
1 Set up your development environment
-
Install Node.js and npm:
- Make sure you have
Node.js v22.10.0or above installed. If not, installNode.js v22.10.0using their documentation. npmis bundled with Node.js. If you can’t runnpm, reinstall/update Node.js from the official installer.
- Make sure you have
-
Install/Update Chainlink CCIP-CLI. You can also find the GitHub repository here.
npm install -g @chainlink/ccip-cli
Verify the installation by running the following command:
ccip-cli --version
- Install Foundry:
If you haven't already, follow the instructions in the Foundry documentation to install Foundry.
Verify the installation by running the following command:
forge --version
- Clone the repository and navigate to the project directory:
Clone the CCIP 2.0 Foundry template for a smoother CCT setup.
git clone https://github.com/smartcontractkit/docs-cct-foundry.git
cd docs-cct-foundry
- Create an encrypted Foundry keystore, if you haven't already:
cast wallet import your_keystore_name --interactive
- Create a
.envfile by copying the.env.examplefile, and fill in the required values:
cp .env.example .env
# Keystore name (created via `cast wallet import`)
KEYSTORE_NAME=your_keystore_name
# RPC URLs (lane used in this guide)
ETHEREUM_SEPOLIA_RPC_URL=your_eth_sepolia_rpc
ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL=your_arbitrum_sepolia_rpc
# Etherscan API key (required only if you pass --verify to deployment scripts)
ETHERSCAN_API_KEY=your_etherscan_api_key
View the complete list of supported chains in the HelperConfig.s.sol file.
- To make sure your terminal has access to these variables, run the following command:
source .env
- Install dependencies and build the project:
npm install && forge build
2 What this guide assumes
- EVM-to-EVM only: this page is strictly for Ethereum Sepolia ↔ Arbitrum Sepolia. - Burn & Mint pools
only: standard
BurnMintTokenPoolmigrations (no custom pools). - You control theTokenAdminRegistryadministrator role for the token on both chains. - You can grant the required mint/burn permissions to the new v2 pools on each chain. - You can execute pool-owner actions on both chains (for example,applyChainUpdates,removeRemotePool). - You are migrating an already-live CCIP v1 setup (1.5.x or 1.6.x), not doing a new registration.
Migration Guide
1 Step 1: Confirm the live CCIP v1 pool (and record v1 addresses)
This step answers two questions, on both chains:
- Which pool is currently live for your token? (read from
TokenAdminRegistry.getTokenConfig) - Is that pool a v1.5/v1.6 pool? (read
typeAndVersion()from the pool)
View the token config query script on GitHub.
- Read the token config on Ethereum Sepolia:
TOKEN=$ETHEREUM_SEPOLIA_TOKEN forge script \
script/setup/GetTokenConfig.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Your output should look something like this:
Token Config:
administrator: 0xYourAdminAddress
pendingAdministrator: 0x0000000000000000000000000000000000000000
tokenPool: 0xOldSepoliaV1PoolAddress
Export the v1 pool address:
export ETHEREUM_SEPOLIA_V1_POOL=0xOldSepoliaV1PoolAddress
- Repeat on Arbitrum Sepolia:
TOKEN=$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN forge script \
script/setup/GetTokenConfig.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
Your output should look something like this:
Token Config:
administrator: 0xYourAdminAddress
pendingAdministrator: 0x0000000000000000000000000000000000000000
tokenPool: 0xOldArbitrumV1PoolAddress
Export the v1 pool address:
export ARBITRUM_SEPOLIA_V1_POOL=0xOldArbitrumV1PoolAddress
View the type/version query script on GitHub.
- Confirm the v1 pool version on Ethereum Sepolia:
ADDRESS=$ETHEREUM_SEPOLIA_V1_POOL forge script \
script/setup/GetTypeAndVersion.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Your output should look something like this:
typeAndVersion: BurnMintTokenPool 1.6.1
- Confirm the v1 pool version on Arbitrum Sepolia:
ADDRESS=$ARBITRUM_SEPOLIA_V1_POOL forge script \
script/setup/GetTypeAndVersion.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
Your output should look something like this:
typeAndVersion: BurnMintTokenPool 1.6.1
2 Step 2: Record your v1 lane configuration (remote pools + rate limits)
Before deploying anything, snapshot your current v1 configuration so you can keep behavior consistent after migration.
GetSupportedChains.s.solView the supported-chains query script on GitHub.
- List configured remote chains on the Sepolia v1 pool:
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL forge script \
script/setup/GetSupportedChains.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Your output should look something like this:
Supported Remote Chains: 1
[0] Arbitrum Sepolia (3478487238524512106)
Remote Pools: 1
[0] 0xOldArbitrumV1PoolAddress
- List configured remote chains on the Arbitrum v1 pool:
TOKEN_POOL=$ARBITRUM_SEPOLIA_V1_POOL forge script \
script/setup/GetSupportedChains.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
Your output should look something like this:
Supported Remote Chains: 1
[0] Ethereum Sepolia (16015286601757825753)
Remote Pools: 1
[0] 0xOldSepoliaV1PoolAddress
View the rate limiter query script on GitHub.
- Snapshot current v1 rate limits on the Sepolia → Arbitrum lane:
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL DEST_CHAIN=ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1 forge script \
script/configure/rate-limiter/GetCurrentRateLimits.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Your output should look something like this:
Pool Version: v1
Outbound Enabled: true
Outbound Capacity: 1000000000000000000000
Outbound Rate: 100000000000000000
Inbound Enabled: true
Inbound Capacity: 1000000000000000000000
Inbound Rate: 100000000000000000
- Snapshot current v1 rate limits on the Arbitrum → Sepolia lane:
TOKEN_POOL=$ARBITRUM_SEPOLIA_V1_POOL DEST_CHAIN=ETHEREUM_SEPOLIA forge script \
script/configure/rate-limiter/GetCurrentRateLimits.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
Your output should look something like this:
Pool Version: v1
Outbound Enabled: true
Outbound Capacity: 1000000000000000000000
Outbound Rate: 100000000000000000
Inbound Enabled: true
Inbound Capacity: 1000000000000000000000
Inbound Rate: 100000000000000000
Record these values since you'll reuse them when configuring the v2 pools in Step 5.
3 Step 3: Deploy new v2 pools (per chain)
Deploy a new v2 BurnMintTokenPool on each chain, pointing to your existing token address on that chain.
View the Burn & Mint pool deployment script on GitHub.
- Deploy the v2 pool on Ethereum Sepolia:
TOKEN=$ETHEREUM_SEPOLIA_TOKEN forge script \
script/deploy/DeployBurnMintTokenPool.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
Token Pool deployed at: 0xNewSepoliaV2PoolAddress
✅ Roles granted successfully!
Export the new v2 pool address:
export ETHEREUM_SEPOLIA_V2_POOL=0xNewSepoliaV2PoolAddress
- Deploy the v2 pool on Arbitrum Sepolia:
TOKEN=$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN forge script \
script/deploy/DeployBurnMintTokenPool.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
Token Pool deployed at: 0xNewArbitrumV2PoolAddress
✅ Roles granted successfully!
Export the new v2 pool address:
export ARBITRUM_SEPOLIA_V2_POOL=0xNewArbitrumV2PoolAddress
4 Step 4 (Recommended): Pause outbound transfers on the v1 pools
This recommended step throttles new outbound traffic on v1 while you cut over. Use capacity = 2 and rate = 1 as the most restrictive (near-zero) configuration we document for v1 pools while keeping the lane configured.
UpdateRateLimiters.s.solView the rate limiter update script on GitHub.
- Pause outbound on the Sepolia v1 pool (lane to Arbitrum Sepolia):
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL \
DEST_CHAIN=ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1 \
OUTBOUND_RATE_LIMIT_CAPACITY=2 \
OUTBOUND_RATE_LIMIT_RATE=1 \
forge script \
script/configure/rate-limiter/UpdateRateLimiters.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
Pool Version: v1 (setChainRateLimiterConfig)
New Configuration:
Outbound Enabled: true
Outbound Capacity: 2
Outbound Rate: 1
- Pause outbound on the Arbitrum v1 pool (lane to Ethereum Sepolia):
TOKEN_POOL=$ARBITRUM_SEPOLIA_V1_POOL \
DEST_CHAIN=ETHEREUM_SEPOLIA \
OUTBOUND_RATE_LIMIT_CAPACITY=2 \
OUTBOUND_RATE_LIMIT_RATE=1 \
forge script \
script/configure/rate-limiter/UpdateRateLimiters.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
5 Step 5: Configure the v2 pools (applyChainUpdates)
On each chain, configure the v2 pool to recognize the lane to the remote chain.
Critical migration rule: the destPools list must include both the old v1 remote pool address and the new
v2 remote pool address. This allows in-flight messages sent via v1 (before cutover) to still validate after you cut
over to v2.
View the applyChainUpdates script (JSON mode recommended) on GitHub.
apply-chain-updates.jsonView the JSON input file used for applyChainUpdates on GitHub.
- Configure the Sepolia v2 pool (remote chain: Arbitrum Sepolia)
Update script/input/apply-chain-updates.json:
{
"_comment": "Configure Sepolia v2 pool → Arbitrum Sepolia. Replace 0x... placeholders with your real addresses.",
"sourcePool": "0xNewSepoliaV2PoolAddress",
"remoteChains": [
{
"destChain": "ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1",
"destPools": ["0xOldArbitrumV1PoolAddress", "0xNewArbitrumV2PoolAddress"],
"destToken": "0x...",
"outboundRateLimit": {
"enabled": true,
"capacity": 1000000000000000000000,
"rate": 100000000000000000
},
"inboundRateLimit": {
"enabled": true,
"capacity": 1000000000000000000000,
"rate": 100000000000000000
}
}
]
}
Copy your v1 lane rate limits from Step 2 into outboundRateLimit and inboundRateLimit. If you don’t have existing
values, you can use the baseline values shown above. Setting "enabled": false disables rate limiting (unlimited
transfers).
Run the script on Ethereum Sepolia:
VIA_JSON_FILE=true forge script \
script/setup/ApplyChainUpdates.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
Remote Chains: 1
[0] Arbitrum Sepolia
Pools: 2
[0] 0xOldArbitrumV1PoolAddress
[1] 0xNewArbitrumV2PoolAddress
✅ Chain updates applied successfully!
- Configure the Arbitrum v2 pool (remote chain: Ethereum Sepolia)
Update script/input/apply-chain-updates.json:
{
"_comment": "Configure Arbitrum v2 pool → Ethereum Sepolia. Replace 0x... placeholders with your real addresses.",
"sourcePool": "0xNewArbitrumV2PoolAddress",
"remoteChains": [
{
"destChain": "ETHEREUM_SEPOLIA",
"destPools": ["0xOldSepoliaV1PoolAddress", "0xNewSepoliaV2PoolAddress"],
"destToken": "0x...",
"outboundRateLimit": {
"enabled": true,
"capacity": 1000000000000000000000,
"rate": 100000000000000000
},
"inboundRateLimit": {
"enabled": true,
"capacity": 1000000000000000000000,
"rate": 100000000000000000
}
}
]
}
Run the script on Arbitrum Sepolia:
VIA_JSON_FILE=true forge script \
script/setup/ApplyChainUpdates.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
Remote Chains: 1
[0] Ethereum Sepolia
Pools: 2
[0] 0xOldSepoliaV1PoolAddress
[1] 0xNewSepoliaV2PoolAddress
✅ Chain updates applied successfully!
6 Step 6: Verify v2 configuration (before cutover)
Verify that each v2 pool has two remote pools configured for the lane: [remoteV1Pool, remoteV2Pool].
View the remote pool query script on GitHub.
- Check remote pools on the Sepolia v2 pool:
TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL DEST_CHAIN=ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1 forge script \
script/configure/remote-pools/GetRemotePools.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Your output should look something like this:
Chain Supported: Yes
Remote Pools: 2
[0] 0xOldArbitrumV1PoolAddress
[1] 0xNewArbitrumV2PoolAddress
- Check remote pools on the Arbitrum v2 pool:
TOKEN_POOL=$ARBITRUM_SEPOLIA_V2_POOL DEST_CHAIN=ETHEREUM_SEPOLIA forge script \
script/configure/remote-pools/GetRemotePools.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
Your output should look something like this:
Chain Supported: Yes
Remote Pools: 2
[0] 0xOldSepoliaV1PoolAddress
[1] 0xNewSepoliaV2PoolAddress
View the rate limiter query script on GitHub.
- Re-check rate limits on the v2 pools (optional but recommended)
Sepolia v2 pool (lane to Arbitrum):
TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL DEST_CHAIN=ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1 forge script \
script/configure/rate-limiter/GetCurrentRateLimits.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Arbitrum v2 pool (lane to Sepolia):
TOKEN_POOL=$ARBITRUM_SEPOLIA_V2_POOL DEST_CHAIN=ETHEREUM_SEPOLIA forge script \
script/configure/rate-limiter/GetCurrentRateLimits.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
7 Step 7: Cut over routing (TokenAdminRegistry.setPool)
This is the migration cutover step. On each chain, update TokenAdminRegistry so your token routes through the new v2
pool.
View the setPool script on GitHub.
- Cut over on Ethereum Sepolia:
TOKEN=$ETHEREUM_SEPOLIA_TOKEN TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL forge script \
script/setup/SetPool.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
✅ Pool set successfully!
- Cut over on Arbitrum Sepolia:
TOKEN=$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN TOKEN_POOL=$ARBITRUM_SEPOLIA_V2_POOL forge script \
script/setup/SetPool.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
8 Step 8: Validate end-to-end (Sepolia → Arbitrum) with ccip-cli
Send a small test transfer from Ethereum Sepolia → Arbitrum Sepolia and confirm it succeeds.
Set the router address for Ethereum Sepolia (from the CCIP Directory or HelperConfig.s.sol in the template):
export ETHEREUM_SEPOLIA_ROUTER=0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
- Send a transfer on default finality:
ccip-cli send \
--source ethereum-testnet-sepolia \
--router $ETHEREUM_SEPOLIA_ROUTER \
--dest ethereum-testnet-sepolia-arbitrum-1 \
--transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
--receiver 0xYourReceiverAddress \
--wallet foundry:$KEYSTORE_NAME \
--rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
--rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"
Your output should look something like this:
🚀 Sending message ... messageId => 0x...
CCIP Explorer: https://ccip.chain.link/msg/0x...
- (Optional) Enable and request block-depth faster than finality with depth 32
First, allow block-depth finality on your v2 pools:
SetFinalityConfig.s.solView the finality configuration script on GitHub.
BLOCK_DEPTH=32 TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL forge script \
script/configure/finality-config/SetFinalityConfig.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
BLOCK_DEPTH=32 TOKEN_POOL=$ARBITRUM_SEPOLIA_V2_POOL forge script \
script/configure/finality-config/SetFinalityConfig.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Then send using --extra finality=32:
ccip-cli send \
--source ethereum-testnet-sepolia \
--router $ETHEREUM_SEPOLIA_ROUTER \
--dest ethereum-testnet-sepolia-arbitrum-1 \
--transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
--receiver 0xYourReceiverAddress \
--extra finality=32 \
--wallet foundry:$KEYSTORE_NAME \
--rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
--rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"
9 Step 9: Confirm your token now routes through v2 pools
Re-run the Step 1 checks. You should now see the v2 pool addresses as the live tokenPool on both chains, and
typeAndVersion should report v2.
- Ethereum Sepolia:
TOKEN=$ETHEREUM_SEPOLIA_TOKEN forge script script/setup/GetTokenConfig.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
ADDRESS=$ETHEREUM_SEPOLIA_V2_POOL forge script script/setup/GetTypeAndVersion.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Your output should look something like this:
Token Config:
tokenPool: 0xNewSepoliaV2PoolAddress
typeAndVersion: BurnMintTokenPool 2.0.0
- Arbitrum Sepolia:
TOKEN=$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN forge script script/setup/GetTokenConfig.s.sol --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
ADDRESS=$ARBITRUM_SEPOLIA_V2_POOL forge script script/setup/GetTypeAndVersion.s.sol --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
Your output should look something like this:
Token Config:
tokenPool: 0xNewArbitrumV2PoolAddress
typeAndVersion: BurnMintTokenPool 2.0.0
10 Step 10: Safety checks and cleanup (after v1 inflight settles)
After cutover, keep the v1 remote pool addresses configured on the v2 pools until you are confident there are no more in-flight v1 messages. There is no cost to waiting longer.
Once you’re ready, remove the old v1 remote pool addresses from the v2 pools.
RemoveRemotePool.s.solView the remote pool removal script on GitHub.
- On the Sepolia v2 pool, remove the old Arbitrum v1 pool address:
TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL \
DEST_CHAIN=ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1 \
REMOTE_POOL_ADDRESS=$ARBITRUM_SEPOLIA_V1_POOL \
forge script \
script/configure/remote-pools/RemoveRemotePool.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
Your output should look something like this:
⚠️ WARNING: All inflight transactions from this pool will be rejected after removal.
Current Remote Pools: 2
[0] 0xOldArbitrumV1PoolAddress
[1] 0xNewArbitrumV2PoolAddress
✅ Remote pool removed successfully!
- On the Arbitrum v2 pool, remove the old Sepolia v1 pool address:
TOKEN_POOL=$ARBITRUM_SEPOLIA_V2_POOL \
DEST_CHAIN=ETHEREUM_SEPOLIA \
REMOTE_POOL_ADDRESS=$ETHEREUM_SEPOLIA_V1_POOL \
forge script \
script/configure/remote-pools/RemoveRemotePool.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
View the remote pool add script on GitHub (recovery).
Recovery example (re-add the old pool address, then retry cleanup later):
TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL \
DEST_CHAIN=ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1 \
REMOTE_POOL_ADDRESS=$ARBITRUM_SEPOLIA_V1_POOL \
forge script \
script/configure/remote-pools/AddRemotePool.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast