Skip to content

Latest commit

 

History

History
861 lines (755 loc) · 66.6 KB

config-schema.md

File metadata and controls

861 lines (755 loc) · 66.6 KB
type reference

Chain config and project-store schema

Per-chain state lives in two files, both keyed by the canonical CCIP selectorName:

  • config/chains/<selectorName>.json - pure API/chain facts (the ccip{} 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.

The file name IS the canonical CCIP selectorName

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).

One writer per subtree

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 file - every field (pure API facts)

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:

{
  // ── 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)
}

Field reference

"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.

Per-chain EVM version

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 verify resolves 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 script gets the foundry.toml default, 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 lets make doctor reach 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 preflight sidesteps the question entirely now that it runs ccip-cli: each leg is an eth_call on 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/rpcEnv are DERIVED for newly added chains. make add-chain seeds chainNameIdentifier as UPPER_SNAKE of the selectorName (e.g. avalanche-testnet-fuji → AVALANCHE_TESTNET_FUJI) and rpcEnv as <chainNameIdentifier>_RPC_URL, so a fresh chain's names may differ in style from the six bundled chains' hand-curated SHORT forms (ETHEREUM_SEPOLIA, not ETHEREUM_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-1 derives _0G_TESTNET_GALILEO_1 and the settable rpcEnv _0G_TESTNET_GALILEO_1_RPC_URL. You cannot always guess the derived names, so add-chain prints the exact chainNameIdentifier and rpcEnv it generated in its next-steps output. Override at generation time with the CHAIN_NAME_IDENTIFIER / RPC_ENV env vars; these keys are hand-authored thereafter (the sync never rewrites them).

The bundled 0g-testnet-galileo-1 is the frozen exception. For any NEW digit-leading chain the derivation above prefixes _, and the doctor WARNs when a config's rpcEnv is not a valid shell identifier, so the RPC-gated rungs never SKIP silently. The bundled 0g-testnet-galileo-1 predates that behavior: it carries chainNameIdentifier: 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 .env autoload silently stops parsing the file at a digit-leading key (every later .env line is then ignored too). Its rpcEnv is the hand-curated shell-safe ZERO_G_TESTNET_RPC_URL and belongs in .env as usual. Do not put 0G_...= lines in .env; pass those vars inline via env: env '0G_GALILEO_TESTNET_TOKEN=0x...' forge script ....

Big integers are quoted STRINGS. chainSelector (uint64) and chainId exceed JSON's safe integer range (2^53), so they are stored as quoted decimals and read with vm.parseJsonUint, which parses quoted decimals. Never store them as bare JSON numbers - precision is silently lost.

Targeted key reads, not whole-struct decode. ChainConfig reads by path (.ccip.router, .chainSelector, …), so it is order-independent and robust to the alphabetical key reordering that vm.writeJson and the canonical jq --indent 2 -S format perform.

Manual address planes (configSource: "manual")

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-all REFUSE to write a manual chain (a named [sync] SKIP <name> - configSource is manual line, exit 0) - the API sync can never overwrite the hand-maintained addresses back to the API's plane. configSource must 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-check treats 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 the ccip{} 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's typeAndVersion as 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-lane and the doctor's mesh rung REFUSE a cross-plane lane - one API chain and one manual chain - naming both chains and both configSource values. 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 - project/<selectorName>.json

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 --verify

Adding 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):

  1. Deploy the token and pool under the group (the forge script above), or, for an already-deployed token, adopt it: make adopt-token CHAIN=<chain> TOKEN=<addr> [TOKEN_POOL=<addr>] GROUP=usdx. Either writes project/usdx/<chain>.json.
  2. Declare its lanes: make add-lane LOCAL=<chain> REMOTE=<remote> CAPACITY=<wei> RATE=<wei> BOTH=1 GROUP=usdx.
  3. Back up its authority: make snapshot-chain CHAIN=<chain> GROUP=usdx.
  4. Verify: make doctor CHAIN=<chain> GROUP=usdx and make 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.

The addresses{} sub-store (the registry)

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 slot HelperConfig resolves 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.

    active is 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. a setPool cut from a Safe). make doctor reads 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 and active.tokenPool points 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 its deployments.<name> entry. make doctor surfaces the ambiguity: when deployments{} holds more than one token pool, the registry rung WARNs and names the {CHAIN}_TOKEN_POOL targeted 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 only active, 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.

The lanes{} subtree - owner policy, not API fact

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-lane never rewrites entries it did not create).
  • Self-lane → refused: same LOCAL/REMOTE name, or two config files sharing one chainSelector (a pool must never register its own selector as a remote).
  • Placeholder pool → a lane to a remote whose registry has no tokenPool yet 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-lane writes 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.

The poolPolicy{} block - pool-scoped policy

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).

The roles{} subtree - declared authority, not API fact

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 type that contradicts the probed surface (e.g. factory declared for a token that answers defaultAdmin()) FAILs and the dependent token rungs are skipped to avoid cascade noise. byo never asserts a template; it only point-checks the universal admin points it declares.
  • Enumerable sets get a two-sided compare (lockbox/hooks authorizedCallers, hooks allowlist, safe owners, factory-token minters/burners) - this detects BOTH a revoked holder AND a rogue additive grant. Non-enumerable lists (crosschain/burnmint mint/burn/admin) point-check each declared holder (revoke always detected) and WARN that additive grants are unverified - even when complete:true, because that completeness was proven at snapshot time, not now (never a silent CLEAN). Passing SCAN_FROM_BLOCK=<n> to make roles-check opts into an event-scan two-sided compare that turns an undeclared additive grant into a hard FAIL. A scanned list records scannedFromBlock next to complete as provenance (the block the completeness was proven from).
  • Absent optional block → SKIP, never FAIL - governance{} on an EOA chain, lockbox/hooks/ rebalancer where 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. RegistryModuleOwnerCustom has 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-chain writes only .roles, so a roles refresh never disturbs an API-served, lanes{}, or hand-authored field.

Non-EVM (Solana) chain file

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:

  • chainFamily is "svm", and chainId is the placeholder "0" - Solana has no EVM chain id. The chainId identity guard cannot fire here, so the name (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 and feeTokens is empty: non-EVM chains have no EVM-shaped chainConfig, so they are excluded from the API address sync (the sync SKIPs the ccip{} transform cleanly). It stays as the EVM-typed skeleton every .ccip.* reader needs - a base58 value there makes HelperConfig'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, with nativeChainId next 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 mirror ccip{} where the concept is shared (rmn -> rmnProxy, registryModule -> registryModuleOwnerCustom), plus tokenPoolPrograms{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 no tokenAdminRegistry; Aptos has one but no registryModule and no pool programs), and an absent key says "not a thing here" where a zero would say "missing". feeTokens is not carried - link is the one fee token the repo keys on.
  • nativeChainId is where the chain's real id goes; chainId keeps its "0" sentinel. Putting the base58 genesis hash in chainId makes the chain VANISH from HelperConfig's configured-chain scan, and Aptos's real "2" silently empties its remote-pool resolution. Several HelperConfig paths 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 to solana-devnet (exempt from the doctor's reciprocity rung), but make add-lane LOCAL=solana-devnet ... is refused (no lanes to write). Its addresses{} 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 (and deploy-new-chain through them) and adopt-token TOKEN= name the family and point at the adopt-token path instead of handing forge an endpoint that speaks no EVM JSON-RPC. The read paths are unaffected: sync and detect-evm-version SKIP, roles-check SKIPs, doctor reports the unverifiable rungs, probe-chain and verify-args refuse 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 applyChainUpdates from the store. Adopt it with make adopt-token CHAIN=<solana-chain> TOKEN_B58=<base58> [POOL_B58=<base58>] (the runNonEvm path), which family-validates each value (base58 decodes to exactly 32 bytes) and writes project/<solana-chain>.json addresses{}. An EVM source then reads those remote bytes from the store through the selectorName-keyed getters when it lists Solana as a remote in applyChainUpdates, replacing the old env-only path. Two non-EVM chains that both report chainId "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_B58 must be the Solana pool's config account (the state PDA the OnRamp stamps as sourcePoolAddress), not the pool program id or the token mint; TOKEN_B58 is the mint. Once the Solana side is deployed, stock CLIs confirm both (advisory - the accounts do not exist pre-deploy, so AccountNotFound is expected then): solana account <POOL_B58> --url <cluster> --output json must show the account exists, executable:false, and owner = the CCIP pool program id (a signer PDA returns AccountNotFound; a program id shows executable: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} returns chainMetadata{explorer,nativeCurrency} for every family, so displayName, chainFamily, environment, explorerUrl, and nativeCurrencySymbol are sourced/refreshed from the API on Solana too (e.g. explorerUrl = https://explorer.solana.com?cluster=devnet, nativeCurrencySymbol = SOL). Only the EVM-shaped ccip{} 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 against getRemotePools(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's getRemotePools set matches what an inbound Solana message actually carries.
  • For an Aptos remote, it is the Aptos account address: a 32-byte value written as 0x hex (short forms with a leading run of zero bytes elided, e.g. 0x1, are valid and left-pad to the canonical 32 bytes). Set destChainFamily:"aptos" in the ApplyChainUpdates JSON entry (or DEST_CHAIN_FAMILY=aptos in 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 as sourcePoolAddress; 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.

Related

  • 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 - the roles{} operational runbook.
  • config-architecture.md - the make-command reference and the layered store diagrams.