# Burn & Mint V1 -> V2
Source: https://docs.chain.link/ccip/evm/concepts/cross-chain-token/burn-mint-v1-to-v2

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

## Overview

This migration flow covers upgrading a **burn and mint** token from v1.x to v2.0 token pools, where you have a
**BurnMintTokenPool on each chain**. It assumes your address is the registered administrator in the `TokenAdminRegistry`
on all chains.

This guide covers **EVM-to-EVM only**.

> **NOTE: Prefer scripts and commands?**
>
> For step-by-step scripts and runnable commands, follow the migration tutorials:
> [Hardhat](/ccip/evm/tutorials/cross-chain-tokens/migrate-from-v1-to-v2-burn-mint-hardhat) and
> [Foundry](/ccip/evm/tutorials/cross-chain-tokens/migrate-from-v1-to-v2-burn-mint-foundry).

For simplicity, the steps below illustrate a **2-chain setup (Chain A ↔ Chain B)**. If your token is deployed across
more chains, see [Multi-chain considerations](#multi-chain-considerations).

**Out of scope**

- Customized token pools

**Definitions**

- **v1 pool**: Any standard pool version 1.5.x or 1.6.x (`BurnMintTokenPool 1.5.1`, `BurnMintTokenPool 1.6.0`, etc.)
  - Includes `BurnMintTokenPool`, `BurnWithFromMintTokenPool`, `BurnFromMintTokenPool`; each concrete pool inherits
    `BurnMintTokenPoolAbstract`, which inherits the base `TokenPool` abstraction.
- **v2 pool**: Pool version 2.0 (`BurnMintTokenPool 2.0`) that you want to upgrade to.
- **TokenAdminRegistry**: The CCIP contract where a token ↔ token pool mapping is registered.

## Flow diagram

![Burn & Mint v1 to v2 migration flow diagram](/images/ccip/cct/migration/burn-mint-v1-to-v2-flow.png)

## Batching operations

If you control the administrator and pool owner addresses through a multisig that supports meta-transactions (for
example, Safe), you can batch multiple migration steps into a single atomic transaction.

Recommended groupings per chain:

### Batch 1: Configure + cutover

`applyChainUpdates` (Step 3) + `setPool` (Step 5). If you planned FTF rate limits or pool fees, put `setRateLimitConfig`
and `applyTokenTransferFeeConfigUpdates` between these two calls, since both revert until the remote chain is added.

This configures the v2 pool and activates it in a single transaction, so there is no window between configuration and
cutover.

### Batch 2: Cleanup

`removeRemotePool` (Step 7a) + `revokeMintRole` + `revokeBurnRole` (Step 7b).

Each batch must be executed **per chain**. You cannot batch cross-chain operations into a single transaction.

## Pre-flight checks

Before starting the migration, verify the following on **both chains**:

### 1. Confirm your admin status in TokenAdminRegistry

- Call `TokenAdminRegistry.getTokenConfig(tokenAddress)`. This returns a struct with three fields:
  - `.administrator`: must match your address
  - `.pendingAdministrator`: must be `address(0)` (no pending admin transfer in progress)
  - `.tokenPool`: the current v1 pool address (record this)
- If `.administrator` is a multisig or timelock, execute subsequent steps through that governance mechanism. If using a
  multisig like Safe, you can batch multiple steps into a single meta-transaction for atomicity.
- Note: even if `.pendingAdministrator` is non-zero, the current `.administrator` can still call `setPool`. However,
  resolve the pending transfer before you start to avoid confusion. For example, if an administrator change was
  intended in the past but is no longer relevant, call `transferAdminRole(localToken, address(0))` to cancel the
  pending transfer.

### 2. Confirm you can grant mint/burn roles on the token

- The new v2 pool will need mint and burn privileges on the existing token.
- Verify the address you control can call `grantMintAndBurnRoles` (or equivalent) on the token contract.
- If token ownership was transferred to a different address (different EOA, multisig, timelock, etc.), you must
  coordinate with that owner.
- **This is a hard blocker**: if you can't grant roles to the new pool, you can't migrate.

### 3. Record your existing v1 deployment details

Collect and export the following:

- **Token addresses** on each chain
- **v1 pool addresses** on each chain
- **Current rate limiter settings** on the v1 pools (inbound and outbound per remote chain). You'll replicate these on
  the new v2 pools.
  - Read via two separate calls per remote chain (v1.5.x / v1.6.x):
    - `pool.getCurrentInboundRateLimiterState(remoteChainSelector)` → returns the inbound `TokenBucket`.
    - `pool.getCurrentOutboundRateLimiterState(remoteChainSelector)` → returns the outbound `TokenBucket`.
    - Each `TokenBucket` is `{ tokens, lastUpdated, isEnabled, capacity, rate }`. The struct is identical across v1.5.1,
      v1.6.1, and v2.0, so you can copy the values directly.
    - Note: v2 collapses these into a single `getCurrentRateLimiterState(remoteChainSelector, fastFinality)` call
      returning both buckets, but v1 doesn't have that signature.
- **Current token transfer fee parameters** (if any were configured for your token). In v1, these are set by Chainlink on
  the OnRamp (v1.5) or FeeQuoter (v1.6), not on the pool. In v2, you can set these directly on your own pool. Record
  them so you can plan your v2 fee config in step 4:
  - **v1.5 lanes**: call `OnRamp.getTokenTransferFeeConfig(tokenAddress)` on the source chain OnRamp (one OnRamp per
    destination chain in v1.5).
  - **v1.6 lanes**: call `FeeQuoter.getTokenTransferFeeConfig(destChainSelector, tokenAddress)`. The FeeQuoter address
    can be found via `OnRamp.getDynamicConfig().feeQuoter`.
  - Both return: `{ minFeeUSDCents, maxFeeUSDCents, deciBps, destGasOverhead, destBytesOverhead, isEnabled }`.

### 4. Plan your v2 configuration

v2 pools introduce features not available in v1. Most settings can be changed post-deployment via setter functions.
However, a few are immutable (set in the constructor and cannot be changed): the token address, token decimals, and
RMN proxy. On the `AdvancedPoolHooks` contract, the `allowlistEnabled` flag is also immutable: if you deploy hooks with
an empty allowlist, you cannot enable allowlisting later without deploying a new hooks contract.

#### Pool variant selection

The migration flow is identical regardless of token interface. What changes is which v2 pool variant you deploy in Step
1:

| Token's burn function                        | v2 Pool to deploy            | Notes                                                                                                                                                                                    |
| :------------------------------------------- | :--------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `burn(uint256 amount)`                       | `BurnMintTokenPool`          | Standard (most common)                                                                                                                                                                   |
| `burnFrom(address, uint256 amount)`          | `BurnFromMintTokenPool`      | For tokens using allowance-based burn                                                                                                                                                    |
| `burn(address, uint256 amount)`              | `BurnWithFromMintTokenPool`  | Two-param burn variant                                                                                                                                                                   |
| Token has no burn (transfer to dead address) | `BurnToAddressMintTokenPool` | Uses `safeTransfer` to a preconfigured burn address instead of calling `burn`. Constructor takes an extra `burnAddress` parameter (immutable, so it cannot be changed after deployment). |

All variants call `mint(address account, uint256 amount)` for inbound transfers and the corresponding burn function for
outbound transfers.

#### Advanced pool hooks (constructor decision)

Decide whether to deploy an `AdvancedPoolHooks` contract and pass its address in the pool constructor
(`advancedPoolHooks` parameter). You can also pass `address(0)` and attach hooks later via
`updateAdvancedPoolHooks(IAdvancedPoolHooks)` (`onlyOwner`).

AdvancedPoolHooks enables:

- **Allowlisting**: Restrict which addresses can initiate transfers (moved from pool constructor in v1 to hooks in v2).
- **CCV (Cross-Chain Verifier) management**:
  - Configure per-chain verifiers for inbound and outbound transfers.
  - **Threshold amount for additional CCVs**: When a transfer amount meets or exceeds this threshold, additional CCVs
    are required on top of the base CCVs, which adds tiered security for high-value transfers.
- **Policy engine**: Attach a custom policy contract for pre-flight/post-flight validation via
  `setPolicyEngine(address)` (`onlyOwner`).

Constructor: `(address[] allowlist, uint256 thresholdAmountForAdditionalCCVs, address policyEngine, address[] authorizedCallers)`

Deployment order: The token pool must be an authorized caller on the AdvancedPoolHooks contract (the hooks validate
`msg.sender` via `_validateCaller()`). Since the pool needs the hooks address in its constructor and the hooks need the
pool as an authorized caller, deploy hooks first (with empty `authorizedCallers`), then deploy the pool with the hooks
address, then call `applyAuthorizedCallerUpdates` on the hooks to add the pool address.

If you don't need any of these features, pass `address(0)` for `advancedPoolHooks` in Step 1.

#### Faster-Than-Finality (FTF)

By default, CCIP waits for full source-chain finality before processing a transfer. FTF lets senders request a different
finality mode by setting `requestedFinalityConfig` in their CCIP `extraArgs`.

Multiple components validate the request. The OnRamp checks that the value encodes exactly one mode (one flag bit or a
block depth, never both). The pool checks that it falls within the pool's `allowedFinalityConfig`. The OffRamp checks it
against the receiver's finality config, except on token-only transfers.

As the pool owner, you control only `allowedFinalityConfig`.

**Encoding**: both fields are `bytes4` with the same bit layout:

- bits 16+: flags (bit 16 = `WAIT_FOR_SAFE_FLAG`, safe head instead of full finality)
- bits 0–15: block depth
- `bytes4(0)` (`WAIT_FOR_FINALITY_FLAG`) = full finality, the default for both fields

For `allowedFinalityConfig`: flag bits use bitwise AND (any overlapping flag accepts the request), and block depth is a
minimum (`requestedDepth >= allowedDepth`). You can combine both in one value, and the pool accepts a request when
either rule matches.

If you do not set block depth (bits 0–15 = 0), block-depth-based requests are rejected.

**Recommendation**: in Solidity code, prefer `FinalityCodec` constants/helpers (`WAIT_FOR_SAFE_FLAG`, `_encodeBlockDepth`,
`_encodeBlockDepthAndSafeFlag`) to avoid manual bitmath mistakes. For offchain tooling (Hardhat, Go, ...), implement the
same encoding logic in your language runtime, since these Solidity helper functions are `internal` and not ABI-callable.

**Examples:**

| Intent                                  | `bytes4` hex | How to construct in Solidity                                    |
| :-------------------------------------- | :----------- | :-------------------------------------------------------------- |
| Full finality (default)                 | `0x00000000` | `bytes4(0)`                                                     |
| Safe head                               | `0x00010000` | `bytes4(uint32(1 << 16))` or `FinalityCodec.WAIT_FOR_SAFE_FLAG` |
| Accept ≥10-block depth                  | `0x0000000A` | `bytes4(uint32(10))`                                            |
| Accept ≥100-block depth                 | `0x00000064` | `bytes4(uint32(100))`                                           |
| Pool accepts safe-head **or** ≥10-block | `0x0001000A` | `bytes4(uint32(1 << 16) \| uint32(10))`                         |

- Decide whether you want to allow FTF on this pool. If not, leave the default (`bytes4(0)`), and the pool only accepts
  full-finality transfers. If yes, choose which mode(s) to allow, then call
  `setAllowedFinalityConfig(bytes4 allowedFinality)`. Read the current value via `getAllowedFinalityConfig()`.
- When FTF is enabled, the pool keeps separate rate limiter buckets per remote chain: one for default
  (wait-for-finality) transfers and one for FTF transfers. If the fast-finality bucket is not enabled
  (`isEnabled = false`), FTF transfers fall back to the default bucket.
- v2 pools let you charge two independent fees per transfer, each with a default-finality and an FTF variant:
  - **Flat fee** (`finalityFeeUSDCents` / `fastFinalityFeeUSDCents`): included in the CCIP fee that `Router.getFee`
    returns, in place of the FeeQuoter's flat token transfer fee. The OnRamp transfers this amount directly to your pool
    contract.
  - **Bps fee** (`finalityTransferFeeBps` / `fastFinalityTransferFeeBps`): a percentage (in basis points) deducted from
    the token amount inside `lockOrBurn`. From the sender's perspective, this is not part of the CCIP fee that
    `Router.getFee` returns. However, the recipient gets fewer tokens. The pool retains the deducted tokens.
  - If you skip `applyTokenTransferFeeConfigUpdates` (leaving `isEnabled = false`), the pool tells the OnRamp it has no
    custom fee config, and the OnRamp falls back to the FeeQuoter, which only covers a flat USD fee plus gas and bytes
    overhead (no bps fee). If you do call it with `isEnabled = true` but leave all fee fields at zero, neither fee
    applies.

#### Rate limits

- v2 pools support separate rate limits for default (wait-for-finality) and FTF transfers per remote chain.
- Plan to copy your v1 rate limiter values (recorded in pre-flight step 3) into the default finality bucket. If you
  enable FTF, plan a separate rate limit for the fast-finality bucket.
- After `applyChainUpdates` adds the remote chain (Step 3), call `setRateLimitConfig(RateLimitConfigArgs[])`
  (`onlyOwner` or rate limit admin). The struct is:

```
struct RateLimitConfigArgs {
  uint64 remoteChainSelector;
  bool fastFinality;                        // false → default bucket, true → fast finality bucket
  RateLimiter.Config outboundRateLimiterConfig;
  RateLimiter.Config inboundRateLimiterConfig;
}
```

- To read current rate limits after configuration:
  - `getCurrentRateLimiterState(remoteChainSelector, fastFinality)` returns `(TokenBucket outbound, TokenBucket inbound)`
    in a single call.
  - Pass `false` for the default (wait-for-finality) bucket and `true` for the fast-finality bucket.

#### Token transfer fee configuration

In v2, you can control fees and other fee-impacting configs in your own pool.

Use the v1 fee data recorded in pre-flight step 3 and the mapping table below to plan your v2 fee config. After
`applyChainUpdates` adds the remote chain (Step 3), call
`applyTokenTransferFeeConfigUpdates(TokenTransferFeeConfigArgs[], uint64[] disableTokenTransferFeeConfigs)` on your pool
(`onlyOwner`).

**v1 → v2 field mapping**:

| v1 field            | v2 pool field       | Conversion                                                                                                                                                                     |
| :------------------ | :------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `destGasOverhead`   | `destGasOverhead`   | Same field. Once you enable a pool fee config, the pool's value replaces the FeeQuoter default (configured by Chainlink per destination chain). Required and must be non-zero. |
| `destBytesOverhead` | `destBytesOverhead` | Same field. Once you enable a pool fee config, the pool's value replaces the FeeQuoter default.                                                                                |

**v2-only fields** (no v1 equivalent; configure as needed):

| v2 pool field                | Description                                                                                                                                                                         |
| :--------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `finalityTransferFeeBps`     | Percentage-based fee (in basis points) deducted from the transferred token amount for default (wait-for-finality) transfers. Set to 0 by default.                                   |
| `finalityFeeUSDCents`        | Flat fee in USD cents for default (wait-for-finality) transfers. Set to 0 by default.                                                                                               |
| `fastFinalityFeeUSDCents`    | Flat fee in USD cents for FTF transfers. Set to 0 by default. Configure if you enable FTF and want a flat fee for FTF transfers (they don't use `finalityFeeUSDCents`).             |
| `fastFinalityTransferFeeBps` | Percentage-based fee (in basis points) deducted from the transferred token amount for FTF transfers. Set to 0 by default. Configure if you want a percentage fee for FTF transfers. |

#### Rate limit admin & fee admin delegation

v2 pools allow delegating specific responsibilities to separate addresses without giving full pool ownership:

- A **rate limit admin** can call `setRateLimitConfig()` to modify rate limits.
- A **fee admin** can call `withdrawFeeTokens(address[], address)` to withdraw the fees the pool accrues: any bps fee
  it deducts (`finalityTransferFeeBps`, `fastFinalityTransferFeeBps`) and the flat token transfer fee the OnRamp pays
  to v2 pools (`finalityFeeUSDCents`, `fastFinalityFeeUSDCents`, or the FeeQuoter's fee if you haven't enabled a pool
  fee config).

If you want to use delegation, decide which addresses will fill these roles. After deployment, call
`setDynamicConfig(address router, address rateLimitAdmin, address feeAdmin)` on the pool (`onlyOwner`) to assign them.

This replaces all three values at once, so pass the current value for any field you don't want to change.

#### Pool ownership transfer (recommended for production)

After migration, consider transferring pool ownership to a multisig or timelock contract via
`pool.transferOwnership(timelockAddress)` followed by acceptance through governance.

### 5. Verify pool ownership model

- After deploying the new v2 pool, the deployer becomes the initial owner.
- `applyChainUpdates` and `removeRemotePool` require **pool owner** (`onlyOwner`) privileges.
- `setPool` on TokenAdminRegistry requires **TokenAdminRegistry administrator** (`onlyTokenAdmin`) privileges.
- If these are different addresses, coordinate accordingly.

## Step 1: Deploy new v2 token pools

- Deploy a `BurnMintTokenPool 2.0` on **each chain**, pointing to the existing token address.
- Grant mint and burn roles to the new v2 pool on the token contract on each chain.
- **Constructor parameters**:

```
constructor(
    IBurnMintERC20 token,        // existing token address
    uint8 localTokenDecimals,    // token decimals on this chain
    address advancedPoolHooks,   // address(0) to skip, or AdvancedPoolHooks contract
    address rmnProxy,            // RMN proxy address for this chain
    address router               // CCIP router address for this chain
)
```

- Pass `address(0)` for `advancedPoolHooks` if you don't need allowlisting, custom CCVs, or a policy engine. You can
  attach hooks later via `updateAdvancedPoolHooks`.
- Network-specific addresses (RMN proxy, router) must match the current CCIP deployment on each chain. See the
  [CCIP Directory](/ccip/directory) for contract addresses per chain.

**Dual mint/burn window**: From this point until Step 7b, **both** old v1 and new v2 pools have mint/burn roles
on the token. The old v1 pool still routes live traffic. The new v2 pool has roles but is not yet active in CCIP. Both
pools can mint/burn independently during this window.

**State after this step**

- Old v1 pools: still active, still routing all CCIP traffic, still have mint/burn roles.
- New v2 pools: deployed, have mint/burn roles, but not yet connected to CCIP routing.

## Step 2 (optional): Pause outbound transfers on old v1 pools

- Restrict outbound transfers on the old v1 pools on both chains. Set the outbound rate limiter to `isEnabled: true`
  with `capacity: 2` and `rate: 1`. This is the strictest configuration that works across all pool versions (v1.5.x,
  v1.6.x). It allows at most 2 tokens initially with a 1 token/sec refill, which throttles transfers to near zero.
- **Do NOT use `isEnabled: false`**: this disables rate limiting entirely and allows unlimited transfers.
- Call `setChainRateLimiterConfig(remoteChainSelector, outboundConfig, inboundConfig)` on the v1 pool (`onlyOwner` or
  rate limit admin). Keep inbound unchanged so in-flight messages can still arrive.
- This blocks new cross-chain transfers for a brief maintenance window while the migration completes.

**Why this is optional**

- The `setPool` call in Step 5 is atomic. Once it executes, new traffic immediately routes through the v2 pool.
- Because Step 3 configures the new pool to recognize both old and new remote pools, in-flight messages are handled
  safely without a pause.
- However, pausing gives you a clean cutover with zero overlap if you prefer that.

**If you skip this step**

- There is a short window between `setPool` on chain A and `setPool` on chain B where one chain routes through the new
  pool and the other still routes through the old pool. This is safe because of the dual remote pool configuration in
  Step 3, but pausing eliminates this window entirely.

## Step 3: Configure new v2 pools (`applyChainUpdates`)

On each chain, call `applyChainUpdates` on the new v2 pool to configure remote chain connections.

### Function signature

```
function applyChainUpdates(
    uint64[] calldata remoteChainSelectorsToRemove,
    ChainUpdate[] calldata chainsToAdd
) external onlyOwner
```

Each `ChainUpdate` contains:

- `remoteChainSelector` (uint64)
- `remotePoolAddresses` (bytes\[]): array of remote pool addresses
- `remoteTokenAddress` (bytes): the token address on the remote chain
- `outboundRateLimiterConfig` (isEnabled, capacity, rate)
- `inboundRateLimiterConfig` (isEnabled, capacity, rate)

### Remote pool addresses: include both old and new

The `remotePoolAddresses` array must include **both** the old v1 pool **and** the new v2 pool on the remote chain. You
pass both addresses in a single `applyChainUpdates` call, so no separate `addRemotePool` calls are needed. Because the
contract stores remote pools in an `EnumerableSet`, adding a pool never replaces an existing one, and a remote chain can
have multiple pools.

- v2 pool on chain A: `remotePoolAddresses = [encode(v1ChainBPool), encode(v2ChainBPool)]`
- v2 pool on chain B: `remotePoolAddresses = [encode(v1ChainAPool), encode(v2ChainAPool)]`

**Address encoding**: `remotePoolAddresses` is typed as `bytes[]` and `remoteTokenAddress` as `bytes`. The contract
stores whatever bytes you pass, with no format validation beyond a non-zero-length check.

- **EVM remote chain**: left-pad the 20-byte address to 32 bytes. In Solidity: `abi.encode(poolAddress)`. In Go:
  `common.LeftPadBytes(addr.Bytes(), 32)`. Both produce the same 32 bytes: the 20-byte address right-aligned with 12
  leading zero bytes.

**Why both pools must be listed**: Messages sent through the old v1 pool before the cutover may still be in-flight. When
they arrive at the destination, the destination pool checks that the source pool address encoded in the message matches
one of its configured remote pool addresses. Without the old v1 pool address in the list, these in-flight
messages would fail validation.

### Rate limiter configuration

- Rate limiters are configured **atomically** as part of the `applyChainUpdates` call (not a separate transaction).
- Replicate your existing v1 rate limiter settings (or adjust as needed for v2). The struct is identical between v1 and
  v2.
- Configure both inbound and outbound rate limits per remote chain.
- If you paused transfers in Step 2, set the v2 pool rate limits to your desired production values (not zero, because
  the v2 pool needs to be ready to handle traffic after cutover).
- If you enabled FTF (see pre-flight step 4), configure the fast-finality rate limits separately via
  `setRateLimitConfig` after this call.
- **Note**: setting `isEnabled: false` means unlimited transfers (no rate limiting). Set `isEnabled: true` with
  appropriate capacity and rate to enforce limits.

### Remote token address

- Set the remote token address for each remote chain (same token addresses as your v1 configuration).

### Error recovery

`applyChainUpdates` reverts with `ChainAlreadyExists` if the remote chain is already configured. You cannot call it twice
for the same chain. If you need to fix a misconfiguration after the initial call:

- **Add a missing remote pool**: call `addRemotePool(remoteChainSelector, encodedRemotePoolAddress)` (`onlyOwner`).
  The second argument is `bytes`, encoded as described above.
- **Remove a wrong remote pool**: call `removeRemotePool(remoteChainSelector, encodedRemotePoolAddress)` (`onlyOwner`).
  Pass the same bytes you stored when adding it.
- **Change rate limiters**: call `setRateLimitConfig(RateLimitConfigArgs[])` (`onlyOwner` or rate limit admin).
- **Fully reconfigure a chain**: first remove it via `applyChainUpdates([chainSelector], [])`, then re-add it with the
  correct configuration.

## Step 4: Verify new pool configuration

Before cutting over, verify the new v2 pools are correctly configured on both chains.

Core checks:

- [ ] Remote chain configs include both old v1 and new v2 remote pool addresses.
- [ ] Remote token addresses are correct.
- [ ] Inbound and outbound rate limiters match your intended settings.
- [ ] The new v2 pool has mint and burn roles on the token (verify via the token contract's role-checking functions).
- [ ] Pool owner is the address you control (`pool.owner()`).
- [ ] `TokenAdminRegistry.getTokenConfig(token).pendingAdministrator` is `address(0)`.

If you configured v2 features (from pre-flight step 4 planning):

- [ ] AdvancedPoolHooks: pool is an authorized caller on the hooks contract (`hooks.getAllAuthorizedCallers()` includes
  pool).
- [ ] FTF: `getAllowedFinalityConfig()` returns your intended `bytes4` value.
- [ ] FTF rate limits: configured if planned (check via `getCurrentRateLimiterState(remoteChainSelector, true)`;
  `true` selects the fast-finality bucket).
- [ ] Token transfer fees: configured if planned (call
  `getTokenTransferFeeConfig(address(0), destChainSelector, bytes4(0), "")` on the pool and verify the returned
  `TokenTransferFeeConfig` matches your intended values).
- [ ] Admin delegation: `getDynamicConfig()` returns the correct router, rateLimitAdmin, and feeAdmin.

Verify everything before proceeding, because `setPool` in the next step routes live traffic through the v2 pools.

## Step 5: Cut over to the new v2 pools (`setPool`)

- Ensure all v2 features planned in pre-flight step 4 (hooks, FTF, rate limits, fees, admin delegation) are
  configured before proceeding. Once `setPool` executes, live traffic flows through the v2 pool.
- On **each chain**, call `TokenAdminRegistry.setPool(tokenAddress, newV2PoolAddress)` to point the registry to the new v2
  pool.
- Call `setPool` directly, since your address is already the registered administrator. Do **not** call
  `proposeAdministrator` or `acceptAdminRole`; these register or transfer the administrator role and are not part of a
  pool upgrade.
- Once executed on a chain, all new CCIP transfers for this token on that chain route through the new v2 pool.
- Execute on both chains. The order does not matter because of the dual remote pool configuration from Step 3, which
  supports both old-to-new and new-to-new message flows.

**What happens to in-flight messages after cutover**

- Messages sent through the v1 pool before `setPool` will still arrive at the destination.
- The destination's new v2 pool recognizes the old v1 remote pool address (configured in Step 3), so these messages
  validate and execute correctly.
- No messages are lost or stuck.

**Rollback**: If you discover issues after cutover on one chain, you can call
`TokenAdminRegistry.setPool(tokenAddress, oldV1PoolAddress)` to revert that chain back to the v1 pool. This is safe
because the v1 pool is still deployed and still has mint/burn roles. However, any in-flight messages sent through the v2
pool during the brief window will still need the v2 remote pool address configured on the destination, so only roll
back if necessary and plan accordingly.

## Step 6: Validate end-to-end

- Send a cross-chain token transfer using `ccip-cli` and verify it completes successfully on the destination chain.
  `ccip-cli` uses the CCIP SDK and queries `api.ccip.chain.link` to track message status end-to-end.
- Confirm the transfer was routed through the new v2 pool (check transaction logs for the v2 pool address).
- Test a transfer in the reverse direction to validate both paths.

## Step 7: Post-migration cleanup

**Important**: Perform these steps only **after all in-flight messages from the old v1 pools have settled**.

**How to verify**: Check the [CCIP Explorer](https://ccip.chain.link/) or use `ccip-cli` (which queries
`api.ccip.chain.link`) to confirm all messages originating from
the old v1 pool addresses show SUCCESS status.

**Additional safety margin**: Even after verifying via CCIP Explorer or ccip-cli, wait a couple of extra days as a
precaution. Waiting longer costs nothing, because keeping old v1 pool addresses in the remote pool list has no
performance impact.

**Note**: Steps 7a and 7b are optional. The old pools are already inert (removed from TokenAdminRegistry) and pose
minimal risk if cleanup is deferred.

### 7a. Remove old v1 remote pool addresses from the new v2 pools

Two mechanisms are available:

- `removeRemotePool(uint64 remoteChainSelector, bytes calldata remotePoolAddress)`: removes a specific pool address from
  a specific remote chain (`onlyOwner`). Use this when you want to keep the chain connected but remove only the old v1
  pool address. This is the correct approach for migration cleanup.
- `applyChainUpdates(remoteChainSelectorsToRemove, [])`: removes an entire remote chain from the pool configuration.
  Only use this if you want to completely disconnect from a remote chain (not typical for migration).

For migration cleanup, call `removeRemotePool` on each chain:

- On chain A v2 pool: `removeRemotePool(chainBSelector, encodedOldV1ChainBPoolAddress)`
- On chain B v2 pool: `removeRemotePool(chainASelector, encodedOldV1ChainAPoolAddress)`

**Caution**: Removing a remote pool address causes any still-pending messages from that pool to fail validation on the
destination. Only proceed once you are confident all old v1 messages have been executed (verify via
[CCIP Explorer](https://ccip.chain.link/) or by searching transactions with ccip-cli).

If you remove prematurely and discover a stuck in-flight message, you can re-add the v1 pool address using
`addRemotePool(remoteChainSelector, encodedOldV1PoolAddress)` (`onlyOwner`) to unblock it. Use the same encoded bytes as
before.

### 7b. (Optional) Revoke mint and burn roles from old v1 pools

The v1 pool is already removed from the TokenAdminRegistry at this point, so it can no longer route CCIP traffic.
Revoking its mint/burn roles on the token contract is an extra hardening step that removes any residual ability to
mint or burn tokens.

How you do this depends on which token contract you're using, as each has its own access control model.

- `BurnMintERC677`: does not use OpenZeppelin AccessControl. Roles are managed through an owner-controlled address set.
  Call `revokeMintRole(oldV1PoolAddress)` and `revokeBurnRole(oldV1PoolAddress)`. Both are `onlyOwner`.
- `BurnMintERC20`: uses OpenZeppelin AccessControl with `DEFAULT_ADMIN_ROLE` as the role admin for `MINTER_ROLE` and
  `BURNER_ROLE`. Call `revokeRole(MINTER_ROLE, oldV1PoolAddress)` and `revokeRole(BURNER_ROLE, oldV1PoolAddress)`. Caller
  must hold `DEFAULT_ADMIN_ROLE`.
- `CrossChainToken` (the chainlink-ccip 2.0 token standard): also uses AccessControl, but explicitly sets
  `BURN_MINT_ADMIN_ROLE` (`keccak256("BURN_MINT_ADMIN_ROLE")`) as the role admin for both roles. Same `revokeRole` calls
  as above, but caller must hold `BURN_MINT_ADMIN_ROLE` instead.

Before running this step, verify which token contract you have and that your address holds the required role.

**State after cleanup**

- v2 pools: active, routing all CCIP traffic, only v2 remote pools configured.
- v1 pools: orphaned, no longer in TokenAdminRegistry, no mint/burn roles (if revoked).

## Multi-chain considerations

### 3+ chain deployments

This guide covers a 2-chain scenario. For tokens deployed across 3+ chains:

- Each pool must configure remote pool entries for **every other chain** (both old v1 and new v2 pools).
- Example for a 3-chain deployment (Ethereum, Arbitrum, Avalanche). Each pool gets one `ChainUpdate` per remote chain,
  and each `remotePoolAddresses` array holds that chain's encoded pool addresses:
  - Ethereum v2 pool: Arbitrum `[encode(v1ArbitrumPool), encode(v2ArbitrumPool)]` and Avalanche
    `[encode(v1AvalanchePool), encode(v2AvalanchePool)]`
  - Arbitrum v2 pool: Ethereum `[encode(v1EthereumPool), encode(v2EthereumPool)]` and Avalanche
    `[encode(v1AvalanchePool), encode(v2AvalanchePool)]`
  - Avalanche v2 pool: Ethereum `[encode(v1EthereumPool), encode(v2EthereumPool)]` and Arbitrum
    `[encode(v1ArbitrumPool), encode(v2ArbitrumPool)]`

### Gradual migration

You can migrate chains sequentially (not all at once), but remote pool configuration must be bilateral. Before
activating a v2 pool on chain A via `setPool`, the old v1 pool on chain B must be updated to recognize chain A's new v2
pool address. Otherwise chain B rejects messages from chain A's v2 pool (`InvalidSourcePoolAddress`).

For each chain you migrate:

1. On all remote chains that still have v1 pools, call `addRemotePool(chainSelector, encodedNewV2PoolAddress)` on the v1
   pool to add the new v2 pool address.
2. Then call `setPool` on the chain being migrated.

This ensures the v1 pools on not-yet-migrated chains accept messages from newly activated v2 pools.

v2-specific features only work fully when both the source and destination pools are v2:

- FTF requests from a chain still running a v1 pool are rejected by the OnRamp.
- On a chain that still runs a v1 pool, CCIP uses the lane's default CCVs instead of pool-specific CCV requirements.
- Pool-level fee overrides only apply when the source pool is v2.

To use v2 features on a lane, both ends must be migrated to v2 first.

> **CAUTION: Disclaimer**
>
> Chainlink CCIP is an interoperability messaging protocol. Chainlink does not hold or transfer any assets. The
> performance and behaviour of applications using Chainlink CCIP may depend on coding, engineering, configuration, and
> other technical implementation choices made by developers, token issuers, Cross-Chain Verifiers, and other
> participants. Users remain responsible for evaluating, configuring, testing, deploying, operating, and maintaining
> their own applications and integrations, including assessing any applicable operational, security, technical, and
> legal or regulatory risks. Please review the [Chainlink Terms of Service](https://chain.link/terms) which provides
> important information and disclosures. By using Chainlink CCIP, you expressly acknowledge and agree to accept these
> terms. Cross-Chain Verifiers (CCVs) may be operated by third parties. The security, availability, governance, and
> operational profile of a CCV varies depending on the verifier selected. Users are solely responsible for evaluating
> any CCVs used in connection with their applications or integrations and determining whether they are appropriate for
> their intended use case. This code represents an example of using a Chainlink product or service. It is provided "AS
> IS" and "AS AVAILABLE" without warranties of any kind, has not been audited, and may omit checks or error handling.
> Each party intending to use this reference implementation must perform its own audits, security and code review, and
> testing before any production deployment and ensure the operation and performance of such code matches expectations.
> 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. Use of the Chainlink Network is subject to the Chainlink
> Foundation Terms of Service, which provides important information and disclosures. By using this code, you acknowledge
> and agree to these terms.