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); + } }