Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 55 additions & 17 deletions Makefile

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ copy-pasteable. The `make` golden path resolves the RPC and keystore from each c
you never hand-export an RPC per chain; the raw `forge script` each target wraps is documented on the
linked operations page as the escape hatch.

A target also resolves the chain's EVM version. Most chains use the `foundry.toml` default and the flag
is a no-op, but the few CCIP networks without `PUSH0` declare `"evmVersion": "paris"` in their chain
file so their bytecode stays deployable there. The escape hatch bypasses that resolution along with the
RPC and keystore, so on such a chain pass it yourself:
`--evm-version "$(bash script/config/evm-version.sh <chain>)"`. See
[per-chain EVM version](docs/config-schema.md#per-chain-evm-version).

Run these for EACH of your two chains (`<chain>` is a selectorName such as `ethereum-testnet-sepolia`;
selectorNames may contain underscores, e.g. `binance_smart_chain-mainnet`; set
its `rpcEnv` and `KEYSTORE_NAME` in `.env`, then `source .env`).
Expand Down
5 changes: 3 additions & 2 deletions docs/config-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ flowchart TD
identity, then rewrites **every API-served field** - the `.ccip` subtree AND the API-served identity/metadata
(`displayName`, `chainFamily`, `environment`, `explorerUrl`, `nativeCurrencySymbol`) - and re-canonicalizes,
so a no-drift sync is a zero-diff no-op. The hand-authored keys (`chainNameIdentifier`, `rpcEnv`, the
optional `verifier{}` block) and the guarded join keys are left untouched.
optional `verifier{}` block, the optional `evmVersion`) and the guarded join keys are left untouched.

```mermaid
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#375BD2','primaryTextColor':'#FFFFFF','primaryBorderColor':'#1A2B6B','lineColor':'#375BD2','actorBkg':'#375BD2','actorTextColor':'#FFFFFF','actorBorder':'#1A2B6B','signalColor':'#1A2B6B','signalTextColor':'#0B1636','noteBkgColor':'#E8EDFB','noteTextColor':'#0B1636','noteBorderColor':'#375BD2','fontFamily':'Inter, system-ui, sans-serif'}}}%%
Expand Down Expand Up @@ -141,7 +141,8 @@ Project state lives in two files per chain, both keyed by the canonical **select
- **`config/chains/<selectorName>.json`** - pure API/chain facts. Everything the CCIP REST API serves
(`ccip{}` addresses + the identity/metadata fields `displayName`/`chainFamily`/`environment`/
`explorerUrl`/`nativeCurrencySymbol`) is API-owned, written by the sync. The keys the API serves nothing
for are hand-authored in reviewed PRs (`chainNameIdentifier`, `rpcEnv`, the optional `verifier{}` block), and the join keys
for are hand-authored in reviewed PRs (`chainNameIdentifier`, `rpcEnv`, the optional `verifier{}` block, the optional
`evmVersion`), and the join keys
(`name`/`chainSelector`/`chainId`) are seeded once and guard-validated.
- **`project/[<group>/]<selectorName>.json`** - the project store: three subtrees plus a top-level
`"schema": 3`, **one writer each**. `addresses{}` (deployed-address registry) is written by the deploy
Expand Down
80 changes: 78 additions & 2 deletions docs/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ tracks `project/` gets a git diff there too (see [The project store](#the-projec
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `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** |
Expand All @@ -57,8 +58,8 @@ tracks `project/` gets a git diff there too (see [The project store](#the-projec

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 hand-authored keys
(`chainNameIdentifier`, `rpcEnv`, the optional `verifier{}` block) are preserved untouched and the join keys are validated,
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`
Expand Down Expand Up @@ -108,6 +109,8 @@ Example (`config/chains/ethereum-testnet-sepolia.json`), grouped by writer:
"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)
}
```

Expand Down Expand Up @@ -139,6 +142,7 @@ API serves nothing for it, so a reviewed PR owns it and the sync preserves it ve
| `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
Expand All @@ -148,6 +152,78 @@ id, with a warned fallback to Sourcify for a chain Etherscan v2 does not serve.
so does a stray `confirmations` key (not part of the schema). See
[operations: verification](operations/verification.md).

### 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](gotchas/index.md#evm-version-push0)).

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:

```jsonc
{
"name": "some-chain-without-push0",
"evmVersion": "paris"
// ... the rest of the chain facts
}
```

**The value is measured, not maintained by hand.** `make add-chain` probes the new chain and writes the
key only when that chain rejects PUSH0; a chain that supports it gets no key and inherits the default.
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:

```bash
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` spans two chains in one process and takes the LATER of the two, which is sound because
it only simulates: an interpreter always runs bytecode built for an earlier EVM, and a chain that
rejects an opcode cannot be hosting contracts that contain it.

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` is the
exception: it resolves two chains and aborts if either declaration is unreadable, because a
simulation run against the wrong EVM would answer the question it was asked to settle.

The `lanes.<remote>` field rows (`remoteSelector`, `capacity`, `rate`, `inbound`, the `v2` blocks) live
with the subtree in the project store - see
[The `lanes{}` subtree](#the-lanes-subtree---owner-policy-not-api-fact). Pool-scoped policy values
Expand Down
16 changes: 16 additions & 0 deletions docs/gotchas/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,20 @@ link to.
holders. A timelock owning the pool does not delay-gate a migration cutover unless the registry
administrator moves under it too; keep the rate-limit admin on the Safe for fast emergency throttles.

<a id="evm-version-push0"></a>
- **A `paris` EVM cannot read a contract built by a current toolchain, and the failure blames the
contract.** `PUSH0` (`0x5f`) is a shanghai opcode that any recent solc emits, and Foundry's
`evm_version` configures the local interpreter as well as the compile target, so a paris-configured
`forge script` halts on `EvmError: NotActivated` before it signs anything (`--skip-simulation` does not
help: it skips the on-chain simulation, not the script body). Where the call is wrapped in `try/catch`
the halt is worse than a failure, because it is reported as a missing ABI member: a healthy token gets
`decimals() not found on token`. This repo therefore targets shanghai and declares the exception per
chain: the few CCIP networks that never activated PUSH0 carry `"evmVersion": "paris"` in
`config/chains/<selectorName>.json`. `make add-chain` probes this automatically, and
`make detect-evm-version CHAIN=<selectorName>` re-checks a chain already in the repo. To probe by
hand, run `cast call --rpc-url <rpc> --create 0x5f5ff3`, and trust the answer only when both controls
behave: `--create 0xfe` must error (some nodes swallow invalid opcodes) and `--create 0x60006000f3`
must return `0x` (otherwise the endpoint is not running `eth_call` at all). Check `eth_chainId`
matches the chain you meant, because public RPC directories carry chainId collisions.

_The registry grows as findings graduate from the internal vault under the publication gate._
6 changes: 5 additions & 1 deletion docs/primitives/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"note": "Generated from the script/ tree and the Makefile. Do not edit by hand; run `npm run docs:catalog`.",
"counts": {
"primitives": 60,
"makeTargets": 26
"makeTargets": 27
},
"primitives": [
{
Expand Down Expand Up @@ -992,6 +992,10 @@
"target": "add-chain",
"help": "Generate config/chains/<CHAIN>.json from the live API (CHAIN= and SELECTOR= required)"
},
{
"target": "detect-evm-version",
"help": "Re-probe CHAIN for PUSH0 support and pin evmVersion if the chain needs it"
},
{
"target": "add-lane",
"help": "Append a lanes{} policy entry LOCAL -> REMOTE (LOCAL= REMOTE= CAPACITY= RATE= required; INBOUND_CAPACITY= + INBOUND_RATE= add the inbound block; BOTH=1 adds the reciprocal; GROUP= scopes to a token group)"
Expand Down
40 changes: 38 additions & 2 deletions foundry.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
# `evm_version` is the repo-wide DEFAULT, and in Foundry it configures two things at once: the compile
# target for bytecode this repo deploys, AND the local EVM that `forge test` and every `forge script`
# simulation runs in. `shanghai` is the floor for a current toolchain - the `PUSH0` opcode (0x5f) it
# introduced is emitted by any recent solc, and a `paris` interpreter halts on it with
# `EvmError: NotActivated`, so a paris-pinned run cannot read an operator's BYO token at all.
#
# A handful of CCIP chains never activated shanghai and reject PUSH0 bytecode. They are handled per
# chain, in data, not by lowering this default: `config/chains/<selectorName>.json` carries an optional
# `"evmVersion"` key, and the make targets forward it as `--evm-version` for that chain's runs. See
# docs/config-schema.md ("Per-chain EVM version") and docs/gotchas/index.md#evm-version-push0.
#
# `ignored_error_codes` EXTENDS nothing - it REPLACES Foundry's defaults, so all six of them are
# repeated here (`forge config` prints the list on a tree that does not set the key). Naming only the
# ones this repo cares about would silently un-ignore the rest. `too-many-warnings` (solc 4591) is the
# addition: shanghai activates EIP-3860, whose ~107 extra initcode-size warnings push the total past
# solc's 256-warning print cap, and solc then emits 4591 to say it truncated. Dropping `code-size` or
# `init-code-size` would surface 64+ size warnings on test and script contracts that never leave the
# local EVM.
[profile.default]
src = "src"
out = "out"
libs = ["node_modules"]
solc_version = "0.8.24"
evm_version = "paris"
evm_version = "shanghai"
optimizer = true
optimizer_runs = 200
ignored_error_codes = [
"license",
"code-size",
"init-code-size",
"transient-storage",
"transfer-deprecated",
"natspec-memory-safe-assembly-deprecated",
"too-many-warnings",
]

remappings = [
"forge-std/=node_modules/@chainlink/contracts/src/v0.8/vendor/forge-std/src/",
Expand All @@ -29,9 +56,18 @@ src = "src"
out = "out"
libs = ["node_modules"]
solc_version = "0.8.24"
evm_version = "paris"
evm_version = "shanghai"
optimizer = true
optimizer_runs = 200
ignored_error_codes = [
"license",
"code-size",
"init-code-size",
"transient-storage",
"transfer-deprecated",
"natspec-memory-safe-assembly-deprecated",
"too-many-warnings",
]
ffi = true
remappings = [
"forge-std/=node_modules/@chainlink/contracts/src/v0.8/vendor/forge-std/src/",
Expand Down
4 changes: 2 additions & 2 deletions script/config/SyncCcipConfig.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {ProjectStore} from "../../src/utils/ProjectStore.sol";
/// API-served identity + metadata fields (`displayName`, `chainFamily`, `environment`, `explorerUrl`,
/// `nativeCurrencySymbol`), all of which `GET /v2/chains/{selector}` serves, so none is hand-typed.
/// What it PRESERVES (never touches): the genuinely hand-authored keys the API serves nothing for
/// (`chainNameIdentifier`, `rpcEnv`, and the optional `verifier{}` block) and the immutable join keys
/// (`chainNameIdentifier`, `rpcEnv`, the optional `verifier{}` block, and the optional `evmVersion`) and the immutable join keys
/// (`name`/`chainSelector`/`chainId`) which are GUARD-validated, not rewritten. One writer per field.
/// Project state (`lanes{}`, `roles{}`, deployed `addresses{}`) is NOT in this file — it lives in
/// `project/<selectorName>.json`, so the sync never touches it (config/chains is pure API/chain facts).
Expand Down Expand Up @@ -281,7 +281,7 @@ contract SyncCcipConfig is Script {
/// `GET /v2/chains/{selector}` — `displayName`/`chainFamily`/`environment` from `.chain`,
/// `explorerUrl`/`nativeCurrencySymbol` from `.chainMetadata` — so none is hand-authored. NOT in
/// this list (genuinely hand-authored, the API serves nothing for them): `chainNameIdentifier`,
/// `rpcEnv`, the optional `verifier{}` block.
/// `rpcEnv`, the optional `verifier{}` block, the optional `evmVersion`.
function metadataKeys() public pure returns (string[5] memory) {
return ["displayName", "chainFamily", "environment", "explorerUrl", "nativeCurrencySymbol"];
}
Expand Down
Loading
Loading