Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

This book showcases practical usage of CCIP v2.0 in this Foundry starter kit.

The goal is not to re-teach CCIP fundamentals. It is to provide runnable example flows you can execute and adapt.

Before You Run Any Example

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

Examples in this book assume --account myAccount.

RPC URLs

Set RPC URLs in a local .env file (do not commit secrets):

cp .env.example .env

Populate RPC URL variables only for the chains you actually plan to run.

In foundry.toml, each chain alias points to an env var (for example polygonAmoy = "${POLYGON_AMOY_RPC_URL}").

If a variable is missing in .env, commands using the corresponding alias will fail.

Chain Configuration

Always pull the latest values from the CCIP Directory before running examples.

Examples 01–10 use Sepolia → Amoy. Examples 01, 03, and 04 use faucet-issued CCIP-BnM; Examples 06–10 deploy custom tokens. Verify the lane’s OnRamp 2.0.0 before sending.

Example 01: Token Transfer + Fast Transfers (FTF)

This example sends one token transfer message using ExtraArgsV3 with Fast Transfers (blockConfirmations > 0).

Script path: script/examples/Example01.s.sol

What You Will Do

  1. Mint 1 CCIP-BnM token to your EOA with script/Faucet.s.sol.
  2. (Optional) Read the executor allowed finality config (FinalityCodec bytes4) on the source chain.
  3. Send that token from source chain to destination chain with script/examples/Example01.s.sol.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Step 1: Get 1 CCIP-BnM Token

Use the faucet script on the source chain:

forge script script/Faucet.s.sol:Faucet \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS>

Step 2: (Optional) Check Executor Allowed Finality

Before choosing a Fast Transfers depth in ExtraArgsV3, you can read what finality modes the Executor allows. The on-chain API is getAllowedFinalityConfig() → bytes4, encoded with FinalityCodec (same family of values as requestedFinalityConfig in your message’s ExtraArgs).

From a shell (replace RPC and address):

cast call <EXECUTOR_ADDRESS> "getAllowedFinalityConfig()(bytes4)" --rpc-url ethereumSepolia

Why this matters:

  • Your requested finality (derived from blockConfirmations in EncodeExtraArgsOffchain / Example01 via FinalityCodec._encodeBlockDepth) must be permitted by the executor’s dynamic config; otherwise the send can revert.

Check the source pool, executor, and CCV finality settings before sending:

forge script script/examples/Example06.s.sol:Example06CheckFinalityGates \
  --rpc-url ethereumSepolia \
  --sig "run(address,uint64,address,uint16)" \
  <SOURCE_ROUTER> <DESTINATION_CHAIN_SELECTOR> <CCIP_BNM_SOURCE_TOKEN_ADDRESS> <BLOCK_CONFIRMATIONS_GT_ZERO>

A quote alone does not prove ccipSend will succeed with a non-IPoolV2 pool.

Step 3: Send Token With Fast Transfers

Run Example01:

forge script script/examples/Example01.s.sol:Example01 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,uint16,address)" \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <RECEIVER_ON_DESTINATION_CHAIN> \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT> \
  <BLOCK_CONFIRMATIONS_GT_ZERO> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT>: token amount in token decimals (for 18 decimals, 1e18 is 1 token).
  • <GAS_LIMIT>: set to 0 for token-only transfer to an EOA receiver.
  • <BLOCK_CONFIRMATIONS_GT_ZERO>: must be > 0 and meet the source pool and executor minimums from Step 2.
  • <FEE_TOKEN_ADDRESS>: Pass the LINK token address on the source chain here. If you want to pay for CCIP fees in native coin instead, pass 0x0000000000000000000000000000000000000000

Verify Result

Example01 logs the message ID.

Use that ID in the CCIP Explorer to monitor status:

  • https://ccip.chain.link

Example 02: Hello World to BasicMessageReceiverWithCCVs with Fast Transfers (FTF)

This example deploys a destination BasicMessageReceiverWithCCVs contract and sends a Hello World data message from an EOA using Fast Transfers (blockConfirmations > 0).

Scripts used:

  • script/examples/Example02.s.sol:DeployBasicMessageReceiverWithCCVs
  • script/examples/Example02.s.sol:SetBasicMessageReceiverWithCCVsMinBlockDepth
  • script/examples/Example02.s.sol:Example02

What You Will Do

  1. Deploy BasicMessageReceiverWithCCVs on destination chain.
  2. Configure minimum block depth for the source chain.
  3. Send a Hello World CCIP data message from source chain to BasicMessageReceiverWithCCVs.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Receiver Contract Context

Pre-v2.0

To receive CCIP messages (data or data+tokens), contracts implemented IAny2EVMMessageReceiver:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Client} from "../libraries/Client.sol";

interface IAny2EVMMessageReceiver {
  function ccipReceive(
    Client.Any2EVMMessage calldata message
  ) external;
}

As a convenience, applications generally inherited CCIPReceiver.sol and implemented _ccipReceive.

CCIP v2.0

In v2.0, receivers expose Cross Chain Verifiers and finality requirements via getCCVsAndFinalityConfig (encoded with FinalityCodec as bytes4 allowedFinalityConfig):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Client} from "../libraries/Client.sol";

interface IAny2EVMMessageReceiverV2 {
  function ccipReceive(
    Client.Any2EVMMessage calldata message
  ) external;

  function getCCVsAndFinalityConfig(
    uint64 sourceChainSelector,
    bytes calldata sender
  )
    external
    view
    returns (
      address[] memory requiredCCVs,
      address[] memory optionalCCVs,
      uint8 optionalThreshold,
      bytes4 allowedFinalityConfig
    );
}

The receiver controls two independent dimensions:

  • requiredCCVs and optionalCCVs define additional verifiers required for acceptance.
  • allowedFinalityConfig defines which requested finality modes are accepted (see FinalityCodec in chainlink-ccip). A zero value (WAIT_FOR_FINALITY_FLAG) means only fully finalized messages are accepted.

You can combine these independently (for example, add verifiers while requiring full finality, or allow Fast Transfers when policy allows).

In this starter kit, src/BasicMessageReceiverWithCCVs.sol adds configurable verifier sets and stores a per-chain minimum block depth, which it exposes via FinalityCodec._encodeBlockDepth in getCCVsAndFinalityConfig.

Step 1: Deploy BasicMessageReceiverWithCCVs on Destination Chain

Run:

forge script script/examples/Example02.s.sol:DeployBasicMessageReceiverWithCCVs \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <DESTINATION_ROUTER>

Save the deployed address as <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS>.

By default, this receiver starts with minBlockDepth = 0 (default finality only) for all source chains.

Step 2: Configure Minimum Block Depth for Your Source Chain

Run:

forge script script/examples/Example02.s.sol:SetBasicMessageReceiverWithCCVsMinBlockDepth \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,uint16)" \
  <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> \
  <SOURCE_CHAIN_SELECTOR> \
  <MIN_BLOCK_DEPTH>

For this chapter, use <MIN_BLOCK_DEPTH> = 1.

  • 0 means deafault finality-only behavior.
  • > 0 enables Fast Transfers with that minimum depth.

Step 3: Send Hello World Data Message from EOA

Run:

forge script script/examples/Example02.s.sol:Example02 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,string,uint32,uint16,address)" \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> \
  "Hello, World" \
  <GAS_LIMIT> \
  <BLOCK_CONFIRMATIONS_GT_ZERO> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <GAS_LIMIT> must be > 0 because the destination receiver callback needs gas.
  • <BLOCK_CONFIRMATIONS_GT_ZERO> must be > 0 for Fast Transfers.
  • <BLOCK_CONFIRMATIONS_GT_ZERO> should be greater than or equal to <MIN_BLOCK_DEPTH>.
  • Executor may enforce a minimum block confirmations value and revert if too low.
  • If requested confirmations exceed chain finality, default finality is used.
  • <FEE_TOKEN_ADDRESS>: Pass the LINK token address on the source chain here. If you want to pay for CCIP fees in native coin instead, pass 0x0000000000000000000000000000000000000000

Verify Result

Example02 logs a CCIP message ID.

Use that ID in the CCIP Explorer:

  • https://ccip.chain.link

You can inspect receiver state on destination chain:

cast call <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> "latestMessage()(bytes)" --rpc-url polygonAmoy

Example 03: Programmable Token Transfer with Fast Transfers (FTF)

This example sends a programmable token transfer from an EOA on Ethereum Sepolia to a receiver that supports Fast Transfers on Polygon Amoy:

  • Data payload: "Hello, World"
  • Tokens: CCIP-BnM

Script path: script/examples/Example03.s.sol

What You Will Do

  1. Ensure a destination BasicMessageReceiverWithCCVs exists on Amoy.
  2. Mint 1 CCIP-BnM on Sepolia using script/Faucet.s.sol.
  3. Send one CCIP message containing both data and tokens.

Receiver Compatibility Note

This chapter uses Fast Transfers (blockConfirmations > 0), so destination receiver should return a non-zero minimum block depth.

  • If your receiver is default-finality-only, this message can fail on destination.
  • Deploy BasicMessageReceiverWithCCVs before running this chapter.
  • For default-finality programmable token transfer (blockConfirmations = 0), use Example04.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

If you do not have a receiver deployed yet

Run:

forge script script/examples/Example02.s.sol:DeployBasicMessageReceiverWithCCVs \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <DESTINATION_ROUTER>

Then configure min block depth for your source chain:

forge script script/examples/Example02.s.sol:SetBasicMessageReceiverWithCCVsMinBlockDepth \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,uint16)" \
  <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> \
  <SOURCE_CHAIN_SELECTOR> \
  <MIN_BLOCK_DEPTH>

For this Fast Transfers example, use <MIN_BLOCK_DEPTH> > 0 and check the source pool minimum too.

The script argument accepts <MIN_BLOCK_DEPTH> (a uint16 passed to BasicMessageReceiverWithCCVs.setMinBlockDepth). On-chain, the receiver does not return that integer directly to CCIP: getCCVsAndFinalityConfig sets allowedFinalityConfig to FinalityCodec._encodeBlockDepth(minBlockDepth) — the same bytes4 finality encoding CCIP 2.0 uses elsewhere for allowed finality (depth 0 means wait for full/default finality).

Save the receiver address from the [RESULT] log and use it in Step 2 below.

Step 1: Get 1 CCIP-BnM Token on Sepolia

Use the token and finality check from Example 01:

forge script script/Faucet.s.sol:Faucet \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS>

Step 2: Send Hello World + CCIP-BnM

forge script script/examples/Example03.s.sol:Example03 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,string,address,uint256,uint32,uint16,address)" \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> \
  "Hello, World" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT> \
  <BLOCK_CONFIRMATIONS_GT_ZERO> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT> uses token decimals (1e18 is 1 token for 18-decimal tokens).
  • <GAS_LIMIT> must be > 0 because the receiver contract callback handles data.
  • <BLOCK_CONFIRMATIONS_GT_ZERO> must meet the source pool and receiver minimums.
  • Executor may enforce a minimum block confirmation value and revert if too low.
  • If requested confirmations exceed chain finality, default finality is used.
  • <FEE_TOKEN_ADDRESS>: Pass the LINK token address on the source chain here. If you want to pay for CCIP fees in native coin instead, pass 0x0000000000000000000000000000000000000000

Verify Result

Example03 logs a CCIP message ID. Track it in the CCIP Explorer:

  • https://ccip.chain.link

Optional receiver checks on Amoy:

cast call <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> "latestMessage()(bytes)" --rpc-url polygonAmoy
cast call <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> "latestSender()(address)" --rpc-url polygonAmoy
cast call <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> "latestSourceChainSelector()(uint64)" --rpc-url polygonAmoy

Example 04: Programmable Token Transfer (Default Finality + Sender Contract)

This example sends a programmable token transfer from Ethereum Sepolia to Polygon Amoy using a deployed BasicMessageSender contract:

  • Data payload: "Hello, World"
  • Tokens: CCIP-BnM
  • Finality mode: default finality (blockConfirmations = 0)

Because this chapter uses default finality, the baseline BasicMessageReceiver is sufficient on destination. If you want the Fast Transfers (FTF) variant (blockConfirmations > 0), use Example03 with BasicMessageReceiverWithCCVs.

Scripts used:

  • script/examples/Example04.s.sol:DeployBasicMessageSender
  • script/examples/Example04.s.sol:Example04

What You Will Do

  1. Deploy BasicMessageSender on source chain (Sepolia).
  2. Ensure destination BasicMessageReceiver exists on Amoy.
  3. Mint 1 CCIP-BnM on Sepolia.
  4. Quote fee, fund sender contract with that exact fee amount, then send data+token.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

If you do not have a receiver deployed yet

Run:

forge script script/examples/Example04.s.sol:DeployBasicMessageReceiver \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <DESTINATION_ROUTER>

Save the receiver address from the [RESULT] log and use it in Step 3.

Step 1: Deploy BasicMessageSender on Sepolia

forge script script/examples/Example04.s.sol:DeployBasicMessageSender \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address)" \
  <SOURCE_ROUTER> \
  <LINK_TOKEN_ON_SOURCE_CHAIN>

Save the deployed sender address from the [RESULT] log.

Step 2: Get 1 CCIP-BnM Token on Sepolia

Use the registered Sepolia token from Example 01:

forge script script/Faucet.s.sol:Faucet \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS>

Step 3: Send Hello World + CCIP-BnM Through Sender Contract

forge script script/examples/Example04.s.sol:Example04 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,uint64,address,string,address,uint256,uint32)" \
  <DEPLOYED_BASIC_MESSAGE_SENDER_ADDRESS> \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <DEPLOYED_BASIC_MESSAGE_RECEIVER_ADDRESS> \
  "Hello, World" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT>

Parameter notes:

  • This example always uses default finality by setting blockConfirmations = 0 in the script.
  • <AMOUNT> uses token decimals (1e18 is 1 token for 18-decimal tokens).
  • <GAS_LIMIT> must be > 0 because the receiver contract callback handles data.
  • The script first quotes the fee, then funds sender contract with that exact amount before calling send.
  • If fee conditions change between quote and send, rerun the script to re-quote and retry.

Verify Result

Example04 logs a CCIP message ID. Track it in the CCIP Explorer:

  • https://ccip.chain.link

Optional receiver checks on Amoy:

cast call <DEPLOYED_BASIC_MESSAGE_RECEIVER_ADDRESS> "latestMessage()(bytes)" --rpc-url polygonAmoy
cast call <DEPLOYED_BASIC_MESSAGE_RECEIVER_ADDRESS> "latestSender()(address)" --rpc-url polygonAmoy
cast call <DEPLOYED_BASIC_MESSAGE_RECEIVER_ADDRESS> "latestSourceChainSelector()(uint64)" --rpc-url polygonAmoy

Example 05: ExtraArgsV3 No-Execution-Tag (Manual Execution Path)

This example sends a Hello World data message from an EOA, but disables automatic execution by setting executor to NO_EXECUTION_ADDRESS inside ExtraArgsV3.

Script path: script/examples/Example05.s.sol

What You Will Do

  1. Ensure a compatible destination receiver exists (default finality or Fast Transfers (FTF) based on your blockConfirmations).
  2. Send a CCIP data message with ExtraArgsV3 no-execution-tag.
  3. Observe pending execution in CCIP Explorer and execute manually.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

If you do not have a receiver deployed yet

Choose deployment based on your blockConfirmations:

forge script script/examples/Example04.s.sol:DeployBasicMessageReceiver \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <DESTINATION_ROUTER>

Use this for default finality (blockConfirmations = 0). Save this as <BASIC_MESSAGE_RECEIVER_ADDRESS>.

forge script script/examples/Example02.s.sol:DeployBasicMessageReceiverWithCCVs \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <DESTINATION_ROUTER>

If you use Fast Transfers (blockConfirmations > 0), configure min block depth for your source chain:

forge script script/examples/Example02.s.sol:SetBasicMessageReceiverWithCCVsMinBlockDepth \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,uint16)" \
  <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> \
  <SOURCE_CHAIN_SELECTOR> \
  <MIN_BLOCK_DEPTH>

Use <MIN_BLOCK_DEPTH> > 0 for Fast Transfers.

Use this for Fast Transfers (blockConfirmations > 0). Save this as <BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS>.

Understanding ExtraArgsV3

The ExtraArgsV3 is a structured set of delivery/execution options encoded and attached to a CCIP message.

struct GenericExtraArgsV3 {
    uint32 gasLimit;
    bytes4 requestedFinalityConfig;
    address[] ccvs;
    bytes[] ccvArgs;
    address executor;
    bytes executorArgs;
    bytes tokenReceiver;
    bytes tokenArgs;
}

(On-chain this struct lives in ExtraArgsCodec from @chainlink/contracts-ccip.)

  • gasLimit: gas allocated for callback execution on destination. If 0 and message data is empty, no callback executes.
  • requestedFinalityConfig: bytes4 finality mode + parameters per FinalityCodec (not a bare uint16). All-zero means wait for default/lane finality. For block-depth style Fast Transfers, encode with FinalityCodec._encodeBlockDepth(uint16) (see CCIP / FinalityCodec NatSpec). Example05 and EncodeExtraArgsOffchain.encodeV3 / encodeV3Basic take a blockConfirmations argument only as a convenience and set this field to FinalityCodec._encodeBlockDepth(blockConfirmations).
  • ccvs: list of cross-chain verifier addresses. Empty means default verifiers.
  • ccvArgs: optional arguments for each CCV. Must match ccvs length.
  • executor: executor address on source chain. address(0) uses default executor.
  • executorArgs: chain/executor-specific args (format depends on chain family/executor implementation).
  • tokenReceiver: encoded destination token receiver. If empty, receiver address is used.
  • tokenArgs: extra token transfer args (pool-specific format).

Related helpers in script/EncodeExtraArgsOffchain.s.sol:

  • encodeV3Basic(gasLimit, blockConfirmations) for a minimal V3 payload.
  • getNoExecutionAddress() returns Client.NO_EXECUTION_ADDRESS for manual execution path.

Step 1: Send Message With No-Execution-Tag

forge script script/examples/Example05.s.sol:Example05 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,string,uint32,uint16,address)" \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <BASIC_MESSAGE_RECEIVER_ADDRESS_OR_BASIC_MESSAGE_RECEIVER_WITH_CCVS_ADDRESS> \
  "Hello, World" \
  <GAS_LIMIT> \
  <BLOCK_CONFIRMATIONS> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <GAS_LIMIT> must be > 0 because the receiver callback needs gas.
  • <BLOCK_CONFIRMATIONS> can be 0 (default finality) or > 0.
  • If <BLOCK_CONFIRMATIONS> > 0, destination receiver must accept Fast Transfers (minBlockDepth > 0) for this source chain.
  • If <BLOCK_CONFIRMATIONS> = 0, a default-finality receiver is sufficient.
  • <FEE_TOKEN_ADDRESS>: Pass the LINK token address on the source chain here. If you want to pay for CCIP fees in native coin instead, pass 0x0000000000000000000000000000000000000000
  • This example sets executor to NO_EXECUTION_ADDRESS, so execution is not automatic.

Verify Result

Example05 logs a CCIP message ID.

Use that ID in CCIP Explorer:

  • https://ccip.chain.link

Expected behavior:

  • Message is delivered to a pending/manual execution state because automatic execution is disabled.

Example 06: CCT Burn and Mint

This example covers the full BurnMint CCT flow on Sepolia -> Amoy:

  1. Deploy CrossChainToken + BurnMint pool on both chains.
  2. Configure pools to trust each other.
  3. Opt the pools into Fast Transfers (FTF) (this is what makes the token “FTF enabled”).
  4. Send a token transfer across the lane.
  5. Verify BurnMint behavior (burn on source, mint on destination).

Scripts used:

  • script/examples/Example06.s.sol:DeployCCTBurnMintTokenAndPool
  • script/examples/Example06.s.sol:Example06
  • script/examples/Example06.s.sol:Example06SetPoolAllowedFinalityConfig
  • script/examples/Example06.s.sol:Example06GetPoolAllowedFinalityConfig
  • script/examples/Example06.s.sol:Example06CheckFinalityGates
  • script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality
  • script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3CustomFinality

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Script Defaults

DeployCCTBurnMintTokenAndPool deploys a CrossChainToken (via BaseERC20.ConstructorParams) with:

  • name: TestToken
  • symbol: TEST
  • decimals: 18
  • preMint: 1_000_000 * 1e18
  • maxSupply: 100_000_000 * 1e18
  • preMintRecipient, ccipAdmin, burn/mint role admin, and AccessControl owner: the broadcasting EOA

Token admin registration uses RegistryModuleOwnerCustom.registerAdminViaGetCCIPAdmin (CrossChainToken exposes getCCIPAdmin() from BaseERC20, not owner()).

The deployment also uses:

  • BurnMint pool (no advanced pool hook in this example)
  • disabled rate limiter config in chain updates

Important: Optimizer is required for CCT pool deployment

BurnMintTokenPool can exceed EVM max code size if compiled without optimizer. This repo includes a dedicated CCT profile in foundry.toml:

[profile.cct]
optimizer = true
optimizer_runs = 200

For this chapter, use FOUNDRY_PROFILE=cct in all commands.

Step 1: Deploy Token + Pool on Sepolia (source)

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:DeployCCTBurnMintTokenAndPool \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address)" \
  <SEPOLIA_TOKEN_ADMIN_REGISTRY> \
  <SEPOLIA_REGISTRY_MODULE_OWNER_CUSTOM> \
  <SEPOLIA_ARM_PROXY> \
  <SEPOLIA_ROUTER>

Save from logs:

  • <SEPOLIA_TOKEN_ADDRESS>
  • <SEPOLIA_POOL_ADDRESS>

Step 2: Deploy Token + Pool on Amoy (destination)

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:DeployCCTBurnMintTokenAndPool \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address)" \
  <AMOY_TOKEN_ADMIN_REGISTRY> \
  <AMOY_REGISTRY_MODULE_OWNER_CUSTOM> \
  <AMOY_ARM_PROXY> \
  <AMOY_ROUTER>

Save from logs:

  • <AMOY_TOKEN_ADDRESS>
  • <AMOY_POOL_ADDRESS>

Step 3: Configure Sepolia Pool With Amoy Remote

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <SEPOLIA_POOL_ADDRESS> \
  <AMOY_CHAIN_SELECTOR> \
  <AMOY_TOKEN_ADDRESS> \
  <AMOY_POOL_ADDRESS>

Step 4: Configure Amoy Pool With Sepolia Remote

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06 \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <AMOY_POOL_ADDRESS> \
  <SEPOLIA_CHAIN_SELECTOR> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <SEPOLIA_POOL_ADDRESS>

Step 5: Enable Fast Transfers on Both Pools

New pools allow full finality only (0x00000000). FTF is a pool setting; configure both pools:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06SetPoolAllowedFinalityConfig \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,bytes4)" \
  <SEPOLIA_POOL_ADDRESS> \
  <ALLOWED_FINALITY_CONFIG>

Repeat on Amoy with --rpc-url polygonAmoy and <AMOY_POOL_ADDRESS>. Read back each pool:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06GetPoolAllowedFinalityConfig \
  --rpc-url ethereumSepolia \
  --sig "run(address)" \
  <SEPOLIA_POOL_ADDRESS>
FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06GetPoolAllowedFinalityConfig \
  --rpc-url polygonAmoy \
  --sig "run(address)" \
  <AMOY_POOL_ADDRESS>

0x00000000 disables FTF; values 0x00000001–0x0000ffff set the minimum block depth. The staged wait-for-safe flag is not usable on this lane.

Step 6: Check Finality Gates

The read-only check reports the pool, executor, and CCV settings before an FTF send:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:Example06CheckFinalityGates \
  --rpc-url ethereumSepolia \
  --sig "run(address,uint64,address,uint16)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <BLOCK_CONFIRMATIONS_GT_ZERO>

A getFee quote alone does not prove ccipSend will succeed for a non-IPoolV2 pool.

Step 7: Send BurnMint Transfer (ExtraArgsV3 + Default Finality)

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT> uses token decimals (1e18 is 1 token for 18-decimal token).
  • For token-only transfer to EOA receiver, use <GAS_LIMIT> = 0.
  • This sender uses ExtraArgsV3 with blockConfirmations = 0 (default finality).
  • <FEE_TOKEN_ADDRESS>:
    • native fee: 0x0000000000000000000000000000000000000000
    • LINK fee: LINK token address on Sepolia (the source chain)

For the FTF version, use the existing custom-finality sender after Steps 5 and 6:

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3CustomFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,uint16,address)" \
  <SEPOLIA_ROUTER> <AMOY_CHAIN_SELECTOR> <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> <AMOUNT> <GAS_LIMIT> <BLOCK_CONFIRMATIONS_GT_ZERO> <FEE_TOKEN_ADDRESS>

Step 8: Verify BurnMint Behavior

Verify token->pool registration:

cast call <SEPOLIA_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <SEPOLIA_TOKEN_ADDRESS> --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <AMOY_TOKEN_ADDRESS> --rpc-url polygonAmoy

Verify remote lane mapping:

cast call <SEPOLIA_POOL_ADDRESS> "getRemoteToken(uint64)(bytes)" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia
cast call <SEPOLIA_POOL_ADDRESS> "getRemotePools(uint64)(bytes[])" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia
cast call <AMOY_POOL_ADDRESS> "getRemoteToken(uint64)(bytes)" <SEPOLIA_CHAIN_SELECTOR> --rpc-url polygonAmoy
cast call <AMOY_POOL_ADDRESS> "getRemotePools(uint64)(bytes[])" <SEPOLIA_CHAIN_SELECTOR> --rpc-url polygonAmoy

Verify balances and supplies after transfer finalizes:

cast call <SEPOLIA_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <SOURCE_EOA_ADDRESS> --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <RECEIVER_ON_AMOY> --rpc-url polygonAmoy
cast call <SEPOLIA_TOKEN_ADDRESS> "totalSupply()(uint256)" --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADDRESS> "totalSupply()(uint256)" --rpc-url polygonAmoy

Monitor message status with the message ID on:

  • https://ccip.chain.link

Example 07: CCT Lock and Release

This example covers the full LockRelease CCT flow on Sepolia -> Amoy:

  1. Deploy token + lock box + LockRelease pool on both chains.
  2. Configure pools to trust each other.
  3. Fund destination lock box liquidity.
  4. Send a token transfer across the lane.
  5. Verify lock/release behavior.

Scripts used:

  • script/examples/Example07.s.sol:DeployCCTLockReleaseTokenAndPool
  • script/examples/Example07.s.sol:Example07
  • script/examples/Example07.s.sol:FundCCTLockBoxLiquidity
  • script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality (reused for the final transfer step)

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Script Defaults

DeployCCTLockReleaseTokenAndPool deploys a CrossChainToken (same defaults as Example 06) for the underlying ERC-20, with:

  • name: TestToken
  • symbol: TEST
  • decimals: 18
  • preMint: 1_000_000 * 1e18
  • maxSupply: 100_000_000 * 1e18

Admin registration uses registerAdminViaGetCCIPAdmin on the token (see Example 06).

The deployment also uses:

  • LockRelease pool
  • ERC20LockBox bound to the local token
  • disabled rate limiter config in chain updates

Important: Optimizer is required for CCT pool deployment

This repo includes a dedicated CCT profile in foundry.toml:

[profile.cct]
optimizer = true
optimizer_runs = 200

For this chapter, use FOUNDRY_PROFILE=cct in all commands.

Step 1: Deploy Token + Lock Box + Pool on Sepolia

FOUNDRY_PROFILE=cct forge script script/examples/Example07.s.sol:DeployCCTLockReleaseTokenAndPool \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address)" \
  <SEPOLIA_TOKEN_ADMIN_REGISTRY> \
  <SEPOLIA_REGISTRY_MODULE_OWNER_CUSTOM> \
  <SEPOLIA_ARM_PROXY> \
  <SEPOLIA_ROUTER>

Save from logs:

  • <SEPOLIA_TOKEN_ADDRESS>
  • <SEPOLIA_LOCKBOX_ADDRESS>
  • <SEPOLIA_POOL_ADDRESS>

Step 2: Deploy Token + Lock Box + Pool on Amoy

FOUNDRY_PROFILE=cct forge script script/examples/Example07.s.sol:DeployCCTLockReleaseTokenAndPool \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address)" \
  <AMOY_TOKEN_ADMIN_REGISTRY> \
  <AMOY_REGISTRY_MODULE_OWNER_CUSTOM> \
  <AMOY_ARM_PROXY> \
  <AMOY_ROUTER>

Save from logs:

  • <AMOY_TOKEN_ADDRESS>
  • <AMOY_LOCKBOX_ADDRESS>
  • <AMOY_POOL_ADDRESS>

Step 3: Configure Sepolia Pool With Amoy Remote

FOUNDRY_PROFILE=cct forge script script/examples/Example07.s.sol:Example07 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <SEPOLIA_POOL_ADDRESS> \
  <AMOY_CHAIN_SELECTOR> \
  <AMOY_TOKEN_ADDRESS> \
  <AMOY_POOL_ADDRESS>

Step 4: Configure Amoy Pool With Sepolia Remote

FOUNDRY_PROFILE=cct forge script script/examples/Example07.s.sol:Example07 \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <AMOY_POOL_ADDRESS> \
  <SEPOLIA_CHAIN_SELECTOR> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <SEPOLIA_POOL_ADDRESS>

Step 5: Fund Destination Lock Box Liquidity

For Sepolia -> Amoy transfers, fund the Amoy lock box first:

FOUNDRY_PROFILE=cct forge script script/examples/Example07.s.sol:FundCCTLockBoxLiquidity \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,uint256)" \
  <AMOY_TOKEN_ADDRESS> \
  <AMOY_LOCKBOX_ADDRESS> \
  <LIQUIDITY_AMOUNT>

If you also want Amoy -> Sepolia transfers, fund Sepolia lock box too with the same script.

Step 6: Send LockRelease Transfer (Reuse Example06 CCT Sender)

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <AMOUNT> \
  0 \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT> uses token decimals (1e18 is 1 token for 18-decimal token).
  • gasLimit is set to 0 in this command because this is token-only transfer to an EOA receiver.
  • This sender uses ExtraArgsV3 with blockConfirmations = 0 (default finality).
  • <FEE_TOKEN_ADDRESS>:
    • native fee: 0x0000000000000000000000000000000000000000
    • LINK fee: LINK token address on Sepolia

Step 7: Verify LockRelease Behavior

Verify token->pool registration:

cast call <SEPOLIA_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <SEPOLIA_TOKEN_ADDRESS> --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <AMOY_TOKEN_ADDRESS> --rpc-url polygonAmoy

Verify remote lane mapping:

cast call <SEPOLIA_POOL_ADDRESS> "getRemoteToken(uint64)(bytes)" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia
cast call <SEPOLIA_POOL_ADDRESS> "getRemotePools(uint64)(bytes[])" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia
cast call <AMOY_POOL_ADDRESS> "getRemoteToken(uint64)(bytes)" <SEPOLIA_CHAIN_SELECTOR> --rpc-url polygonAmoy
cast call <AMOY_POOL_ADDRESS> "getRemotePools(uint64)(bytes[])" <SEPOLIA_CHAIN_SELECTOR> --rpc-url polygonAmoy

Verify lock box balances and receiver balance after transfer finalizes:

cast call <SEPOLIA_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <SEPOLIA_LOCKBOX_ADDRESS> --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <AMOY_LOCKBOX_ADDRESS> --rpc-url polygonAmoy
cast call <AMOY_TOKEN_ADDRESS> "balanceOf(address)(uint256)" <RECEIVER_ON_AMOY> --rpc-url polygonAmoy

Monitor message status with the message ID on:

  • https://ccip.chain.link

Example 08: CCT Burn and Mint With AdvancedPoolHooks

This example covers BurnMint CCT on Sepolia -> Amoy with AdvancedPoolHooks attached to the pool.

Token Pool Hooks Development

Token pool hooks are standalone contracts used by token pools for additional checks and behavior.

At a high level:

  • pool calls hook preflightCheck(...) on source-side lock/burn
  • pool calls hook postflightCheck(...) on destination-side release/mint
  • hook can enforce allowlists and CCV requirements
  • hook can optionally call a policy engine

For custom development, the pool-level integration point is IAdvancedPoolHooks and the pool constructor receives the hook address.

AdvancedPoolHooks includes optional policy engine integration through IPolicyEngine. In this example, policy engine is intentionally disabled (address(0)) to focus on hook attachment and allowlist flow.

When building policy-engine-driven flows, Chainlink ACE provides the policy engine interfaces and implementation model.

This flow still demonstrates the correct hook wiring pattern for ACE-enabled setups.

What This Example Covers

  1. Deploy CrossChainToken + AdvancedPoolHooks + BurnMint pool on both chains.
  2. Configure pools to trust each other.
  3. Send token transfer with the existing ExtraArgsV3 default-finality sender.
  4. Verify hook attachment and allowlist settings.

Scripts used:

  • script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHook
  • script/examples/Example08.s.sol:Example08
  • script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Script Defaults

DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHook deploys CrossChainToken with the same token defaults as Example 06 (BaseERC20.ConstructorParams + registerAdminViaGetCCIPAdmin):

  • name: TestToken
  • symbol: TEST
  • decimals: 18
  • preMint: 1_000_000 * 1e18
  • maxSupply: 100_000_000 * 1e18

Hook deployment defaults in this example:

  • allowlist enabled and seeded with broadcaster EOA
  • thresholdAmountForAdditionalCCVs is provided via CLI (use 1 in this tutorial)
  • policyEngine = address(0) (disabled)
  • hook is configured to authorize the deployed pool as caller

Important: Optimizer is required for CCT pool deployment

This repo includes a dedicated CCT profile in foundry.toml:

[profile.cct]
optimizer = true
optimizer_runs = 200

For this chapter, use FOUNDRY_PROFILE=cct in all commands.

Step 1: Deploy Token + Hook + Pool on Sepolia

FOUNDRY_PROFILE=cct forge script script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHook \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address,uint256)" \
  <SEPOLIA_TOKEN_ADMIN_REGISTRY> \
  <SEPOLIA_REGISTRY_MODULE_OWNER_CUSTOM> \
  <SEPOLIA_ARM_PROXY> \
  <SEPOLIA_ROUTER> \
  1

Save from logs:

  • <SEPOLIA_TOKEN_ADDRESS>
  • <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS>
  • <SEPOLIA_POOL_ADDRESS>

Step 2: Deploy Token + Hook + Pool on Amoy

FOUNDRY_PROFILE=cct forge script script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHook \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,address,address,address,uint256)" \
  <AMOY_TOKEN_ADMIN_REGISTRY> \
  <AMOY_REGISTRY_MODULE_OWNER_CUSTOM> \
  <AMOY_ARM_PROXY> \
  <AMOY_ROUTER> \
  1

Save from logs:

  • <AMOY_TOKEN_ADDRESS>
  • <AMOY_ADVANCED_POOL_HOOK_ADDRESS>
  • <AMOY_POOL_ADDRESS>

Step 3: Configure Sepolia Pool With Amoy Remote

FOUNDRY_PROFILE=cct forge script script/examples/Example08.s.sol:Example08 \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <SEPOLIA_POOL_ADDRESS> \
  <AMOY_CHAIN_SELECTOR> \
  <AMOY_TOKEN_ADDRESS> \
  <AMOY_POOL_ADDRESS>

Step 4: Configure Amoy Pool With Sepolia Remote

FOUNDRY_PROFILE=cct forge script script/examples/Example08.s.sol:Example08 \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address)" \
  <AMOY_POOL_ADDRESS> \
  <SEPOLIA_CHAIN_SELECTOR> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <SEPOLIA_POOL_ADDRESS>

Step 5: Send Transfer (Reuse Example06 Sender)

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <AMOUNT> \
  0 \
  <FEE_TOKEN_ADDRESS>

Optional: Update Hook Allowlist

Use this if you want to add or remove senders in the allowlist after deployment.

Add one sender address:

FOUNDRY_PROFILE=cct forge script script/examples/Example08.s.sol:Example08UpdateAllowlist \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address[],address[])" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> \
  "[]" \
  "[<ADDRESS_TO_ADD>]"

Remove one sender address:

FOUNDRY_PROFILE=cct forge script script/examples/Example08.s.sol:Example08UpdateAllowlist \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address[],address[])" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> \
  "[<ADDRESS_TO_REMOVE>]" \
  "[]"

Step 6: Verify Hook and Pool State

Verify token->pool registration:

cast call <SEPOLIA_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <SEPOLIA_TOKEN_ADDRESS> --rpc-url ethereumSepolia
cast call <AMOY_TOKEN_ADMIN_REGISTRY> "getPool(address)(address)" <AMOY_TOKEN_ADDRESS> --rpc-url polygonAmoy

Verify attached hook on each pool:

cast call <SEPOLIA_POOL_ADDRESS> "getAdvancedPoolHooks()(address)" --rpc-url ethereumSepolia
cast call <AMOY_POOL_ADDRESS> "getAdvancedPoolHooks()(address)" --rpc-url polygonAmoy

Verify allowlist status:

cast call <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> "getAllowListEnabled()(bool)" --rpc-url ethereumSepolia
cast call <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> "getAllowList()(address[])" --rpc-url ethereumSepolia
cast call <AMOY_ADVANCED_POOL_HOOK_ADDRESS> "getAllowListEnabled()(bool)" --rpc-url polygonAmoy
cast call <AMOY_ADVANCED_POOL_HOOK_ADDRESS> "getAllowList()(address[])" --rpc-url polygonAmoy

Monitor message status with the message ID on:

  • https://ccip.chain.link

Example 09: Pool-Based CCV Requirements on AdvancedPoolHooks

This example covers pool-based CCV (Cross-Chain Verifier) requirements on AdvancedPoolHooks, continuing the setup from Example 08.

Pool-Based CCVs

A token pool can require specific CCVs for its transfers. When a pool requires CCVs:

  • the source OnRamp consults the pool’s getRequiredCCVs(...) outbound and calls each CCV’s getOutboundImplementation and forwardToVerifier on the source chain
  • the destination OffRamp resolves the same CCV addresses on the destination chain and calls getInboundImplementation and verifyMessage before releasing or minting tokens

Because the destination OffRamp resolves the same address, a pool-required CCV must exist at the same address on both chains. Real CCVs are CREATE2-deployed to matching addresses on each chain.

AdvancedPoolHooks exposes this through per-remote-chain CCV configs:

  • outboundCCVs / inboundCCVs: base CCVs, always required for transfers to/from that chain
  • thresholdOutboundCCVs / thresholdInboundCCVs: additional CCVs, required only when the transfer amount is at or above thresholdAmountForAdditionalCCVs
  • address(0) in a base list means “use the lane defaults alongside the other listed CCVs”

Threshold CCV lists require non-empty base lists (use address(0) in the base list if you want defaults below the threshold). Duplicates within and across lists are rejected.

What This Example Covers

  1. Deploy CrossChainToken + AdvancedPoolHooks + BurnMint pool on both chains (Example 08 steps 1-4).
  2. Set pool CCV configs on both hooks.
  3. Send a token transfer and observe the CCV being consulted on both sides.
  4. Adjust the threshold amount and observe threshold CCVs becoming required.
  5. Clear the CCV config.

Scripts used:

  • script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHook and Example08 (setup, from Example 08)
  • script/examples/Example09.s.sol:Example09SetPoolCCVConfig
  • script/examples/Example09.s.sol:Example09SetThresholdAmount
  • script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality (send)

Note on real CCVs

This starter kit does not deploy a real CCV. To try pool-based CCVs end to end on the live testnets, deploy your own CCV with the CCV starter kit and use its resolver address as <CCV_ADDRESS> below. The short version:

  1. On-chain contracts, on both chains — use the on-chain contracts kit to deploy the CREATE2 factory, the VersionedVerifierResolver, and the committee verifier, then apply the lane and signature config. The kit’s full flow walks the exact make commands; the resolver lands at the same address on both chains by construction (make deployments-check asserts the parity).
  2. Off-chain cell — use the off-chain kit to run the verifier and aggregator that actually attest messages (a Helm chart in Kubernetes, or the local docker-compose stack for testing). Without a running cell, the CCV exists on-chain but never attests, so messages requiring it will not execute.

Use the resolver address (not the committee verifier implementation) as <CCV_ADDRESS>: a pool that lists the implementation address reverts during the fee quote.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Step 1: Deploy Token + Hook + Pool on Both Chains

Follow Example 08 steps 1-4 to deploy the token, hook and pool on both chains and link the lanes. Save from the logs:

  • <SEPOLIA_TOKEN_ADDRESS>, <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS>, <SEPOLIA_POOL_ADDRESS>
  • <AMOY_TOKEN_ADDRESS>, <AMOY_ADVANCED_POOL_HOOK_ADDRESS>, <AMOY_POOL_ADDRESS>

Step 2: Set Pool CCV Config on Sepolia

Require <CCV_ADDRESS> for both outbound and inbound traffic to Amoy:

FOUNDRY_PROFILE=cct forge script script/examples/Example09.s.sol:Example09SetPoolCCVConfig \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address[],address[],address[],address[])" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> \
  <AMOY_CHAIN_SELECTOR> \
  "[<CCV_ADDRESS>]" \
  "[]" \
  "[<CCV_ADDRESS>]" \
  "[]"

Step 3: Set Pool CCV Config on Amoy

Require the same <CCV_ADDRESS> for traffic to Sepolia:

FOUNDRY_PROFILE=cct forge script script/examples/Example09.s.sol:Example09SetPoolCCVConfig \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address[],address[],address[],address[])" \
  <AMOY_ADVANCED_POOL_HOOK_ADDRESS> \
  <SEPOLIA_CHAIN_SELECTOR> \
  "[<CCV_ADDRESS>]" \
  "[]" \
  "[<CCV_ADDRESS>]" \
  "[]"

Step 4: Send Transfer (Reuse Example06 Sender)

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  <AMOUNT> \
  0 \
  <FEE_TOKEN_ADDRESS>

The source OnRamp now consults <CCV_ADDRESS> outbound, and the destination OffRamp verifies the message with the same address before minting.

Note on execution with a self-run CCV

Until your CCV is onboarded into the CCIP indexer, the default executor cannot fetch its attestation, so the message sits at UNTOUCHED after it is sent. Execute it yourself with the CCIP CLI (1.14.0 or later), reading the attestation directly from your own aggregator:

ccip-cli manual-exec <SOURCE_TX_HASH> --verifiers grpcs://<YOUR_AGGREGATOR_HOST>:443

See Test your setup in the CCV starter kit docs for the full walkthrough.

Step 5: Adjust the Threshold Amount

Threshold CCVs are required only for transfers at or above thresholdAmountForAdditionalCCVs. Example 08 deployed the hook with threshold 1, so threshold CCVs are effectively always required. Raise the threshold so they apply only to large transfers:

FOUNDRY_PROFILE=cct forge script script/examples/Example09.s.sol:Example09SetThresholdAmount \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint256)" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> \
  10000000000000000000000

Repeat on Amoy for the inbound side:

FOUNDRY_PROFILE=cct forge script script/examples/Example09.s.sol:Example09SetThresholdAmount \
  --rpc-url polygonAmoy \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint256)" \
  <AMOY_ADVANCED_POOL_HOOK_ADDRESS> \
  10000000000000000000000

To use threshold CCVs, set a config with non-empty threshold lists, for example base [<CCV_ADDRESS>] and threshold [<SECOND_CCV_ADDRESS>] (duplicates across lists are rejected).

Step 6: Clear the CCV Config

Re-run Example09SetPoolCCVConfig with empty lists to remove the config for that lane:

FOUNDRY_PROFILE=cct forge script script/examples/Example09.s.sol:Example09SetPoolCCVConfig \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address[],address[],address[],address[])" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> \
  <AMOY_CHAIN_SELECTOR> \
  "[]" \
  "[]" \
  "[]" \
  "[]"

Step 7: Verify Hook State

Verify the stored CCV config:

cast call <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> "getCCVConfig(uint64)((address[],address[],address[],address[]))" <AMOY_CHAIN_SELECTOR> --rpc-url ethereumSepolia
cast call <AMOY_ADVANCED_POOL_HOOK_ADDRESS> "getCCVConfig(uint64)((address[],address[],address[],address[]))" <SEPOLIA_CHAIN_SELECTOR> --rpc-url polygonAmoy

Verify the threshold amount:

cast call <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> "getThresholdAmount()(uint256)" --rpc-url ethereumSepolia
cast call <AMOY_ADVANCED_POOL_HOOK_ADDRESS> "getThresholdAmount()(uint256)" --rpc-url polygonAmoy

Monitor message status with the message ID on:

  • https://ccip.chain.link

Example 10: Policy Engine on AdvancedPoolHooks

This example covers policy engine integration on AdvancedPoolHooks, continuing the setup from Example 08.

Policy Engine

AdvancedPoolHooks optionally calls a policy engine on every transfer:

  • on the source chain, the hook’s preflightCheck calls policyEngine.run(...) before the pool locks or burns
  • on the destination chain, the hook’s postflightCheck calls policyEngine.run(...) before the pool releases or mints

The hook passes its own preflightCheck/postflightCheck calldata (minus the selector) as the payload, so the engine can decode the transfer details and enforce policies. If run reverts, the transfer is rejected: on the source side the send reverts, on the destination side the message execution fails and can be retried.

The engine is called locally on each chain, so the source and destination engines are independent instances — unlike pool-required CCVs, they do not need to share an address.

For production-grade policy engines, use Chainlink ACE. This example attaches an existing engine by address and uses a minimal example engine (src/ExamplePolicyEngine.sol) that enforces a simple transfer-amount cap. To use a real ACE policy engine instead of the example engine, see Use a real ACE policy engine below.

What This Example Covers

  1. Deploy CrossChainToken + AdvancedPoolHooks + BurnMint pool on both chains (Example 08 steps 1-4).
  2. Deploy the minimal example policy engine.
  3. Attach the engine to the hook.
  4. Send a transfer within the cap (allowed) and above the cap (rejected).
  5. Detach the engine.

Scripts used:

  • script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHook and Example08 (setup, from Example 08)
  • script/examples/Example10.s.sol:Example10DeployExamplePolicyEngine
  • script/examples/Example10.s.sol:Example10AttachPolicyEngine
  • script/examples/Example10.s.sol:Example10DetachPolicyEngine
  • script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality (send)

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

Step 1: Deploy Token + Hook + Pool on Both Chains

Follow Example 08 steps 1-4 to deploy the token, hook and pool on both chains and link the lanes. Save from the logs:

  • <SEPOLIA_TOKEN_ADDRESS>, <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS>, <SEPOLIA_POOL_ADDRESS>
  • <AMOY_TOKEN_ADDRESS>, <AMOY_ADVANCED_POOL_HOOK_ADDRESS>, <AMOY_POOL_ADDRESS>

Step 2: Deploy the Example Policy Engine on Sepolia

FOUNDRY_PROFILE=cct forge script script/examples/Example10.s.sol:Example10DeployExamplePolicyEngine \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast

Save from the logs:

  • <SEPOLIA_POLICY_ENGINE_ADDRESS>

The example engine’s default cap is 10 ether, and only its owner (the deploying account) can change it with setCap. To use a real ACE policy engine instead, skip this step and follow Use a real ACE policy engine below, then pass the ACE engine’s onchain address to Step 3.

Step 3: Attach the Engine to the Sepolia Hook

FOUNDRY_PROFILE=cct forge script script/examples/Example10.s.sol:Example10AttachPolicyEngine \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,address)" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> \
  <SEPOLIA_POLICY_ENGINE_ADDRESS>

The hook calls the engine’s attach(), which records the hook as an attached target.

Step 4: Send a Transfer Within the Cap

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  1000000000000000000 \
  0 \
  <FEE_TOKEN_ADDRESS>

1 ether is within the 10 ether cap, so the engine allows the transfer.

Step 5: Send a Transfer Above the Cap

Send more than the cap (for example 11 ether):

FOUNDRY_PROFILE=cct forge script script/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinality \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint32,address)" \
  <SEPOLIA_ROUTER> \
  <AMOY_CHAIN_SELECTOR> \
  <RECEIVER_ON_AMOY> \
  <SEPOLIA_TOKEN_ADDRESS> \
  11000000000000000000 \
  0 \
  <FEE_TOKEN_ADDRESS>

The source-side preflightCheck calls the engine, which reverts with AmountExceedsCap, and the send reverts.

Step 6: Detach the Engine

FOUNDRY_PROFILE=cct forge script script/examples/Example10.s.sol:Example10DetachPolicyEngine \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS>

The hook calls the engine’s detach(). Transfers are no longer policy-checked.

Optional: Destination-Side Engine

Attach an engine to the Amoy hook too (deploy a second engine on Amoy, then attach it to <AMOY_ADVANCED_POOL_HOOK_ADDRESS>). A destination-side rejection does not revert the send — the message is sent, but its execution on Amoy fails; check the message status on https://ccip.chain.link.

Use a real ACE policy engine

The example engine exists so this chapter runs without any Platform setup. For real policy enforcement, use Chainlink ACE: it provides managed policy engines, a built-in CCIP-AdvancedPoolHooks contract type with a pre-built extractor for the hook calldata, and policies you configure in the Chainlink Platform. You need an ACE-enabled organization on the Platform and a completed ACE account setup first. The flow below summarizes the Protect CCIP Token Pools with ACE guide, which has the complete Platform walkthrough.

  1. Create a policy engine in the Platform’s Policy Manager, including every EVM chain where you want to enforce policies (see Create a policy engine). Wait until the engine is Active, then copy its onchain address for each chain.
  2. Attach the hook to the engine with Step 3 above, passing the ACE engine’s onchain address for that chain. The hook’s attach() call is detected on-chain by ACE, which automatically adds the hook as a target under the engine. Do not register the hook manually as a second target.
  3. Verify the detected target: in the Platform, open the engine’s Contracts tab, find the target matching your hook’s address, and assign it the built-in CCIP-AdvancedPoolHooks contract type. It ships with preflightCheck and postflightCheck and their pre-built extractor, so no custom contract type or extractor is needed.
  4. Create a policy instance and protect a hook function: create the policy under the same engine and chain as the hook function you want to protect, then create a target protection selecting preflightCheck (source-side) or postflightCheck (destination-side), mapping policy parameters to the extractor’s outputs (for example from, to, amount_post_fee).

One mapping gotcha from the ACE guide: the hook sets the payload sender to msg.sender, which is the Token Pool, not the user who initiated the transfer. To evaluate the user, map the extracted from output to the policy parameter.

Step 7: Verify Hook and Engine State

Verify the attached engine:

cast call <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> "getPolicyEngine()(address)" --rpc-url ethereumSepolia

Verify the engine records the hook as attached:

cast call <SEPOLIA_POLICY_ENGINE_ADDRESS> "isAttached(address)(bool)" <SEPOLIA_ADVANCED_POOL_HOOK_ADDRESS> --rpc-url ethereumSepolia

Verify the cap:

cast call <SEPOLIA_POLICY_ENGINE_ADDRESS> "getCap()(uint256)" --rpc-url ethereumSepolia

Monitor message status with the message ID on:

  • https://ccip.chain.link

Using the CCIP API

CCIP API is available at:

https://api.ccip.chain.link/

Documentation at:

https://docs.chain.link/ccip/tools/api/

With the CCIP REST API you can:

  • fetch one message by messageId
  • search your messages by sender, chain selectors, or source tx hash
  • paginate through large result sets with cursor
  • filter messages that are ready for manual execution
  • read lane latency (totalMs) to estimate route timing

Set your variables once

Paste this once per terminal session, replace with your values, then run the commands below. Use the same EOA and messageId you got from your Foundry examples.

export MY_EOA="0xYourEoaAddressHere"
export MESSAGE_ID="0xYourMessageIdFromExample01Logs"   # optional

Get one message by ID

After Example 01 (or any send), the script logs a message ID. Fetch its status and full details:

curl -s -X 'GET' \
  "https://api.ccip.chain.link/v2/messages/${MESSAGE_ID}" \
  -H 'accept: application/json' | jq

Or open in browser:

https://api.ccip.chain.link/v2/messages/<MESSAGE_ID>

You get status, source/dest network, sendTransactionHash, receiptTransactionHash, token amounts, fees. If you didn’t set MESSAGE_ID, set it to a real ID from your logs and run again.


List your messages (search, paginate, filter)

After you run a few examples, this is the fastest way to inspect only your own CCIP traffic.

Last 10 messages:

curl -s -X 'GET' \
  "https://api.ccip.chain.link/v2/messages?sender=${MY_EOA}&limit=10" \
  -H 'accept: application/json' | jq

The response has data (array of message summaries) and pagination with limit, hasNextPage, and cursor. Each item has a messageId; use the “Get one message” curl above with that ID to see full details and status.

Next page: copy the cursor value from the response, then run:

export CURSOR="paste_the_cursor_value_here"
curl -s -X 'GET' \
  "https://api.ccip.chain.link/v2/messages?cursor=${CURSOR}&limit=10" \
  -H 'accept: application/json' | jq

Don’t mix cursor with other query params (sender, chain selectors, etc.).

Filter by:

  • Ready for manual execution (e.g. from Example 05): add &readyForManualExecOnly=true
  • Source chain or destination chain: add &sourceChainSelector=... or &destChainSelector=... (values from CCIP Directory)

Examples:

# Only messages ready for manual execution
curl -s -X 'GET' \
  "https://api.ccip.chain.link/v2/messages?sender=${MY_EOA}&readyForManualExecOnly=true&limit=10" \
  -H 'accept: application/json' | jq

# Only messages from a given source chain (selector from CCIP Directory)
curl -s -X 'GET' \
  "https://api.ccip.chain.link/v2/messages?sender=${MY_EOA}&sourceChainSelector=14767482510784806043&limit=10" \
  -H 'accept: application/json' | jq

Lane latency

To show “about X minutes” for a route, call the latency endpoint with source and destination chain selectors (CCIP Directory). This one needs no variables—paste and run:

curl -s -X 'GET' \
  'https://api.ccip.chain.link/v2/lanes/latency?sourceChainSelector=14767482510784806043&destChainSelector=16015286601757825753' \
  -H 'accept: application/json' | jq

Response includes totalMs (p90 in milliseconds). Swap the selectors for the same chains you use in your examples.

CCIP SDK: ccip-send (Data, Token, Token+Data)

CCIP SDK package

Install from NPM: npm install @chainlink/ccip-sdk

NPM: https://www.npmjs.com/package/@chainlink/ccip-sdk

Docs: https://docs.chain.link/ccip/tools/sdk

This chapter shows how to run the SDK script for the three message shapes used in CCIP v2 examples:

  1. data-only
  2. token-only
  3. token+data

The script lives in sdk-examples/src/ccip-send.ts.

Receiver Compatibility for Fast Transfers (FTF)

When using --block-confirmations > 0 (Fast Transfers), receiver compatibility matters for message modes that execute receiver callbacks.

Mode--block-confirmations 0--block-confirmations > 0
databaseline receiver worksdestination receiver must allow the requested finality (see getCCVsAndFinalityConfig / allowedFinalityConfig)
token-databaseline receiver worksdestination receiver must allow the requested finality (see getCCVsAndFinalityConfig / allowedFinalityConfig)
token with --gas-limit 0callback not executed (EOA-style token receive path)callback not executed (EOA-style token receive path)

Terminology note:

  • Sender side (ExtraArgsV3): requested finality (e.g. FinalityCodec / block depth in requestedFinalityConfig)
  • Receiver side (getCCVsAndFinalityConfig): allowedFinalityConfig (bytes4, FinalityCodec)

How This Script Uses the SDK

At a high level, the script builds EVMChain instances for source and destination, asks the SDK for a fee quote, then submits the message via the SDK send call.

import { EVMChain } from '@chainlink/ccip-sdk'

const source = await EVMChain.fromUrl(sourceRpcUrl)
const dest = await EVMChain.fromUrl(destRpcUrl)

const fee = await source.getFee({ router, destChainSelector: dest.network.chainSelector, message })
await source.sendMessage({ router, destChainSelector: dest.network.chainSelector, message: { ...message, fee }, wallet })

Prerequisites

Requires Node.js 24 or later (@chainlink/ccip-sdk 1.x uses syntax Node 22 cannot run).

From repo root:

npm --prefix sdk-examples install

For actual sends (non---dry-run), set USER_KEY (or PRIVATE_KEY) in the root .env.

RPC Configuration

ccip-send accepts RPC URLs either:

  • directly via flags (--source-rpc-url, --dest-rpc-url)
  • or from .env:
    • CCIP_SOURCE_RPC_URL, CCIP_DEST_RPC_URL
    • fallback: ETHEREUM_SEPOLIA_RPC_URL, POLYGON_AMOY_RPC_URL

1) Data-Only

Fast Transfers (blockConfirmations=1):

npm --prefix sdk-examples run ccip-send -- \
  --mode data \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --dest-rpc-url "$POLYGON_AMOY_RPC_URL" \
  --router <SOURCE_ROUTER> \
  --receiver <RECEIVER> \
  --block-confirmations 1

Default finality (blockConfirmations=0):

npm --prefix sdk-examples run ccip-send -- \
  --mode data \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --dest-rpc-url "$POLYGON_AMOY_RPC_URL" \
  --router <SOURCE_ROUTER> \
  --receiver <RECEIVER> \
  --block-confirmations 0

2) Token-Only

Use Sepolia CCIP-BnM and the finality check from Example 01.

npm --prefix sdk-examples run ccip-send -- \
  --mode token \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --dest-rpc-url "$POLYGON_AMOY_RPC_URL" \
  --router <SOURCE_ROUTER> \
  --receiver <RECEIVER> \
  --token <SEPOLIA_CCIP_BNM_TOKEN> \
  --amount 1 \
  --block-confirmations <BLOCK_CONFIRMATIONS_GT_ZERO>

3) Token + Data

Configure the Amoy receiver as in Example 03.

npm --prefix sdk-examples run ccip-send -- \
  --mode token-data \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --dest-rpc-url "$POLYGON_AMOY_RPC_URL" \
  --router <SOURCE_ROUTER> \
  --receiver <RECEIVER> \
  --token <SEPOLIA_CCIP_BNM_TOKEN> \
  --amount 1 \
  --data "hello + token" \
  --block-confirmations <BLOCK_CONFIRMATIONS_GT_ZERO>

If You Need Testnet Tokens

Use the Foundry faucet script on Sepolia:

forge script script/Faucet.s.sol:Faucet \
  --rpc-url ethereumSepolia \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <SEPOLIA_CCIP_BNM_TOKEN>

CCIP SDK: ccip-track (Tx Hash, Message ID, Sender)

CCIP SDK package

Install from NPM: npm install @chainlink/ccip-sdk

NPM: https://www.npmjs.com/package/@chainlink/ccip-sdk

Docs: https://docs.chain.link/ccip/tools/sdk

This chapter shows how to track CCIP v2 messages from source-chain activity using the SDK script:

  • sdk-examples/src/ccip-track.ts

How This Script Uses the SDK

The tracking script uses the SDK in three paths: fetch messages from a source tx hash, fetch by message ID, and stream sender history from source-chain logs.

import { EVMChain, getMessagesForSender } from '@chainlink/ccip-sdk'

const source = await EVMChain.fromUrl(sourceRpcUrl)
const byTx = await source.getMessagesInTx(sourceTxHash)
const byId = await source.getMessageById(messageId)

for await (const msg of getMessagesForSender(source, sender, { startBlock })) {
  console.log(msg.message.messageId)
}

Prerequisites

Requires Node.js 24 or later (@chainlink/ccip-sdk 1.x uses syntax Node 22 cannot run).

From repo root:

npm --prefix sdk-examples install

RPC Configuration

ccip-track reads source RPC URL from:

  • --source-rpc-url <url> flag, or
  • .env:
    • CCIP_SOURCE_RPC_URL
    • fallback: ETHEREUM_SEPOLIA_RPC_URL
source .env

1) Track by Source Transaction Hash

Use a source tx hash from your Foundry run (for example Example01-Example08):

npm --prefix sdk-examples run ccip-track -- \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --tx-hash <SOURCE_TX_HASH> 

2) Track by Message ID

npm --prefix sdk-examples run ccip-track -- \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --message-id <MESSAGE_ID>

Output prints the same concise SDK summary for that message ID.

3) Track by Sender

npm --prefix sdk-examples run ccip-track -- \
  --source-rpc-url "$ETHEREUM_SEPOLIA_RPC_URL" \
  --limit 10 \
  --sender <SENDER_ADDRESS>

Arguments:

  • --limit: max rows to print (default 10)
  • --start-block: first source block to scan (default 0)

Using the CCIP CLI

CCIP CLI package

Install globally from NPM: npm install -g @chainlink/ccip-cli

NPM: https://www.npmjs.com/package/@chainlink/ccip-cli

Docs: https://docs.chain.link/ccip/tools/cli

This chapter covers:

  1. CLI install
  2. observability commands after Foundry/SDK sends
  3. send parity commands for data-only, token-only, and token+data

Install and Verify

npm install -g @chainlink/ccip-cli
ccip-cli --version
ccip-cli --help

Observability

1) Inspect by source transaction hash

ccip-cli show <SOURCE_TX_HASH> \
  --rpcs <SOURCE_RPC_URL> \
  --rpcs <DEST_RPC_URL>

<SOURCE_TX_HASH> is the source-chain transaction hash.
<SOURCE_RPC_URL> and <DEST_RPC_URL> are RPC endpoints for the source and destination chains.

Wait for delivery from the same command:

ccip-cli show <SOURCE_TX_HASH> \
  --rpcs <SOURCE_RPC_URL> \
  --rpcs <DEST_RPC_URL> \
  --wait

--wait keeps polling until destination execution status is available.

2) Inspect by messageId

tx-hash positional becomes messageId when --id-from-source is used:

ccip-cli show <MESSAGE_ID> \
  --id-from-source <SOURCE_CHAIN_SELECTOR> \
  --rpcs <SOURCE_RPC_URL> \
  --rpcs <DEST_RPC_URL>

<MESSAGE_ID> is the CCIP message ID.
<SOURCE_CHAIN_SELECTOR> is the source CCIP chain selector.

Send Parity (CLI)

TO DO: Adapt these examples to use ExtraArgsV3

1) Data-only

ccip-cli send -s <SOURCE_CHAIN> -d <DEST_CHAIN> -r <SOURCE_ROUTER> \
  --rpcs <SOURCE_RPC_URL> \
  --rpcs <DEST_RPC_URL> \
  --wallet <PRIVATE_KEY> \
  --receiver <RECEIVER_ADDRESS> \
  --data "hello from ccip-cli" \
  --gas-limit 200000 \
  --wait

<SOURCE_CHAIN> and <DEST_CHAIN> can be chain ID, chain selector, or chain name.
Chain ID examples: Ethereum Sepolia 11155111, Polygon Amoy 80002.
<SOURCE_ROUTER> is the source-chain router for the selected lane.

2) Token-only

ccip-cli send -s <SOURCE_CHAIN> -d <DEST_CHAIN> -r <SOURCE_ROUTER> \
  --rpcs <SOURCE_RPC_URL> \
  --rpcs <DEST_RPC_URL> \
  --wallet <PRIVATE_KEY> \
  --receiver <RECEIVER_ADDRESS> \
  --transfer-tokens <TOKEN_ADDRESS>=<TOKEN_AMOUNT> \
  --gas-limit 0 \
  --wait

<TOKEN_ADDRESS>=<TOKEN_AMOUNT> uses token=amount format.
<TOKEN_AMOUNT> is human-readable; CLI converts it using token decimals.

3) Token + data

ccip-cli send -s <SOURCE_CHAIN> -d <DEST_CHAIN> -r <SOURCE_ROUTER> \
  --rpcs <SOURCE_RPC_URL> \
  --rpcs <DEST_RPC_URL> \
  --wallet <PRIVATE_KEY> \
  --receiver <RECEIVER_ADDRESS> \
  --data "hello + token" \
  --transfer-tokens <TOKEN_ADDRESS>=<TOKEN_AMOUNT> \
  --gas-limit 200000 \
  --wait

Same chain/router placeholders as above; --transfer-tokens uses the same token=amount format.

Token Amount Format

--transfer-tokens currently uses token=amount format.
Amount is human-readable and CLI converts it using token decimals.

Examples:

  • --transfer-tokens <TOKEN_ADDRESS>=1 means 1 whole token.
  • --transfer-tokens <TOKEN_ADDRESS>=1.5 means 1.5 tokens.

Legacy 01: ExtraArgsV1 Token Transfer

This example sends a token transfer using ExtraArgsV1 from an EOA.

Script path: script/examples/Legacy01.s.sol

What You Will Do

  1. Mint test token(s) to your EOA on the source chain.
  2. Send token(s) using ExtraArgsV1.
  3. Observe the message on CCIP Explorer.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

You do not need to deploy a receiver contract for this token-only example. Use a destination EOA as the receiver and pass GAS_LIMIT = 0.

Step 1: Get 1 CCIP-BnM Token on Source Chain

forge script script/Faucet.s.sol:Faucet \
  --rpc-url avalancheFuji \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS>

Step 2: Send Token With ExtraArgsV1

forge script script/examples/Legacy01.s.sol:Legacy01 \
  --rpc-url avalancheFuji \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint256,address)" \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <DESTINATION_EOA_ADDRESS> \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT>: token amount in token decimals (for 18 decimals, 1e18 is 1 token).
  • <GAS_LIMIT>: set 0 for token-only transfer to an EOA receiver. Use > 0 for contract callback.
  • <FEE_TOKEN_ADDRESS>: Pass the LINK token address on the source chain here. If you want to pay for CCIP fees in native coin instead, pass 0x0000000000000000000000000000000000000000

Verify Result

Use the logged message ID in:

  • https://ccip.chain.link

Legacy 02: ExtraArgsV2 Token Transfer (CLI allowOutOfOrderExecution)

This example sends a token transfer using ExtraArgsV2 from an EOA.

Script path: script/examples/Legacy02.s.sol

You can toggle allowOutOfOrderExecution via CLI arg to cover both scenarios in one script.

What You Will Do

  1. Mint test token(s) to your EOA on the source chain.
  2. Send token(s) using ExtraArgsV2.
  3. Toggle out-of-order execution with a boolean CLI value.
  4. Observe the message on CCIP Explorer.

Before You Start

Important: Keystore first

Use a local keystore account for script execution:

cast wallet import myAccount --interactive
Enter private key:
Enter password:
`myAccount` keystore was saved successfully. Address: <YOUR_EOA_ADDRESS_SHOULD_APPEAR_HERE>

This chapter assumes --account myAccount.

You do not need to deploy a receiver contract for this token-only example. Use a destination EOA as the receiver and pass GAS_LIMIT = 0.

Step 1: Get 1 CCIP-BnM Token on Source Chain

forge script script/Faucet.s.sol:Faucet \
  --rpc-url avalancheFuji \
  --account myAccount \
  --broadcast \
  --sig "run(address)" \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS>

Step 2: Send Token With ExtraArgsV2

forge script script/examples/Legacy02.s.sol:Legacy02 \
  --rpc-url avalancheFuji \
  --account myAccount \
  --broadcast \
  --sig "run(address,uint64,address,address,uint256,uint256,bool,address)" \
  <SOURCE_ROUTER> \
  <DESTINATION_CHAIN_SELECTOR> \
  <DESTINATION_EOA_ADDRESS> \
  <CCIP_BNM_SOURCE_TOKEN_ADDRESS> \
  <AMOUNT> \
  <GAS_LIMIT> \
  <ALLOW_OUT_OF_ORDER_EXECUTION> \
  <FEE_TOKEN_ADDRESS>

Parameter notes:

  • <AMOUNT>: token amount in token decimals (for 18 decimals, 1e18 is 1 token).
  • <GAS_LIMIT>: set 0 for token-only transfer to an EOA receiver. Use > 0 for contract callback.
  • <ALLOW_OUT_OF_ORDER_EXECUTION>: true or false.
  • <FEE_TOKEN_ADDRESS>: Pass the LINK token address on the source chain here. If you want to pay for CCIP fees in native coin instead, pass 0x0000000000000000000000000000000000000000

Verify Result

Use the logged message ID in:

  • https://ccip.chain.link