Local Devnets and Forks
The CLI resolves chains from a selector table bundled at build time, generated from the public
chain-selectors registry. A chain that is not
in that table cannot be resolved, and commands fail with CHAIN_NOT_FOUND:
$ ccip-cli send -s 1337 -d 2337 -r 0x5FC8... --rpcs http://localhost:8545 --no-api
error[CHAIN_NOT_FOUND]: Chain not found: 2337
This affects three cases:
- a local devnet started with an arbitrary chain id (
anvil --chain-id 2337) - a fork served under a chain id that differs from the network it forked (a Tenderly Virtual
Environment,
anvil --fork-url … --chain-id …) - a public network added to the registry after your installed CLI was released
The first needs a new chain; the second needs a fork, which is a different thing: see Forks.
Add those chains with --chain-selectors. Everything else — send, show, manual-exec — then
works on the lane as it would on a bundled chain.
Local networks are never indexed by the hosted CCIP API, so --no-api is required for any local
lane. Pass an RPC for each side of the lane.
Add a Chain
The shorthand is <chainId>=<selector>. The family is inferred from the chain id's format — 2337
is EVM, aptos:4 Aptos, sui:4 Sui, -217 TON, canton:LocalNet Canton, a base58 genesis hash
Solana — and the network type defaults to TESTNET (a hex chain id, as eth_chainId reports it,
works too):
ccip-cli send -s 1337 -d 2337 \
-r 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 \
--to 0x2E2Ed0Cfd3AD2f1d34481277b3204d807Ca2F8c2 \
--data "hello" \
--chain-selectors 2337=12922642891491394802 \
--rpcs http://localhost:8545 \
--rpcs http://localhost:8555 \
--no-api
Track the message the same way, passing the source transaction hash:
ccip-cli show 0xc0ac87bc918dd987391814e9353ddc7551c0f3f51dbe41c0cc7acc609c1b3cda \
--chain-selectors 2337=12922642891491394802 \
--rpcs http://localhost:8545 \
--rpcs http://localhost:8555 \
--no-api
The flag is repeatable, and each value may be a comma- or space-separated list. It reads from
CCIP_CHAIN_SELECTORS like every other option; the flag, when given, replaces the variable:
export CCIP_CHAIN_SELECTORS="2337=12922642891491394802,735711155111=ethereum-testnet-sepolia"
A Sui or TON chain added this way resolves by selector — as a lane's destination, or when decoding a message — but the CLI can't match it to an RPC: Sui and TON RPCs are identified by fixed network identities, not by chain id.
Forks
A fork is not a new chain. Its contracts hold the forked network's state and emit that network's chain selector on-chain, so it must keep that identity — giving it a fresh selector breaks message decoding, because a decoded message's selector then resolves to the real network rather than to your fork's RPC.
Name the forked chain after the =, and the CLI re-keys it to the fork's chain id:
# a Sepolia fork served at chain id 735711155111
ccip-cli show 0xSourceTxHash \
--chain-selectors 735711155111=ethereum-testnet-sepolia \
--rpcs $TENDERLY_VIRTUAL_ENV_RPC \
--rpcs https://arb-sepolia.example.com \
--no-api
The forked chain's selector works as well: 735711155111=16015286601757825753 is the same fork,
because a selector that a known chain already owns always means that chain. A number after the =
is always a selector, never a chain id. 735711155111=11155111 is rejected, since 11155111 is
Sepolia's chain id, and the error names the fix. Add name to label the fork:
[{ "chainId": 735711155111, "forkOf": "ethereum-testnet-sepolia", "name": "tenderly-sepolia" }]
The fork takes over the forked chain's identity: 11155111 stops resolving for the rest of the
process, and the selector, name, and family now point at the fork. That is deliberate: a selector
identifies exactly one chain, so you cannot address a network and its fork in the same invocation.
Forgot the flag? An RPC whose chain id is not in the table is skipped, and the resulting
RPC_NOT_FOUND or TRANSACTION_NOT_FOUND error's help: line names that chain id (and the RPC's
host) with the value to pass.
A fork may also take over a chain id that is already bundled, as long as it is not a mainnet one.
hardhat node --fork keeps chain id 31337 (bundled as anvil-devnet, with a selector that does
not match the forked network), so a Hardhat fork of Sepolia needs:
--chain-selectors 31337=ethereum-testnet-sepolia
Without it the RPC resolves to anvil-devnet and decoding later fails with RPC_NOT_FOUND, because
the message carries Sepolia's selector. anvil --fork-url inherits the upstream chain id and needs
nothing.
Tenderly Virtual Environments
Tenderly does not default a Virtual Environment to its parent's chain id: the REST API requires
virtual_network_config.chain_config.chain_id, the dashboard defaults to a custom id, and Tenderly's
own docs recommend prefixing 7357 to the parent's id (a Sepolia fork becomes 735711155111). So a
Virtual Environment almost always needs a fork entry.
Read the id from the environment itself rather than assuming one:
cast chain-id --rpc-url $TENDERLY_VIRTUAL_ENV_RPC
Fund a sender with Tenderly's faucet on the Admin RPC before sending:
cast rpc tenderly_setBalance '["0xYourSender"]' 0xDE0B6B3A7640000 --rpc-url $TENDERLY_ADMIN_RPC
A message sent on a fork is never picked up by CCIP's offchain network — the fork is private. show
reconstructs and decodes it from the fork's RPC, and reports no commit or execution, which is
expected. Use a fork to exercise sending, fee quoting, and decoding, not delivery.
The Virtual Environment RPC URL embeds a UUID that acts as its credential; keep the Admin RPC secret.
Chainlink Local
Chainlink Local's non-fork simulator is not reachable
from the CLI: it runs inside forge test / hardhat test with no RPC endpoint, and its
MockCCIPRouter emits MessageExecuted, not the real CCIPSendRequested / CCIPMessageSent, so
there is nothing for show to decode. Its fork helpers are Foundry cheatcodes, equally out of reach.
What does work is forking onto a standalone node — anvil --fork-url … or
hardhat node --fork … — where the real CCIP contracts are present. send and show's source side
then behave normally. Run one node per lane side and pass both to --rpcs, or show will fail when
it reaches the destination chain.
A message sent on a fork is never committed or executed: no CCIP DON watches your fork, and
manual-exec cannot substitute, because it builds a proof against a commit root that will never
exist there. Chainlink Local sidesteps this by impersonating the OffRamp and calling
executeSingleMessage directly.
What Works Locally
Verified against anvil --fork (parent and custom chain ids), a Tenderly Virtual Environment, and a
committee-backed local devnet (a full verifier/committee stack, not the bare anvil in the intro).
All of it requires --no-api.
| Command | Local devnet | Fork | Notes |
|---|---|---|---|
show | ✅ | ✅ | On a fork, reports no commit/execution — nothing delivers it |
send | ✅ | ✅ | Including --only-get-fee |
lane | ✅ | ✅ | |
token | ✅ | ✅ | Native and ERC-20 |
manual-exec | ⚠️ | ❌ | Only if the devnet runs a committee that produces a commit |
parse | ✅ | ✅ | Offline, no RPC |
getSupportedTokens | ⚠️ | ⚠️ | Slow on forks; needs a registry the CLI can probe |
laneLatency | ❌ | ❌ | API-only (API_CLIENT_NOT_AVAILABLE) |
search | ❌ | ❌ | API-only |
manual-exec works only on a devnet that runs its own verifier/committee stack, because a commit
really is produced there. The bare anvil devnet in this guide has no committee, so a message stays
unexecuted and manual-exec fails with COMMIT_NOT_FOUND, exactly as it does on a fork of a public
network where no CCIP DON is watching. That is correct behaviour, not a bug.
Add from a File
For names, mainnet chains, or more than a couple of entries, pass a JSON or YAML file.
The selectors: shape of the chain-selectors registry is accepted as-is, so test_selectors.yml
can be used unmodified:
# local-selectors.yml
selectors:
2337:
selector: 12922642891491394802
name: local-anvil-dst
network_type: testnet # family defaults to EVM
ccip-cli show 0xc0ac87bc... --chain-selectors ./local-selectors.yml --rpcs ... --no-api
An array of entries and a chainId -> entry map both work, in JSON or YAML, inline or from a file:
[
{
"chainId": 2337,
"chainSelector": "12922642891491394802",
"name": "local-anvil-dst",
"family": "EVM",
"networkType": "TESTNET"
}
]
| Field | Required | Default | Notes |
|---|---|---|---|
chainId | yes | - | Map keys are used as the chain id when given |
chainSelector | yes* | - | Also accepted as selector. A selector a known chain owns makes the entry a fork of that chain |
forkOf | yes* | - | A fork of a known chain, by name or selector; also accepted as fork_of. The fork inherits the forked chain's selector, family and network type |
name | no | custom-<id> | For a fork, defaults to the forked chain's name. Must be unique, and must not look like a chain id or number |
family | no | from the chain id | EVM, SVM (or SOLANA), APTOS, SUI, TON, CANTON, any case. With APTOS, SUI or CANTON, a bare id gets its prefix (4 becomes aptos:4) |
networkType | no | TESTNET | Also accepted as network_type, any case |
* Exactly one of chainSelector and forkOf.
Adding a chain id that is already bundled replaces it, unless it is a mainnet chain: the CLI refuses
to point a mainnet chain id at another chain. A per-family registry file such as
selectors_aptos.yml, keyed by bare ids (1, 2, …), loads once each entry sets family: aptos:
the ids get their prefix.
From the SDK
The CLI flag writes into the SDK's exported SELECTORS table, keyed by chain id. Do the same from
code, before resolving the chain; every resolution reads the live table:
import { SELECTORS, networkInfo } from '@chainlink/ccip-sdk'
// a new chain (from-scratch devnet)
SELECTORS['2337'] = {
selector: 12922642891491394802n,
name: 'local-anvil-dst',
family: 'EVM',
network_type: 'TESTNET',
}
networkInfo(12922642891491394802n).chainId // 2337
// a fork served under a different chain id: MOVE the forked chain's entry
SELECTORS['735711155111'] = SELECTORS['11155111']!
delete SELECTORS['11155111']
networkInfo(16015286601757825753n).chainId // 735711155111
Move a fork's entry rather than copying it: with both keys present, a selector resolves to whichever chain id the table lists first. The SDK doesn't validate entries, so the CLI's checks — unique names, no mainnet takeover — are yours to keep.
See Also
- Configuration - RPCs, wallets, and global options
- send - Send messages
- show - Track messages