CCT on EVM
Use EVMTokenManager for CCT administration on EVM chains. It wraps an EVMChain and supports both signed and unsigned transaction workflows.
import { EVMChain, networkInfo } from '@chainlink/ccip-sdk'
import { EVMTokenManager } from '@chainlink/ccip-sdk/cct/evm'
const chain = await EVMChain.fromUrl(process.env.RPC_URL!)
const cct = EVMTokenManager.fromChain(chain)
// The signed operations below take a `wallet`, an ethers `Signer` (or a viem wallet). It must be
// the token, pool, or registry owner for owner-gated writes. See Multi-Chain to construct it:
// const wallet = new Wallet(process.env.PRIVATE_KEY!, chain.provider)
See Multi-Chain to construct chain and a wallet for your signing setup (ethers Signer, viemWallet(client), and other options).
Required configuration
| Value | Purpose | Where it comes from |
|---|---|---|
| RPC URL | Connects the manager to the source EVM chain. | Your node provider. |
| Router and RMN proxy | Required to deploy the pool and configure CCIP routing. | The CCIP Directory, per chain. Not bundled in the SDK for EVM. |
| Registry module | Required by registerAdmin (RegistryModuleOwnerCustom). Deployment-specific; cannot be discovered on-chain. | The CCIP Directory, per chain. |
| Chain selectors | Identify remote chains in applyChainUpdates. A CCIP selector, not the chain ID. | networkInfo('<network>').chainSelector. |
| Lockbox | Required only for a v2.0.0 LockReleaseTokenPool. It must escrow the same token. | Deployed with deployLockbox. See Lock/release pools. |
The CCT-specific deployment addresses come from the CCIP Directory, and chain selectors from the SDK's network registry:
const ROUTER = process.env.ROUTER! // Router for the target CCIP deployment (CCIP Directory)
const RMN_PROXY = process.env.RMN_PROXY! // RMN (Risk Management Network) proxy (CCIP Directory)
const REGISTRY_MODULE = process.env.REGISTRY_MODULE! // RegistryModuleOwnerCustom; only registerAdmin needs it
// Chain selectors are not chain IDs. Resolve them from the SDK's network registry:
const DEST_CHAIN_SELECTOR = networkInfo('avalanche-testnet-fuji').chainSelector
A testnet can host more than one CCIP deployment, each with its own Router, RMN proxy, and RegistryModuleOwnerCustom, and a router only reaches the lanes it was set up for. This matters most for an EVM-to-Solana lane: use a router that serves Solana, and confirm the lane exists before you wire it. Look up the per-chain Router, RMN, and RegistryModule addresses in the CCIP Directory on docs.chain.link, and verify the source-to-destination lane is listed there.
Set up a CCT
This walkthrough takes you from a token to a registered, remote-connected token pool. Use the addresses for your target CCIP deployment throughout. Do not reuse the placeholders below.
Which token, which path
| Goal | Method | Result |
|---|---|---|
| Deploy a new managed token and sign directly | deployToken | A CrossChainToken (v2.0.0). This is the only token contract the SDK deploys; there is no v1.x deploy path. |
| Deploy token + pool with addresses known before signing (multisig or Safe) | generateUnsignedDeployTokenAndTokenPoolViaFactory | Predicted token, pool, and optional lockbox addresses plus one unsigned transaction. Unsigned-only. |
| Reuse a token you already deployed | n/a | Pass its address as tokenAddress (or token for the factory) in the remaining steps. |
Deploy or use an existing token
const token = await cct.deployToken({
name: 'Example Token',
symbol: 'EXAMPLE',
decimals: 18,
maxSupply: 0n,
owner: await wallet.getAddress(),
wallet,
})
For an existing FactoryBurnMintERC20 or CrossChainToken, use its address as tokenAddress in the remaining steps.
Register and accept the token admin
Claim administration of the token in the TokenAdminRegistry. This is a two-step handoff: propose the administrator, then accept from the proposed account.
await cct.registerAdmin({
tokenAddress: token.contractAddress,
registryModule: process.env.REGISTRY_MODULE!,
address: process.env.ROUTER!,
wallet,
})
await cct.acceptAdmin({
tokenAddress: token.contractAddress,
address: process.env.ROUTER!,
wallet,
})
The wallet that calls acceptAdmin must be the proposed TokenAdminRegistry administrator.
Deploy the pool and grant roles
Building a lock/release pool? See Lock/release pools. The deploy and liquidity steps differ, and you skip grantMintAndBurnRoles.
Deploy the token pool, then grant it the mint and burn roles it needs to move tokens. Registering the pool in the TokenAdminRegistry with setPool comes later, after the lanes are configured (see Register the pool with the Router).
const pool = await cct.deployTokenPool({
type: 'BurnMintTokenPool',
token: token.contractAddress,
localTokenDecimals: 18,
rmnProxy: process.env.RMN_PROXY!,
router: process.env.ROUTER!,
wallet,
})
await cct.grantMintAndBurnRoles({
tokenAddress: token.contractAddress,
burnAndMinter: pool.contractAddress,
wallet,
})
This example deploys a BurnMintTokenPool. deployTokenPool and the factory deploy v2.0.0 pools; the read and administration operations recognize more types across older versions. See Pool types for all supported types and versions.
Alternative: deploy via TokenPoolFactory
Use the factory only when the target CCIP deployment provides a TokenPoolFactory or you need addresses known before signing. Its two methods are unsigned-only and return the predicted token, pool, and optional lockbox address with a transaction for the configured sender to submit.
const deployment = await cct.generateUnsignedDeployTokenAndTokenPoolViaFactory({
factory: process.env.TOKEN_POOL_FACTORY!,
sender: process.env.SAFE_ADDRESS!, // Must be the account that submits `transaction`
salt: 'example-token-v1',
type: 'BurnMintTokenPool',
token: {
name: 'Example Token',
symbol: 'EXAMPLE',
decimals: 18,
maxSupply: 0n,
},
expectedStaticConfig: {
rmnProxy: process.env.RMN_PROXY!,
router: process.env.ROUTER!,
},
})
console.log('Predicted token:', deployment.token)
console.log('Predicted pool:', deployment.pool)
// Submit deployment.transaction from deployment's `sender` (for example, the Safe).
For an existing ERC-20, use generateUnsignedDeployTokenPoolWithExistingTokenViaFactory and provide token and localTokenDecimals. The factory salt is tied to sender. Submit the transaction from that account, or the predicted addresses will change. futureOwner must accept ownership in a separate transaction. The factory deploys v2 pools without AdvancedPoolHooks. Add hooks afterward if you need them.
Configure additional networks
Add remote-chain configuration once both sides have a deployed pool. applyChainUpdates acts on the pool directly, so it does not need the pool registered in the TokenAdminRegistry yet; that comes next. It can add and remove configurations in one transaction.
applyChainUpdates takes the same parameters for every pool version: removals in remoteChainSelectorsToRemove, additions in chainsToAdd. It reads the pool's version on-chain and encodes the matching call, adapting to the older signature of a legacy v1.5.0 pool for you. A v1.5.0 pool holds only one remote pool per lane, so give each remotePoolAddresses a single entry there.
await cct.applyChainUpdates({
poolAddress: pool.contractAddress,
remoteChainSelectorsToRemove: [],
chainsToAdd: [
{
remoteChainSelector: DEST_CHAIN_SELECTOR,
remotePoolAddresses: [process.env.REMOTE_POOL!],
remoteTokenAddress: process.env.REMOTE_TOKEN!,
outboundRateLimiterConfig: {
enabled: true,
capacity: 100_000n * 10n ** 18n, // bucket max: 100,000 tokens (18 decimals) in flight
rate: 167n * 10n ** 18n, // refill ~167 tokens/sec (~100,000 tokens per 10 min)
},
inboundRateLimiterConfig: {
enabled: true,
capacity: 100_000n * 10n ** 18n,
rate: 167n * 10n ** 18n,
},
},
],
wallet,
})
Set rate limits deliberately. capacity (bucket max) and rate (refill per second) are in the token's smallest unit, so the 10n ** 18n multiplier expresses whole tokens for an 18-decimal token. To run a lane with no cap instead, pass { enabled: false } for that direction, which means unlimited.
Use getTokenPoolState, getTokenPoolRemotes, and getTokenAdminRegistry to read back the deployed configuration before enabling transfers.
Register the pool with the Router
With the pool deployed and its lanes configured, register it in the TokenAdminRegistry so the Router routes transfers through it. setPool comes after applyChainUpdates, the same order as Solana, so the pool is fully wired before it goes live. You need to have accepted the admin role first.
await cct.setPool({
tokenAddress: token.contractAddress,
poolAddress: pool.contractAddress,
address: process.env.ROUTER!,
wallet,
})
Unsigned operations (multisig or offline)
Every signed operation above has a generateUnsigned<Operation> twin that takes sender (the address that will submit the transaction) instead of wallet, and returns an unsigned transaction for a multisig or offline signer to sign and broadcast. Use it whenever the token, pool, or registry owner is a Safe or other external signer.
const unsigned = await cct.generateUnsignedRegisterAdmin({
tokenAddress: token.contractAddress,
registryModule: REGISTRY_MODULE,
address: ROUTER,
sender: process.env.SAFE_ADDRESS!, // the account that will submit `unsigned`
})
// Hand `unsigned` to the Safe or offline signer to sign and broadcast.
Configure a v2 pool
deployTokenPool and the factory create v2.0.0 pools, which add a configuration surface beyond lanes and rate limits: per-destination transfer fees, faster-than-finality settings, and a pluggable AdvancedPoolHooks contract that holds the sender allowlist, Cross-Chain Verifier (CCV) requirements, an optional policy engine (ACE), and a threshold amount. The subsections below cover each, and a lock/release v2 pool also escrows through an ERC20LockBox.
Which configuration and read operations a pool supports depends on its version. A v2.0.0-only operation throws CCTOperationUnsupportedError on an older pool, and the sender allowlist exists only through v1.6.1. Read the pool version with getTokenPoolState first, and see Pool types for the full applicability matrix.
Rate limits and transfer fees
After a lane exists, use setChainRateLimiterConfigs to adjust its inbound and outbound buckets. It is callable by the pool owner or its delegated rate-limit admin. On v2 pools, fastFinality: true targets the separate FTF bucket. If you administer an existing pool at version 1.5.0, 1.5.1, or 1.6.0, read the decimals metering warning before setting limits on a lane between tokens of different decimals.
For v2 pools, use applyTokenTransferFeeConfigUpdates to configure or disable per-destination transfer fees. Use setAllowedFinalityConfig to enable or change FTF/FCR finality. Read the current configuration first. These setters replace the relevant configuration. See Fee Estimation and Faster-Than-Finality for transfer behavior.
Every setter below is v2.0.0-only and throws CCTOperationUnsupportedError on an older pool. Each also has a generateUnsigned<Operation> twin that takes sender instead of wallet for multisig or offline signing.
setAllowedFinalityConfig replaces the whole finality config in one call. finalityDepth is the minimum FTF block depth, an integer in [0, 65535], where 0 disables FTF; finalitySafe enables the Fast Confirmation Rule (FCR/"safe"), and omitting it (or false) disables FCR. Read the current config with getAllowedFinalityConfig first so you do not clear the field you are not changing:
await cct.setAllowedFinalityConfig({
poolAddress: pool.contractAddress,
allowedFinality: {
finalityDepth: 10, // accept FTF once a source tx is 10 blocks deep; 0 disables FTF
finalitySafe: false, // FCR / "safe" finality off; omit or false to disable
},
wallet,
})
FTF transfers draw from a separate rate-limit bucket. Set fastFinality: true on a setChainRateLimiterConfigs entry to target that FTF bucket for a lane instead of its finalized one. The lane must already be configured. As with the finalized buckets, capacity and rate are in the token's smallest unit:
await cct.setChainRateLimiterConfigs({
poolAddress: pool.contractAddress,
updates: [
{
remoteChainSelector: DEST_CHAIN_SELECTOR,
fastFinality: true, // configure the lane's FTF buckets, not its finalized ones
outboundRateLimiterConfig: {
enabled: true,
capacity: 10_000n * 10n ** 18n, // bucket max: 10,000 tokens (18 decimals) in flight
rate: 17n * 10n ** 18n, // refill ~17 tokens/sec (~10,000 tokens per 10 min)
},
inboundRateLimiterConfig: {
enabled: true,
capacity: 10_000n * 10n ** 18n,
rate: 17n * 10n ** 18n,
},
},
],
wallet,
})
applyTokenTransferFeeConfigUpdates sets per-destination transfer fees. Each tokenTransferFeeConfig carries a flat USD surcharge (in cents) added to the CCIP fee and a basis-point (BPS) rate deducted from the transferred amount, each in a finalized and a fast-finality variant. It also sets the destination gas and byte overheads. Pass disables with a list of remote chain selectors to turn fees off for those lanes instead:
await cct.applyTokenTransferFeeConfigUpdates({
poolAddress: pool.contractAddress,
updates: [
{
remoteChainSelector: DEST_CHAIN_SELECTOR,
tokenTransferFeeConfig: {
destGasOverhead: 90_000, // extra destination gas budgeted for the token transfer
destBytesOverhead: 32, // extra data-availability bytes budgeted
finalityFeeUSDCents: 50, // +$0.50 flat surcharge under standard finality
fastFinalityFeeUSDCents: 150, // +$1.50 flat surcharge under FTF
finalityTransferFeeBps: 10, // 0.10% of the amount under standard finality (0..9999)
fastFinalityTransferFeeBps: 25, // 0.25% of the amount under FTF (0..9999)
isEnabled: true,
},
},
],
disables: [], // e.g. [SOME_OTHER_SELECTOR] to turn that lane's fees off
wallet,
})
Advanced pool hooks and CCVs
On v2 pools, sender allowlists and Cross-Chain Verifier (CCV) requirements live in AdvancedPoolHooks, not in the pool itself. Deploy hooks with the pool in authorizedCallers, then bind them to the pool. A pool omitted from authorizedCallers cannot transfer through the hooks.
const hooks = await cct.deployAdvancedPoolHooks({
authorizedCallers: [pool.contractAddress],
wallet,
})
await cct.updateAdvancedPoolHooks({
poolAddress: pool.contractAddress,
advancedPoolHooks: hooks.contractAddress,
wallet,
})
Binding is only the start. The hooks also carry the sender allowlist, per-remote-chain CCV requirements, an optional policy engine (ACE), a threshold amount, and their own authorized-caller set.
CCV requirements come in two lists per lane: base CCVs apply to every transfer, and threshold CCVs add requirements only once a transfer's amount reaches the hooks' configured threshold. setThresholdAmount sets that single amount on the hooks contract (not on the pool). Pass zero to disable threshold CCVs; base CCVs still apply. thresholdAmount is in the token's smallest unit, and getThresholdAmount reads it back:
await cct.setThresholdAmount({
advancedPoolHooks: hooks.contractAddress,
thresholdAmount: 1_000n * 10n ** 18n, // threshold CCVs kick in at 1,000 tokens (18 decimals); 0 disables
wallet,
})
setThresholdAmount runs against the hooks contract as its owner and has a generateUnsignedSetThresholdAmount twin for multisig or offline signing. Configure the base and threshold CCV lists themselves separately. See Advanced pool hooks for the full feature set and every read/write method.
Lock/release pools
A v2.0.0 LockReleaseTokenPool requires a pre-deployed lockbox for the same token. Its lockbox is immutable: an incorrect address requires a new pool deployment and registry update.
A lock/release pool still needs the same admin sequence as a burn/mint pool: registerAdmin, acceptAdmin, and setPool all apply. Skip grantMintAndBurnRoles: a lock/release pool escrows tokens rather than minting them, so granting it mint/burn roles is a mis-setup.
const lockbox = await cct.deployLockbox({
token: token.contractAddress,
wallet,
})
const lockReleasePool = await cct.deployTokenPool({
type: 'LockReleaseTokenPool',
token: token.contractAddress,
localTokenDecimals: 18,
rmnProxy: process.env.RMN_PROXY!,
router: process.env.ROUTER!,
lockbox: lockbox.contractAddress,
wallet,
})
await cct.updateLockboxAuthorizedCallers({
lockbox: lockbox.contractAddress,
addedCallers: [lockReleasePool.contractAddress, await wallet.getAddress()],
removedCallers: [],
wallet,
})
await cct.approveToken({
tokenAddress: token.contractAddress,
spender: lockbox.contractAddress,
amount: 1_000_000n,
wallet,
})
await cct.depositToLockbox({
lockbox: lockbox.contractAddress,
token: token.contractAddress,
amount: 1_000_000n,
wallet,
})
Verify and test a transfer
Verify the TokenAdminRegistry points to the intended pool, the pool reports the expected remote token and remote pool addresses, and rate limits can accommodate the transfer. Then run Pre-Send Validation against the source and destination before sending a production transfer.
Mint tokens to the sender
A burn/mint transfer burns the source balance, so the sender needs tokens first. A freshly deployed CrossChainToken starts with no supply. Mint some to the sending account with cct.mint:
await cct.mint({
tokenAddress: token.contractAddress,
account: await wallet.getAddress(),
amount: 1_000_000_000_000_000_000n, // one token at 18 decimals
wallet,
})
amount is in the token's smallest unit. The wallet must hold the token's mint role, which the pool setup above already grants to the pool; grant it to that account too, or mint from an account that holds it.
Send a first transfer
Send with the CLI. Use the CCIP network identifier (ethereum-testnet-sepolia) or its numeric selector for -s/-d; a plain alias such as sepolia is rejected:
ccip-cli send \
-s ethereum-testnet-sepolia \
-d avalanche-testnet-fuji \
-r 0xYourSourceRouter \
--to 0xReceiverOnDestination \
-t 0xYourToken=1.0 \
-w $PRIVATE_KEY \
--no-interactive
Sending into Solana uses the same command shape. Set -d to the Solana network and pass a source and destination RPC with repeated --rpc flags:
ccip-cli send \
-s avalanche-testnet-fuji \
-d solana-devnet \
-r <FujiRouter> \
--rpc <fujiRpc> \
--rpc <solanaDevnetRpc> \
--to <ownerPubkey> \
-t <token>=1.0 \
-w $PRIVATE_KEY \
--no-interactive
For a token-only transfer, --to is the recipient's owner wallet pubkey (base58), not an associated token account (ATA): the SDK derives and credits the ATA for that owner, so no --token-receiver flag is needed.
See Sending Messages for the full flag set.
Ownership handoffs
| Resource | Propose | Accept |
|---|---|---|
| TokenAdminRegistry administrator | transferAdmin | acceptAdmin |
| Pool owner | transferPoolOwnership | acceptPoolOwnership |
| v1 token owner | transferTokenOwnership | acceptTokenOwnership |
| v2 token default admin | beginDefaultAdminTransfer | acceptDefaultAdminTransfer |
The proposed account must submit the acceptance transaction. Factory futureOwner follows the same two-step ownership model. Use generateUnsigned<Operation> when an external signer submits a transaction. Set sender to enable local authority checks.
Related
- EVMTokenManager API reference
- Token Pools: inspect deployed CCIP pool configuration
- Pre-Send Validation: validate the destination pool leg
- Sending Messages: send transfers after CCT setup