| type | reference |
|---|
Per-chain state lives in two files, both keyed by the canonical CCIP selectorName:
config/chains/<selectorName>.json- pure API/chain facts (theccip{}addresses, API-served identity/metadata, the hand-authored keys, and the join keys). Git-tracked.project/<selectorName>.json- the project store: three subtrees (addresses{},lanes{},roles{}) plus"schema": 3. Gitignored in this template repo; a fork tracks it (see The project store).
This repo treats CCIP chain metadata as DATA, not code. Every chain selector, router, and CCIP
infra address the scripts need is read from config/chains/<selectorName>.json at build/test time via
vm.parseJson* (src/config/ChainConfig.sol). There are no hardcoded selectors or CCIP addresses
in Solidity: HelperConfig discovers the chain list by scanning the config/chains directory
(vm.readDir), so adding a chain - or updating a CCIP address - is a reviewed config edit with zero
Solidity changes.
The operational how-to (discover → add-chain → sync → doctor, and the "which command when" table) lives
in the operations: chains; the make-command reference and the layered architecture
(with diagrams) are in config-architecture.md. This document is the
field-by-field reference for both files.
Each file is named by, and carries a name field equal to, the canonical CCIP selectorName from the
chain-selectors registry - the one identifier
the CCIP REST API (GET /v2/chains/{selector} → .name) and
ccip-cli all key on. So the files are ethereum-testnet-sepolia.json,
ethereum-testnet-sepolia-mantle-1.json, 0g-testnet-galileo-1.json, plume-testnet-sepolia.json,
ink-testnet-sepolia.json, and solana-devnet.json - not bespoke short slugs like ethereum-sepolia.
name is the value you pass as CHAIN= to every make target. The sync validates it: after each API
fetch it asserts the config name equals the API selectorName and reverts SELECTOR NAME MISMATCH
otherwise (a sibling to the numeric SELECTOR MISMATCH chainId guard). make add-chain refuses any
CHAIN= that is not the canonical selectorName. This is the portable identity key for non-EVM chains
too, whose chainId is a placeholder "0" the chainId guard cannot verify (see below).
Each subtree of each file has a single writer, so a change is always attributable. In config/chains
(git-tracked) that attribution is a git diff. In the project store it is the writing command; a fork that
tracks project/ gets a git diff there too (see The project store).
| File | Subtree / field group | Owner | Sole writer |
|---|---|---|---|
config/chains |
ccip{} + the API-served identity/metadata fields (displayName, chainFamily, environment, explorerUrl, nativeCurrencySymbol) |
the CCIP REST API | the API sync (make add-chain / sync / sync-all) - never by hand |
config/chains |
the hand-authored keys the API serves nothing for (chainNameIdentifier, rpcEnv, the optional verifier{} block) |
repo maintainers | a reviewed hand edit in a pull request |
config/chains |
the optional evmVersion |
the chain itself, probed for PUSH0 support | script/config/detect-evm-version.sh (from make add-chain / make detect-evm-version), overridable by hand |
config/chains |
immutable join keys (name, chainSelector, chainId) |
the chain-selectors registry | seeded at add-chain, then guard-validated by the sync (never rewritten) |
project |
addresses{} (the deployed-address registry sub-store) |
the deployer | the deploy scripts (one DeploymentRecorder call → RegistryWriter) and make adopt-token, on --broadcast |
project |
lanes{} (which remotes this pool connects to, at what outbound rate limits) |
the token owner (policy) | make add-lane / make remove-lane or a reviewed hand edit - never the API sync |
project |
roles{} (the privileged-authority surface: who holds token/pool/TAR/lockbox/hooks roles + optional governance{}) |
the project's security owner (declared intent) | make snapshot-chain (backfill FROM chain) or a reviewed hand edit; make roles-check only READS it |
project |
poolPolicy{} (pool-scoped values: the pool-global ccvThreshold and the allowed-finality finality{} block) |
the token owner (policy) | a reviewed hand edit only (no flag surface, no scripted writer, no silent writeback); make fmt-config repairs form |
The sync enforces this structurally: SyncCcipConfig.run writes only the API-served fields: the
.ccip subtree (vm.writeJson(json, path, ".ccip")) plus the five identity/metadata keys the CCIP REST
API serves, each a targeted vm.writeJson(value, path, ".<key>"). So the keys the API serves nothing for
(chainNameIdentifier, rpcEnv, the optional verifier{} block, the optional probed evmVersion) are preserved untouched and the join keys are validated,
not overwritten. The sync never touches the project store at all. Each project-store writer mirrors the
same discipline from its side: make add-lane writes only .lanes (vm.writeJson(lanes, path, ".lanes")),
RegistryWriter writes only .addresses, and make snapshot-chain writes only .roles
(SnapshotChain.s.sol → vm.writeJson(roles, path, ".roles"), preserve-and-replace on that subtree only) -
each seeds the full skeleton first if the file is absent. The rule of thumb: if a field exists on
GET /v2/chains/{selector}, the sync sources it from the API; you never hand-type it. If a field is policy,
authority, or a deployed address, the owning command writes it into the project store, and the sync never
will. See The roles{} subtree below and the
operational runbook in docs/roles.md.
config/chains/<selectorName>.json carries only chain facts: the API-synced ccip{} and
identity/metadata, the hand-authored keys, and the join keys. Owner policy (lanes{}), authority
(roles{}), and deployed addresses (addresses{}) are not here - they live in the project store (see
The project store).
Example (config/chains/ethereum-testnet-sepolia.json), grouped by writer:
"Written by" values: API sync = sourced + refreshed + drift-checked from the CCIP REST API;
API sync (guard) = seeded at add-chain then validated (not rewritten) every sync; hand = the
API serves nothing for it, so a reviewed PR owns it and the sync preserves it verbatim.
| Field | Type / format | Written by | API source (if any) | Consumed by |
|---|---|---|---|---|
name |
string (canonical selectorName) | API sync (guard) | chain.name |
file key + basename; validated by the sync |
chainId |
quoted decimal string ("0" non-EVM) |
API sync (guard) | chain.chainId (EVM; "0" placeholder non-EVM) |
ChainConfig.chainId; sync identity guard |
nativeChainId |
string, non-EVM only | API sync | chain.chainId (base58 / hash) |
the chain's real id, kept OUT of chainId |
ccipNative{} |
strings, non-EVM only | API sync | chainConfig in the chain's own encoding |
the native address plane (see below) |
chainSelector |
quoted uint64 string |
API sync (guard) | chain.chainSelector |
ChainConfig.load; the primary join key |
displayName |
string | API sync | chain.displayName |
ChainConfig.load → chainName; log output |
chainFamily |
"evm" | "svm" | "aptos" |
API sync | chain.chainFamily (lowercased) |
ChainConfig.load; EVM/non-EVM dispatch |
environment |
"testnet" | "mainnet" |
API sync | chain.environment |
provenance |
explorerUrl |
URL string | API sync | chainMetadata.explorer.url |
ChainConfig.load; output/verification links |
nativeCurrencySymbol |
string | API sync | chainMetadata.nativeCurrency.symbol |
ChainConfig.load |
ccip.router |
address | API sync | chainConfig.router (active) |
ChainConfig.load → router |
ccip.rmnProxy |
address | API sync | chainConfig.rmn (active) |
ChainConfig.load → rmnProxy |
ccip.tokenAdminRegistry |
address | API sync | chainConfig.tokenAdminRegistry (active) |
ChainConfig.load |
ccip.registryModuleOwnerCustom |
address | API sync | chainConfig.registryModule (active) |
ChainConfig.load |
ccip.feeQuoter |
address | API sync | chainConfig.feeQuoter (active) |
reference (drift-checked) |
ccip.tokenPoolFactory |
address | API sync | chainConfig.tokenPoolFactory (active) |
reference (drift-checked) |
ccip.link |
address | API sync | chainConfig.feeTokens[symbol==LINK] |
ChainConfig.load → link |
ccip.feeTokens |
address[] | API sync | chainConfig.feeTokens[].tokenAddress |
reference (drift-checked) |
chainNameIdentifier |
UPPER_SNAKE string | hand | - (not in the API) | ChainConfig.load; the <ID>_* env prefix |
rpcEnv |
env-var name string | hand | - (not in the API) | fork setup; the doctor's RPC rung |
verifier.type |
"etherscan" | "blockscout" | "sourcify" (optional block) |
hand | (not in the API) | script/config/verify-args.sh (forge verifier flags) |
verifier.url |
URL string (required for blockscout only) |
hand | (not in the API) | script/config/verify-args.sh (--verifier-url) |
evmVersion |
EVM version string (optional) | probed | (not in the API) | script/config/evm-version.sh → --evm-version |
The optional verifier{} block selects the chain's explorer-verification backend. Absent means the
Etherscan family: bare --verify works because forge resolves the Etherscan v2 endpoint from the chain
id, with a warned fallback to Sourcify for a chain Etherscan v2 does not serve. "blockscout" requires
url (the instance API endpoint, usually <explorerUrl>/api); "sourcify" is keyless and needs no URL.
make doctor CHAIN=<name> validates it: an unknown type FAILs, blockscout without a url FAILs, and
so does a stray confirmations key (not part of the schema). See
operations: verification.
foundry.toml pins the repo-wide evm_version, and in Foundry that one setting does two jobs: it is
the compile target for bytecode this repo deploys, and it configures the local EVM that forge test
and every forge script simulation runs in. The default is shanghai, because the PUSH0 opcode it
introduced is emitted by any current solc, and a paris interpreter halts on it with
EvmError: NotActivated rather than reading the operator's token
(gotcha).
A few CCIP chains never activated PUSH0. Bytecode compiled for shanghai is undeployable there, so those chains lower the version for their own runs, in data:
{
"name": "some-chain-without-push0",
"evmVersion": "paris"
// ... the rest of the chain facts
}The knob moves in both directions, because the setting does both jobs. A chain that never activated
PUSH0 needs it lowered so the bytecode is deployable. A chain whose deployed contracts use cancun
opcodes needs it raised, or the local interpreter halts on EvmError: NotActivated while reading them -
the same failure as the paris case, arriving from the opposite side:
{
"name": "some-cancun-chain",
"evmVersion": "cancun"
}The value is measured, not maintained by hand. make add-chain probes the new chain and writes a
key only when the chain differs from the default: paris when it rejects PUSH0, cancun when it
supports both MCOPY and TSTORE. A chain at the default gets no key. Both cancun opcodes must answer,
because a chain serving one and not the other is not a cancun EVM and pinning from a half-answer would
record a guess. An existing key is never overwritten - changing a pin changes the bytecode every future
deploy produces, so that stays a human decision.
The probe is an eth_call carrying initcode, so it needs no funds, no keys and no gas, and it checks
eth_chainId first because public RPC directories carry chainId collisions and a mismatched endpoint
would otherwise answer for a different chain.
A chain added before its RPC was available cannot be probed, so add-chain says so at the time and the
chain compiles at the default. Measure it once the RPC is set:
make detect-evm-version CHAIN=<selectorName>An absent key is also the normal, correct state for the great majority of chains, so nothing treats it as a problem on its own.
The probe writes a pin only when the node explicitly reports an invalid opcode. A timeout or a rate limit leaves the config alone and says so, because pinning on a failed request would record a guess that every later run would then treat as measured fact.
script/config/evm-version.sh <selectorName> is the single resolver (the key if present, else the
default read back from forge config, so there is no second copy of the default to drift). Every make
target scoped to a chain forwards the result as --evm-version: the deploy targets, add-chain,
sync, sync-preview, sync-all, add-lane, remove-lane, adopt-token, doctor,
snapshot-chain, and the roles-check / verify shell tooling.
make preflight is not in that list: it runs ccip-cli, not forge, so there is no --evm-version
to forward - the simulation is an eth_call executed by each chain's own node.
Three consequences worth knowing:
- Explorer verification must match the deploy.
make verifyresolves the same value, so a paris-pinned chain is re-compiled at paris for the bytecode comparison instead of failing on a mismatch that has nothing to do with the source. - A raw
forge scriptgets thefoundry.tomldefault, not the chain's value. The escape hatch bypasses the resolver like it bypasses the RPC and keystore resolution, so on a pinned chain pass--evm-version "$(bash script/config/evm-version.sh <selectorName>)"yourself. - Switching versions between runs forces a full recompile. Foundry keys its build cache on the compiler settings, so alternating between a default chain and a pinned one rebuilds each time.
A typo is caught by the resolver, which knows the same accepted set the doctor does, and the two paths part deliberately:
- Anything that broadcasts refuses to run. The deploy targets abort before forge is invoked, so an unreadable declaration can never quietly ship default-version bytecode to a chain that pinned another version.
- Diagnose paths keep running on the repo default, printing the same
[evm-version]diagnostic. That is what letsmake doctorreach its schema rung and FAIL the key by name with the fix, instead of dying on a forge CLI error about a flag the operator never typed.make preflightsidesteps the question entirely now that it runsccip-cli: each leg is aneth_callon its own chain, so the node decides the EVM version, not this repo.
The lanes.<remote> field rows (remoteSelector, capacity, rate, inbound, the v2 blocks) live
with the subtree in the project store - see
The lanes{} subtree. Pool-scoped policy values
(ccvThreshold, finality{}) live there too, in the poolPolicy{} block - a ccvThreshold key left in
a config file is a schema-rung FAIL naming the move (see
The poolPolicy{} block).
chainNameIdentifier/rpcEnvare DERIVED for newly added chains.make add-chainseedschainNameIdentifieras UPPER_SNAKE of the selectorName (e.g.avalanche-testnet-fuji→AVALANCHE_TESTNET_FUJI) andrpcEnvas<chainNameIdentifier>_RPC_URL, so a fresh chain's names may differ in style from the six bundled chains' hand-curated SHORT forms (ETHEREUM_SEPOLIA, notETHEREUM_TESTNET_SEPOLIA). A selectorName that starts with a digit gets a leading_, because a POSIX shell env-var name cannot start with a digit:0g-testnet-galileo-1derives_0G_TESTNET_GALILEO_1and the settablerpcEnv_0G_TESTNET_GALILEO_1_RPC_URL. You cannot always guess the derived names, soadd-chainprints the exactchainNameIdentifierandrpcEnvit generated in its next-steps output. Override at generation time with theCHAIN_NAME_IDENTIFIER/RPC_ENVenv vars; these keys are hand-authored thereafter (the sync never rewrites them).The bundled
0g-testnet-galileo-1is the frozen exception. For any NEW digit-leading chain the derivation above prefixes_, and the doctor WARNs when a config'srpcEnvis not a valid shell identifier, so the RPC-gated rungs never SKIP silently. The bundled0g-testnet-galileo-1predates that behavior: it carrieschainNameIdentifier: 0G_GALILEO_TESTNET, so its<ID>_*override vars (0G_GALILEO_TESTNET_TOKEN,0G_GALILEO_TESTNET_TOKEN_POOL, ...) are digit-leading.export 0G_...=is refused by the shell, and forge's.envautoload silently stops parsing the file at a digit-leading key (every later.envline is then ignored too). ItsrpcEnvis the hand-curated shell-safeZERO_G_TESTNET_RPC_URLand belongs in.envas usual. Do not put0G_...=lines in.env; pass those vars inline viaenv:env '0G_GALILEO_TESTNET_TOKEN=0x...' forge script ....
Big integers are quoted STRINGS.
chainSelector(uint64) andchainIdexceed JSON's safe integer range (2^53), so they are stored as quoted decimals and read withvm.parseJsonUint, which parses quoted decimals. Never store them as bare JSON numbers - precision is silently lost.
Targeted key reads, not whole-struct decode.
ChainConfigreads by path (.ccip.router,.chainSelector, …), so it is order-independent and robust to the alphabetical key reordering thatvm.writeJsonand the canonicaljq --indent 2 -Sformat perform.
Every ccip{} address is normally sourced from the CCIP REST API. Some deployments are not served by
that API - for example a pre-release or otherwise non-production CCIP deployment that reuses the SAME
real chain selectors and chainIds but a DIFFERENT Router / RMN proxy / TokenAdminRegistry /
RegistryModuleOwnerCustom / FeeQuoter set. For those, one clone = one address plane, declared in data:
a reviewer hand-edits the existing config/chains/<selectorName>.json, replaces the ccip{} block with
that plane's addresses, and stamps a single optional key "configSource": "manual". Absent, it reads as
"api" - byte-identical to a config that never carried the key.
The marker flips the declared WRITER of the ccip{} subtree from the API to a reviewed hand edit (the
one-writer table gains a row, not an exception), and every API-coupled tool
reacts by name:
make sync/make sync-allREFUSE to write a manual chain (a named[sync] SKIP <name> - configSource is manualline, exit 0) - the API sync can never overwrite the hand-maintained addresses back to the API's plane.configSourcemust be exactly"api"or"manual"; an unrecognized value (a typo like"Manual") is refused with a named error, never quietly treated as"api"and overwritten (fail-closed), and the doctor's schema rung FAILs it too.make sync-checktreats a manual chain as SKIP, so the CI drift sweep stays green and honest.make doctor's API rung is a named SKIP plus one WARN (the residual risk: an address change on this plane is not API-detectable, so on a failure re-verify theccip{}addresses against your address source). Every other rung (schema, RPC, on-chain code, TAR, mesh, lanes, roles) is a pure RPC / file read and runs unchanged. With an RPC configured, the on-chain rung also logs the FeeQuoter'stypeAndVersionas a quick check that the plane is the intended CCIP version (the FeeQuoter, OnRamp, and OffRamp carry the discriminating version string; the Router reports the same string across versions, so it is never the version probe).make add-laneand the doctor's mesh rung REFUSE a cross-plane lane - one API chain and one manual chain - naming both chains and bothconfigSourcevalues. A lane must connect two chains on the same address plane.
The cross-plane refusal runs before the non-EVM reciprocity exemption, so a manual plane whose
destination is a non-EVM chain (Solana, Aptos) must mark that chain's config manual too; the sync's
manual refusal then also covers that chain's metadata refresh.
Hand-editing a ccip{} block WITHOUT the marker stays loud: make doctor reports DRIFT, make sync-check goes red, and the next make sync reverts it. The drift error names configSource: "manual"
as the intended path when the addresses are a deliberate non-API plane rather than stale.
make add-chain stays API-only: a manual chain is never CREATED by tooling, only converted by a
reviewed edit. This repo ships no address preset for any such plane - those addresses change on
redeploys and no external service tracks them, so the mechanism is shipped and the data never is. Keep the whole plane in a
branch (the reviewed diff is the audit trail of which addresses were in use when); the documented pattern
is one git worktree per plane.
The project store holds all per-chain state this repo owns, in three subtrees plus a version field, with one writer each:
{
"addresses": { "active": {}, "deployments": {} },
"lanes": {},
"roles": {},
"schema": 3
}That skeleton is what every writer seeds when it first touches a chain (a user's first touch is often
make add-lane or make snapshot-chain, not a deploy), so no command hits a raw vm.writeJson-on-missing
cheatcode revert. "schema": 3 is the version integer future migrations dispatch on. The file is keyed by
the canonical selectorName (the config/chains basename), so two non-EVM chains that both report
chainId "0" never collide - each resolves to its own project/<selectorName>.json, and no project/0.json
is ever created.
Tracking. This template repo gitignores project/ - it ships no real deployment addresses, only the
concrete example project/ethereum-testnet-sepolia.example.json. A downstream fork should un-gitignore
project/ so its lanes, roles, and addresses become one reviewed, git-versioned, team-shared source of
truth: track public data only (addresses + reviewed policy and authority), never secrets (a lint FAILs a
secret-shaped value). See deployed-addresses.md.
Token groups (multiple tokens in one clone). A clone can hold N independent token groups, each in its
own subdirectory: project/<group>/<selectorName>.json. GROUP=<name> (the make var, threaded to the
scripts as PROJECT_GROUP) selects one; an unset group is the default group - the flat
project/<selectorName>.json, byte-identical to a single-token clone. So a one-token user never sets
GROUP and sees no change, and a second token goes in its own group with no effect on the first (the one
exception is make roles-check: with GROUP unset it sweeps the default group AND every other group, see
roles.md):
project/
ethereum-testnet-sepolia.json # default group (token #1)
avalanche-fuji.json # default group (token #1)
usdx/
ethereum-testnet-sepolia.json # group "usdx" (token #2)
avalanche-fuji.json # group "usdx" (token #2)
The group name is validated [a-z0-9][a-z0-9-]* (dashes only, unlike chain names, which also allow underscores; a bad name FAILs with a
named error). Each group is its own mesh universe: the doctor's lane reciprocity reads siblings in the
same group directory, so a lane declared in one group never satisfies another's reciprocity, and a group's
git diff is confined to its directory. make add-lane, remove-lane, adopt-token, snapshot-chain,
doctor, and roles-check all take GROUP=. Those are the config-layer make targets; the deploys
have no make wrapper, so a second token's deploy is the first grouped step - a raw forge script that
takes PROJECT_GROUP=<name> directly (the same value GROUP= threads to the scripts):
# token #2 goes in group "usdx"; deploys pass PROJECT_GROUP directly (no make wrapper)
PROJECT_GROUP=usdx forge script script/deploy/DeployToken.s.sol ... --broadcast --verify
PROJECT_GROUP=usdx forge script script/deploy/DeployBurnMintTokenPool.s.sol ... --broadcast --verifyAdding a second token, end to end. The same steps a first token takes, all under one group, with
token #1 left untouched (omit GROUP=/PROJECT_GROUP= and you target the default group, i.e. token #1):
- Deploy the token and pool under the group (the
forge scriptabove), or, for an already-deployed token, adopt it:make adopt-token CHAIN=<chain> TOKEN=<addr> [TOKEN_POOL=<addr>] GROUP=usdx. Either writesproject/usdx/<chain>.json. - Declare its lanes:
make add-lane LOCAL=<chain> REMOTE=<remote> CAPACITY=<wei> RATE=<wei> BOTH=1 GROUP=usdx. - Back up its authority:
make snapshot-chain CHAIN=<chain> GROUP=usdx. - Verify:
make doctor CHAIN=<chain> GROUP=usdxandmake roles-check CHAIN=<chain> GROUP=usdx.
Repeat per chain the token spans. make doctor with no GROUP= also lists any groups that hold the chain,
so a routine check never silently skips a grouped token.
What stays shared across groups: config/chains/*.json (chain facts - one Router/selector per
physical chain) and the history/ ledger. History is keyed by selectorName + token symbol + timestamp, so
co-located tokens in one group never collide; two same-symbol tokens in different groups still share
one history/<category>/<selectorName>/ directory, their append-only entries separated only by timestamp
(harmless - history/ is a write-only diary, never read back). Give each token its own group when more than
one token lives on a chain - it is the durable fix the doctor and the repoint warning point at.
Canonical form (differs from config/chains). Each store has its own on-disk canonical, and the writers
emit it directly so a no-op re-write is a zero-diff even on the direct forge script path (no make):
| Store | Canonical form | Emitted by |
|---|---|---|
project/*.json |
sorted keys at every level, 2-space indent, NO trailing newline (forge vm.writeJson's exact output) |
the Solidity writers (RegistryWriter, add-lane, snapshot-chain) |
config/chains/*.json |
sorted keys, 2-space indent, trailing newline (jq --indent 2 -S) |
the sync + jq canonicalize step |
make fmt-config repairs both stores to their respective canonical (project/*.json without the
trailing newline, config/chains/*.json with it). It is a repair tool only, never a required step - a
direct-forge run is already canonical. A golden test pins project/ writer output against
jq --indent 2 -S (newline-normalized) so a future Foundry formatter change fails CI visibly.
addresses{} is the deployed-address registry: the current-state, machine-read store that
HelperConfig resolution, the redeploy guard, and the doctor read back. (The word "registry" is reserved
for this subtree; the whole file is the "project store".) Its sole writers are the deploy recorder and
make adopt-token, both through RegistryWriter, which writes .addresses only. Values are strings:
EVM hex on EVM chains, base58 on non-EVM chains, family-validated on write against the chain's
config/chains/<selectorName>.json .chainFamily.
"addresses": {
"active": { // <- what HelperConfig resolves (zero-export)
"token": "0xToken", // EVM hex here; a base58 string on a non-EVM chain
"tokenPool": "0xPoolV2", // most-recently-deployed pool for the chain
"lockBox": "0xLockBox",
"poolHooks": "0xHooks"
},
"deployments": { // <- uniquely named per artifact (type + version in the key)
"BnM-T_Token": "0xToken",
"BnM-T_BurnMintTokenPool_2.0.0": "0xPoolV2", // key carries the pool type + version
"BnM-T_LockBox": "0xLockBox",
"BnM-T_BurnMint_PoolHooks": "0xHooks"
}
}active.<role>is the single slotHelperConfigresolves for each of the four roles (token/tokenPool/lockBox/poolHooks) - the zero-export default.read(selectorName, role)resolves.addresses.active.<role>. Environment variables still override the registry (see the deployed addresses precedence ladder), as read-only inputs: an env-driven run resolves the override but never writes the store.activeis what this repo last deployed - NOT proof of what is wired. The on-chain TokenAdminRegistry (getPool(token)) is the authority for the pool CCIP actually routes through. They legitimately diverge whenever the wired pool was changed out-of-band (e.g. asetPoolcut from a Safe).make doctorreads the TAR and reports any divergence as a WARN (never a FAIL). Single-valued limit:active.<role>holds exactly one address per role. Deploy two pools for the same symbol on one chain andactive.tokenPoolpoints at the LAST one deployed; the zero-export getters then resolve that same last pool for both tokens. To address a specific earlier artifact, pass it explicitly via env or read itsdeployments.<name>entry.make doctorsurfaces the ambiguity: whendeployments{}holds more than one token pool, the registry rung WARNs and names the{CHAIN}_TOKEN_POOLtargeted override.deployments.<name>is the uniquely-named archive: the key carries the pool's type and version, so distinct artifacts never collide or clobber each other in storage. This is a storage property, not a resolution one - the zero-export ladder resolves onlyactive, which is single-valued (above).
Per-artifact keys and the redeploy guard are the reference of
deployed-addresses.md; the resolution precedence is in the
deployed addresses.
lanes{} declares which remote chains this pool connects to and at what outbound rate limits - the
mesh the owner intends, consumed by the applyChainUpdates flows: ApplyChainUpdates (CLI mode) applies
the declared capacity/rate (and the optional inbound{} block) whenever the rate-limit env vars are
unset, and the env vars remain the explicit override on top - an override that diverges from the
declaration is noticed on the console (with the exact make add-lane command to reconcile) and FAILs in
make doctor until the declaration is brought in line. That is policy, so it has a different
writer from everything above: the API sync never touches it (a make sync preserves it verbatim), and
make add-lane LOCAL=<a> REMOTE=<b> CAPACITY=<wei> RATE=<wei> [BOTH=1] is the scripted writer (BOTH=1
writes the reciprocal entry on the remote's file too). Each entry is keyed by the remote's canonical
selectorName and carries the remote's chainSelector plus the outbound rate-limit policy - all
quoted-decimal strings (the big-int rule above).
Beyond the required core (remoteSelector/capacity/rate), an entry may declare optional policy
blocks: inbound{capacity,rate} (written by make add-lane ... INBOUND_CAPACITY=<wei> INBOUND_RATE=<wei>, or a hand edit) and the 2.0.0-only v2{} block - v2.fastFinality.outbound /
v2.fastFinality.inbound buckets, the per-lane v2.feeConfig fee override, and the per-lane
v2.ccv verifier requirements (all declared by a reviewed hand edit; add-lane has no flag surface
for them). The v2 blocks are consumed, not just verified: UpdateRateLimiters with FAST_FINALITY=true
on a 2.0.0 pool applies the declared v2.fastFinality bucket(s) whenever the rate-limit env vars are
unset for a direction, UpdateTokenTransferFeeConfig resolves every fee field the same way from
v2.feeConfig (env var > declared field > current on-chain value), and UpdateCCVConfig resolves
every CCV array the same way from v2.ccv - the same env-over-lanes ladder ApplyChainUpdates uses for
the core fields. The semantics are strict: an absent block or field is undeclared - the doctor
does not reconcile it and the apply scripts fall through to their historical defaults; a declared
bucket with 0/0 is declared-disabled and the doctor asserts the live bucket is off. add-lane
preserves every block verbatim when it rewrites the lanes subtree to append another lane.
The v2.ccv block and the pool-scoped ccvThreshold. CCVs (Cross-Chain Verifiers) live on the
pool's AdvancedPoolHooks contract (TokenPool(pool).getAdvancedPoolHooks()), so this policy needs a
2.0.0 pool with hooks wired - a declared v2.ccv on a cataloged pre-2.0.0 pool, or a 2.0.0 pool with
no hooks, FAILs in the doctor by name and refuses by name in UpdateCCVConfig (wire hooks first:
updateAdvancedPoolHooks / DeployAdvancedPoolHooks). v2.ccv carries four optional address-string
arrays, each declared-only-if-present (an absent array is undeclared and is never reconciled; a present
empty array [] is declared-empty): outboundCCVs and inboundCCVs are the base verifier sets
required for every message in that direction, and thresholdOutboundCCVs / thresholdInboundCCVs are the
extra verifiers required only at or above the threshold amount. The on-chain rules are enforced by both
the setter and the hooks contract: a non-empty threshold*CCVs list requires a non-empty base list in
the same direction (MustSpecifyUnderThresholdCCVsForThresholdCCVs), no address may appear twice within a
list or be shared between a list and its threshold list, and a lane whose outboundCCVs and
inboundCCVs are both empty is removed from the pool's configured set. applyCCVConfigUpdates fully
replaces a lane's entry, so UpdateCCVConfig reads the current on-chain config first and writes back
every array the caller did not declare unchanged (declaring only outboundCCVs leaves the three other
arrays at their live values). The ccvThreshold is pool-scoped and pool-global, not per-lane: it is a
single value on the AdvancedPoolHooks (setThresholdAmount) that governs the threshold for every lane's
threshold*CCVs, declared at poolPolicy.ccvThreshold (see
The poolPolicy{} block); 0 or absent means no threshold
is declared.
Guards (each verified by script/config/test-tooling.sh and test/config/LaneConfig.t.sol):
- Duplicate lane → a logged no-op that leaves the file byte-identical (edit the JSON to change an
existing lane's policy;
add-lanenever rewrites entries it did not create). - Self-lane → refused: same
LOCAL/REMOTEname, or two config files sharing onechainSelector(a pool must never register its own selector as a remote). - Placeholder pool → a lane to a remote whose registry has no
tokenPoolyet logs a WARN naming the missing deploy - the lane can be declared ahead of the deploy, but transfers cannot execute over it until the pool exists. - Subtree isolation →
add-lanewrites only.lanes(same preserve-and-replace pattern the sync uses for.ccip), so a lane edit can never disturb an API-served or hand-authored field.
make doctor closes the loop with two rungs. The mesh rung proves the committed policy agrees with
itself: every declared lane's remote config file must exist and its stored remoteSelector must equal
the remote's chainSelector, and reciprocity is checked across the whole mesh - a one-sided lane (A
declares B without B declaring A, in either direction) is a FAIL naming both chains. Non-EVM remotes
are exempt from reciprocity: they are destination-only in this repo and carry no lanes{} of their own
(see below). The lanes rung then proves the policy agrees with the chain (RPC-gated; see
config-architecture.md): every declared lane must be applied on the
registry-resolved pool, and every declared value (the core outbound bucket, and any declared
inbound/v2/poolPolicy block) must match the live chain. A declared value the chain contradicts
is a FAIL naming the exact field - the declaration is the intent, so a deliberate emergency throttle
is recorded by updating the declaration (the git diff documents the incident). The rung reports every
drifted field in one run and exits nonzero once at the end, so a multi-field drift is remediated as a
batch, not one fix-and-rerun at a time. Forward-intent states (a declared lane not yet applied), an
on-chain lane not declared back, an uncataloged pool version, and any unanswered read stay WARN:
they are pending work or degraded visibility, never proven drift.
poolPolicy{} declares the pool-scoped (not per-lane) 2.0.0 policy values. It is optional and
hand-authored only: its sole writer is a reviewed hand edit - no script writes it, add-lane and
the sync preserve it verbatim, and no apply ever writes it back (make fmt-config repairs formatting).
Absent = undeclared: a project store without the block behaves byte-identically to today.
{
"addresses": { ... },
"lanes": { ... },
"poolPolicy": {
// OPTIONAL. The pool-global additional-CCV threshold on the AdvancedPoolHooks
// (setThresholdAmount), quoted-decimal wei. Governs every lane's threshold*CCVs.
"ccvThreshold": "1000000000000000000000",
// OPTIONAL. The pool's allowed finality config (setAllowedFinalityConfig), declared in mode
// terms. PRESENT (even empty {}) = declared; both keys optional:
// blockDepth quoted-decimal 1..65535 - allow fast finality after N confirmations
// waitForSafe bool - allow fast finality at the `safe` head
// both - either mode acceptable; {} - WAIT_FOR_FINALITY (fast finality disabled)
"finality": { "blockDepth": "5", "waitForSafe": true }
},
"roles": { ... },
"schema": 3
}The declaration is in mode terms, never raw hex; the tooling derives the on-chain bytes4 (lower 16
bits = block depth, bit 16 = the safe flag; full finality is always an allowed request - the value only
adds faster modes) and every doctor/console line prints the raw bytes4 plus its decoded meaning
(e.g. 0x00010020 (WAIT_FOR_SAFE + BLOCK_DEPTH (32 blocks))). Reserved flag bits this tooling cannot
decode print honestly as Custom / Reserved flags. Consumption mirrors the lanes ladder:
UpdateCCVConfig resolves the threshold as env (CCV_THRESHOLD_AMOUNT) > declared
poolPolicy.ccvThreshold > current on-chain, and SetFinalityConfig resolves the finality config as
env (WAIT_FOR_SAFE/BLOCK_DEPTH, either present) > declared poolPolicy.finality > the
WAIT_FOR_FINALITY reset. The doctor's lanes rung reconciles whatever is declared, once per chain: drift
FAILs naming the field; a declaration against a cataloged pre-2.0.0 pool, or ccvThreshold against a
2.0.0 pool with no hooks wired, FAILs by name (the declaration can never converge). Against an
uncataloged version the two values differ: finality is read best-effort (an unanswered
getAllowedFinalityConfig stays WARN; a successful read that contradicts the declaration still FAILs
as drift), while ccvThreshold emits the version WARN without attempting the hooks read. Both values
are pool-scoped chain state, per chain: each chain's project store declares its own pool's values.
A ccvThreshold key left at the top level of config/chains/<name>.json is a schema-rung FAIL
naming the correct location and remediation (pool policy never lives in the API-synced file).
roles{} declares who holds every privileged role across the token, its pool, the
TokenAdminRegistry, and (when present) the lockbox and hooks - the authority surface a security owner
intends. It is the durable, reviewed record of "who controls this deployment" (git-versioned once a fork
tracks project/), reconciled against the live chain by make roles-check and make doctor's roles rung.
Its sole writer is make snapshot-chain CHAIN=<name> (backfill FROM chain, preserve-and-replace on the
.roles subtree only, the same discipline as .addresses/.lanes); the API sync never touches it, and
roles-check only reads
it. The full operational model - declared-intent vs live, the read-only-vs-writer split, the
drift-response decision tree, and the honest-coverage caveat - lives in docs/roles.md;
this section is the field reference.
Every governance-critical single-holder slot (token.defaultAdmin, token.ccipAdmin, pool.owner,
pool.rateLimitAdmin, pool.feeAdmin, tokenAdminRegistry.administrator, lockbox.owner,
hooks.owner) is verified by a direct getter point-read - a plain eth_call, reliable on any RPC,
no eth_getLogs. Multi-holder role lists (minters, burners, defaultAdmins, burnMintRoleAdmins)
carry an honest complete marker: true only when the token enumerates its holders or a
snapshot-chain SCAN_FROM_BLOCK=<n> event scan proved the list; false (candidate seed) otherwise, and
the auditor WARNs so a partial list is never read as full.
Absence is the unread state. A chain-readable field the snapshot could not read is not written -
never written as 0x0 / false / [], which are what a failed probe returns and are indistinguishable
from real values once on disk. The snapshot logs each such field as UNREAD and omits it; the audit then
refuses to compare a declared value against a read that failed (could not be read, so nothing was compared - a FAIL, not a PASS), and a declared hooks/lockbox contract that answers none of its
getters fails as unauditable outright. A declaration can still carry zeros
that were produced by failed reads (a snapshot written by older tooling, or a hand edit): they were
never facts, and against a still-unreadable contract they FAIL instead of reconciling - re-run
make snapshot-chain on a working RPC to rebuild the block from actual reads.
The token block dispatches on a declared type, because the admin model differs per template - the
engine never assumes one:
type |
Template | Top-level admin field(s) | Notes |
|---|---|---|---|
crosschain |
CrossChainToken |
defaultAdmin (+ pendingDefaultAdmin) |
OZ AccessControlDefaultAdminRules; single-holder, two-step transfer. Has the separate burnMintRoleAdmins list (the BURN_MINT_ADMIN_ROLE that admins mint/burn - a slot a naive sweep forgets). |
burnmint |
BurnMintERC20 |
defaultAdmins (list) |
plain OZ AccessControl; multi-holder DEFAULT_ADMIN_ROLE admins mint/burn directly. |
factory |
FactoryBurnMintERC20 |
owner |
Ownable; enumerable getMinters()/getBurners() sets. |
byo |
unknown / externally deployed | whichever of owner / defaultAdmins answers |
only the universal admin-registration points are probed; every other token-internal list stays complete:false (a clean check does NOT prove a BYO token's mint/burn rights are safe). |
"roles": {
// ── token authority (template-dispatched on `type`) ──────────────────────────
"token": {
"address": "0xa1f7882a...", // the token this block describes (the snapshot/audit anchor;
// make doctor WARNs when it diverges from addresses.active.token
// - a plain snapshot-chain refuses; re-anchor with REANCHOR=true)
"type": "crosschain", // crosschain | burnmint | factory | byo - selects the admin model
"ccipAdmin": "0xGov...", // getCCIPAdmin() - the TAR registration authority (one-step, owner-gated)
"defaultAdmin": "0xGov...", // crosschain only: defaultAdmin() (single-holder, two-step)
"pendingDefaultAdmin": "0x0", // crosschain only: a non-zero value means a transfer is IN FLIGHT
// "owner": "0xGov...", // factory/byo instead of defaultAdmin
// "defaultAdmins": { "holders": ["0x.."], "complete": false }, // burnmint/byo multi-holder admin list
"burnMintRoleAdmins": { // crosschain only: BURN_MINT_ADMIN_ROLE holders (admin of mint/burn)
"holders": ["0xGov..."], "complete": false
},
"minters": { "holders": ["0xPool..."], "complete": false }, // MINTER_ROLE holders (pool + any EOAs)
"burners": { "holders": ["0xPool..."], "complete": false } // BURNER_ROLE holders
},
// ── TokenAdminRegistry (the cutover authority; the TAR CONTRACT owner is out of scope) ──
"tokenAdminRegistry": {
"registry": "0x95F29FEE...", // the TAR the token is REGISTERED in (may differ from the directory TAR)
"administrator": "0xGov...", // getTokenConfig(token).administrator - the onlyTokenAdmin authority
"pendingAdministrator": "0x0" // a non-zero value means a two-step admin transfer is IN FLIGHT
},
// ── pool authority (dual-generation: feeAdmin/hooks are 2.0.0-only) ──────────
"pool": {
"address": "0x4CAc9C8c...", // the pool this block describes (the snapshot/audit anchor)
"owner": "0xGov...", // Ownable2Step owner (config authority)
"rateLimitAdmin": "0xSafe...", // the fast-throttle authority (v2: inside getDynamicConfig)
"feeAdmin": "0xGov...", // 2.0.0 only: inside getDynamicConfig
"hooks": "0xHooks..." // 2.0.0 only: getAdvancedPoolHooks() (0x0 when unwired)
},
// ── OPTIONAL blocks: declared-only-if-present (omit for a burnmint chain with no lockbox/hooks) ──
"lockbox": { // v2 LockRelease only (the v2 replacement for the v1 rebalancer)
"address": "0xLockbox...", "owner": "0xGov...",
"authorizedCallers": ["0xPool..."] // enumerable → full two-sided set compare
},
"lockboxes": { // Siloed 2.0 only: one entry per distinct box, keyed by address
"0xBoxA...": { "owner": "0xGov...", "authorizedCallers": ["0xPool..."] }
}, // two-sided: every declared box is mapped, every mapped box declared
"hooks": { // the CCV/allowlist authority (security-critical)
"address": "0xHooks...", "owner": "0xGov...", "policyEngine": "0xPolicy...",
"allowlistEnabled": false, // immutable (set at deploy)
"allowlist": [], "authorizedCallers": ["0xPool..."]
},
// "rebalancer": "0xGov...", // v1 LockRelease only (v2 uses the lockbox above)
// ── OPTIONAL governance{} - three shapes: safe-only, timelock-only, or both. ──
// Absent = EOA-only chain (a valid SKIP, never a FAIL). ──────────────────
"governance": {
"safe": { "address": "0xSafe...", "threshold": 2, "owners": ["0x..","0x..","0x.."] },
"timelock": { // pure declarations (proposers/etc.) are not enumerable on-chain
"address": "0xTL...", "minDelay": 172800,
"proposers": ["0xSafe..."], "cancellers": ["0xSafe..."], "executors": ["0x0"],
"adminRenounced": true
}
}
}Semantics the auditor enforces (all in src/roles/RolesAuditor.sol, mounted as the doctor's roles rung
and run standalone by make roles-check):
- Type mismatch is a FAIL - a declared
typethat contradicts the probed surface (e.g.factorydeclared for a token that answersdefaultAdmin()) FAILs and the dependent token rungs are skipped to avoid cascade noise.byonever asserts a template; it only point-checks the universal admin points it declares. - Enumerable sets get a two-sided compare (lockbox/hooks
authorizedCallers, hooksallowlist, safeowners, factory-token minters/burners) - this detects BOTH a revoked holder AND a rogue additive grant. Non-enumerable lists (crosschain/burnmintmint/burn/admin) point-check each declared holder (revoke always detected) and WARN that additive grants are unverified - even whencomplete:true, because that completeness was proven at snapshot time, not now (never a silent CLEAN). PassingSCAN_FROM_BLOCK=<n>tomake roles-checkopts into an event-scan two-sided compare that turns an undeclared additive grant into a hard FAIL. A scanned list recordsscannedFromBlocknext tocompleteas provenance (the block the completeness was proven from). - Absent optional block → SKIP, never FAIL -
governance{}on an EOA chain,lockbox/hooks/rebalancerwhere the pool has none.governance{}supports three shapes; a timelock-only shape with an EOA proposer is valid. - The TAR CONTRACT
owner(the registry-module authority) is the network operator's (Chainlink's), deliberately out of scope - never read, never a FAIL.RegistryModuleOwnerCustomhas no privileged slot to declare (self-service registration). - Cross-consistency WARNs (never FAILs, they are conventions): a declared safe that is not the
pool's
rateLimitAdmin(the fast-emergency-throttle convention); a timelock declared without the safe among its proposers. - Subtree isolation -
snapshot-chainwrites only.roles, so a roles refresh never disturbs an API-served,lanes{}, or hand-authored field.
Non-EVM chains are supported as a destination only (to register a non-EVM pool as a remote on an EVM
source). config/chains/solana-devnet.json keeps the same shape but:
chainFamilyis"svm", andchainIdis the placeholder"0"- Solana has no EVM chain id. The chainId identity guard cannot fire here, so thename(selectorName) is the only validatable identity; the sync/doctor selectorName guard is what protects a non-EVM file from a wrong selector.- The
ccip{}block is all-zero andfeeTokensis empty: non-EVM chains have no EVM-shapedchainConfig, so they are excluded from the API address sync (the sync SKIPs theccip{}transform cleanly). It stays as the EVM-typed skeleton every.ccip.*reader needs - a base58 value there makesHelperConfig's constructor scan abort on UNRELATED chains, because it parses every configured file's.ccip.*as an EVM address. - The real addresses live in the sibling
ccipNative{}block, withnativeChainIdnext to it, both API-synced and both non-EVM only. Values are family-native STRINGS (base58 on SVM, 32-byte hex on Aptos). Key names mirrorccip{}where the concept is shared (rmn->rmnProxy,registryModule->registryModuleOwnerCustom), plustokenPoolPrograms{burnMint,lockRelease}- SVM ships both pool programs and lock-release is a first-class use case, not a burn-mint fallback. A key the API does not serve for that family is ABSENT, not zero: which contracts exist differs by family (SVM has notokenAdminRegistry; Aptos has one but noregistryModuleand no pool programs), and an absent key says "not a thing here" where a zero would say "missing".feeTokensis not carried -linkis the one fee token the repo keys on. nativeChainIdis where the chain's real id goes;chainIdkeeps its"0"sentinel. Putting the base58 genesis hash inchainIdmakes the chain VANISH fromHelperConfig's configured-chain scan, and Aptos's real"2"silently empties its remote-pool resolution. SeveralHelperConfigpaths key off the sentinel (the constructor scan, the broadcast guard,getSelectorName, the chainId lookups).- The non-EVM chain's own project store carries no
lanes{}: lanes are outbound policy, and non-EVM chains are destination-only here - an EVM chain may declare a lane tosolana-devnet(exempt from the doctor's reciprocity rung), butmake add-lane LOCAL=solana-devnet ...is refused (no lanes to write). Itsaddresses{}subtree DOES apply, holding the Solana token/pool as base58 strings (see below). - The EVM-only make targets refuse a non-EVM chain by family.
deploy-token,deploy-pool,deploy-lockbox,deploy-lockrelease-pool(anddeploy-new-chainthrough them) andadopt-token TOKEN=name the family and point at theadopt-tokenpath instead of handing forge an endpoint that speaks no EVM JSON-RPC. The read paths are unaffected:syncanddetect-evm-versionSKIP,roles-checkSKIPs,doctorreports the unverifiable rungs,probe-chainandverify-argsrefuse by family with their own message.make tools CHAIN=<solana-chain>names the family and asks for nothing extra to install, because this repo has no non-EVM write path. - A non-EVM token/pool is stored as base58 and feeds
applyChainUpdatesfrom the store. Adopt it withmake adopt-token CHAIN=<solana-chain> TOKEN_B58=<base58> [POOL_B58=<base58>](therunNonEvmpath), which family-validates each value (base58 decodes to exactly 32 bytes) and writesproject/<solana-chain>.jsonaddresses{}. An EVM source then reads those remote bytes from the store through the selectorName-keyed getters when it lists Solana as a remote inapplyChainUpdates, replacing the old env-only path. Two non-EVM chains that both reportchainId "0"never collide, because the store is keyed by selectorName. - The base58 validation is syntactic only - sanity-check the accounts before wiring the lane.
POOL_B58must be the Solana pool's config account (the state PDA the OnRamp stamps assourcePoolAddress), not the pool program id or the token mint;TOKEN_B58is the mint. Once the Solana side is deployed, stock CLIs confirm both (advisory - the accounts do not exist pre-deploy, soAccountNotFoundis expected then):solana account <POOL_B58> --url <cluster> --output jsonmust show the account exists,executable:false, andowner= the CCIP pool program id (a signer PDA returnsAccountNotFound; a program id showsexecutable:true; a mint shows a token-program owner);spl-token display <TOKEN_B58> --url <cluster>must report an SPL/Token-2022 mint. - The chain-level identity + metadata ARE served for non-EVM and ARE synced.
GET /v2/chains/{selector}returnschainMetadata{explorer,nativeCurrency}for every family, sodisplayName,chainFamily,environment,explorerUrl, andnativeCurrencySymbolare sourced/refreshed from the API on Solana too (e.g.explorerUrl=https://explorer.solana.com?cluster=devnet,nativeCurrencySymbol=SOL). Only the EVM-shapedccip{}addresses are skipped.
{
"name": "solana-devnet", // canonical selectorName (already canonical; unchanged)
"displayName": "Solana Devnet", // <- chain.displayName (API-synced)
"chainNameIdentifier": "SOLANA_DEVNET", // hand
"chainFamily": "svm", // <- chain.chainFamily (API-synced)
"environment": "testnet", // <- chain.environment (API-synced)
"chainId": "0", // placeholder - non-EVM; selectorName is the portable identity
"chainSelector": "16423721717087811551",
"rpcEnv": "SOLANA_DEVNET_RPC_URL", // hand
"ccip": {
"router": "0x00...00",
"rmnProxy": "0x00...00",
"tokenAdminRegistry": "0x00...00",
"registryModuleOwnerCustom": "0x00...00",
"feeQuoter": "0x00...00",
"tokenPoolFactory": "0x00...00",
"link": "0x00...00",
"feeTokens": []
},
"ccipNative": {
// the same plane in Solana's own encoding - non-EVM only, API-synced, absent keys are unserved
"router": "Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C",
"rmnProxy": "RmnXLft1mSEwDgMKu2okYuHkiazxntFFcZFrrcXxYg7",
"feeQuoter": "FeeQPGkKDeRV1MgoYfMH6L8o3KeuYjwUZrgn4LRKfjHi",
"link": "LinkhB3afbBKb2EQQu7s7umdZceV3wcvAUJhQAfQ23L",
"tokenPoolPrograms": {
"burnMint": "41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB",
"lockRelease": "8eqh8wppT9c5rw4ERqNCffvU6cNFJWff9WmkcYtmGiqC"
}
},
"nativeChainId": "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", // <- chain.chainId (API-synced)
"explorerUrl": "https://explorer.solana.com?cluster=devnet", // <- chainMetadata.explorer.url (API-synced)
"nativeCurrencySymbol": "SOL" // <- chainMetadata.nativeCurrency.symbol (API-synced)
}Aptos is a THIRD shape and carries the same block: tokenAdminRegistry present, tokenPoolPrograms and
registryModuleOwnerCustom absent, one Move package address serving router/feeQuoter/tokenAdminRegistry/rmn.
It is read-only here.
The Solana deployed addresses live in project/solana-devnet.json addresses{} as base58 strings, not in
this file.
The remote-pool field is chain-family-shaped. The remote-pool value an EVM source registers for a lane
(through ApplyChainUpdates, read from the remote chain's addresses{}) is what the destination pool
checks a message's sourcePoolAddress against at release time. Getting the shape wrong fails the same way
a wrong EVM remote does:
- For an EVM remote, it is the remote pool's EVM address, abi-encoded to 32 bytes. The message a send
emits carries that address as
sourcePoolAddress, and the destination pool validates it againstgetRemotePools(sourceSelector). - For a Solana remote, it is a Solana account in base58, not an EVM-style address. The CCIP Solana
router stamps the pool's per-mint config account as the message's
sourcePoolAddress(the state PDA identified under Non-EVM (Solana) chain file above, derived from the pool program and the token mint). That account, base58-encoded, is what belongs in the remote-pool field, so the EVM destination pool'sgetRemotePoolsset matches what an inbound Solana message actually carries. - For an Aptos remote, it is the Aptos account address: a 32-byte value written as
0xhex (short forms with a leading run of zero bytes elided, e.g.0x1, are valid and left-pad to the canonical 32 bytes). SetdestChainFamily:"aptos"in theApplyChainUpdatesJSON entry (orDEST_CHAIN_FAMILY=aptosin CLI mode); the address is hex-parsed to 32 raw bytes, the same native-width form as a Solana remote but hex-decoded rather than base58-decoded. The remote pool is the Aptos token pool's address the CCIP Aptos router stamps assourcePoolAddress; the remote token is the fungible-asset metadata object address. As with Solana, the validation is syntactic (well-formed 32-byte hex), never that it is the right account - confirm it against the deployed Aptos pool before wiring the lane.
Registering the wrong Solana account here (the pool program id or the token mint instead of the config
account), or an EVM-style 0x encoding, makes the EVM destination release revert InvalidSourcePoolAddress,
exactly as a wrong or decommissioned EVM remote pool does: the stamped sourcePoolAddress is not in the
destination's registered set. The addresses{} write is family-validated (base58 that decodes to exactly
32 bytes on an svm chain, per The addresses{} sub-store), but
that check is syntactic. It proves the value is a well-formed Solana account, never that it is the right
one. Confirm the account with the solana account / spl-token checks in the non-EVM section above before
wiring the lane.
deployed-addresses.md- the deployed-address loop (writers, overrides, reconcile, warns), the per-artifact keying table, the redeploy guard, and the template-vs-fork tracking rule.roles.md- theroles{}operational runbook.config-architecture.md- themake-command reference and the layered store diagrams.
{ // ── join keys (seeded at add-chain, then guard-validated by the sync; never rewritten) ─── "name": "ethereum-testnet-sepolia", // canonical CCIP selectorName; == file basename; the CHAIN= arg "chainId": "11155111", // native chain id, quoted STRING (see big-int note); "0" for non-EVM "chainSelector": "16015286601757825753", // uint64 CCIP selector, quoted STRING; the primary join key // ── API-synced identity + metadata (sourced from GET /v2/chains/{selector}; never hand-edit) ── "displayName": "Ethereum Sepolia", // <- chain.displayName; human label for logs / output links "chainFamily": "evm", // <- chain.chainFamily (lowercased); dispatches EVM vs non-EVM "environment": "testnet", // <- chain.environment ("testnet" | "mainnet") "explorerUrl": "https://sepolia.etherscan.io", // <- chainMetadata.explorer.url; output/verification links "nativeCurrencySymbol": "ETH", // <- chainMetadata.nativeCurrency.symbol; native gas-token symbol // ── ccip{} : API-synced (overwritten by the sync; never hand-edit) ─────────── "ccip": { "router": "0x0BF3dE8c...", // CCIP Router - entrypoint for ccipSend / offRamp "rmnProxy": "0xba3f6251...", // RMN (Risk Management Network) proxy / ARMProxy "tokenAdminRegistry": "0x95F29FEE...", // TokenAdminRegistry - maps token → its pool + admin "registryModuleOwnerCustom": "0xa3c796d4...", // RegistryModuleOwnerCustom - claim-admin registry module "feeQuoter": "0x8632C302...", // FeeQuoter - quotes CCIP fees (reference) "tokenPoolFactory": "0x2067C044...", // TokenPoolFactory - deploys standard pools (reference) "link": "0x779877A7...", // LINK token (the LINK fee token on this chain) "feeTokens": ["0xc4bF5CbD...", "0x779877A7...", "0x097D90c9..."] // accepted CCIP fee tokens (reference) }, // ── hand-authored (the API serves nothing for these; reviewed in a PR, preserved by sync) ── "chainNameIdentifier": "ETHEREUM_SEPOLIA", // UPPER_SNAKE env-var prefix: <ID>_RPC_URL, <ID>_TOKEN, <ID>_TOKEN_POOL "rpcEnv": "ETHEREUM_SEPOLIA_RPC_URL" // name of the env var holding this chain's RPC URL // optional "verifier": { "type": "...", "url": "..." } - explorer-verification backend (absent = Etherscan v2) // optional "evmVersion": "paris" - the EVM version forge compiles and simulates at for this chain // (absent = the foundry.toml default; see "Per-chain EVM version" below) }