Migrate your CCT token pools from CCIP v1 to v2 (Lock & Mint) using Foundry

This migration guide helps you upgrade a deployed Lock & 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:

  1. Confirm prerequisites and record your existing CCIP v1 deployment details, including the locked liquidity on the canonical chain.
  2. Deploy the new CCIP v2 pools: an ERC20LockBox + LockReleaseTokenPool on the canonical chain, and a BurnMintTokenPool on the destination chain.
  3. Pause the old v1 pools and migrate the locked liquidity from the v1 Lock & Release pool into the new lockbox.
  4. Configure the v2 pools to accept messages from both the old v1 pools and the new v2 pools.
  5. 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
  1. Install Node.js and npm:

    • Make sure you have Node.js v22.10.0 or above installed. If not, install Node.js v22.10.0 using their documentation.
    • npm is bundled with Node.js. If you canโ€™t run npm, reinstall/update Node.js from the official installer.
  2. Install/Update Chainlink CCIP-CLI. You can also find the GitHub repository here.

Terminal
npm install -g @chainlink/ccip-cli

Verify the installation by running the following command:

Terminal
ccip-cli --version
  1. 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:
Terminal
forge --version
  1. Clone the repository and navigate to the project directory:
CCIP 2.0 Foundry template

Clone the CCIP 2.0 Foundry template for a smoother CCT setup.

Terminal
git clone https://github.com/smartcontractkit/docs-cct-foundry.git
cd docs-cct-foundry
  1. Create an encrypted Foundry keystore, if you haven't already:
Terminal
cast wallet import your_keystore_name --interactive
  1. Create a .env file by copying the .env.example file, and fill in the required values:
Terminal
cp .env.example .env
.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
Complete List of Supported Chains

View the complete list of supported chains in the HelperConfig.s.sol file.

  1. To make sure your terminal has access to these variables, run the following command:
Terminal
source .env
  1. Install dependencies and build the project:
Terminal
npm install && forge build
2 What this guide assumes
  • EVM-to-EVM only: this page is strictly for Ethereum Sepolia โ†” Arbitrum Sepolia.
  • Asymmetric Lock & Mint topology: Ethereum Sepolia is the canonical / Lock & Release chain (it holds the ERC20LockBox and the LockReleaseTokenPool); Arbitrum Sepolia is the destination / Burn & Mint chain (it holds the BurnMintTokenPool).
  • Standard pools only: a single shared lockbox with LockReleaseTokenPool (not SiloedLockReleaseTokenPool) and a standard BurnMintTokenPool (no custom pools).
  • You control the TokenAdminRegistry administrator role for the token on both chains.
  • You own the v1 pools on both chains (needed for setRebalancer, withdrawLiquidity, applyChainUpdates, and removeRemotePool).
  • You can grant the required mint/burn permissions to the new v2 BurnMintTokenPool on Arbitrum Sepolia.
  • 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 pools (and record v1 addresses)

This step answers two questions, on both chains:

  1. Which pool is currently live for your token? (read from TokenAdminRegistry.getTokenConfig)
  2. Is that pool a v1.5/v1.6 pool? (read typeAndVersion() from the pool)

Because Lock & Mint is asymmetric, expect a LockReleaseTokenPool on Ethereum Sepolia and a BurnMintTokenPool on Arbitrum Sepolia.

GetTokenConfig.s.sol

View the token config query script on GitHub.

  1. Read the token config on Ethereum Sepolia (canonical / Lock & Release):
Terminal
TOKEN=$ETHEREUM_SEPOLIA_TOKEN forge script \
  script/setup/GetTokenConfig.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

Your output should look something like this:

Terminal
Token Config:
  administrator:        0xYourAdminAddress
  pendingAdministrator: 0x0000000000000000000000000000000000000000
  tokenPool:            0xOldSepoliaV1LockReleasePool

Export the v1 pool address:

Terminal
export ETHEREUM_SEPOLIA_V1_POOL=0xOldSepoliaV1LockReleasePool
  1. Repeat on Arbitrum Sepolia (destination / Burn & Mint):
Terminal
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:

Terminal
Token Config:
  administrator:        0xYourAdminAddress
  pendingAdministrator: 0x0000000000000000000000000000000000000000
  tokenPool:            0xOldArbitrumV1BurnMintPool

Export the v1 pool address:

Terminal
export ARBITRUM_SEPOLIA_V1_POOL=0xOldArbitrumV1BurnMintPool
GetTypeAndVersion.s.sol

View the type/version query script on GitHub.

  1. Confirm the v1 pool version on Ethereum Sepolia (expect a Lock & Release pool):
Terminal
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:

Terminal
typeAndVersion: LockReleaseTokenPool 1.6.1
  1. Confirm the v1 pool version on Arbitrum Sepolia (expect a Burn & Mint pool):
Terminal
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:

Terminal
typeAndVersion: BurnMintTokenPool 1.6.1
2 Step 2: Record your v1 lane configuration (remote pools, rate limits, and canonical liquidity)

Before deploying anything, snapshot your current v1 configuration so you can keep behavior consistent after migration; and record the locked liquidity you will move into the new lockbox.

GetSupportedChains.s.sol

View the supported-chains query script on GitHub.

  1. List configured remote chains on the Sepolia v1 pool:
Terminal
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL forge script \
  script/setup/GetSupportedChains.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
  1. List configured remote chains on the Arbitrum v1 pool:
Terminal
TOKEN_POOL=$ARBITRUM_SEPOLIA_V1_POOL forge script \
  script/setup/GetSupportedChains.s.sol \
  --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL
GetCurrentRateLimits.s.sol

View the rate limiter query script on GitHub.

  1. Snapshot current v1 rate limits on the Sepolia โ†’ Arbitrum lane:
Terminal
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:

Terminal
Outbound Enabled:  true
Outbound Capacity: 1000000000000000000000
Outbound Rate:     100000000000000000
Inbound  Enabled:  true
Inbound  Capacity: 1000000000000000000000
Inbound  Rate:     100000000000000000
  1. Snapshot current v1 rate limits on the Arbitrum โ†’ Sepolia lane:
Terminal
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

Record these values since you'll reuse them when configuring the v2 pools in Step 6.

  1. Record the locked liquidity held by the Sepolia v1 Lock & Release pool. This is the exact amount (in the token's smallest unit) you will migrate into the new lockbox in Step 5. There is no โ€œdrain allโ€ shortcut, the withdrawal script requires an exact amount; so read the balance directly:
Terminal
cast call $ETHEREUM_SEPOLIA_TOKEN \
  "balanceOf(address)(uint256)" \
  $ETHEREUM_SEPOLIA_V1_POOL \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

Export the value so you can reuse it later:

Terminal
export DRAIN_AMOUNT=<the balance printed above, in wei>
3 Step 3: Deploy the new v2 pools (LockBox + Lock & Release on Ethereum Sepolia, Burn & Mint on Arbitrum Sepolia)

This step is asymmetric: Ethereum Sepolia needs a lockbox and a LockReleaseTokenPool, while Arbitrum Sepolia needs only a BurnMintTokenPool.

Ethereum Sepolia (canonical): LockBox โ†’ Lock & Release pool โ†’ authorize

DeployERC20LockBox.s.sol

View the lockbox deployment script on GitHub.

  1. Deploy the ERC20LockBox on Ethereum Sepolia. It holds the locked liquidity on behalf of the Lock & Release pool and must be deployed before the pool:
Terminal
TOKEN=$ETHEREUM_SEPOLIA_TOKEN forge script \
  script/deploy/DeployERC20LockBox.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

Your output should look something like this:

Terminal
ERC20LockBox deployed at: 0xNewSepoliaLockBox
โœ… ERC20LockBox deployed successfully!

Export the lockbox address:

Terminal
export LOCK_BOX=0xNewSepoliaLockBox
DeployLockReleaseTokenPool.s.sol

View the Lock & Release pool deployment script on GitHub.

  1. Deploy the v2 LockReleaseTokenPool on Ethereum Sepolia, passing the lockbox address:
Terminal
TOKEN=$ETHEREUM_SEPOLIA_TOKEN LOCK_BOX=$LOCK_BOX forge script \
  script/deploy/DeployLockReleaseTokenPool.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

Your output should look something like this:

Terminal
Token Pool deployed at: 0xNewSepoliaV2LockReleasePool
โœ… LockReleaseTokenPool deployed successfully!

Export the new v2 pool address:

Terminal
export ETHEREUM_SEPOLIA_V2_POOL=0xNewSepoliaV2LockReleasePool
UpdateAuthorizedCallers.s.sol

View the authorized callers script on GitHub.

  1. Authorize the new v2 pool as a caller on the lockbox so it can deposit and withdraw tokens during cross-chain transfers:
Terminal
LOCK_BOX=$LOCK_BOX \
  ADD_ADDRESSES=$ETHEREUM_SEPOLIA_V2_POOL \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

Arbitrum Sepolia (destination): Burn & Mint pool

DeployBurnMintTokenPool.s.sol

View the Burn & Mint pool deployment script on GitHub.

  1. Deploy the v2 BurnMintTokenPool on Arbitrum Sepolia:
Terminal
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:

Terminal
Token Pool deployed at: 0xNewArbitrumV2BurnMintPool
โœ… Roles granted successfully!

Export the new v2 pool address:

Terminal
export ARBITRUM_SEPOLIA_V2_POOL=0xNewArbitrumV2BurnMintPool
4 Step 4 (Required): Pause outbound transfers on the v1 pools

For Lock & Mint this step is required, not optional: Step 5 drains the old v1 Lock & Release pool, and pausing prevents new transfers from locking additional tokens (which would be stranded outside the lockbox). Use capacity = 2 and rate = 1: the most restrictive (near-zero) configuration we document that works across all v1 pool versions while keeping the lane configured. Only the outbound direction is changed; inbound is left as-is so in-flight messages can still arrive.

UpdateRateLimiters.s.sol

View the rate limiter update script on GitHub.

  1. Pause outbound on the Sepolia v1 pool (lane to Arbitrum Sepolia):
Terminal
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
  1. Pause outbound on the Arbitrum v1 pool (lane to Ethereum Sepolia):
Terminal
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: Migrate locked liquidity into the LockBox (Ethereum Sepolia only)

This step is unique to Lock & Mint. In v1 the Lock & Release pool held liquidity directly in its own balance; in v2 that liquidity lives in the ERC20LockBox. You must move it.

First, export the address of your keystore account (it must be the v1 pool owner and will be the temporary rebalancer and lockbox depositor):

Terminal
export MY_ADDRESS=0xYourKeystoreAccountAddress
GetRebalancer.s.sol

View the rebalancer query script on GitHub.

  1. (Optional) Read the current rebalancer on the v1 pool:
Terminal
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL forge script \
  script/configure/liquidity/GetRebalancer.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
SetRebalancer.s.sol

View the set-rebalancer script on GitHub.

  1. Set yourself as the rebalancer on the v1 pool (only the rebalancer can withdraw liquidity; this call must be sent by the pool owner):
Terminal
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL \
  REBALANCER=$MY_ADDRESS \
  forge script \
  script/configure/liquidity/SetRebalancer.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast
WithdrawLiquidity.s.sol

View the withdraw-liquidity script on GitHub.

  1. Withdraw the full balance from the v1 pool into your wallet (must be sent by the rebalancer you just set). Use the exact DRAIN_AMOUNT you recorded in Step 2:
Terminal
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL \
  AMOUNT=$DRAIN_AMOUNT \
  forge script \
  script/configure/liquidity/WithdrawLiquidity.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast
  1. Temporarily authorize your address as a caller on the lockbox. The lockbox deposit function is restricted to authorized callers; even the lockbox owner must be authorized to deposit:
Terminal
LOCK_BOX=$LOCK_BOX \
  ADD_ADDRESSES=$MY_ADDRESS \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast
DepositToLockBox.s.sol

View the lockbox deposit script on GitHub.

  1. Deposit the tokens into the lockbox. The script approves the lockbox and deposits in a single run:
Terminal
LOCK_BOX=$LOCK_BOX \
  TOKEN=$ETHEREUM_SEPOLIA_TOKEN \
  AMOUNT=$DRAIN_AMOUNT \
  forge script \
  script/operations/DepositToLockBox.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast
  1. Remove your address from the lockbox authorized callers, leaving only the pool authorized:
Terminal
LOCK_BOX=$LOCK_BOX \
  REMOVE_ADDRESSES=$MY_ADDRESS \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast
  1. Verify the v1 pool is fully drained (this should return 0):
Terminal
cast call $ETHEREUM_SEPOLIA_TOKEN \
  "balanceOf(address)(uint256)" \
  $ETHEREUM_SEPOLIA_V1_POOL \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

The old v1 pool is being retired, so there is no need to restore its original rebalancer.

6 Step 6: 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. You must use JSON mode here, because it is the only mode that accepts more than one remote pool per chain.

ApplyChainUpdates.s.sol

View the applyChainUpdates script (JSON mode) on GitHub.

apply-chain-updates.json

View the JSON input file used for applyChainUpdates on GitHub.

  1. Configure the Sepolia v2 Lock & Release pool (remote chain: Arbitrum Sepolia).

Update script/input/apply-chain-updates.json:

script/input/apply-chain-updates.json
{
  "_comment": "Configure Sepolia v2 pool โ†’ Arbitrum Sepolia. Replace 0x... placeholders with your real addresses.",
  "sourcePool": "0xNewSepoliaV2LockReleasePool",
  "remoteChains": [
    {
      "destChain": "ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1",
      "destPools": ["0xOldArbitrumV1BurnMintPool", "0xNewArbitrumV2BurnMintPool"],
      "destToken": "0xYourArbitrumToken",
      "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:

Terminal
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:

Terminal
Remote Chains: 1

  [0] Arbitrum Sepolia
      Pools: 2
        [0] 0xOldArbitrumV1BurnMintPool
        [1] 0xNewArbitrumV2BurnMintPool

โœ… Chain updates applied successfully!
  1. Configure the Arbitrum v2 Burn & Mint pool (remote chain: Ethereum Sepolia).

Update script/input/apply-chain-updates.json:

script/input/apply-chain-updates.json
{
  "_comment": "Configure Arbitrum v2 pool โ†’ Ethereum Sepolia. Replace 0x... placeholders with your real addresses.",
  "sourcePool": "0xNewArbitrumV2BurnMintPool",
  "remoteChains": [
    {
      "destChain": "ETHEREUM_SEPOLIA",
      "destPools": ["0xOldSepoliaV1LockReleasePool", "0xNewSepoliaV2LockReleasePool"],
      "destToken": "0xYourSepoliaToken",
      "outboundRateLimit": {
        "enabled": true,
        "capacity": 1000000000000000000000,
        "rate": 100000000000000000
      },
      "inboundRateLimit": {
        "enabled": true,
        "capacity": 1000000000000000000000,
        "rate": 100000000000000000
      }
    }
  ]
}

Run the script on Arbitrum Sepolia:

Terminal
VIA_JSON_FILE=true forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast
7 Step 7: Verify the v2 configuration (before cutover)

Verify that each v2 pool has two remote pools configured for the lane ([remoteV1Pool, remoteV2Pool]), and that the lockbox is attached and funded.

GetRemotePools.s.sol

View the remote pool query script on GitHub.

  1. Check remote pools on the Sepolia v2 pool:
Terminal
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:

Terminal
Chain Supported: Yes
Remote Pools:    2
  [0] 0xOldArbitrumV1BurnMintPool
  [1] 0xNewArbitrumV2BurnMintPool
  1. Check remote pools on the Arbitrum v2 pool:
Terminal
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
GetCurrentRateLimits.s.sol

View the rate limiter query script on GitHub.

  1. (Optional but recommended) Re-check rate limits on the v2 pools:
Terminal
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

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
GetLockBox.s.sol

View the lockbox query script on GitHub.

  1. Confirm the lockbox is attached to the Sepolia v2 pool and holds the migrated liquidity (Balance should equal your DRAIN_AMOUNT):
Terminal
TOKEN_POOL=$ETHEREUM_SEPOLIA_V2_POOL forge script \
  script/configure/GetLockBox.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

Your output should look something like this:

Terminal
โœ… LockBox:
   0xNewSepoliaLockBox
   Token:   0xYourSepoliaToken (CCT)
   Balance: 1000000000000000000000
8 Step 8: 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.

SetPool.s.sol

View the setPool script on GitHub.

  1. Cut over on Ethereum Sepolia:
Terminal
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:

Terminal
โœ… Pool set successfully!
  1. Cut over on Arbitrum Sepolia:
Terminal
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

The order does not matter, because Step 6 configured both pools to recognize the old and new remote pools.

9 Step 9: Validate end-to-end with ccip-cli

Send small test transfers in both directions and confirm they succeed.

Set the router addresses (from the CCIP Directory or HelperConfig.s.sol in the template):

Terminal
export ETHEREUM_SEPOLIA_ROUTER=0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
export ARBITRUM_SEPOLIA_ROUTER=0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165
  1. Ethereum Sepolia โ†’ Arbitrum Sepolia (tokens are locked into the lockbox on Sepolia and minted on Arbitrum):
Terminal
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:

Terminal
๐Ÿš€ Sending message ... messageId => 0x...
CCIP Explorer: https://ccip.chain.link/msg/0x...
  1. Arbitrum Sepolia โ†’ Ethereum Sepolia (tokens are burned on Arbitrum and released from the lockbox on Sepolia):
Terminal
ccip-cli send \
  --source ethereum-testnet-sepolia-arbitrum-1 \
  --router $ARBITRUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia \
  --transfer-tokens $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN=1.23 \
  --receiver 0xYourReceiverAddress \
  --wallet foundry:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL"
  1. (Optional) Enable and request faster than finality with block depth 32.

First, allow block-depth faster than finality on your v2 pools:

SetFinalityConfig.s.sol

View the finality configuration script on GitHub.

Terminal
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:

Terminal
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"
10 Step 10: Confirm your token now routes through the 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.

  1. Ethereum Sepolia:
Terminal
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:

Terminal
Token Config:
  tokenPool:            0xNewSepoliaV2LockReleasePool

typeAndVersion: LockReleaseTokenPool 2.0.0
  1. Arbitrum Sepolia:
Terminal
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:

Terminal
Token Config:
  tokenPool:            0xNewArbitrumV2BurnMintPool

typeAndVersion: BurnMintTokenPool 2.0.0
11 Step 11: Safety checks and cleanup (after v1 in-flight 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.sol

View the remote pool removal script on GitHub.

  1. On the Sepolia v2 pool, remove the old Arbitrum v1 pool address:
Terminal
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
  1. On the Arbitrum v2 pool, remove the old Sepolia v1 pool address:
Terminal
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
AddRemotePool.s.sol

View the remote pool add script on GitHub (recovery).

Recovery example (re-add the old pool address, then retry cleanup later):

Terminal
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

What's next

Get the latest Chainlink content straight to your inbox.