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
- Mint 1 CCIP-BnM token to your EOA with
script/Faucet.s.sol. - (Optional) Read the executor allowed finality config (
FinalityCodecbytes4) on the source chain. - 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
blockConfirmationsinEncodeExtraArgsOffchain/Example01viaFinalityCodec._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,1e18is 1 token).<GAS_LIMIT>: set to0for token-only transfer to an EOA receiver.<BLOCK_CONFIRMATIONS_GT_ZERO>: must be> 0and 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, pass0x0000000000000000000000000000000000000000
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:DeployBasicMessageReceiverWithCCVsscript/examples/Example02.s.sol:SetBasicMessageReceiverWithCCVsMinBlockDepthscript/examples/Example02.s.sol:Example02
What You Will Do
- Deploy
BasicMessageReceiverWithCCVson destination chain. - Configure minimum block depth for the source chain.
- 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:
requiredCCVsandoptionalCCVsdefine additional verifiers required for acceptance.allowedFinalityConfigdefines which requested finality modes are accepted (seeFinalityCodecin 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.
0means deafault finality-only behavior.> 0enables 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> 0because the destination receiver callback needs gas.<BLOCK_CONFIRMATIONS_GT_ZERO>must be> 0for 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, pass0x0000000000000000000000000000000000000000
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
- Ensure a destination
BasicMessageReceiverWithCCVsexists on Amoy. - Mint 1
CCIP-BnMon Sepolia usingscript/Faucet.s.sol. - 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
BasicMessageReceiverWithCCVsbefore 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> > 0and check the source pool minimum too.The script argument accepts
<MIN_BLOCK_DEPTH>(auint16passed toBasicMessageReceiverWithCCVs.setMinBlockDepth). On-chain, the receiver does not return that integer directly to CCIP:getCCVsAndFinalityConfigsetsallowedFinalityConfigtoFinalityCodec._encodeBlockDepth(minBlockDepth)— the samebytes4finality encoding CCIP 2.0 uses elsewhere for allowed finality (depth0means 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 (1e18is 1 token for 18-decimal tokens).<GAS_LIMIT>must be> 0because 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, pass0x0000000000000000000000000000000000000000
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:DeployBasicMessageSenderscript/examples/Example04.s.sol:Example04
What You Will Do
- Deploy
BasicMessageSenderon source chain (Sepolia). - Ensure destination
BasicMessageReceiverexists on Amoy. - Mint 1
CCIP-BnMon Sepolia. - 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 = 0in the script. <AMOUNT>uses token decimals (1e18is 1 token for 18-decimal tokens).<GAS_LIMIT>must be> 0because 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
- Ensure a compatible destination receiver exists (default finality or Fast Transfers (FTF) based on your
blockConfirmations). - Send a CCIP data message with
ExtraArgsV3no-execution-tag. - 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> > 0for 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. If0and message data is empty, no callback executes.requestedFinalityConfig:bytes4finality mode + parameters perFinalityCodec(not a bareuint16). All-zero means wait for default/lane finality. For block-depth style Fast Transfers, encode withFinalityCodec._encodeBlockDepth(uint16)(see CCIP /FinalityCodecNatSpec). Example05 andEncodeExtraArgsOffchain.encodeV3/encodeV3Basictake ablockConfirmationsargument only as a convenience and set this field toFinalityCodec._encodeBlockDepth(blockConfirmations).ccvs: list of cross-chain verifier addresses. Empty means default verifiers.ccvArgs: optional arguments for each CCV. Must matchccvslength.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()returnsClient.NO_EXECUTION_ADDRESSfor 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> 0because the receiver callback needs gas.<BLOCK_CONFIRMATIONS>can be0(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, pass0x0000000000000000000000000000000000000000- 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:
- Deploy
CrossChainToken+ BurnMint pool on both chains. - Configure pools to trust each other.
- Opt the pools into Fast Transfers (FTF) (this is what makes the token “FTF enabled”).
- Send a token transfer across the lane.
- Verify BurnMint behavior (burn on source, mint on destination).
Scripts used:
script/examples/Example06.s.sol:DeployCCTBurnMintTokenAndPoolscript/examples/Example06.s.sol:Example06script/examples/Example06.s.sol:Example06SetPoolAllowedFinalityConfigscript/examples/Example06.s.sol:Example06GetPoolAllowedFinalityConfigscript/examples/Example06.s.sol:Example06CheckFinalityGatesscript/examples/Example06.s.sol:SendCCTTokenWithExtraArgsV3DefaultFinalityscript/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:TestTokensymbol:TESTdecimals:18preMint:1_000_000 * 1e18maxSupply:100_000_000 * 1e18preMintRecipient,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
BurnMintTokenPoolcan exceed EVM max code size if compiled without optimizer. This repo includes a dedicated CCT profile infoundry.toml:[profile.cct] optimizer = true optimizer_runs = 200For this chapter, use
FOUNDRY_PROFILE=cctin 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 (1e18is 1 token for 18-decimal token).- For token-only transfer to EOA receiver, use
<GAS_LIMIT>=0. - This sender uses
ExtraArgsV3withblockConfirmations = 0(default finality). <FEE_TOKEN_ADDRESS>:- native fee:
0x0000000000000000000000000000000000000000 - LINK fee: LINK token address on Sepolia (the source chain)
- native fee:
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:
- Deploy token + lock box + LockRelease pool on both chains.
- Configure pools to trust each other.
- Fund destination lock box liquidity.
- Send a token transfer across the lane.
- Verify lock/release behavior.
Scripts used:
script/examples/Example07.s.sol:DeployCCTLockReleaseTokenAndPoolscript/examples/Example07.s.sol:Example07script/examples/Example07.s.sol:FundCCTLockBoxLiquidityscript/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:TestTokensymbol:TESTdecimals:18preMint:1_000_000 * 1e18maxSupply:100_000_000 * 1e18
Admin registration uses registerAdminViaGetCCIPAdmin on the token (see Example 06).
The deployment also uses:
- LockRelease pool
ERC20LockBoxbound 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 = 200For this chapter, use
FOUNDRY_PROFILE=cctin 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 (1e18is 1 token for 18-decimal token).gasLimitis set to0in this command because this is token-only transfer to an EOA receiver.- This sender uses
ExtraArgsV3withblockConfirmations = 0(default finality). <FEE_TOKEN_ADDRESS>:- native fee:
0x0000000000000000000000000000000000000000 - LINK fee: LINK token address on Sepolia
- native fee:
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.
Chainlink ACE Context
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
- Deploy CrossChainToken +
AdvancedPoolHooks+ BurnMint pool on both chains. - Configure pools to trust each other.
- Send token transfer with the existing ExtraArgsV3 default-finality sender.
- Verify hook attachment and allowlist settings.
Scripts used:
script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHookscript/examples/Example08.s.sol:Example08script/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:TestTokensymbol:TESTdecimals:18preMint:1_000_000 * 1e18maxSupply:100_000_000 * 1e18
Hook deployment defaults in this example:
- allowlist enabled and seeded with broadcaster EOA
thresholdAmountForAdditionalCCVsis provided via CLI (use1in 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 = 200For this chapter, use
FOUNDRY_PROFILE=cctin 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’sgetOutboundImplementationandforwardToVerifieron the source chain - the destination OffRamp resolves the same CCV addresses on the destination chain and calls
getInboundImplementationandverifyMessagebefore 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 chainthresholdOutboundCCVs/thresholdInboundCCVs: additional CCVs, required only when the transfer amount is at or abovethresholdAmountForAdditionalCCVsaddress(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
- Deploy CrossChainToken +
AdvancedPoolHooks+ BurnMint pool on both chains (Example 08 steps 1-4). - Set pool CCV configs on both hooks.
- Send a token transfer and observe the CCV being consulted on both sides.
- Adjust the threshold amount and observe threshold CCVs becoming required.
- Clear the CCV config.
Scripts used:
script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHookandExample08(setup, from Example 08)script/examples/Example09.s.sol:Example09SetPoolCCVConfigscript/examples/Example09.s.sol:Example09SetThresholdAmountscript/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:
- 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 exactmakecommands; the resolver lands at the same address on both chains by construction (make deployments-checkasserts the parity).- 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
UNTOUCHEDafter 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>:443See 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
preflightCheckcallspolicyEngine.run(...)before the pool locks or burns - on the destination chain, the hook’s
postflightCheckcallspolicyEngine.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
- Deploy CrossChainToken +
AdvancedPoolHooks+ BurnMint pool on both chains (Example 08 steps 1-4). - Deploy the minimal example policy engine.
- Attach the engine to the hook.
- Send a transfer within the cap (allowed) and above the cap (rejected).
- Detach the engine.
Scripts used:
script/examples/Example08.s.sol:DeployCCTBurnMintTokenAndPoolWithAdvancedPoolHookandExample08(setup, from Example 08)script/examples/Example10.s.sol:Example10DeployExamplePolicyEnginescript/examples/Example10.s.sol:Example10AttachPolicyEnginescript/examples/Example10.s.sol:Example10DetachPolicyEnginescript/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.
- 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.
- 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. - 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-AdvancedPoolHookscontract type. It ships withpreflightCheckandpostflightCheckand their pre-built extractor, so no custom contract type or extractor is needed. - 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) orpostflightCheck(destination-side), mapping policy parameters to the extractor’s outputs (for examplefrom,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:
Documentation at:
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
This chapter shows how to run the SDK script for the three message shapes used in CCIP v2 examples:
- data-only
- token-only
- 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 |
|---|---|---|
data | baseline receiver works | destination receiver must allow the requested finality (see getCCVsAndFinalityConfig / allowedFinalityConfig) |
token-data | baseline receiver works | destination receiver must allow the requested finality (see getCCVsAndFinalityConfig / allowedFinalityConfig) |
token with --gas-limit 0 | callback 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 inrequestedFinalityConfig) - 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
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 (default10)--start-block: first source block to scan (default0)
Using the CCIP CLI
CCIP CLI package
Install globally from NPM:
npm install -g @chainlink/ccip-cli
This chapter covers:
- CLI install
- observability commands after Foundry/SDK sends
- 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>=1means 1 whole token.--transfer-tokens <TOKEN_ADDRESS>=1.5means 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
- Mint test token(s) to your EOA on the source chain.
- Send token(s) using
ExtraArgsV1. - 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,1e18is 1 token).<GAS_LIMIT>: set0for token-only transfer to an EOA receiver. Use> 0for 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, pass0x0000000000000000000000000000000000000000
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
- Mint test token(s) to your EOA on the source chain.
- Send token(s) using
ExtraArgsV2. - Toggle out-of-order execution with a boolean CLI value.
- 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,1e18is 1 token).<GAS_LIMIT>: set0for token-only transfer to an EOA receiver. Use> 0for contract callback.<ALLOW_OUT_OF_ORDER_EXECUTION>:trueorfalse.<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, pass0x0000000000000000000000000000000000000000
Verify Result
Use the logged message ID in:
- https://ccip.chain.link