Skip to main content
Version: 1.14.0

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:

CONSOLE
$ 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):

Bash
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:

Bash
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:

Bash
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:

Bash
# 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:

JSON
[{ "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:

Bash
--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:

Bash
cast chain-id --rpc-url $TENDERLY_VIRTUAL_ENV_RPC

Fund a sender with Tenderly's faucet on the Admin RPC before sending:

Bash
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'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.

CommandLocal devnetForkNotes
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:

YAML
# local-selectors.yml
selectors:
2337:
selector: 12922642891491394802
name: local-anvil-dst
network_type: testnet # family defaults to EVM
Bash
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:

JSON
[
{
"chainId": 2337,
"chainSelector": "12922642891491394802",
"name": "local-anvil-dst",
"family": "EVM",
"networkType": "TESTNET"
}
]
FieldRequiredDefaultNotes
chainIdyes-Map keys are used as the chain id when given
chainSelectoryes*-Also accepted as selector. A selector a known chain owns makes the entry a fork of that chain
forkOfyes*-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
namenocustom-<id>For a fork, defaults to the forked chain's name. Must be unique, and must not look like a chain id or number
familynofrom the chain idEVM, SVM (or SOLANA), APTOS, SUI, TON, CANTON, any case. With APTOS, SUI or CANTON, a bare id gets its prefix (4 becomes aptos:4)
networkTypenoTESTNETAlso 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:

TypeScript
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​