From 133f056aacb51f54dda75134a6da9be6d3fc658f Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Tue, 7 Jul 2026 22:18:34 +0100 Subject: [PATCH] refactor: git-tracked chain config with API sync, drift check, chain doctor, and a single-writer address registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chain metadata lives in git-tracked config/chains/.json, read by HelperConfig via ChainConfig.sol; the ccip{} address block is synced from the CCIP REST API v2 with a field-by-field drift check and a layered chain doctor. The config file basename and lowercase `name` are the canonical CCIP selectorName from the chain-selectors registry (the same key the REST API, CLD, Atlas, the directory URL leaf, and ccip-cli all share), e.g. ethereum-testnet-sepolia rather than the bespoke ethereum-sepolia. The sync still joins on the numeric chainSelector (the immutable machine key), and now also validates that the config name equals the API selectorName. Unlike the chainId guard this works for non-EVM chains too, whose config carries a placeholder chainId "0" the chainId check cannot verify — closing that gap at add-chain time and in the doctor. Unify the deployed-address write path and re-key the registry (schema v2). Every deploy script previously wrote the same address twice, through two adjacent, independently-maintained calls: DeploymentUtils.save* (the detailed timestamped ledger) and RegistryWriter.record (the resolution registry). Two stores, two formats, nothing keeping them in sync. Collapse them into a single writer, script/utils/DeploymentRecorder.s.sol: one call per artifact emits the ledger file (format byte-for-byte unchanged) AND upserts the registry, so the two can no longer drift. Reshape addresses/.json to schema v2: an `active` map of per-role pointers (token/tokenPool/lockBox/poolHooks) that HelperConfig resolves with zero export, plus a `deployments` map of uniquely-named, versioned entries (e.g. BnM-T_BurnMintTokenPool_2.0.0). The redeploy guard keys on the unique deployments name, so a BurnMint and a LockRelease pool for one token — or an old and a new pool version — coexist without collision. This is required by the pool-migration flow, where the old and new pool must both be resolvable across the setPool cutover. RegistryWriter.read resolves active. with a legacy fallback to the pre-v2 flat top-level key, and never reverts on a concurrently-written (empty/partial) file. After each pool deploy, the script asserts the on-chain typeAndVersion matches the version composed into the key. Wire the previously dead registry read path: HelperConfig now resolves lockBox and poolHooks through the same inline > {CHAIN}_ env > registry ladder as token/tokenPool (getDeployedLockBox / getDeployedPoolHooks), so all four artifacts resolve with no manual export. The allowlist, authorized-callers, pool-deploy, and lockbox-operation scripts fall back to the getters; the lockbox operations still revert with a clear message when nothing resolves. Add a doctor rung reconciling the registry's active.tokenPool against the pool wired in the on-chain TokenAdminRegistry: active is what this repo last deployed, the TAR is what CCIP routes through, and they legitimately diverge mid-migration — so divergence is reported as a WARN, never a FAIL. The addresses/ registry is gitignored and local to the deploy machine; its integrity comes from the single-writer recorder, not from git history (unlike the git-tracked config/chains/*.json). Docs and the schema example are updated to schema v2, the per-artifact keying table, the one-writer model, and the active-vs-wired distinction. --- .env.example | 9 + .github/workflows/config-drift.yml | 42 ++ .gitignore | 15 +- Makefile | 87 +++ README.md | 133 +++- addresses/.gitkeep | 0 addresses/11155111.example.json | 14 + config/chains/0g-testnet-galileo-1.json | 27 + .../ethereum-testnet-sepolia-mantle-1.json | 27 + config/chains/ethereum-testnet-sepolia.json | 28 + config/chains/ink-testnet-sepolia.json | 27 + config/chains/plume-testnet-sepolia.json | 27 + config/chains/solana-devnet.json | 24 + docs/config-architecture.md | 191 ++++++ docs/config-schema.md | 236 +++++++ docs/deployed-addresses.md | 139 +++++ foundry.toml | 25 + script/HelperConfig.s.sol | 304 +++++---- script/config/SyncCcipConfig.s.sol | 577 ++++++++++++++++++ script/config/VerifyChain.s.sol | 496 +++++++++++++++ script/config/ccip-chain-meta.sh | 68 +++ script/config/ccip-config-source.sh | 89 +++ script/config/sync-check.sh | 56 ++ script/config/sync-discover.sh | 64 ++ script/config/test-tooling.sh | 462 ++++++++++++++ .../allowlist/DeployAdvancedPoolHooks.s.sol | 57 +- script/configure/allowlist/GetAllowList.s.sol | 3 +- .../configure/allowlist/IsAllowListed.s.sol | 7 +- .../configure/allowlist/UpdateAllowList.s.sol | 4 +- .../GetAuthorizedCallers.s.sol | 17 +- .../UpdateAuthorizedCallers.s.sol | 17 +- script/deploy/DeployBurnMintTokenPool.s.sol | 34 +- script/deploy/DeployERC20LockBox.s.sol | 11 +- .../deploy/DeployLockReleaseTokenPool.s.sol | 41 +- script/deploy/DeployToken.s.sol | 14 +- script/operations/DepositToLockBox.s.sol | 15 +- script/operations/WithdrawFromLockBox.s.sol | 8 +- script/utils/DeploymentRecorder.s.sol | 127 ++++ script/utils/DeploymentUtils.s.sol | 10 +- src/config/CcipApiSource.sol | 39 ++ src/config/ChainConfig.sol | 128 ++++ src/config/IConfigSource.sol | 22 + src/utils/RegistryWriter.sol | 453 ++++++++++++++ test/actions/LockboxOps.t.sol | 8 +- test/config/DynamicChainDiscovery.t.sol | 124 ++++ test/config/HelperConfigGoldenData.t.sol | 202 ++++++ test/config/RegistryGuard.t.sol | 280 +++++++++ test/config/RegistryRepointGuard.t.sol | 91 +++ test/config/RegistryResolution.t.sol | 181 ++++++ test/config/SyncFixtureTransform.t.sol | 122 ++++ test/config/SyncToolingRules.t.sol | 66 ++ test/config/VerifyChainTarReconcile.t.sol | 70 +++ .../ccip-api/chain-16015286601757825753.json | 176 ++++++ test/operations/DepositToLockBox.t.sol | 33 + 54 files changed, 5338 insertions(+), 189 deletions(-) create mode 100644 .github/workflows/config-drift.yml create mode 100644 Makefile create mode 100644 addresses/.gitkeep create mode 100644 addresses/11155111.example.json create mode 100644 config/chains/0g-testnet-galileo-1.json create mode 100644 config/chains/ethereum-testnet-sepolia-mantle-1.json create mode 100644 config/chains/ethereum-testnet-sepolia.json create mode 100644 config/chains/ink-testnet-sepolia.json create mode 100644 config/chains/plume-testnet-sepolia.json create mode 100644 config/chains/solana-devnet.json create mode 100644 docs/config-architecture.md create mode 100644 docs/config-schema.md create mode 100644 docs/deployed-addresses.md create mode 100644 script/config/SyncCcipConfig.s.sol create mode 100644 script/config/VerifyChain.s.sol create mode 100755 script/config/ccip-chain-meta.sh create mode 100755 script/config/ccip-config-source.sh create mode 100755 script/config/sync-check.sh create mode 100755 script/config/sync-discover.sh create mode 100755 script/config/test-tooling.sh create mode 100644 script/utils/DeploymentRecorder.s.sol create mode 100644 src/config/CcipApiSource.sol create mode 100644 src/config/ChainConfig.sol create mode 100644 src/config/IConfigSource.sol create mode 100644 src/utils/RegistryWriter.sol create mode 100644 test/config/DynamicChainDiscovery.t.sol create mode 100644 test/config/HelperConfigGoldenData.t.sol create mode 100644 test/config/RegistryGuard.t.sol create mode 100644 test/config/RegistryRepointGuard.t.sol create mode 100644 test/config/RegistryResolution.t.sol create mode 100644 test/config/SyncFixtureTransform.t.sol create mode 100644 test/config/SyncToolingRules.t.sol create mode 100644 test/config/VerifyChainTarReconcile.t.sol create mode 100644 test/fixtures/ccip-api/chain-16015286601757825753.json create mode 100644 test/operations/DepositToLockBox.t.sol 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(); + } +}