# Migrate your CCT token pools from CCIP v1 to v2 (Lock & Mint) using Foundry
Source: https://docs.chain.link/ccip/evm/tutorials/cross-chain-tokens/migrate-from-v1-to-v2-lock-mint-foundry
Last Updated: 2026-06-11

> For the complete documentation index, see [llms.txt](/llms.txt).

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 2.0**, while keeping your existing token addresses and minimizing disruption.

This guide uses Foundry scripts inside our [docs-cct-foundry](https://github.com/smartcontractkit/docs-cct-foundry) repository.

> **NOTE: Lock & Mint is asymmetric**
>
> In a Lock & Mint deployment the two chains are **not** symmetric. The canonical (source) chain runs a **Lock &
> Release** pool backed by an **ERC20 lockbox** that holds the locked liquidity; the destination chain runs a **Burn &
> Mint** pool. Because of this, the migration has an extra step that Burn & Mint does not: you must **move the locked
> liquidity** out of the old v1 pool and into the new v2 lockbox.

> **NOTE: Finality and v2 features are optional**
>
> 1. **Default finality:** Transfers use the chain’s native finality unless you explicitly opt into
>    block-depth faster than finality.
> 2. **Opt-in changes:** Pool-level fees, faster than finality, and hooks only apply if you configure
>    them. This guide focuses on a safe pool migration first.

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 2.0 pools: an [`ERC20LockBox`](https://github.com/smartcontractkit/chainlink-ccip/tree/contracts-ccip-v2.0.0/chains/evm/contracts/pools/ERC20LockBox.sol) + [`LockReleaseTokenPool`](https://github.com/smartcontractkit/chainlink-ccip/tree/contracts-ccip-v2.0.0/chains/evm/contracts/pools/LockReleaseTokenPool.sol) on the canonical chain, and a [`BurnMintTokenPool`](https://github.com/smartcontractkit/chainlink-ccip/tree/contracts-ccip-v2.0.0/chains/evm/contracts/pools/BurnMintTokenPool.sol) 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`](https://github.com/smartcontractkit/chainlink-ccip/tree/contracts-ccip-v2.0.0/chains/evm/contracts/tokenAdminRegistry/TokenAdminRegistry.sol).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](https://nodejs.org/en/download/).
   - `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](https://docs.chain.link/ccip/tools/cli/). You can also find the [GitHub repository here](https://github.com/smartcontractkit/ccip-cli).

```bash filename="Terminal"
npm install -g @chainlink/ccip-cli
```

Verify the installation by running the following command:

```bash filename="Terminal"
ccip-cli --version
```

3. **Install Foundry**:
   If you haven't already, follow the instructions in the [Foundry documentation](https://book.getfoundry.sh/getting-started/installation) to install Foundry. \
   Verify the installation by running the following command:

```bash filename="Terminal"
forge --version
```

4. **Clone the repository and navigate to the project directory**:

[CCIP 2.0 Foundry template](https://github.com/smartcontractkit/docs-cct-foundry)

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

```bash filename="Terminal"
git clone https://github.com/smartcontractkit/docs-cct-foundry.git
cd docs-cct-foundry
```

5. Create an encrypted Foundry keystore, if you haven't already:

```bash filename="Terminal"
cast wallet import your_keystore_name --interactive
```

6. Create a `.env` file by copying the `.env.example` file, and fill in the required values:

```bash filename="Terminal"
cp .env.example .env
```

```bash filename=".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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/HelperConfig.s.sol)

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

7. To make sure your terminal has access to these variables, run the following command:

```bash filename="Terminal"
source .env
```

8. Install dependencies and build the project:

```bash filename="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`](https://github.com/smartcontractkit/chainlink-ccip/tree/contracts-ccip-v2.0.0/chains/evm/contracts/pools/SiloedLockReleaseTokenPool.sol)) 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

> **TIP: Set these variables once for cleaner copy/paste**
>
> This guide uses explicit addresses so you don’t need local `script/deployments/` files. Start by exporting
> your token addresses; we’ll export the v1/v2 pool addresses and the lockbox address as we discover or deploy them.
>
> ```bash filename="Terminal"
> export ETHEREUM_SEPOLIA_TOKEN=0x...
> export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN=0x...
> ```

### 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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/setup/GetTokenConfig.s.sol)

View the token config query script on GitHub.

1. Read the token config on **Ethereum Sepolia** (canonical / Lock & Release):

```bash filename="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:

```bash filename="Terminal"
Token Config:
  administrator:        0xYourAdminAddress
  pendingAdministrator: 0x0000000000000000000000000000000000000000
  tokenPool:            0xOldSepoliaV1LockReleasePool
```

Export the v1 pool address:

```bash filename="Terminal"
export ETHEREUM_SEPOLIA_V1_POOL=0xOldSepoliaV1LockReleasePool
```

2. Repeat on **Arbitrum Sepolia** (destination / Burn & Mint):

```bash filename="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:

```bash filename="Terminal"
Token Config:
  administrator:        0xYourAdminAddress
  pendingAdministrator: 0x0000000000000000000000000000000000000000
  tokenPool:            0xOldArbitrumV1BurnMintPool
```

Export the v1 pool address:

```bash filename="Terminal"
export ARBITRUM_SEPOLIA_V1_POOL=0xOldArbitrumV1BurnMintPool
```

[GetTypeAndVersion.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/setup/GetTypeAndVersion.s.sol)

View the type/version query script on GitHub.

3. Confirm the v1 pool version on **Ethereum Sepolia** (expect a Lock & Release pool):

```bash filename="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:

```bash filename="Terminal"
typeAndVersion: LockReleaseTokenPool 1.6.1
```

4. Confirm the v1 pool version on **Arbitrum Sepolia** (expect a Burn & Mint pool):

```bash filename="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:

```bash filename="Terminal"
typeAndVersion: BurnMintTokenPool 1.6.1
```

> **NOTE: What to check before continuing**
>
> - **administrator** should match the account/multisig you control (this account must execute `setPool` during cutover).
> - **pendingAdministrator** should typically be `0x0`. If it’s set, resolve it before
>   migrating to avoid confusion.
> - **typeAndVersion** should indicate a v1.5.x or v1.6.x `LockReleaseTokenPool` on Ethereum
>   Sepolia and a v1.5.x or v1.6.x `BurnMintTokenPool` on Arbitrum Sepolia.

### 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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/setup/GetSupportedChains.s.sol)

View the supported-chains query script on GitHub.

1. List configured remote chains on the **Sepolia v1 pool**:

```bash filename="Terminal"
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL forge script \
  script/setup/GetSupportedChains.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
```

2. List configured remote chains on the **Arbitrum v1 pool**:

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/rate-limiter/GetCurrentRateLimits.s.sol)

View the rate limiter query script on GitHub.

3. Snapshot current v1 rate limits on the **Sepolia → Arbitrum** lane:

```bash filename="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:

```bash filename="Terminal"
Outbound Enabled:  true
Outbound Capacity: 1000000000000000000000
Outbound Rate:     100000000000000000
Inbound  Enabled:  true
Inbound  Capacity: 1000000000000000000000
Inbound  Rate:     100000000000000000
```

4. Snapshot current v1 rate limits on the **Arbitrum → Sepolia** lane:

```bash filename="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.

5. 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:

```bash filename="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:

```bash filename="Terminal"
export DRAIN_AMOUNT=<the balance printed above, in wei>
```

> **CAUTION: Record rate limits before you pause**
>
> Step 4 overwrites the v1 pool’s **outbound** rate limits. If you want to carry your original v1 rate
> limits over to v2, make sure you record them here first.

### 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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/deploy/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:

```bash filename="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:

```bash filename="Terminal"
ERC20LockBox deployed at: 0xNewSepoliaLockBox
✅ ERC20LockBox deployed successfully!
```

Export the lockbox address:

```bash filename="Terminal"
export LOCK_BOX=0xNewSepoliaLockBox
```

[DeployLockReleaseTokenPool.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/deploy/DeployLockReleaseTokenPool.s.sol)

View the Lock & Release pool deployment script on GitHub.

2. Deploy the v2 `LockReleaseTokenPool` on **Ethereum Sepolia**, passing the lockbox address:

```bash filename="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:

```bash filename="Terminal"
Token Pool deployed at: 0xNewSepoliaV2LockReleasePool
✅ LockReleaseTokenPool deployed successfully!
```

Export the new v2 pool address:

```bash filename="Terminal"
export ETHEREUM_SEPOLIA_V2_POOL=0xNewSepoliaV2LockReleasePool
```

[UpdateAuthorizedCallers.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol)

View the authorized callers script on GitHub.

3. Authorize the new v2 pool as a **caller on the lockbox** so it can deposit and withdraw tokens during cross-chain transfers:

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/deploy/DeployBurnMintTokenPool.s.sol)

View the Burn & Mint pool deployment script on GitHub.

4. Deploy the v2 `BurnMintTokenPool` on **Arbitrum Sepolia**:

```bash filename="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:

```bash filename="Terminal"
Token Pool deployed at: 0xNewArbitrumV2BurnMintPool
✅ Roles granted successfully!
```

Export the new v2 pool address:

```bash filename="Terminal"
export ARBITRUM_SEPOLIA_V2_POOL=0xNewArbitrumV2BurnMintPool
```

> **CAUTION: If the role grant is skipped**
>
> The deploy script tries to call `grantMintAndBurnRoles` on your Arbitrum token. If your token predates that
> interface, the script prints a warning instead of `✅ Roles granted successfully!` and you must grant **mint** and **burn** roles to `$ARBITRUM_SEPOLIA_V2_POOL` yourself, using
> whatever access-control mechanism your token exposes. The new pool cannot mint or burn until it holds these roles.

> **NOTE: Dual-role window**
>
> Until post-migration cleanup, **both** v1 and v2 Burn & Mint pools may hold mint/burn roles on your
> Arbitrum token. The new Ethereum Sepolia pool is deployed and authorized on the lockbox but is still **empty** (no liquidity yet). Your CCIP routing still points to the v1 pools until Step 8.

### 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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/rate-limiter/UpdateRateLimiters.s.sol)

View the rate limiter update script on GitHub.

1. Pause outbound on the **Sepolia v1 pool** (lane to Arbitrum Sepolia):

```bash filename="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
```

2. Pause outbound on the **Arbitrum v1 pool** (lane to Ethereum Sepolia):

```bash filename="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
```

> **CAUTION: Wait for in-flight transfers to settle before draining**
>
> - After pausing, wait until all in-flight transfers in **both** directions show a final status in the [CCIP
>   Explorer](https://ccip.chain.link/) before you drain liquidity in Step 5. - If you drain the Sepolia v1 pool while a
>   Arbitrum → Sepolia (mint → release) message is still in flight, that message would try to release from the now-empty
>   v1 pool: which is still the registered pool until cutover, and fail.

### 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.

> **CAUTION: Non-atomic custody**
>
> Executed as separate EOA transactions, the drained tokens sit in **your wallet** between the withdrawal and the
> deposit. Use a secure signer (for example, a hardware wallet). Teams that require atomicity can batch these calls
> through a Safe multisig meta-transaction, which is outside the scope of this guide.

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

```bash filename="Terminal"
export MY_ADDRESS=0xYourKeystoreAccountAddress
```

[GetRebalancer.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/liquidity/GetRebalancer.s.sol)

View the rebalancer query script on GitHub.

1. (Optional) Read the current rebalancer on the v1 pool:

```bash filename="Terminal"
TOKEN_POOL=$ETHEREUM_SEPOLIA_V1_POOL forge script \
  script/configure/liquidity/GetRebalancer.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
```

[SetRebalancer.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/liquidity/SetRebalancer.s.sol)

View the set-rebalancer script on GitHub.

2. Set yourself as the rebalancer on the v1 pool (only the rebalancer can withdraw liquidity; this call must be sent by the **pool owner**):

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/liquidity/WithdrawLiquidity.s.sol)

View the withdraw-liquidity script on GitHub.

3. 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:

```bash filename="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
```

4. 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:

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/operations/DepositToLockBox.s.sol)

View the lockbox deposit script on GitHub.

5. Deposit the tokens into the lockbox. The script approves the lockbox and deposits in a single run:

```bash filename="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
```

6. Remove your address from the lockbox authorized callers, leaving only the pool authorized:

```bash filename="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
```

7. Verify the v1 pool is fully drained (this should return `0`):

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/setup/ApplyChainUpdates.s.sol)

View the applyChainUpdates script (JSON mode) on GitHub.

[apply-chain-updates.json](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/input/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`:

```json filename="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**:

```bash filename="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:

```bash filename="Terminal"
Remote Chains: 1

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

✅ Chain updates applied successfully!
```

2. Configure the **Arbitrum v2 Burn & Mint pool** (remote chain: Ethereum Sepolia).

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

```json filename="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**:

```bash filename="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
```

> **NOTE: Re-running is safe**
>
> The script is idempotent: if a remote chain is already configured, it removes and re-adds it in the same transaction.
> You do not need to worry about a “chain already exists” error.

### 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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/remote-pools/GetRemotePools.s.sol)

View the remote pool query script on GitHub.

1. Check remote pools on the **Sepolia v2 pool**:

```bash filename="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:

```bash filename="Terminal"
Chain Supported: Yes
Remote Pools:    2
  [0] 0xOldArbitrumV1BurnMintPool
  [1] 0xNewArbitrumV2BurnMintPool
```

2. Check remote pools on the **Arbitrum v2 pool**:

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/rate-limiter/GetCurrentRateLimits.s.sol)

View the rate limiter query script on GitHub.

3. (Optional but recommended) Re-check rate limits on the v2 pools:

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/GetLockBox.s.sol)

View the lockbox query script on GitHub.

4. Confirm the lockbox is attached to the Sepolia v2 pool and holds the migrated liquidity (`Balance` should equal your `DRAIN_AMOUNT`):

```bash filename="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:

```bash filename="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.

> **NOTE: Who must run this step**
>
> The signer running `setPool` must be the token’s `TokenAdminRegistry` **administrator** on that chain (as shown by `getTokenConfig` in Step 1).

[SetPool.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/setup/SetPool.s.sol)

View the setPool script on GitHub.

1. Cut over on **Ethereum Sepolia**:

```bash filename="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:

```bash filename="Terminal"
✅ Pool set successfully!
```

2. Cut over on **Arbitrum Sepolia**:

```bash filename="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.

> **CAUTION: Rollback is harder for Lock & Mint**
>
> You can roll back by calling `setPool` again and pointing back to your v1 pool on a chain. But for Lock &
> Mint this is painful once you have cut over: the Sepolia v1 pool has been drained, so you would first have to re-fund
> it (via `provideLiquidity` as its rebalancer) before it can release again, and liquidity is now split
> between the old pool and the lockbox. Prefer resolving issues **before** the liquidity migration in Step
> 5\.

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

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

> **NOTE: Prerequisites for the sends**
>
> 1. You need some of your token on the source chain to send.
> 2. You need Sepolia ETH and Arbitrum Sepolia ETH for gas and fees.

Set the router addresses (from the [CCIP Directory](https://docs.chain.link/ccip/directory/testnet) or `HelperConfig.s.sol` in the template):

```bash filename="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):

```bash filename="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:

```bash filename="Terminal"
🚀 Sending message ... messageId => 0x...
CCIP Explorer: https://ccip.chain.link/msg/0x...
```

2. **Arbitrum Sepolia → Ethereum Sepolia** (tokens are **burned** on Arbitrum and **released** from the lockbox on Sepolia):

```bash filename="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"
```

3. (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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/finality-config/SetFinalityConfig.s.sol)

View the finality configuration script on GitHub.

```bash filename="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`:

```bash filename="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:

```bash filename="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:

```bash filename="Terminal"
Token Config:
  tokenPool:            0xNewSepoliaV2LockReleasePool

typeAndVersion: LockReleaseTokenPool 2.0.0
```

2. Arbitrum Sepolia:

```bash filename="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:

```bash filename="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](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/remote-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:

```bash filename="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
```

2. On the **Arbitrum v2 pool**, remove the old Sepolia v1 pool address:

```bash filename="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
```

> **CAUTION: If you removed too early**
>
> If a v1-sent message is still in-flight and you removed its source pool address, it can fail validation on the
> destination. You can recover by adding the pool address back with `AddRemotePool`, then retry cleanup
> later.

[AddRemotePool.s.sol](https://github.com/smartcontractkit/docs-cct-foundry/blob/main/script/configure/remote-pools/AddRemotePool.s.sol)

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

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

```bash filename="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
```

> **NOTE: Optional hardening**
>
> On **Arbitrum Sepolia**, you can revoke mint/burn roles from the old v1 Burn & Mint pool (this is token-specific
> and may require calling role-revocation functions directly on your token contract). On **Ethereum Sepolia**, the old
> v1 Lock & Release pool never held mint/burn roles and is already drained and removed from the
> `TokenAdminRegistry`, so it is inert; no revocation is needed.

> **CAUTION: Educational Example Disclaimer**
>
> Please note, this page contains community examples only — these are not Chainlink products or services and are not
> supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is
> intended for demonstration and educational purposes only. It is provided "AS IS" and "AS AVAILABLE" without warranties
> of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this
> example code does so entirely at their own risk and must perform its own audits, security and code review, key
> management, and testing before any production deployment and ensure the operation and performance of such code matches
> expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses
> any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no
> commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely
> on this code without first conducting your own technical, engineering, and security review. This code is also outside
> the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node
> operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for
> any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of
> Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you
> acknowledge and agree to these terms.