From a31c3daafc30ae5957c040a09f83eeac6e6284b4 Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Thu, 30 Jul 2026 17:39:04 +0100 Subject: [PATCH] feat: resolve evm_version per chain and detect PUSH0 support automatically Target shanghai by default and pin an older EVM only where a chain needs it, instead of holding the whole repo back to paris. The setting does double duty in Foundry: it is the compile target AND the version of the local EVM that forge scripts execute in. Pinned to paris, a script cannot run any contract built with a current toolchain, so operations against an operator-supplied token fail before a transaction is signed, and the failure is easy to misread as a fault in that token. Reading decimals() off a healthy BYO token is the costly case, because the value that gets substituted when the read fails decides the deployed pool's decimals. A few chains have not activated PUSH0, and bytecode compiled for shanghai is undeployable there, so the pin cannot simply be raised. config/chains/.json gains an optional evmVersion key and every chain-scoped target forwards it as --evm-version. add-chain probes the chain and records the pin only when the chain rejects PUSH0, so the value is measured rather than maintained by hand; detect-evm-version re-checks a chain already in the repo. The probe is eth_call with initcode, needing no funds or keys. The probe refuses rather than guesses, because a pin it writes is permanent and valid enough to pass review. It records one only when the node reports an invalid opcode: a timeout or a rate limit is not a rejection, and pinning on absence of success would return a chain to the compile target this mechanism exists to move it off. It asserts eth_chainId before trusting an endpoint, since public RPC directories carry chainId collisions, and it refuses when it cannot identify the endpoint at all. Missing tools and unparseable config are separated from "this chain needs nothing", so neither can exit as a silent success. The resolver reads its default from forge config so it cannot drift from foundry.toml, and validates a declared value: broadcasting targets abort on an unrecognised one or an unreadable config, while read-only paths fall back and report it, so a typo can never quietly deploy bytecode built for the wrong EVM. ignored_error_codes now lists code-size and init-code-size explicitly alongside too-many-warnings, because setting the key replaces Foundry's defaults rather than extending them, and shanghai adds enough EIP-3860 warnings to cross solc's 256-warning cap. Chains that support PUSH0 need no key and their configs are untouched. Contracts deployed after this change carry PUSH0, so their bytecode differs from before. --- Makefile | 72 +++++++--- README.md | 7 + docs/config-architecture.md | 5 +- docs/config-schema.md | 80 ++++++++++- docs/gotchas/index.md | 16 +++ docs/primitives/catalog.json | 6 +- foundry.toml | 40 +++++- script/config/SyncCcipConfig.s.sol | 4 +- script/config/VerifyChain.s.sol | 67 ++++++++- script/config/detect-evm-version.sh | 152 ++++++++++++++++++++ script/config/evm-version.sh | 94 +++++++++++++ script/config/roles-check.sh | 3 +- script/config/test-tooling.sh | 178 +++++++++++++++++++++++- script/config/verify-contract.sh | 18 ++- test/config/VerifyChainSchemaRung.t.sol | 44 +++++- 15 files changed, 746 insertions(+), 40 deletions(-) create mode 100755 script/config/detect-evm-version.sh create mode 100755 script/config/evm-version.sh diff --git a/Makefile b/Makefile index cd971a7..9d75ca2 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,25 @@ CONFIG_DIR := config/chains KNOWN_CHAINS := $(filter-out zz-scratch-%,$(basename $(notdir $(wildcard $(CONFIG_DIR)/*.json)))) SYNC_SCRIPT := script/config/SyncCcipConfig.s.sol +# Per-chain EVM version. `foundry.toml` pins the repo default (see the comment there); a chain whose +# network never activated an opcode the default emits declares an optional `"evmVersion"` in its +# config/chains/.json, and every forge run scoped to that chain forwards it as --evm-version. +# It matters most on the deploy path, where it is the compile target for bytecode that has to run on +# that chain; on the read paths it is the local interpreter the simulation executes in. +# +# One resolver for every caller: `script/config/evm-version.sh ` (the shell tooling calls it +# directly). It falls back to `forge config`'s own value, so a chain that declares nothing is passed +# the version forge would have chosen anyway - a no-op flag, never a second default to keep in sync. +# +# `$(call evm-version,)` is deliberately recursive (`=`, not `:=`): it must expand when a recipe +# runs and CHAIN is known, not when the Makefile is parsed. +# Read/diagnose targets resolve LENIENTLY: an unrecognized declaration degrades to the repo default +# with a stderr diagnostic, so `make doctor` still runs and FAILs the schema rung by name instead of +# dying on a forge CLI error about a flag the operator never typed. The shell macros below that +# BROADCAST use the strict form (no --lenient) and abort. +evm-version = $(shell bash script/config/evm-version.sh "$(1)" --lenient) +evm-version-flag = --evm-version $(call evm-version,$(1)) + # Optional token group. GROUP= selects one of N token groups # (project//.json); unset is the flat default (project/.json). It # threads to the scripts as PROJECT_GROUP; GROUP_DIR locates the same file for the jq repair steps here. @@ -88,8 +107,13 @@ discover: tools ## List the CCIP API testnet catalog vs local configs (FILTER=.json from the live API (CHAIN= and SELECTOR= required) $(if $(CHAIN),,$(error CHAIN is required: make add-chain CHAIN= SELECTOR= - both from the make discover API NAME + SELECTOR columns)) $(if $(SELECTOR),,$(error SELECTOR is required - find it with: make discover FILTER=)) - FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --sig "init(string,uint256)" "$(CHAIN)" "$(SELECTOR)" + FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(CHAIN)) --sig "init(string,uint256)" "$(CHAIN)" "$(SELECTOR)" $(canon-chain-config) + @bash script/config/detect-evm-version.sh "$(CHAIN)" || true + +detect-evm-version: tools ## Re-probe CHAIN for PUSH0 support and pin evmVersion if the chain needs it + $(if $(CHAIN),,$(error CHAIN is required: make detect-evm-version CHAIN=)) + @bash script/config/detect-evm-version.sh "$(CHAIN)" || true add-lane: tools ## 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) $(if $(LOCAL),,$(error LOCAL is required: make add-lane LOCAL= REMOTE= CAPACITY= RATE= [INBOUND_CAPACITY= INBOUND_RATE=] [BOTH=1])) @@ -109,15 +133,15 @@ endif exit 1; }; \ done ifdef INBOUND_CAPACITY - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) --sig "addLane(string,string,uint256,uint256,uint256,uint256)" "$(LOCAL)" "$(REMOTE)" "$(CAPACITY)" "$(RATE)" "$(INBOUND_CAPACITY)" "$(INBOUND_RATE)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(LOCAL)) --sig "addLane(string,string,uint256,uint256,uint256,uint256)" "$(LOCAL)" "$(REMOTE)" "$(CAPACITY)" "$(RATE)" "$(INBOUND_CAPACITY)" "$(INBOUND_RATE)" else - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) --sig "addLane(string,string,uint256,uint256)" "$(LOCAL)" "$(REMOTE)" "$(CAPACITY)" "$(RATE)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(LOCAL)) --sig "addLane(string,string,uint256,uint256)" "$(LOCAL)" "$(REMOTE)" "$(CAPACITY)" "$(RATE)" endif ifdef BOTH ifdef INBOUND_CAPACITY - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) --sig "addLane(string,string,uint256,uint256,uint256,uint256)" "$(REMOTE)" "$(LOCAL)" "$(CAPACITY)" "$(RATE)" "$(INBOUND_CAPACITY)" "$(INBOUND_RATE)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(REMOTE)) --sig "addLane(string,string,uint256,uint256,uint256,uint256)" "$(REMOTE)" "$(LOCAL)" "$(CAPACITY)" "$(RATE)" "$(INBOUND_CAPACITY)" "$(INBOUND_RATE)" else - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) --sig "addLane(string,string,uint256,uint256)" "$(REMOTE)" "$(LOCAL)" "$(CAPACITY)" "$(RATE)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(REMOTE)) --sig "addLane(string,string,uint256,uint256)" "$(REMOTE)" "$(LOCAL)" "$(CAPACITY)" "$(RATE)" endif endif @for c in "$(LOCAL)" "$(REMOTE)"; do \ @@ -128,9 +152,9 @@ endif remove-lane: tools ## Remove a lanes{} policy entry LOCAL -> REMOTE from the declaration (LOCAL= REMOTE= required; BOTH=1 removes the reciprocal; GROUP= scopes to a token group; on-chain removal via RemoveChain, or RemoveRemotePool for a single pool, is a separate step) $(if $(LOCAL),,$(error LOCAL is required: make remove-lane LOCAL= REMOTE= [BOTH=1])) $(if $(REMOTE),,$(error REMOTE is required: make remove-lane LOCAL= REMOTE= [BOTH=1])) - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) --sig "removeLane(string,string)" "$(LOCAL)" "$(REMOTE)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(LOCAL)) --sig "removeLane(string,string)" "$(LOCAL)" "$(REMOTE)" ifdef BOTH - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) --sig "removeLane(string,string)" "$(REMOTE)" "$(LOCAL)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(REMOTE)) --sig "removeLane(string,string)" "$(REMOTE)" "$(LOCAL)" endif @for c in "$(LOCAL)" "$(REMOTE)"; do \ test -f "$(CONFIG_DIR)/$$c.json" || continue; \ @@ -142,29 +166,30 @@ adopt-token: tools ## Adopt an externally deployed token into project/[/] $(if $(CHAIN),,$(error CHAIN is required: make adopt-token CHAIN= TOKEN= [TOKEN_POOL=], or non-EVM: TOKEN_B58= [POOL_B58=])) $(require-chain-config) ifdef TOKEN_B58 - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/AdoptToken.s.sol --sig "runNonEvm(string,string,string)" "$(CHAIN)" "$(TOKEN_B58)" "$(POOL_B58)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/AdoptToken.s.sol $(call evm-version-flag,$(CHAIN)) --sig "runNonEvm(string,string,string)" "$(CHAIN)" "$(TOKEN_B58)" "$(POOL_B58)" else $(if $(TOKEN),,$(error TOKEN is required - the externally deployed token address to adopt (or TOKEN_B58 for a non-EVM chain))) - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/AdoptToken.s.sol --sig "run(string,address,address)" "$(CHAIN)" "$(TOKEN)" "$(or $(TOKEN_POOL),0x0000000000000000000000000000000000000000)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/AdoptToken.s.sol $(call evm-version-flag,$(CHAIN)) --sig "run(string,address,address)" "$(CHAIN)" "$(TOKEN)" "$(or $(TOKEN_POOL),0x0000000000000000000000000000000000000000)" endif sync: tools ## Refresh 's ccip{} block from the live API (CHAIN= required) $(if $(CHAIN),,$(error CHAIN is required: make sync CHAIN=)) $(require-chain-config) - FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --sig "run(string)" "$(CHAIN)" + FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(CHAIN)) --sig "run(string)" "$(CHAIN)" $(canon-chain-config) sync-preview: tools ## Fetch + log 's ccip{} from the API without writing (CHAIN= required) $(if $(CHAIN),,$(error CHAIN is required: make sync-preview CHAIN=)) $(require-chain-config) - FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --sig "preview(string)" "$(CHAIN)" + FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) $(call evm-version-flag,$(CHAIN)) --sig "preview(string)" "$(CHAIN)" sync-all: tools ## Refresh every configured chain (non-EVM chains SKIP; failures are collected) @failed=""; for f in $(CONFIG_DIR)/*.json; do \ name="$$(basename "$$f" .json)"; \ case "$$name" in zz-scratch-*) continue ;; esac; \ echo ">> sync $$name"; \ - FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --sig "run(string)" "$$name" || failed="$$failed $$name"; \ + evm="$$(bash script/config/evm-version.sh "$$name" --lenient)"; \ + FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --evm-version "$$evm" --sig "run(string)" "$$name" || failed="$$failed $$name"; \ tmp="$$(mktemp)" && jq --indent 2 -S . "$$f" > "$$tmp" && mv "$$tmp" "$$f"; \ done; \ if [ -n "$$failed" ]; then echo "sync-all: FAILED for:$$failed"; exit 1; fi; \ @@ -208,8 +233,13 @@ verify: tools ## Source-verify an already-deployed contract on 's explore doctor: tools ## Layered verification of one chain's config (CHAIN= required; GROUP= scopes to one token group) $(if $(CHAIN),,$(error CHAIN is required: make doctor CHAIN=)) $(require-chain-config) - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig "run(string)" "$(CHAIN)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/VerifyChain.s.sol $(call evm-version-flag,$(CHAIN)) --tc VerifyChain --sig "run(string)" "$(CHAIN)" +# The one target that spans TWO chains in one forge process, so it cannot use "the chain's" evm +# version - it picks the LATER of the two. That is sound because preflight only simulates: an +# interpreter always executes bytecode built for an earlier EVM, and a chain that rejects the later +# version's opcodes cannot be hosting contracts that contain them in the first place. An unrecognized +# value (a version newer than this list) ranks highest, so an explicit declaration always wins. preflight: tools ## Preflight a token transfer before sending: simulate source lockOrBurn + dest releaseOrMint against live state, GO/NO-GO (SOURCE_CHAIN= DEST_CHAIN= AMOUNT= RECEIVER= required; opt SOURCE_POOL= DEST_POOL= ORIGINAL_SENDER= REQUESTED_FINALITY=; read-only, no keystore) $(if $(SOURCE_CHAIN),,$(error SOURCE_CHAIN is required: make preflight SOURCE_CHAIN= DEST_CHAIN= AMOUNT= RECEIVER=)) $(if $(DEST_CHAIN),,$(error DEST_CHAIN is required: make preflight SOURCE_CHAIN= DEST_CHAIN= AMOUNT= RECEIVER=)) @@ -219,11 +249,14 @@ preflight: tools ## Preflight a token transfer before sending: simulate source l test -f "$(CONFIG_DIR)/$(DEST_CHAIN).json" || { echo "unknown DEST_CHAIN '$(DEST_CHAIN)' - known chains: $(KNOWN_CHAINS)"; exit 1; }; \ src_rpc_env="$$(jq -r '.rpcEnv // empty' "$(CONFIG_DIR)/$(SOURCE_CHAIN).json")"; \ dst_rpc_env="$$(jq -r '.rpcEnv // empty' "$(CONFIG_DIR)/$(DEST_CHAIN).json")"; \ + src_evm="$$(bash script/config/evm-version.sh "$(SOURCE_CHAIN)")" || exit 1; \ + dst_evm="$$(bash script/config/evm-version.sh "$(DEST_CHAIN)")" || exit 1; \ src_rpc="$$(printenv "$$src_rpc_env" || true)"; dst_rpc="$$(printenv "$$dst_rpc_env" || true)"; \ test -n "$$src_rpc" || { echo "source RPC not set - export $$src_rpc_env= (the rpcEnv field in $(CONFIG_DIR)/$(SOURCE_CHAIN).json)"; exit 1; }; \ test -n "$$dst_rpc" || { echo "dest RPC not set - export $$dst_rpc_env= (the rpcEnv field in $(CONFIG_DIR)/$(DEST_CHAIN).json)"; exit 1; }; \ + evm_version="$$(printf '%s\n%s\n' "$$src_evm" "$$dst_evm" | awk '{r["london"]=1;r["paris"]=2;r["shanghai"]=3;r["cancun"]=4;r["prague"]=5;r["osaka"]=6} {v=r[$$0]; if (v==0) v=99; if (v>best) {best=v; pick=$$0}} END {print pick}')"; \ SOURCE_CHAIN="$(SOURCE_CHAIN)" DEST_CHAIN="$(DEST_CHAIN)" SOURCE_RPC_URL="$$src_rpc" DEST_RPC_URL="$$dst_rpc" \ - forge script script/diagnostics/PreflightTransfer.s.sol --tc PreflightTransfer + forge script script/diagnostics/PreflightTransfer.s.sol --evm-version "$$evm_version" --tc PreflightTransfer # No `tools:` prereq (unlike the sibling targets): this needs ccip-cli + jq, not the forge/curl that # `tools` checks, and the script self-checks its own dependencies. @@ -242,17 +275,22 @@ verify-execution: ## Check whether a sent message executed on its destination (M # $(call run-deploy,): resolve the chain's RPC + keystore, then broadcast. VERIFY=1 appends # --verify plus the config-driven verifier flags (script/config/verify-args.sh) so deploy and explorer # verification are one step; ETHERSCAN_API_KEY is read from the environment and never echoed. +# The evmVersion declaration is resolved BEFORE the RPC and keystore are required. A broken value in +# config/chains is wrong wherever it is read, so reporting it does not depend on having an endpoint or a +# signer to hand, and an operator fixing their config should not have to satisfy unrelated prerequisites +# first to see the message. It also keeps this reachable where no RPC is configured, such as CI. define run-deploy @case "$(CHAIN)" in ""|*[!a-z0-9-]*) echo "invalid CHAIN '$(CHAIN)' - use lowercase letters, digits, and hyphens only"; exit 1;; esac; \ rpc_env="$$(jq -r '.rpcEnv // empty' "$(CONFIG_DIR)/$(CHAIN).json")"; \ test -n "$$rpc_env" || { echo "chain '$(CHAIN)' declares no rpcEnv - run: make sync CHAIN=$(CHAIN)"; exit 1; }; \ + evm_version="$$(bash script/config/evm-version.sh "$(CHAIN)")" || exit 1; \ rpc_url="$$(printenv "$$rpc_env" || true)"; \ test -n "$$rpc_url" || { echo "RPC URL not set - export $$rpc_env= (the rpcEnv field named in $(CONFIG_DIR)/$(CHAIN).json)"; exit 1; }; \ test -n "$(KEYSTORE_NAME)" || { echo "KEYSTORE_NAME is required - export KEYSTORE_NAME= (create one with: cast wallet import)"; exit 1; }; \ verify=""; \ if [ -n "$(VERIFY)" ]; then verify="--verify $$(bash script/config/verify-args.sh "$(CHAIN)")" || { echo "could not compose verifier flags for $(CHAIN)"; exit 1; }; fi; \ - echo ">> deploy $(1) on $(CHAIN) (rpc: $$rpc_env, account: $(KEYSTORE_NAME))"; \ - PROJECT_GROUP="$(GROUP)" forge script $(1) --rpc-url "$$rpc_url" --account "$(KEYSTORE_NAME)" --broadcast $$verify + echo ">> deploy $(1) on $(CHAIN) (rpc: $$rpc_env, account: $(KEYSTORE_NAME), evm: $$evm_version)"; \ + PROJECT_GROUP="$(GROUP)" forge script $(1) --rpc-url "$$rpc_url" --evm-version "$$evm_version" --account "$(KEYSTORE_NAME)" --broadcast $$verify endef deploy-token: tools ## Deploy a cross-chain token on (CHAIN= + KEYSTORE_NAME= required; token params via env TOKEN_NAME= TOKEN_SYMBOL= ...; VERIFY=1 source-verifies; FORCE_REDEPLOY=1 overrides the redeploy guard; GROUP= scopes to a token group) @@ -292,7 +330,7 @@ deploy-new-chain: tools ## Guided deploy: add-chain -> deploy-token -> deploy-po snapshot-chain: tools ## Backfill the declared roles{} authority block FROM chain (CHAIN= required; GROUP= scopes to one token group; opt: TOKEN= TOKEN_POOL= TAR= SCAN_FROM_BLOCK=) $(if $(CHAIN),,$(error CHAIN is required: make snapshot-chain CHAIN=)) $(require-chain-config) - FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/SnapshotChain.s.sol --sig "run(string)" "$(CHAIN)" + FOUNDRY_PROFILE=sync PROJECT_GROUP="$(GROUP)" forge script script/config/SnapshotChain.s.sol $(call evm-version-flag,$(CHAIN)) --sig "run(string)" "$(CHAIN)" $(canon-project) @echo "review the roles{} diff in project/$(GROUP_DIR)$(CHAIN).json (roles{} = declared authority), then reconcile: make roles-check CHAIN=$(CHAIN)$(if $(GROUP), GROUP=$(GROUP),)" diff --git a/README.md b/README.md index 87e59b6..ce65bb8 100644 --- a/README.md +++ b/README.md @@ -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 )"`. See +[per-chain EVM version](docs/config-schema.md#per-chain-evm-version). + Run these for EACH of your two chains (`` 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`). diff --git a/docs/config-architecture.md b/docs/config-architecture.md index 3d9535a..ab5f989 100644 --- a/docs/config-architecture.md +++ b/docs/config-architecture.md @@ -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'}}}%% @@ -141,7 +141,8 @@ Project state lives in two files per chain, both keyed by the canonical **select - **`config/chains/.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/[/].json`** - the project store: three subtrees plus a top-level `"schema": 3`, **one writer each**. `addresses{}` (deployed-address registry) is written by the deploy diff --git a/docs/config-schema.md b/docs/config-schema.md index 6ef56e5..be8afb9 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -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** | @@ -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, ".")`. 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, ".")`. 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` @@ -108,6 +109,8 @@ Example (`config/chains/ethereum-testnet-sepolia.json`), grouped by writer: "chainNameIdentifier": "ETHEREUM_SEPOLIA", // UPPER_SNAKE env-var prefix: _RPC_URL, _TOKEN, _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) } ``` @@ -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 @@ -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= +``` + +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 ` 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 )"` 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.` 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 diff --git a/docs/gotchas/index.md b/docs/gotchas/index.md index c21b6f8..0197623 100644 --- a/docs/gotchas/index.md +++ b/docs/gotchas/index.md @@ -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 `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/.json`. `make add-chain` probes this automatically, and + `make detect-evm-version CHAIN=` re-checks a chain already in the repo. To probe by + hand, run `cast call --rpc-url --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._ diff --git a/docs/primitives/catalog.json b/docs/primitives/catalog.json index 41336d8..e7eccaf 100644 --- a/docs/primitives/catalog.json +++ b/docs/primitives/catalog.json @@ -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": [ { @@ -992,6 +992,10 @@ "target": "add-chain", "help": "Generate config/chains/.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)" diff --git a/foundry.toml b/foundry.toml index ae1439f..a940fe1 100644 --- a/foundry.toml +++ b/foundry.toml @@ -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/.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/", @@ -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/", diff --git a/script/config/SyncCcipConfig.s.sol b/script/config/SyncCcipConfig.s.sol index e90b3ff..3cae6c4 100644 --- a/script/config/SyncCcipConfig.s.sol +++ b/script/config/SyncCcipConfig.s.sol @@ -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/.json`, so the sync never touches it (config/chains is pure API/chain facts). @@ -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"]; } diff --git a/script/config/VerifyChain.s.sol b/script/config/VerifyChain.s.sol index 5d2f9b7..2886d5c 100644 --- a/script/config/VerifyChain.s.sol +++ b/script/config/VerifyChain.s.sol @@ -493,6 +493,65 @@ contract VerifyChain is Script { ); } _checkVerifierBlock(name, json); + _checkEvmVersion(name, json); + } + + /// @dev The optional hand-authored `evmVersion` key: the EVM version every forge run scoped to + /// this chain compiles and simulates at (`script/config/evm-version.sh`, forwarded by the make + /// targets as `--evm-version`). Absent is the normal case and means the repo default from + /// `foundry.toml`. It is declared only for a chain whose network never activated the default's + /// opcodes - the `PUSH0` chains - where bytecode built at the default would be undeployable. + /// + /// A typo here is silent otherwise: the resolver substitutes the default for an unreadable value, + /// and the operator would be told nothing until a deploy reverts on chain. So an unknown value is + /// a FAIL that names the accepted set, and a value read through the probe so a non-string node is + /// a named FAIL rather than a raw cheatcode revert that aborts the whole doctor. + function _checkEvmVersion(string memory name, string memory json) private { + bool isEvmChain = vm.keyExistsJson(json, ".chainFamily") + && keccak256(bytes(vm.parseJsonString(json, ".chainFamily"))) == keccak256(bytes("evm")); + + // Absent is the correct and overwhelmingly common state: nearly every chain runs PUSH0, so it + // needs no pin. It cannot be distinguished here from a chain nothing ever probed, and warning on + // every healthy chain to catch that minority would train operators to ignore the rung. The probe + // runs at `add-chain` instead, and says so on the spot when it cannot reach the RPC. + if (!vm.keyExistsJson(json, ".evmVersion")) return; + + // The key selects a compile target for EVM bytecode, so on any other family it is inert. Passing + // it told the operator a setting was in force that nothing reads. + if (!isEvmChain) { + _fail( + string.concat( + "schema: evmVersion in config/chains/", + name, + ".json has no effect on a non-EVM chain - remove the key" + ) + ); + return; + } + (bool ok, string memory version) = _optionalString(json, ".evmVersion"); + if (!ok) { + _fail( + string.concat("schema: evmVersion in config/chains/", name, ".json must be a string (e.g. \"paris\")") + ); + return; + } + bytes32 v = keccak256(bytes(version)); + bool known = v == keccak256(bytes("london")) || v == keccak256(bytes("paris")) + || v == keccak256(bytes("shanghai")) || v == keccak256(bytes("cancun")) || v == keccak256(bytes("prague")) + || v == keccak256(bytes("osaka")); + if (!known) { + _fail( + string.concat( + "schema: evmVersion '", + version, + "' in config/chains/", + name, + ".json is not one of london/paris/shanghai/cancun/prague/osaka" + ) + ); + return; + } + _pass(string.concat("schema: evmVersion '", version, "' is a known EVM version")); } /// @dev The optional hand-authored `verifier{type,url}` block: `type` must be one of @@ -505,7 +564,7 @@ contract VerifyChain is Script { /// a valid url (`vm.parseJsonString` would otherwise coerce `123` to `"123"`). function _checkVerifierBlock(string memory name, string memory json) private { if (!vm.keyExistsJson(json, ".verifier")) return; - (bool typeOk, string memory vtype) = _verifierString(json, ".verifier.type"); + (bool typeOk, string memory vtype) = _optionalString(json, ".verifier.type"); if (!typeOk) { _fail( string.concat( @@ -532,7 +591,7 @@ contract VerifyChain is Script { return; } if (t == keccak256(bytes("blockscout"))) { - (bool urlOk, string memory url) = _verifierString(json, ".verifier.url"); + (bool urlOk, string memory url) = _optionalString(json, ".verifier.url"); if (!urlOk || !_hasPrefix(url, "http")) { _fail( string.concat( @@ -547,11 +606,11 @@ contract VerifyChain is Script { _pass(string.concat("schema: verifier{} is valid (", vtype, ")")); } - /// @dev Reads a verifier{} string key through the probe: returns `(false, "")` when the key is + /// @dev Reads an optional string key through the probe: returns `(false, "")` when the key is /// absent OR its value is not a JSON string the parse can read (an array/object reverts, and the /// probe's external call makes that revert catchable). A scalar number/bool coerces to its text /// here, so callers that need a real string (the blockscout url) additionally shape-check it. - function _verifierString(string memory json, string memory path) + function _optionalString(string memory json, string memory path) private view returns (bool ok, string memory value) diff --git a/script/config/detect-evm-version.sh b/script/config/detect-evm-version.sh new file mode 100755 index 0000000..c528fca --- /dev/null +++ b/script/config/detect-evm-version.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# detect-evm-version.sh +# +# Decide whether a chain needs an `evmVersion` pin and record it in config/chains/.json. +# Run by `make add-chain` so a new chain arrives with the right answer, and available on its own +# (`make detect-evm-version CHAIN=`) to re-check a chain already in the repo. +# +# What it is protecting against: the repo compiles for shanghai, whose PUSH0 opcode a few CCIP chains +# never activated. Bytecode built for shanghai is undeployable on those chains, and the failure lands +# at deploy time on a chain the operator probably cannot test cheaply. Detecting it from the chain +# itself is the difference between a fact anyone can observe and a list somebody has to remember. +# +# The probe is `eth_call --create`: it runs initcode in the node's own EVM, so it needs no funds, no +# keys and no gas, and it answers about the real network rather than about the repo's configuration. +# +# It writes ONLY the pin (`"evmVersion": "paris"`). A chain that supports PUSH0 gets no key, so the +# common case leaves the config exactly as the API generated it and inherits the repo default. +# +# Exit-code contract: +# 0 - measured. Either the pin was written, or PUSH0 is supported and nothing was needed. +# 4 - NOT measured (no RPC, unreachable, or the controls misbehaved). Nothing is written and the +# reason goes to stderr. This is deliberately not a failure: it must not block adding a chain, +# because refusing would be worse than inheriting the default. `make doctor` reports the gap. +set -uo pipefail +cd "$(dirname "$0")/../.." + +name="${1:-}" +[ -n "$name" ] || { + echo "[detect-evm-version] usage: detect-evm-version.sh " >&2 + exit 4 +} + +file="config/chains/${name}.json" +[ -f "$file" ] || { + echo "[detect-evm-version] no config/chains/${name}.json - nothing to annotate" >&2 + exit 4 +} + +# The tools this needs, checked by name. Without this, a missing jq makes every read below return the +# empty string, which reads as "not an EVM chain" and exits 0 - a silent success that measured nothing. +for tool in jq cast; do + command -v "$tool" > /dev/null 2>&1 || { + echo "[detect-evm-version] ${name}: '${tool}' is not installed, so PUSH0 support was not" \ + "measured and no pin was written. Install it, then: make detect-evm-version CHAIN=${name}" >&2 + exit 4 + } +done + +# Malformed JSON must not read as an absent key. `jq -r '.x // empty'` cannot tell the two apart, and +# treating "the file did not parse" as "the chain is fine" is how a wrong answer gets blessed. +jq -e . "$file" > /dev/null 2>&1 || { + echo "[detect-evm-version] ${name}: ${file} is not valid JSON, so nothing about this chain could be" \ + "read and no pin was written. Fix the file, then: make detect-evm-version CHAIN=${name}" >&2 + exit 4 +} + +# Non-EVM families have no EVM opcodes to probe, so the question does not apply to them. +family="$(jq -r '.chainFamily // "evm"' "$file")" +if [ "$family" != "evm" ]; then + exit 0 +fi + +rpc_env="$(jq -r '.rpcEnv // empty' "$file")" +if [ -z "$rpc_env" ]; then + echo "[detect-evm-version] ${name}: ${file} declares no rpcEnv, so there is no endpoint to probe and" \ + "no pin was written. Add the key (make sync CHAIN=${name}), then:" \ + "make detect-evm-version CHAIN=${name}" >&2 + exit 4 +fi +rpc="${!rpc_env:-}" +if [ -z "$rpc" ]; then + echo "[detect-evm-version] ${name}: \$${rpc_env:-} is not set, so PUSH0 support was not measured." \ + "The chain inherits the repo default. If this chain does not support PUSH0, set" \ + "\"evmVersion\": \"paris\" in ${file} by hand, or re-run with the RPC set:" \ + "make detect-evm-version CHAIN=${name}" >&2 + exit 4 +fi + +probe() { cast call --rpc-url "$rpc" --create "$1" 2> /dev/null; } + +# Controls first. Without both behaving, a result means nothing: some nodes answer 0x for an invalid +# opcode, which would read as "PUSH0 supported" on a chain that rejects it. +if probe 0xfe > /dev/null 2>&1; then + echo "[detect-evm-version] ${name}: the node accepted an INVALID opcode, so it cannot be probed" \ + "this way. PUSH0 support was NOT determined and no pin was written." >&2 + exit 4 +fi +if [ "$(probe 0x60006000f3)" != "0x" ]; then + echo "[detect-evm-version] ${name}: the paris-valid control did not run (endpoint unreachable or" \ + "rejecting eth_call). PUSH0 support was NOT determined and no pin was written." >&2 + exit 4 +fi + +# Guard against answering about the wrong network: public RPC directories carry chainId collisions, +# and a mismatched endpoint would otherwise pin this chain from another chain's opcode set. The guard +# refuses when it cannot run, rather than waving the probe through on an unidentified endpoint. +declared_id="$(jq -r '.chainId // empty' "$file")" +live_id="$(cast chain-id --rpc-url "$rpc" 2> /dev/null)" +if [ -z "$declared_id" ] || [ -z "$live_id" ]; then + echo "[detect-evm-version] ${name}: could not confirm which chain \$${rpc_env} answers as" \ + "(declared '${declared_id:-}', live '${live_id:-}'). Refusing to pin from an" \ + "unidentified endpoint. No pin was written." >&2 + exit 4 +fi +if [ "$declared_id" != "$live_id" ]; then + echo "[detect-evm-version] ${name}: \$${rpc_env} answers as chain ${live_id} but this config declares" \ + "${declared_id}. Refusing to pin from another chain's EVM. Fix the RPC, then:" \ + "make detect-evm-version CHAIN=${name}" >&2 + exit 4 +fi + +# PUSH0 PUSH0 RETURN: returns 0x where the opcode exists. +push0_out="$(cast call --rpc-url "$rpc" --create 0x5f5ff3 2>&1)" +if [ "$push0_out" = "0x" ]; then + if [ "$(jq -r '.evmVersion // empty' "$file")" = "paris" ]; then + echo "[detect-evm-version] ${name}: PUSH0 IS supported, but this config pins paris. Leaving the" \ + "pin in place - removing it would change the bytecode every future deploy produces. Delete" \ + "the evmVersion key by hand if the pin is genuinely obsolete." >&2 + fi + exit 0 +fi + +# Anything else is NOT yet a rejection. A timeout, a rate limit or a malformed response also fail to +# return 0x, and pinning on absence-of-success writes a permanent paris pin from a transient blip - +# which silently returns the chain to the compile target this whole mechanism exists to move off. +# So require the node to SAY it rejected the opcode. +case "$push0_out" in + *"invalid opcode"* | *"InvalidFEOpcode"* | *"not activated"* | *"NotActivated"*) ;; + *) + echo "[detect-evm-version] ${name}: the PUSH0 probe neither succeeded nor reported an invalid" \ + "opcode, so support was NOT determined and no pin was written. The node said:" \ + "${push0_out}" >&2 + exit 4 + ;; +esac + +# Re-run the control after a rejection verdict. If the endpoint has meanwhile stopped serving eth_call, +# the rejection above says nothing about the opcode, and pinning on it would record a guess. +if [ "$(probe 0x60006000f3)" != "0x" ]; then + echo "[detect-evm-version] ${name}: the PUSH0 probe reported an invalid opcode, but the paris-valid" \ + "control then stopped working, so the endpoint - not the opcode - is the likely cause. No pin" \ + "was written. Re-run when the endpoint is healthy: make detect-evm-version CHAIN=${name}" >&2 + exit 4 +fi + +# Same canonical form the Makefile's canon-chain-config applies to these files (sorted keys, 2-space +# indent, trailing newline kept), so annotating a chain does not show up as a formatting diff. +tmp="$(mktemp)" +jq --indent 2 -S '.evmVersion = "paris"' "$file" > "$tmp" && mv "$tmp" "$file" +echo "[detect-evm-version] ${name}: PUSH0 is REJECTED by this chain, so \"evmVersion\": \"paris\" was" \ + "recorded. Runs scoped to it will compile down to what the network can execute." >&2 +exit 0 diff --git a/script/config/evm-version.sh b/script/config/evm-version.sh new file mode 100755 index 0000000..fc32eea --- /dev/null +++ b/script/config/evm-version.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# evm-version.sh [--lenient] +# +# Print the EVM version a forge run scoped to must use, so every caller (the Makefile +# recipes, roles-check, verify-contract) resolves it the same way from the same data. +# +# Resolution: the chain's optional `evmVersion` key in config/chains/.json, else the +# repo default. The default is READ FROM `forge config` rather than hardcoded, so it can never drift +# from `foundry.toml` - a run with no per-chain declaration then passes `--evm-version `, which is a no-op by construction. +# +# Why the key exists at all: `foundry.toml` targets shanghai, whose PUSH0 opcode a few CCIP chains +# never activated. Bytecode compiled for shanghai is undeployable there, so those chains declare +# `"evmVersion": "paris"` and their runs compile down to what their network can execute. On a +# read-only path the same flag configures the local interpreter instead; that direction is harmless, +# because a chain that rejects PUSH0 cannot be hosting a contract that contains one. +# +# A DECLARED value must be one forge accepts, and this is where that is caught. Left unchecked, a typo +# reaches forge as `--evm-version parris` and every target dies on a CLI error about a flag the +# operator never typed, including `make doctor` - the one command whose job is to name schema problems. +# +# Exit-code contract: +# 0 - resolved (no key, unreadable file, unknown chain: the default; a valid key: its value) +# 3 - the key holds a value this repo does not recognize (diagnostic on stderr, nothing on stdout) +# `--lenient` turns the 3 into the default plus the same stderr diagnostic, for callers that must keep +# running so they can report the problem themselves. Read and diagnose paths pass it (`make doctor` +# then FAILs the schema rung by name, with the fix); paths that BROADCAST do not, so a typo can never +# quietly deploy default-version bytecode to a chain that pins another version. +set -uo pipefail +cd "$(dirname "$0")/../.." + +name="${1:-}" +lenient="" +[ "${2:-}" = "--lenient" ] && lenient="1" + +# The repo default, from forge itself (honors FOUNDRY_PROFILE). The literal is the last resort for an +# environment where `forge config` cannot run at all; it matches foundry.toml's [profile.default]. +default="$(forge config --json 2> /dev/null | jq -r '.evm_version // empty' 2> /dev/null)" +[ -n "$default" ] || default="shanghai" + +declared="" +file="config/chains/${name}.json" +if [ -n "$name" ] && [ -f "$file" ]; then + # A file that does not parse is not a file with no declaration. Reading it as the latter would hand + # the default to a broadcast that may have been pinned to another version, which is the one outcome + # this resolver exists to prevent, so it is refused on the same terms as a bad value. + if ! jq -e . "$file" > /dev/null 2>&1; then + echo "[evm-version] ${file} is not valid JSON, so no evmVersion could be read from it." \ + "Fix the file, then: make doctor CHAIN=${name}" >&2 + if [ -n "$lenient" ]; then + printf '%s\n' "$default" + exit 0 + fi + exit 3 + fi + # `// empty` collapses absent, null and "" into the same answer, and only the first of those means + # "this chain inherits the default". The other two are a broken declaration, which the schema rung + # FAILs - so treating them as absent here would have the two disagree about the same file. + if jq -e 'has("evmVersion")' "$file" > /dev/null 2>&1; then + declared="$(jq -r '.evmVersion // ""' "$file")" + if [ -z "$declared" ]; then + echo "[evm-version] evmVersion in ${file} is empty or null. Give it a version" \ + "(london/paris/shanghai/cancun/prague/osaka) or remove the key to inherit the default," \ + "then: make doctor CHAIN=${name}" >&2 + if [ -n "$lenient" ]; then + printf '%s\n' "$default" + exit 0 + fi + exit 3 + fi + fi +fi + +if [ -z "$declared" ]; then + printf '%s\n' "$default" + exit 0 +fi + +# The accepted set is the same one VerifyChain's schema rung names, so the two cannot disagree about +# what a valid declaration is. +case "$declared" in + london | paris | shanghai | cancun | prague | osaka) ;; + *) + echo "[evm-version] '$declared' in config/chains/${name}.json is not an evmVersion this repo" \ + "recognizes (london/paris/shanghai/cancun/prague/osaka). Fix the key, then: make doctor CHAIN=${name}" >&2 + if [ -n "$lenient" ]; then + printf '%s\n' "$default" + exit 0 + fi + exit 3 + ;; +esac + +printf '%s\n' "$declared" diff --git a/script/config/roles-check.sh b/script/config/roles-check.sh index 036ba48..8e925aa 100755 --- a/script/config/roles-check.sh +++ b/script/config/roles-check.sh @@ -139,7 +139,8 @@ for i in "${!pair_chain[@]}"; do echo ">> roles-check [group: $label] $name" # No FOUNDRY_PROFILE=sync here (unlike sync-check.sh): RolesCheck is read-only and needs no ffi; the # default profile already grants the fs read it uses. PROJECT_GROUP selects the group's project store. - out="$(PROJECT_GROUP="$g" forge script script/config/RolesCheck.s.sol --sig "run(string)" "$name" 2>&1)" + evm="$(bash script/config/evm-version.sh "$name" --lenient)" + out="$(PROJECT_GROUP="$g" forge script script/config/RolesCheck.s.sol --evm-version "$evm" --sig "run(string)" "$name" 2>&1)" status=$? grep -q "NO_ROLES_DECLARED" <<< "$out" || reconciled=$((reconciled + 1)) matched="$(echo "$out" | grep -E "\[PASS\]|\[FAIL\]|\[WARN\]|\[SKIP\]|CLEAN|ROLES_DRIFT|RPC_UNAVAILABLE|NO_ROLES_DECLARED|unknown chain" || true)" diff --git a/script/config/test-tooling.sh b/script/config/test-tooling.sh index 1b93733..689653b 100755 --- a/script/config/test-tooling.sh +++ b/script/config/test-tooling.sh @@ -1598,7 +1598,7 @@ EOF argv="$(tr '\n' ' ' < "$argv_file")" wrapper_ok=1 [ $status -eq 0 ] || wrapper_ok=0 - grep -q "verify-contract --chain 763373 --watch --retries 10 --delay 10 " <<< "$argv" || wrapper_ok=0 + grep -q "verify-contract --chain 763373 --evm-version $(bash script/config/evm-version.sh ink-testnet-sepolia) --watch --retries 10 --delay 10 " <<< "$argv" || wrapper_ok=0 grep -q -- "--verifier blockscout --verifier-url https://explorer-sepolia.inkonchain.com/api" <<< "$argv" || wrapper_ok=0 grep -q -- "--constructor-args 0x1234 0x000000000000000000000000000000000000dEaD src/CrossChainToken.sol:CrossChainToken" <<< "$argv" || wrapper_ok=0 if grep -q -- "--guess-constructor-args" <<< "$argv"; then wrapper_ok=0; fi @@ -1626,7 +1626,8 @@ EOF echo "[FAIL] verify-contract wrapper guess path (exit=$status, argv='$argv')" fi - # No ctor-args and the rpcEnv unset: a named error BEFORE any forge invocation. + # No ctor-args and the rpcEnv unset: a named error BEFORE any forge invocation - including the + # `forge config` the evm-version resolver makes, which is why that resolution runs after the guard. rm -f "$argv_file" run_case "verify-contract wrapper: no ctor-args and no RPC -> named error" nonzero "needs the RPC" -- \ env PATH="$stub_dir:$PATH" STUB_ARGV_FILE="$argv_file" bash -c \ @@ -1660,6 +1661,179 @@ EOF rm -rf "$stub_dir" fi +# ---------------------------------------------------------------- per-chain evm version (offline) +# +# evm-version.sh resolves the EVM version a chain's forge runs use: the optional evmVersion key, else +# the repo default read back from `forge config` (so there is no second copy of the default to drift). +# The value is validated here rather than at forge's CLI, and the two exits differ ON PURPOSE: a +# broadcast path must refuse an unreadable declaration, while a read/diagnose path (--lenient) has to +# keep running so `make doctor` can name the problem instead of dying on a forge CLI error. + +if offline_enabled; then + # No key: the repo default, and it must equal what forge itself would use. + out="$(bash script/config/evm-version.sh ethereum-testnet-sepolia 2>&1)" + expected="$(forge config --json | jq -r '.evm_version')" + if [ "$out" = "$expected" ]; then + pass=$((pass + 1)) + echo "[PASS] evm-version: no evmVersion key -> the foundry.toml default ($expected)" + else + fail=$((fail + 1)) + failures+=("evm-version default") + echo "[FAIL] evm-version default (got '$out', forge config says '$expected')" + fi + + # A declared value wins over the default. + jq --indent 2 -S '.evmVersion = "paris"' config/chains/ethereum-testnet-sepolia.json > "$TMP_FILE" + out="$(bash script/config/evm-version.sh "$TMP_CHAIN" 2>&1)" + if [ "$out" = "paris" ]; then + pass=$((pass + 1)) + echo "[PASS] evm-version: a declared evmVersion wins over the default" + else + fail=$((fail + 1)) + failures+=("evm-version declared") + echo "[FAIL] evm-version declared (got '$out')" + fi + + # An unrecognized value: broadcast paths get a non-zero exit naming the file and the fix. + jq --indent 2 -S '.evmVersion = "parris"' config/chains/ethereum-testnet-sepolia.json > "$TMP_FILE" + run_case "evm-version rejects an unrecognized value by name" nonzero "is not an evmVersion this repo" -- \ + bash script/config/evm-version.sh "$TMP_CHAIN" + + # The same value with --lenient: the default on stdout so the caller can carry on and report it. + out="$(bash script/config/evm-version.sh "$TMP_CHAIN" --lenient 2> /dev/null)" + if [ "$out" = "$expected" ]; then + pass=$((pass + 1)) + echo "[PASS] evm-version: --lenient degrades an unrecognized value to the default" + else + fail=$((fail + 1)) + failures+=("evm-version lenient") + echo "[FAIL] evm-version lenient (got '$out')" + fi + + # And the deploy path refuses BEFORE forge runs, so nothing is ever broadcast on a bad declaration. + run_case "deploy-token refuses an unrecognized evmVersion before forge runs" nonzero "is not an evmVersion this repo" -- \ + make deploy-token "CHAIN=$TMP_CHAIN" KEYSTORE_NAME=zz-scratch-not-a-real-keystore + + rm_fixture_config "$TMP_FILE" + + # An unknown chain resolves to the default rather than erroring: the callers all guard the config + # file's existence themselves, with a better message than this could give. + out="$(bash script/config/evm-version.sh zz-scratch-no-such-chain 2>&1)" + if [ "$out" = "$expected" ]; then + pass=$((pass + 1)) + echo "[PASS] evm-version: an unknown chain resolves to the default" + else + fail=$((fail + 1)) + failures+=("evm-version unknown chain") + echo "[FAIL] evm-version unknown chain (got '$out')" + fi + + # A file that does not parse must not read as a file with no declaration: the chain may well be + # pinned, so handing the default to a broadcast would compile for the wrong EVM. + printf '{"evmVersion": "paris",,}' > "$TMP_FILE" + run_case "evm-version refuses a malformed chain config on the broadcast path" nonzero "is not valid JSON" -- \ + bash script/config/evm-version.sh "$TMP_CHAIN" + + # A present-but-empty declaration is a broken key, not an absent one. The schema rung FAILs it, so + # resolving it to the default here would have the two disagree about the same file. + jq --indent 2 -S '.evmVersion = null' config/chains/ethereum-testnet-sepolia.json > "$TMP_FILE" + run_case "evm-version refuses a null evmVersion" nonzero "is empty or null" -- \ + bash script/config/evm-version.sh "$TMP_CHAIN" + jq --indent 2 -S '.evmVersion = ""' config/chains/ethereum-testnet-sepolia.json > "$TMP_FILE" + run_case "evm-version refuses an empty evmVersion" nonzero "is empty or null" -- \ + bash script/config/evm-version.sh "$TMP_CHAIN" + + rm_fixture_config "$TMP_FILE" + + # ---------------------------------------------------------------- detect-evm-version.sh + # + # The probe writes a tracked config file, so its refusals matter more than its successes: a wrong + # pin is permanent, is valid enough for `doctor` to pass, and silently returns the chain to the + # compile target the per-chain mechanism exists to move it off. `cast` is stubbed on PATH so each + # node behaviour can be reproduced exactly, offline. + STUB_DIR="$(mktemp -d)" + make_cast_stub() { + # $1 = what the PUSH0 probe prints, $2 = its exit code. + cat > "$STUB_DIR/cast" < "$TMP_FILE" + + # A transient failure is NOT a rejection. Pinning on "did not return 0x" turns one rate-limited + # request into a permanent paris pin that a later probe then refuses to remove. + make_cast_stub "error: server returned an error response: 429 Too Many Requests" 1 + ZZ_STUB_RPC_URL=http://stub PATH="$STUB_DIR:$PATH" \ + bash script/config/detect-evm-version.sh "$TMP_CHAIN" > /dev/null 2>&1 + status=$? + pinned="$(jq -r '.evmVersion // "absent"' "$TMP_FILE")" + if [ "$status" -eq 4 ] && [ "$pinned" = "absent" ]; then + pass=$((pass + 1)) + echo "[PASS] detect-evm-version: a transient probe error writes no pin" + else + fail=$((fail + 1)) + failures+=("detect-evm-version transient") + echo "[FAIL] detect-evm-version transient (exit=$status, evmVersion=$pinned - expected 4/absent)" + fi + + # A node that says the opcode is invalid is the one case that justifies writing the pin. + make_cast_stub "error: server returned an error response: -32000 invalid opcode: PUSH0" 1 + ZZ_STUB_RPC_URL=http://stub PATH="$STUB_DIR:$PATH" \ + bash script/config/detect-evm-version.sh "$TMP_CHAIN" > /dev/null 2>&1 + status=$? + pinned="$(jq -r '.evmVersion // "absent"' "$TMP_FILE")" + if [ "$status" -eq 0 ] && [ "$pinned" = "paris" ]; then + pass=$((pass + 1)) + echo "[PASS] detect-evm-version: a reported invalid opcode pins paris" + else + fail=$((fail + 1)) + failures+=("detect-evm-version rejects") + echo "[FAIL] detect-evm-version rejects (exit=$status, evmVersion=$pinned - expected 0/paris)" + fi + + # A supported chain gets no key at all, so the common case leaves the config untouched. + jq --indent 2 -S 'del(.evmVersion)' "$TMP_FILE" > "$TMP_FILE.t" && mv "$TMP_FILE.t" "$TMP_FILE" + before="$(md5 -q "$TMP_FILE" 2> /dev/null || md5sum "$TMP_FILE" | cut -d' ' -f1)" + make_cast_stub "0x" 0 + ZZ_STUB_RPC_URL=http://stub PATH="$STUB_DIR:$PATH" \ + bash script/config/detect-evm-version.sh "$TMP_CHAIN" > /dev/null 2>&1 + status=$? + after="$(md5 -q "$TMP_FILE" 2> /dev/null || md5sum "$TMP_FILE" | cut -d' ' -f1)" + if [ "$status" -eq 0 ] && [ "$before" = "$after" ]; then + pass=$((pass + 1)) + echo "[PASS] detect-evm-version: a PUSH0 chain is left byte-identical" + else + fail=$((fail + 1)) + failures+=("detect-evm-version supported") + echo "[FAIL] detect-evm-version supported (exit=$status, file changed=$([ "$before" = "$after" ] && echo no || echo yes))" + fi + + # Malformed JSON must not read as "not an EVM chain" and exit 0 - that is a success that measured + # nothing, and it is indistinguishable from a real one. + printf '{"chainFamily": "evm",,}' > "$TMP_FILE" + run_case "detect-evm-version refuses a malformed chain config" nonzero "is not valid JSON" -- \ + bash script/config/detect-evm-version.sh "$TMP_CHAIN" + + # No rpcEnv: a named refusal, not an unbound-variable crash. + jq -n '{name: "x", chainId: "1112", chainFamily: "evm"}' > "$TMP_FILE" + run_case "detect-evm-version refuses a config with no rpcEnv" nonzero "declares no rpcEnv" -- \ + bash script/config/detect-evm-version.sh "$TMP_CHAIN" + + rm -rf "$STUB_DIR" + rm_fixture_config "$TMP_FILE" +fi + # ---------------------------------------------------------------- committed-tree gates (offline) # # The project store and the deploy ledger hold local, throwaway, sometimes secret-bearing state, so only diff --git a/script/config/verify-contract.sh b/script/config/verify-contract.sh index 4b31223..e7131e2 100755 --- a/script/config/verify-contract.sh +++ b/script/config/verify-contract.sh @@ -29,15 +29,23 @@ chain_id="$(jq -r '.chainId' "$file")" rpc_env="$(jq -r '.rpcEnv' "$file")" rpc_url="${!rpc_env:-}" +# Every input check happens before anything external is invoked, so a run that cannot succeed says why +# without having called out first. +if [ -z "$ctor_args" ] && [ -z "$rpc_url" ]; then + echo "[verify] no ctor-args given and ${rpc_env} is unset - --guess-constructor-args needs the RPC. Pass ctor-args or export ${rpc_env}" >&2 + exit 1 +fi + +# Explorer verification recompiles the source and compares bytecode, so it must use the SAME evm +# version the deploy did. A chain that pins `evmVersion` would otherwise be re-compiled at the repo +# default here and fail on a bytecode mismatch that has nothing to do with the source. +evm_version="$(bash script/config/evm-version.sh "$name")" || exit 1 + # shellcheck disable=SC2086 # $flags is a composed flag list, word-splitting is the point -cmd=(forge verify-contract --chain "$chain_id" --watch --retries 10 --delay 10 $flags) +cmd=(forge verify-contract --chain "$chain_id" --evm-version "$evm_version" --watch --retries 10 --delay 10 $flags) if [ -n "$ctor_args" ]; then cmd+=(--constructor-args "$ctor_args") else - if [ -z "$rpc_url" ]; then - echo "[verify] no ctor-args given and ${rpc_env} is unset - --guess-constructor-args needs the RPC. Pass ctor-args or export ${rpc_env}" >&2 - exit 1 - fi cmd+=(--guess-constructor-args --rpc-url "$rpc_url") fi cmd+=("$address" "$contract") diff --git a/test/config/VerifyChainSchemaRung.t.sol b/test/config/VerifyChainSchemaRung.t.sol index 507b3cf..14111a2 100644 --- a/test/config/VerifyChainSchemaRung.t.sol +++ b/test/config/VerifyChainSchemaRung.t.sol @@ -21,6 +21,11 @@ import {ProjectStore} from "../../src/utils/ProjectStore.sol"; /// name when malformed (unknown type; a structurally-wrong array/object type; blockscout with a /// missing, empty, or non-http url) without ever raw-reverting the doctor; a stray `confirmations` /// key (the removed field) is a NAMED FAIL; a config without either is clean (non-regression). +/// 4. **evmVersion validation** - the optional per-chain `evmVersion` key (the EVM version every +/// forge run scoped to that chain compiles and simulates at) passes for a known version, FAILs by +/// name for an unrecognized one, and FAILs by name rather than raw-reverting when written as a +/// JSON array. Absent is the normal case and is covered by the clean config in (1), which has 0 +/// fails - a typo must never quietly resolve to the repo default. /// Every test writes uniquely-named `config/chains/zz-scratch-*` (discovery-safe, all chain-fact keys) /// and cleans both config + project in setUp() (revert-safe, gitignored). contract VerifyChainSchemaRungTest is Test { @@ -38,6 +43,9 @@ contract VerifyChainSchemaRungTest is Test { string internal constant SEL_VER_ARRTYPE = "zz-scratch-schema-verarrtype"; string internal constant SEL_VER_NUMURL = "zz-scratch-schema-vernumurl"; string internal constant SEL_VER_ARRURL = "zz-scratch-schema-verarrurl"; + string internal constant SEL_EVM_PARIS = "zz-scratch-schema-evmparis"; + string internal constant SEL_EVM_BAD = "zz-scratch-schema-evmbad"; + string internal constant SEL_EVM_ARR = "zz-scratch-schema-evmarr"; function setUp() public { _clean(); @@ -48,7 +56,7 @@ contract VerifyChainSchemaRungTest is Test { /// ONLY the fixtures it owns at the end of its body (suite siblings run in parallel), so a green /// run leaves no residue. function _clean() private { - string[13] memory sels = [ + string[16] memory sels = [ SEL_CLEAN, SEL_STRAY_LANES, SEL_STRAY_ROLES, @@ -61,7 +69,10 @@ contract VerifyChainSchemaRungTest is Test { SEL_VER_EMPTYURL, SEL_VER_ARRTYPE, SEL_VER_NUMURL, - SEL_VER_ARRURL + SEL_VER_ARRURL, + SEL_EVM_PARIS, + SEL_EVM_BAD, + SEL_EVM_ARR ]; for (uint256 i = 0; i < sels.length; i++) { _clean(sels[i]); @@ -247,4 +258,33 @@ contract VerifyChainSchemaRungTest is Test { assertEq(fails, 1, "a blockscout verifier.url written as a JSON array must FAIL by name (not revert) once"); _clean(SEL_VER_ARRURL); } + + // (14) A known evmVersion passes. This is the key a PUSH0-less chain declares so its bytecode is + // compiled down to what that network can execute; the clean config in (1) pins that an absent key + // is equally valid (the repo default from foundry.toml applies). + function test_SchemaRung_EvmVersionKnown_Pass() public { + _writeConfig(SEL_EVM_PARIS, 889001401, 8890014010000000001, ',"evmVersion":"paris"'); + (, uint256 fails,) = new VerifyChain().checkSchemaForTest(SEL_EVM_PARIS); + assertEq(fails, 0, "a known evmVersion must not FAIL the schema rung"); + _clean(SEL_EVM_PARIS); + } + + // (15) An unrecognized evmVersion is a NAMED FAIL. The resolver substitutes the repo default for a + // value it cannot use, so without this the operator would learn about the typo from a reverted + // deploy rather than from the doctor. + function test_SchemaRung_EvmVersionUnknown_NamedFail() public { + _writeConfig(SEL_EVM_BAD, 889001501, 8890015010000000001, ',"evmVersion":"parris"'); + (, uint256 fails,) = new VerifyChain().checkSchemaForTest(SEL_EVM_BAD); + assertEq(fails, 1, "an unrecognized evmVersion must FAIL exactly once"); + _clean(SEL_EVM_BAD); + } + + // (16) A structurally-wrong evmVersion (a JSON array) FAILs by name, never a raw cheatcode revert + // that would abort the doctor before its remaining rungs run. + function test_SchemaRung_EvmVersionArray_NamedFail() public { + _writeConfig(SEL_EVM_ARR, 889001601, 8890016010000000001, ',"evmVersion":["paris"]'); + (, uint256 fails,) = new VerifyChain().checkSchemaForTest(SEL_EVM_ARR); + assertEq(fails, 1, "an evmVersion written as a JSON array must FAIL by name (not revert) exactly once"); + _clean(SEL_EVM_ARR); + } }