diff --git a/.env.example b/.env.example index 4284f64..c0e59af 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,12 @@ MANTLE_SEPOLIA_RPC_URL= # Etherscan API v2 supports all chains with a single key. # -------------------------------------------------------------- ETHERSCAN_API_KEY= + + +# -------------------------------------------------------------- +# Optional — chain-config tooling (script/config/*) +# Non-secret override of the CCIP REST API base URL used by the +# add-chain / sync / drift-check tooling. Leave unset to use the +# public API (https://api.ccip.chain.link/v2). +# -------------------------------------------------------------- +# CCIP_API_BASE=https://api.ccip.chain.link/v2 diff --git a/.github/workflows/config-drift.yml b/.github/workflows/config-drift.yml new file mode 100644 index 0000000..e7c36c8 --- /dev/null +++ b/.github/workflows/config-drift.yml @@ -0,0 +1,42 @@ +name: CCIP Config Drift + +# Scheduled read-only drift check: compares every config/chains/.json ccip{} block +# field-by-field against the live CCIP API (script/config/sync-check.sh). Exit contract: +# 0 clean / 1 drift (fail visibly) / 2 API unreachable (warn-and-pass — flake is not drift). +# Not a pull_request gate: this surfaces upstream CCIP address rotations, not PR regressions. + +on: + schedule: + - cron: "43 5 * * 1" # Mondays 05:43 UTC + workflow_dispatch: + +permissions: + contents: read + +env: + FOUNDRY_VERSION: v1.7.1 + +jobs: + sync-check: + name: sync-check vs live CCIP API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 22 + cache: npm + - uses: foundry-rs/foundry-toolchain@b00af27efadbc7b4ca8b82abbd903b17cc874d2a # v1.9.0 + with: + version: ${{ env.FOUNDRY_VERSION }} + - run: npm ci + - name: sync-check all configured chains + run: | + set +e + bash script/config/sync-check.sh + code=$? + if [ "$code" -eq 2 ]; then + echo "::warning::CCIP API unreachable - drift could not be determined (flake, not drift)" + exit 0 + fi + exit $code diff --git a/.gitignore b/.gitignore index 7465274..44d6132 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,11 @@ out/ broadcast/ lib/ -# Docs -docs/ +# Docs (e.g. generated `forge doc` output); keep the hand-authored references +docs/* +!docs/config-schema.md +!docs/config-architecture.md +!docs/deployed-addresses.md # Dotenv file .env @@ -19,4 +22,10 @@ docs/ */.DS_Store # Deployments (user-generated, kept local) -script/deployments/ \ No newline at end of file +script/deployments/ + +# Deployed-address registry (written by the deploy scripts, kept local) +addresses/*.json +!addresses/11155111.example.json +# Scratch chain configs written (and removed) by DynamicChainDiscovery tests - never commit a leak +config/chains/zz-scratch-*.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..917f413 --- /dev/null +++ b/Makefile @@ -0,0 +1,87 @@ +# Makefile — golden path over the chain-config tooling (script/config/*). +# +# Every target is a thin wrapper: the raw `forge script` / `bash` commands it runs are documented +# in README.md ("Chain config tooling") and remain the escape hatch. `FOUNDRY_PROFILE=sync` is set +# inside each recipe (the sync profile enables `ffi` for the curl+jq API fetch), never exported. +# +# NOTE on exit codes: the canonical drift-check exit contract (0 clean / 1 drift / 2 API +# unreachable) belongs to `bash script/config/sync-check.sh` — GNU make remaps ANY failing recipe +# to its own exit code 2, so `make sync-check` is pass/fail only. CI calls the script directly. + +CONFIG_DIR := config/chains +KNOWN_CHAINS := $(basename $(notdir $(wildcard $(CONFIG_DIR)/*.json))) +SYNC_SCRIPT := script/config/SyncCcipConfig.s.sol + +.DEFAULT_GOAL := help +.PHONY: help tools discover add-chain sync sync-preview sync-all sync-check doctor fmt-config + +# Recipe-time guard: the CHAIN's config file must exist (helpful list + add-chain hint on a miss). +define require-chain-config + @test -f "$(CONFIG_DIR)/$(CHAIN).json" || { \ + echo "unknown chain '$(CHAIN)' - known chains: $(KNOWN_CHAINS)"; \ + echo "New chain? make add-chain CHAIN= SELECTOR="; \ + exit 1; } +endef + +# Canonical JSON format for config/chains/*.json: `jq --indent 2 -S .` (2-space indent, sorted keys, +# trailing newline — jq always emits one). The committed files use this exact style, and every target +# that writes a config re-canonicalizes it as its last step, so a no-drift `make sync` produces ZERO +# git diff (Foundry's `vm.writeJson` has its own style; raw `forge script` runs bypass the reformat — +# `make fmt-config` restores canon). +define canon-chain-config +@tmp="$$(mktemp)" && jq --indent 2 -S . "$(CONFIG_DIR)/$(CHAIN).json" > "$$tmp" && mv "$$tmp" "$(CONFIG_DIR)/$(CHAIN).json" +endef + +help: ## List the available targets + @echo "Chain-config tooling golden path (raw commands: README.md > Chain config tooling):" + @awk 'BEGIN {FS = ":.*## "} /^[a-z][a-z-]*:.*## / {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +tools: ## Check the required tools are installed (forge, curl, jq) + @command -v forge > /dev/null || { echo "missing: forge - install Foundry: https://book.getfoundry.sh/getting-started/installation"; exit 2; } + @command -v curl > /dev/null || { echo "missing: curl - install it (usually preinstalled; else brew install curl / apt install curl)"; exit 2; } + @command -v jq > /dev/null || { echo "missing: jq - install it (e.g. brew install jq / apt install jq)"; exit 2; } + @echo "tools: forge, curl and jq are all present" + +discover: tools ## List the CCIP API testnet catalog vs local configs (FILTER= narrows) + @FILTER="$(FILTER)" bash script/config/sync-discover.sh + +add-chain: tools ## Generate config/chains/.json from the live API (CHAIN= and SELECTOR= required) + $(if $(CHAIN),,$(error CHAIN is required: make add-chain CHAIN= SELECTOR=)) + $(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)" + $(canon-chain-config) + +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)" + $(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)" + +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)"; \ + echo ">> sync $$name"; \ + FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --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; \ + echo "sync-all: OK - every configured chain synced (or SKIPped)" + +fmt-config: tools ## Rewrite config/chains/*.json in the canonical style (jq --indent 2 -S, trailing newline) + @for f in $(CONFIG_DIR)/*.json; do \ + tmp="$$(mktemp)" && jq --indent 2 -S . "$$f" > "$$tmp" && mv "$$tmp" "$$f"; \ + done; \ + echo "fmt-config: canonicalized $(CONFIG_DIR)/*.json" + +sync-check: tools ## Read-only drift check (CHAIN= optional; pass/fail only - CI uses the script for 0/1/2) + @bash script/config/sync-check.sh $(CHAIN) + +doctor: tools ## Layered verification of one chain's config (CHAIN= required) + $(if $(CHAIN),,$(error CHAIN is required: make doctor CHAIN=)) + $(require-chain-config) + FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig "run(string)" "$(CHAIN)" diff --git a/README.md b/README.md index b035e79..2f97403 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ Foundry scripts for deploying and managing cross-chain tokens using Chainlink CC ## Prerequisites +Everything a fresh machine needs, in one place: + +| Tool | Needed for | Check | +|---|---|---| +| [Foundry](https://book.getfoundry.sh/getting-started/installation) (`forge`, `cast`) | building, testing, and every deploy/config script | `forge --version` | +| Node.js + npm | installing the Solidity dependencies (`npm install`) | `npm --version` | +| `make` | the golden-path targets in the `Makefile` (preinstalled on macOS/Linux; on Windows use WSL) | `make --version` | +| `bash` | the thin wrapper scripts under `script/config/` | `bash --version` | +| `curl` + `jq` | **only** the chain-config sync tooling (fetching the [CCIP API](https://api.ccip.chain.link/v2)) — deploys don't use them | `curl --version`, `jq --version` | + +The chain-config sync tooling (`make discover` / `add-chain` / `sync` / `sync-check` / `doctor`) needs **no RPC URL, no keystore, and no API key** — it only reads the public CCIP API. `make tools` runs this same presence check and prints install hints for anything missing. + 1. Install [Foundry](https://book.getfoundry.sh/getting-started/installation) 2. Install dependencies: @@ -55,7 +67,7 @@ Foundry scripts for deploying and managing cross-chain tokens using Chainlink CC ### Step 1: Deploy Token (on both chains) -Configure token parameters in `script/input/token.json` (see [Configuration Files](#configuration-files) section), or override any field with environment variables: +Configure token parameters in `script/input/token.json` (see the [Configuration](#configuration) section), or override any field with environment variables: | Env var | Default (from `token.json`) | |---|---| @@ -99,7 +111,9 @@ script/deployments/tokens/{CHAIN_NAME_IDENTIFIER}/{timestamp}-{SYMBOL}-Token.jso ``` The file uses the env var name as the key (e.g. `ETHEREUM_SEPOLIA_TOKEN`). If you need to retrieve the deployed address later, open the file — the key is the env var name and the value is the address, so you can copy both directly into an `export` command. The `script/deployments/` directory is ignored by `.gitignore` — files are local to each user. -Set the token address so subsequent scripts can find it — choose one approach: +A broadcast deploy also records the address in the [address registry](#deployed-address-registry--addresseschainidjson-the-default) (`addresses/.json`), so **subsequent scripts resolve the token automatically — no `export` needed**. Re-running the deploy on the same chain is refused while the registry holds a live address (set `FORCE_REDEPLOY=true` to deploy a replacement). + +To override the registry address for a session, choose one approach: ```bash # Option A: export for the session (persists across all commands in the current terminal) @@ -281,7 +295,7 @@ The `LockReleaseTokenPool` requires the `ERC20LockBox` at deploy time, and the l `LOCK_BOX` is required and must be the address of a deployed `ERC20LockBox` for the token. Optional: Set `POOL_HOOKS=0x...` to attach an already-deployed `AdvancedPoolHooks` contract at deploy time. Set `DECIMALS=` if your token does not implement the optional `decimals()` ERC20 function. When deploying the lockbox, you can optionally set `AUTHORIZED_CALLERS` (CSV or JSON array) to authorize addresses immediately — useful for authorizing the deployer or token issuer to deposit/withdraw liquidity initially. -Set the pool address so subsequent scripts can find it — choose one approach: +A broadcast deploy records the pool (and lockbox) address in the [address registry](#deployed-address-registry--addresseschainidjson-the-default), so **subsequent scripts resolve it automatically — no `export` needed** (the LockRelease pool deploy also resolves `LOCK_BOX` from the registry). To override the registry address for a session, choose one approach: ```bash # Option A: export for the session (persists across all commands in the current terminal) @@ -487,6 +501,34 @@ forge script \ --broadcast ``` +## Adding a New Chain + +Supporting a new chain is a **config edit, not a code change** — three commands generate and verify `config/chains/.json` from the live [CCIP API](https://api.ccip.chain.link/v2) (needs only `curl` + `jq` from the [Prerequisites](#prerequisites) — no RPC URL, no keystore, no API key): + +```bash +make discover FILTER=base # 1. find the chain in the API catalog, note its NAME + SELECTOR +make add-chain CHAIN=ethereum-testnet-sepolia-base-1 SELECTOR=10344971235874465080 # 2. generate from the API +make doctor CHAIN=ethereum-testnet-sepolia-base-1 # 3. layered verification — re-run until green +``` + +`CHAIN` is the chain's **canonical CCIP selectorName** as shown by `make discover` (the API/registry name — e.g. `ethereum-testnet-sepolia-base-1`, not a bespoke `base-sepolia`); it becomes the file name `config/chains/.json` and is validated against the API. `SELECTOR` is the **explicit identity key**, also from `make discover` — every fetch cross-checks both: a valid-but-wrong selector fails loudly as `SELECTOR MISMATCH`, and a non-canonical name as `SELECTOR NAME MISMATCH`, instead of silently writing another chain's contracts. New chains are **discovered automatically** from `config/chains/` — `HelperConfig` scans the directory, so no Solidity edit is needed anywhere. For a newly added chain the `chainNameIdentifier` (and hence the `rpcEnv` and the `_TOKEN`/`_TOKEN_POOL` override prefix) is **derived from the selectorName** as UPPER_SNAKE — so it may differ in style from the six bundled chains' curated short forms (e.g. `AVALANCHE_TESTNET_FUJI`, not `AVALANCHE_FUJI`); `add-chain` **prints the exact `chainNameIdentifier` and `rpcEnv` names it generated** so you never have to guess (or open the JSON) which env var to export. `add-chain` prints your next steps: add the chain's RPC env var to `.env`, then deploy your token and pool there ([Step 1](#step-1-deploy-token-on-both-chains) / [Step 2](#step-2-deploy-token-pools-on-both-chains)). Wiring the new chain into your cross-chain lanes ([Step 5](#step-5-apply-chain-updates-configure-cross-chain-routes)) stays a manual flow for now — a scripted lane-wiring golden path lands in a follow-up. + +Full details: [Configuration](#configuration) overview, the per-field [`docs/config-schema.md`](docs/config-schema.md), and the command + architecture reference [`docs/config-architecture.md`](docs/config-architecture.md). + +### Which command when + +| I want to... | Run | +|---|---| +| See which chains exist / find a selector | `make discover FILTER=` | +| Onboard a new chain | `make add-chain CHAIN= SELECTOR=`, then `make doctor CHAIN=` | +| Check whether any config drifted from the API (routine; what CI runs weekly) | `make sync-check` (CI/automation: `bash script/config/sync-check.sh` for the 0/1/2 exit codes) | +| Inspect what the API currently has for one chain before changing anything | `make sync-preview CHAIN=` | +| Apply the API's current values | `make sync CHAIN=` / `make sync-all` | +| Deep-verify one chain end to end (human health check) | `make doctor CHAIN=` | +| Restore canonical formatting after a raw `forge script` run | `make fmt-config` | + +`doctor` and `sync-check` layer rather than overlap: `doctor` is the deep single-chain health check for a human (schema, identity, drift, RPC, on-chain code, registry warnings), while `sync-check` is the fleet-wide drift verdict for routine use and CI. + ## Ownership Management (Optional) The following scripts are not required for the core deployment flow but are useful when handing off control to a multisig or a different EOA after initial setup. All token ownership scripts auto-detect the correct ownership pattern — no configuration needed. @@ -928,7 +970,7 @@ DEST_CHAIN=MANTLE_SEPOLIA \ Use this script for enhanced security features like allowlists, CCV management, policy engine integration, and threshold-based validation. -Configure defaults in `script/input/advanced-pool-hooks.json` (see [Configuration Files](#configuration-files) section), or override any field with environment variables: +Configure defaults in `script/input/advanced-pool-hooks.json` (see the [Configuration](#configuration) section), or override any field with environment variables: | Env var | Type | Default (from `advanced-pool-hooks.json`) | |---|---|---| @@ -1275,7 +1317,16 @@ DEST_CHAIN=MANTLE_SEPOLIA \ - Solana Devnet (`SOLANA_DEVNET`) -## Configuration Files +## Configuration + +The repo keeps its cross-chain state as **data, not code**, in two git-visible stores: + +- **`config/chains/.json`** — one reviewed file per chain: the API-synced `ccip{}` address block + API-synced identity/metadata (`displayName`, `chainFamily`, `environment`, `explorerUrl`, `nativeCurrencySymbol`), and the hand-authored keys (`chainNameIdentifier`, `rpcEnv`, `confirmations`, `ccipBnM`). Files are named by the canonical CCIP **selectorName** (e.g. `ethereum-testnet-sepolia.json`). `HelperConfig` reads them through `src/config/ChainConfig.sol` and discovers the chain list by scanning the directory, so adding a chain — or updating a CCIP address — is a **reviewed config edit with zero Solidity changes**. +- **`addresses/.json`** — the deployed-address registry (schema v2: `active` role pointers + versioned `deployments`), written automatically on a real broadcast via a single `DeploymentRecorder` call per artifact (user-specific, **gitignored**, local to the deploy machine; see `addresses/11155111.example.json`). + +**One writer per field:** the CCIP REST API owns **every field it serves** in the **git-tracked** `config/chains/*.json` (via the sync) — the `ccip{}` addresses AND the identity/metadata fields (`displayName`, `chainFamily`, `environment`, `explorerUrl`, `nativeCurrencySymbol`); only the keys the API serves nothing for (`chainNameIdentifier`, `rpcEnv`, `confirmations`, `ccipBnM`) are hand-authored in reviewed PRs, and the join keys are guard-validated — so for that file **a git diff is an unambiguous audit log**. The deployed-address registry `addresses/` is a **separate, gitignored** store (not a git audit trail); its integrity comes from a single writer — the `DeploymentRecorder` that emits the `script/deployments/**` ledger and the registry entry in one call. Environment variables remain available as **overrides** on top of both. + +> **Reference docs:** **[`docs/config-schema.md`](docs/config-schema.md)** is the per-field reference (every key in a chain config — identity, the `ccip{}` block, extras, and the non-EVM shape — with its type, writer, and whether it is API-synced, hand-authored, or deploy-written); **[`docs/config-architecture.md`](docs/config-architecture.md)** is the `make`-command reference plus the layered architecture (brand-colored Mermaid diagrams: layering, sync data-flow, the one-writer store model, and the selectorName join). The operational how-to (discover → add-chain → sync → doctor) stays below. ### Token Deployment Configuration @@ -1304,15 +1355,75 @@ Default hooks parameters are in `script/input/advanced-pool-hooks.json`. All fie } ``` -## Environment Variable Reference +### Chain config tooling - discover, add, sync, verify + +Tooling under `script/config/` keeps the config files true to the live [CCIP API](https://api.ccip.chain.link/v2). The golden path is the repo `Makefile`: every target is a thin wrapper that sets `FOUNDRY_PROFILE=sync` for you (it enables `ffi` so Foundry can fetch the API via `curl` + `jq`; no RPC URL or keystore needed). The **full command reference** - each target's required args, the raw `forge script` / `bash` command it runs underneath, the `0`/`1`/`2` drift exit-code contract, and the architecture diagrams - is in **[`docs/config-architecture.md`](docs/config-architecture.md)**. + +```bash +make discover [FILTER=] # list the API catalog vs your local configs +make add-chain CHAIN= SELECTOR= # generate config/chains/.json from the API +make sync-preview CHAIN= # fetch + log a chain's ccip{}, no write +make sync CHAIN= / make sync-all # rewrite API-served fields (ccip{} + identity/metadata) from the API +make sync-check [CHAIN=] # read-only drift check (CI: bash script/config/sync-check.sh for 0/1/2) +make doctor CHAIN= # layered [PASS]/[FAIL]/[WARN]/[SKIP] check of one chain +make fmt-config # restore the canonical config format after a raw forge run +``` + +`CHAIN=` is the canonical **selectorName** (the file basename). The sync writes **every API-served field** — the `ccip{}` subtree plus the identity/metadata fields (`displayName`, `chainFamily`, `environment`, `explorerUrl`, `nativeCurrencySymbol`) — leaving every hand-authored key (`chainNameIdentifier`, `rpcEnv`, `confirmations`, `ccipBnM`) untouched, and re-canonicalizes as `jq --indent 2 -S`, so a no-drift `make sync` is a **zero-diff** no-op. Every fetch is guarded: the API chainId must equal the config's (`SELECTOR MISMATCH`) and the config `name` must equal the API selectorName (`SELECTOR NAME MISMATCH`); non-EVM chains (e.g. `solana-devnet`) skip the EVM address sync cleanly but still refresh their served identity/metadata. A scheduled workflow (`.github/workflows/config-drift.yml`) runs the drift check weekly: drift fails visibly, an unreachable API only warns. + +The tooling is tested twice over: `forge test` pins the API->config transform against a committed real API response (`test/fixtures/ccip-api/`), and `bash script/config/test-tooling.sh` is a re-runnable failure-path suite (unknown chain, overwrite refusal, invalid names, `SELECTOR MISMATCH`, `SELECTOR NAME MISMATCH`, `NOT_FOUND`, API-down, non-EVM skip, the sync-check exit contract, an offline end-to-end sync against a local fixture server, the Makefile golden-path guards, and the canonical-format + zero-diff guarantees). + +### Deployed-address registry — `addresses/.json` (the default) + +After a `--broadcast` deploy, every later script resolves the deployed addresses from the registry automatically — no `export` step. This now holds for **all four artifacts**: `token`, `tokenPool`, `lockBox`, **and** `poolHooks` (the last two previously had to be re-exported by hand). Resolution precedence (highest first), per role: + +1. Inline alias — `TOKEN=0x...` / `TOKEN_POOL=0x...` / `LOCK_BOX=0x...` / `POOL_HOOKS=0x...` on the command line +2. Chain-scoped session export — `ETHEREUM_SEPOLIA_TOKEN`, `MANTLE_SEPOLIA_TOKEN_POOL`, `ETHEREUM_SEPOLIA_LOCK_BOX`, ... +3. **Address registry** — `addresses/.json` → `active.` (the default path) + +The registry is a **schema-v2** file with two sub-stores: `active` (the single per-role pointer `HelperConfig` resolves) and `deployments` (uniquely-named entries whose key carries the pool's type and version — e.g. `BnM-T_BurnMintTokenPool_2.0.0` — so distinct artifacts never collide in storage). One writer owns it: each deploy script makes a single `DeploymentRecorder` call that emits the `script/deployments/**` ledger **and** updates the registry, so the two never drift. `active.` is single-valued: deploy two pools for the same symbol on one chain and the zero-export getters resolve the last one for both tokens (pass the other explicitly). See [config-schema.md](docs/config-schema.md#the-deployed-address-registry---addresseschainidjson-schema-v2) and [deployed-addresses.md](docs/deployed-addresses.md) for the keying table and the two-store model. + +The registry also guards against accidental redeploys: while it holds a live address under a `deployments` name, the corresponding deploy script refuses to run and prints the registered address. Set `FORCE_REDEPLOY=true` to deploy a replacement of the *same* name (the old address stays in the append-only `script/deployments/` ledger; the registry itself is gitignored, so it is not in git history). Note `active.tokenPool` is *what this repo last deployed*, not proof of what CCIP routes through — the on-chain TokenAdminRegistry is the authority, and `make doctor` WARNs when they diverge. + +### Sharing addresses with your team + +Both deployed-address stores are gitignored in this template. When you **fork it into your own project**, you +MAY un-gitignore them to share addresses with colleagues and CI. The two stores warrant different advice — see +[deployed-addresses.md](docs/deployed-addresses.md) for the full two-store model. + +- **Registry (`addresses/.json`) — recommended for teams.** It holds public addresses only, no + secrets. Track it and every colleague plus CI resolves the same addresses on clone, with zero `export`. +- **History (`script/deployments/`) — optional.** Be honest about what it is: it is **write-only** (nothing in + this repo reads it), and it grows one file per deploy forever. Foundry's own `broadcast/` directory already + records every deploy with richer detail (and is itself gitignored, `.gitignore:8`). Track `script/deployments/` + only if you specifically want an in-repo, human-readable deploy log. + +**Guardrails (mandatory if you track the registry):** + +1. **Never commit local/anvil chains.** Keep an explicit ignore for `addresses/31337.json` (and any other + local chain id). +2. **The test suite writes real `addresses/.json` files for scratch chain ids** (e.g. `16602`, and + the `9000000xx` throwaways). Today `.gitignore` hides them. If you un-ignore the registry, add explicit + ignores for those scratch ids, or a stray test artifact gets committed. This is real: a leftover scratch + registry file once bricked the local test suite while `git status` stayed clean. +3. **`active` is not authority.** It records what this repo deployed most recently, not what is wired. The + on-chain **TokenAdminRegistry** is the source of truth. Run `make doctor CHAIN=` (in CI too) — it + reconciles the registry pool against the wired pool and WARNs on divergence. +4. **Review registry diffs like config changes.** Put `addresses/` under CODEOWNERS, and gate mainnet + chain-id files behind stricter approval. +5. **Do this in a single-deployment project**, not a shared template clone where many developers push + disposable fixtures. + +Do **not** claim git gives an audit trail for the registry (it is gitignored), and do not tell people to trust +the file over the on-chain TokenAdminRegistry. -### Session exports — `export VAR=0x...` after each deployment +### Session exports — `export VAR=0x...` (overrides) -These are **not** stored in `.env`. After each deployment, run `export` in your terminal — or use the chain-agnostic `TOKEN` / `TOKEN_POOL` inline aliases (see [CLI inline vars](#cli-inline-vars----varvalue-forge-script-)) to pass an address to a single command without exporting. Session exports last for the current terminal — re-export after opening a new one, or recover values from `script/deployments/`. +These are **not** stored in `.env` and are now **optional**: the registry covers the default flow. Use an export (or the chain-agnostic `TOKEN` / `TOKEN_POOL` inline aliases, see [CLI inline vars](#cli-inline-vars----varvalue-forge-script-)) when you want to target a *different* contract than the registered one — e.g. an older deployment or a contract deployed outside this repo. Session exports last for the current terminal — values can always be recovered from `addresses/.json` or `script/deployments/`. -> **Note:** Do not add these to `.env`. If unset, `vm.envOr()` falls back to `address(0)` — scripts will revert when they receive it. If sourced from a stale `.env` value, scripts will silently target the wrong contract after a redeployment. +> **Note:** Do not add these to `.env`. An env var always **beats** the registry, so a stale `.env` value would silently target the wrong contract after a redeployment. -**Token addresses** — exported after [Step 1: Deploy Token](#step-1-deploy-token-on-both-chains): +**Token addresses** — override after [Step 1: Deploy Token](#step-1-deploy-token-on-both-chains): | Variable | Chain | |---|---| @@ -1325,7 +1436,7 @@ These are **not** stored in `.env`. After each deployment, run `export` in your |---|---| | `SOLANA_DEVNET_TOKEN` | Solana Devnet | -**Token pool addresses** — exported after [Step 2: Deploy Token Pools](#step-2-deploy-token-pools-on-both-chains): +**Token pool addresses** — override after [Step 2: Deploy Token Pools](#step-2-deploy-token-pools-on-both-chains): | Variable | Chain | |---|---| diff --git a/addresses/.gitkeep b/addresses/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/addresses/11155111.example.json b/addresses/11155111.example.json new file mode 100644 index 0000000..3986df5 --- /dev/null +++ b/addresses/11155111.example.json @@ -0,0 +1,14 @@ +{ + "active": { + "token": "0x1111111111111111111111111111111111111111", + "tokenPool": "0x2222222222222222222222222222222222222222", + "lockBox": "0x3333333333333333333333333333333333333333", + "poolHooks": "0x4444444444444444444444444444444444444444" + }, + "deployments": { + "BnM-T_Token": "0x1111111111111111111111111111111111111111", + "BnM-T_BurnMintTokenPool_2.0.0": "0x2222222222222222222222222222222222222222", + "BnM-T_LockBox": "0x3333333333333333333333333333333333333333", + "BnM-T_BurnMint_PoolHooks": "0x4444444444444444444444444444444444444444" + } +} diff --git a/config/chains/0g-testnet-galileo-1.json b/config/chains/0g-testnet-galileo-1.json new file mode 100644 index 0000000..94e8070 --- /dev/null +++ b/config/chains/0g-testnet-galileo-1.json @@ -0,0 +1,27 @@ +{ + "ccip": { + "feeQuoter": "0x300B743B188c101b09EaAB4e13644e2B4f907d93", + "feeTokens": [ + "0x1Cd0690fF9a693f5EF2dD976660a8dAFc81A109c", + "0xe5e3a4fF1773d043a387b16Ceb3c91cC49bAFD54" + ], + "link": "0xe5e3a4fF1773d043a387b16Ceb3c91cC49bAFD54", + "registryModuleOwnerCustom": "0x0820f975ce90EE5c508657F0C58b71D1fcc85cE0", + "rmnProxy": "0x995ab3eC29E1660A93cFddAA19C710A1b5afCCc9", + "router": "0xD610B8f58689de7755947C05342A2DFaC30ebD57", + "tokenAdminRegistry": "0x23a5084Fa78104F3DF11C63Ae59fcac4f6AD9DeE", + "tokenPoolFactory": "0xd3e461C55676B10634a5F81b747c324B85686Dd1" + }, + "ccipBnM": "0xDbB255D37BC7c9e2b08e5a1C9f9506c9E85F1644", + "chainFamily": "evm", + "chainId": "16602", + "chainNameIdentifier": "0G_GALILEO_TESTNET", + "chainSelector": "6892437333620424805", + "confirmations": 2, + "displayName": "0g Galileo 1", + "environment": "testnet", + "explorerUrl": "https://chainscan-galileo.0g.ai", + "name": "0g-testnet-galileo-1", + "nativeCurrencySymbol": "0G", + "rpcEnv": "ZERO_G_TESTNET_RPC_URL" +} diff --git a/config/chains/ethereum-testnet-sepolia-mantle-1.json b/config/chains/ethereum-testnet-sepolia-mantle-1.json new file mode 100644 index 0000000..38f11c9 --- /dev/null +++ b/config/chains/ethereum-testnet-sepolia-mantle-1.json @@ -0,0 +1,27 @@ +{ + "ccip": { + "feeQuoter": "0x8c5Bd1D4E19af3fc2779EA4cA4a09115236CDe9f", + "feeTokens": [ + "0x22bdEdEa0beBdD7CfFC95bA53826E55afFE9DE04", + "0x19f5557E23e9914A18239990f6C70D68FDF0deD5" + ], + "link": "0x22bdEdEa0beBdD7CfFC95bA53826E55afFE9DE04", + "registryModuleOwnerCustom": "0xf76cE612250eeEb8889F49FBCB11f1c2705305F6", + "rmnProxy": "0xcCB84Ec3F6AFdD2052134f74aaAc95Ae41A7B333", + "router": "0xFd33fd627017fEf041445FC19a2B6521C9778f86", + "tokenAdminRegistry": "0x0F1eE88A582f31d92510E300fc1330AA5a525D51", + "tokenPoolFactory": "0x9fCd83bC7F67ADa1fB51a4caBEa333c72B641bd1" + }, + "ccipBnM": "0xBB370F829bdB6fC44f3D34e2A2107578bB2c3F0B", + "chainFamily": "evm", + "chainId": "5003", + "chainNameIdentifier": "MANTLE_SEPOLIA", + "chainSelector": "8236463271206331221", + "confirmations": 2, + "displayName": "Mantle Sepolia", + "environment": "testnet", + "explorerUrl": "https://explorer.sepolia.mantle.xyz", + "name": "ethereum-testnet-sepolia-mantle-1", + "nativeCurrencySymbol": "MNT", + "rpcEnv": "MANTLE_SEPOLIA_RPC_URL" +} diff --git a/config/chains/ethereum-testnet-sepolia.json b/config/chains/ethereum-testnet-sepolia.json new file mode 100644 index 0000000..e5e9f8c --- /dev/null +++ b/config/chains/ethereum-testnet-sepolia.json @@ -0,0 +1,28 @@ +{ + "ccip": { + "feeQuoter": "0x8632C3025FAFdD85A299211FD5838b5fBE2df816", + "feeTokens": [ + "0xc4bF5CbDaBE595361438F8c6a187bDc330539c60", + "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "0x097D90c9d3E0B50Ca60e1ae45F6A81010f9FB534" + ], + "link": "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "registryModuleOwnerCustom": "0xa3c796d480638d7476792230da1E2ADa86e031b0", + "rmnProxy": "0xba3f6251de62dED61Ff98590cB2fDf6871FbB991", + "router": "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59", + "tokenAdminRegistry": "0x95F29FEE11c5C55d26cCcf1DB6772DE953B37B82", + "tokenPoolFactory": "0x2067C0444F9dc58cFB33B095279A28886562f169" + }, + "ccipBnM": "0x9a97F119cFE1D5Ea77c264441C0A0aBC9B34E119", + "chainFamily": "evm", + "chainId": "11155111", + "chainNameIdentifier": "ETHEREUM_SEPOLIA", + "chainSelector": "16015286601757825753", + "confirmations": 2, + "displayName": "Ethereum Sepolia", + "environment": "testnet", + "explorerUrl": "https://sepolia.etherscan.io", + "name": "ethereum-testnet-sepolia", + "nativeCurrencySymbol": "ETH", + "rpcEnv": "ETHEREUM_SEPOLIA_RPC_URL" +} diff --git a/config/chains/ink-testnet-sepolia.json b/config/chains/ink-testnet-sepolia.json new file mode 100644 index 0000000..f70b2cb --- /dev/null +++ b/config/chains/ink-testnet-sepolia.json @@ -0,0 +1,27 @@ +{ + "ccip": { + "feeQuoter": "0xA6f3eff08557f757F4eDdCDC9EaC904f2B863Cd5", + "feeTokens": [ + "0x3423C922911956b1Ccbc2b5d4f38216a6f4299b4", + "0x4200000000000000000000000000000000000006" + ], + "link": "0x3423C922911956b1Ccbc2b5d4f38216a6f4299b4", + "registryModuleOwnerCustom": "0xaB018890bBdDf9B80E21d1c335c5f6acdbE0f5D6", + "rmnProxy": "0x84017cfddD12D319E5bBf090e0de6d55B78160Cb", + "router": "0x17fCda531D8E43B4e2a2A2492FBcd4507a1685A1", + "tokenAdminRegistry": "0x3A849a05a590FeaEf26c2d425241A2BF29307161", + "tokenPoolFactory": "0xfB7b57501AeA0789D312c5a062ebfAc016f8eb11" + }, + "ccipBnM": "0x414dbe1d58dd9BA7C84f7Fc0e4f82bc858675d37", + "chainFamily": "evm", + "chainId": "763373", + "chainNameIdentifier": "INK_SEPOLIA", + "chainSelector": "9763904284804119144", + "confirmations": 2, + "displayName": "Ink Sepolia", + "environment": "testnet", + "explorerUrl": "https://explorer-sepolia.inkonchain.com", + "name": "ink-testnet-sepolia", + "nativeCurrencySymbol": "ETH", + "rpcEnv": "INK_SEPOLIA_RPC_URL" +} diff --git a/config/chains/plume-testnet-sepolia.json b/config/chains/plume-testnet-sepolia.json new file mode 100644 index 0000000..d901e8a --- /dev/null +++ b/config/chains/plume-testnet-sepolia.json @@ -0,0 +1,27 @@ +{ + "ccip": { + "feeQuoter": "0x66bc24445e94FF302710E66Ce127E3174F723BD4", + "feeTokens": [ + "0xC1FD14775c8665B31c7154074f537338774351EB", + "0xB97e3665AEAF96BDD6b300B2e0C93C662104A068" + ], + "link": "0xB97e3665AEAF96BDD6b300B2e0C93C662104A068", + "registryModuleOwnerCustom": "0x693926456C8b210f56E29Bc5b4514B32A5224c88", + "rmnProxy": "0xAa3ae5481EE445711252131f1516922D0962916A", + "router": "0x5e5Fd4720E1CE826138D043aF578D69f48af502F", + "tokenAdminRegistry": "0x855cF0d18A0BeBEDA7c1CD2F943686120cCCC6bd", + "tokenPoolFactory": "0x0c43bb1A15A1FF1CbDdFF2B8F9518C1B0d3B17E2" + }, + "ccipBnM": "0x225fAc4130595d1C7dabbE61A8bA9B051440b76c", + "chainFamily": "evm", + "chainId": "98867", + "chainNameIdentifier": "PLUME_TESTNET", + "chainSelector": "13874588925447303949", + "confirmations": 2, + "displayName": "Plume Testnet", + "environment": "testnet", + "explorerUrl": "https://testnet-explorer.plume.org", + "name": "plume-testnet-sepolia", + "nativeCurrencySymbol": "PLUME", + "rpcEnv": "PLUME_TESTNET_RPC_URL" +} diff --git a/config/chains/solana-devnet.json b/config/chains/solana-devnet.json new file mode 100644 index 0000000..0eb441f --- /dev/null +++ b/config/chains/solana-devnet.json @@ -0,0 +1,24 @@ +{ + "ccip": { + "feeQuoter": "0x0000000000000000000000000000000000000000", + "feeTokens": [], + "link": "0x0000000000000000000000000000000000000000", + "registryModuleOwnerCustom": "0x0000000000000000000000000000000000000000", + "rmnProxy": "0x0000000000000000000000000000000000000000", + "router": "0x0000000000000000000000000000000000000000", + "tokenAdminRegistry": "0x0000000000000000000000000000000000000000", + "tokenPoolFactory": "0x0000000000000000000000000000000000000000" + }, + "ccipBnM": "0x0000000000000000000000000000000000000000", + "chainFamily": "svm", + "chainId": "0", + "chainNameIdentifier": "SOLANA_DEVNET", + "chainSelector": "16423721717087811551", + "confirmations": 0, + "displayName": "Solana Devnet", + "environment": "testnet", + "explorerUrl": "https://explorer.solana.com?cluster=devnet", + "name": "solana-devnet", + "nativeCurrencySymbol": "SOL", + "rpcEnv": "SOLANA_DEVNET_RPC_URL" +} diff --git a/docs/config-architecture.md b/docs/config-architecture.md new file mode 100644 index 0000000..0c57b10 --- /dev/null +++ b/docs/config-architecture.md @@ -0,0 +1,191 @@ +# Chain config architecture + +How the chain-config infrastructure is wired: the `make` command surface, and the layered design that +keeps `config/chains/.json` true to the live CCIP REST API. For the per-field schema, see +**[`config-schema.md`](config-schema.md)**. + +## Command reference + +Every target is a thin wrapper defined in the repo [`Makefile`](../Makefile); the raw `forge script` / +`bash` command each runs is the escape hatch (also shown in the [README](../README.md#configuration)). +`FOUNDRY_PROFILE=sync` (which enables `ffi` for the `curl`+`jq` API fetch) is set **inside** the recipes +that need it, never exported. Targets that touch the API need only `curl` + `jq` - no RPC URL, no keystore. + +| `make` target | Purpose | Args | Runs underneath | +| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------- | +| `help` (default) | List every target with its one-line purpose | - | `awk` over the `Makefile` | +| `tools` | Check `forge` / `curl` / `jq` are installed | - | `command -v` preflight | +| `discover` | List the CCIP API testnet catalog joined against local configs | `FILTER=` (optional) | `bash script/config/sync-discover.sh` | +| `add-chain` | Generate `config/chains/.json` from the live API, then sync | `CHAIN=` **+** `SELECTOR=` (both required) | `SyncCcipConfig.s.sol --sig "init(string,uint256)" ` → canonicalize | +| `sync` | Refresh one chain's API-served fields (`ccip{}` + identity/metadata) from the API | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "run(string)" ` → canonicalize | +| `sync-preview` | Fetch + log a chain's `ccip{}` from the API **without writing** | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "preview(string)" ` | +| `sync-all` | Refresh every configured chain (non-EVM SKIP; failures collected) | - | loops `--sig "run(string)"` over `config/chains/*.json` + canonicalize each | +| `sync-check` | Read-only drift check vs the live API (pass/fail via make) | `CHAIN=` (optional) | `bash script/config/sync-check.sh []` → `SyncCcipConfig --sig "check(string)"` | +| `doctor` | Layered single-chain verification (schema → API → RPC → on-chain) | `CHAIN=` (required) | `VerifyChain.s.sol --tc VerifyChain --sig "run(string)" ` | +| `fmt-config` | Rewrite `config/chains/*.json` in the canonical `jq --indent 2 -S` style | - | `jq` over every config file | + +`CHAIN=` is always the chain's **canonical CCIP selectorName** (the file basename; validated against the +API - see [`config-schema.md`](config-schema.md#the-file-name-is-the-canonical-ccip-selectorname)). + +**Which command when:** see the decision table in the +[README → Which command when](../README.md#which-command-when) (not duplicated here). In short: `discover` +to find a chain, `add-chain` to onboard it, `sync`/`sync-all` to apply the API's current values, +`sync-check` for the routine/CI drift verdict, and `doctor` for a deep single-chain health check. + +### Exit-code contract (drift check) + +The canonical `0` clean / `1` drift / `2` API-unreachable contract belongs to +`bash script/config/sync-check.sh`. GNU make remaps any failing recipe to its own exit `2`, so +`make sync-check` is **pass/fail only** - CI and automation call the script directly to tell drift +(actionable) from an API flake (retry later). A scheduled workflow (`.github/workflows/config-drift.yml`) +runs it weekly: drift fails visibly, an unreachable API only warns. + +## Architecture + +### 1. Layering & responsibility split + +The `make` UX front door delegates to Solidity scripts (all logic, guards, and chain access), which reach +the network only through a thin bash boundary (`curl`+`jq` over `ffi`). The `IConfigSource` interface is +the swap point: `CcipApiSource` is today's CCIP REST API v2 implementation, and a future API version is a +one-file change behind the same seam. + +```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#375BD2','primaryTextColor':'#FFFFFF','primaryBorderColor':'#1A2B6B','lineColor':'#375BD2','fontFamily':'Inter, system-ui, sans-serif'}}}%% +flowchart TD + U["Operator / CI"] --> M["make targets
discover · add-chain · sync · doctor"] + M --> S["Solidity scripts (logic + guards)
SyncCcipConfig.s.sol · VerifyChain.s.sol"] + S --> I{{"IConfigSource seam
CcipApiSource"}} + I -->|vm.ffi| B["bash boundary
ccip-config-source.sh · ccip-chain-meta.sh
curl + jq"] + B --> API["CCIP REST API v2
api.ccip.chain.link/v2/chains"] + S -->|writes API-served fields:
.ccip + identity/metadata| CFG[("config/chains/<selectorName>.json")] + + classDef make fill:#1A2B6B,color:#FFFFFF,stroke:#0B1636,stroke-width:1px; + classDef sol fill:#E8EDFB,color:#0B1636,stroke:#375BD2,stroke-width:1px; + classDef seam fill:#E8EDFB,color:#1A2B6B,stroke:#375BD2,stroke-width:2px; + classDef bash fill:#FFFFFF,color:#1A2B6B,stroke:#375BD2,stroke-width:1px; + classDef api fill:#375BD2,color:#FFFFFF,stroke:#1A2B6B,stroke-width:1px; + classDef store fill:#FFFFFF,color:#0B1636,stroke:#1A2B6B,stroke-width:1px; + class U,M make; + class S sol; + class I seam; + class B bash; + class API api; + class CFG store; +``` + +### 2. Sync data-flow + +`make sync` fetches the per-chain config, selects the single `isActive` entry per contract type, validates +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`, +`confirmations`, `ccipBnM`) 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'}}}%% +sequenceDiagram + participant M as make sync + participant S as SyncCcipConfig + participant B as ccip-config-source.sh + participant A as CCIP REST API v2 + M->>S: run(selectorName) + S->>B: fetch by chainSelector (vm.ffi) + B->>A: GET /chains/{selector} + A-->>B: chainConfig (versioned entries) + B-->>S: flat JSON (isActive per type + apiName + identity/metadata) + Note over S: _requireIdentity: chainId matches
_requireSelectorName: name == apiName + S->>S: vm.writeJson(...) - .ccip subtree
+ displayName/chainFamily/environment/
explorerUrl/nativeCurrencySymbol + S-->>M: wrote .ccip block + metadata + Note over M: canonicalize jq --indent 2 -S
no drift => ZERO git diff + Note over M,A: sync-check reuses this path read-only
exit 0 clean · 1 drift · 2 API-down +``` + +### 3. One-writer-per-field store model + +The **git-tracked** `config/chains/*.json` is a durable, versioned store; each field has exactly one writer, +so a git diff is an unambiguous audit artifact. **Everything the CCIP REST API serves is API-owned** - the +`ccip{}` addresses AND the identity/metadata fields (`displayName`, `chainFamily`, `environment`, +`explorerUrl`, `nativeCurrencySymbol`); only the keys the API serves nothing for (`chainNameIdentifier`, +`rpcEnv`, `confirmations`, `ccipBnM`) are hand-authored; the join keys (`name`/`chainSelector`/`chainId`) +are seeded once and guard-validated. (The `lanes` and `roles` subtrees below are the general model - +owner-policy and governance-written respectively - and are deferred to a follow-up PR.) + +**The deployed-address registry is separate and NOT git-tracked.** `addresses/.json` is +**gitignored** and local to the machine that ran the deploy (a fresh clone / CI has none), so its history is +not a git audit trail. Its integrity comes instead from a **single writer**: each deploy script makes ONE +call to `script/utils/DeploymentRecorder.s.sol` per artifact, and that one call writes **both** stores it +touches - the detailed `script/deployments/**` ledger (via `DeploymentUtils.save*`, format unchanged) **and** +the registry (`deployments[name]` + `active[role]`, via `RegistryWriter`). Because one writer owns both, the +ledger and the registry cannot drift. The registry is the only address store read back (by `HelperConfig` +resolution and the redeploy guard); the ledger is write-only history. `active.` records the +most-recently-deployed address, while the on-chain **TokenAdminRegistry** stays the authority for the wired +pool - `make doctor` reports any divergence as a WARN. + +```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#375BD2','primaryTextColor':'#FFFFFF','primaryBorderColor':'#1A2B6B','lineColor':'#375BD2','fontFamily':'Inter, system-ui, sans-serif'}}}%% +flowchart LR + API["CCIP REST API"] -->|sync writes| CCIP["ccip addresses
+ identity/metadata
(displayName, chainFamily,
environment, explorerUrl,
nativeCurrencySymbol)"] + HUMAN["Maintainer (reviewed PR)"] -->|hand edit| ID["hand keys
chainNameIdentifier, rpcEnv,
confirmations, ccipBnM"] + OWNER["Policy owner"] -. deferred .-> LANES["lanes - rate-limit policy"] + GOV["Governance"] -. deferred .-> ROLES["roles - privileged roles"] + CCIP --> DIFF["git diff = the audit log
(config/chains/*.json only)"] + ID --> DIFF + + subgraph GT["git-tracked config/chains/<selectorName>.json"] + CCIP + ID + end + + subgraph LOCAL["local-only, gitignored (per deploy machine)"] + REC["DeploymentRecorder
ONE call per artifact"] + LEDGER["script/deployments/**
timestamped ledger (write-only)"] + ADDR["addresses/<chainId>.json
active[role] + deployments[name]
(read back by HelperConfig + guard)"] + end + DEPLOY["Deploy scripts"] -->|broadcast| REC + REC -->|save*| LEDGER + REC -->|RegistryWriter| ADDR + TAR["on-chain TokenAdminRegistry
(authority for the WIRED pool)"] -. make doctor: WARN on divergence .-> ADDR + + classDef api fill:#375BD2,color:#FFFFFF,stroke:#1A2B6B,stroke-width:1px; + classDef writer fill:#1A2B6B,color:#FFFFFF,stroke:#0B1636,stroke-width:1px; + classDef subtree fill:#E8EDFB,color:#0B1636,stroke:#375BD2,stroke-width:1px; + classDef future fill:#FFFFFF,color:#375BD2,stroke:#375BD2,stroke-width:1px,stroke-dasharray:4 3; + classDef audit fill:#375BD2,color:#FFFFFF,stroke:#1A2B6B,stroke-width:2px; + class API,TAR api; + class HUMAN,DEPLOY,OWNER,GOV,REC writer; + class CCIP,ID,ADDR,LEDGER subtree; + class LANES,ROLES future; + class DIFF audit; +``` + +### 4. The selectorName join + +The config `name`, the CCIP REST API `name`, and the `chain-selectors` registry `name` are the **same +canonical selectorName** - the human-readable key tying the local store to the API and the source-of-truth +registry (the numeric `chainSelector` is the immutable machine join key they all also share). + +```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#375BD2','primaryTextColor':'#FFFFFF','primaryBorderColor':'#1A2B6B','lineColor':'#375BD2','fontFamily':'Inter, system-ui, sans-serif'}}}%% +flowchart LR + CFG["config/chains/<name>.json
.name = ethereum-testnet-sepolia"] + API["CCIP REST API
/v2/chains/<selector>.name"] + REG["chain-selectors registry
all_selectors.yml name"] + CFG ---|validated by sync + doctor| API + API ---|1:1 with registry| REG + CFG -->|joined by numeric chainSelector| API + + classDef store fill:#E8EDFB,color:#0B1636,stroke:#375BD2,stroke-width:1px; + classDef api fill:#375BD2,color:#FFFFFF,stroke:#1A2B6B,stroke-width:1px; + classDef reg fill:#1A2B6B,color:#FFFFFF,stroke:#0B1636,stroke-width:1px; + class CFG store; + class API api; + class REG reg; +``` + +## Related + +- **[`config-schema.md`](config-schema.md)** - the per-field reference for a chain config file. +- **[`deployed-addresses.md`](deployed-addresses.md)** - the two deployed-address stores (the append-only + `script/deployments/` history vs the machine-read `addresses/.json` registry), how one recorder + call emits both, the resolution ladder, and the doctor's TAR reconciliation. +- [README → Configuration](../README.md#configuration) · [README → Adding a New Chain](../README.md#adding-a-new-chain). diff --git a/docs/config-schema.md b/docs/config-schema.md new file mode 100644 index 0000000..b7e2518 --- /dev/null +++ b/docs/config-schema.md @@ -0,0 +1,236 @@ +# Chain config schema (`config/chains/.json`) + +This repo treats **CCIP chain metadata as DATA, not code**. Every chain selector, router, and CCIP +infra address the scripts need is read from `config/chains/.json` at build/test time via +`vm.parseJson*` (`src/config/ChainConfig.sol`). There are **no hardcoded selectors or CCIP addresses** +in Solidity: `HelperConfig` discovers the chain list by scanning the directory (`vm.readDir`), so adding +a chain - or updating a CCIP address - is a reviewed config edit with zero Solidity changes. + +The operational how-to (discover → add-chain → sync → doctor, and the "which command when" table) lives +in the [README](../README.md#configuration); the `make`-command reference and the layered architecture +(with diagrams) are in **[`config-architecture.md`](config-architecture.md)**. This document is the +**field-by-field reference**. + +## The file name IS the canonical CCIP selectorName + +Each file is named by, and carries a `name` field equal to, the **canonical CCIP selectorName** from the +[`chain-selectors`](https://github.com/smartcontractkit/chain-selectors) registry - the one identifier +the CCIP REST API (`GET /v2/chains/{selector}` → `.name`), CLD, Atlas, the directory URL leaf, and +`ccip-cli` all key on. So the files are `ethereum-testnet-sepolia.json`, +`ethereum-testnet-sepolia-mantle-1.json`, `0g-testnet-galileo-1.json`, `plume-testnet-sepolia.json`, +`ink-testnet-sepolia.json`, and `solana-devnet.json` - **not** bespoke short slugs like `ethereum-sepolia`. + +`name` is the value you pass as `CHAIN=` to every `make` target. The sync **validates** it: after each API +fetch it asserts the config `name` equals the API selectorName and reverts `SELECTOR NAME MISMATCH` +otherwise (a sibling to the numeric `SELECTOR MISMATCH` chainId guard). `make add-chain` refuses any +`CHAIN=` that is not the canonical selectorName. This is the portable identity key for **non-EVM** chains +too, whose `chainId` is a placeholder `"0"` the chainId guard cannot verify (see below). + +## One writer per subtree + +The **git-tracked** `config/chains/.json` is a durable, versioned store; each part has a single +writer so a git diff is an unambiguous audit log. (The `addresses/.json` row below is a **separate, +gitignored** file — its single-writer integrity comes from the deploy-time recorder, not from git history; +see [its section](#the-deployed-address-registry---addresseschainidjson-schema-v2).) + +| Subtree / field group | Owner | Sole writer | +| ------------------------------------------------ | -------------------- | ------------------------------------------------------------ | +| `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 | +| hand-authored keys the API serves nothing for (`chainNameIdentifier`, `rpcEnv`, `confirmations`, `ccipBnM`) | repo maintainers | a **reviewed hand edit** in a pull request | +| immutable join keys (`name`, `chainSelector`, `chainId`) | the chain-selectors registry | seeded at `add-chain`, then **guard-validated** by the sync (never rewritten) | +| `addresses/.json` (separate, **gitignored**) | the deployer | the **deploy scripts** — one `DeploymentRecorder` call → ledger + `RegistryWriter`, on `--broadcast` | + +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 every hand-authored key +(`chainNameIdentifier`, `rpcEnv`, `confirmations`, `ccipBnM`) is preserved untouched, and the join keys +are validated, not overwritten. **The rule of thumb: if a field exists on `GET /v2/chains/{selector}`, the +sync sources it from the API; you never hand-type it.** The general config-as-data model also has a +**lanes** subtree (per-lane rate-limit policy, owner-written) and a **roles** subtree (the privileged-role +surface, governance-written); those are **not present in this repo yet** - scripted lane wiring is deferred +to a follow-up PR - but the one-writer principle is the same. + +## EVM chain file - every field + +Example (`config/chains/ethereum-testnet-sepolia.json`), grouped by writer: + +```jsonc +{ + // ── join keys (seeded at add-chain, then guard-validated by the sync; never rewritten) ─── + "name": "ethereum-testnet-sepolia", // canonical CCIP selectorName; == file basename; the CHAIN= arg + "chainId": "11155111", // native chain id, quoted STRING (see big-int note); "0" for non-EVM + "chainSelector": "16015286601757825753", // uint64 CCIP selector, quoted STRING; the primary join key + + // ── API-synced identity + metadata (sourced from GET /v2/chains/{selector}; never hand-edit) ── + "displayName": "Ethereum Sepolia", // <- chain.displayName; human label for logs / output links + "chainFamily": "evm", // <- chain.chainFamily (lowercased); dispatches EVM vs non-EVM + "environment": "testnet", // <- chain.environment ("testnet" | "mainnet") + "explorerUrl": "https://sepolia.etherscan.io", // <- chainMetadata.explorer.url; output/verification links + "nativeCurrencySymbol": "ETH", // <- chainMetadata.nativeCurrency.symbol; native gas-token symbol + + // ── ccip{} : API-synced (overwritten by the sync; never hand-edit) ─────────── + "ccip": { + "router": "0x0BF3dE8c...", // CCIP Router - entrypoint for ccipSend / offRamp + "rmnProxy": "0xba3f6251...", // RMN (Risk Management Network) proxy / ARMProxy + "tokenAdminRegistry": "0x95F29FEE...", // TokenAdminRegistry - maps token → its pool + admin + "registryModuleOwnerCustom": "0xa3c796d4...", // RegistryModuleOwnerCustom - claim-admin registry module + "feeQuoter": "0x8632C302...", // FeeQuoter - quotes CCIP fees (reference) + "tokenPoolFactory": "0x2067C044...", // TokenPoolFactory - deploys standard pools (reference) + "link": "0x779877A7...", // LINK token (the LINK fee token on this chain) + "feeTokens": ["0xc4bF5CbD...", "0x779877A7...", "0x097D90c9..."] // accepted CCIP fee tokens (reference) + }, + + // ── hand-authored (the API serves nothing for these; reviewed in a PR, preserved by sync) ── + "chainNameIdentifier": "ETHEREUM_SEPOLIA", // UPPER_SNAKE env-var prefix: _RPC_URL, _TOKEN, _TOKEN_POOL + "rpcEnv": "ETHEREUM_SEPOLIA_RPC_URL", // name of the env var holding this chain's RPC URL + "confirmations": 2, // block confirmations the scripts wait for (operator choice) + "ccipBnM": "0x9a97F119..." // optional CCIP-BnM test token (0x0 / omitted when unused) +} +``` + +### Field reference + +"Written by" values: **API sync** = sourced + refreshed + drift-checked from the CCIP REST API; +**API sync (guard)** = seeded at `add-chain` then validated (not rewritten) every sync; **hand** = the +API serves nothing for it, so a reviewed PR owns it and the sync preserves it verbatim. + +| Field | Type / format | Written by | API source (if any) | Consumed by | +| ---------------------------- | -------------------------------- | --------------------- | ---------------------------------------------- | ---------------------------------------------- | +| `name` | string (canonical selectorName) | **API sync (guard)** | `chain.name` | file key + basename; validated by the sync | +| `chainId` | quoted decimal string (`"0"` non-EVM) | **API sync (guard)** | `chain.chainId` (EVM; `"0"` placeholder non-EVM) | `ChainConfig.chainId`; sync identity guard | +| `chainSelector` | quoted `uint64` string | **API sync (guard)** | `chain.chainSelector` | `ChainConfig.load`; the primary join key | +| `displayName` | string | **API sync** | `chain.displayName` | `ChainConfig.load` → `chainName`; log output | +| `chainFamily` | `"evm"` \| `"svm"` | **API sync** | `chain.chainFamily` (lowercased) | `ChainConfig.load`; EVM/non-EVM dispatch | +| `environment` | `"testnet"` \| `"mainnet"` | **API sync** | `chain.environment` | provenance | +| `explorerUrl` | URL string | **API sync** | `chainMetadata.explorer.url` | `ChainConfig.load`; output/verification links | +| `nativeCurrencySymbol` | string | **API sync** | `chainMetadata.nativeCurrency.symbol` | `ChainConfig.load` | +| `ccip.router` | address | **API sync** | `chainConfig.router` (active) | `ChainConfig.load` → `router` | +| `ccip.rmnProxy` | address | **API sync** | `chainConfig.rmn` (active) | `ChainConfig.load` → `rmnProxy` | +| `ccip.tokenAdminRegistry` | address | **API sync** | `chainConfig.tokenAdminRegistry` (active) | `ChainConfig.load` | +| `ccip.registryModuleOwnerCustom` | address | **API sync** | `chainConfig.registryModule` (active) | `ChainConfig.load` | +| `ccip.feeQuoter` | address | **API sync** | `chainConfig.feeQuoter` (active) | reference (drift-checked) | +| `ccip.tokenPoolFactory` | address | **API sync** | `chainConfig.tokenPoolFactory` (active) | reference (drift-checked) | +| `ccip.link` | address | **API sync** | `chainConfig.feeTokens[symbol==LINK]` | `ChainConfig.load` → `link` | +| `ccip.feeTokens` | address[] | **API sync** | `chainConfig.feeTokens[].tokenAddress` | reference (drift-checked) | +| `chainNameIdentifier` | UPPER_SNAKE string | hand | — (not in the API) | `ChainConfig.load`; the `_*` env prefix | +| `rpcEnv` | env-var name string | hand | — (not in the API) | fork setup; the doctor's RPC rung | +| `confirmations` | number | hand | — (`chainConfig.finality`/`blockTime` are `null`) | `ChainConfig.load` | +| `ccipBnM` | address (`0x0` / omitted = none) | hand | — (no authoritative CCIP token API) | `ChainConfig.load` | + +> **`chainNameIdentifier`/`rpcEnv` are DERIVED for newly added chains.** `make add-chain` seeds +> `chainNameIdentifier` as UPPER_SNAKE of the selectorName (e.g. `avalanche-testnet-fuji` → +> `AVALANCHE_TESTNET_FUJI`) and `rpcEnv` as `_RPC_URL`, so a fresh chain's names +> may differ in style from the six bundled chains' hand-curated SHORT forms (`ETHEREUM_SEPOLIA`, not +> `ETHEREUM_TESTNET_SEPOLIA`). You cannot always guess them — so `add-chain` **prints the exact +> `chainNameIdentifier` and `rpcEnv` it generated** in its next-steps output. Override at generation +> time with the `CHAIN_NAME_IDENTIFIER` / `RPC_ENV` env vars; these keys are hand-authored thereafter +> (the sync never rewrites them). + +> **Big integers are quoted STRINGS.** `chainSelector` (uint64) and `chainId` exceed JSON's safe integer +> range (2^53), so they are stored as quoted decimals and read with `vm.parseJsonUint`, which parses +> quoted decimals. Never store them as bare JSON numbers - precision is silently lost. + +> **Targeted key reads, not whole-struct decode.** `ChainConfig` reads by path (`.ccip.router`, +> `.chainSelector`, …), so it is order-independent and robust to the alphabetical key reordering that +> `vm.writeJson` and the canonical `jq --indent 2 -S` format perform. + +## Non-EVM (Solana) chain file + +Non-EVM chains are supported as a **destination** only (to register a non-EVM pool as a remote on an EVM +source). `config/chains/solana-devnet.json` keeps the same shape but: + +- `chainFamily` is `"svm"`, and `chainId` is the placeholder `"0"` - Solana has no EVM chain id. **The + chainId identity guard cannot fire here**, so the `name` (selectorName) is the only validatable + identity; the sync/doctor selectorName guard is what protects a non-EVM file from a wrong selector. +- The `ccip{}` block is **all-zero** and `feeTokens` is empty: non-EVM chains have no EVM-shaped + `chainConfig`, so they are excluded from the API **address** sync (the sync SKIPs the `ccip{}` + transform cleanly, and `ccipBnM` stays `0x0`). +- **The chain-level identity + metadata ARE served for non-EVM and ARE synced.** `GET /v2/chains/{selector}` + returns `chainMetadata{explorer,nativeCurrency}` for every family, so `displayName`, `chainFamily`, + `environment`, `explorerUrl`, and `nativeCurrencySymbol` are sourced/refreshed from the API on Solana too + (e.g. `explorerUrl` = `https://explorer.solana.com?cluster=devnet`, `nativeCurrencySymbol` = `SOL`). Only + the EVM-shaped `ccip{}` addresses are skipped. + +```jsonc +{ + "name": "solana-devnet", // canonical selectorName (already canonical; unchanged) + "displayName": "Solana Devnet", // <- chain.displayName (API-synced) + "chainNameIdentifier": "SOLANA_DEVNET", // hand + "chainFamily": "svm", // <- chain.chainFamily (API-synced) + "environment": "testnet", // <- chain.environment (API-synced) + "chainId": "0", // placeholder - non-EVM; selectorName is the portable identity + "chainSelector": "16423721717087811551", + "rpcEnv": "SOLANA_DEVNET_RPC_URL", // hand + "ccip": { "router": "0x00...00", "rmnProxy": "0x00...00", "tokenAdminRegistry": "0x00...00", + "registryModuleOwnerCustom": "0x00...00", "feeQuoter": "0x00...00", + "tokenPoolFactory": "0x00...00", "link": "0x00...00", "feeTokens": [] }, + "confirmations": 0, // hand (operator choice) + "explorerUrl": "https://explorer.solana.com?cluster=devnet", // <- chainMetadata.explorer.url (API-synced) + "nativeCurrencySymbol": "SOL", // <- chainMetadata.nativeCurrency.symbol (API-synced) + "ccipBnM": "0x00...00" // hand (optional) +} +``` + +## The deployed-address registry - `addresses/.json` (schema v2) + +A complementary, separate store, keyed by numeric `chainId` (not selectorName), user-specific and +**gitignored** (only the committed `addresses/11155111.example.json` shows the shape). Unlike the git-tracked +`config/chains/*.json` above, a per-chain registry file is **local to the machine that ran the deploy** - a +fresh clone or a CI job has none. It is governed by the deploy scripts alone; the API sync never touches it. + +### Two sub-stores: `active` role pointers + named `deployments` + +```jsonc +{ + "active": { // <- what HelperConfig resolves (zero-export) + "token": "0xToken", + "tokenPool": "0xPoolV2", // most-recently-deployed pool for the chain + "lockBox": "0xLockBox", + "poolHooks": "0xHooks" + }, + "deployments": { // <- uniquely named per artifact (type + version in the key) + "BnM-T_Token": "0xToken", + "BnM-T_BurnMintTokenPool_2.0.0": "0xPoolV2", // key carries the pool type + version + "BnM-T_LockBox": "0xLockBox", + "BnM-T_BurnMint_PoolHooks": "0xHooks" + } +} +``` + +- **`active.`** is the single slot `HelperConfig` resolves for each of the four roles + (`token`/`tokenPool`/`lockBox`/`poolHooks`) - the zero-export default. `read(chainId, role)` resolves + `.active.`, with a legacy fallback to a flat pre-v2 top-level `.` so any older local file keeps + working. Environment variables still override the registry (see the [README](../README.md#deployed-address-registry--addresseschainidjson-the-default) precedence ladder). + > **`active` is what this repo last deployed - NOT proof of what is wired.** The on-chain + > **TokenAdminRegistry** (`getPool(token)`) is the authority for the pool CCIP actually routes through. + > They legitimately diverge whenever the wired pool was changed out-of-band (e.g. a `setPool` cut from a + > Safe). `make doctor` reads the TAR and reports any divergence as a **WARN** (never a FAIL). + > **Single-valued limit:** `active.` holds exactly one address per role. Deploy two pools for the + > same symbol on one chain and `active.tokenPool` points at the LAST one deployed; the zero-export getters + > (`getDeployedTokenPool`/`LockBox`/`PoolHooks` read only `active`) then resolve that same last pool for + > both tokens. To address a specific earlier artifact, pass it explicitly via env or read its + > `deployments.` entry. +- **`deployments.`** is the uniquely-named archive: the key carries the pool's **type and version**, so + distinct artifacts never collide or clobber each other in storage. Note this is a **storage** property, not + a resolution one - the zero-export ladder resolves only `active`, which is single-valued (above). + +### Per-artifact keying + +| Artifact | `deployments` key | `active` role | Why | +| ----------- | ----------------------------------------- | ------------- | --- | +| `token` | `{symbol}_Token` | `token` | one token per symbol | +| `tokenPool` | `{symbol}_{poolType}TokenPool_{version}` | `tokenPool` | type + version in the key so artifacts never collide | +| `lockBox` | `{symbol}_LockBox` | `lockBox` | one lockbox per lock-release token | +| `poolHooks` | `{symbol}_{poolType}_PoolHooks` | `poolHooks` | hooks belong to a pool, not a chain | + +### One writer per artifact (the recorder) + +Each deploy script makes **one** call to `script/utils/DeploymentRecorder.s.sol` per artifact. That single +call (a) emits the detailed timestamped ledger file via `DeploymentUtils.save*` (format unchanged) **and** +(b) upserts `deployments[name]` + `active[role]` via `RegistryWriter` - the two stores can no longer drift, +because one writer owns both. The redeploy guard keys on the unique `deployments` name: re-deploying the +*same* name is refused unless `FORCE_REDEPLOY=true` (which drops the stale entry and clears its `active` +pointer, then records the replacement), while a new *version* deploys freely. The registry is the **only** +address store read back (by `HelperConfig` resolution and the guard); the `script/deployments/**` ledger is +write-only history. Resolution precedence and the guard are documented in the +[README](../README.md#deployed-address-registry--addresseschainidjson-the-default). diff --git a/docs/deployed-addresses.md b/docs/deployed-addresses.md new file mode 100644 index 0000000..8eeda98 --- /dev/null +++ b/docs/deployed-addresses.md @@ -0,0 +1,139 @@ +# Deployed-address stores + +Every deploy in this repo records its output in **two files**. They are not two sources of truth - they are +**two views of a single write**. Understanding which store answers which question keeps you from trusting the +wrong one. + +- **History** - `script/deployments///--.json` + An append-only, timestamped log. One file per deploy, forever. **Write-only: nothing in this repo reads it + back.** It exists as a human-readable deploy diary. +- **Registry** - `addresses/.json` + The current-state, machine-read store. This is the **only** store that resolution + (`HelperConfig.getDeployed*`), the redeploy guard (`RegistryWriter.guardRedeploy`), and the doctor + (`make doctor` / `VerifyChain`) consult. + +| Question | Store that answers it | +| --- | --- | +| "What is the current token/pool/lockbox/hooks address for this chain?" | Registry (`addresses/.json` → `active.`) | +| "Does an artifact with this exact type+version key already exist?" (redeploy guard) | Registry (`deployments.`) | +| "Is the registry pool the one CCIP actually routes through?" | `make doctor` reads the on-chain **TokenAdminRegistry**, not either file | +| "When did each deploy happen, in order?" | History (`script/deployments/…`) | +| "What did later scripts resolve with zero `export`?" | Registry only - the history is never read | + +Both stores are **gitignored** (`.gitignore`: `script/deployments/` and `addresses/*.json`, with a single +committed sample `addresses/11155111.example.json`). Only `config/chains/*.json` is git-tracked, so **only the +chain config has a git audit trail** - the deployed-address stores do not. + +## One recorder call emits both (the anti-drift property) + +Each deploy script makes **one** call to `script/utils/DeploymentRecorder.s.sol` per artifact. That single +call writes the history file (via `DeploymentUtils.save*`) **and** upserts the registry (via +`RegistryWriter.recordDeterministic`, which sets the `deployments.` entry and the `active.` +pointer in one file write). Because both stores flow from the same call, they cannot drift apart. + +```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#375BD2','primaryTextColor':'#FFFFFF','primaryBorderColor':'#1A2B6B','lineColor':'#375BD2','fontFamily':'Inter, system-ui, sans-serif'}}}%% +flowchart LR + D["Deploy script
(--broadcast)"]:::sol + R["DeploymentRecorder
(one call per artifact)"]:::seam + H["History
script/deployments/…
(append-only, write-only)"]:::store + G["Registry
addresses/<chainId>.json
(active + deployments)"]:::store + D --> R + R -->|"DeploymentUtils.save*"| H + R -->|"RegistryWriter.recordDeterministic
(deployments.name + active.role, one write)"| G + classDef sol fill:#E8EDFB,color:#0B1636,stroke:#375BD2,stroke-width:1px; + classDef seam fill:#E8EDFB,color:#1A2B6B,stroke:#375BD2,stroke-width:2px; + classDef store fill:#FFFFFF,color:#0B1636,stroke:#1A2B6B,stroke-width:1px; +``` + +## Registry schema v2: `active` vs `deployments` + +```jsonc +{ + "active": { // single per-role pointer HelperConfig resolves (zero-export) + "token": "0xToken", + "tokenPool": "0xPoolV2", // the most-recently-deployed pool for this chain + "lockBox": "0xLockBox", + "poolHooks": "0xHooks" + }, + "deployments": { // uniquely named per artifact; the key carries type + version + "BnM-T_Token": "0xToken", + "BnM-T_BurnMintTokenPool_2.0.0": "0xPoolV2", + "BnM-T_LockBox": "0xLockBox", + "BnM-T_BurnMint_PoolHooks": "0xHooks" + } +} +``` + +Per-artifact keys (`DeploymentRecorder`): + +| Artifact | `deployments` key | `active` role | +| --- | --- | --- | +| Token | `{symbol}_Token` | `token` | +| Token pool | `{symbol}_{poolType}TokenPool_{version}` | `tokenPool` | +| LockBox | `{symbol}_LockBox` | `lockBox` | +| Pool hooks | `{symbol}_{poolType}_PoolHooks` | `poolHooks` | + +The pool key includes the pool's **type and version** purely so distinct artifacts never collide in storage. +This is a mechanical keying property, not a migration workflow: the deploy scripts pin the version +(`DeploymentRecorder.POOL_VERSION` = `"2.0.0"`), so `poolName()` only ever emits the `_2.0.0` key. Deploying +the same symbol + pool type again produces the same key, which trips the guard. + +## The redeploy guard and `FORCE_REDEPLOY` + +Before a deploy, `RegistryWriter.guardRedeploy` checks whether the `deployments.` key already resolves +to a non-zero address. If it does, the script **refuses to run** and prints the registered address. Set +`FORCE_REDEPLOY=true` to deploy a replacement of the same name: the stale `deployments` entry (and any +`active` pointer at that address) is dropped, and the new deploy records the replacement. The prior address +**stays in the append-only history ledger** under `script/deployments/`. It does **not** stay in git history - +the registry is gitignored. + +## Resolution ladder (per role) + +`HelperConfig.getDeployed{Token,TokenPool,LockBox,PoolHooks}` resolves each role in this order: + +1. **Inline alias** - `TOKEN` / `TOKEN_POOL` / `LOCK_BOX` / `POOL_HOOKS` (chain-agnostic, highest priority) +2. **Chain-scoped env** - `{CHAIN}_TOKEN`, `{CHAIN}_LOCK_BOX`, … (e.g. `ETHEREUM_SEPOLIA_LOCK_BOX`) +3. **Registry** - `addresses/.json` → `active.` +4. Otherwise `address(0)` + +**Single-valued limit (be honest about it).** `active.` holds exactly one address per role, and the +zero-export getters read only `active` (never `deployments`). Deploy two tokens/pools for the same chain and +`active.tokenPool` points at the **last** one deployed; the zero-export path then resolves that same pool for +both tokens. This is a limit of the *resolution* layer, not the *storage* layer - both artifacts are still +distinct entries under `deployments`. To target the earlier one, pass it explicitly via an inline alias or a +`{CHAIN}_` env var, or read its `deployments.` entry. + +## The doctor's TAR reconciliation rung + +`make doctor CHAIN=` (`VerifyChain`) reconciles the registry's `active.tokenPool` against the pool +actually wired in the on-chain **TokenAdminRegistry** (`getPool(token)`): + +- **PASS** when the registry pool == the wired pool. +- **WARN** (never FAIL) when they diverge - the wired pool was changed out-of-band, or the registry pointer is + stale. +- **WARN** when the token has no pool registered in the TAR. + +It is always a WARN because the registry is *local bookkeeping of what this repo deployed*, while the TAR is +*what CCIP routes through*; they can legitimately differ. + +## Authority boundary + +The on-chain **TokenAdminRegistry is the source of truth** for what is wired. The `addresses/.json` +registry is local bookkeeping - "what this repo deployed most recently" - not an authority for what CCIP uses. +Because both deployed-address stores are gitignored, neither carries a git audit trail; only `config/chains/*.json` +(git-tracked) does. When in doubt about wiring, read the TAR (or run `make doctor`), never the registry file. + +## One format note (unchanged by this work) + +The hooks **history** filename carries no symbol or pool type - it is +`script/deployments/advanced-pool-hooks//-AdvancedPoolHooks.json`. The hooks **registry** key +is finer-grained (`{symbol}_{poolType}_PoolHooks`). This asymmetry is pre-existing; we did not change the +history file format. + +## Related + +- [`config-schema.md`](config-schema.md) - the registry schema in the wider config-file reference. +- [`config-architecture.md`](config-architecture.md) - the one-writer-per-field store model and the sync tooling. +- [README → Deployed-address registry](../README.md#deployed-address-registry--addresseschainidjson-the-default) + and [README → Sharing addresses with your team](../README.md#sharing-addresses-with-your-team). diff --git a/foundry.toml b/foundry.toml index e9d4952..ae1439f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -20,6 +20,31 @@ remappings = [ fs_permissions = [{ access = "read-write", path = "./" }] +# The `sync` profile runs the chain-config tooling (script/config/*.s.sol): it needs `ffi` to fetch +# the live CCIP API via curl + jq (Foundry cannot HTTP-GET directly). Named profiles do not inherit +# from [profile.default], so the build settings are repeated here. +# FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig "run(string)" ethereum-sepolia +[profile.sync] +src = "src" +out = "out" +libs = ["node_modules"] +solc_version = "0.8.24" +evm_version = "paris" +optimizer = true +optimizer_runs = 200 +ffi = true +remappings = [ + "forge-std/=node_modules/@chainlink/contracts/src/v0.8/vendor/forge-std/src/", + "@chainlink/contracts-ccip/contracts/=node_modules/@chainlink/contracts-ccip/contracts/", + "@chainlink/contracts@1.4.0/=node_modules/@chainlink/contracts/", + "@chainlink/contracts/=node_modules/@chainlink/contracts/", + "@chainlink/policy-management/=node_modules/@chainlink/ace/packages/policy-management/src/", + "@openzeppelin/contracts@4.8.3/=node_modules/@openzeppelin/contracts-4.8.3/", + "@openzeppelin/contracts@5.3.0/=node_modules/@openzeppelin/contracts-5.3.0/", + "@openzeppelin/contracts/=node_modules/@openzeppelin/contracts-5.3.0/" +] +fs_permissions = [{ access = "read-write", path = "./" }] + [lint] exclude_lints = [ "mixed-case-variable", diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 2d6bbb9..9f1241a 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -2,7 +2,17 @@ pragma solidity 0.8.24; import {Script} from "forge-std/Script.sol"; +import {ChainConfig} from "../src/config/ChainConfig.sol"; +import {RegistryWriter} from "../src/utils/RegistryWriter.sol"; +/// @notice Network configuration helper. Chain metadata (selectors, CCIP addresses, chain labels) +/// lives in git-tracked JSON files under `config/chains/` and is read through `ChainConfig` — +/// supporting a new chain is a config edit, not a Solidity change: the chain LIST itself is +/// discovered by scanning `config/chains/*.json` once at construction into a storage cache (the +/// per-chain constants and getters below are kept as fast paths / back-compat API; every lookup +/// falls back to the discovered-chain cache for chains added after they were written). Deployed contract addresses resolve from environment variables +/// first, then from the `addresses/.json` registry written by the deploy scripts (see +/// `RegistryWriter`). contract HelperConfig is Script { struct NetworkConfig { uint64 chainSelector; @@ -30,17 +40,34 @@ contract HelperConfig is Script { // Deployed contract addresses mapping(uint256 => address) public deployedTokens; mapping(uint256 => address) public deployedTokenPools; + mapping(uint256 => address) public deployedLockBoxes; + mapping(uint256 => address) public deployedPoolHooks; + + // Discovered-chain cache: `config/chains/*.json` is scanned ONCE at construction and every + // record is kept in storage — the scan fallbacks below never touch the filesystem again. + string[] private s_configuredChains; // config names (file basenames), same order as s_chains + NetworkConfig[] private s_chains; + uint256[] private s_chainIds; // declared chainId per entry (0 for non-EVM chains) constructor() { - // Initialize deployed contracts from environment variables - _initializeDeployedContracts(ETHEREUM_SEPOLIA_CHAIN_ID); - _initializeDeployedContracts(ZERO_G_TESTNET_CHAIN_ID); - _initializeDeployedContracts(PLUME_TESTNET_CHAIN_ID); - _initializeDeployedContracts(INK_SEPOLIA_CHAIN_ID); - _initializeDeployedContracts(MANTLE_SEPOLIA_CHAIN_ID); + // Discover every configured chain from `config/chains/*.json` — a chain added by + // `make add-chain` is picked up automatically, no Solidity change — then initialize + // deployed contracts (env vars / the address registry) for each EVM chain. + string[] memory chains = ChainConfig.names(); + for (uint256 i = 0; i < chains.length; i++) { + (bool ok, ChainConfig.Chain memory c, uint256 chainId) = ChainConfig.tryLoad(chains[i]); + if (!ok) continue; // deleted between the directory scan and the read (parallel tests) + s_configuredChains.push(chains[i]); + s_chains.push(_toNetworkConfig(c)); + s_chainIds.push(chainId); + if (chainId != 0) { + // non-EVM chains (chainId 0, e.g. Solana) are destination-only: nothing to resolve + _initializeDeployedContracts(chainId, c.chainNameIdentifier); + } + } } - /// @dev Helper to initialize deployed contract addresses from environment variables. + /// @dev Helper to initialize deployed contract addresses. /// /// Resolution order (highest priority first): /// 1. Inline short alias — `TOKEN` / `TOKEN_POOL` @@ -48,119 +75,92 @@ contract HelperConfig is Script { /// `TOKEN=0x... TOKEN_POOL=0x... forge script ...` /// 2. Chain-specific var — `{CHAIN}_TOKEN` / `{CHAIN}_TOKEN_POOL` /// Set once per session: `export ETHEREUM_SEPOLIA_TOKEN=0x...` - function _initializeDeployedContracts(uint256 chainId) private { - string memory chainNameId = getNetworkConfig(chainId).chainNameIdentifier; - + /// 3. Address registry — `addresses/.json`, written automatically by the deploy + /// scripts (`token` / `tokenPool` entries). This is the default: after a deploy, later + /// scripts resolve the address with no environment variable at all. + function _initializeDeployedContracts(uint256 chainId, string memory chainNameId) private { // Initialize TOKEN contract — inline TOKEN alias takes priority - address tokenFromAlias = vm.envOr("TOKEN", address(0)); - if (tokenFromAlias != address(0)) { - deployedTokens[chainId] = tokenFromAlias; - } else { - deployedTokens[chainId] = vm.envOr(string.concat(chainNameId, "_TOKEN"), address(0)); + address token = vm.envOr("TOKEN", address(0)); + if (token == address(0)) { + token = vm.envOr(string.concat(chainNameId, "_TOKEN"), address(0)); } + if (token == address(0)) { + token = RegistryWriter.read(chainId, "token"); + } + deployedTokens[chainId] = token; // Initialize TOKEN_POOL contract — inline TOKEN_POOL alias takes priority - address tokenPoolFromAlias = vm.envOr("TOKEN_POOL", address(0)); - if (tokenPoolFromAlias != address(0)) { - deployedTokenPools[chainId] = tokenPoolFromAlias; - } else { - deployedTokenPools[chainId] = vm.envOr(string.concat(chainNameId, "_TOKEN_POOL"), address(0)); + address tokenPool = vm.envOr("TOKEN_POOL", address(0)); + if (tokenPool == address(0)) { + tokenPool = vm.envOr(string.concat(chainNameId, "_TOKEN_POOL"), address(0)); + } + if (tokenPool == address(0)) { + tokenPool = RegistryWriter.read(chainId, "tokenPool"); + } + deployedTokenPools[chainId] = tokenPool; + + // Initialize LOCK_BOX — inline LOCK_BOX alias > {CHAIN}_LOCK_BOX > registry active.lockBox + address lockBox = vm.envOr("LOCK_BOX", address(0)); + if (lockBox == address(0)) { + lockBox = vm.envOr(string.concat(chainNameId, "_LOCK_BOX"), address(0)); + } + if (lockBox == address(0)) { + lockBox = RegistryWriter.read(chainId, "lockBox"); + } + deployedLockBoxes[chainId] = lockBox; + + // Initialize POOL_HOOKS — inline POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks + address poolHooks = vm.envOr("POOL_HOOKS", address(0)); + if (poolHooks == address(0)) { + poolHooks = vm.envOr(string.concat(chainNameId, "_POOL_HOOKS"), address(0)); + } + if (poolHooks == address(0)) { + poolHooks = RegistryWriter.read(chainId, "poolHooks"); } + deployedPoolHooks[chainId] = poolHooks; } - function getEthereumSepoliaConfig() public pure returns (NetworkConfig memory) { - NetworkConfig memory ethereumSepoliaConfig = NetworkConfig({ - chainSelector: 16015286601757825753, - router: 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59, - rmnProxy: 0xba3f6251de62dED61Ff98590cB2fDf6871FbB991, - tokenAdminRegistry: 0x95F29FEE11c5C55d26cCcf1DB6772DE953B37B82, - registryModuleOwnerCustom: 0xa3c796d480638d7476792230da1E2ADa86e031b0, - link: 0x779877A7B0D9E8603169DdbD7836e478b4624789, - ccipBnM: 0x9a97F119cFE1D5Ea77c264441C0A0aBC9B34E119, - confirmations: 2, - chainName: "Ethereum Sepolia", - chainNameIdentifier: "ETHEREUM_SEPOLIA", - explorerUrl: "https://sepolia.etherscan.io", - nativeCurrencySymbol: "ETH", - chainFamily: "evm" - }); - return ethereumSepoliaConfig; + /// @dev Maps a NetworkConfig from a `config/chains/.json` record. + function _load(string memory configName) private view returns (NetworkConfig memory) { + return _toNetworkConfig(ChainConfig.load(configName)); } - function getZeroGTestnetConfig() public pure returns (NetworkConfig memory) { - NetworkConfig memory zeroGTestnetConfig = NetworkConfig({ - chainSelector: 6892437333620424805, - router: 0xD610B8f58689de7755947C05342A2DFaC30ebD57, - rmnProxy: 0x995ab3eC29E1660A93cFddAA19C710A1b5afCCc9, - tokenAdminRegistry: 0x23a5084Fa78104F3DF11C63Ae59fcac4f6AD9DeE, - registryModuleOwnerCustom: 0x0820f975ce90EE5c508657F0C58b71D1fcc85cE0, - link: 0xe5e3a4fF1773d043a387b16Ceb3c91cC49bAFD54, - ccipBnM: 0xDbB255D37BC7c9e2b08e5a1C9f9506c9E85F1644, - confirmations: 2, - chainName: "0g Galileo Testnet", - chainNameIdentifier: "0G_GALILEO_TESTNET", - explorerUrl: "https://chainscan-galileo.0g", - nativeCurrencySymbol: "OG", - chainFamily: "evm" + function _toNetworkConfig(ChainConfig.Chain memory c) private pure returns (NetworkConfig memory) { + return NetworkConfig({ + chainSelector: c.chainSelector, + router: c.router, + rmnProxy: c.rmnProxy, + tokenAdminRegistry: c.tokenAdminRegistry, + registryModuleOwnerCustom: c.registryModuleOwnerCustom, + link: c.link, + ccipBnM: c.ccipBnM, + confirmations: c.confirmations, + chainName: c.chainName, + chainNameIdentifier: c.chainNameIdentifier, + explorerUrl: c.explorerUrl, + nativeCurrencySymbol: c.nativeCurrencySymbol, + chainFamily: c.chainFamily }); - return zeroGTestnetConfig; } - function getPlumeTestnetConfig() public pure returns (NetworkConfig memory) { - NetworkConfig memory plumeTestnetConfig = NetworkConfig({ - chainSelector: 13874588925447303949, - router: 0x5e5Fd4720E1CE826138D043aF578D69f48af502F, - rmnProxy: 0xAa3ae5481EE445711252131f1516922D0962916A, - tokenAdminRegistry: 0x855cF0d18A0BeBEDA7c1CD2F943686120cCCC6bd, - registryModuleOwnerCustom: 0x693926456C8b210f56E29Bc5b4514B32A5224c88, - link: 0xB97e3665AEAF96BDD6b300B2e0C93C662104A068, - ccipBnM: 0x225fAc4130595d1C7dabbE61A8bA9B051440b76c, - confirmations: 2, - chainName: "Plume Testnet", - chainNameIdentifier: "PLUME_TESTNET", - explorerUrl: "https://testnet-explorer.plume.org", - nativeCurrencySymbol: "PLUME", - chainFamily: "evm" - }); - return plumeTestnetConfig; + function getEthereumSepoliaConfig() public view returns (NetworkConfig memory) { + return _load("ethereum-testnet-sepolia"); } - function getInkSepoliaConfig() public pure returns (NetworkConfig memory) { - NetworkConfig memory inkSepoliaConfig = NetworkConfig({ - chainSelector: 9763904284804119144, - router: 0x17fCda531D8E43B4e2a2A2492FBcd4507a1685A1, - rmnProxy: 0x84017cfddD12D319E5bBf090e0de6d55B78160Cb, - tokenAdminRegistry: 0x3A849a05a590FeaEf26c2d425241A2BF29307161, - registryModuleOwnerCustom: 0xaB018890bBdDf9B80E21d1c335c5f6acdbE0f5D6, - link: 0x3423C922911956b1Ccbc2b5d4f38216a6f4299b4, - ccipBnM: 0x414dbe1d58dd9BA7C84f7Fc0e4f82bc858675d37, - confirmations: 2, - chainName: "Ink Sepolia", - chainNameIdentifier: "INK_SEPOLIA", - explorerUrl: "https://explorer-sepolia.inkonchain.com", - nativeCurrencySymbol: "INK", - chainFamily: "evm" - }); - return inkSepoliaConfig; + function getZeroGTestnetConfig() public view returns (NetworkConfig memory) { + return _load("0g-testnet-galileo-1"); } - function getMantleSepoliaConfig() public pure returns (NetworkConfig memory) { - NetworkConfig memory mantleSepoliaConfig = NetworkConfig({ - chainSelector: 8236463271206331221, - router: 0xFd33fd627017fEf041445FC19a2B6521C9778f86, - rmnProxy: 0xcCB84Ec3F6AFdD2052134f74aaAc95Ae41A7B333, - tokenAdminRegistry: 0x0F1eE88A582f31d92510E300fc1330AA5a525D51, - registryModuleOwnerCustom: 0xf76cE612250eeEb8889F49FBCB11f1c2705305F6, - link: 0x22bdEdEa0beBdD7CfFC95bA53826E55afFE9DE04, - ccipBnM: 0xBB370F829bdB6fC44f3D34e2A2107578bB2c3F0B, - confirmations: 2, - chainName: "Mantle Sepolia", - chainNameIdentifier: "MANTLE_SEPOLIA", - explorerUrl: "https://sepolia.mantlescan.xyz", - nativeCurrencySymbol: "MNT", - chainFamily: "evm" - }); - return mantleSepoliaConfig; + function getPlumeTestnetConfig() public view returns (NetworkConfig memory) { + return _load("plume-testnet-sepolia"); + } + + function getInkSepoliaConfig() public view returns (NetworkConfig memory) { + return _load("ink-testnet-sepolia"); + } + + function getMantleSepoliaConfig() public view returns (NetworkConfig memory) { + return _load("ethereum-testnet-sepolia-mantle-1"); } // ── Non-EVM destination chains ────────────────────────────────────────────────────────────── @@ -168,33 +168,19 @@ contract HelperConfig is Script { // — i.e. to register a non-EVM token pool on an EVM source chain. They cannot be used as // source chains in this repo. // That's why fields like router, rmnProxy, tokenAdminRegistry, etc. are not applicable - // and are intentionally left as zero/empty. - // Add more entries here as new non-EVM lanes go live. + // and are intentionally zero/empty in `config/chains/solana-devnet.json`. + // Add more entries under `config/chains/` as new non-EVM lanes go live. /// @notice Returns the network configuration for Solana Devnet. /// @dev Only chainSelector, chainName, chainNameIdentifier, nativeCurrencySymbol, and chainFamily are used. - function getSolanaDevnetConfig() public pure returns (NetworkConfig memory) { - return NetworkConfig({ - chainSelector: 16423721717087811551, - router: address(0), // NOT REQUIRED — non-EVM chain - rmnProxy: address(0), // NOT REQUIRED — non-EVM chain - tokenAdminRegistry: address(0), // NOT REQUIRED — non-EVM chain - registryModuleOwnerCustom: address(0), // NOT REQUIRED — non-EVM chain - link: address(0), // NOT REQUIRED — non-EVM chain - ccipBnM: address(0), // NOT REQUIRED — non-EVM chain - confirmations: 0, // NOT REQUIRED — non-EVM chain - chainName: "Solana Devnet", - chainNameIdentifier: "SOLANA_DEVNET", - explorerUrl: "", // NOT REQUIRED — non-EVM chain - nativeCurrencySymbol: "SOL", - chainFamily: "svm" - }); + function getSolanaDevnetConfig() public view returns (NetworkConfig memory) { + return _load("solana-devnet"); } /// @notice Resolves a chain name (e.g. "SOLANA_DEVNET", "AVALANCHE_FUJI") to its NetworkConfig. /// @dev Handles both EVM and non-EVM chains. Returns a zero config (chainFamily = "") for /// unrecognized names so callers can fall back to DEST_CHAIN_FAMILY / DEST_CHAIN_SELECTOR. - function getDestChainConfig(string memory chainName) public pure returns (NetworkConfig memory) { + function getDestChainConfig(string memory chainName) public view returns (NetworkConfig memory) { bytes32 h = keccak256(abi.encodePacked(chainName)); if (h == keccak256(abi.encodePacked("ETHEREUM_SEPOLIA"))) return getEthereumSepoliaConfig(); if (h == keccak256(abi.encodePacked("ZERO_G_TESTNET"))) return getZeroGTestnetConfig(); @@ -202,11 +188,15 @@ contract HelperConfig is Script { if (h == keccak256(abi.encodePacked("INK_SEPOLIA"))) return getInkSepoliaConfig(); if (h == keccak256(abi.encodePacked("MANTLE_SEPOLIA"))) return getMantleSepoliaConfig(); if (h == keccak256(abi.encodePacked("SOLANA_DEVNET"))) return getSolanaDevnetConfig(); + // Fall back to the discovered-chain cache for chains added after this dispatch was written. + (bool found, uint256 idx) = _findByIdentifier(chainName); + if (found) return s_chains[idx]; NetworkConfig memory unknown; return unknown; } - function getNetworkConfig(uint256 chainId) public pure returns (NetworkConfig memory) { + function getNetworkConfig(uint256 chainId) public view returns (NetworkConfig memory) { + // Fast path: the chains this dispatch was written for. if (chainId == ETHEREUM_SEPOLIA_CHAIN_ID) { return getEthereumSepoliaConfig(); } else if (chainId == ZERO_G_TESTNET_CHAIN_ID) { @@ -217,9 +207,39 @@ contract HelperConfig is Script { return getInkSepoliaConfig(); } else if (chainId == MANTLE_SEPOLIA_CHAIN_ID) { return getMantleSepoliaConfig(); - } else { - revert("Unsupported chain ID"); } + // Fallback: the discovered-chain cache (scanned from config/chains/*.json at + // construction) — a chain added by `make add-chain` resolves here with no Solidity change. + (bool found, uint256 idx) = _findByChainId(chainId); + if (found) return s_chains[idx]; + revert("Unsupported chain ID"); + } + + /// @notice Enumerates every configured chain (config names, i.e. `config/chains/.json` + /// basenames as discovered at construction) — the dynamic chain list backing the fallbacks above. + function getConfiguredChains() public view returns (string[] memory) { + return s_configuredChains; + } + + /// @dev Cache index of the discovered chain whose declared `chainId` matches. Non-EVM + /// chains declare `chainId` 0 and never match (callers must pass a real EVM chain ID). + function _findByChainId(uint256 chainId) private view returns (bool, uint256) { + if (chainId == 0) return (false, 0); + for (uint256 i = 0; i < s_chainIds.length; i++) { + if (s_chainIds[i] == chainId) return (true, i); + } + return (false, 0); + } + + /// @dev Cache index of the discovered chain whose `chainNameIdentifier` matches. + function _findByIdentifier(string memory chainNameIdentifier) private view returns (bool, uint256) { + bytes32 nameHash = keccak256(abi.encodePacked(chainNameIdentifier)); + for (uint256 i = 0; i < s_chains.length; i++) { + if (keccak256(abi.encodePacked(s_chains[i].chainNameIdentifier)) == nameHash) { + return (true, i); + } + } + return (false, 0); } function getDeployedToken(uint256 chainId) public view returns (address) { @@ -230,10 +250,24 @@ contract HelperConfig is Script { return deployedTokenPools[chainId]; } + /// @notice Resolves the deployed ERC20LockBox for this chain via the same 3-rung ladder as + /// token/tokenPool: inline `LOCK_BOX` alias > `{CHAIN}_LOCK_BOX` env > registry `active.lockBox`. + /// `address(0)` when unresolved (callers requiring it must revert with a clear message). + function getDeployedLockBox(uint256 chainId) public view returns (address) { + return deployedLockBoxes[chainId]; + } + + /// @notice Resolves the deployed AdvancedPoolHooks for this chain via the same 3-rung ladder as + /// token/tokenPool: inline `POOL_HOOKS` alias > `{CHAIN}_POOL_HOOKS` env > registry `active.poolHooks`. + /// `address(0)` when unresolved. + function getDeployedPoolHooks(uint256 chainId) public view returns (address) { + return deployedPoolHooks[chainId]; + } + /// @dev Converts a chain name identifier (e.g. "AVALANCHE_FUJI") to its EVM chain ID. /// EVM chains only — non-EVM chains (e.g. "SOLANA_DEVNET") have no EVM chain ID /// and will revert with "Invalid chain name". - function parseChainName(string memory chainName) public pure returns (uint256) { + function parseChainName(string memory chainName) public view returns (uint256) { bytes32 nameHash = keccak256(abi.encodePacked(chainName)); if (nameHash == keccak256(abi.encodePacked(getEthereumSepoliaConfig().chainNameIdentifier))) { @@ -252,30 +286,38 @@ contract HelperConfig is Script { return MANTLE_SEPOLIA_CHAIN_ID; } + // Fallback: the discovered-chain cache. Non-EVM matches (chainId 0) keep reverting — + // they have no EVM chain ID. + (bool found, uint256 idx) = _findByIdentifier(chainName); + if (found && s_chainIds[idx] != 0) return s_chainIds[idx]; revert("Invalid chain name"); } - function getChainName(uint256 chainId) public pure returns (string memory) { + function getChainName(uint256 chainId) public view returns (string memory) { return getNetworkConfig(chainId).chainName; } - function getChainNameBySelector(uint64 chainSelector) public pure returns (string memory) { + function getChainNameBySelector(uint64 chainSelector) public view returns (string memory) { if (chainSelector == getEthereumSepoliaConfig().chainSelector) return getEthereumSepoliaConfig().chainName; if (chainSelector == getZeroGTestnetConfig().chainSelector) return getZeroGTestnetConfig().chainName; if (chainSelector == getPlumeTestnetConfig().chainSelector) return getPlumeTestnetConfig().chainName; if (chainSelector == getInkSepoliaConfig().chainSelector) return getInkSepoliaConfig().chainName; if (chainSelector == getMantleSepoliaConfig().chainSelector) return getMantleSepoliaConfig().chainName; if (chainSelector == getSolanaDevnetConfig().chainSelector) return getSolanaDevnetConfig().chainName; + // Fallback: the discovered-chain cache. + for (uint256 i = 0; i < s_chains.length; i++) { + if (s_chains[i].chainSelector == chainSelector) return s_chains[i].chainName; + } return "Unknown"; } - function getNativeCurrencySymbol(uint256 chainId) public pure returns (string memory) { + function getNativeCurrencySymbol(uint256 chainId) public view returns (string memory) { return getNetworkConfig(chainId).nativeCurrencySymbol; } function getExplorerUrl(uint256 chainId, string memory pathType, address contractAddress) public - pure + view returns (string memory) { return string.concat(getNetworkConfig(chainId).explorerUrl, pathType, vm.toString(contractAddress)); diff --git a/script/config/SyncCcipConfig.s.sol b/script/config/SyncCcipConfig.s.sol new file mode 100644 index 0000000..8276f0e --- /dev/null +++ b/script/config/SyncCcipConfig.s.sol @@ -0,0 +1,577 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; + +import {IConfigSource} from "../../src/config/IConfigSource.sol"; +import {CcipApiSource} from "../../src/config/CcipApiSource.sol"; + +/// @title SyncCcipConfig +/// @notice The config-sync entrypoints: everything that generates, refreshes, or drift-checks a +/// `config/chains/.json` file from the live CCIP REST API v2. JSON file generation stays +/// Foundry-side (`vm.serialize*` + `vm.writeJson`); the shell helpers only fetch + select. +/// +/// All entrypoints require the `sync` foundry profile (enables `ffi` for the curl/jq fetch): +/// - add a chain: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol \ +/// --sig "init(string,uint256)" +/// - refresh ccip{}: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol \ +/// --sig "run(string)" +/// - preview (no write): ... --sig "preview(string)" +/// - drift check (read-only): ... --sig "check(string)" +/// (or `bash script/config/sync-check.sh`, which owns the 0 clean / 1 drift / 2 api-down +/// exit-code contract across all configured chains) +/// +/// @dev What the sync OWNS (overwrites): every API-served field. That is the `ccip{}` object — the +/// API-syncable, directory-canonical addresses (`router`, `rmnProxy`, `tokenAdminRegistry`, +/// `registryModuleOwnerCustom`, `link`, `feeQuoter`, `tokenPoolFactory`, `feeTokens[]`) — AND the +/// 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`, `confirmations`, `ccipBnM`), and the immutable join keys +/// (`name`/`chainSelector`/`chainId`) which are GUARD-validated, not rewritten. One writer per field. +/// +/// Guards (each verified by `script/config/test-tooling.sh`): +/// - SELECTOR MISMATCH: after every fetch the API's chainId must equal the local file's chainId — +/// a wrong-but-valid selector can never silently write another chain's contracts. +/// - non-EVM SKIP: non-EVM chain families (e.g. solana-devnet) skip the EVM `ccip{}` transform — an +/// SVM file keeps its zeroed `ccip{}` block — but their chain-level identity + metadata (served +/// for every family) ARE validated + refreshed. The guard is Solidity-side so every entrypoint +/// is covered. +/// - chain-name validation: config names become file paths and shell arguments, so `init` only +/// accepts `[a-z0-9][a-z0-9-]*` (no path traversal, no spaces). +contract SyncCcipConfig is Script { + string private constant META_HELPER = "script/config/ccip-chain-meta.sh"; + + /// @notice The active config source. Swap this to target a different API version/source. + function _source() internal returns (IConfigSource) { + return new CcipApiSource(); + } + + function _path(string memory name) internal pure returns (string memory) { + return string.concat("config/chains/", name, ".json"); + } + + /// @dev All entrypoints need ffi, which only the `sync` profile grants. Failing early with the + /// real fix beats forge's misleading "--ffi" advice. + function _requireSyncProfile() internal view { + require( + keccak256(bytes(vm.envOr("FOUNDRY_PROFILE", string("")))) == keccak256(bytes("sync")), + "run with FOUNDRY_PROFILE=sync (enables ffi): FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig ..." + ); + } + + /// @dev Non-EVM guard, Solidity-side so no invocation path can inject an EVM `ccip{}` block + /// into a non-EVM file (the EVM transform would die on the missing contract entries anyway). + function _skipNonEvm(string memory name, string memory json) internal pure returns (bool) { + string memory fam = vm.parseJsonString(json, ".chainFamily"); + if (keccak256(bytes(fam)) != keccak256(bytes("evm"))) { + console.log( + string.concat("[sync] SKIP ", name, " - chainFamily ", fam, " is not EVM-syncable (non-EVM chain)") + ); + return true; + } + return false; + } + + /// @dev Unknown chain -> a helpful list of the configured chains, never a raw cheatcode revert. + function _requireConfigExists(string memory name) internal view returns (string memory path) { + path = _path(name); + if (!vm.exists(path)) { + revert( + string.concat( + "[sync] no ", + path, + ". Known chains: ", + _knownChains(), + ". New chain? run: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"init(string,uint256)\" ", + name, + " " + ) + ); + } + } + + /// @dev Comma-joined basenames of config/chains/*.json. + function _knownChains() internal view returns (string memory list) { + Vm.DirEntry[] memory entries = vm.readDir("config/chains"); + for (uint256 i = 0; i < entries.length; i++) { + string memory base = _jsonBasename(entries[i].path); + if (bytes(base).length == 0) continue; + list = bytes(list).length == 0 ? base : string.concat(list, ", ", base); + } + } + + /// @dev "config/chains/ethereum-testnet-sepolia.json" -> "ethereum-testnet-sepolia" (empty for non-.json entries). + function _jsonBasename(string memory filePath) internal pure returns (string memory) { + bytes memory b = bytes(filePath); + bytes memory suffix = bytes(".json"); + if (b.length < suffix.length) return ""; + for (uint256 i = 0; i < suffix.length; i++) { + if (b[b.length - suffix.length + i] != suffix[i]) return ""; + } + uint256 start = 0; + for (uint256 i = 0; i < b.length; i++) { + if (b[i] == "/") start = i + 1; + } + bytes memory out = new bytes(b.length - suffix.length - start); + for (uint256 i = 0; i < out.length; i++) { + out[i] = b[start + i]; + } + return string(out); + } + + /// @dev The SELECTOR MISMATCH guard: the API row fetched BY SELECTOR must describe the same + /// chainId the local file claims — otherwise the selector is a valid-but-WRONG one and syncing + /// would silently write another chain's contracts into this file. After the chainId check it + /// also asserts the config `name` equals the canonical selectorName (`_requireSelectorName`). + function _requireIdentity(string memory name, string memory localJson, string memory flat) internal pure { + uint256 localChainId = vm.parseJsonUint(localJson, ".chainId"); + uint256 apiChainId = vm.parseJsonUint(flat, ".chainId"); + require( + localChainId == apiChainId, + string.concat( + "[sync] SELECTOR MISMATCH for ", + name, + ": config says chainId ", + vm.toString(localChainId), + " but the selector resolves to chainId ", + vm.toString(apiChainId), + " (", + vm.parseJsonString(flat, ".apiName"), + ") - fix .chainSelector in config/chains/", + name, + ".json (script/config/sync-discover.sh lists valid selectors)" + ) + ); + _requireSelectorName(name, vm.parseJsonString(localJson, ".name"), vm.parseJsonString(flat, ".apiName")); + } + + /// @dev The SELECTOR NAME guard: the config's lowercase `name` MUST equal the canonical CCIP + /// **selectorName** the chain-selectors registry (and the REST API `name` field) assign to this + /// selector — the one universal key the CCIP API, CLD, Atlas, the directory URL leaf, and + /// `ccip-cli` all share (e.g. `ethereum-testnet-sepolia`, not the bespoke `ethereum-sepolia`). + /// Unlike the chainId guard this ALSO validates non-EVM chains, whose config carries a + /// placeholder `chainId: "0"` the chainId check can never verify — for those the selectorName is + /// the only real identity. `apiName` is the API `.chain.name` (== the registry `name`) the + /// fetch helpers already surface, so no extra round-trip is needed on the EVM sync path. + function _requireSelectorName(string memory name, string memory localName, string memory apiName) internal pure { + require( + keccak256(bytes(localName)) == keccak256(bytes(apiName)), + string.concat( + "[sync] SELECTOR NAME MISMATCH for ", + name, + ": config name '", + localName, + "' is not the canonical selectorName for this selector - the CCIP registry/API name is '", + apiName, + "'. Set .name to '", + apiName, + "' and rename the file to config/chains/", + apiName, + ".json (the config basename IS the selectorName)" + ) + ); + } + + // ================================================================ + // preview / run — fetch + (optionally) write the ccip{} block + // ================================================================ + + /// @notice Fetch + log a chain's active CCIP config WITHOUT writing (dry run). + function preview(string memory name) public returns (string memory flatJson) { + _requireSyncProfile(); + string memory json = vm.readFile(_requireConfigExists(name)); + if (_skipNonEvm(name, json)) return ""; + uint64 selector = uint64(vm.parseJsonUint(json, ".chainSelector")); + flatJson = _source().fetchActiveCcipConfig(selector); + _requireIdentity(name, json, flatJson); + console.log("[sync preview]", name, "selector", selector); + console.log(flatJson); + } + + /// @notice Sync ONE chain: overwrite its API-served fields from the API; preserve every + /// hand-authored key. The API-sync writer now owns the `ccip{}` address block AND the API-served + /// identity + metadata fields (`displayName`, `chainFamily`, `environment`, `explorerUrl`, + /// `nativeCurrencySymbol`) — all of which the CCIP REST API serves, so none of them should be + /// hand-typed. Hand-authored keys (`chainNameIdentifier`, `rpcEnv`, `confirmations`, `ccipBnM`) + /// and the immutable join keys (`name`/`chainSelector`/`chainId`, guarded, not rewritten) are + /// preserved untouched. Non-EVM chains have no EVM-shaped `chainConfig`, so their `ccip{}` block + /// stays zeroed (SKIP), but their chain-level identity + metadata ARE served and get refreshed. + function run(string memory name) public { + _requireSyncProfile(); + string memory path = _requireConfigExists(name); + string memory json = vm.readFile(path); + uint64 selector = uint64(vm.parseJsonUint(json, ".chainSelector")); + + if (_skipNonEvm(name, json)) { + // Non-EVM: no EVM-shaped ccip{} to sync, but the chain-level identity + metadata are + // served for every family. Validate the selectorName (the only identity a "0"-chainId + // non-EVM file can be checked on) and refresh the metadata fields from the API. + string memory meta = _fetchChainMeta(selector); + _requireSelectorName(name, vm.parseJsonString(json, ".name"), vm.parseJsonString(meta, ".apiName")); + _refreshMetadata(path, meta); + console.log(string.concat("[sync] refreshed identity metadata for ", name, " -> ", path)); + return; + } + + string memory flat = _source().fetchActiveCcipConfig(selector); + _requireIdentity(name, json, flat); + + // Refresh the API-served metadata fields, then replace ONLY the `.ccip` subtree; every + // hand-authored key is preserved untouched (the merge rule). + _refreshMetadata(path, flat); + vm.writeJson(_buildCcipJson(name, flat), path, ".ccip"); + console.log(string.concat("[sync] wrote .ccip block + metadata for ", name, " -> ", path)); + } + + /// @notice THE single list of API-served metadata fields the sync MAINTAINS alongside `ccip{}` + /// (shared by the `run` write and the `check` drift-compare). Every one is served by + /// `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`, `confirmations`, `ccipBnM`. + function metadataKeys() public pure returns (string[5] memory) { + return ["displayName", "chainFamily", "environment", "explorerUrl", "nativeCurrencySymbol"]; + } + + /// @dev Overwrite each API-served metadata field in-place from the flat source JSON (which the + /// EVM `ccip-config-source.sh` and the non-EVM `ccip-chain-meta.sh` both carry). Targeted + /// `vm.writeJson(value, path, key)` writes preserve every other key. `chainFamily` arrives + /// already lowercased from the fetcher, so a matching config does not churn. + function _refreshMetadata(string memory path, string memory src) internal { + string[5] memory keys = metadataKeys(); + for (uint256 i = 0; i < keys.length; i++) { + string memory value = vm.parseJsonString(src, string.concat(".", keys[i])); + // Write as a JSON string literal at the top-level key (values are controlled API strings + // with no embedded quotes/backslashes: names, symbols, explorer URLs). + vm.writeJson(string.concat("\"", value, "\""), path, string.concat(".", keys[i])); + } + } + + /// @dev Serialize the normalized flat source JSON into the `ccip` object (JSON generation stays + /// Foundry-side). `check()` reuses the same field list via `ccipAddressKeys` so the drift check + /// and the write can never diverge. + function _buildCcipJson(string memory name, string memory flat) internal returns (string memory) { + string memory obj = string.concat("ccip-", name); + string[7] memory keys = ccipAddressKeys(); + for (uint256 i = 0; i < keys.length; i++) { + vm.serializeAddress(obj, keys[i], vm.parseJsonAddress(flat, string.concat(".", keys[i]))); + } + return vm.serializeAddress(obj, "feeTokens", vm.parseJsonAddressArray(flat, ".feeTokens")); + } + + /// @notice THE single list of API-synced `ccip{}` address fields (shared by the `run` write and + /// the `check` drift-compare; also pinned by the fixture test). + function ccipAddressKeys() public pure returns (string[7] memory) { + return [ + "router", + "rmnProxy", + "tokenAdminRegistry", + "registryModuleOwnerCustom", + "link", + "feeQuoter", + "tokenPoolFactory" + ]; + } + + // ================================================================ + // init — add-chain: generate config/chains/.json FROM the API + // ================================================================ + + /// @notice Generate `config/chains/.json` from the API row for `selector`, then sync + /// its `ccip{}` block in the same invocation. The SELECTOR is the numeric lookup key; the + /// supplied name MUST be the canonical CCIP selectorName the API/registry assign to that + /// selector (`_requireSelectorName` enforces this for every family, incl. non-EVM), so the file + /// basename and `.name` are always the universal selectorName (e.g. `ethereum-testnet-sepolia`). + /// @dev Refuses to overwrite an existing file (refresh an existing chain with `run` instead). + /// `chainNameIdentifier` defaults to UPPER_SNAKE(localName) and `rpcEnv` to + /// `_RPC_URL`; override per-run with the `CHAIN_NAME_IDENTIFIER` / `RPC_ENV` + /// environment variables. Repo extras that the API does not carry (`confirmations`, + /// `explorerUrl`, `nativeCurrencySymbol`, `ccipBnM`) are written as review-me defaults. + function init(string memory localName, uint256 selector) public { + _requireSyncProfile(); + require( + isValidChainName(localName), + string.concat( + "[add-chain] invalid chain name '", + localName, + "' - use lowercase letters, digits and dashes only ([a-z0-9][a-z0-9-]*); the name becomes a file path" + ) + ); + string memory path = _path(localName); + require( + !vm.exists(path), + string.concat( + "[add-chain] ", + path, + " already exists - refusing to overwrite. Refresh it instead: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"run(string)\" ", + localName + ) + ); + + string memory meta = _fetchChainMeta(selector); + // The provided CHAIN name MUST be the canonical selectorName for this selector. This works + // for EVERY family (the meta row carries `apiName` for EVM and non-EVM alike), so it closes + // the non-EVM gap: a Solana/other non-EVM config has a placeholder `chainId: "0"` the + // chainId guard can't verify, but its selectorName is fully validated here at creation. + _requireSelectorName(localName, localName, vm.parseJsonString(meta, ".apiName")); + string memory fam = vm.parseJsonString(meta, ".chainFamily"); + bool isEvm = keccak256(bytes(fam)) == keccak256(bytes("evm")); + + string memory chainNameId = vm.envOr("CHAIN_NAME_IDENTIFIER", string("")); + if (bytes(chainNameId).length == 0) chainNameId = chainNameIdentifierFor(localName); + string memory rpcEnv = vm.envOr("RPC_ENV", string("")); + if (bytes(rpcEnv).length == 0) rpcEnv = string.concat(chainNameId, "_RPC_URL"); + + string memory root = string.concat("chain-", localName); + vm.serializeString(root, "name", localName); + vm.serializeString(root, "displayName", vm.parseJsonString(meta, ".displayName")); + vm.serializeString(root, "chainNameIdentifier", chainNameId); + vm.serializeString(root, "chainFamily", fam); + vm.serializeString(root, "environment", vm.parseJsonString(meta, ".environment")); + // chainId: API-sourced for EVM; a "0" placeholder for non-EVM (the API's non-EVM chainId is a + // base58/hash string, not the numeric id the repo keys addresses on — selectorName is the + // portable identity there). + vm.serializeString(root, "chainId", isEvm ? vm.parseJsonString(meta, ".chainId") : "0"); + vm.serializeString(root, "chainSelector", vm.parseJsonString(meta, ".chainSelector")); + vm.serializeString(root, "rpcEnv", rpcEnv); + // Genuinely hand-authored (the API serves nothing for these): confirmations (block + // confirmations the scripts wait for — user-overridable per chain, preserved by sync) and + // ccipBnM (optional). explorerUrl/nativeCurrencySymbol are seeded empty here but the + // `run(localName)` call below sources them from the API's chainMetadata in the same invocation. + vm.serializeUint(root, "confirmations", 2); + vm.serializeString(root, "explorerUrl", ""); + vm.serializeString(root, "nativeCurrencySymbol", ""); + vm.serializeAddress(root, "ccipBnM", address(0)); + // `vm.writeJson` cannot CREATE keys, so the stub must ship an (empty) ccip object. + string memory stub = vm.serializeString(root, "ccip", "{}"); + vm.writeFile(path, stub); + console.log( + string.concat("[add-chain] generated ", path, " from API row ", vm.parseJsonString(meta, ".apiName")) + ); + + // fill .ccip in the same invocation (non-EVM chains get the SKIP log + keep the empty block). + run(localName); + + _logNextSteps(localName, chainNameId, rpcEnv, isEvm); + } + + /// @dev Fetch the chain-list row (identity metadata) by selector via the meta helper script. + function _fetchChainMeta(uint256 selector) internal returns (string memory) { + string[] memory cmd = new string[](3); + cmd[0] = "bash"; + cmd[1] = META_HELPER; + cmd[2] = vm.toString(selector); + Vm.FfiResult memory r = vm.tryFfi(cmd); + if (r.exitCode != 0) { + revert(string(bytes.concat(bytes("[add-chain] chain metadata fetch failed: "), r.stderr))); + } + return string(r.stdout); + } + + /// @notice Validates a local chain short name: `[a-z0-9][a-z0-9-]*`. Names become file paths + /// (`config/chains/.json`) and shell/script arguments, so anything else is refused. + function isValidChainName(string memory name) public pure returns (bool) { + bytes memory b = bytes(name); + if (b.length == 0) return false; + for (uint256 i = 0; i < b.length; i++) { + bytes1 ch = b[i]; + bool lowerAlnum = (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9"); + if (i == 0 ? !lowerAlnum : !(lowerAlnum || ch == "-")) return false; + } + return true; + } + + /// @notice Derives the default `chainNameIdentifier` (the `{CHAIN}_*` env-var prefix) from the + /// selectorName: UPPER_SNAKE, e.g. "ethereum-testnet-sepolia" -> "ETHEREUM_TESTNET_SEPOLIA". + /// Review it — the repo usually prefers a shorter identifier (e.g. `ETHEREUM_SEPOLIA` / + /// `ETHEREUM_SEPOLIA_RPC_URL`, or `0G_GALILEO_TESTNET` with `ZERO_G_TESTNET_RPC_URL`); override + /// with `CHAIN_NAME_IDENTIFIER` / `RPC_ENV`. + function chainNameIdentifierFor(string memory localName) public pure returns (string memory) { + bytes memory b = bytes(localName); + bytes memory out = new bytes(b.length); + for (uint256 i = 0; i < b.length; i++) { + bytes1 ch = b[i]; + if (ch == "-") out[i] = "_"; + else if (ch >= "a" && ch <= "z") out[i] = bytes1(uint8(ch) - 32); + else out[i] = ch; + } + return string(out); + } + + function _logNextSteps(string memory localName, string memory chainNameId, string memory rpcEnv, bool isEvm) + internal + view + { + bool rpcSet = bytes(vm.envOr(rpcEnv, string(""))).length != 0; + console.log(""); + // Print the EXACT derived names: `chainNameIdentifier` is UPPER_SNAKE(selectorName) for newly + // added chains, so it can differ in style from the bundled chains' curated short forms + // (e.g. AVALANCHE_TESTNET_FUJI, not AVALANCHE_FUJI). Printing them removes the guesswork — the + // operator no longer has to open the generated JSON to learn which env var to export. + console.log(string.concat("[add-chain] generated env-var names for ", localName, ":")); + console.log(string.concat(" chainNameIdentifier: ", chainNameId)); + console.log(string.concat(" rpcEnv: ", rpcEnv, " <- export this to use RPC-dependent commands")); + console.log(""); + console.log(string.concat("[add-chain] NEXT STEPS for ", localName, ":")); + console.log( + string.concat( + " 1. RPC env var ", + rpcEnv, + rpcSet ? " (already set - nothing to do)" : " (UNSET - add it to your .env)" + ) + ); + console.log( + string.concat( + " 2. review the generated defaults in config/chains/", + localName, + ".json: chainNameIdentifier, rpcEnv, confirmations, explorerUrl, nativeCurrencySymbol, ccipBnM" + ) + ); + if (isEvm) { + console.log( + " 3. no Solidity change needed - HelperConfig discovers the chain from config/chains/ automatically" + ); + console.log( + string.concat( + " 4. verify: FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig \"run(string)\" ", + localName, + " (re-run until it reports 0 FAIL)" + ) + ); + } else { + console.log(" 3. non-EVM chain: the ccip{} block stays zeroed (destination-only support, see README)"); + } + } + + // ================================================================ + // check — READ-ONLY drift detection (`bash script/config/sync-check.sh`) + // ================================================================ + + /// @notice Compare the on-disk `ccip{}` block field-by-field against the live API — NO writes. + /// Reverts `CONFIG_DRIFT` if any field differs (greppable `DRIFT .ccip.` lines + /// first); a fetch failure reverts with the fetch script's named error (NOT_FOUND / + /// API_UNREACHABLE), so `script/config/sync-check.sh` can classify its exit-code contract: + /// 0 clean / 1 drift-or-config-error / 2 api-down. + /// @dev Field-by-field via the same `vm.parseJson*` paths `ChainConfig.load` uses — never a + /// string-compare of serialized JSON (key reordering would false-positive). Reuses + /// `ccipAddressKeys` so check and write cannot diverge. + function check(string memory name) public { + _requireSyncProfile(); + string memory json = vm.readFile(_requireConfigExists(name)); + uint64 selector = uint64(vm.parseJsonUint(json, ".chainSelector")); + + if (_skipNonEvm(name, json)) { + // Non-EVM: the ccip{} block is zeroed by design, but the chain-level identity + metadata + // ARE served and drift-checkable. Validate the selectorName and diff the metadata fields. + string memory meta = _fetchChainMeta(selector); + _requireSelectorName(name, vm.parseJsonString(json, ".name"), vm.parseJsonString(meta, ".apiName")); + uint256 metaDrift = _checkMetadata(name, json, meta); + if (metaDrift > 0) { + revert( + string.concat( + "CONFIG_DRIFT: ", + vm.toString(metaDrift), + " metadata field(s) drifted for ", + name, + " - refresh with: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"run(string)\" ", + name + ) + ); + } + console.log(string.concat("[sync-check] CLEAN ", name, " - identity metadata matches the live API")); + return; + } + + string memory flat = _source().fetchActiveCcipConfig(selector); + _requireIdentity(name, json, flat); + + uint256 drift = _checkMetadata(name, json, flat); + string[7] memory keys = ccipAddressKeys(); + for (uint256 i = 0; i < keys.length; i++) { + address cur = vm.parseJsonAddress(json, string.concat(".ccip.", keys[i])); + address live = vm.parseJsonAddress(flat, string.concat(".", keys[i])); + if (cur != live) { + console.log( + string.concat("DRIFT ", name, " .ccip.", keys[i], " ", vm.toString(cur), " -> ", vm.toString(live)) + ); + drift++; + } + } + drift += _checkFeeTokens(name, json, flat); + + if (drift > 0) { + revert( + string.concat( + "CONFIG_DRIFT: ", + vm.toString(drift), + " field(s) drifted for ", + name, + " - refresh with: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"run(string)\" ", + name + ) + ); + } + console.log(string.concat("[sync-check] CLEAN ", name, " - .ccip matches the live API")); + } + + /// @dev Diff the API-served metadata fields (string values) against the flat source, one + /// greppable `DRIFT .` line per divergence. Reuses `metadataKeys` so the check and + /// the `run` write can never diverge. Runs for BOTH families (identity metadata is served for all). + function _checkMetadata(string memory name, string memory json, string memory src) + internal + pure + returns (uint256 drift) + { + string[5] memory keys = metadataKeys(); + for (uint256 i = 0; i < keys.length; i++) { + string memory cur = vm.parseJsonString(json, string.concat(".", keys[i])); + string memory live = vm.parseJsonString(src, string.concat(".", keys[i])); + if (keccak256(bytes(cur)) != keccak256(bytes(live))) { + console.log(string.concat("DRIFT ", name, " .", keys[i], " '", cur, "' -> '", live, "'")); + drift++; + } + } + } + + function _checkFeeTokens(string memory name, string memory json, string memory flat) + internal + pure + returns (uint256 drift) + { + address[] memory cur = vm.parseJsonAddressArray(json, ".ccip.feeTokens"); + address[] memory live = vm.parseJsonAddressArray(flat, ".feeTokens"); + if (cur.length != live.length) { + console.log( + string.concat( + "DRIFT ", + name, + " .ccip.feeTokens length ", + vm.toString(cur.length), + " -> ", + vm.toString(live.length) + ) + ); + return 1; + } + for (uint256 i = 0; i < cur.length; i++) { + if (cur[i] != live[i]) { + console.log( + string.concat( + "DRIFT ", + name, + " .ccip.feeTokens[", + vm.toString(i), + "] ", + vm.toString(cur[i]), + " -> ", + vm.toString(live[i]) + ) + ); + drift++; + } + } + } +} diff --git a/script/config/VerifyChain.s.sol b/script/config/VerifyChain.s.sol new file mode 100644 index 0000000..82c6882 --- /dev/null +++ b/script/config/VerifyChain.s.sol @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; + +import {ChainConfig} from "../../src/config/ChainConfig.sol"; +import {CcipApiSource} from "../../src/config/CcipApiSource.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; +import {TokenAdminRegistry} from "@chainlink/contracts-ccip/contracts/tokenAdminRegistry/TokenAdminRegistry.sol"; + +/// @dev External try/catch targets for `VerifyChain` (forge forbids `this.` self-calls in ephemeral +/// script contracts). Deployed by the script, so it inherits cheatcode access; reverts from the real +/// `ChainConfig` parse paths / fork cheatcodes become catchable, attributed FAILs. +contract ChainProbe { + Vm private constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function parseChain(string memory name) external view returns (ChainConfig.Chain memory) { + return ChainConfig.load(name); + } + + function parseQuotedDecimals(string memory json) external pure returns (string memory, string memory) { + return (VM.parseJsonString(json, ".chainId"), VM.parseJsonString(json, ".chainSelector")); + } + + function fetchFlat(uint64 selector) external returns (string memory) { + return (new CcipApiSource()).fetchActiveCcipConfig(selector); + } + + function forkTo(string memory rpcUrl) external returns (uint256) { + VM.createSelectFork(rpcUrl); + return block.chainid; + } + + /// @dev The pool CCIP actually routes through for `token`, read from the on-chain + /// TokenAdminRegistry. External so a revert (no TAR entry / RPC hiccup) is catchable by VerifyChain. + function wiredPool(address tokenAdminRegistry, address token) external view returns (address) { + return TokenAdminRegistry(tokenAdminRegistry).getPool(token); + } +} + +/// @title VerifyChain +/// @notice The layered chain-config doctor. One aligned [PASS]/[FAIL]/[WARN]/[SKIP] line per check, +/// reverting at the end iff any FAIL, so a chain can be verified end-to-end between "config file +/// edited" and "scripts run against it". Layers: +/// 1. TOOLS curl + jq present (the ffi fetch preflight) +/// 2. SCHEMA every key the real `ChainConfig.load` path consumes, incl. the quoted-decimal +/// big-int rule, plus an actual `ChainConfig.load` parse +/// 3. API re-fetch via the config-sync seam: selector<->chainId identity + field drift +/// (WARN + skip when the API is unreachable — flake is not failure) +/// 4. RPC rpcEnv set (SKIP cleanly when unset) -> fork -> block.chainid == chainId +/// 5. ON-CHAIN code present for router/rmnProxy/tokenAdminRegistry/registryModuleOwnerCustom/link +/// on the fork (proves the addresses belong on this chain) +/// 6. REGISTRY `addresses/.json` token/tokenPool entries (WARN while undeployed) and +/// review-me extras (explorerUrl/nativeCurrencySymbol/ccipBnM) — WARNs, not FAILs +/// +/// Run: FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig "run(string)" +/// @dev Non-EVM chains (e.g. solana-devnet) get the schema parse only; API/RPC/on-chain/registry +/// rungs are skipped (destination-only support, zeroed `ccip{}` by design). +contract VerifyChain is Script { + uint256 private fails; + uint256 private warns; + bool private forked; + ChainProbe private probe; + + function _pass(string memory msg_) private pure { + console.log(string.concat("[PASS] ", msg_)); + } + + function _fail(string memory msg_) private { + fails++; + console.log(string.concat("[FAIL] ", msg_)); + } + + function _warn(string memory msg_) private { + warns++; + console.log(string.concat("[WARN] ", msg_)); + } + + function _skip(string memory msg_) private pure { + console.log(string.concat("[SKIP] ", msg_)); + } + + function _path(string memory name) private pure returns (string memory) { + return string.concat("config/chains/", name, ".json"); + } + + function run(string memory name) public { + require( + keccak256(bytes(vm.envOr("FOUNDRY_PROFILE", string("")))) == keccak256(bytes("sync")), + "run with FOUNDRY_PROFILE=sync (enables ffi): FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig \"run(string)\" " + ); + console.log(string.concat("== check-chain ", name, " ==")); + probe = new ChainProbe(); + + _checkTools(); + + string memory path = _path(name); + if (!vm.exists(path)) { + _fail( + string.concat( + "config: no ", + path, + " - new chain? FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"init(string,uint256)\" ", + name, + " " + ) + ); + _verdict(name); + return; + } + string memory json = vm.readFile(path); + bool isEvm = _checkSchema(name, json); + if (isEvm) { + _checkApi(name, json); + bool rpcOk = _checkRpc(json); + if (rpcOk) _checkOnChainCode(name, json); + _checkRegistryAndExtras(name, json); + } else { + // Non-EVM chains have no EVM-shaped ccip{} to sync, so the API/RPC/on-chain/registry + // rungs are skipped — but the selectorName IS validatable for every family (chainId is a + // placeholder "0" here, so it is the only identity the doctor can check). + _checkSelectorNameNonEvm(json); + _skip("rpc/on-chain/registry: non-EVM chain (destination-only support) - schema + selectorName only"); + } + _verdict(name); + } + + function _verdict(string memory name) private view { + console.log( + string.concat("== check-chain ", name, ": ", vm.toString(fails), " FAIL, ", vm.toString(warns), " WARN ==") + ); + require(fails == 0, string.concat("check-chain FAILED for ", name, " - see [FAIL] lines above")); + } + + // ---------------------------------------------------------------- 1. TOOLS + function _checkTools() private { + string[] memory cmd = new string[](3); + cmd[0] = "bash"; + cmd[1] = "-lc"; + cmd[2] = "command -v curl >/dev/null && command -v jq >/dev/null"; + Vm.FfiResult memory r = vm.tryFfi(cmd); + if (r.exitCode == 0) _pass("tools: curl + jq present"); + else _fail("tools: curl and/or jq missing - install them (brew install curl jq)"); + } + + // ---------------------------------------------------------------- 2. SCHEMA + function _checkSchema(string memory name, string memory json) private returns (bool isEvm) { + if (!vm.keyExistsJson(json, ".chainFamily")) { + _fail("schema: missing .chainFamily"); + return false; + } + isEvm = keccak256(bytes(vm.parseJsonString(json, ".chainFamily"))) == keccak256(bytes("evm")); + + // quoted-decimal big-int rule (bare JSON numbers lose precision above 2^53) + try probe.parseQuotedDecimals(json) { + _pass("schema: chainId + chainSelector are quoted decimal strings"); + } catch { + _fail("schema: chainId/chainSelector must be quoted decimal STRINGS (see config/chains/*.json)"); + } + + string[18] memory required = [ + ".name", + ".displayName", + ".chainNameIdentifier", + ".chainId", + ".chainSelector", + ".rpcEnv", + ".confirmations", + ".explorerUrl", + ".nativeCurrencySymbol", + ".ccipBnM", + ".ccip.router", + ".ccip.rmnProxy", + ".ccip.tokenAdminRegistry", + ".ccip.registryModuleOwnerCustom", + ".ccip.link", + ".ccip.feeQuoter", + ".ccip.tokenPoolFactory", + ".ccip.feeTokens" + ]; + uint256 missing = 0; + for (uint256 i = 0; i < required.length; i++) { + if (!vm.keyExistsJson(json, required[i])) { + _fail(string.concat("schema: missing key ", required[i])); + missing++; + } + } + if (missing == 0) _pass("schema: all keys consumed by ChainConfig.load + the sync tooling present"); + + try probe.parseChain(name) { + _pass("schema: ChainConfig.load parses (the real read path)"); + } catch Error(string memory reason) { + _fail(string.concat("schema: ChainConfig.load reverts - ", reason)); + } catch { + _fail("schema: ChainConfig.load reverts (cheatcode parse error - check value formats)"); + } + } + + // ---------------------------------------------------------------- 3. API + function _checkApi(string memory name, string memory json) private { + uint64 selector = uint64(vm.parseJsonUint(json, ".chainSelector")); + string memory flat; + try probe.fetchFlat(selector) returns (string memory f) { + flat = f; + } catch Error(string memory reason) { + _warn(string.concat("api: fetch failed - drift check skipped: ", reason)); + return; + } catch { + _warn("api: fetch failed - drift check skipped"); + return; + } + // selector <-> chainId identity (a valid-but-wrong selector is the worst silent failure) + uint256 localChainId = vm.parseJsonUint(json, ".chainId"); + uint256 apiChainId = vm.parseJsonUint(flat, ".chainId"); + string memory apiName = vm.parseJsonString(flat, ".apiName"); + if (localChainId == apiChainId) { + _pass(string.concat("api: selector ", vm.toString(selector), " resolves to this chainId (", apiName, ")")); + } else { + _fail( + string.concat( + "api: SELECTOR MISMATCH - config chainId ", + vm.toString(localChainId), + " but selector is chainId ", + vm.toString(apiChainId), + " (", + apiName, + ") - fix .chainSelector" + ) + ); + return; + } + // selectorName identity: the config `name` must be the canonical CCIP selectorName (the + // universal key shared by the API, CLD, Atlas, and ccip-cli) for this selector. + string memory localName = vm.parseJsonString(json, ".name"); + if (keccak256(bytes(localName)) == keccak256(bytes(apiName))) { + _pass(string.concat("api: config name '", localName, "' matches the canonical selectorName")); + } else { + _fail( + string.concat( + "api: SELECTOR NAME MISMATCH - config name '", + localName, + "' but the selector's canonical selectorName is '", + apiName, + "' - set .name and rename the file to ", + apiName, + ".json" + ) + ); + } + // field drift vs the stored ccip{} block (same key list the sync writes) + string[7] memory keys = [ + "router", + "rmnProxy", + "tokenAdminRegistry", + "registryModuleOwnerCustom", + "link", + "feeQuoter", + "tokenPoolFactory" + ]; + uint256 drift = 0; + for (uint256 i = 0; i < keys.length; i++) { + address cur = vm.parseJsonAddress(json, string.concat(".ccip.", keys[i])); + address live = vm.parseJsonAddress(flat, string.concat(".", keys[i])); + if (cur != live) { + _fail(string.concat("api: DRIFT .ccip.", keys[i], " ", vm.toString(cur), " -> ", vm.toString(live))); + drift++; + } + } + if (drift == 0) { + _pass("api: .ccip matches the live API (no drift)"); + } else { + _fail( + string.concat( + "api: ", + vm.toString(drift), + " field(s) drifted - refresh: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"run(string)\" ", + name + ) + ); + } + } + + /// @dev Non-EVM selectorName rung: fetch the chain-list identity row by selector and assert the + /// config `name` equals the canonical selectorName. Uses the same meta helper the sync's + /// add-chain path uses (works for every family). API flake is a WARN, not a FAIL. + function _checkSelectorNameNonEvm(string memory json) private { + uint64 selector = uint64(vm.parseJsonUint(json, ".chainSelector")); + string[] memory cmd = new string[](3); + cmd[0] = "bash"; + cmd[1] = "script/config/ccip-chain-meta.sh"; + cmd[2] = vm.toString(uint256(selector)); + Vm.FfiResult memory r = vm.tryFfi(cmd); + if (r.exitCode != 0) { + _warn("api: selectorName check skipped - chain metadata fetch failed (flake, not config error)"); + return; + } + string memory apiName = vm.parseJsonString(string(r.stdout), ".apiName"); + string memory localName = vm.parseJsonString(json, ".name"); + if (keccak256(bytes(localName)) == keccak256(bytes(apiName))) { + _pass(string.concat("api: config name '", localName, "' matches the canonical selectorName")); + } else { + _fail( + string.concat( + "api: SELECTOR NAME MISMATCH - config name '", + localName, + "' but the selector's canonical selectorName is '", + apiName, + "' - set .name and rename the file to ", + apiName, + ".json" + ) + ); + } + } + + // ---------------------------------------------------------------- 4. RPC + function _checkRpc(string memory json) private returns (bool ok) { + string memory rpcEnv = vm.parseJsonString(json, ".rpcEnv"); + string memory url = vm.envOr(rpcEnv, string("")); + if (bytes(url).length == 0) { + _skip(string.concat("rpc: env ", rpcEnv, " unset - add it to your .env to enable fork checks")); + return false; + } + try probe.forkTo(url) returns (uint256 forkChainId) { + uint256 expected = vm.parseJsonUint(json, ".chainId"); + if (forkChainId == expected) { + forked = true; + _pass(string.concat("rpc: ", rpcEnv, " reachable, block.chainid == ", vm.toString(expected))); + return true; + } + _fail( + string.concat( + "rpc: ", + rpcEnv, + " points at chainId ", + vm.toString(forkChainId), + " but config says ", + vm.toString(expected), + " (wrong network in .env?)" + ) + ); + } catch { + _fail(string.concat("rpc: could not fork via ", rpcEnv, " - endpoint down or URL invalid")); + } + return false; + } + + // ---------------------------------------------------------------- 5. ON-CHAIN + function _checkOnChainCode(string memory name, string memory json) private { + if (!forked) return; + string[5] memory keys = ["router", "rmnProxy", "tokenAdminRegistry", "registryModuleOwnerCustom", "link"]; + uint256 bad = 0; + for (uint256 i = 0; i < keys.length; i++) { + address a = vm.parseJsonAddress(json, string.concat(".ccip.", keys[i])); + if (a.code.length == 0) { + _fail( + string.concat( + "on-chain: .ccip.", keys[i], " ", vm.toString(a), " has NO code on ", name, " (wrong chain?)" + ) + ); + bad++; + } + } + if (bad == 0) { + _pass("on-chain: router/rmnProxy/tokenAdminRegistry/registryModuleOwnerCustom/link all have code"); + } + } + + // ---------------------------------------------------------------- 6. REGISTRY + EXTRAS + function _checkRegistryAndExtras(string memory name, string memory json) private { + uint256 chainId = vm.parseJsonUint(json, ".chainId"); + + address token = RegistryWriter.read(chainId, "token"); + address pool = RegistryWriter.read(chainId, "tokenPool"); + if (token == address(0)) { + _warn( + string.concat( + "registry: no token in addresses/", + vm.toString(chainId), + ".json - deploy one (script/deploy/DeployToken.s.sol) or export {CHAIN}_TOKEN" + ) + ); + } else if (forked && token.code.length == 0) { + _fail(string.concat("registry: token ", vm.toString(token), " has NO code on ", name)); + } else { + _pass(string.concat("registry: token ", vm.toString(token), forked ? " (has code)" : " (set; no fork)")); + } + if (pool == address(0)) { + _warn( + string.concat( + "registry: no tokenPool in addresses/", vm.toString(chainId), ".json - deploy one before Step 3+" + ) + ); + } else if (forked && pool.code.length == 0) { + _fail(string.concat("registry: tokenPool ", vm.toString(pool), " has NO code on ", name)); + } else { + _pass(string.concat("registry: tokenPool ", vm.toString(pool), forked ? " (has code)" : " (set; no fork)")); + } + + // Reconcile the registry's pool against the ON-CHAIN TokenAdminRegistry. `active.tokenPool` is + // "what this repo deployed most recently"; the TAR is "the pool CCIP actually routes through". + // They legitimately diverge whenever the wired pool was changed out-of-band (the TAR was pointed + // at a different pool outside this repo's scripts), so this is always a WARN, never a FAIL. + if (token != address(0) && pool != address(0)) { + // Read the TAR address INSIDE the defensive path: a config missing `.ccip.tokenAdminRegistry` + // must degrade to a WARN, not revert the whole doctor with a raw parse error (the missing key + // is already reported by the schema rung above). Only reconcile when the key is present. + if (vm.keyExistsJson(json, ".ccip.tokenAdminRegistry")) { + _reconcilePoolWithTar(vm.parseJsonAddress(json, ".ccip.tokenAdminRegistry"), token, pool); + } else { + _warn( + "registry: .ccip.tokenAdminRegistry missing - cannot reconcile the registry pool against on-chain wiring" + ); + } + } + + // Extras (WARN, never FAIL). explorerUrl/nativeCurrencySymbol are API-sourced by the sync, so + // empty means the API served none for this chain - re-run `make sync` or fill by hand. ccipBnM + // is genuinely hand-authored (no CCIP token API) and optional. + if (bytes(vm.parseJsonString(json, ".explorerUrl")).length == 0) { + _warn("extras: explorerUrl is empty - run `make sync` (it is sourced from chainMetadata.explorer.url)"); + } + if (bytes(vm.parseJsonString(json, ".nativeCurrencySymbol")).length == 0) { + _warn( + "extras: nativeCurrencySymbol is empty - run `make sync` (sourced from chainMetadata.nativeCurrency.symbol)" + ); + } + if (vm.parseJsonAddress(json, ".ccipBnM") == address(0)) { + _warn("extras: ccipBnM is 0x0 - optional, hand-authored (only needed when using the CCIP test token)"); + } + } + + /// @dev Registry-pool vs on-chain-TAR reconciliation (WARN-only). Needs an RPC (skips when not + /// forked). Defensive: a token with no TAR entry / an RPC hiccup degrades to a WARN, never an + /// unhandled revert that would kill the whole doctor run. + function _reconcilePoolWithTar(address tar, address token, address pool) private { + if (!forked) { + _skip( + "registry: TAR reconciliation needs an RPC (no fork) - registry pool not checked against on-chain wiring" + ); + return; + } + try probe.wiredPool(tar, token) returns (address wired) { + if (wired == pool) { + _pass( + string.concat( + "registry: tokenPool ", vm.toString(pool), " is the pool wired in the TokenAdminRegistry" + ) + ); + } else if (wired == address(0)) { + _warn( + string.concat( + "registry: token ", + vm.toString(token), + " has no pool registered in the TokenAdminRegistry - run script/setup/SetPool.s.sol" + ) + ); + } else { + _warn( + string.concat( + "registry: tokenPool ", + vm.toString(pool), + " is NOT the wired pool (", + vm.toString(wired), + ") - the wired pool was changed out-of-band; otherwise the registry pointer is stale" + ) + ); + } + } catch { + _warn( + string.concat( + "registry: could not read the TokenAdminRegistry (", + vm.toString(tar), + ") for token ", + vm.toString(token), + " - RPC hiccup or no TAR entry; skipping the wired-pool reconciliation" + ) + ); + } + } + + /// @notice Test hook: runs ONLY the registry-vs-TAR reconciliation against the currently-selected + /// fork and returns `(fails, warns)`. Lets a fork test assert the WARN-not-FAIL contract (divergence + /// must never increment `fails`) without the full ffi/API doctor run. Not used by any production path. + function reconcilePoolWithTarForTest(address tar, address token, address pool) + public + returns (uint256 failsOut, uint256 warnsOut) + { + forked = true; + probe = new ChainProbe(); + _reconcilePoolWithTar(tar, token, pool); + return (fails, warns); + } +} diff --git a/script/config/ccip-chain-meta.sh b/script/config/ccip-chain-meta.sh new file mode 100755 index 0000000..9cec880 --- /dev/null +++ b/script/config/ccip-chain-meta.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# ccip-chain-meta.sh +# +# Fetch ONE chain's identity + metadata from the CCIP REST API v2 per-chain detail +# (`GET /chains/{selector}`), selected **by chainSelector** — the numeric identity key the whole +# stack shares. The per-selector endpoint carries the family-agnostic `chain{}` identity block AND +# the `chainMetadata{}` (explorer + native currency) block, so this single GET supplies every +# API-served field the sync maintains for BOTH EVM and non-EVM chains (the chain LIST endpoint has +# no `chainMetadata`). The config `name` carries the same canonical selectorName as the API `name` +# (e.g. "ethereum-testnet-sepolia-mantle-1"), but the join stays on the immutable selector. +# +# Emits a flat JSON row on stdout (all values strings; chainFamily lowercased to match the repo +# schema): +# { apiName, displayName, chainFamily, environment, chainId, chainSelector, +# explorerUrl, nativeCurrencySymbol } +# +# Consumed by SyncCcipConfig.init() (add-chain), SyncCcipConfig.run()/check() on the NON-EVM path +# (EVM uses ccip-config-source.sh, which carries the same fields), and VerifyChain (the doctor's +# non-EVM selectorName rung) via vm.tryFfi — config-file generation stays Foundry-side +# (vm.serialize* + vm.writeFile / vm.writeJson); this script only fetches + selects. +# +# Exit-code contract (stderr becomes the Solidity revert reason): +# 0 OK | 2 MISSING_TOOL | 4 NOT_FOUND (no chain for this selector) | 5 API_UNREACHABLE +set -euo pipefail + +err() { echo "[ccip-chain-meta] $*" >&2; } + +for tool in curl jq; do + command -v "$tool" > /dev/null 2>&1 || { + err "MISSING_TOOL: '$tool' not found on PATH - install it (e.g. brew install $tool)" + exit 2 + } +done + +SELECTOR="${1:?usage: ccip-chain-meta.sh }" +BASE_URL="${CCIP_API_BASE:-https://api.ccip.chain.link/v2}" + +body_file="$(mktemp)" +trap 'rm -f "$body_file"' EXIT + +http_code="$(curl -sS --retry 3 --max-time 30 -o "$body_file" -w '%{http_code}' \ + "${BASE_URL}/chains/${SELECTOR}" 2> /dev/null)" || { + err "API_UNREACHABLE: could not reach ${BASE_URL}/chains/${SELECTOR} (network error/timeout after retries) - retry later or fix CCIP_API_BASE" + exit 5 +} + +case "$http_code" in + 200) ;; + 404) + err "NOT_FOUND: no chain for selector ${SELECTOR} - run script/config/sync-discover.sh to list valid selectors" + exit 4 + ;; + *) + err "API_UNREACHABLE: HTTP ${http_code} from ${BASE_URL}/chains/${SELECTOR} - retry later" + exit 5 + ;; +esac + +jq -c '{ + apiName: (.chain.name // error("no .chain.name in API body")), + displayName: (.chain.displayName // .chain.name // ""), + chainFamily: ((.chain.chainFamily // "EVM") | ascii_downcase), + environment: (.chain.environment // "testnet"), + chainId: (.chain.chainId | tostring), + chainSelector: (.chain.chainSelector | tostring), + explorerUrl: (.chainMetadata.explorer.url // ""), + nativeCurrencySymbol: (.chainMetadata.nativeCurrency.symbol // "") +}' "$body_file" diff --git a/script/config/ccip-config-source.sh b/script/config/ccip-config-source.sh new file mode 100755 index 0000000..ded4925 --- /dev/null +++ b/script/config/ccip-config-source.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ccip-config-source.sh +# +# The CCIP REST API v2 config source (https://api.ccip.chain.link/v2): the fetch + transform half of +# the config-sync seam. GETs the per-chain detail (`GET /chains/{selector}`) and flattens +# `chainConfig` to the single ACTIVE (`isActive: true`) address per contract type, emitting a +# compact, normalized JSON object on stdout whose keys mirror the repo's `config/chains/.json` +# `ccip{}` block. Foundry (script/config/SyncCcipConfig.s.sol) then parses this and writes the +# config file — JSON *file* generation stays Foundry-side; this script only fetches + selects. +# +# The flat JSON also carries `apiName` + `chainId` so the Solidity side can cross-check the local +# config's chainId against what the selector ACTUALLY resolves to (the SELECTOR MISMATCH guard), plus +# the API-served identity + metadata fields the sync now MAINTAINS alongside `ccip{}`: +# `displayName`, `chainFamily` (lowercased to match the repo schema), `environment` (from `.chain`), +# and `explorerUrl` / `nativeCurrencySymbol` (from `.chainMetadata.explorer.url` / +# `.chainMetadata.nativeCurrency.symbol`). The per-selector body carries all of these in one GET — no +# extra round-trip — so the sync can source EVERY API-served field from the API instead of by hand. +# +# Exit-code contract (consumed by CcipApiSource via vm.tryFfi — stderr becomes the revert reason): +# 0 OK (flat JSON on stdout) +# 2 MISSING_TOOL curl or jq not installed +# 4 NOT_FOUND HTTP 404 — no chain for this selector (typo'd .chainSelector) +# 5 API_UNREACHABLE network error / timeout / 5xx (flake, NOT drift — retry later) +# 6 BAD_BODY 200 but chainConfig lacks an active entry (partial/unsupported chain) +# +# The API base URL can be overridden with the non-secret CCIP_API_BASE env var (see .env.example). +set -euo pipefail + +err() { echo "[ccip-config-source] $*" >&2; } + +for tool in curl jq; do + command -v "$tool" > /dev/null 2>&1 || { + err "MISSING_TOOL: '$tool' not found on PATH - install it (e.g. brew install $tool)" + exit 2 + } +done + +SELECTOR="${1:?usage: ccip-config-source.sh }" +BASE_URL="${CCIP_API_BASE:-https://api.ccip.chain.link/v2}" + +body_file="$(mktemp)" +trap 'rm -f "$body_file"' EXIT + +http_code="$(curl -sS --retry 3 --max-time 30 -o "$body_file" -w '%{http_code}' \ + "${BASE_URL}/chains/${SELECTOR}" 2> /dev/null)" || { + err "API_UNREACHABLE: could not reach ${BASE_URL}/chains/${SELECTOR} (network error/timeout after retries) - retry later or fix CCIP_API_BASE" + exit 5 +} + +case "$http_code" in + 200) ;; + 404) + err "NOT_FOUND: no chain for selector ${SELECTOR} - check .chainSelector in your config (script/config/sync-discover.sh lists valid selectors)" + exit 4 + ;; + *) + err "API_UNREACHABLE: HTTP ${http_code} from ${BASE_URL}/chains/${SELECTOR} (server error/flake, not config drift) - retry later" + exit 5 + ;; +esac + +# Key mapping API -> repo schema: rmn -> rmnProxy, registryModule -> registryModuleOwnerCustom, +# link = the feeTokens entry with tokenSymbol "LINK". +jq -c ' + .chainConfig as $c + | def act(k): (($c[k] // []) | ((map(select(.isActive == true))[0]) // .[0]) + | (.address // error("no active \(k) entry in chainConfig"))); + { + apiName: (.chain.name // error("no .chain.name in API body")), + chainId: ((.chain.chainId | tostring) // error("no .chain.chainId in API body")), + displayName: (.chain.displayName // .chain.name // ""), + chainFamily: ((.chain.chainFamily // "EVM") | ascii_downcase), + environment: (.chain.environment // "testnet"), + explorerUrl: (.chainMetadata.explorer.url // ""), + nativeCurrencySymbol: (.chainMetadata.nativeCurrency.symbol // ""), + router: act("router"), + rmnProxy: act("rmn"), + tokenAdminRegistry: act("tokenAdminRegistry"), + registryModuleOwnerCustom: act("registryModule"), + feeQuoter: act("feeQuoter"), + tokenPoolFactory: act("tokenPoolFactory"), + link: ((($c.feeTokens // []) | map(select(.tokenSymbol == "LINK")) | .[0].tokenAddress) + // error("no LINK fee token in chainConfig.feeTokens")), + feeTokens: [ (($c.feeTokens // [])[] | .tokenAddress) ] + } +' "$body_file" || { + err "BAD_BODY: chainConfig for selector ${SELECTOR} is missing an active entry (see jq error above) - partial or non-EVM chain? Non-EVM chains are not API-syncable" + exit 6 +} diff --git a/script/config/sync-check.sh b/script/config/sync-check.sh new file mode 100755 index 0000000..2795e6f --- /dev/null +++ b/script/config/sync-check.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# sync-check.sh [chain ...] — READ-ONLY config drift check, wrapping SyncCcipConfig.check(string). +# +# With no args, checks EVERY config/chains/*.json (non-EVM files SKIP inside Solidity). +# Classifies the forge output into a CI-ready exit-code contract (owned by THIS script — callers +# such as the scheduled workflow rely on it): +# 0 CLEAN every checked chain's ccip{} matches the live API +# 1 CONFIG_DRIFT at least one chain drifted (or a real config error, e.g. NOT_FOUND selector) +# 2 API_UNREACHABLE the API could not be reached for at least one chain and NOTHING drifted +# (flake, not drift — CI should warn-and-pass, never go red on this) +# +# The sync path reads no secret, so a missing .env is tolerated (CI has none). +set -uo pipefail + +cd "$(dirname "$0")/../.." + +# shellcheck disable=SC1091 +[ -f ./.env ] && { set -a && source ./.env && set +a; } + +chains=("$@") +if [ ${#chains[@]} -eq 0 ]; then + for f in config/chains/*.json; do + chains+=("$(basename "$f" .json)") + done +fi + +drift=0 +unreachable=0 +declare -a drifted=() flaked=() + +for name in "${chains[@]}"; do + echo ">> sync-check $name" + out="$(FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig "check(string)" "$name" 2>&1)" + status=$? + echo "$out" | grep -E "DRIFT |SKIP |CLEAN |SELECTOR MISMATCH|API_UNREACHABLE|NOT_FOUND|CONFIG_DRIFT" || true + if [ $status -ne 0 ]; then + if echo "$out" | grep -q "API_UNREACHABLE"; then + unreachable=1 + flaked+=("$name") + else + drift=1 + drifted+=("$name") + fi + fi +done + +if [ $drift -ne 0 ]; then + echo "sync-check: CONFIG_DRIFT (or config error) for: ${drifted[*]} - refresh with:" \ + "FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig \"run(string)\" " + exit 1 +elif [ $unreachable -ne 0 ]; then + echo "sync-check: API_UNREACHABLE for: ${flaked[*]} - flake, not drift; retry later" + exit 2 +fi +echo "sync-check: CLEAN - no drift against the live API" +exit 0 diff --git a/script/config/sync-discover.sh b/script/config/sync-discover.sh new file mode 100755 index 0000000..87eb131 --- /dev/null +++ b/script/config/sync-discover.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# sync-discover.sh — step 1 of onboarding a chain: list the CCIP REST API v2 testnet catalog with +# local state awareness. +# +# Columns: DISPLAY NAME | API NAME | FAMILY | SELECTOR | CHAIN ID | STATUS. STATUS joins the local +# config/chains/*.json files **BY SELECTOR** (the numeric identity key). The config `name` now +# matches the API selectorName one-to-one (e.g. "ethereum-testnet-sepolia-mantle-1"), but the join +# stays on the selector. Shows `configured()` vs `available`. +# FILTER= filters case-insensitively across all columns. +# +# Exit codes: 0 OK | 2 MISSING_TOOL | 5 API_UNREACHABLE +set -euo pipefail + +err() { echo "[sync-discover] $*" >&2; } + +for tool in curl jq; do + command -v "$tool" > /dev/null 2>&1 || { + err "MISSING_TOOL: '$tool' not found on PATH - install it (e.g. brew install $tool)" + exit 2 + } +done + +cd "$(dirname "$0")/../.." + +BASE_URL="${CCIP_API_BASE:-https://api.ccip.chain.link/v2}" +FILTER="${FILTER:-}" + +body_file="$(mktemp)" +map_file="$(mktemp)" +trap 'rm -f "$body_file" "$map_file"' EXIT + +http_code="$(curl -sS --retry 3 --max-time 30 -o "$body_file" -w '%{http_code}' \ + "${BASE_URL}/chains?environment=testnet" 2> /dev/null)" || { + err "API_UNREACHABLE: could not reach ${BASE_URL}/chains (network error/timeout) - retry later" + exit 5 +} +if [ "$http_code" != "200" ]; then + err "API_UNREACHABLE: HTTP ${http_code} from ${BASE_URL}/chains?environment=testnet - retry later" + exit 5 +fi + +# selector -> comma-joined local config names +for f in config/chains/*.json; do + jq -r '[(.chainSelector | tostring), .name] | @tsv' "$f" +done | awk -F'\t' '{ a[$1] = (a[$1] == "" ? $2 : a[$1] "," $2) } END { for (s in a) print s "\t" a[s] }' \ + > "$map_file" + +{ + printf 'DISPLAY NAME\tAPI NAME\tFAMILY\tSELECTOR\tCHAIN ID\tSTATUS\n' + jq -r '(if type == "array" then . else .chains end)[] + | [(.displayName // .name), .name, (.chainFamily // "?"), + (.chainSelector | tostring), (.chainId | tostring)] | @tsv' "$body_file" | + awk -F'\t' -v OFS='\t' ' + NR == FNR { cfg[$1] = $2; next } + { status = ($4 in cfg) ? "configured(" cfg[$4] ")" : "available"; print $1, $2, $3, $4, $5, status } + ' "$map_file" - | + { if [ -n "$FILTER" ]; then grep -i -- "$FILTER" || true; else cat; fi } | + sort +} | column -t -s "$(printf '\t')" + +echo "" +echo "onboard one: make add-chain CHAIN= SELECTOR= (FILTER= narrows this list)" +echo " raw: FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol" \ + "--sig \"init(string,uint256)\" " diff --git a/script/config/test-tooling.sh b/script/config/test-tooling.sh new file mode 100755 index 0000000..cc57085 --- /dev/null +++ b/script/config/test-tooling.sh @@ -0,0 +1,462 @@ +#!/usr/bin/env bash +# test-tooling.sh — re-runnable failure-path + fixture tests for the chain-config tooling. +# +# These guards are the point of the tooling: every failure must be FAST and self-explaining (never a +# raw cheatcode revert), and the happy paths must be no-op-safe. Uses a throwaway config file +# (config/chains/tooling-tmp.json, cleaned up on exit) — never mutates the real chain files. +# Network cases hit the live CCIP API (read-only); API-down is simulated via CCIP_API_BASE; the +# sync TRANSFORM cases run fully offline against the committed API fixture +# (test/fixtures/ccip-api/) served by a local python3 http.server. +# +# Run from the repo root: bash script/config/test-tooling.sh +set -uo pipefail + +cd "$(dirname "$0")/../.." + +TMP_CHAIN="tooling-tmp" +TMP_FILE="config/chains/${TMP_CHAIN}.json" +FIXTURE="test/fixtures/ccip-api/chain-16015286601757825753.json" +SEPOLIA_SELECTOR="16015286601757825753" +# A real, non-bundled chain used by the add-chain print-names case; its generated config is a +# throwaway removed on exit (never committed). +FUJI_CHAIN="avalanche-testnet-fuji" +FUJI_FILE="config/chains/${FUJI_CHAIN}.json" +FUJI_SELECTOR="14767482510784806043" +pass=0 +fail=0 +declare -a failures=() +server_pid="" +server_dir="" + +cleanup() { + rm -f "$TMP_FILE" "$FUJI_FILE" + [ -n "$server_pid" ] && kill "$server_pid" 2> /dev/null + [ -n "$server_dir" ] && rm -rf "$server_dir" +} +trap cleanup EXIT + +# run_case -- +run_case() { + local name="$1" expect="$2" pattern="$3" + shift 4 # name expect pattern -- + local out status + out="$("$@" 2>&1)" + status=$? + local ok=1 + if [ "$expect" = "zero" ] && [ $status -ne 0 ]; then ok=0; fi + if [ "$expect" = "nonzero" ] && [ $status -eq 0 ]; then ok=0; fi + if ! echo "$out" | grep -q -- "$pattern"; then ok=0; fi + if [ $ok -eq 1 ]; then + pass=$((pass + 1)) + echo "[PASS] $name" + else + fail=$((fail + 1)) + failures+=("$name") + echo "[FAIL] $name (exit=$status, expected $expect + /$pattern/)" + echo "$out" | tail -8 | sed 's/^/ | /' + fi +} + +sync_script() { + FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol "$@" +} + +echo "== test-tooling: chain-config tooling failure-path + fixture suite ==" + +# ---------------------------------------------------------------- guards (no network) + +# 1. unknown chain -> helpful list of configured chains, never a raw cheatcode revert +run_case "sync unknown chain lists known chains" nonzero "Known chains:" -- \ + sync_script --sig "run(string)" doesnotexist + +# 2. direct invocation without the sync profile -> profile guard with the real fix +run_case "profile guard names the sync profile" nonzero "FOUNDRY_PROFILE=sync" -- \ + forge script script/config/SyncCcipConfig.s.sol --sig "run(string)" ethereum-testnet-sepolia + +# 3. add-chain refuses to overwrite an existing config +run_case "add-chain refuses to overwrite an existing config" nonzero "already exists - refusing to overwrite" -- \ + sync_script --sig "init(string,uint256)" ethereum-testnet-sepolia "$SEPOLIA_SELECTOR" + +# 4. chain names become file paths + shell args: unsafe names are refused up front +run_case "add-chain rejects a path-traversal name" nonzero "invalid chain name" -- \ + sync_script --sig "init(string,uint256)" "../evil" "$SEPOLIA_SELECTOR" +run_case "add-chain rejects a name with a space" nonzero "invalid chain name" -- \ + sync_script --sig "init(string,uint256)" "evil name" "$SEPOLIA_SELECTOR" + +# 5. non-EVM chain -> Solidity-side SKIP (covers every entrypoint), exit 0 +run_case "sync on a non-EVM chain SKIPs cleanly" zero "SKIP solana-devnet - chainFamily svm" -- \ + sync_script --sig "run(string)" solana-devnet +run_case "check on a non-EVM chain SKIPs cleanly" zero "SKIP solana-devnet - chainFamily svm" -- \ + sync_script --sig "check(string)" solana-devnet + +# ---------------------------------------------------------------- live API (read-only) + +# 6. wrong-but-valid SELECTOR for an existing config -> SELECTOR MISMATCH naming both chainIds +cat > "$TMP_FILE" << 'EOF' +{ + "name": "tooling-tmp", + "displayName": "Tooling test stub (throwaway)", + "chainNameIdentifier": "TOOLING_TMP", + "chainFamily": "evm", + "environment": "testnet", + "chainId": "99999", + "chainSelector": "16015286601757825753", + "rpcEnv": "TOOLING_TMP_RPC_URL", + "confirmations": 2, + "explorerUrl": "", + "nativeCurrencySymbol": "", + "ccipBnM": "0x0000000000000000000000000000000000000000", + "ccip": {} +} +EOF +run_case "wrong selector -> SELECTOR MISMATCH naming both chainIds" nonzero \ + "SELECTOR MISMATCH for tooling-tmp: config says chainId 99999 but the selector resolves to chainId 11155111" -- \ + sync_script --sig "run(string)" "$TMP_CHAIN" + +# 6b. right chainId+selector but a NON-canonical name -> SELECTOR NAME MISMATCH (the selectorName +# guard: the config `name` must equal the CCIP registry/API selectorName). chainId matches so the +# chainId guard passes and the NAME guard is the one that fires. +python3 -c " +import json +d = json.load(open('$TMP_FILE')) +d['chainId'] = '11155111'; d['name'] = 'ethereum-sepolia' +json.dump(d, open('$TMP_FILE','w'), indent=4) +" +run_case "non-canonical name -> SELECTOR NAME MISMATCH names the canonical selectorName" nonzero \ + "SELECTOR NAME MISMATCH for tooling-tmp: config name 'ethereum-sepolia' is not the canonical selectorName" -- \ + sync_script --sig "run(string)" "$TMP_CHAIN" + +# 7. unknown selector -> NOT_FOUND from the fetch script, surfaced as the revert reason +python3 -c " +import json +d = json.load(open('$TMP_FILE')); d['chainSelector'] = '123'; json.dump(d, open('$TMP_FILE','w'), indent=4) +" +run_case "unknown selector -> named NOT_FOUND error" nonzero "NOT_FOUND: no chain for selector 123" -- \ + sync_script --sig "run(string)" "$TMP_CHAIN" +rm -f "$TMP_FILE" + +# 7b. add-chain enforces the selectorName too: a non-canonical CHAIN name for a real selector fails +# up front (this is the guard path that also validates non-EVM chains, whose chainId is "0"). +run_case "add-chain rejects a non-canonical name -> SELECTOR NAME MISMATCH" nonzero \ + "SELECTOR NAME MISMATCH for ethereum-sepolia: config name 'ethereum-sepolia' is not the canonical selectorName" -- \ + sync_script --sig "init(string,uint256)" ethereum-sepolia "$SEPOLIA_SELECTOR" + +# 7c. add-chain PRINTS the exact derived env-var names so the operator never has to guess (or open +# the JSON) which var to export. Uses a real, non-bundled chain (Fuji) whose UPPER_SNAKE-derived +# AVALANCHE_TESTNET_FUJI differs from a curated short form; the generated config is a throwaway +# removed here and in cleanup(). Asserts BOTH the chainNameIdentifier and the rpcEnv line. +rm -f "$FUJI_FILE" +out="$(sync_script --sig "init(string,uint256)" "$FUJI_CHAIN" "$FUJI_SELECTOR" 2>&1)" +if echo "$out" | grep -q "chainNameIdentifier: AVALANCHE_TESTNET_FUJI" && + echo "$out" | grep -q "rpcEnv: *AVALANCHE_TESTNET_FUJI_RPC_URL"; then + pass=$((pass + 1)) + echo "[PASS] add-chain prints the generated chainNameIdentifier + rpcEnv names" +else + fail=$((fail + 1)) + failures+=("add-chain prints env-var names") + echo "[FAIL] add-chain prints env-var names (missing chainNameIdentifier/rpcEnv line)" + echo "$out" | tail -8 | sed 's/^/ | /' +fi +rm -f "$FUJI_FILE" + +# 8. API down (CCIP_API_BASE override) -> distinct API_UNREACHABLE error +run_case "API down -> named API_UNREACHABLE error" nonzero "API_UNREACHABLE" -- \ + env CCIP_API_BASE=http://127.0.0.1:1 bash -c \ + 'FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig "run(string)" ethereum-testnet-sepolia-mantle-1' + +# ---------------------------------------------------------------- sync-check exit contract (0/1/2) + +# 9. clean chain -> exit 0 (live API) +run_case "sync-check clean chain exits 0" zero "CLEAN" -- \ + bash script/config/sync-check.sh ethereum-testnet-sepolia-mantle-1 + +# 10. drift (mutated router on a throwaway COPY of ethereum-testnet-sepolia) -> exit 1 + DRIFT line +python3 -c " +import json +d = json.load(open('config/chains/ethereum-testnet-sepolia.json')) +d['ccip']['router'] = '0x0000000000000000000000000000000000000001' +json.dump(d, open('$TMP_FILE','w'), indent=4) +" +out="$(bash script/config/sync-check.sh "$TMP_CHAIN" 2>&1)" +status=$? +if [ $status -eq 1 ] && echo "$out" | grep -q "DRIFT tooling-tmp .ccip.router"; then + pass=$((pass + 1)) + echo "[PASS] sync-check classifies drift as exit 1 + DRIFT line" +else + fail=$((fail + 1)) + failures+=("sync-check drift") + echo "[FAIL] sync-check drift (exit=$status)" + echo "$out" | tail -6 | sed 's/^/ | /' +fi + +# 11. API down -> exit 2 (flake, not drift) +out="$(CCIP_API_BASE=http://127.0.0.1:1 bash script/config/sync-check.sh "$TMP_CHAIN" 2>&1)" +status=$? +if [ $status -eq 2 ] && echo "$out" | grep -q "API_UNREACHABLE"; then + pass=$((pass + 1)) + echo "[PASS] sync-check classifies API-down as exit 2" +else + fail=$((fail + 1)) + failures+=("sync-check api-down") + echo "[FAIL] sync-check api-down (exit=$status)" + echo "$out" | tail -6 | sed 's/^/ | /' +fi +rm -f "$TMP_FILE" + +# ---------------------------------------------------------------- fixture transform (offline) + +# Serve the committed API fixture from a local http.server so the REAL pipeline (curl + jq select + +# Solidity vm.writeJson) runs offline: GET /chains/ -> the committed real response. +server_dir="$(mktemp -d)" +mkdir -p "$server_dir/chains" +cp "$FIXTURE" "$server_dir/chains/$SEPOLIA_SELECTOR" +port=$((20000 + RANDOM % 20000)) +python3 -m http.server "$port" --directory "$server_dir" > /dev/null 2>&1 & +server_pid=$! +for _ in $(seq 1 20); do + curl -s -o /dev/null "http://127.0.0.1:$port/chains/$SEPOLIA_SELECTOR" && break + sleep 0.5 +done + +# 12. sync-config against the fixture server: ccip{} rewritten from the fixture's isActive entries, +# every non-ccip key preserved, and a SECOND run leaves the file byte-identical (idempotency). +python3 -c " +import json +d = json.load(open('config/chains/ethereum-testnet-sepolia.json')) +d['ccip'] = {'router': '0x0000000000000000000000000000000000000001', + 'rmnProxy': '0x0000000000000000000000000000000000000001', + 'tokenAdminRegistry': '0x0000000000000000000000000000000000000001', + 'registryModuleOwnerCustom': '0x0000000000000000000000000000000000000001', + 'link': '0x0000000000000000000000000000000000000001', + 'feeQuoter': '0x0000000000000000000000000000000000000001', + 'tokenPoolFactory': '0x0000000000000000000000000000000000000001', + 'feeTokens': []} +json.dump(d, open('$TMP_FILE','w'), indent=4) +" +run_case "fixture sync: run() succeeds against the local fixture server" zero "wrote .ccip block" -- \ + env CCIP_API_BASE="http://127.0.0.1:$port" bash -c \ + "FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig 'run(string)' $TMP_CHAIN" + +out="$(python3 -c " +import json +synced = json.load(open('$TMP_FILE')) +committed = json.load(open('config/chains/ethereum-testnet-sepolia.json')) +# 1. ccip{} == the committed sepolia block (fixture is a real API response for the same chain) +assert synced['ccip'] == committed['ccip'], ('ccip mismatch', synced['ccip']) +# 2. every non-ccip key preserved verbatim (the throwaway keeps the committed canonical .name, so +# the selectorName guard passes and nothing outside .ccip is touched) +for k, v in committed.items(): + if k == 'ccip': + continue + assert synced[k] == v, ('extra key mutated', k, synced[k], v) +print('TRANSFORM_OK') +" 2>&1)" +if echo "$out" | grep -q "TRANSFORM_OK"; then + pass=$((pass + 1)) + echo "[PASS] fixture sync: isActive selection + ccip-subtree-only write + extras preserved" +else + fail=$((fail + 1)) + failures+=("fixture transform") + echo "[FAIL] fixture transform: $out" +fi + +before="$(shasum "$TMP_FILE")" +CCIP_API_BASE="http://127.0.0.1:$port" FOUNDRY_PROFILE=sync \ + forge script script/config/SyncCcipConfig.s.sol --sig "run(string)" "$TMP_CHAIN" > /dev/null 2>&1 +after="$(shasum "$TMP_FILE")" +if [ "$before" = "$after" ]; then + pass=$((pass + 1)) + echo "[PASS] fixture sync: second run is idempotent (file byte-identical)" +else + fail=$((fail + 1)) + failures+=("fixture idempotency") + echo "[FAIL] fixture sync: second run mutated the file" +fi + +# 12b. API-served fields sourced from the API: a HAND-EDITED displayName/environment/explorerUrl/ +# nativeCurrencySymbol is CORRECTED to the API value on sync, while the genuinely hand-authored +# keys (confirmations, ccipBnM, rpcEnv, chainNameIdentifier) survive VERBATIM. This is the +# one-writer-per-field guarantee for the widened synced surface. +cat > "$TMP_FILE" << 'EOF' +{ + "name": "ethereum-testnet-sepolia", + "displayName": "HAND WRONG NAME", + "chainNameIdentifier": "CUSTOM_HAND_ID", + "chainFamily": "evm", + "environment": "mainnet", + "chainId": "11155111", + "chainSelector": "16015286601757825753", + "rpcEnv": "CUSTOM_HAND_RPC_URL", + "confirmations": 7, + "explorerUrl": "http://hand.example/wrong", + "nativeCurrencySymbol": "XXX", + "ccipBnM": "0x000000000000000000000000000000000000dEaD", + "ccip": {} +} +EOF +run_case "sync sources API-served metadata from the API (corrects hand values)" zero "wrote .ccip block + metadata" -- \ + env CCIP_API_BASE="http://127.0.0.1:$port" bash -c \ + "FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig 'run(string)' $TMP_CHAIN" + +out="$(python3 -c " +import json +d = json.load(open('$TMP_FILE')) +api = json.load(open('config/chains/ethereum-testnet-sepolia.json')) +# API-served fields are now the API's values, NOT the hand-edited ones +assert d['displayName'] == api['displayName'], ('displayName not sourced', d['displayName']) +assert d['environment'] == api['environment'], ('environment not sourced', d['environment']) +assert d['explorerUrl'] == api['explorerUrl'], ('explorerUrl not sourced', d['explorerUrl']) +assert d['nativeCurrencySymbol'] == api['nativeCurrencySymbol'], ('nativeCurrencySymbol not sourced', d['nativeCurrencySymbol']) +assert d['chainFamily'] == 'evm', ('chainFamily not normalized', d['chainFamily']) +# genuinely hand-authored keys survive verbatim +assert d['confirmations'] == 7, ('confirmations clobbered', d['confirmations']) +assert d['ccipBnM'].lower() == '0x000000000000000000000000000000000000dead', ('ccipBnM clobbered', d['ccipBnM']) +assert d['rpcEnv'] == 'CUSTOM_HAND_RPC_URL', ('rpcEnv clobbered', d['rpcEnv']) +assert d['chainNameIdentifier'] == 'CUSTOM_HAND_ID', ('chainNameIdentifier clobbered', d['chainNameIdentifier']) +print('SOURCE_OK') +" 2>&1)" +if echo "$out" | grep -q "SOURCE_OK"; then + pass=$((pass + 1)) + echo "[PASS] sync sources API-served fields + preserves hand-authored keys verbatim" +else + fail=$((fail + 1)) + failures+=("metadata sourcing") + echo "[FAIL] metadata sourcing: $out" +fi + +# 12c. drift in an API-served metadata field is caught by sync-check (exit 1 + DRIFT line), same +# contract as ccip{} address drift. +python3 -c " +import json +d = json.load(open('config/chains/ethereum-testnet-sepolia.json')) +d['explorerUrl'] = 'https://tampered.example' +json.dump(d, open('$TMP_FILE','w'), indent=2) +" +out="$(CCIP_API_BASE="http://127.0.0.1:$port" bash script/config/sync-check.sh "$TMP_CHAIN" 2>&1)" +status=$? +if [ $status -eq 1 ] && echo "$out" | grep -q "DRIFT tooling-tmp .explorerUrl"; then + pass=$((pass + 1)) + echo "[PASS] sync-check catches metadata drift (.explorerUrl) as exit 1 + DRIFT line" +else + fail=$((fail + 1)) + failures+=("metadata drift check") + echo "[FAIL] metadata drift check (exit=$status, expected 1 + /DRIFT tooling-tmp .explorerUrl/)" + echo "$out" | tail -6 | sed 's/^/ | /' +fi +rm -f "$TMP_FILE" + +# 13. sync-check against the fixture server -> CLEAN exit 0 (offline check path). The throwaway is a +# copy of the committed sepolia file (name stays the canonical selectorName so the guard passes), +# so every synced field (ccip{} + metadata) already matches the fixture. +cp config/chains/ethereum-testnet-sepolia.json "$TMP_FILE" +run_case "fixture sync-check: clean against the fixture server" zero "CLEAN" -- \ + env CCIP_API_BASE="http://127.0.0.1:$port" bash script/config/sync-check.sh "$TMP_CHAIN" +rm -f "$TMP_FILE" + +# ---------------------------------------------------------------- check-chain doctor + +# 14. unknown chain -> attributed FAIL + nonzero verdict +run_case "check-chain unknown chain FAILs with the add-chain hint" nonzero "config: no config/chains/doesnotexist" -- \ + env FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig "run(string)" doesnotexist + +# 15. non-EVM chain -> schema parse only, 0 FAIL +run_case "check-chain on solana-devnet passes (non-EVM path)" zero "0 FAIL" -- \ + env FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig "run(string)" solana-devnet + +# 16. EVM chain with rpcEnv unset -> rpc SKIP (not FAIL), overall 0 FAIL (live API for the drift rung) +run_case "check-chain SKIPs rpc when the rpcEnv var is unset" zero "\[SKIP\] rpc: env MANTLE_SEPOLIA_RPC_URL unset" -- \ + env -u MANTLE_SEPOLIA_RPC_URL FOUNDRY_PROFILE=sync \ + forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig "run(string)" ethereum-testnet-sepolia-mantle-1 + +# ---------------------------------------------------------------- Makefile golden path + +# 17. the make wrappers guard their arguments and stay consistent with the scripts they wrap. +run_case "make help lists the golden-path targets" zero "add-chain" -- \ + make help +run_case "make tools reports the required tools" zero "all present" -- \ + make tools +run_case "make add-chain without CHAIN errors up front" nonzero "CHAIN is required" -- \ + make add-chain +run_case "make add-chain without SELECTOR errors up front" nonzero "SELECTOR is required" -- \ + make add-chain CHAIN=tooling-make-tmp +run_case "make sync on an unknown chain prints the add-chain hint" nonzero "make add-chain CHAIN=" -- \ + make sync CHAIN=doesnotexist +run_case "make doctor without CHAIN errors up front" nonzero "CHAIN is required" -- \ + make doctor + +# 18. the make exit-code remap: the canonical 0/1/2 contract belongs to sync-check.sh (case 10 +# proved drift -> exit 1 there); GNU make remaps ANY failing recipe to ITS OWN exit 2, so +# `make sync-check` reports the same drift as exit 2 - pass/fail only. CI must call the script. +python3 -c " +import json +d = json.load(open('config/chains/ethereum-testnet-sepolia.json')) +d['ccip']['router'] = '0x0000000000000000000000000000000000000001' +json.dump(d, open('$TMP_FILE','w'), indent=4) +" +out="$(make sync-check CHAIN="$TMP_CHAIN" 2>&1)" +status=$? +if [ $status -eq 2 ] && echo "$out" | grep -q "CONFIG_DRIFT"; then + pass=$((pass + 1)) + echo "[PASS] make sync-check remaps the script's drift exit 1 to make exit 2 (pass/fail only)" +else + fail=$((fail + 1)) + failures+=("make sync-check remap") + echo "[FAIL] make sync-check remap (exit=$status, expected 2 + /CONFIG_DRIFT/)" + echo "$out" | tail -6 | sed 's/^/ | /' +fi +rm -f "$TMP_FILE" + +# ---------------------------------------------------------------- canonical config format + +# 19. the canonical-format guarantee: committed configs ARE canon (fmt-config -> no diff vs the git +# index), fmt-config is idempotent (second run byte-identical), and a real live `make sync` on a +# clean chain yields ZERO git diff (the recipe re-canonicalizes vm.writeJson's output). +run_case "make fmt-config runs clean" zero "canonicalized" -- \ + make fmt-config +if git diff --exit-code --quiet -- config/chains/; then + pass=$((pass + 1)) + echo "[PASS] fmt-config: committed configs are already canonical (zero git diff)" +else + fail=$((fail + 1)) + failures+=("fmt-config committed==canon") + echo "[FAIL] fmt-config: committed configs are NOT canonical (git diff below)" + git diff --stat -- config/chains/ | sed 's/^/ | /' +fi +before="$(shasum config/chains/*.json)" +make fmt-config > /dev/null 2>&1 +after="$(shasum config/chains/*.json)" +if [ "$before" = "$after" ]; then + pass=$((pass + 1)) + echo "[PASS] fmt-config: second run is idempotent (files byte-identical)" +else + fail=$((fail + 1)) + failures+=("fmt-config idempotency") + echo "[FAIL] fmt-config: second run mutated the files" +fi + +# 20. live zero-diff sync: with sync-check CLEAN (no value drift), `make sync` must be byte-identical +# to the committed file — this is the no-churn guarantee the canonical format exists for. +if bash script/config/sync-check.sh ethereum-testnet-sepolia > /dev/null 2>&1; then + make sync CHAIN=ethereum-testnet-sepolia > /dev/null 2>&1 + if git diff --exit-code --quiet -- config/chains/ethereum-testnet-sepolia.json; then + pass=$((pass + 1)) + echo "[PASS] live sync on a CLEAN chain yields zero git diff (canonical format)" + else + fail=$((fail + 1)) + failures+=("live sync zero-diff") + echo "[FAIL] live sync on a CLEAN chain produced a git diff:" + git diff -- config/chains/ethereum-testnet-sepolia.json | head -20 | sed 's/^/ | /' + git checkout -- config/chains/ethereum-testnet-sepolia.json 2> /dev/null + fi +else + fail=$((fail + 1)) + failures+=("live sync zero-diff (precheck)") + echo "[FAIL] live sync zero-diff: precheck sync-check ethereum-testnet-sepolia not CLEAN (drift or API down)" +fi + +echo "" +echo "== test-tooling: $pass passed, $fail failed ==" +if [ $fail -ne 0 ]; then + printf 'failed: %s\n' "${failures[@]}" + exit 1 +fi diff --git a/script/configure/allowlist/DeployAdvancedPoolHooks.s.sol b/script/configure/allowlist/DeployAdvancedPoolHooks.s.sol index 9abe2fe..7638b73 100644 --- a/script/configure/allowlist/DeployAdvancedPoolHooks.s.sol +++ b/script/configure/allowlist/DeployAdvancedPoolHooks.s.sol @@ -5,6 +5,8 @@ import {Script, console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {HelperUtils} from "../../utils/HelperUtils.s.sol"; import {DeploymentUtils} from "../../utils/DeploymentUtils.s.sol"; +import {DeploymentRecorder} from "../../utils/DeploymentRecorder.s.sol"; +import {RegistryWriter} from "../../../src/utils/RegistryWriter.sol"; import {AdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/pools/AdvancedPoolHooks.sol"; /** @@ -55,13 +57,11 @@ contract DeployAdvancedPoolHooks is Script { console.log(""); // Define the path to the configuration file - string memory root = vm.projectRoot(); - string memory configPath = string.concat(root, "/script/input/advanced-pool-hooks.json"); + string memory configPath = string.concat(vm.projectRoot(), "/script/input/advanced-pool-hooks.json"); // Parse parameters — env vars take priority, JSON config is the fallback - string memory allowlistEnv = vm.envOr("ALLOWLIST", string("")); - address[] memory allowlist = bytes(allowlistEnv).length > 0 - ? HelperUtils.parseAddressArray(vm, allowlistEnv, "") + address[] memory allowlist = bytes(vm.envOr("ALLOWLIST", string(""))).length > 0 + ? HelperUtils.parseAddressArray(vm, vm.envOr("ALLOWLIST", string("")), "") : HelperUtils.parseAddressArray(vm, configPath, ".allowlist"); uint256 thresholdAmount = @@ -69,9 +69,8 @@ contract DeployAdvancedPoolHooks is Script { address policyEngine = vm.envOr("POLICY_ENGINE", HelperUtils.getAddressFromJson(vm, configPath, ".policyEngine")); - string memory callersEnv = vm.envOr("AUTHORIZED_CALLERS", string("")); - address[] memory authorizedCallers = bytes(callersEnv).length > 0 - ? HelperUtils.parseAddressArray(vm, callersEnv, "") + address[] memory authorizedCallers = bytes(vm.envOr("AUTHORIZED_CALLERS", string(""))).length > 0 + ? HelperUtils.parseAddressArray(vm, vm.envOr("AUTHORIZED_CALLERS", string("")), "") : HelperUtils.parseAddressArray(vm, configPath, ".authorizedCallers"); console.log("Advanced Pool Hooks Parameters:"); @@ -102,6 +101,11 @@ contract DeployAdvancedPoolHooks is Script { } console.log(""); + // Hooks belong to a token's pool, so the registry key carries the token symbol and pool type + // (see _hooksDeploymentName). Refuse to redeploy over a live registry entry (FORCE_REDEPLOY + // overrides). The name is composed in a helper to keep this stack-heavy function under the limit. + RegistryWriter.guard(chainId, _hooksDeploymentName(chainId)); + vm.startBroadcast(); console.log(string.concat("\n[Step 1] Deploying AdvancedPoolHooks on ", chainName)); @@ -113,6 +117,23 @@ contract DeployAdvancedPoolHooks is Script { vm.stopBroadcast(); + _recordAndReport( + chainId, chainNameId, chainName, hooksAddress, allowlist, thresholdAmount, policyEngine, authorizedCallers + ); + } + + /// @dev Post-deploy: the single-writer registry+ledger record and the human-readable summary. + /// Split off `run()` so its locals do not add to that stack-heavy function. + function _recordAndReport( + uint256 chainId, + string memory chainNameId, + string memory chainName, + address hooksAddress, + address[] memory allowlist, + uint256 thresholdAmount, + address policyEngine, + address[] memory authorizedCallers + ) private { console.log(""); console.log("========================================"); console.log(string.concat(unicode"✅ Deployment Complete on ", chainName, "!")); @@ -120,7 +141,11 @@ contract DeployAdvancedPoolHooks is Script { console.log(string.concat("AdvancedPoolHooks Address: ", vm.toString(hooksAddress))); console.log(helperConfig.getExplorerUrl(chainId, "/address/", hooksAddress)); console.log(""); - DeploymentUtils.savePoolHooksDeployment(vm, chainNameId, hooksAddress); + // Single writer: one call emits the detailed ledger file AND records the address in the + // registry (deployments[{symbol}_{poolType}_PoolHooks] + active.poolHooks). + DeploymentRecorder.recordPoolHooks( + vm, chainId, chainNameId, hooksAddress, helperConfig.getDeployedToken(chainId), _hooksPoolType() + ); console.log(""); console.log("Configuration Summary:"); console.log(string.concat(" Allowlist: ", allowlist.length > 0 ? "Enabled" : "Disabled")); @@ -149,4 +174,18 @@ contract DeployAdvancedPoolHooks is Script { console.log("========================================"); console.log(""); } + + /// @dev The `deployments` key for these hooks: `{symbol}_{poolType}_PoolHooks`. The symbol comes + /// from the chain's deployed token (env `TOKEN` / `{CHAIN}_TOKEN` / registry, else `TOKEN_SYMBOL` + /// / "unknown"); the pool type from env `POOL_TYPE` (default "BurnMint"). Split into its own + /// function so its locals do not add to the stack-heavy `run()`. + function _hooksDeploymentName(uint256 chainId) private view returns (string memory) { + return DeploymentRecorder.hooksName( + DeploymentUtils.getSymbol(vm, helperConfig.getDeployedToken(chainId)), _hooksPoolType() + ); + } + + function _hooksPoolType() private view returns (string memory) { + return vm.envOr("POOL_TYPE", string("BurnMint")); + } } diff --git a/script/configure/allowlist/GetAllowList.s.sol b/script/configure/allowlist/GetAllowList.s.sol index c3c62b7..66b932a 100644 --- a/script/configure/allowlist/GetAllowList.s.sol +++ b/script/configure/allowlist/GetAllowList.s.sol @@ -20,7 +20,8 @@ contract GetAllowList is Script { uint256 chainId = block.chainid; string memory chainName = helperConfig.getChainName(chainId); - address hooksAddress = vm.envOr("POOL_HOOKS", address(0)); + // POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks (no manual export needed). + address hooksAddress = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); require( hooksAddress != address(0), string.concat( diff --git a/script/configure/allowlist/IsAllowListed.s.sol b/script/configure/allowlist/IsAllowListed.s.sol index 0032680..782b8dd 100644 --- a/script/configure/allowlist/IsAllowListed.s.sol +++ b/script/configure/allowlist/IsAllowListed.s.sol @@ -20,7 +20,12 @@ contract IsAllowListed is Script { uint256 chainId = block.chainid; string memory chainName = helperConfig.getChainName(chainId); - address hooksAddress = vm.envAddress("POOL_HOOKS"); + // POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks (no manual export needed). + address hooksAddress = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); + require( + hooksAddress != address(0), + "Pool hooks not deployed. Set POOL_HOOKS or the {CHAIN}_POOL_HOOKS environment variable." + ); address checkAddress = vm.envAddress("CHECK_ADDRESS"); console.log(""); diff --git a/script/configure/allowlist/UpdateAllowList.s.sol b/script/configure/allowlist/UpdateAllowList.s.sol index ac273fb..04d5607 100644 --- a/script/configure/allowlist/UpdateAllowList.s.sol +++ b/script/configure/allowlist/UpdateAllowList.s.sol @@ -25,7 +25,9 @@ contract UpdateAllowList is EoaExecutor { string memory chainName = helperConfig.getChainName(chainId); address tokenPoolAddress = vm.envOr("TOKEN_POOL", helperConfig.getDeployedTokenPool(chainId)); - address hooksAddress = vm.envOr("POOL_HOOKS", address(0)); + // POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks. Optional: unset (0x0) targets + // the pool itself (v1 allowlist); a resolved hooks address targets the v2 AdvancedPoolHooks. + address hooksAddress = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); require( tokenPoolAddress != address(0), diff --git a/script/configure/authorized-callers/GetAuthorizedCallers.s.sol b/script/configure/authorized-callers/GetAuthorizedCallers.s.sol index 1f64bec..5764a79 100644 --- a/script/configure/authorized-callers/GetAuthorizedCallers.s.sol +++ b/script/configure/authorized-callers/GetAuthorizedCallers.s.sol @@ -25,10 +25,19 @@ contract GetAuthorizedCallers is Script { uint256 chainId = block.chainid; string memory chainName = helperConfig.getChainName(chainId); - address poolHooks = vm.envOr("POOL_HOOKS", address(0)); - address lockBox = vm.envOr("LOCK_BOX", address(0)); - require(poolHooks != address(0) || lockBox != address(0), "POOL_HOOKS or LOCK_BOX env var required"); - require(poolHooks == address(0) || lockBox == address(0), "Only one of POOL_HOOKS or LOCK_BOX may be set"); + // Resolve the target via the standard ladder (env alias > {CHAIN}_ env > registry), so a freshly + // deployed hooks/lockbox is readable with no manual export. This script reads exactly ONE of the + // two, so when both resolve the user must pick via env. + address poolHooks = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); + address lockBox = vm.envOr("LOCK_BOX", helperConfig.getDeployedLockBox(chainId)); + require( + poolHooks != address(0) || lockBox != address(0), + "No POOL_HOOKS or LOCK_BOX resolved (env or registry). Set one, or deploy hooks/a lockbox first." + ); + require( + poolHooks == address(0) || lockBox == address(0), + "Both POOL_HOOKS and LOCK_BOX resolved (env or registry). Set exactly one explicitly to disambiguate." + ); bool isLockBox = lockBox != address(0); address contractAddress = isLockBox ? lockBox : poolHooks; diff --git a/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol b/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol index 9b54f4f..3f7563c 100644 --- a/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol +++ b/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol @@ -30,10 +30,19 @@ contract UpdateAuthorizedCallers is EoaExecutor { uint256 chainId = block.chainid; string memory chainName = helperConfig.getChainName(chainId); - address poolHooks = vm.envOr("POOL_HOOKS", address(0)); - address lockBox = vm.envOr("LOCK_BOX", address(0)); - require(poolHooks != address(0) || lockBox != address(0), "POOL_HOOKS or LOCK_BOX env var required"); - require(poolHooks == address(0) || lockBox == address(0), "Only one of POOL_HOOKS or LOCK_BOX may be set"); + // Resolve the target via the standard ladder (env alias > {CHAIN}_ env > registry), so a freshly + // deployed hooks/lockbox is targetable with no manual export. This script configures exactly ONE + // of the two, so when both resolve (e.g. both deployed on this chain) the user must pick via env. + address poolHooks = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); + address lockBox = vm.envOr("LOCK_BOX", helperConfig.getDeployedLockBox(chainId)); + require( + poolHooks != address(0) || lockBox != address(0), + "No POOL_HOOKS or LOCK_BOX resolved (env or registry). Set one, or deploy hooks/a lockbox first." + ); + require( + poolHooks == address(0) || lockBox == address(0), + "Both POOL_HOOKS and LOCK_BOX resolved (env or registry). Set exactly one explicitly to disambiguate." + ); bool isLockBox = lockBox != address(0); address contractAddress = isLockBox ? lockBox : poolHooks; diff --git a/script/deploy/DeployBurnMintTokenPool.s.sol b/script/deploy/DeployBurnMintTokenPool.s.sol index 6bd9cad..4b7d57e 100644 --- a/script/deploy/DeployBurnMintTokenPool.s.sol +++ b/script/deploy/DeployBurnMintTokenPool.s.sol @@ -8,6 +8,8 @@ import {CrossChainToken} from "@chainlink/contracts-ccip/contracts/tokens/CrossC import {IBurnMintERC20} from "@chainlink/contracts-ccip/contracts/interfaces/IBurnMintERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {DeploymentUtils} from "../utils/DeploymentUtils.s.sol"; +import {DeploymentRecorder} from "../utils/DeploymentRecorder.s.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; contract DeployBurnMintTokenPool is Script { HelperConfig public helperConfig; @@ -50,7 +52,8 @@ contract DeployBurnMintTokenPool is Script { console.log(unicode"⚠️ decimals() not found on token, falling back to DECIMALS env var"); decimals = uint8(vm.envUint("DECIMALS")); } - address poolHooks = vm.envOr("POOL_HOOKS", address(0)); + // POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks. Optional (0x0 = no hooks). + address poolHooks = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); console.log("Token Pool Parameters:"); console.log(string.concat(" Token: ", vm.toString(tokenAddress))); @@ -64,6 +67,12 @@ contract DeployBurnMintTokenPool is Script { ); console.log(""); + // Refuse to redeploy over a live registry entry (FORCE_REDEPLOY=true overrides). Keyed on the + // unique per-symbol/per-pool-type/per-version deployment name so a BurnMint and a LockRelease + // pool (or an old and a new version) for the same token never collide. + string memory symbol = DeploymentUtils.getSymbol(vm, tokenAddress); + RegistryWriter.guard(chainId, DeploymentRecorder.poolName(symbol, "BurnMint")); + vm.startBroadcast(); console.log(string.concat("\n[Step 1] Deploying BurnMintTokenPool on ", chainName)); @@ -90,6 +99,20 @@ contract DeployBurnMintTokenPool is Script { vm.stopBroadcast(); + // Assert the on-chain typeAndVersion matches the version composed into the registry key — a + // cheap guard against a pinned-dependency mismatch (recording a 2.0.0 key for a stale pool). + string memory expectedTypeAndVersion = string.concat("BurnMintTokenPool ", DeploymentRecorder.POOL_VERSION); + require( + keccak256(bytes(tokenPool.typeAndVersion())) == keccak256(bytes(expectedTypeAndVersion)), + string.concat( + "typeAndVersion mismatch: on-chain '", + tokenPool.typeAndVersion(), + "' != key '", + expectedTypeAndVersion, + "'" + ) + ); + console.log(""); console.log("========================================"); console.log(string.concat(unicode"✅ Deployment Complete on ", chainName, "!")); @@ -97,11 +120,14 @@ contract DeployBurnMintTokenPool is Script { console.log(string.concat("Token Pool Address: ", vm.toString(tokenPoolAddress))); console.log(helperConfig.getExplorerUrl(chainId, "/address/", tokenPoolAddress)); console.log(""); - DeploymentUtils.saveTokenPoolDeployment( - vm, config.chainNameIdentifier, tokenPoolAddress, tokenAddress, "BurnMint" + // Single writer: one call emits the detailed ledger file AND records the address in the + // registry (deployments[{symbol}_BurnMintTokenPool_{version}] + active.tokenPool). + DeploymentRecorder.recordTokenPool( + vm, chainId, config.chainNameIdentifier, tokenPoolAddress, tokenAddress, "BurnMint" ); console.log(""); - console.log("Run this command to set the environment variable:"); + console.log("The address is registered in the address registry; later scripts resolve it automatically."); + console.log("To override it for a session, set the environment variable:"); console.log(string.concat("export ", config.chainNameIdentifier, "_TOKEN_POOL=", vm.toString(tokenPoolAddress))); console.log("========================================"); console.log(""); diff --git a/script/deploy/DeployERC20LockBox.s.sol b/script/deploy/DeployERC20LockBox.s.sol index 70ce09e..7adce2b 100644 --- a/script/deploy/DeployERC20LockBox.s.sol +++ b/script/deploy/DeployERC20LockBox.s.sol @@ -7,6 +7,8 @@ import {HelperUtils} from "../utils/HelperUtils.s.sol"; import {ERC20LockBox} from "@chainlink/contracts-ccip/contracts/pools/ERC20LockBox.sol"; import {AuthorizedCallers} from "@chainlink/contracts/src/v0.8/shared/access/AuthorizedCallers.sol"; import {DeploymentUtils} from "../utils/DeploymentUtils.s.sol"; +import {DeploymentRecorder} from "../utils/DeploymentRecorder.s.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; /** * @title DeployERC20LockBox @@ -61,6 +63,10 @@ contract DeployERC20LockBox is Script { } console.log(""); + // Refuse to redeploy over a live registry entry (FORCE_REDEPLOY=true overrides). Keyed on the + // unique per-symbol deployment name so distinct tokens on one chain never collide. + RegistryWriter.guard(chainId, DeploymentRecorder.lockBoxName(DeploymentUtils.getSymbol(vm, tokenAddress))); + vm.startBroadcast(); console.log(string.concat("\n[Step 1] Deploying ERC20LockBox on ", chainName)); @@ -89,8 +95,11 @@ contract DeployERC20LockBox is Script { console.log(string.concat("ERC20LockBox Address: ", vm.toString(lockBoxAddress))); console.log(helperConfig.getExplorerUrl(chainId, "/address/", lockBoxAddress)); console.log(""); - DeploymentUtils.saveLockBoxDeployment(vm, chainNameId, lockBoxAddress, tokenAddress); + // Single writer: one call emits the detailed ledger file AND records the address in the + // registry (deployments[{symbol}_LockBox] + active.lockBox). + DeploymentRecorder.recordLockBox(vm, chainId, chainNameId, lockBoxAddress, tokenAddress); console.log(""); + console.log("The address is registered in the address registry; later scripts resolve it automatically."); console.log("Copy this address to use in the next commands:"); console.log(string.concat(" LOCK_BOX=", vm.toString(lockBoxAddress))); if (authorizedCallers.length == 0) { diff --git a/script/deploy/DeployLockReleaseTokenPool.s.sol b/script/deploy/DeployLockReleaseTokenPool.s.sol index 3dbc169..c7865ad 100644 --- a/script/deploy/DeployLockReleaseTokenPool.s.sol +++ b/script/deploy/DeployLockReleaseTokenPool.s.sol @@ -7,6 +7,8 @@ import {LockReleaseTokenPool} from "@chainlink/contracts-ccip/contracts/pools/Lo import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {DeploymentUtils} from "../utils/DeploymentUtils.s.sol"; +import {DeploymentRecorder} from "../utils/DeploymentRecorder.s.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; contract DeployLockReleaseTokenPool is Script { HelperConfig public helperConfig; @@ -37,9 +39,10 @@ contract DeployLockReleaseTokenPool is Script { ) ); - // Get LockBox address from environment variable (required) - address lockBox = vm.envOr("LOCK_BOX", address(0)); - require(lockBox != address(0), "LOCK_BOX env var required"); + // Get LockBox address via the standard ladder: LOCK_BOX alias > {CHAIN}_LOCK_BOX > registry + // active.lockBox (written automatically by DeployERC20LockBox). + address lockBox = vm.envOr("LOCK_BOX", helperConfig.getDeployedLockBox(chainId)); + require(lockBox != address(0), "LockBox not deployed. Set LOCK_BOX or run DeployERC20LockBox first."); // Validate router and RMN proxy addresses require(config.router != address(0), "Router not defined for this network"); @@ -53,7 +56,8 @@ contract DeployLockReleaseTokenPool is Script { console.log(unicode"⚠️ decimals() not found on token, falling back to DECIMALS env var"); decimals = uint8(vm.envUint("DECIMALS")); } - address poolHooks = vm.envOr("POOL_HOOKS", address(0)); + // POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks. Optional (0x0 = no hooks). + address poolHooks = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId)); console.log("Token Pool Parameters:"); console.log(string.concat(" Token: ", vm.toString(tokenAddress))); @@ -68,6 +72,12 @@ contract DeployLockReleaseTokenPool is Script { ); console.log(""); + // Refuse to redeploy over a live registry entry (FORCE_REDEPLOY=true overrides). Keyed on the + // unique per-symbol/per-pool-type/per-version deployment name so a BurnMint and a LockRelease + // pool (or an old and a new version) for the same token never collide. + string memory symbol = DeploymentUtils.getSymbol(vm, tokenAddress); + RegistryWriter.guard(chainId, DeploymentRecorder.poolName(symbol, "LockRelease")); + vm.startBroadcast(); console.log(string.concat("\n[Step 1] Deploying LockReleaseTokenPool on ", chainName)); @@ -81,6 +91,20 @@ contract DeployLockReleaseTokenPool is Script { vm.stopBroadcast(); + // Assert the on-chain typeAndVersion matches the version composed into the registry key — a + // cheap guard against a pinned-dependency mismatch (recording a 2.0.0 key for a stale pool). + string memory expectedTypeAndVersion = string.concat("LockReleaseTokenPool ", DeploymentRecorder.POOL_VERSION); + require( + keccak256(bytes(tokenPool.typeAndVersion())) == keccak256(bytes(expectedTypeAndVersion)), + string.concat( + "typeAndVersion mismatch: on-chain '", + tokenPool.typeAndVersion(), + "' != key '", + expectedTypeAndVersion, + "'" + ) + ); + console.log(""); console.log("========================================"); console.log(string.concat(unicode"✅ Deployment Complete on ", chainName, "!")); @@ -88,11 +112,14 @@ contract DeployLockReleaseTokenPool is Script { console.log(string.concat("Token Pool Address: ", vm.toString(tokenPoolAddress))); console.log(helperConfig.getExplorerUrl(chainId, "/address/", tokenPoolAddress)); console.log(""); - DeploymentUtils.saveLockReleaseTokenPoolDeployment( - vm, config.chainNameIdentifier, tokenPoolAddress, tokenAddress, lockBox, "LockRelease" + // Single writer: one call emits the detailed ledger file (with the lock box) AND records the + // address in the registry (deployments[{symbol}_LockReleaseTokenPool_{version}] + active.tokenPool). + DeploymentRecorder.recordTokenPool( + vm, chainId, config.chainNameIdentifier, tokenPoolAddress, tokenAddress, lockBox, "LockRelease" ); console.log(""); - console.log("Run this command to set the environment variable:"); + console.log("The address is registered in the address registry; later scripts resolve it automatically."); + console.log("To override it for a session, set the environment variable:"); console.log(string.concat("export ", config.chainNameIdentifier, "_TOKEN_POOL=", vm.toString(tokenPoolAddress))); console.log("========================================"); console.log(""); diff --git a/script/deploy/DeployToken.s.sol b/script/deploy/DeployToken.s.sol index a1928d8..e6d81e9 100644 --- a/script/deploy/DeployToken.s.sol +++ b/script/deploy/DeployToken.s.sol @@ -4,7 +4,8 @@ pragma solidity 0.8.24; import {Script, console} from "forge-std/Script.sol"; import {HelperUtils} from "../utils/HelperUtils.s.sol"; // Utility functions for JSON parsing and chain info import {HelperConfig} from "../HelperConfig.s.sol"; // Network configuration helper -import {DeploymentUtils} from "../utils/DeploymentUtils.s.sol"; +import {DeploymentRecorder} from "../utils/DeploymentRecorder.s.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; import {BaseERC20} from "@chainlink/contracts-ccip/contracts/tokens/BaseERC20.sol"; import {CrossChainToken} from "@chainlink/contracts-ccip/contracts/tokens/CrossChainToken.sol"; @@ -42,6 +43,10 @@ contract DeployToken is Script { TokenConfig memory tokenConfig = _loadTokenConfig(tokenConfigPath); + // Refuse to redeploy over a live registry entry (FORCE_REDEPLOY=true overrides). Keyed on the + // unique deployment name so distinct symbols on one chain never collide. + RegistryWriter.guard(chainId, DeploymentRecorder.tokenName(tokenConfig.symbol)); + vm.startBroadcast(); (, address broadcaster,) = vm.readCallers(); @@ -95,9 +100,12 @@ contract DeployToken is Script { console.log(string.concat("Token Address: ", vm.toString(tokenAddress))); console.log(helperConfig.getExplorerUrl(chainId, "/address/", tokenAddress)); console.log(""); - DeploymentUtils.saveTokenDeployment(vm, chainNameIdentifier, tokenConfig.symbol, tokenAddress); + // Single writer: one call emits the detailed ledger file AND records the address in the + // registry (deployments[{symbol}_Token] + active.token). + DeploymentRecorder.recordToken(vm, chainId, chainNameIdentifier, tokenConfig.symbol, tokenAddress); console.log(""); - console.log("Run this command to set the environment variable:"); + console.log("The address is registered in the address registry; later scripts resolve it automatically."); + console.log("To override it for a session, set the environment variable:"); console.log(string.concat("export ", chainNameIdentifier, "_TOKEN=", vm.toString(tokenAddress))); console.log("========================================"); console.log(""); diff --git a/script/operations/DepositToLockBox.s.sol b/script/operations/DepositToLockBox.s.sol index e4cff19..0d2068d 100644 --- a/script/operations/DepositToLockBox.s.sol +++ b/script/operations/DepositToLockBox.s.sol @@ -24,13 +24,24 @@ import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; contract DepositToLockBox is EoaExecutor { HelperConfig public helperConfig; + /// @dev LockBox resolution seam: `LOCK_BOX` alias > `{CHAIN}_LOCK_BOX` > registry `active.lockBox` + /// (no manual export needed). `virtual` so a test can inject an unresolved (`address(0)`) result and + /// assert the named revert DETERMINISTICALLY, independent of the process-wide env other parallel + /// suites set — the ladder itself is proven in `test/config/RegistryResolution.t.sol`. + function _resolveLockBox(uint256 chainId) internal virtual returns (address) { + return vm.envOr("LOCK_BOX", helperConfig.getDeployedLockBox(chainId)); + } + function run() external { helperConfig = new HelperConfig(); uint256 chainId = block.chainid; string memory chainName = helperConfig.getChainName(chainId); - address lockBoxAddress = vm.envAddress("LOCK_BOX"); - require(lockBoxAddress != address(0), "LOCK_BOX environment variable not set"); + address lockBoxAddress = _resolveLockBox(chainId); + require( + lockBoxAddress != address(0), + "LockBox not deployed. Set LOCK_BOX or the {CHAIN}_LOCK_BOX environment variable." + ); console.log(""); console.log("========================================"); diff --git a/script/operations/WithdrawFromLockBox.s.sol b/script/operations/WithdrawFromLockBox.s.sol index cc7f55e..831f1e6 100644 --- a/script/operations/WithdrawFromLockBox.s.sol +++ b/script/operations/WithdrawFromLockBox.s.sol @@ -30,8 +30,12 @@ contract WithdrawFromLockBox is EoaExecutor { uint256 chainId = block.chainid; string memory chainName = helperConfig.getChainName(chainId); - address lockBoxAddress = vm.envAddress("LOCK_BOX"); - require(lockBoxAddress != address(0), "LOCK_BOX environment variable not set"); + // LOCK_BOX alias > {CHAIN}_LOCK_BOX > registry active.lockBox (no manual export needed). + address lockBoxAddress = vm.envOr("LOCK_BOX", helperConfig.getDeployedLockBox(chainId)); + require( + lockBoxAddress != address(0), + "LockBox not deployed. Set LOCK_BOX or the {CHAIN}_LOCK_BOX environment variable." + ); console.log(""); console.log("========================================"); diff --git a/script/utils/DeploymentRecorder.s.sol b/script/utils/DeploymentRecorder.s.sol new file mode 100644 index 0000000..a3ec843 --- /dev/null +++ b/script/utils/DeploymentRecorder.s.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm} from "forge-std/Vm.sol"; +import {DeploymentUtils} from "./DeploymentUtils.s.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; + +/// @title DeploymentRecorder +/// @notice The single writer for a deployed artifact. Each deploy script makes ONE recorder call per +/// artifact that (a) emits the detailed timestamped ledger file via `DeploymentUtils.save*` (Syed's +/// format, byte-for-byte unchanged) AND (b) upserts the address into `addresses/.json` via +/// `RegistryWriter` (`deployments[name]` + `active[role]`). This collapses the historical +/// double-write — two adjacent, independently-maintained calls (`DeploymentUtils.save*` then +/// `RegistryWriter.record`) that wrote the same address to two stores in two formats with nothing +/// keeping them in sync — into one call, so the two stores can never drift. +/// +/// @dev The registry half is context-aware (via `RegistryWriter.record`): it no-ops under `forge test` +/// and on a dry-run `forge script`, and writes only on `--broadcast`/`--resume`. The ledger half +/// (`DeploymentUtils.save*`) always writes, exactly as before. The `deployments` key is composed here +/// from the same symbol the ledger file is named with (`DeploymentUtils.getSymbol`), so the key and the +/// filename never disagree. +/// +/// Keying: +/// | Artifact | `deployments` key | `active` role | +/// | --------- | ---------------------------------------- | ------------- | +/// | token | `{symbol}_Token` | `token` | +/// | tokenPool | `{symbol}_{poolType}TokenPool_{version}` | `tokenPool` | +/// | lockBox | `{symbol}_LockBox` | `lockBox` | +/// | poolHooks | `{symbol}_{poolType}_PoolHooks` | `poolHooks` | +library DeploymentRecorder { + /// @notice The pinned `@chainlink/contracts-ccip` pool version. Used both to compose the versioned + /// `tokenPool` key and as the value the deploy scripts assert against the pool's on-chain + /// `typeAndVersion()` (a cheap check that the pinned dependency matches the recorded key). + string internal constant POOL_VERSION = "2.0.0"; + + /// @notice Records a token deployment: ledger file + `deployments[{symbol}_Token]` + `active.token`. + function recordToken( + Vm vm, + uint256 chainId, + string memory chainNameIdentifier, + string memory symbol, + address tokenAddress + ) internal { + DeploymentUtils.saveTokenDeployment(vm, chainNameIdentifier, symbol, tokenAddress); + RegistryWriter.record(chainId, "token", tokenName(symbol), tokenAddress); + } + + /// @notice Records a burn-mint-style token pool: ledger file + + /// `deployments[{symbol}_{poolType}TokenPool_{version}]` + `active.tokenPool`. + function recordTokenPool( + Vm vm, + uint256 chainId, + string memory chainNameIdentifier, + address tokenPoolAddress, + address tokenAddress, + string memory poolType + ) internal { + DeploymentUtils.saveTokenPoolDeployment(vm, chainNameIdentifier, tokenPoolAddress, tokenAddress, poolType); + string memory symbol = DeploymentUtils.getSymbol(vm, tokenAddress); + RegistryWriter.record(chainId, "tokenPool", poolName(symbol, poolType), tokenPoolAddress); + } + + /// @notice Records a lock-release token pool (ledger includes the lock box): ledger file + + /// `deployments[{symbol}_{poolType}TokenPool_{version}]` + `active.tokenPool`. + function recordTokenPool( + Vm vm, + uint256 chainId, + string memory chainNameIdentifier, + address tokenPoolAddress, + address tokenAddress, + address lockBox, + string memory poolType + ) internal { + DeploymentUtils.saveLockReleaseTokenPoolDeployment( + vm, chainNameIdentifier, tokenPoolAddress, tokenAddress, lockBox, poolType + ); + string memory symbol = DeploymentUtils.getSymbol(vm, tokenAddress); + RegistryWriter.record(chainId, "tokenPool", poolName(symbol, poolType), tokenPoolAddress); + } + + /// @notice Records a lock box: ledger file + `deployments[{symbol}_LockBox]` + `active.lockBox`. + function recordLockBox( + Vm vm, + uint256 chainId, + string memory chainNameIdentifier, + address lockBoxAddress, + address tokenAddress + ) internal { + DeploymentUtils.saveLockBoxDeployment(vm, chainNameIdentifier, lockBoxAddress, tokenAddress); + string memory symbol = DeploymentUtils.getSymbol(vm, tokenAddress); + RegistryWriter.record(chainId, "lockBox", lockBoxName(symbol), lockBoxAddress); + } + + /// @notice Records pool hooks: ledger file + `deployments[{symbol}_{poolType}_PoolHooks]` + + /// `active.poolHooks`. Hooks belong to a token's pool, so the key carries the token symbol (resolved + /// from `tokenAddress`, `address(0)` → env `TOKEN_SYMBOL` / "unknown") and the pool type. + function recordPoolHooks( + Vm vm, + uint256 chainId, + string memory chainNameIdentifier, + address hooksAddress, + address tokenAddress, + string memory poolType + ) internal { + DeploymentUtils.savePoolHooksDeployment(vm, chainNameIdentifier, hooksAddress); + string memory symbol = DeploymentUtils.getSymbol(vm, tokenAddress); + RegistryWriter.record(chainId, "poolHooks", hooksName(symbol, poolType), hooksAddress); + } + + // ── key composition (pure; the deploy scripts reuse these to key the pre-broadcast guard) ── + + function tokenName(string memory symbol) internal pure returns (string memory) { + return string.concat(symbol, "_Token"); + } + + function poolName(string memory symbol, string memory poolType) internal pure returns (string memory) { + return string.concat(symbol, "_", poolType, "TokenPool_", POOL_VERSION); + } + + function lockBoxName(string memory symbol) internal pure returns (string memory) { + return string.concat(symbol, "_LockBox"); + } + + function hooksName(string memory symbol, string memory poolType) internal pure returns (string memory) { + return string.concat(symbol, "_", poolType, "_PoolHooks"); + } +} diff --git a/script/utils/DeploymentUtils.s.sol b/script/utils/DeploymentUtils.s.sol index cffe228..88bf797 100644 --- a/script/utils/DeploymentUtils.s.sol +++ b/script/utils/DeploymentUtils.s.sol @@ -56,7 +56,7 @@ library DeploymentUtils { address tokenAddress, string memory poolType ) internal { - string memory symbol = _getSymbol(vm, tokenAddress); + string memory symbol = getSymbol(vm, tokenAddress); string memory deploymentDir = string.concat(vm.projectRoot(), "/script/deployments/token-pools/", chainNameIdentifier, "/"); vm.createDir(deploymentDir, true); @@ -100,7 +100,7 @@ library DeploymentUtils { address lockBox, string memory poolType ) internal { - string memory symbol = _getSymbol(vm, tokenAddress); + string memory symbol = getSymbol(vm, tokenAddress); string memory deploymentDir = string.concat(vm.projectRoot(), "/script/deployments/token-pools/", chainNameIdentifier, "/"); vm.createDir(deploymentDir, true); @@ -141,7 +141,7 @@ library DeploymentUtils { address lockBoxAddress, address tokenAddress ) internal { - string memory symbol = _getSymbol(vm, tokenAddress); + string memory symbol = getSymbol(vm, tokenAddress); string memory deploymentDir = string.concat(vm.projectRoot(), "/script/deployments/lock-boxes/", chainNameIdentifier, "/"); vm.createDir(deploymentDir, true); @@ -194,7 +194,9 @@ library DeploymentUtils { /// @dev Resolves the token symbol by calling `symbol()` on the token contract, falling back to /// the TOKEN_SYMBOL environment variable, or "unknown" if neither is available. - function _getSymbol(Vm vm, address tokenAddress) private view returns (string memory symbol) { + /// `internal` so the single-writer `DeploymentRecorder` composes the registry key from the same + /// symbol the ledger file is named with. + function getSymbol(Vm vm, address tokenAddress) internal view returns (string memory symbol) { try IERC20Metadata(tokenAddress).symbol() returns (string memory s) { symbol = s; } catch { diff --git a/src/config/CcipApiSource.sol b/src/config/CcipApiSource.sol new file mode 100644 index 0000000..d41ca96 --- /dev/null +++ b/src/config/CcipApiSource.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm} from "forge-std/Vm.sol"; +import {IConfigSource} from "./IConfigSource.sol"; + +/// @title CcipApiSource +/// @notice CCIP REST API v2 implementation of the config-sync seam — fetches the ACTIVE CCIP +/// infrastructure addresses from `https://api.ccip.chain.link/v2`. Foundry cannot HTTP-GET +/// directly, so the fetch + `isActive` selection is delegated via `vm.tryFfi` to +/// `script/config/ccip-config-source.sh` (curl + jq), which returns the normalized flat JSON +/// this seam expects. Requires the `sync` foundry profile (`ffi = true`). +/// @dev The v2 flow is two-step: `GET /chains?environment=testnet` lists chains (identity metadata, +/// used by `script/config/sync-discover.sh` and `SyncCcipConfig.init`), and `GET /chains/{selector}` +/// returns `chainConfig` where each contract type is an array of versioned entries with `isActive`. +/// This source implements the address-bearing step 2. The API base URL can be overridden with the +/// non-secret `CCIP_API_BASE` environment variable (see `.env.example`). +contract CcipApiSource is IConfigSource { + /// @dev Well-known cheatcode address (forge-std pattern) so this contract can reach `vm`. + Vm private constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + string private constant HELPER = "script/config/ccip-config-source.sh"; + + /// @inheritdoc IConfigSource + /// @dev Uses `vm.tryFfi` so the helper's exit-code contract surfaces as a NAMED revert: the + /// script writes a diagnostic to stderr (NOT_FOUND / API_UNREACHABLE / BAD_BODY / MISSING_TOOL) + /// and that stderr becomes the revert reason here — never a raw "FFI failed" or a silent null. + function fetchActiveCcipConfig(uint64 chainSelector) external returns (string memory flatJson) { + string[] memory cmd = new string[](3); + cmd[0] = "bash"; + cmd[1] = HELPER; + cmd[2] = VM.toString(uint256(chainSelector)); + Vm.FfiResult memory r = VM.tryFfi(cmd); + if (r.exitCode != 0) { + revert(string(bytes.concat(bytes("[sync] config fetch failed: "), r.stderr))); + } + return string(r.stdout); + } +} diff --git a/src/config/ChainConfig.sol b/src/config/ChainConfig.sol new file mode 100644 index 0000000..5c2b056 --- /dev/null +++ b/src/config/ChainConfig.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm} from "forge-std/Vm.sol"; + +/// @title ChainConfig +/// @notice Chain metadata is DATA, not code: every CCIP address, chain selector, and chain label the +/// scripts need is read from a git-tracked `config/chains/.json` file via `vm.parseJson*` +/// cheatcodes, so supporting a new chain is a config edit reviewed in a pull request — no Solidity +/// changes and no redeploy of anything. +/// @dev Schema per file (see `config/chains/ethereum-testnet-sepolia.json`): +/// - identity: `name`, `displayName`, `chainNameIdentifier` (the `{CHAIN}_*` env-var prefix, +/// e.g. `ETHEREUM_SEPOLIA`), `chainFamily` (`evm`/`svm`), `environment`, `chainId`, +/// `chainSelector`, `rpcEnv` (the env var holding the chain's RPC URL). +/// - `ccip{}`: the CCIP directory addresses (`router`, `rmnProxy`, `tokenAdminRegistry`, +/// `registryModuleOwnerCustom`, `link`, plus `feeQuoter`/`tokenPoolFactory`/`feeTokens` +/// kept for reference). +/// - repo extras: `confirmations`, `explorerUrl`, `nativeCurrencySymbol`, `ccipBnM`. +/// `chainId` and `chainSelector` are quoted decimal STRINGS (uint64 selectors exceed JSON's safe +/// integer range) and are read with `vm.parseJsonUint`, which parses quoted decimals. Reads use +/// targeted key paths (`vm.parseJsonAddress(json, ".ccip.router")`) rather than whole-struct +/// decoding, which is field-order-sensitive and brittle. +library ChainConfig { + /// @dev Well-known cheatcode address (forge-std pattern) so a library can reach `vm`. + Vm private constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @notice One chain record, flattened from `config/chains/.json`. + struct Chain { + uint64 chainSelector; + address router; + address rmnProxy; + address tokenAdminRegistry; + address registryModuleOwnerCustom; + address link; + address ccipBnM; + uint256 confirmations; + string chainName; + string chainNameIdentifier; + string explorerUrl; + string nativeCurrencySymbol; + string chainFamily; + } + + function _path(string memory name) private view returns (string memory) { + return string.concat(VM.projectRoot(), "/config/chains/", name, ".json"); + } + + /// @notice Reads a chain's full config record by config file name (e.g. "ethereum-testnet-sepolia"). + function load(string memory name) internal view returns (Chain memory) { + return _parse(VM.readFile(_path(name))); + } + + /// @notice `load` + the declared chain ID, tolerating a file that no longer exists (returns + /// `ok = false`). One single `readFile` backs the whole record, so directory-scan consumers + /// (see `names()`) racing a parallel deletion — e.g. a test cleaning up its scratch config — + /// have the smallest possible window between the existence check and the read. + function tryLoad(string memory name) internal view returns (bool ok, Chain memory c, uint256 declaredChainId) { + if (!VM.exists(_path(name))) return (false, c, 0); + string memory json = VM.readFile(_path(name)); + return (true, _parse(json), VM.parseJsonUint(json, ".chainId")); + } + + /// @dev Parses one already-read chain JSON document into a `Chain` record. + function _parse(string memory json) private pure returns (Chain memory c) { + c.chainSelector = uint64(VM.parseJsonUint(json, ".chainSelector")); + c.router = VM.parseJsonAddress(json, ".ccip.router"); + c.rmnProxy = VM.parseJsonAddress(json, ".ccip.rmnProxy"); + c.tokenAdminRegistry = VM.parseJsonAddress(json, ".ccip.tokenAdminRegistry"); + c.registryModuleOwnerCustom = VM.parseJsonAddress(json, ".ccip.registryModuleOwnerCustom"); + c.link = VM.parseJsonAddress(json, ".ccip.link"); + c.ccipBnM = VM.parseJsonAddress(json, ".ccipBnM"); + c.confirmations = VM.parseJsonUint(json, ".confirmations"); + c.chainName = VM.parseJsonString(json, ".displayName"); + c.chainNameIdentifier = VM.parseJsonString(json, ".chainNameIdentifier"); + c.explorerUrl = VM.parseJsonString(json, ".explorerUrl"); + c.nativeCurrencySymbol = VM.parseJsonString(json, ".nativeCurrencySymbol"); + c.chainFamily = VM.parseJsonString(json, ".chainFamily"); + } + + /// @notice The chain's declared EVM chain ID (`0` for non-EVM chains such as Solana). + function chainId(string memory name) internal view returns (uint256) { + return VM.parseJsonUint(VM.readFile(_path(name)), ".chainId"); + } + + /// @notice Enumerates every configured chain by scanning `config/chains/*.json` — the config + /// name of each entry (file basename without `.json`, e.g. "ethereum-testnet-sepolia") feeds `load`. + /// Directory contents ARE the chain list: dropping a new JSON file in makes the chain + /// discoverable with no Solidity change. + function names() internal view returns (string[] memory) { + Vm.DirEntry[] memory entries = VM.readDir(string.concat(VM.projectRoot(), "/config/chains")); + string[] memory found = new string[](entries.length); + uint256 count = 0; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].isDir) continue; + string memory base = _jsonBasename(entries[i].path); + if (bytes(base).length == 0) continue; // not a .json file + found[count++] = base; + } + string[] memory out = new string[](count); + for (uint256 i = 0; i < count; i++) { + out[i] = found[i]; + } + return out; + } + + /// @dev Extracts the file basename without the `.json` extension; empty string when `path` + /// is not a `.json` file. + function _jsonBasename(string memory path) private pure returns (string memory) { + bytes memory p = bytes(path); + bytes memory ext = bytes(".json"); + if (p.length <= ext.length) return ""; + for (uint256 i = 0; i < ext.length; i++) { + if (p[p.length - ext.length + i] != ext[i]) return ""; + } + uint256 start = 0; + for (uint256 i = p.length; i > 0; i--) { + if (p[i - 1] == "/") { + start = i; + break; + } + } + bytes memory out = new bytes(p.length - ext.length - start); + for (uint256 i = 0; i < out.length; i++) { + out[i] = p[start + i]; + } + return string(out); + } +} diff --git a/src/config/IConfigSource.sol b/src/config/IConfigSource.sol new file mode 100644 index 0000000..f9eb2f0 --- /dev/null +++ b/src/config/IConfigSource.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @title IConfigSource +/// @notice The config-sync seam: abstracts *where* CCIP chain metadata comes from, so the config +/// writer (`script/config/SyncCcipConfig.s.sol`) is decoupled from any specific API version. An +/// implementation fetches a chain's ACTIVE CCIP address set and returns it as a compact, normalized, +/// flat JSON object whose keys mirror the repo's `config/chains/.json` `ccip{}` block +/// (`router`, `rmnProxy`, `tokenAdminRegistry`, `registryModuleOwnerCustom`, `link`, `feeQuoter`, +/// `tokenPoolFactory`, `feeTokens[]`), plus `apiName` + `chainId` so the caller can cross-check the +/// selector's identity against the local config (the SELECTOR MISMATCH guard). +/// @dev Swapping the config source (a future API version, or a local snapshot for offline testing) +/// is a one-file change: provide a new implementation of this interface. The current implementation +/// is `CcipApiSource` (CCIP REST API v2, `https://api.ccip.chain.link/v2`). +interface IConfigSource { + /// @notice Fetches a chain's ACTIVE CCIP infrastructure addresses, normalized to a flat JSON object. + /// @param chainSelector The CCIP chain selector (uint64) identifying the chain. + /// @return flatJson A compact JSON object: {apiName, chainId, router, rmnProxy, tokenAdminRegistry, + /// registryModuleOwnerCustom, link, feeQuoter, tokenPoolFactory, feeTokens:[...]} — every address + /// already resolved to the `isActive` entry. + function fetchActiveCcipConfig(uint64 chainSelector) external returns (string memory flatJson); +} diff --git a/src/utils/RegistryWriter.sol b/src/utils/RegistryWriter.sol new file mode 100644 index 0000000..1cb1a36 --- /dev/null +++ b/src/utils/RegistryWriter.sol @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm, VmSafe} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; + +/// @title RegistryWriter +/// @notice The deployed-address registry: deploy scripts record their outputs in +/// `addresses/.json`, one file per chain, so a fresh deployment is immediately resolvable +/// by every later script with no `export` step — the registry survives the terminal session that +/// shell exports vanish with. Environment variables (`TOKEN`, `{CHAIN}_TOKEN`, ...) keep working and +/// always take priority over the registry. +/// +/// @dev **Schema v2 — `active` role pointers + named `deployments` entries.** +/// ```jsonc +/// { +/// "active": { // what HelperConfig resolves (zero-export) +/// "token": "0x..", "tokenPool": "0x..", "lockBox": "0x..", "poolHooks": "0x.." +/// }, +/// "deployments": { // uniquely named per artifact (type + version in the key) +/// "BnM-T_Token": "0x..", +/// "BnM-T_BurnMintTokenPool_2.0.0": "0x..", +/// "BnM-T_LockBox": "0x..", +/// "BnM-T_BurnMint_PoolHooks": "0x.." +/// } +/// } +/// ``` +/// `read(chainId, role)` resolves `.active.` (with a legacy fallback to a flat top-level +/// `.` so any pre-v2 runtime file keeps resolving). The redeploy guard keys on the unique +/// `deployments` name: because the key includes the pool's TYPE and VERSION, distinct artifacts never +/// collide (a different type or version is a different key and records freely), while re-deploying the +/// *same* name is guarded and needs `FORCE_REDEPLOY=true`. Note the deploy scripts pin the pool version +/// (`DeploymentRecorder.POOL_VERSION` = "2.0.0"), so the scripts only ever emit the `_2.0.0` key. +/// +/// Needs `fs_permissions` read-write on `./addresses` (covered by the repo's root permission). +library RegistryWriter { + /// @dev Well-known cheatcode address (forge-std pattern) so a library can reach `vm`. + Vm private constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + // ───────────────────────────────────────────────────────────────────────── + // Script-facing wrappers (context-aware) + // + // The registry is a durable store of REAL deployments, so the script-facing + // entry points sense the forge execution context: + // - `forge test` → both are no-ops (test fixtures rerun the deploy + // scripts constantly; simulations must not mutate + // or be blocked by the durable store) + // - `forge script` (dry run) → `guard` is active (the dry run previews exactly + // what the broadcast would do) but `record` does + // not write (a simulation is not a deployment) + // - `forge script --broadcast` → both are active + // The deterministic cores below (`guardRedeploy`/`recordDeterministic`/`set*`/`read*`) never look + // at the context, so tests can drive every branch directly. + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Script-facing idempotency guard — call BEFORE `vm.startBroadcast()`. `deploymentName` + /// is the unique `deployments` key (e.g. `BnM-T_BurnMintTokenPool_2.0.0`). + function guard(uint256 chainId, string memory deploymentName) internal { + if (VM.isContext(VmSafe.ForgeContext.TestGroup)) return; + guardRedeploy(chainId, deploymentName); + } + + /// @notice Script-facing single-writer registry write — call after the deployment succeeds. Upserts + /// BOTH the named `deployments[deploymentName]` entry AND the `active[role]` pointer in one call. + /// Only a real broadcast (`--broadcast` / `--resume`) mutates the registry. + function record(uint256 chainId, string memory role, string memory deploymentName, address addr) internal { + if (!VM.isContext(VmSafe.ForgeContext.ScriptBroadcast) && !VM.isContext(VmSafe.ForgeContext.ScriptResume)) { + return; + } + recordDeterministic(chainId, role, deploymentName, addr); + } + + /// @notice Deploy idempotency guard (deterministic core entry). If `deploymentName` already + /// resolves to a non-zero address in `addresses/.json` `deployments`, REFUSE (revert + /// naming the existing address and the exact override) unless env `FORCE_REDEPLOY=true`. When + /// forced, the stale entry is dropped from `deployments` (the old address stays in the append-only + /// ledger under `script/deployments/`; note the registry itself is gitignored, so it is NOT in git + /// history) so the post-deploy `record` registers the replacement. + /// First-time flows (no registry file / no entry for `deploymentName`) are complete no-ops. + function guardRedeploy(uint256 chainId, string memory deploymentName) internal { + guardRedeploy(chainId, deploymentName, VM.envOr("FORCE_REDEPLOY", false)); + } + + /// @dev Deterministic core (the env read is split out so tests can exercise both the refuse and + /// the force branches without toggling `FORCE_REDEPLOY` — `vm.setEnv` is process-wide and would + /// race parallel test suites). + function guardRedeploy(uint256 chainId, string memory deploymentName, bool forced) internal { + address existing = readDeployment(chainId, deploymentName); + if (existing == address(0)) return; // first-time flow: nothing registered under this name + + string memory path = _path(chainId); + if (!forced) { + revert( + string.concat( + "RegistryWriter: '", + deploymentName, + "' is already deployed at ", + VM.toString(existing), + " (", + path, + "). Refusing to redeploy - set FORCE_REDEPLOY=true to deploy a replacement." + ) + ); + } + console.log("FORCE_REDEPLOY=true:", deploymentName, "will be replaced in the registry; old address:"); + console.log(" ", existing, "(stays in the append-only ledger: script/deployments)"); + _dropDeployment(chainId, deploymentName); // drop the stale entry so record() registers the replacement + } + + /// @notice Resolves a ROLE (`token`/`tokenPool`/`lockBox`/`poolHooks`) to the currently-active + /// address from `addresses/.json`: `.active.` first, then a legacy fallback to a + /// flat top-level `.` (pre-v2 runtime files). `address(0)` when the file or the key is + /// absent (never reverts — callers treat the registry as an optional fallback). + function read(uint256 chainId, string memory role) internal view returns (address) { + string memory path = _path(chainId); + if (!VM.exists(path)) return address(0); + // TOCTOU-safe: a parallel test suite can remove this file between `exists` above and the read + // below (the resolution tests write then delete a throwaway chain's registry while another + // suite is constructing HelperConfig, which eagerly reads every configured chain). `readFile` + // reverts on a missing file, so a raw read would crash that unrelated suite; catch it and treat + // a vanished file exactly like an absent one (address(0)). + string memory json; + try VM.readFile(path) returns (string memory data) { + json = data; + } catch { + return address(0); + } + // Resilience: a parallel test suite may be mid-write to this chain's file (VM.writeFile + // truncates then writes, so a concurrent reader can momentarily see an empty/partial file). + // The registry is an OPTIONAL fallback that must NEVER revert (see the natspec / HelperConfig), + // so an empty or unparseable snapshot resolves to address(0) rather than crashing an unrelated + // test that merely constructed HelperConfig. + if (bytes(json).length == 0) return address(0); + string memory activeKey = string.concat(".active.", role); + try VM.keyExistsJson(json, activeKey) returns (bool exists) { + if (exists) return VM.parseJsonAddress(json, activeKey); + } catch { + return address(0); + } + // Legacy fallback: a pre-v2 flat `{ "": "0x.." }` file keeps resolving. + string memory legacyKey = string.concat(".", role); + if (VM.keyExistsJson(json, legacyKey)) return VM.parseJsonAddress(json, legacyKey); + return address(0); + } + + /// @notice Resolves a uniquely-named `deployments` entry (e.g. a specific pool type + version). + /// `address(0)` when the file or the key is absent (never reverts). + function readDeployment(uint256 chainId, string memory deploymentName) internal view returns (address) { + string memory path = _path(chainId); + if (!VM.exists(path)) return address(0); + // TOCTOU-safe (see `read`): tolerate the file being removed by a parallel suite between the + // `exists` check and the read — a vanished file resolves to address(0), never a revert. + string memory json; + try VM.readFile(path) returns (string memory data) { + json = data; + } catch { + return address(0); + } + if (bytes(json).length == 0) return address(0); // concurrent-write snapshot: never revert + // Bracket notation: version keys (e.g. `..._2.0.0`) contain dots, which dot-path notation would + // mis-split. `[""]` treats the whole name as one literal key. + string memory key = string.concat(".deployments[\"", deploymentName, "\"]"); + try VM.keyExistsJson(json, key) returns (bool exists) { + if (exists) return VM.parseJsonAddress(json, key); + } catch { + return address(0); + } + return address(0); + } + + /// @notice Upserts the `active[role]` pointer, preserving every other entry (both stores). + function setActive(uint256 chainId, string memory role, address addr) internal { + _warnRepoint(chainId, role, addr); + (string[] memory aKeys, address[] memory aVals, string[] memory dKeys, address[] memory dVals) = + _loadMaps(chainId); + (aKeys, aVals) = _upsert(aKeys, aVals, role, addr); + _store(chainId, aKeys, aVals, dKeys, dVals); + console.log(string.concat("Registry updated: addresses/", VM.toString(chainId), ".json (active.", role, ")")); + } + + /// @notice Deterministic view helper: would calling `setActive`/`recordDeterministic` with + /// (`chainId`, `role`, `addr`) REPOINT the zero-export `active[role]` pointer onto a DIFFERENT + /// address? Returns (`repoints`, `previous`) where `previous` is the current `active[role]` + /// (`address(0)` when unset) and `repoints` is true ONLY when a non-zero pointer already exists + /// and differs from `addr`. First set (previous == 0) and idempotent re-set (previous == addr) + /// are NOT repoints. Pure of side effects (view) so `setActive`/`recordDeterministic` can gate the + /// repoint warning on it and the unit tests can assert it directly. + function wouldRepointActive(uint256 chainId, string memory role, address addr) + internal + view + returns (bool repoints, address previous) + { + previous = read(chainId, role); + repoints = previous != address(0) && previous != addr; + } + + /// @dev Warn LOUDLY (console only, no behavior change) when an `active[role]` pointer is about to + /// be silently repointed onto a different address — e.g. deploying a second token on a chain moves + /// `active.token` off the first fixture, hijacking the zero-export pointer every no-override script + /// resolves. The repoint still happens; the operator is told how to pin the previous address. Never + /// fires on a first set or an idempotent re-set (see `wouldRepointActive`). Both write paths that + /// touch `active[role]` (`setActive` and `recordDeterministic`) route through here. + function _warnRepoint(uint256 chainId, string memory role, address addr) private view { + (bool repoints, address previous) = wouldRepointActive(chainId, role, addr); + if (!repoints) return; + string memory env = _roleEnvVar(role); + console.log( + string.concat( + "WARNING: active.", + role, + " repointed ", + VM.toString(previous), + " -> ", + VM.toString(addr), + " on chain ", + VM.toString(chainId), + "." + ) + ); + console.log(string.concat(" Scripts with no env override will now resolve ", VM.toString(addr), ".")); + console.log( + string.concat( + " Export ", + env, + "=", + VM.toString(previous), + " (or _", + env, + "=", + VM.toString(previous), + ") to keep targeting the previous one." + ) + ); + } + + /// @dev Maps a camelCase registry role to the UPPER_SNAKE env-var stem HelperConfig reads as the + /// override (`token`->`TOKEN`, `tokenPool`->`TOKEN_POOL`, `lockBox`->`LOCK_BOX`, + /// `poolHooks`->`POOL_HOOKS`); the chain-scoped form is `_`. + function _roleEnvVar(string memory role) private pure returns (string memory) { + bytes memory b = bytes(role); + bytes memory out = new bytes(b.length * 2); + uint256 j = 0; + for (uint256 i = 0; i < b.length; i++) { + bytes1 ch = b[i]; + if (ch >= "A" && ch <= "Z") { + if (i != 0) { + out[j++] = "_"; + } + out[j++] = ch; + } else if (ch >= "a" && ch <= "z") { + out[j++] = bytes1(uint8(ch) - 32); + } else { + out[j++] = ch; + } + } + bytes memory trimmed = new bytes(j); + for (uint256 i = 0; i < j; i++) { + trimmed[i] = out[i]; + } + return string(trimmed); + } + + /// @notice Upserts a named `deployments[deploymentName]` entry, preserving every other entry. + function setDeployment(uint256 chainId, string memory deploymentName, address addr) internal { + (string[] memory aKeys, address[] memory aVals, string[] memory dKeys, address[] memory dVals) = + _loadMaps(chainId); + (dKeys, dVals) = _upsert(dKeys, dVals, deploymentName, addr); + _store(chainId, aKeys, aVals, dKeys, dVals); + console.log( + string.concat( + "Registry updated: addresses/", VM.toString(chainId), ".json (deployments.", deploymentName, ")" + ) + ); + } + + /// @notice Back-compat alias: records a ROLE pointer (writes `active[role]`). Retained so callers + /// that only need the resolvable-pointer semantics (and the resolution tests) keep working. + function set(uint256 chainId, string memory role, address addr) internal { + setActive(chainId, role, addr); + } + + /// @notice Deterministic single-writer core: upserts `deployments[deploymentName]` AND + /// `active[role]` in ONE file write, so the two stores can never drift apart. This is the + /// anti-duplication write the deploy scripts route every artifact through (via `record`). + function recordDeterministic(uint256 chainId, string memory role, string memory deploymentName, address addr) + internal + { + _warnRepoint(chainId, role, addr); + (string[] memory aKeys, address[] memory aVals, string[] memory dKeys, address[] memory dVals) = + _loadMaps(chainId); + (dKeys, dVals) = _upsert(dKeys, dVals, deploymentName, addr); + (aKeys, aVals) = _upsert(aKeys, aVals, role, addr); + _store(chainId, aKeys, aVals, dKeys, dVals); + console.log( + string.concat( + "Registry updated: addresses/", + VM.toString(chainId), + ".json (deployments.", + deploymentName, + " + active.", + role, + ")" + ) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Internal file (de)serialization + // ───────────────────────────────────────────────────────────────────────── + + function _path(uint256 chainId) private view returns (string memory) { + return string.concat(VM.projectRoot(), "/addresses/", VM.toString(chainId), ".json"); + } + + /// @dev Drops `deploymentName` from `deployments` and clears any `active[role]` that pointed at the + /// dropped address (so a forced redeploy leaves no dangling active pointer). + function _dropDeployment(uint256 chainId, string memory deploymentName) private { + (string[] memory aKeys, address[] memory aVals, string[] memory dKeys, address[] memory dVals) = + _loadMaps(chainId); + + address dropped = address(0); + for (uint256 i = 0; i < dKeys.length; i++) { + if (keccak256(bytes(dKeys[i])) == keccak256(bytes(deploymentName))) { + dropped = dVals[i]; + break; + } + } + (dKeys, dVals) = _remove(dKeys, dVals, deploymentName); + if (dropped != address(0)) { + (aKeys, aVals) = _removeByValue(aKeys, aVals, dropped); + } + _store(chainId, aKeys, aVals, dKeys, dVals); + } + + function _loadMaps(uint256 chainId) + private + view + returns (string[] memory aKeys, address[] memory aVals, string[] memory dKeys, address[] memory dVals) + { + string memory path = _path(chainId); + if (!VM.exists(path)) { + return (new string[](0), new address[](0), new string[](0), new address[](0)); + } + string memory json = VM.readFile(path); + (aKeys, aVals) = _readObj(json, ".active"); + (dKeys, dVals) = _readObj(json, ".deployments"); + } + + function _readObj(string memory json, string memory objPath) + private + view + returns (string[] memory keys, address[] memory vals) + { + if (!VM.keyExistsJson(json, objPath)) { + return (new string[](0), new address[](0)); + } + keys = VM.parseJsonKeys(json, objPath); + vals = new address[](keys.length); + for (uint256 i = 0; i < keys.length; i++) { + // Bracket notation so keys containing dots (versioned pool names) are read as one literal key. + vals[i] = VM.parseJsonAddress(json, string.concat(objPath, "[\"", keys[i], "\"]")); + } + } + + function _store( + uint256 chainId, + string[] memory aKeys, + address[] memory aVals, + string[] memory dKeys, + address[] memory dVals + ) private { + string memory body = string.concat( + "{\n \"active\": ", _obj(aKeys, aVals), ",\n \"deployments\": ", _obj(dKeys, dVals), "\n}\n" + ); + VM.writeFile(_path(chainId), body); + } + + /// @dev Serializes a `{key: address}` map with 2-space nesting under a 4-space-indented parent key. + function _obj(string[] memory keys, address[] memory vals) private pure returns (string memory) { + if (keys.length == 0) return "{}"; + string memory inner = ""; + for (uint256 i = 0; i < keys.length; i++) { + inner = string.concat( + inner, "\n \"", keys[i], "\": \"", VM.toString(vals[i]), "\"", i + 1 < keys.length ? "," : "" + ); + } + return string.concat("{", inner, "\n }"); + } + + function _upsert(string[] memory keys, address[] memory vals, string memory key, address val) + private + pure + returns (string[] memory, address[] memory) + { + for (uint256 i = 0; i < keys.length; i++) { + if (keccak256(bytes(keys[i])) == keccak256(bytes(key))) { + vals[i] = val; + return (keys, vals); + } + } + string[] memory nk = new string[](keys.length + 1); + address[] memory nv = new address[](keys.length + 1); + for (uint256 i = 0; i < keys.length; i++) { + nk[i] = keys[i]; + nv[i] = vals[i]; + } + nk[keys.length] = key; + nv[keys.length] = val; + return (nk, nv); + } + + function _remove(string[] memory keys, address[] memory vals, string memory key) + private + pure + returns (string[] memory, address[] memory) + { + uint256 n = 0; + for (uint256 i = 0; i < keys.length; i++) { + if (keccak256(bytes(keys[i])) != keccak256(bytes(key))) n++; + } + string[] memory nk = new string[](n); + address[] memory nv = new address[](n); + uint256 j = 0; + for (uint256 i = 0; i < keys.length; i++) { + if (keccak256(bytes(keys[i])) == keccak256(bytes(key))) continue; + nk[j] = keys[i]; + nv[j] = vals[i]; + j++; + } + return (nk, nv); + } + + function _removeByValue(string[] memory keys, address[] memory vals, address val) + private + pure + returns (string[] memory, address[] memory) + { + uint256 n = 0; + for (uint256 i = 0; i < vals.length; i++) { + if (vals[i] != val) n++; + } + string[] memory nk = new string[](n); + address[] memory nv = new address[](n); + uint256 j = 0; + for (uint256 i = 0; i < keys.length; i++) { + if (vals[i] == val) continue; + nk[j] = keys[i]; + nv[j] = vals[i]; + j++; + } + return (nk, nv); + } +} diff --git a/test/actions/LockboxOps.t.sol b/test/actions/LockboxOps.t.sol index d548b0a..653ce8b 100644 --- a/test/actions/LockboxOps.t.sol +++ b/test/actions/LockboxOps.t.sol @@ -42,7 +42,13 @@ contract LockboxOpsForkTest is BaseForkTest { assertGt(address(lockBox).code.length, 0, "lockbox not deployed"); // README order, step 2: deploy the LockReleaseTokenPool pointed at the lockbox. - vm.setEnv("LOCK_BOX", vm.toString(address(lockBox))); + // Use the CHAIN-SCOPED `ETHEREUM_SEPOLIA_LOCK_BOX` (this fork is Sepolia), NEVER the bare inline + // `LOCK_BOX` alias. `vm.setEnv` is process-wide and never unset: a bare `LOCK_BOX` would leak into + // every parallel suite that resolves a lockbox (the RegistryResolution / DepositToLockBox tests + // assert on the bare-alias rung), silently poisoning them. The chain-scoped name is namespaced to + // Sepolia, so it cannot collide with the throwaway-chain resolution tests. `getDeployedLockBox` + // reads `{CHAIN}_LOCK_BOX` after the bare alias, so the pool deploy below still resolves it. + vm.setEnv("ETHEREUM_SEPOLIA_LOCK_BOX", vm.toString(address(lockBox))); uint256 noncePool = vm.getNonce(owner); new DeployLockReleaseTokenPool().run(); pool = LockReleaseTokenPool(vm.computeCreateAddress(owner, noncePool)); diff --git a/test/config/DynamicChainDiscovery.t.sol b/test/config/DynamicChainDiscovery.t.sol new file mode 100644 index 0000000..e311f7c --- /dev/null +++ b/test/config/DynamicChainDiscovery.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {HelperConfig} from "../../script/HelperConfig.s.sol"; + +/// @title DynamicChainDiscoveryTest +/// @notice The end-to-end "zero Solidity changes" proof: dropping a NEW `config/chains/.json` +/// file in (exactly what `make add-chain` produces) makes the chain resolvable through every +/// HelperConfig lookup — by chain ID, by chain name identifier, by selector, as a destination +/// chain, and in the configured-chain enumeration — WITHOUT touching any Solidity dispatch. +/// The scratch config uses a fake-but-valid chain ID / selector / identifier that no hardcoded +/// fast path knows, so it can only resolve via the directory scan. All scratch assertions live in +/// ONE test so the shared `config/chains/` directory sees exactly one write + one delete per run +/// (tests run in parallel; every HelperConfig constructor scans this directory ONCE into a storage +/// cache via `ChainConfig.tryLoad`, which tolerates a concurrently-deleted entry). +contract DynamicChainDiscoveryTest is Test { + uint256 internal constant SCRATCH_CHAIN_ID = 777000777; + uint64 internal constant SCRATCH_SELECTOR = 7770007770007770077; + string internal constant SCRATCH_NAME = "zz-scratch-dynamic"; + string internal constant SCRATCH_IDENTIFIER = "ZZ_SCRATCH_DYNAMIC"; + + /// @dev Writes the scratch `config/chains/.json` in the exact shape + /// `make add-chain` generates, returning its absolute path so the test can remove it. + function _writeScratchChain() internal returns (string memory path) { + path = string.concat(vm.projectRoot(), "/config/chains/", SCRATCH_NAME, ".json"); + vm.writeFile( + path, + string.concat( + "{\n", + ' "ccip": {\n', + ' "feeQuoter": "0x0000000000000000000000000000000000000000",\n', + ' "feeTokens": [],\n', + ' "link": "0x0000000000000000000000000000000000000001",\n', + ' "registryModuleOwnerCustom": "0x0000000000000000000000000000000000000004",\n', + ' "rmnProxy": "0x0000000000000000000000000000000000000003",\n', + ' "router": "0x0000000000000000000000000000000000000002",\n', + ' "tokenAdminRegistry": "0x0000000000000000000000000000000000000005",\n', + ' "tokenPoolFactory": "0x0000000000000000000000000000000000000000"\n', + " },\n", + ' "ccipBnM": "0x0000000000000000000000000000000000000000",\n', + ' "chainFamily": "evm",\n', + ' "chainId": "', + vm.toString(SCRATCH_CHAIN_ID), + '",\n', + ' "chainNameIdentifier": "', + SCRATCH_IDENTIFIER, + '",\n', + ' "chainSelector": "', + vm.toString(SCRATCH_SELECTOR), + '",\n', + ' "confirmations": 2,\n', + ' "displayName": "Zz Scratch Dynamic",\n', + ' "environment": "testnet",\n', + ' "explorerUrl": "https://example.invalid",\n', + ' "name": "', + SCRATCH_NAME, + '",\n', + ' "nativeCurrencySymbol": "ZZZ",\n', + ' "rpcEnv": "ZZ_SCRATCH_DYNAMIC_RPC_URL"\n', + "}\n" + ) + ); + } + + function test_NewChainConfigFile_ResolvesEverywhereWithoutSolidityChange() public { + string memory path = _writeScratchChain(); + HelperConfig helperConfig = new HelperConfig(); + + // getNetworkConfig(chainId) — the directory-scan fallback resolves every field + HelperConfig.NetworkConfig memory c = helperConfig.getNetworkConfig(SCRATCH_CHAIN_ID); + assertEq(c.chainSelector, SCRATCH_SELECTOR, "chainSelector"); + assertEq(c.router, address(2), "router"); + assertEq(c.rmnProxy, address(3), "rmnProxy"); + assertEq(c.registryModuleOwnerCustom, address(4), "registryModuleOwnerCustom"); + assertEq(c.tokenAdminRegistry, address(5), "tokenAdminRegistry"); + assertEq(c.link, address(1), "link"); + assertEq(c.confirmations, 2, "confirmations"); + assertEq(c.chainName, "Zz Scratch Dynamic", "chainName"); + assertEq(c.chainNameIdentifier, SCRATCH_IDENTIFIER, "chainNameIdentifier"); + assertEq(c.chainFamily, "evm", "chainFamily"); + + // parseChainName(identifier) -> chain ID + assertEq(helperConfig.parseChainName(SCRATCH_IDENTIFIER), SCRATCH_CHAIN_ID, "parseChainName"); + + // getDestChainConfig(identifier) and getChainNameBySelector(selector) + assertEq( + helperConfig.getDestChainConfig(SCRATCH_IDENTIFIER).chainSelector, SCRATCH_SELECTOR, "getDestChainConfig" + ); + assertEq(helperConfig.getChainNameBySelector(SCRATCH_SELECTOR), "Zz Scratch Dynamic", "getChainNameBySelector"); + + // getConfiguredChains() enumerates the new chain alongside the committed ones + string[] memory chains = helperConfig.getConfiguredChains(); + bool foundScratch = false; + bool foundSepolia = false; + for (uint256 i = 0; i < chains.length; i++) { + bytes32 h = keccak256(bytes(chains[i])); + if (h == keccak256(bytes(SCRATCH_NAME))) foundScratch = true; + if (h == keccak256(bytes("ethereum-testnet-sepolia"))) foundSepolia = true; + } + assertTrue(foundScratch, "scratch chain enumerated"); + assertTrue(foundSepolia, "committed chains still enumerated"); + + vm.removeFile(path); + } + + function test_UnknownChain_StillFailsExactlyAsBefore() public { + // No scratch file for this case: behavior with only chains that are actually configured. + HelperConfig helperConfig = new HelperConfig(); + + vm.expectRevert(bytes("Unsupported chain ID")); + helperConfig.getNetworkConfig(43113); + + vm.expectRevert(bytes("Invalid chain name")); + helperConfig.parseChainName("AVALANCHE_FUJI"); + + // Non-EVM identifiers resolve via the scan but still have no EVM chain ID. + vm.expectRevert(bytes("Invalid chain name")); + helperConfig.parseChainName("SOLANA_DEVNET"); + + assertEq(helperConfig.getChainNameBySelector(1), "Unknown", "unknown selector"); + assertEq(helperConfig.getDestChainConfig("AVALANCHE_FUJI").chainFamily, "", "unknown dest zero config"); + } +} diff --git a/test/config/HelperConfigGoldenData.t.sol b/test/config/HelperConfigGoldenData.t.sol new file mode 100644 index 0000000..d5e85e0 --- /dev/null +++ b/test/config/HelperConfigGoldenData.t.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {HelperConfig} from "../../script/HelperConfig.s.sol"; + +/// @title HelperConfigGoldenDataTest +/// @notice Golden-data parity for the config/chains JSON migration: the addresses, selectors, and +/// genuinely hand-authored keys (`chainNameIdentifier`, `confirmations`, `ccipBnM`) below are pinned as +/// LITERALS captured from HelperConfig BEFORE the hardcoded per-chain config functions were replaced by +/// `config/chains/.json` + `ChainConfig` — they must NOT change across the migration. +/// @dev The API-served identity/metadata fields (`chainName`/`displayName`, `chainFamily`, `explorerUrl`, +/// `nativeCurrencySymbol`) are now SOURCED from the CCIP REST API by the config sync, so a few diverge +/// from the old hand-typed pre-migration values and are pinned to the API truth (captured live +/// 2026-07-09): 0g `chainName` "0g Galileo 1" / `explorerUrl` ".../0g.ai" / `nativeCurrencySymbol` "0G" +/// (was the mistyped "OG"), Ink `nativeCurrencySymbol` "ETH" (Ink settles in ETH), Mantle `explorerUrl` +/// "explorer.sepolia.mantle.xyz", and Solana `explorerUrl` the devnet explorer (was empty). These are +/// the intended corrections of hand-typed drift — the API is the source of truth for these fields. +/// No fork needed: `HelperConfig` reads local JSON only. +contract HelperConfigGoldenDataTest is Test { + HelperConfig internal helperConfig; + + function setUp() public { + helperConfig = new HelperConfig(); + } + + function _assertConfig( + HelperConfig.NetworkConfig memory c, + uint64 chainSelector, + address router, + address rmnProxy, + address tokenAdminRegistry, + address registryModuleOwnerCustom, + address link, + address ccipBnM, + uint256 confirmations, + string memory chainName, + string memory chainNameIdentifier, + string memory explorerUrl, + string memory nativeCurrencySymbol, + string memory chainFamily + ) internal pure { + assertEq(c.chainSelector, chainSelector, "chainSelector"); + assertEq(c.router, router, "router"); + assertEq(c.rmnProxy, rmnProxy, "rmnProxy"); + assertEq(c.tokenAdminRegistry, tokenAdminRegistry, "tokenAdminRegistry"); + assertEq(c.registryModuleOwnerCustom, registryModuleOwnerCustom, "registryModuleOwnerCustom"); + assertEq(c.link, link, "link"); + assertEq(c.ccipBnM, ccipBnM, "ccipBnM"); + assertEq(c.confirmations, confirmations, "confirmations"); + assertEq(c.chainName, chainName, "chainName"); + assertEq(c.chainNameIdentifier, chainNameIdentifier, "chainNameIdentifier"); + assertEq(c.explorerUrl, explorerUrl, "explorerUrl"); + assertEq(c.nativeCurrencySymbol, nativeCurrencySymbol, "nativeCurrencySymbol"); + assertEq(c.chainFamily, chainFamily, "chainFamily"); + } + + function test_EthereumSepolia_MatchesPreMigrationValues() public view { + _assertConfig( + helperConfig.getNetworkConfig(11155111), + 16015286601757825753, + 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59, + 0xba3f6251de62dED61Ff98590cB2fDf6871FbB991, + 0x95F29FEE11c5C55d26cCcf1DB6772DE953B37B82, + 0xa3c796d480638d7476792230da1E2ADa86e031b0, + 0x779877A7B0D9E8603169DdbD7836e478b4624789, + 0x9a97F119cFE1D5Ea77c264441C0A0aBC9B34E119, + 2, + "Ethereum Sepolia", + "ETHEREUM_SEPOLIA", + "https://sepolia.etherscan.io", + "ETH", + "evm" + ); + } + + function test_ZeroGTestnet_MatchesPreMigrationValues() public view { + _assertConfig( + helperConfig.getNetworkConfig(16602), + 6892437333620424805, + 0xD610B8f58689de7755947C05342A2DFaC30ebD57, + 0x995ab3eC29E1660A93cFddAA19C710A1b5afCCc9, + 0x23a5084Fa78104F3DF11C63Ae59fcac4f6AD9DeE, + 0x0820f975ce90EE5c508657F0C58b71D1fcc85cE0, + 0xe5e3a4fF1773d043a387b16Ceb3c91cC49bAFD54, + 0xDbB255D37BC7c9e2b08e5a1C9f9506c9E85F1644, + 2, + "0g Galileo 1", + "0G_GALILEO_TESTNET", + "https://chainscan-galileo.0g.ai", + "0G", + "evm" + ); + } + + function test_PlumeTestnet_MatchesPreMigrationValues() public view { + _assertConfig( + helperConfig.getNetworkConfig(98867), + 13874588925447303949, + 0x5e5Fd4720E1CE826138D043aF578D69f48af502F, + 0xAa3ae5481EE445711252131f1516922D0962916A, + 0x855cF0d18A0BeBEDA7c1CD2F943686120cCCC6bd, + 0x693926456C8b210f56E29Bc5b4514B32A5224c88, + 0xB97e3665AEAF96BDD6b300B2e0C93C662104A068, + 0x225fAc4130595d1C7dabbE61A8bA9B051440b76c, + 2, + "Plume Testnet", + "PLUME_TESTNET", + "https://testnet-explorer.plume.org", + "PLUME", + "evm" + ); + } + + function test_InkSepolia_MatchesPreMigrationValues() public view { + _assertConfig( + helperConfig.getNetworkConfig(763373), + 9763904284804119144, + 0x17fCda531D8E43B4e2a2A2492FBcd4507a1685A1, + 0x84017cfddD12D319E5bBf090e0de6d55B78160Cb, + 0x3A849a05a590FeaEf26c2d425241A2BF29307161, + 0xaB018890bBdDf9B80E21d1c335c5f6acdbE0f5D6, + 0x3423C922911956b1Ccbc2b5d4f38216a6f4299b4, + 0x414dbe1d58dd9BA7C84f7Fc0e4f82bc858675d37, + 2, + "Ink Sepolia", + "INK_SEPOLIA", + "https://explorer-sepolia.inkonchain.com", + "ETH", + "evm" + ); + } + + function test_MantleSepolia_MatchesPreMigrationValues() public view { + _assertConfig( + helperConfig.getNetworkConfig(5003), + 8236463271206331221, + 0xFd33fd627017fEf041445FC19a2B6521C9778f86, + 0xcCB84Ec3F6AFdD2052134f74aaAc95Ae41A7B333, + 0x0F1eE88A582f31d92510E300fc1330AA5a525D51, + 0xf76cE612250eeEb8889F49FBCB11f1c2705305F6, + 0x22bdEdEa0beBdD7CfFC95bA53826E55afFE9DE04, + 0xBB370F829bdB6fC44f3D34e2A2107578bB2c3F0B, + 2, + "Mantle Sepolia", + "MANTLE_SEPOLIA", + "https://explorer.sepolia.mantle.xyz", + "MNT", + "evm" + ); + } + + function test_SolanaDevnet_MatchesPreMigrationValues() public view { + _assertConfig( + helperConfig.getSolanaDevnetConfig(), + 16423721717087811551, + address(0), + address(0), + address(0), + address(0), + address(0), + address(0), + 0, + "Solana Devnet", + "SOLANA_DEVNET", + "https://explorer.solana.com?cluster=devnet", + "SOL", + "svm" + ); + } + + /// @dev The lookup helpers must keep resolving exactly as before the migration. + function test_LookupHelpers_MatchPreMigrationBehavior() public { + // parseChainName: identifier -> chain ID (EVM only) + assertEq(helperConfig.parseChainName("ETHEREUM_SEPOLIA"), 11155111); + assertEq(helperConfig.parseChainName("0G_GALILEO_TESTNET"), 16602); + assertEq(helperConfig.parseChainName("PLUME_TESTNET"), 98867); + assertEq(helperConfig.parseChainName("INK_SEPOLIA"), 763373); + assertEq(helperConfig.parseChainName("MANTLE_SEPOLIA"), 5003); + + // getChainNameBySelector: selector -> display name (incl. non-EVM + unknown) + assertEq(helperConfig.getChainNameBySelector(16015286601757825753), "Ethereum Sepolia"); + assertEq(helperConfig.getChainNameBySelector(16423721717087811551), "Solana Devnet"); + assertEq(helperConfig.getChainNameBySelector(1), "Unknown"); + + // getDestChainConfig: dest-name dispatch incl. the zero config for unknown names + assertEq(helperConfig.getDestChainConfig("SOLANA_DEVNET").chainSelector, 16423721717087811551); + assertEq(helperConfig.getDestChainConfig("ZERO_G_TESTNET").chainSelector, 6892437333620424805); + assertEq(helperConfig.getDestChainConfig("AVALANCHE_FUJI").chainSelector, 0); + assertEq(helperConfig.getDestChainConfig("AVALANCHE_FUJI").chainFamily, ""); + + // getExplorerUrl composition + assertEq( + helperConfig.getExplorerUrl(11155111, "/address/", 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59), + "https://sepolia.etherscan.io/address/0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59" + ); + + // Unsupported chain IDs must keep reverting with the same reason + vm.expectRevert(bytes("Unsupported chain ID")); + helperConfig.getNetworkConfig(43113); + } +} diff --git a/test/config/RegistryGuard.t.sol b/test/config/RegistryGuard.t.sol new file mode 100644 index 0000000..6a8f2c2 --- /dev/null +++ b/test/config/RegistryGuard.t.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {VmSafe} from "forge-std/Vm.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; +import {DeploymentRecorder} from "../../script/utils/DeploymentRecorder.s.sol"; + +/// @dev External wrapper so `vm.expectRevert` can observe the library's revert (internal library +/// calls are inlined into the test frame otherwise), and so the tests drive the deterministic cores +/// directly (the context-aware `guard`/`record` no-op under `forge test`, which is exactly the +/// inertness `BaseForkTest` relies on — see `test_ContextAware_NoRegistryMutationUnderForgeTest`). +contract GuardHarness { + function guardForced(uint256 chainId, string memory name, bool forced) external { + RegistryWriter.guardRedeploy(chainId, name, forced); + } + + function guardEnv(uint256 chainId, string memory name) external { + RegistryWriter.guardRedeploy(chainId, name); + } + + function recordDeterministic(uint256 chainId, string memory role, string memory name, address addr) external { + RegistryWriter.recordDeterministic(chainId, role, name, addr); + } + + function record(uint256 chainId, string memory role, string memory name, address addr) external { + RegistryWriter.record(chainId, role, name, addr); + } + + function guard(uint256 chainId, string memory name) external { + RegistryWriter.guard(chainId, name); + } + + function setActive(uint256 chainId, string memory role, address addr) external { + RegistryWriter.setActive(chainId, role, addr); + } + + function setDeployment(uint256 chainId, string memory name, address addr) external { + RegistryWriter.setDeployment(chainId, name, addr); + } + + function read(uint256 chainId, string memory role) external view returns (address) { + return RegistryWriter.read(chainId, role); + } + + function readDeployment(uint256 chainId, string memory name) external view returns (address) { + return RegistryWriter.readDeployment(chainId, name); + } +} + +/// @notice Registry schema v2 (`active` role pointers + named `deployments`) and its single-writer +/// record + redeploy guard. Each test uses its OWN throwaway `addresses/.json` (removed +/// at the end): forge runs tests in parallel, and no real chain's registry file is ever touched. The +/// deploy-time keys are composed through `DeploymentRecorder` so the tests key exactly as the scripts do. +contract RegistryGuardTest is Test { + // A BurnMint and a LockRelease pool for the same token, and two versions of the BurnMint pool. + string internal constant SYMBOL = "BnM-T"; + address internal constant TOKEN = address(0x1111111111111111111111111111111111111111); + address internal constant POOL_BURNMINT = address(0x2222222222222222222222222222222222222222); + address internal constant POOL_LOCKRELEASE = address(0x3333333333333333333333333333333333333333); + address internal constant POOL_V161 = address(0x4444444444444444444444444444444444444444); + address internal constant POOL_V200 = address(0x5555555555555555555555555555555555555555); + address internal constant HOOKS_A = address(0x6666666666666666666666666666666666666666); + address internal constant HOOKS_B = address(0x7777777777777777777777777777777777777777); + + GuardHarness internal harness; + + function setUp() public { + harness = new GuardHarness(); + } + + function _path(uint256 chainId) internal pure returns (string memory) { + return string.concat("addresses/", vm.toString(chainId), ".json"); + } + + function _rm(uint256 chainId) internal { + if (vm.exists(_path(chainId))) vm.removeFile(_path(chainId)); + } + + // (1) No cross-pool-type collision: a BurnMint and a LockRelease pool for the SAME token on one + // chain both record and both resolve; neither clobbers the other's deployments entry. + function test_CrossPoolType_BothResolvable_NoClobber() public { + uint256 chainId = 900_000_000_101; + string memory bmName = DeploymentRecorder.poolName(SYMBOL, "BurnMint"); + string memory lrName = DeploymentRecorder.poolName(SYMBOL, "LockRelease"); + + harness.recordDeterministic(chainId, "tokenPool", bmName, POOL_BURNMINT); + harness.recordDeterministic(chainId, "tokenPool", lrName, POOL_LOCKRELEASE); + + assertEq(harness.readDeployment(chainId, bmName), POOL_BURNMINT, "BurnMint pool resolvable"); + assertEq(harness.readDeployment(chainId, lrName), POOL_LOCKRELEASE, "LockRelease pool resolvable"); + // active.tokenPool mirrors the most-recently-recorded pool. + assertEq(harness.read(chainId, "tokenPool"), POOL_LOCKRELEASE, "active.tokenPool is newest"); + _rm(chainId); + } + + // (2) Registry DATA LAYER holds two versioned entries: because the deployments key carries the pool + // type + version, two version keys (1.6.1, 2.0.0) coexist without tripping the guard, both + // addresses resolve via readDeployment, and active.tokenPool mirrors the newest write. This + // proves ONLY the storage layer — it is NOT a migration flow reachable through the deploy + // scripts: `DeploymentRecorder.POOL_VERSION` is hardcoded "2.0.0", so the scripts only ever emit + // the _2.0.0 key. The 1.6.1 key below is hand-injected to exercise the data structure directly. + function test_TwoVersionedEntries_CoexistInRegistryDataLayer() public { + uint256 chainId = 900_000_000_102; + // Hand-inject a 1.6.1 key (the scripts never emit it — POOL_VERSION is pinned to 2.0.0). + string memory oldName = string.concat(SYMBOL, "_BurnMintTokenPool_1.6.1"); + string memory newName = DeploymentRecorder.poolName(SYMBOL, "BurnMint"); // ..._2.0.0 + + harness.recordDeterministic(chainId, "tokenPool", oldName, POOL_V161); + + // A DIFFERENT deployments name (different version key) → guard must NOT revert (no force needed). + harness.guardForced(chainId, newName, false); + harness.recordDeterministic(chainId, "tokenPool", newName, POOL_V200); + + assertEq(harness.readDeployment(chainId, oldName), POOL_V161, "1.6.1-keyed entry still resolvable"); + assertEq(harness.readDeployment(chainId, newName), POOL_V200, "2.0.0-keyed entry resolvable"); + assertEq(harness.read(chainId, "tokenPool"), POOL_V200, "active.tokenPool is the newest write"); + _rm(chainId); + } + + // (3) Same-name redeploy is guarded; forcing drops the entry and re-records; siblings survive. + function test_SameNameRedeploy_Guarded_ForceDropsAndReRecords() public { + uint256 chainId = 900_000_000_103; + string memory name = DeploymentRecorder.poolName(SYMBOL, "BurnMint"); + string memory sibling = DeploymentRecorder.lockBoxName(SYMBOL); + + harness.recordDeterministic(chainId, "tokenPool", name, POOL_BURNMINT); + harness.recordDeterministic(chainId, "lockBox", sibling, POOL_LOCKRELEASE); + + // Un-forced redeploy of the SAME name refuses, naming the existing address + the override. + vm.expectRevert( + bytes( + string.concat( + "RegistryWriter: '", + name, + "' is already deployed at ", + vm.toString(POOL_BURNMINT), + " (", + string.concat(vm.projectRoot(), "/", _path(chainId)), + "). Refusing to redeploy - set FORCE_REDEPLOY=true to deploy a replacement." + ) + ) + ); + harness.guardForced(chainId, name, false); + + // Forcing drops the entry (and its active pointer), then the redeploy re-records under the name. + harness.guardForced(chainId, name, true); + assertEq(harness.readDeployment(chainId, name), address(0), "stale deployment dropped"); + assertEq(harness.read(chainId, "tokenPool"), address(0), "active pointer to dropped addr cleared"); + harness.recordDeterministic(chainId, "tokenPool", name, POOL_V200); + + assertEq(harness.readDeployment(chainId, name), POOL_V200, "replacement recorded under same name"); + assertEq(harness.readDeployment(chainId, sibling), POOL_LOCKRELEASE, "sibling deployment survived"); + assertEq(harness.read(chainId, "lockBox"), POOL_LOCKRELEASE, "sibling active pointer survived"); + _rm(chainId); + } + + // (4) Hooks replacement: new hooks for the same pool are guarded; FORCE_REDEPLOY replaces; the old + // address is still in the append-only ledger under script/deployments (the registry itself is + // gitignored, so NOT in git history) — the registry drops it, per the guard's force path. + function test_HooksReplacement_GuardedThenForced() public { + uint256 chainId = 900_000_000_104; + string memory name = DeploymentRecorder.hooksName(SYMBOL, "BurnMint"); + + harness.recordDeterministic(chainId, "poolHooks", name, HOOKS_A); + assertEq(harness.read(chainId, "poolHooks"), HOOKS_A, "initial hooks active"); + + // Same name → guarded. + vm.expectRevert(bytes(_alreadyDeployed(chainId, name, HOOKS_A))); + harness.guardForced(chainId, name, false); + + // Forced replacement. + harness.guardForced(chainId, name, true); + harness.recordDeterministic(chainId, "poolHooks", name, HOOKS_B); + assertEq(harness.readDeployment(chainId, name), HOOKS_B, "hooks replaced in registry"); + assertEq(harness.read(chainId, "poolHooks"), HOOKS_B, "active.poolHooks is the replacement"); + _rm(chainId); + } + + // (5) One call writes BOTH stores: a single deterministic record upserts the named deployment AND + // the active role pointer in one write, so the two can never drift (the anti-duplication + // invariant Syed asked for). The recorder facade folds this together with the ledger file; the + // ledger half is asserted in the fork end-to-end proof (the registry half no-ops under forge + // test, mirroring BaseForkTest inertness — see the context-awareness test below). + function test_OneCallWritesBothStores() public { + uint256 chainId = 900_000_000_105; + string memory name = DeploymentRecorder.poolName(SYMBOL, "BurnMint"); + + harness.recordDeterministic(chainId, "tokenPool", name, POOL_BURNMINT); + + assertEq(harness.readDeployment(chainId, name), POOL_BURNMINT, "deployments entry written"); + assertEq(harness.read(chainId, "tokenPool"), POOL_BURNMINT, "active pointer written by the same call"); + _rm(chainId); + } + + // (6) Context-awareness preserved: under `forge test` (TestGroup) the context-aware wrappers are + // no-ops — neither the guard nor the record touches the durable store. This is exactly why + // BaseForkTest can rerun the real deploy scripts as fixtures without mutating a real registry. + function test_ContextAware_NoRegistryMutationUnderForgeTest() public { + assertTrue(vm.isContext(VmSafe.ForgeContext.TestGroup), "precondition: running under forge test"); + uint256 chainId = 900_000_000_106; + string memory name = DeploymentRecorder.poolName(SYMBOL, "BurnMint"); + + // Even with an entry that WOULD trip the deterministic guard, the context-aware guard no-ops. + harness.setDeployment(chainId, name, POOL_BURNMINT); + harness.guard(chainId, name); // must not revert under forge test + + // The context-aware record no-ops: it must not create/mutate a fresh chain's registry. + uint256 freshChain = 900_000_000_107; + assertFalse(vm.exists(_path(freshChain)), "precondition: no registry file"); + harness.record(freshChain, "tokenPool", name, POOL_V200); + assertFalse(vm.exists(_path(freshChain)), "context-aware record must not write under forge test"); + + _rm(chainId); + } + + // (7) Legacy fallback: a pre-v2 FLAT `{ "": "0x.." }` registry still resolves via read(). + function test_LegacyFlatRegistryStillResolves() public { + uint256 chainId = 900_000_000_108; + vm.writeFile( + _path(chainId), + string.concat( + "{\n \"token\": \"", + vm.toString(TOKEN), + "\",\n \"tokenPool\": \"", + vm.toString(POOL_BURNMINT), + "\"\n}\n" + ) + ); + assertEq(harness.read(chainId, "token"), TOKEN, "legacy flat token resolves"); + assertEq(harness.read(chainId, "tokenPool"), POOL_BURNMINT, "legacy flat tokenPool resolves"); + assertEq(harness.read(chainId, "lockBox"), address(0), "absent legacy role resolves to 0"); + _rm(chainId); + } + + // The env wrapper honors FORCE_REDEPLOY=true (the exact path the deploy scripts call). + function test_EnvWrapperHonorsForceRedeploy() public { + uint256 chainId = 900_000_000_109; + string memory name = DeploymentRecorder.poolName(SYMBOL, "BurnMint"); + harness.recordDeterministic(chainId, "tokenPool", name, POOL_BURNMINT); + vm.setEnv("FORCE_REDEPLOY", "true"); + harness.guardEnv(chainId, name); // must not revert + harness.recordDeterministic(chainId, "tokenPool", name, POOL_V200); + assertEq(harness.readDeployment(chainId, name), POOL_V200, "replacement registered"); + vm.setEnv("FORCE_REDEPLOY", "false"); + _rm(chainId); + } + + // The committed example registry parses with the v2 reader (schema smoke). + function test_ExampleRegistryParses() public view { + string memory json = vm.readFile("addresses/11155111.example.json"); + assertEq( + vm.parseJsonAddress(json, ".active.token"), + address(0x1111111111111111111111111111111111111111), + "example active.token entry" + ); + assertEq( + vm.parseJsonAddress(json, ".active.tokenPool"), + address(0x2222222222222222222222222222222222222222), + "example active.tokenPool entry" + ); + assertEq( + vm.parseJsonAddress(json, ".deployments[\"BnM-T_BurnMintTokenPool_2.0.0\"]"), + address(0x2222222222222222222222222222222222222222), + "example versioned deployments entry" + ); + } + + function _alreadyDeployed(uint256 chainId, string memory name, address addr) internal view returns (string memory) { + return string.concat( + "RegistryWriter: '", + name, + "' is already deployed at ", + vm.toString(addr), + " (", + string.concat(vm.projectRoot(), "/", _path(chainId)), + "). Refusing to redeploy - set FORCE_REDEPLOY=true to deploy a replacement." + ); + } +} diff --git a/test/config/RegistryRepointGuard.t.sol b/test/config/RegistryRepointGuard.t.sol new file mode 100644 index 0000000..b72b5aa --- /dev/null +++ b/test/config/RegistryRepointGuard.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; + +/// @dev External wrapper so the internal library view is reachable from an external call frame and +/// so the tests drive the deterministic core directly (the context-aware wrappers no-op under +/// `forge test`). +contract RepointHarness { + function wouldRepointActive(uint256 chainId, string memory role, address addr) + external + view + returns (bool repoints, address previous) + { + return RegistryWriter.wouldRepointActive(chainId, role, addr); + } + + function setActive(uint256 chainId, string memory role, address addr) external { + RegistryWriter.setActive(chainId, role, addr); + } + + function read(uint256 chainId, string memory role) external view returns (address) { + return RegistryWriter.read(chainId, role); + } +} + +/// @notice The `active[role]` REPOINT guard: `wouldRepointActive` is the deterministic view that +/// `setActive`/`recordDeterministic` gate their loud console warning on when a single-valued `active` +/// pointer is about to be moved off an existing fixture onto a different address (observed live: +/// deploying a second token on a chain silently hijacked `active.token`). The behavior does NOT +/// change — the repoint still happens — so only the decision helper is unit-testable; these tests +/// pin its truth table. +/// +/// Cleanup is REVERT-SAFE and done in `setUp()` (before every test), NEVER at end-of-test: a test +/// that reverts mid-body would otherwise leak its throwaway `addresses/.json`, and an +/// end-of-test `deleteFile` was the earlier poison-pill. Each test owns a distinct throwaway chainId. +contract RegistryRepointGuardTest is Test { + RepointHarness internal harness; + + // Distinct throwaway chain IDs (well outside any real chainId) — one per test. + uint256 internal constant CHAIN_FIRST_SET = 900_000_000_201; + uint256 internal constant CHAIN_SAME_ADDR = 900_000_000_202; + uint256 internal constant CHAIN_REPOINT = 900_000_000_203; + + address internal constant ADDR_OLD = address(0x1111111111111111111111111111111111111111); + address internal constant ADDR_NEW = address(0x2222222222222222222222222222222222222222); + + function _path(uint256 chainId) internal pure returns (string memory) { + return string.concat("addresses/", vm.toString(chainId), ".json"); + } + + /// @dev Revert-safe cleanup BEFORE each test (never after): guarantees every test starts from a + /// clean slate even if a prior run leaked a file, and no test relies on end-of-test deletion. + function setUp() public { + harness = new RepointHarness(); + uint256[3] memory chains = [CHAIN_FIRST_SET, CHAIN_SAME_ADDR, CHAIN_REPOINT]; + for (uint256 i = 0; i < chains.length; i++) { + string memory p = _path(chains[i]); + if (vm.exists(p)) vm.removeFile(p); + } + } + + // (1) First set: no active[role] yet -> NOT a repoint, previous is address(0). + function test_WouldRepoint_FirstSet_ReturnsFalseZero() public view { + (bool repoints, address previous) = harness.wouldRepointActive(CHAIN_FIRST_SET, "token", ADDR_NEW); + assertFalse(repoints, "first set is never a repoint"); + assertEq(previous, address(0), "no previous pointer on first set"); + } + + // (2) Idempotent re-set: active[role] already == addr -> NOT a repoint, previous is that address. + function test_WouldRepoint_SameAddressReset_ReturnsFalseSame() public { + harness.setActive(CHAIN_SAME_ADDR, "token", ADDR_OLD); + (bool repoints, address previous) = harness.wouldRepointActive(CHAIN_SAME_ADDR, "token", ADDR_OLD); + assertFalse(repoints, "re-setting the same address is not a repoint"); + assertEq(previous, ADDR_OLD, "previous is the unchanged address"); + } + + // (3) Overwrite a DIFFERENT non-zero address -> repoints=true, previous is the old address. The + // repoint still happens (behavior unchanged) — assert the pointer moved after the warned set. + function test_WouldRepoint_DifferentNonZero_ReturnsTrueOld() public { + harness.setActive(CHAIN_REPOINT, "token", ADDR_OLD); + (bool repoints, address previous) = harness.wouldRepointActive(CHAIN_REPOINT, "token", ADDR_NEW); + assertTrue(repoints, "overwriting a different non-zero pointer is a repoint"); + assertEq(previous, ADDR_OLD, "previous is the address being repointed away from"); + + // Warning is advisory only: the repoint is NOT blocked. + harness.setActive(CHAIN_REPOINT, "token", ADDR_NEW); + assertEq(harness.read(CHAIN_REPOINT, "token"), ADDR_NEW, "repoint still applied (warn-only)"); + } +} diff --git a/test/config/RegistryResolution.t.sol b/test/config/RegistryResolution.t.sol new file mode 100644 index 0000000..1636f23 --- /dev/null +++ b/test/config/RegistryResolution.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {HelperConfig} from "../../script/HelperConfig.s.sol"; +import {RegistryWriter} from "../../src/utils/RegistryWriter.sol"; + +/// @title RegistryResolutionTest +/// @notice Deployed-address resolution precedence in `HelperConfig`: +/// inline alias env (`TOKEN_POOL`) > chain-scoped env (`{CHAIN}_TOKEN_POOL`) +/// > address registry (`addresses/.json`) > `address(0)`. +/// @dev ONE test function: `vm.setEnv` is process-wide and forge runs tests in parallel, so the +/// env escalation must be strictly ordered inside a single function (env vars are only ever +/// escalated, never unset — the one-way discipline the existing fixtures use). All assertions +/// use the `*_TOKEN_POOL` family on chains no other suite touches: the fork fixtures +/// (`BaseForkTest`) already set the `TOKEN` alias concurrently, so the `TOKEN` ladder cannot +/// be asserted race-free here; `TOKEN` and `TOKEN_POOL` share the exact same resolution code +/// in `HelperConfig._initializeDeployedContracts`, so the ladder proven for `TOKEN_POOL` +/// holds for `TOKEN`. +contract RegistryResolutionTest is Test { + uint256 internal constant INK_SEPOLIA_CHAIN_ID = 763373; + uint256 internal constant MANTLE_SEPOLIA_CHAIN_ID = 5003; + uint256 internal constant PLUME_TESTNET_CHAIN_ID = 98867; + + address internal constant REGISTRY_POOL = address(uint160(0xA1)); + address internal constant CHAIN_ENV_POOL = address(uint160(0xB2)); + address internal constant INLINE_POOL = address(uint160(0xC3)); + address internal constant BACKCOMPAT_POOL = address(uint160(0xD4)); + + function _registryPath(uint256 chainId) internal pure returns (string memory) { + return string.concat("addresses/", vm.toString(chainId), ".json"); + } + + /// @dev Revert-safe cleanup: delete this suite's scratch registry file BEFORE the test runs, never + /// relying on end-of-test deletion. `addresses/*.json` is gitignored, so a file left behind by a + /// mid-test revert survives invisibly (`git status` stays clean) and bricks every later `forge test` + /// (the rung-4 preconditions assert the file is absent). Cleaning up front makes the suite idempotent. + function setUp() public { + if (vm.exists(_registryPath(INK_SEPOLIA_CHAIN_ID))) vm.removeFile(_registryPath(INK_SEPOLIA_CHAIN_ID)); + } + + function test_ResolutionPrecedence_InlineOverChainEnvOverRegistryOverZero() public { + // Preconditions: the ladder is only observable when the relevant vars start unset + // (skip instead of failing when the caller's shell already exports them). + if ( + vm.envOr("TOKEN_POOL", address(0)) != address(0) + || vm.envOr("INK_SEPOLIA_TOKEN_POOL", address(0)) != address(0) + || vm.envOr("MANTLE_SEPOLIA_TOKEN_POOL", address(0)) != address(0) + || vm.envOr("PLUME_TESTNET_TOKEN_POOL", address(0)) != address(0) + ) { + vm.skip(true); + } + + // Rung 4 — nothing anywhere: resolution stays address(0) (unchanged pre-registry behavior). + assertFalse(vm.exists(_registryPath(PLUME_TESTNET_CHAIN_ID)), "precondition: no plume registry file"); + assertEq( + new HelperConfig().getDeployedTokenPool(PLUME_TESTNET_CHAIN_ID), + address(0), + "absent everywhere must resolve to address(0)" + ); + + // Rung 3 — registry only: the deploy-flow-written file resolves with ZERO env vars. + RegistryWriter.set(INK_SEPOLIA_CHAIN_ID, "tokenPool", REGISTRY_POOL); + assertEq( + new HelperConfig().getDeployedTokenPool(INK_SEPOLIA_CHAIN_ID), + REGISTRY_POOL, + "registry entry must resolve when no env var is set" + ); + + // Back-compat — the pre-registry env-var flow keeps working with NO registry file present. + assertFalse(vm.exists(_registryPath(MANTLE_SEPOLIA_CHAIN_ID)), "precondition: no mantle registry file"); + vm.setEnv("MANTLE_SEPOLIA_TOKEN_POOL", vm.toString(BACKCOMPAT_POOL)); + assertEq( + new HelperConfig().getDeployedTokenPool(MANTLE_SEPOLIA_CHAIN_ID), + BACKCOMPAT_POOL, + "old env-var flow must keep working without any registry file" + ); + + // Rung 2 — chain-scoped env var beats the registry. + vm.setEnv("INK_SEPOLIA_TOKEN_POOL", vm.toString(CHAIN_ENV_POOL)); + assertEq( + new HelperConfig().getDeployedTokenPool(INK_SEPOLIA_CHAIN_ID), + CHAIN_ENV_POOL, + "chain-scoped env var must beat the registry" + ); + + // Rung 1 — inline alias beats both. + vm.setEnv("TOKEN_POOL", vm.toString(INLINE_POOL)); + assertEq( + new HelperConfig().getDeployedTokenPool(INK_SEPOLIA_CHAIN_ID), + INLINE_POOL, + "inline TOKEN_POOL alias must beat the chain-scoped env var and the registry" + ); + + vm.removeFile(_registryPath(INK_SEPOLIA_CHAIN_ID)); + } +} + +/// @title RegistryResolutionExtrasTest +/// @notice The same deployed-address ladder, now proven for the two artifacts wired in this PR: +/// `lockBox` and `poolHooks` (previously written to the registry but never read back). The +/// `HelperConfig` getters resolve: inline alias > `{CHAIN}_` env > registry `active.` +/// > `address(0)`. +/// @dev Uses the 0g testnet chain (16602) — a configured chain NO other suite touches — so its +/// registry file and chain-scoped env vars cannot race the ladder above (which uses ink/mantle/ +/// plume) or the Sepolia fork fixtures. Rungs 2-4 are asserted directly here. Rung 1 (the bare +/// inline `LOCK_BOX` / `POOL_HOOKS` alias) is deliberately NOT set process-wide: the deploy/ops +/// fork fixtures consume the lockbox/hooks addresses via the CHAIN-SCOPED vars +/// (`LockboxOps` sets `ETHEREUM_SEPOLIA_LOCK_BOX`, not the bare `LOCK_BOX`), and no suite sets the +/// bare `LOCK_BOX`/`POOL_HOOKS` alias, so rungs 2-4 here are race-free. The inline rung is the +/// identical `vm.envOr("LOCK_BOX"/"POOL_HOOKS", getter)` first argument already proven race-free +/// for `TOKEN_POOL` above — the same code, so the same ladder holds. +contract RegistryResolutionExtrasTest is Test { + uint256 internal constant ZERO_G_TESTNET_CHAIN_ID = 16602; + string internal constant CHAIN_LOCK_BOX_ENV = "0G_GALILEO_TESTNET_LOCK_BOX"; + string internal constant CHAIN_POOL_HOOKS_ENV = "0G_GALILEO_TESTNET_POOL_HOOKS"; + + address internal constant REG_LOCK_BOX = address(uint160(0xB0)); + address internal constant REG_POOL_HOOKS = address(uint160(0xB1)); + address internal constant CHAIN_LOCK_BOX = address(uint160(0xC0)); + address internal constant CHAIN_POOL_HOOKS = address(uint160(0xC1)); + + function _registryPath(uint256 chainId) internal pure returns (string memory) { + return string.concat("addresses/", vm.toString(chainId), ".json"); + } + + /// @dev Revert-safe cleanup (see the sibling `RegistryResolutionTest.setUp`): delete the scratch + /// `addresses/16602.json` BEFORE the precondition, so a mid-test revert can never leave a gitignored + /// file that deterministically bricks the next `forge test` at "precondition: no 0g registry file". + function setUp() public { + if (vm.exists(_registryPath(ZERO_G_TESTNET_CHAIN_ID))) vm.removeFile(_registryPath(ZERO_G_TESTNET_CHAIN_ID)); + } + + function test_LockBoxAndPoolHooks_ResolutionLadder() public { + // Preconditions: observable only when the relevant vars start unset (skip, don't fail). + if ( + vm.envOr("LOCK_BOX", address(0)) != address(0) || vm.envOr("POOL_HOOKS", address(0)) != address(0) + || vm.envOr(CHAIN_LOCK_BOX_ENV, address(0)) != address(0) + || vm.envOr(CHAIN_POOL_HOOKS_ENV, address(0)) != address(0) + ) { + vm.skip(true); + } + + // Rung 4 — nothing anywhere: both resolve to address(0). + assertFalse(vm.exists(_registryPath(ZERO_G_TESTNET_CHAIN_ID)), "precondition: no 0g registry file"); + assertEq(new HelperConfig().getDeployedLockBox(ZERO_G_TESTNET_CHAIN_ID), address(0), "lockBox absent -> 0"); + assertEq(new HelperConfig().getDeployedPoolHooks(ZERO_G_TESTNET_CHAIN_ID), address(0), "poolHooks absent -> 0"); + + // Rung 3 — registry only (the deploy-flow-written active pointers) resolves with ZERO env vars. + // This is the exact zero-export promise: after a lockbox/hooks deploy, later scripts resolve them. + RegistryWriter.setActive(ZERO_G_TESTNET_CHAIN_ID, "lockBox", REG_LOCK_BOX); + RegistryWriter.setActive(ZERO_G_TESTNET_CHAIN_ID, "poolHooks", REG_POOL_HOOKS); + assertEq( + new HelperConfig().getDeployedLockBox(ZERO_G_TESTNET_CHAIN_ID), + REG_LOCK_BOX, + "registry active.lockBox resolves with no env var" + ); + assertEq( + new HelperConfig().getDeployedPoolHooks(ZERO_G_TESTNET_CHAIN_ID), + REG_POOL_HOOKS, + "registry active.poolHooks resolves with no env var" + ); + + // Rung 2 — chain-scoped env var beats the registry. These `{CHAIN}_` vars are 0g-specific, so + // they cannot leak into the Sepolia fork fixtures. + vm.setEnv(CHAIN_LOCK_BOX_ENV, vm.toString(CHAIN_LOCK_BOX)); + vm.setEnv(CHAIN_POOL_HOOKS_ENV, vm.toString(CHAIN_POOL_HOOKS)); + assertEq( + new HelperConfig().getDeployedLockBox(ZERO_G_TESTNET_CHAIN_ID), + CHAIN_LOCK_BOX, + "chain-scoped env beats the registry (lockBox)" + ); + assertEq( + new HelperConfig().getDeployedPoolHooks(ZERO_G_TESTNET_CHAIN_ID), + CHAIN_POOL_HOOKS, + "chain-scoped env beats the registry (poolHooks)" + ); + + vm.removeFile(_registryPath(ZERO_G_TESTNET_CHAIN_ID)); + } +} diff --git a/test/config/SyncFixtureTransform.t.sol b/test/config/SyncFixtureTransform.t.sol new file mode 100644 index 0000000..32d8968 --- /dev/null +++ b/test/config/SyncFixtureTransform.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; + +/// @notice Pins the config-sync transform against a REAL, committed CCIP REST API v2 response +/// (`test/fixtures/ccip-api/chain-16015286601757825753.json`, `GET /chains/{selector}` for +/// Ethereum Sepolia). Offline (no ffi, no network): asserts that selecting the `isActive: true` +/// entry per contract type — exactly what `script/config/ccip-config-source.sh` does with jq — +/// reproduces the committed `config/chains/ethereum-testnet-sepolia.json` `ccip{}` block, including the +/// API->repo key mapping (`rmn` -> `rmnProxy`, `registryModule` -> `registryModuleOwnerCustom`, +/// LINK fee token -> `link`). The end-to-end write path (jq + `vm.writeJson`, idempotency, extras +/// preservation) is exercised by `script/config/test-tooling.sh` against a local fixture server. +contract SyncFixtureTransformTest is Test { + string internal fixtureJson; + string internal configJson; + + function setUp() public { + fixtureJson = vm.readFile("test/fixtures/ccip-api/chain-16015286601757825753.json"); + configJson = vm.readFile("config/chains/ethereum-testnet-sepolia.json"); + } + + /// @dev Selects the `isActive: true` entry for a chainConfig contract type (the jq `act()` rule). + function _activeAddress(string memory apiKey) internal view returns (address) { + string memory base = string.concat(".chainConfig.", apiKey); + for (uint256 i = 0; i < 16; i++) { + string memory entry = string.concat(base, "[", vm.toString(i), "]"); + if (!vm.keyExistsJson(fixtureJson, entry)) break; + if (vm.parseJsonBool(fixtureJson, string.concat(entry, ".isActive"))) { + return vm.parseJsonAddress(fixtureJson, string.concat(entry, ".address")); + } + } + revert(string.concat("no active ", apiKey, " entry in fixture")); + } + + function test_FixtureIdentityMatchesCommittedConfig() public view { + assertEq( + vm.parseJsonUint(fixtureJson, ".chain.chainId"), + vm.parseJsonUint(configJson, ".chainId"), + "fixture chainId != config chainId" + ); + assertEq( + vm.parseJsonUint(fixtureJson, ".chain.chainSelector"), + vm.parseJsonUint(configJson, ".chainSelector"), + "fixture selector != config selector" + ); + assertEq(vm.parseJsonString(fixtureJson, ".chain.chainFamily"), "EVM", "fixture chainFamily"); + } + + function test_ActiveSelectionMatchesCommittedCcipBlock() public view { + // API key -> repo ccip{} key mapping, exactly as the fetch script emits it. + assertEq(_activeAddress("router"), vm.parseJsonAddress(configJson, ".ccip.router"), "router"); + assertEq(_activeAddress("rmn"), vm.parseJsonAddress(configJson, ".ccip.rmnProxy"), "rmn -> rmnProxy"); + assertEq( + _activeAddress("tokenAdminRegistry"), + vm.parseJsonAddress(configJson, ".ccip.tokenAdminRegistry"), + "tokenAdminRegistry" + ); + assertEq( + _activeAddress("registryModule"), + vm.parseJsonAddress(configJson, ".ccip.registryModuleOwnerCustom"), + "registryModule -> registryModuleOwnerCustom" + ); + assertEq(_activeAddress("feeQuoter"), vm.parseJsonAddress(configJson, ".ccip.feeQuoter"), "feeQuoter"); + assertEq( + _activeAddress("tokenPoolFactory"), + vm.parseJsonAddress(configJson, ".ccip.tokenPoolFactory"), + "tokenPoolFactory" + ); + } + + /// @dev The fixture carries `isActive: false` siblings (an old router + FeeQuoter), so blind + /// first-entry selection would produce a DIFFERENT config — proves the isActive rule matters. + function test_InactiveSiblingsExistAndDiffer() public view { + assertFalse(vm.parseJsonBool(fixtureJson, ".chainConfig.router[1].isActive"), "router[1] should be inactive"); + assertTrue( + vm.parseJsonAddress(fixtureJson, ".chainConfig.router[1].address") != _activeAddress("router"), + "inactive router should differ from the active one" + ); + assertFalse( + vm.parseJsonBool(fixtureJson, ".chainConfig.feeQuoter[1].isActive"), "feeQuoter[1] should be inactive" + ); + assertTrue( + vm.parseJsonAddress(fixtureJson, ".chainConfig.feeQuoter[1].address") != _activeAddress("feeQuoter"), + "inactive feeQuoter should differ from the active one" + ); + } + + function test_LinkFeeTokenMatchesCommittedLink() public view { + address link = address(0); + for (uint256 i = 0; i < 16; i++) { + string memory entry = string.concat(".chainConfig.feeTokens[", vm.toString(i), "]"); + if (!vm.keyExistsJson(fixtureJson, entry)) break; + if ( + keccak256(bytes(vm.parseJsonString(fixtureJson, string.concat(entry, ".tokenSymbol")))) + == keccak256(bytes("LINK")) + ) { + link = vm.parseJsonAddress(fixtureJson, string.concat(entry, ".tokenAddress")); + break; + } + } + assertEq(link, vm.parseJsonAddress(configJson, ".ccip.link"), "LINK fee token -> ccip.link"); + } + + function test_FeeTokensMatchCommittedArray() public view { + address[] memory committed = vm.parseJsonAddressArray(configJson, ".ccip.feeTokens"); + for (uint256 i = 0; i < committed.length; i++) { + string memory entry = string.concat(".chainConfig.feeTokens[", vm.toString(i), "]"); + assertTrue(vm.keyExistsJson(fixtureJson, entry), "fixture has fewer feeTokens than config"); + assertEq( + vm.parseJsonAddress(fixtureJson, string.concat(entry, ".tokenAddress")), + committed[i], + string.concat("feeTokens[", vm.toString(i), "]") + ); + } + // and no extra entries beyond the committed list + assertFalse( + vm.keyExistsJson(fixtureJson, string.concat(".chainConfig.feeTokens[", vm.toString(committed.length), "]")), + "fixture has more feeTokens than config" + ); + } +} diff --git a/test/config/SyncToolingRules.t.sol b/test/config/SyncToolingRules.t.sol new file mode 100644 index 0000000..f388fe7 --- /dev/null +++ b/test/config/SyncToolingRules.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {SyncCcipConfig} from "../../script/config/SyncCcipConfig.s.sol"; + +/// @notice Pins the pure rules of the chain-config tooling (`script/config/SyncCcipConfig.s.sol`): +/// chain-name validation (names become file paths and shell arguments — path traversal and shell +/// metacharacters must be refused up front) and the derived-default conventions used by add-chain. +contract SyncToolingRulesTest is Test { + SyncCcipConfig internal sync; + + function setUp() public { + sync = new SyncCcipConfig(); + } + + function test_ValidChainNamesAccepted() public view { + assertTrue(sync.isValidChainName("ethereum-testnet-sepolia-mantle-1")); + assertTrue(sync.isValidChainName("0g-testnet-galileo-1")); + assertTrue(sync.isValidChainName("sepolia")); + assertTrue(sync.isValidChainName("chain2")); + } + + function test_PathTraversalAndSeparatorNamesRejected() public view { + assertFalse(sync.isValidChainName("../evil"), "path traversal"); + assertFalse(sync.isValidChainName("evil/sub"), "path separator"); + assertFalse(sync.isValidChainName("..."), "dots"); + assertFalse(sync.isValidChainName(""), "empty"); + } + + function test_ShellUnsafeNamesRejected() public view { + assertFalse(sync.isValidChainName("evil name"), "space"); + assertFalse(sync.isValidChainName("Evil"), "uppercase"); + assertFalse(sync.isValidChainName("-evil"), "leading dash"); + assertFalse(sync.isValidChainName("evil;rm"), "shell metacharacter"); + } + + function test_ChainNameIdentifierDerivation() public view { + assertEq(sync.chainNameIdentifierFor("ethereum-testnet-sepolia-mantle-1"), "ETHEREUM_TESTNET_SEPOLIA_MANTLE_1"); + assertEq(sync.chainNameIdentifierFor("ethereum-testnet-sepolia"), "ETHEREUM_TESTNET_SEPOLIA"); + assertEq(sync.chainNameIdentifierFor("0g-testnet-galileo-1"), "0G_TESTNET_GALILEO_1"); + } + + /// @dev Pins THE single list of API-synced ccip{} address fields (shared by the sync write and + /// the drift check) to the committed `config/chains/.json` schema. + function test_CcipAddressKeysMatchSchema() public view { + string[7] memory keys = sync.ccipAddressKeys(); + string[7] memory expected = [ + "router", + "rmnProxy", + "tokenAdminRegistry", + "registryModuleOwnerCustom", + "link", + "feeQuoter", + "tokenPoolFactory" + ]; + string memory configJson = vm.readFile("config/chains/ethereum-testnet-sepolia.json"); + for (uint256 i = 0; i < keys.length; i++) { + assertEq(keys[i], expected[i], "key order changed"); + assertTrue( + vm.keyExistsJson(configJson, string.concat(".ccip.", keys[i])), + string.concat("committed schema lacks .ccip.", keys[i]) + ); + } + } +} diff --git a/test/config/VerifyChainTarReconcile.t.sol b/test/config/VerifyChainTarReconcile.t.sol new file mode 100644 index 0000000..01a6c31 --- /dev/null +++ b/test/config/VerifyChainTarReconcile.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {TokenAdminRegistry} from "@chainlink/contracts-ccip/contracts/tokenAdminRegistry/TokenAdminRegistry.sol"; +import { + RegistryModuleOwnerCustom +} from "@chainlink/contracts-ccip/contracts/tokenAdminRegistry/RegistryModuleOwnerCustom.sol"; +import {VerifyChain} from "../../script/config/VerifyChain.s.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; + +/// @notice The `make doctor` registry rung reconciles the registry's pool (`active.tokenPool`) against the +/// pool actually wired in the on-chain TokenAdminRegistry. It must PASS on a match, WARN (never FAIL) on a +/// divergence (legitimate when the wired pool was changed out-of-band), and WARN when the token has no +/// TAR entry. This asserts the WARN-not-FAIL contract directly via `VerifyChain.reconcilePoolWithTarForTest`. +contract VerifyChainTarReconcileForkTest is BaseForkTest { + address internal token; + address internal pool; + address internal deployer; + TokenAdminRegistry internal registry; + RegistryModuleOwnerCustom internal registryModule; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + deployer = _scriptBroadcaster(); + registry = TokenAdminRegistry(networkConfig.tokenAdminRegistry); + registryModule = RegistryModuleOwnerCustom(networkConfig.registryModuleOwnerCustom); + } + + function _register() internal { + vm.startPrank(deployer); + registryModule.registerAdminViaGetCCIPAdmin(token); + registry.acceptAdminRole(token); + vm.stopPrank(); + } + + // PASS: the registry pool IS the pool wired in the TAR -> no WARN, no FAIL. + function test_Reconcile_Pass_WhenRegistryPoolIsWired() public { + _register(); + vm.prank(deployer); + registry.setPool(token, pool); + + (uint256 fails, uint256 warns) = new VerifyChain().reconcilePoolWithTarForTest(address(registry), token, pool); + assertEq(fails, 0, "match must never FAIL"); + assertEq(warns, 0, "match must not WARN"); + } + + // WARN: the registry pool differs from the wired pool (out-of-band change) -> WARN, still 0 FAIL. + function test_Reconcile_Warn_WhenRegistryPoolDivergesFromWired() public { + _register(); + vm.prank(deployer); + registry.setPool(token, pool); // TAR is wired to `pool` + + // Simulate `active.tokenPool` pointing at a different pool than the one the TAR routes through. + address newerPool = address(0xBEEF); + (uint256 fails, uint256 warns) = + new VerifyChain().reconcilePoolWithTarForTest(address(registry), token, newerPool); + assertEq(fails, 0, "divergence must never FAIL (legitimate out-of-band change)"); + assertEq(warns, 1, "divergence must emit exactly one WARN"); + } + + // WARN: the token has no pool registered in the TAR -> WARN, still 0 FAIL. + function test_Reconcile_Warn_WhenTokenHasNoTarEntry() public { + // Token registered (admin accepted) but setPool never called: wired pool is address(0). + _register(); + (uint256 fails, uint256 warns) = new VerifyChain().reconcilePoolWithTarForTest(address(registry), token, pool); + assertEq(fails, 0, "no-entry must never FAIL"); + assertEq(warns, 1, "no-entry must emit exactly one WARN"); + } +} diff --git a/test/fixtures/ccip-api/chain-16015286601757825753.json b/test/fixtures/ccip-api/chain-16015286601757825753.json new file mode 100644 index 0000000..02b34c7 --- /dev/null +++ b/test/fixtures/ccip-api/chain-16015286601757825753.json @@ -0,0 +1,176 @@ +{ + "chain": { + "name": "ethereum-testnet-sepolia", + "displayName": "Ethereum Sepolia", + "chainSelector": "16015286601757825753", + "chainId": "11155111", + "chainFamily": "EVM", + "environment": "testnet", + "isPrivate": false + }, + "chainConfig": { + "chainFamily": "EVM", + "router": [ + { + "address": "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59", + "type": "Router", + "version": "1.2.0", + "isActive": true + }, + { + "address": "0x3F1f176e347235858DD6Db905DDBA09Eaf25478a", + "type": "Router", + "version": "1.2.0", + "isActive": false + } + ], + "feeQuoter": [ + { + "address": "0x8632C3025FAFdD85A299211FD5838b5fBE2df816", + "type": "FeeQuoter", + "version": "2.0.0", + "isActive": true + }, + { + "address": "0xb6a1171fCfE2191430478026718872CF8817CE31", + "type": "FeeQuoter", + "version": "1.6.3", + "isActive": false + } + ], + "feeTokens": [ + { + "tokenAddress": "0xc4bF5CbDaBE595361438F8c6a187bDc330539c60", + "tokenSymbol": "GHO", + "tokenName": "Gho Token", + "decimals": 18 + }, + { + "tokenAddress": "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "tokenSymbol": "LINK", + "tokenName": "ChainLink Token", + "decimals": 18 + }, + { + "tokenAddress": "0x097D90c9d3E0B50Ca60e1ae45F6A81010f9FB534", + "tokenSymbol": "WETH", + "tokenName": "Wrapped Ether", + "decimals": 18 + } + ], + "tokenAdminRegistry": [ + { + "address": "0x95F29FEE11c5C55d26cCcf1DB6772DE953B37B82", + "type": "TokenAdminRegistry", + "version": "1.5.0", + "isActive": true + } + ], + "registryModule": [ + { + "address": "0xa3c796d480638d7476792230da1E2ADa86e031b0", + "type": "RegistryModuleOwnerCustom", + "version": "1.6.0", + "isActive": true + } + ], + "tokenPoolFactory": [ + { + "address": "0x2067C0444F9dc58cFB33B095279A28886562f169", + "type": "TokenPoolFactory", + "version": "1.5.1", + "isActive": true + } + ], + "rmn": [ + { + "address": "0xba3f6251de62dED61Ff98590cB2fDf6871FbB991", + "type": "ARMProxy", + "version": "1.0.0", + "isActive": true + } + ], + "permaBlessedCommitStores": [ + "0xdF4a1595cF4d14e16344B788485Ea5e9A5B0A48e", + "0x80e9E195B9efd49b0021023203Bcb376d4A3ca58", + "0x408875a649fc73D406e727108Eb03Ff0aD61Fa97", + "0xBAA632015672BBEF9791B2a26F8abF8B27201703", + "0x24BA37C2f1C540F73664E0361011DB1e1D3EF957", + "0xb147CDBfbFFC98AE12EaF20ceDd21aC5fBcfbFa8", + "0x9988db0F607839c8807e97b64bddae97972cdF8f", + "0x2f57D7d78C9dec55f819F7531eBa583De952F286", + "0xC00020CDbDfb34D0F47507E5e981A1A54489B5A5", + "0xaab7fe2aF41Edd71ac49AC82B19e621dbe6c3155", + "0x8AeE6dD6c0b42a5377234B16465ec13a9bBe2a21", + "0xDDE7726BD766ecCF75553fcCF49FE5A3e79DF3EB", + "0x59dbC90da3A50dbCE55410791a7705B3528175e3", + "0x23dD008B45144d10A88b122F29Cb25196B132B97", + "0x7bde45a9F3F2F09BdffB4825c3E6eEbbA7F9174a", + "0x447219F40121412421C4e73Ca080C707b9aadD50", + "0xaF3D6ECD45C2c4b94d433f33a77E066FfF3A46F8", + "0xAB7545732dE27072531afBbfD54F2cdDf1bc06d3", + "0xb742Fd2DFA79877bD2a35c3088ee2E8178D1fA56", + "0xB4aA4C76Baeea0E08AEF63f4c7B00F95E797F7c7", + "0xC1226b8Fc045Bc599feF3c9d4E47D5A819c73210", + "0xeFfFD86643EEfA77Fd6b01aA9B95F543ce3eBcBD", + "0xa5707f2e776567DFbCeCF9343550c24075F491B7", + "0x64d292F559a45D7B4ED884cd4209675cB0Aa8412", + "0xd2bC4c5916ACd6579BF64e9aD3241B9C2d25Bbf6", + "0x36802d1545E7D54e4908f9dB2ED52dD553073690", + "0x33dE60b3982034f4926b4cfD9785A0197e3A8d4C", + "0x78Cdaea82F3603169b9aeBEd30354189e3Af2745", + "0x4e7869F865dA6622fA5223b2C5f25A993b93ECe3", + "0x949Eb4c738c7770181aC31e82b94425e39f18B7e", + "0xb86a8dF01Dede20b52B0ae393B9D33aA4A99770F", + "0xaAFa992D13EE34566E1806211e121d2265Fe1174", + "0x15084E8772164f90A81cEc344F87fe67b077e5fF", + "0x2E4545304B5DF54d919d87A8F8119f7C940D6534", + "0x4562315d2858018451477b3F7629A18eEeCDdE06", + "0x0eCA6284692eF8F97b27df3Ee1D2E94FEB7a53e0", + "0x539602E52B3807C4c75FE270F2d3505daFF939B7", + "0xE03830fF0400A6ADc42d2e0B68b200aB7B61c051", + "0xf807282AdC9D7e33B923e3A6C9BDF9Ea18DD980d", + "0x2D3100F85Ad017205bA0c1dC62a6Fdb5c001017d", + "0xD2104488B181cc8CF09FF53a24498C5b60627971", + "0xfA331946c4471411259448A44f6d379Fd8DbBfB0", + "0x40F72cFDA9B1113d5785F5227f81042E6258a1c1", + "0x1e68d91360e4EaA9A0138ddc9435540Ca57c1B06", + "0x2DC5E5382508E2bdAA68C394D3d7a85e42D5EB29", + "0xAd09bc5d5F03177b910a6b1E9b5314D20ca26330", + "0x488F00c32d7d61aF23Ca4C5D62b050677bd2E908", + "0x1fE526317352AEa587A9C59876CB954DB19f5144", + "0x496fE96E440Fc683478a08Df92A1c5E23E412b1C", + "0xB5FbA97Dc61ec68771a92a15360d9C32c9d054E7", + "0x673354B772141F358827C5607D3e3418b847D765", + "0xd57866D97ca26dfaE34088a3EeE4657BAFaac5f9", + "0x240420BC6bCDc067e668c7492D69fe06B3CF80cE", + "0x80F2796ae868a2f21c965c449bc9dF23Fd6290b7", + "0x2ED74B6c01C9C7c3B5193E88fB81B0b8D09a48B0", + "0x32edD59840CD9e474A280cf1707439F2Bd5872d4", + "0x1e46bAC486Dd878cD57B62845530A52343e39693", + "0xF9aAD3AF371d2769573D7d9328b50f4B414CB411", + "0xBBb9baA314eB023E3F9291Aaf4107B6708341B50", + "0xE95c2135e3330E953BB49068d32Fcba368Acd456", + "0x39026BAeb34F274b79401F9eca0e3bE38290bbA9", + "0x6e4601DA99a046e4bde60d051568E3E1F35E3097", + "0x307dDE3c696E399b5837456FbCe03b1Ad76D46E3", + "0x3A27Fd059A4eF0e96B0643283A44a56A8d6CF34A", + "0x139E06b6dBB1a0C41A1686C091795879c943765A", + "0xEeB665281c7ab51d25423898f730Ab078c69dd42" + ] + }, + "chainMetadata": { + "explorer": { + "name": "Etherscan", + "url": "https://sepolia.etherscan.io", + "txPath": "https://sepolia.etherscan.io/tx", + "addressPath": "https://sepolia.etherscan.io/address", + "tokenPath": "https://sepolia.etherscan.io/token" + }, + "nativeCurrency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + } + } +} diff --git a/test/operations/DepositToLockBox.t.sol b/test/operations/DepositToLockBox.t.sol new file mode 100644 index 0000000..21b9a0d --- /dev/null +++ b/test/operations/DepositToLockBox.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {DepositToLockBox} from "../../script/operations/DepositToLockBox.s.sol"; + +/// @dev Harness that forces the lockbox to resolve NOWHERE, regardless of the process-wide env. This is +/// the deterministic "explicit input" the revert path needs: the previous fork test skipped whenever a +/// parallel suite had set `LOCK_BOX` (a process-wide `vm.setEnv`), so ~2 of 3 runs it asserted nothing. +/// Overriding the resolution seam removes ALL env dependence, so the revert is exercised on every run. +contract DepositToLockBoxUnresolvedHarness is DepositToLockBox { + function _resolveLockBox(uint256) internal pure override returns (address) { + return address(0); + } +} + +/// @notice `DepositToLockBox` moved from a REQUIRED `vm.envAddress("LOCK_BOX")` (which reverted with a +/// raw cheatcode error) to the standard resolution ladder (`LOCK_BOX` > `{CHAIN}_LOCK_BOX` > registry +/// `active.lockBox`). When the lockbox resolves NOWHERE it must still fail — but with the clear, +/// self-explaining message, never silently proceed with `address(0)`. +/// @dev No fork, no env, no skip: the harness pins the resolution to `address(0)`, so this asserts the +/// named revert deterministically on every run. `vm.chainId` is set to a CONFIGURED chain so the earlier +/// `getChainName(block.chainid)` in `run()` resolves before the lockbox `require` fires. +contract DepositToLockBoxTest is Test { + uint256 internal constant ETHEREUM_SEPOLIA_CHAIN_ID = 11155111; + + function test_DepositToLockBox_RevertsWithNamedMessageWhenUnresolved() public { + vm.chainId(ETHEREUM_SEPOLIA_CHAIN_ID); + DepositToLockBox script = new DepositToLockBoxUnresolvedHarness(); + vm.expectRevert(bytes("LockBox not deployed. Set LOCK_BOX or the {CHAIN}_LOCK_BOX environment variable.")); + script.run(); + } +}