diff --git a/Makefile b/Makefile index 981160e..bf44866 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ evm-version-flag = --evm-version $(call evm-version,$(1)) GROUP_DIR := $(if $(GROUP),$(GROUP)/,) .DEFAULT_GOAL := help -.PHONY: probe-chain adopt-token help tools discover add-chain add-lane remove-lane sync sync-preview sync-all sync-check doctor fmt-config clean-scratch snapshot-chain roles-check roles-check-all deploy-token deploy-pool deploy-lockbox deploy-lockrelease-pool deploy-new-chain preflight verify verify-args verify-execution +.PHONY: probe-chain adopt-token help tools discover discover-tokens add-chain add-lane remove-lane sync sync-preview sync-all sync-check doctor fmt-config clean-scratch snapshot-chain roles-check roles-check-all deploy-token deploy-pool deploy-lockbox deploy-lockrelease-pool deploy-new-chain preflight verify verify-args verify-execution # Deploy-time parameters are read by the forge scripts from the environment (vm.env*). Forward a value # passed on the make command line (make deploy-token TOKEN_NAME=...) to the forge subprocess; a value @@ -99,6 +99,9 @@ tools: ## Check the required tools are installed (forge, curl, jq) discover: tools ## List the CCIP API chain catalog vs local configs, both planes (FILTER= narrows; ENVIRONMENT= narrows the plane) @FILTER="$(FILTER)" ENVIRONMENT="$(ENVIRONMENT)" bash script/config/sync-discover.sh +discover-tokens: tools ## List the CCIP API token catalog for an operator (ADMIN=, SYMBOL=, CHAIN_SELECTOR=, ENVIRONMENT= all narrow; unreviewed tokens included) + @ADMIN="$(ADMIN)" SYMBOL="$(SYMBOL)" CHAIN_SELECTOR="$(CHAIN_SELECTOR)" ENVIRONMENT="$(ENVIRONMENT)" bash script/config/discover-tokens.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= - both from the make discover API NAME + SELECTOR columns)) $(if $(SELECTOR),,$(error SELECTOR is required - find it with: make discover FILTER=)) diff --git a/docs/index.md b/docs/index.md index 6e75301..1e3ef74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,7 @@ Multi-step flows with a machine-readable manifest each, under [`docs/workflows/` - [Pool-version behavior deltas](reference/pool-behavior-matrix.md) - rate-limit validation, pause, decimals metering, and fast-finality differences, each fixture-backed. - [Config and project-store schema](config-schema.md) - every field of `config/chains` and `project/`. - [Config architecture](config-architecture.md) - the layering, sync data-flow, and writers. +- [The CCIP REST API v2 spec](reference/ccip-api.md) - the source of truth for the `script/config/` API callers, and why an omitted filter is a silent wrong answer. - [Deployed addresses](deployed-addresses.md) - the project store, resolution ladder, and redeploy guard. - [Reading a write script's outcome line](reference/script-output.md) - what `SENDING` and the two `NOT SENT` lines mean, and what actually confirms the state. diff --git a/docs/primitives/catalog.json b/docs/primitives/catalog.json index 3ee1167..cf87f76 100644 --- a/docs/primitives/catalog.json +++ b/docs/primitives/catalog.json @@ -3,7 +3,7 @@ "note": "Generated from the script/ tree and the Makefile. Do not edit by hand; run `npm run docs:catalog`.", "counts": { "primitives": 61, - "makeTargets": 28 + "makeTargets": 29 }, "primitives": [ { @@ -1025,6 +1025,10 @@ "target": "discover", "help": "List the CCIP API chain catalog vs local configs, both planes (FILTER= narrows; ENVIRONMENT= narrows the plane)" }, + { + "target": "discover-tokens", + "help": "List the CCIP API token catalog for an operator (ADMIN=, SYMBOL=, CHAIN_SELECTOR=, ENVIRONMENT= all narrow; unreviewed tokens included)" + }, { "target": "add-chain", "help": "Generate config/chains/.json from the live API (CHAIN= and SELECTOR= required)" diff --git a/docs/reference/ccip-api.md b/docs/reference/ccip-api.md new file mode 100644 index 0000000..56d9652 --- /dev/null +++ b/docs/reference/ccip-api.md @@ -0,0 +1,50 @@ +--- +type: reference +--- + +# The CCIP REST API v2 spec + +Source of truth for every script under `script/config/` that talks to the API. Read it before adding +or changing a call; do not guess parameter names. + +- Swagger UI: +- Machine-readable spec: (OpenAPI document + inlined under the `swaggerDoc` key; `curl` it and slice that value out) + +Base URL is `https://api.ccip.chain.link/v2`, overridable with the non-secret `CCIP_API_BASE` +(see `.env.example`). Every call this repo makes is read-only and unauthenticated. + +## A defaulted filter is a silent wrong answer + +The hazard these scripts keep hitting: an omitted parameter is not neutral. The API applies its own +default, answers HTTP 200 with a well-formed body and a confident `totalCount`, and the operator sees +"nothing found" instead of an error. + +Two instances, both real: + +- `GET /tokens` has **`reviewedOnly`, defaulting to `true`** - only tokens whose projects Chainlink + Labs has reviewed. `?environment=testnet` returns 22 tokens; adding `&reviewedOnly=false` returns + ~25,900. Filtered by `admin=`, the default returns `totalCount 0` for an operator who has 169 + tokens. `script/config/discover-tokens.sh` therefore always sends `reviewedOnly=false`. +- `GET /chains` was queried as `?environment=testnet`, hiding every mainnet chain. Unset returns both + planes, which is what `script/config/sync-discover.sh` now does. + +So: check the spec's `default` for every parameter you omit, and prefer sending the value explicitly +over relying on the default. `GET /tokens` has one more defaulted boolean, `expand` (default `false`), +which decides whether each `remoteChains[]` entry carries `status` and `remoteTokenAddress` at all. + +Name enumeration is cheap but not sufficient: an unknown parameter gives +`400 {"error":"BAD_REQUEST","message":"Unknown query parameter 'zzz'"}`, which finds typos but never +finds a parameter you did not think to try. That is how `reviewedOnly` was missed. Read the spec. + +## Routes this repo calls + +| Route | Caller | Query parameters | +| --- | --- | --- | +| `GET /chains` | [`sync-discover.sh`](../../script/config/sync-discover.sh) | `environment` only (unset = both planes). No paging. | +| `GET /chains/{selector}` | [`ccip-config-source.sh`](../../script/config/ccip-config-source.sh), [`ccip-chain-meta.sh`](../../script/config/ccip-chain-meta.sh) | none accepted. | +| `GET /tokens` | [`discover-tokens.sh`](../../script/config/discover-tokens.sh) | `chainSelector`, `remoteChainSelector`, `groupId`, `symbol`, `address`, `admin`, `environment`, `reviewedOnly`, `expand`, `limit`, `cursor`. | + +`GET /tokens` pages by **keyset, not offset**: `page` and `offset` are rejected with a 400. Follow +`pagination.cursor` while `pagination.hasNextPage` is true. Filters may travel with the cursor only if +they match the ones encoded in it; a differing filter is a 400. diff --git a/script/config/ccip-chain-meta.sh b/script/config/ccip-chain-meta.sh index a1890e3..45f0505 100755 --- a/script/config/ccip-chain-meta.sh +++ b/script/config/ccip-chain-meta.sh @@ -19,6 +19,7 @@ # non-EVM selectorName rung) via vm.tryFfi — config-file generation stays Foundry-side # (vm.serialize* + vm.writeFile / vm.writeJson); this script only fetches + selects. # +# Spec (read it before changing a query): docs/reference/ccip-api.md # 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 diff --git a/script/config/ccip-config-source.sh b/script/config/ccip-config-source.sh index 79ba848..8075515 100755 --- a/script/config/ccip-config-source.sh +++ b/script/config/ccip-config-source.sh @@ -23,6 +23,7 @@ # 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) # +# Spec (read it before changing a query): docs/reference/ccip-api.md # The API base URL can be overridden with the non-secret CCIP_API_BASE env var (see .env.example). set -euo pipefail diff --git a/script/config/discover-tokens.sh b/script/config/discover-tokens.sh new file mode 100755 index 0000000..c143b70 --- /dev/null +++ b/script/config/discover-tokens.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# discover-tokens.sh - list the CCIP REST API v2 TOKEN catalog for an operator, with local state +# awareness. The `make discover` sibling: that one lists chains (`GET /chains`), this one lists +# tokens (`GET /tokens`). +# +# Columns: CHAIN SELECTOR | CHAIN | TOKEN | SYMBOL | GROUP | LANES | LOCAL CONFIG. LANES counts the +# remoteChains entries with status CONNECTED (the array also carries DISCONNECTED ones). LOCAL CONFIG +# joins config/chains/*.json **BY SELECTOR**, the numeric identity key, exactly as sync-discover.sh +# does. +# +# Filters, all optional and all env-driven: ADMIN (TokenAdminRegistry administrator), SYMBOL, +# CHAIN_SELECTOR (home chain), ENVIRONMENT (testnet|mainnet; unset lists both planes). There is no +# flag interface: the script takes no arguments at all. +# +# TWO PARAMETERS THIS SENDS EXPLICITLY, because their defaults are wrong for an operator: +# reviewedOnly=false - defaults to TRUE, i.e. only Chainlink-Labs-reviewed projects. Measured: +# ?environment=testnet returns 22 tokens, &reviewedOnly=false returns ~25,900, +# and with an admin= filter the default returns a confident totalCount 0 for +# an operator who has 169. HTTP 200, well-formed, no error - a silent wrong +# answer. See docs/reference/ccip-api.md. +# expand=true - defaults to FALSE, which strips `status` from every remoteChains entry, so +# the CONNECTED count cannot be computed at all. +# +# Paging is KEYSET, not offset (`page`/`offset` are rejected with a 400): follow pagination.cursor +# while pagination.hasNextPage. Stopping after page 1 silently truncates the operator's list. +# +# AN EMPTY RESULT IS A NORMAL ANSWER, NOT AN ERROR - exit 0. The API lists a token only once its mesh +# is fully configured, so a mesh still being wired is legitimately absent. It is never evidence that +# anything is wrong. +# +# Exit codes: 0 OK (including zero matches) | 2 MISSING_TOOL | 3 BAD_ARG | 5 API_UNREACHABLE | +# 6 BAD_BODY. Matches sync-discover.sh, its direct sibling, which in turn shares the +# ccip-config-source.sh contract. 1 is deliberately unused: everywhere in this repo it +# means a verdict against what the operator owns, and a read-only listing has no verdict +# to issue - a transport or parse failure must never read as "you have no tokens". +# `make discover-tokens` is pass/fail only, since GNU make remaps any failing recipe to +# its own exit 2; call this script directly for the codes (same as probe-chain.sh). +set -euo pipefail + +err() { echo "[discover-tokens] $*" >&2; } + +# A flag-style `--admin 0x...` would otherwise be silently dropped and list the WHOLE catalog +# unfiltered - the same shape of confident wrong answer as the reviewedOnly default. +if [ "$#" -gt 0 ]; then + err "BAD_ARG: unexpected argument '$1' - discover-tokens takes no arguments" + err " filters are environment variables: ADMIN= SYMBOL= CHAIN_SELECTOR= ENVIRONMENT= (make discover-tokens ADMIN=0x...)" + exit 3 +fi + +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}" +ADMIN="${ADMIN:-}" +SYMBOL="${SYMBOL:-}" +CHAIN_SELECTOR="${CHAIN_SELECTOR:-}" +ENVIRONMENT="${ENVIRONMENT:-}" + +uri() { jq -rn --arg v "$1" '$v | @uri'; } + +# Only the two values the API honours; it answers an unrecognised one with 200 and an EMPTY list, +# which would print a confident "no tokens". Refuse by name before any request (same as discover). +case "$ENVIRONMENT" in + "" | testnet | mainnet) ;; + *) + err "BAD_ARG: ENVIRONMENT='$ENVIRONMENT' - use testnet or mainnet, or leave it unset for both" + exit 3 + ;; +esac + +# The spec constrains chainSelector to ^[0-9]+$; a non-numeric one is a 400 the operator would have +# to decode from the API's wording. +case "$CHAIN_SELECTOR" in + "") ;; + *[!0-9]*) + err "BAD_ARG: CHAIN_SELECTOR='$CHAIN_SELECTOR' - must be the numeric chain selector (make discover lists them)" + exit 3 + ;; +esac + +query="reviewedOnly=false&expand=true&limit=1000" +[ -n "$ENVIRONMENT" ] && query="${query}&environment=${ENVIRONMENT}" +[ -n "$ADMIN" ] && query="${query}&admin=$(uri "$ADMIN")" +[ -n "$SYMBOL" ] && query="${query}&symbol=$(uri "$SYMBOL")" +[ -n "$CHAIN_SELECTOR" ] && query="${query}&chainSelector=${CHAIN_SELECTOR}" + +body_file="$(mktemp)" +rows_file="$(mktemp)" +map_file="$(mktemp)" +cfg_file="$(mktemp)" +trap 'rm -f "$body_file" "$rows_file" "$map_file" "$cfg_file"' EXIT + +fetch() { # fetch - body into $body_file, exits 5 on anything but 200 + local code + code="$(curl -sS --retry 3 --max-time 30 -o "$body_file" -w '%{http_code}' "$1" 2> /dev/null)" || { + err "API_UNREACHABLE: could not reach $1 (network error/timeout after retries) - retry later or fix CCIP_API_BASE" + exit 5 + } + [ "$code" = "200" ] || { + err "API_UNREACHABLE: HTTP ${code} from $1 - retry later" + exit 5 + } +} + +# selector -> selectorName, both planes in one GET (/chains takes only `environment` and does not page). +fetch "${BASE_URL}/chains" +jq -r '(if type == "array" then . else .chains end)[] + | [(.chainSelector | tostring), .name] | @tsv' "$body_file" > "$map_file" || { + err "BAD_BODY: ${BASE_URL}/chains answered 200 with a body this script cannot read" + exit 6 +} + +# selector -> local config name (skip the gitignored zz-scratch-* test files) +for f in config/chains/*.json; do + [ -e "$f" ] || continue + case "$(basename "$f")" in zz-scratch-*) continue ;; esac + 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] }' \ + > "$cfg_file" + +cursor="" +pages=0 +total="" +# A cursor that never clears would spin forever against a broken or mocked API; 1000/page makes 500 +# pages a ceiling no real operator reaches. +while [ "$pages" -lt 500 ]; do + url="${BASE_URL}/tokens?${query}" + [ -n "$cursor" ] && url="${url}&cursor=$(uri "$cursor")" + fetch "$url" + pages=$((pages + 1)) + + jq -r '.data[] + | [(.chainSelector | tostring), .address, (.symbol // "-"), (.groupId // "-"), + ([(.remoteChains // [])[] | select(.status == "CONNECTED")] | length | tostring)] + | @tsv' "$body_file" >> "$rows_file" || { + err "BAD_BODY: ${BASE_URL}/tokens answered 200 with a body this script cannot read" + exit 6 + } + + [ -n "$total" ] || total="$(jq -r '.pagination.totalCount // "?"' "$body_file")" + has_next="$(jq -r '.pagination.hasNextPage // false' "$body_file")" + cursor="$(jq -r '.pagination.cursor // ""' "$body_file")" + [ "$has_next" = "true" ] && [ -n "$cursor" ] || { cursor=""; break; } +done +# Truncation must never be silent - that is the same failure as the reviewedOnly default. +[ -z "$cursor" ] || err "WARNING: stopped at the ${pages}-page ceiling; the list below is INCOMPLETE - narrow it with ADMIN=/SYMBOL=/CHAIN_SELECTOR=" + +rows="$(wc -l < "$rows_file" | tr -d ' ')" +scope="$([ -n "$ENVIRONMENT" ] && echo "$ENVIRONMENT" || echo "both planes")" +filters="$(printf '%s' "${ADMIN:+ADMIN=$ADMIN }${SYMBOL:+SYMBOL=$SYMBOL }${CHAIN_SELECTOR:+CHAIN_SELECTOR=$CHAIN_SELECTOR }")" + +if [ "$rows" -eq 0 ]; then + echo "No tokens match (${scope}${filters:+, ${filters% }})." + echo "" + echo "This is a normal answer, not a failure: the API lists a token only once its mesh is fully" + echo "configured, so a token still being wired through this repo is legitimately absent. Nothing" + echo "is wrong with your setup." + echo "" + echo " widen it: drop ADMIN/SYMBOL/CHAIN_SELECTOR, or leave ENVIRONMENT unset to list both planes" + echo " manual: make adopt-token CHAIN= TOKEN= POOL= (record what you already have)" + echo " make deploy-new-chain CHAIN= SELECTOR= (deploy a new one)" + exit 0 +fi + +{ + printf 'CHAIN SELECTOR\tCHAIN\tTOKEN\tSYMBOL\tGROUP\tLANES\tLOCAL CONFIG\n' + awk -F'\t' -v OFS='\t' -v names="$map_file" -v cfgs="$cfg_file" ' + FILENAME == names { nm[$1] = $2; next } + FILENAME == cfgs { cfg[$1] = $2; next } + { print $1, ($1 in nm ? nm[$1] : "?"), $2, $3, $4, $5, + ($1 in cfg ? "configured(" cfg[$1] ")" : "no local config") } + ' "$map_file" "$cfg_file" "$rows_file" | sort +} | column -t -s "$(printf '\t')" + +echo "" +echo "${rows} token(s) listed (API totalCount ${total}, ${pages} page(s), ${scope}${filters:+, ${filters% }})." +if awk -F'\t' -v cfgs="$cfg_file" 'FILENAME == cfgs { cfg[$1]; next } !($1 in cfg) { n++ } END { exit !(n > 0) }' "$cfg_file" "$rows_file"; then + echo "Rows marked 'no local config' need their chain onboarded first:" \ + "make add-chain CHAIN= SELECTOR=" +fi +echo "raw: curl -s '${BASE_URL}/tokens?${query}' | jq" diff --git a/script/config/sync-discover.sh b/script/config/sync-discover.sh index 0c414ec..8e260e0 100755 --- a/script/config/sync-discover.sh +++ b/script/config/sync-discover.sh @@ -12,6 +12,7 @@ # until a mainnet token could not be found through it - `make add-chain` was never restricted (it # fetches /chains/ directly), so the chain was addable all along, just invisible here. # +# Spec (read it before changing a query): docs/reference/ccip-api.md # Exit codes: 0 OK | 2 MISSING_TOOL | 3 BAD_ARG | 5 API_UNREACHABLE set -euo pipefail diff --git a/script/config/test-tooling.sh b/script/config/test-tooling.sh index 97bba60..9033b83 100755 --- a/script/config/test-tooling.sh +++ b/script/config/test-tooling.sh @@ -96,6 +96,10 @@ PROBE_SPOOF_CHAIN="zz-scratch-probe wrong endpoint for this chain" PROBE_SPOOF_FILE="config/chains/${PROBE_SPOOF_CHAIN}.json" PROBE_PORT=8601 probe_anvil_pid="" +# discover-tokens fixture config: joined BY SELECTOR by the token listing, so it cannot be a +# zz-scratch- name (that class is skipped as fake-selector test residue). +DT_CHAIN="tooling-dt" +DT_FILE="config/chains/${DT_CHAIN}.json" MANUAL_CHAIN="zz-scratch-manual-plane" MANUAL_FILE="config/chains/${MANUAL_CHAIN}.json" MANUAL_PROJECT="project/${MANUAL_CHAIN}.json" @@ -174,6 +178,7 @@ cleanup() { fi rm_fixture_config "$MANUAL_FILE" "$XPLANE_API_FILE" rm -f "$MANUAL_PROJECT" "project/$XPLANE_API_CHAIN.json" + rm_fixture_config "$DT_FILE" rm_fixture_config "$TYPO_FILE" rm -f "project/$TYPO_CHAIN.json" rm -rf "$CLEANX_GRPDIR" "$CLEANX_HISTDIR" @@ -1093,6 +1098,198 @@ STUB rm -rf "$dsc_bin" "$dsc_url" fi +# ---------------------------------------------------------------- discover-tokens + +# GET /tokens defaults `reviewedOnly` to TRUE (reviewed projects only) and `expand` to FALSE (no +# per-lane `status`). Both defaults are wrong for an operator: measured, an admin= filter under the +# default returns a confident, well-formed, HTTP-200 totalCount 0 for an address that has 169 tokens. +# The stub below answers DIFFERENTLY with and without each parameter, so dropping either turns these +# cases red instead of leaving them vacuously green. A static http.server cannot do this - it ignores +# the query string entirely (same reason the discover section stubs curl). +if offline_enabled; then + dt_bin="$(mktemp -d)" + # Its OWN throwaway config (in cleanup()'s rm-list), and deliberately NOT a zz-scratch- name: + # the discover tooling skips zz-scratch-* configs (fake selectors), so a scratch-named fixture + # could never join. Reusing $TMP_FILE would couple this section to the sections around it. + dt_chain="$DT_CHAIN" + dt_file="$DT_FILE" + dt_selector="99999999999999999901" + # Second token lives on a chain with NO local config, pinning the other half of the join. + dt_selector_b="99999999999999999902" + + cat > "$dt_bin/curl" << STUB +#!/usr/bin/env bash +# Query-string-aware CCIP API stub: /chains always answers; /tokens answers only what the query earns. +out=""; url="" +for a in "\$@"; do + case "\$prev" in -o) out="\$a" ;; esac + case "\$a" in http*) url="\$a" ;; esac + prev="\$a" +done +body='' +case "\$url" in + *"/chains"*) + body='{"chains":[{"name":"zz-dt-home","chainSelector":$dt_selector,"chainFamily":"EVM","environment":"testnet","chainId":1},{"name":"zz-dt-remote","chainSelector":$dt_selector_b,"chainFamily":"EVM","environment":"testnet","chainId":2}]}' + ;; + *"/tokens"*) + # reviewedOnly defaults to true -> the reviewed subset, which is EMPTY for this operator. + case "\$url" in + *"reviewedOnly=false"*) ;; + *) + body='{"data":[],"pagination":{"limit":1000,"hasNextPage":false,"totalCount":0,"isCountCapped":false,"cursor":null}}' + printf '%s' "\$body" > "\$out"; printf '200'; exit 0 ;; + esac + # A 200 is the ONLY success: a 5xx and an unreadable body must each name themselves rather + # than degrade into "no tokens". + case "\$url" in + *"symbol=zz-dt-500"*) printf '' > "\$out"; printf '500'; exit 0 ;; + *"symbol=zz-dt-garbage"*) printf 'not json at all' > "\$out"; printf '200'; exit 0 ;; + esac + # An operator whose mesh is not fully wired yet is simply absent from the catalog. + case "\$url" in + *"symbol=zz-dt-none"*) + body='{"data":[],"pagination":{"limit":1000,"hasNextPage":false,"totalCount":0,"isCountCapped":false,"cursor":null}}' + printf '%s' "\$body" > "\$out"; printf '200'; exit 0 ;; + esac + # expand defaults to false -> remoteChains entries carry NO status, so no lane can be counted. + case "\$url" in + *"expand=true"*) rc1='{"remoteChainSelector":"$dt_selector_b","status":"CONNECTED"},{"remoteChainSelector":"1","status":"CONNECTED"},{"remoteChainSelector":"2","status":"DISCONNECTED"}' ;; + *) rc1='{"remoteChainSelector":"$dt_selector_b"},{"remoteChainSelector":"1"},{"remoteChainSelector":"2"}' ;; + esac + # Keyset paging: page 2 is reachable only by following pagination.cursor. + case "\$url" in + *"cursor=DTPAGE2"*) + body='{"data":[{"chainSelector":"$dt_selector_b","address":"0xDT0000000000000000000000000000000000000B","symbol":"DTPAGE2TOKEN","groupId":null,"remoteChains":[]}],"pagination":{"limit":1,"hasNextPage":false,"totalCount":2,"isCountCapped":false,"cursor":null}}' ;; + *) + body='{"data":[{"chainSelector":"$dt_selector","address":"0xDT0000000000000000000000000000000000000A","symbol":"DTPAGE1TOKEN","groupId":"dt-group-1","remoteChains":['"\$rc1"']}],"pagination":{"limit":1,"hasNextPage":true,"totalCount":2,"isCountCapped":false,"cursor":"DTPAGE2"}}' ;; + esac + ;; +esac +printf '%s' "\$body" > "\$out" +printf '200' +STUB + chmod +x "$dt_bin/curl" + + # A local config for the home chain only: the join is BY SELECTOR, as in sync-discover. + jq --indent 2 -S --arg n "$dt_chain" --argjson s "$dt_selector" \ + '.name = $n | .chainSelector = $s' config/chains/ethereum-testnet-sepolia.json > "$dt_file" + + dt_run() { env -u ADMIN -u SYMBOL -u CHAIN_SELECTOR -u ENVIRONMENT PATH="$dt_bin:$PATH" \ + bash script/config/discover-tokens.sh 2>&1; } + dt_out="$(dt_run)" + dt_rc=$? + + dt_assert() { # name + if grep -q -- "$2" <<< "$dt_out"; then + pass=$((pass + 1)) + echo "[PASS] $1" + else + fail=$((fail + 1)) + failures+=("$1") + echo "[FAIL] $1 (exit=$dt_rc, no /$2/)" + echo "$dt_out" | tail -6 | sed 's/^/ | /' + fi + } + + # THE phase-0 regression guard: without reviewedOnly=false the stub answers an empty, well-formed + # 200 and this listing becomes "No tokens match". + dt_assert "discover-tokens sends reviewedOnly=false (the default hides an operator's own tokens)" \ + "DTPAGE1TOKEN" + # Without expand=true no remoteChains entry carries a status, so the count collapses to 0. + dt_assert "discover-tokens sends expand=true so CONNECTED lanes can be counted" \ + "DTPAGE1TOKEN.*dt-group-1 *2 " + # Page 2 is reachable only by following pagination.cursor. + dt_assert "discover-tokens follows the pagination cursor past page 1" "DTPAGE2TOKEN" + dt_assert "discover-tokens joins the local chain config by selector" "configured($dt_chain)" + dt_assert "discover-tokens marks a token whose chain has no local config" "no local config" + dt_assert "discover-tokens reports the API totalCount and page count" "2 page(s)" + if [ $dt_rc -eq 0 ]; then + pass=$((pass + 1)) + echo "[PASS] discover-tokens exits 0 on a successful listing" + else + fail=$((fail + 1)) + failures+=("discover-tokens exits 0 on a successful listing") + echo "[FAIL] discover-tokens exits 0 on a successful listing (exit=$dt_rc)" + fi + + # An empty result is a NORMAL answer (the API lists a token only once its mesh is fully wired), + # so it must exit 0 and never imply the operator's setup is broken. + dt_out="$(env -u ADMIN -u CHAIN_SELECTOR -u ENVIRONMENT PATH="$dt_bin:$PATH" \ + SYMBOL=zz-dt-none bash script/config/discover-tokens.sh 2>&1)" + dt_rc=$? + dt_assert "discover-tokens calls an empty result a normal answer" "normal answer, not a failure" + dt_assert "discover-tokens names the manual path when nothing matches" "make adopt-token" + if [ $dt_rc -eq 0 ]; then + pass=$((pass + 1)) + echo "[PASS] discover-tokens exits 0 on an empty result" + else + fail=$((fail + 1)) + failures+=("discover-tokens exits 0 on an empty result") + echo "[FAIL] discover-tokens exits 0 on an empty result (exit=$dt_rc)" + fi + dt_code_case() { # name expected-exit pattern symbol + local out status + out="$(env -u ADMIN -u CHAIN_SELECTOR -u ENVIRONMENT PATH="$dt_bin:$PATH" \ + SYMBOL="$4" bash script/config/discover-tokens.sh 2>&1)" + status=$? + if [ "$status" = "$2" ] && grep -q -- "$3" <<< "$out"; then + pass=$((pass + 1)) + echo "[PASS] $1" + else + fail=$((fail + 1)) + failures+=("$1") + echo "[FAIL] $1 (exit=$status want $2, /$3/)" + fi + } + dt_code_case "discover-tokens calls a 5xx API_UNREACHABLE (exit 5), not an empty result" \ + 5 "API_UNREACHABLE: HTTP 500" zz-dt-500 + dt_code_case "discover-tokens calls an unreadable body BAD_BODY (exit 6), not an empty result" \ + 6 "BAD_BODY" zz-dt-garbage + + rm_fixture_config "$dt_file" + rm -rf "$dt_bin" +fi + +# Both BAD_ARG cases point CCIP_API_BASE at a dead port: the argument must be refused BEFORE any +# request, so a regressed guard reports API_UNREACHABLE instead and fails the pattern. Also what keeps +# the offline partition off the network. +run_case "discover-tokens refuses an unrecognised ENVIRONMENT before any request" nonzero "BAD_ARG: ENVIRONMENT" -- \ + env CCIP_API_BASE=http://127.0.0.1:1 ENVIRONMENT=all bash script/config/discover-tokens.sh +run_case "discover-tokens refuses a non-numeric CHAIN_SELECTOR before any request" nonzero "BAD_ARG: CHAIN_SELECTOR" -- \ + env -u ENVIRONMENT CCIP_API_BASE=http://127.0.0.1:1 CHAIN_SELECTOR=not-a-selector bash script/config/discover-tokens.sh +run_case "discover-tokens names API_UNREACHABLE rather than reporting no tokens" nonzero "API_UNREACHABLE" -- \ + env -u ENVIRONMENT CCIP_API_BASE=http://127.0.0.1:1 bash script/config/discover-tokens.sh + +# The interface is env-driven, so `--admin 0x...` used to be dropped and list the ENTIRE catalog +# unfiltered at exit 0. Pins the exact BAD_ARG code, not just non-zero, plus the message that names +# the env vars - the dead port again proves it is refused before any request. +if offline_enabled; then + dtarg_name="discover-tokens refuses an unrecognised argument with BAD_ARG (exit 3)" + dtarg_out="$(env -u ENVIRONMENT CCIP_API_BASE=http://127.0.0.1:1 \ + bash script/config/discover-tokens.sh --admin 0xdeadbeef 2>&1)" + dtarg_rc=$? + if [ $dtarg_rc -eq 3 ] && grep -q "BAD_ARG: unexpected argument '--admin'" <<< "$dtarg_out" \ + && grep -q "ADMIN= SYMBOL= CHAIN_SELECTOR= ENVIRONMENT=" <<< "$dtarg_out"; then + pass=$((pass + 1)) + echo "[PASS] $dtarg_name" + else + fail=$((fail + 1)) + failures+=("$dtarg_name") + echo "[FAIL] $dtarg_name (exit=$dtarg_rc want 3)" + echo "$dtarg_out" | tail -6 | sed 's/^/ | /' + fi +fi + +# Live: the phase-0 bug end to end. This repo's own testnet deployer administers tokens that the +# API's default reviewedOnly=true hides completely (measured: 169 vs a confident totalCount 0). +run_case_live "discover-tokens finds the repo deployer's unreviewed testnet tokens" zero "token(s) listed" -- \ + env -u SYMBOL -u CHAIN_SELECTOR ENVIRONMENT=testnet \ + ADMIN=0x9d087fC03ae39b088326b67fA3C788236645b717 bash script/config/discover-tokens.sh +# An operator with nothing is a normal answer, not a failure. +run_case_live "discover-tokens exits 0 when an admin has no tokens" zero "normal answer, not a failure" -- \ + env -u SYMBOL -u CHAIN_SELECTOR -u ENVIRONMENT \ + ADMIN=0x000000000000000000000000000000000000dEaD bash script/config/discover-tokens.sh + # ---------------------------------------------------------------- probe-chain (non-forking reader) # probe-chain exists because every other read primitive reaches the chain through