diff --git a/.claude/skills/add-token/SKILL.md b/.claude/skills/add-token/SKILL.md new file mode 100644 index 0000000..00facb4 --- /dev/null +++ b/.claude/skills/add-token/SKILL.md @@ -0,0 +1,146 @@ +--- +name: add-token +description: Add a new token to the Monad token list end-to-end - scaffold mainnet//data.json from the contract address, detect and verify the native bridge protocol on-chain, find a corroborated CoinGecko ID, fill cross-chain addresses, fetch a logo, and run validation. Use this skill whenever the user asks to add, list, or register a token (by address or symbol) in this repository, including requests like "add USDXY", "list token 0x...", or "create the data.json for this token". +--- + +# Add a token to the Monad token list + +You are adding one token directory (`mainnet//data.json` + logo) to this repo. CI will re-verify everything you write against the chain and CoinGecko, so the job is not just to fill fields — it is to fill them with **provable** values. + +## Trust policy (read this first) + +A spam token can copy a legitimate project's name, symbol, and logo perfectly. The only evidence that distinguishes the real token is **address-level**: on-chain back-references between contracts, registries controlled by the bridge operators, CoinGecko's own contract-address mapping, and the project's official documentation. + +Therefore: +- Never source an address, CoinGecko ID, or cross-chain mapping from general web search results, aggregators, or third-party token lists. +- Every `extensions` field you write must carry evidence from the bundled scripts or official docs (and docs-sourced addresses must be re-verified on-chain). +- When you cannot prove something, **omit it and say so in your report**. Optional fields left empty are correct (`mainnet/hyAUSD` has no extensions at all); a plausible guess is not. +- But "cannot prove" has to mean you actually looked. The goal is to fill every field the evidence can support, not to stop at the first empty result. A bridged token in particular exists on other chains by definition, so an empty `crossChainAddresses` usually means the search was incomplete rather than that no counterparts exist — see step 4. + +## Workflow + +Work from the repository root. When `.env` exists (it normally does), prefix EVERY python command with `uv run --env-file=.env` — including `add_token.py` and `validate_tokens.py`, not just the skill's own scripts. The file carries `COINGECKO_API_KEY`, which removes CoinGecko's ~12s/request keyless throttle: with it a full `validate_tokens.py` run takes a couple of minutes, without it the ~100 CoinGecko lookups alone take 20+ minutes or die on HTTP 429. It also carries `MONAD_RPC_URL` and optional per-chain RPC. The two bundled skill scripts additionally self-load the repo `.env` as a safety net, but the repo's own scripts do NOT — for them the `--env-file=.env` prefix is the only way to get the key. + +### 1. Scaffold + +```bash +uv run --env-file=.env python scripts/add_token.py
+``` + +Creates `mainnet//data.json` with on-chain `name`/`symbol`/`decimals` (chainId 143). If it fails with `FileExistsError`, the token already exists — stop and tell the user. + +### 2. Detect the native bridge + +```bash +uv run --env-file=.env python .claude/skills/add-token/scripts/probe_bridges.py
+``` + +This probes every protocol the validator accepts (CCTP, CCIP, M0 Portal, Wormhole, LayerZero OFT, Wormhole NTT, Hyperlane) using on-chain registries, the Hyperlane warp-route registry, and a mint/burn log-correlation scan. Read the JSON: + +- `detected` (tier A): exactly one mint-class protocol verified — copy its `protocol` and `bridgeAddress` into `extensions.bridgeInfo` as-is. +- `ambiguous` non-empty: multiple protocols verified. Do NOT pick one; present both to the user with their evidence. +- `needsHumanConfirmation` non-empty: a candidate surfaced that no on-chain check can tie to this token (the shared multi-token NTT manager is the case that reaches here — its `token()` reverts, so a same-receipt event may belong to any token it carries). Never write it yourself; show the user the candidate and its evidence and let them confirm. +- `detected` null: consult the token's **official documentation only** (found via the CoinGecko coin homepage or the project's well-known domain) for Monad bridge/adapter addresses, then verify any candidate by running the probes' back-reference checks (see `references/bridge-protocols.md`). If nothing verifies, write **no bridgeInfo** and report that. + +For protocol details, discovery sources, and the mint-class vs lock-class distinction, read `references/bridge-protocols.md`. + +### 3. CoinGecko ID + +```bash +uv run --env-file=.env python .claude/skills/add-token/scripts/coingecko_lookup.py
\ + --symbol --name "" \ + --known-address 1=0x... --known-address 56=0x... \ + --official-domain +``` + +Pass `--known-address` for every cross-chain address you have already established independently (bridge probe `crossChainHints`, official docs). Interpret `confidence`: +- `address-verified` → write `coinGeckoId`. +- `weak` (homepage + exact name/symbol only) → show the user; do not write silently unless they confirm. +- `none` → omit `coinGeckoId`. New tokens are often not on CoinGecko yet; that is fine and expected. + +The Monad address itself may not be listed on CoinGecko even when the coin exists (listing lags new deployments) — that is exactly what the corroborated-search fallback is for. + +### 4. crossChainAddresses + +A bridged token is deployed somewhere else — that is what "bridged" means. So whenever step 2 detected a bridge, filling `crossChainAddresses` is the expected outcome, and an empty result is a signal you stopped searching too early. Omit the field only when the token is genuinely Monad-native (no bridge detected) or when you have worked the full supported-chain list below and could prove nothing on any of them. + +Two failure modes make this go wrong, and the methodology below guards against both: giving up when the probe and CoinGecko hints come back empty, and finding one or two counterparts and stopping before checking the rest. Be exhaustive — the point is to fill every chain the evidence supports, not to fill the first one you find. + +**Supported chains.** The authoritative list is the "Chain ID Reference" table in `CONTRIBUTING.md`. Read it rather than relying on memory — chains get added over time, and the validator rejects any chain ID outside that table. Walk the whole table chain by chain so coverage is complete. `scripts/utils/web3.py` exposes a working RPC for each supported chain through `get_web3_connection_for_chain(chain_id)`. + +**Start with the registry.** Run the bundled enumerator, which reads the native bridge's own peer registry — the strongest evidence there is, because it comes from the same contract you verified in step 2: + +```bash +uv run --env-file=.env python .claude/skills/add-token/scripts/find_cross_chain.py [--bridge-address ] +``` + +Pass `--bridge-address` whenever step 2's `bridgeAddress` is not the token itself — a Wormhole NTT manager, or a LayerZero OFT adapter deployed separately from the token — because the peer table lives on that contract, not the token. The script covers every mint-class protocol's registry (LayerZero OFT `peers`, Wormhole NTT `getPeer`, Chainlink CCIP token-pool remote tokens; Hyperlane peers come from the probe's `crossChainHints`) and **verifies each candidate on its own chain**, printing per-chain `symbol`/`decimals` with any overrides. It does not guess counterparts from a shared address. It also follows a **hub hop**: a hub-and-spoke token enrolls only its hub, so the script reads the hub's peer table too. Read `references/bridge-protocols.md` for how each registry works and how to hand-map anything the script reports as `unmapped`. + +**Never hand-roll the enumeration, and never hand-write a chain-mapping table.** This is the rule that a real incident produced, so treat it as hard: + +- Every address you write into `crossChainAddresses` must come from this script's `verified` output. If you find yourself writing an inline `web3` script that walks `peers()` / `getPeer()` / a selector table to fill this field, stop — fix or extend `find_cross_chain.py` instead, then re-run it. An ad-hoc script is exactly where a wrong endpoint-id gets invented, and it carries none of the confirmations below. +- The eid/selector -> chain mapping is **never** written from memory. The script takes it from the LayerZero metadata API, and confirms every candidate against the remote endpoint's own `eid()` before recording it. eid `30370` is Plume and eid `30383` is Plasma; guessing that pair the other way round is what put four unverifiable Plasma addresses into a PR. +- Read the script's `rejected` list and report it. An entry there means a contract exists at that address on that chain and may even carry the right `symbol`, but it is not on the endpoint that enrolled it — a same-address deployment, not a proven counterpart. Never promote a `rejected` entry into `verified` by hand. +- A registry that returns nothing for a chain is a negative result, not an invitation to fall back on a same-address match. `mainnet/frxUSD` ships 8 chains, not 9, for exactly this reason. + +The sources it draws on, strongest first — this is the order to trust them in: +1. **Registry / peer table.** Proves identity outright, and uniquely finds counterparts deployed at a *different* address (e.g. a canonical token that predates the bridge, reached through an adapter's `token()`). This is why the registry is the primary source. +2. **Official docs / the corroborated CoinGecko `platforms` map.** For any chain the registries miss — including a deterministic multi-chain deploy that no peer table enumerates (a hub-and-spoke token often enrolls only its hub as a peer yet is deployed identically on many chains). The same official source that gave you the logo or bridge address usually lists every deployment; pursue it actively. Verify any address it yields exactly as below before writing it. + +**Never add a chain from a same-address guess.** An identical address on another chain can be an unrelated or malicious deployment, and a matching `symbol()` does not prove identity — a clone sets its symbol to anything it likes. So a shared address is written *only* when official docs or an appropriate registry name it for this token; the address matching Monad's is corroboration, never the reason. The script enforces this by never sweeping addresses on its own. + +**Metadata legitimately differs across chains — record it, don't reject it.** USDC is the classic case: 6 decimals on Ethereum but often 18 on the bridged BNB side; symbols can carry chain-specific prefixes or suffixes (XAUt vs XAUt0). These differences are exactly what the per-chain `"symbol"`/`"decimals"` override fields exist to capture — the script emits them for you. What you are confirming is **identity**, not equality: a registry link or a docs listing proves it (so a renamed symbol there is fine). Never write an address the verify step could not confirm. + +**Cross-check against official docs before you finish.** When the project publishes a contract-address page (found the same address-verified way as the logo homepage), diff your `crossChainAddresses` against it chain by chain. It is the cheapest catch for a chain you added that the registry never actually named, and for one you missed. A chain that is in your file but absent from both the registry output and the docs must come out. + +Skip and report any chain where a candidate surfaced but could not be verified, any chain whose RPC was unreachable, and anything the script lists as `unmapped` that you did not resolve by hand. If, after all this, a bridged token still has zero provable counterparts, flag that explicitly — it is a surprising result worth surfacing, not a silent omission. + +If this step establishes cross-chain addresses that step 3's CoinGecko lookup did not have, re-run the lookup with them as `--known-address` — an `address-verified` match can appear once the counterparts are supplied. + +### 5. Logo + +`validate_tokens.py` requires `logo.svg` or `logo.png` in the token directory: square, at least 200x200 (256x256 preferred). The same address-level trust policy applies here as everywhere else — a clone copies the logo perfectly, so the logo you write has to come from a source that keyed it to *this* address, not to a name match. + +The best source is the token's **official app or an integration that lists it** (the project's own `app.`, or Morpho/Euler/Pendle when the token is a vault/collateral/underlying there). These front-ends look their icons up by contract address and serve them as SVGs, so with the exact address you get the right icon, sharp at any size. Do not stop at CoinGecko's PNG: it is a 250x250 raster and, until step 3 address-verified the coin, only as good as a name match. Reach for it only after the app route genuinely comes up empty. + +Establish the project's official site from an address-verified source — the CoinGecko coin `homepage`, official docs, or a known integration's app — never from general search. Do not assume the app is at `app.`; it may be the domain root, a path, or a separate host. The bundled script digs for it: hand it the homepage and it mines the page and its JS bundles for the real app URL before mining that app for icons. Pass any address the app is likely to key on (the Monad address first; if that finds nothing, the cross-chain counterpart from step 4): + +```bash +uv run --env-file=.env python .claude/skills/add-token/scripts/fetch_app_logo.py \ + --app-url https:// --address --symbol \ + --extra-address 1= --out mainnet/ +``` + +It fetches the app and its JS/CSS bundles, extracts address- and symbol-matched icons (inline `data:image/svg+xml`, linked `.svg` assets, and symbol/address-keyed image URLs alike), skips share/favicon/brand/chain images and anything not roughly square, auto-resizes small square SVGs to 256, and prints a ranked `candidates` list with a `confidence` on each. Take the top `strong` candidate (`candidates[0].saved_path`); inspect first when only `weak` candidates exist or they disagree — an address can belong to a sibling token. Then save it (`candidates[0].ext` is `svg`/`png`/`webp`) and delete the other candidate files. + +It also queries the integrations that list the token even when their app has no static icon: **Morpho** (`blue-api.morpho.org`, address-keyed), **Euler** (`token-images.euler.finance`, address-keyed), and the symbol-path apps **Curvance** and **Mento** (`app./tokens/.svg`). These run automatically from the address and symbol — no app URL needed — so pass every cross-chain address from step 4 via `--extra-address CHAINID=0x...` (repeatable), since Morpho/Euler key by the address on each chain and the token may only be listed under its Ethereum deployment. The ranking prefers the token's own app over an integration's copy of the icon. Pass `--no-integrations` to skip them. + +**Read `references/logo-sources.md`** for the full method: finding the app URL, the exact meaning of each `match`/`confidence` value, the integration sources, why the resize is safe, and the by-hand extraction fallback for when a minified bundle defeats the script. If every avenue — official app, integrations, official brand assets, and finally the address-verified CoinGecko image — comes up empty, leave the logo missing and flag it; validation failing until a human adds one is the intended behavior. + +### 6. Format and validate + +Write `data.json` with 2-space indent, sorted the way the other tokens are (field order: chainId, address, name, symbol, decimals, extensions; extensions: coinGeckoId, bridgeInfo, crossChainAddresses). CI requires `jq --indent 2` canonical formatting — check with: + +```bash +jq --indent 2 . mainnet//data.json | diff - mainnet//data.json +``` + +Then run the validator scoped to the new token (keep the `--env-file=.env` prefix — without the CoinGecko key validation throttles heavily): + +```bash +uv run --env-file=.env python scripts/validate_tokens.py --validate-cross-chain +``` + +This runs the exact checks CI runs, just limited to your token — the other tokens are unchanged and already pass. CI will still validate the full list on the PR. + +Fix any error it reports. If an extensions value cannot be made to pass, remove it (and report why) rather than shipping a failing or fudged value. Never edit `tokenlist-mainnet.json` — it is regenerated by CI after merge. + +### 7. Report + +End with a summary the user can review at a glance: +- What was written to `data.json`, with the evidence for each extensions field (probe evidence lines, CoinGecko confidence, per-chain verification results). +- For a bridged token, cross-chain coverage: which supported chains you checked, which have a verified counterpart (with any `symbol`/`decimals` overrides), and which you skipped and why. This makes it easy to see the whole table was worked, not just the first hit. +- What was intentionally omitted and why (no verified bridge, no corroborated CoinGecko ID, unverifiable cross-chain address, missing logo). +- The validator's result. + +If anything was ambiguous (multiple bridges, weak CoinGecko match), ask the user instead of deciding yourself. diff --git a/.claude/skills/add-token/references/bridge-protocols.md b/.claude/skills/add-token/references/bridge-protocols.md new file mode 100644 index 0000000..b78f74f --- /dev/null +++ b/.claude/skills/add-token/references/bridge-protocols.md @@ -0,0 +1,73 @@ +# Bridge protocols + +Reference for step 2 (detect the native bridge) and step 4 (cross-chain addresses). Reconstructed for the add-token skill from the protocol primitives in `scripts/utils/web3.py` and the accepted-protocol constants in `scripts/validate_tokens.py`. + +## What the validator accepts + +`bridgeInfo.protocol` must be exactly one of (`VALID_BRIDGE_PROTOCOLS` in `validate_tokens.py`): + +- `Circle CCTP` +- `Chainlink CCIP` +- `M0 Portal` +- `Wormhole` +- `Wormhole NTT` +- `LayerZero OFT` +- `Hyperlane Warp Route` + +For four of these the `bridgeAddress` is a **fixed shared contract** the validator checks against `EXPECTED_BRIDGE_ADDRESSES`, so you copy it verbatim: + +- Circle CCTP -> `0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d` +- Chainlink CCIP -> `0x33566fE5976AAa420F3d5C64996641Fc3858CaDB` +- M0 Portal -> `0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd` +- Wormhole -> `0x0B2719cdA2F10595369e6673ceA3Ee2EDFa13BA7` + +For `LayerZero OFT`, `Hyperlane Warp Route`, and `Wormhole NTT` the `bridgeAddress` is **token-specific** - the OFT/adapter, the warp route, or the NTT manager - and the probe reports the exact address. + +## Mint-class vs lock-class + +A **mint-class** bridge mints/burns the token on Monad, so the Monad contract *is* (or is wired to) the bridge - this is what `bridgeInfo` records, and exactly one should verify. A **lock-class** bridge locks a canonical token elsewhere and the Monad side is a plain wrapper; when nothing mint-class verifies, write no `bridgeInfo`. + +## How each is detected on-chain (`probe_bridges.py`) + +Each probe is a read against the Monad contract using a primitive in `utils/web3.py`; a revert means "not this protocol". + +- **Circle CCTP** - `burnLimitsPerMessage(token)` on the CCTP minter (`CCTP_TOKEN_MINTER_V2_ADDRESS`) returns a nonzero limit. +- **Chainlink CCIP** - `getTokenConfig(token).tokenPool` on the CCIP admin registry (`CCIP_TOKEN_ADMIN_REGISTRY_ADDRESS`) is a nonzero pool. +- **M0 Portal** - the token exposes `mToken()` (an M extension or the Portal's bridged $M). +- **Wormhole (wrapped)** - the token exposes `chainId()` (uint16), the wrapped-asset marker. +- **LayerZero OFT** - the token is its own OFT: `token()` returns itself, and `endpoint()` is the Monad LayerZero EndpointV2 (`0x6f475642a6e85809b1c36fa62763669b1b48dd5b`). A separately-deployed OFT adapter points `token()` at the canonical token instead; its own address is the `bridgeAddress`. +- **Hyperlane Warp Route** - the token exposes `wrappedToken()`. +- **Wormhole NTT** - the token's `minter()` is an NTT manager whose `token()` returns this token; the manager address is the `bridgeAddress`. + +**The shared multi-token NTT manager** (`0x36878C6FCa7e0E8a88F90dc410CfBBcA5B695C95`) is the one case that reaches `needsHumanConfirmation`: its `token()` reverts because it carries several tokens, so no on-chain check can tie a same-receipt event to *this* token. Never write it yourself; show the user the candidate and let them confirm. + +Outcomes: exactly one mint-class hit -> `detected` (tier A). Several -> `ambiguous` (present all; do not pick). None -> consult official docs, verify any candidate with the same back-reference checks, and otherwise write no `bridgeInfo`. + +## Cross-chain enumeration (`find_cross_chain.py`) + +Enumeration for every source below is gated on the supported counterpart chains (the `SUPPORTED_CHAINS` set, mirroring the "Chain ID Reference" table in `CONTRIBUTING.md`): a counterpart on any other chain is ignored, because the list has nowhere to record it. The per-protocol chain maps (`CCIP_SELECTOR_TO_CHAIN`, `WORMHOLE_ID_TO_CHAIN`, and the LayerZero eid index) list only those chains. + +Every counterpart is drawn from an authoritative source that ties the address to *this* token - a bridge peer registry or a protocol directory. A shared address is never a source on its own: the same address on another chain can be an unrelated or malicious deployment, and a matching `symbol()` does not prove identity (a clone sets its symbol freely). The peer registry is the strongest source because it lives on the same contract you verified. + +- **LayerZero OFT** - `peers(uint32 eid)` on the OFT (or the adapter given via `--bridge-address`, or the adapter the LayerZero registry names for a plain ERC20) returns a `bytes32` peer; its low 20 bytes are the counterpart address. Endpoint IDs map to chains via the LayerZero metadata index, e.g. eid `30101` -> Ethereum (`1`), `30184` -> Base (`8453`), `30110` -> Arbitrum (`42161`). The registry's `peggedTo` gives one more counterpart. +- **Chainlink CCIP** - the offchain CCIP directory (`https://docs.chain.link/api/ccip/v1/tokens?environment=mainnet`, keyed by symbol then chain id) lists every chain the token exists on with its address there, confirmed by matching the Monad (`143`) entry to this token. This is the primary source because the Monad pool's on-chain remote table (`getSupportedChains()` / `getRemoteToken(selector)`, selectors via `CCIP_SELECTOR_TO_CHAIN`) only holds the lanes that one pool enrolled; the on-chain read is the fallback when the directory is unreachable. +- **Wormhole NTT** - `getPeer(uint16 whChainId)` on the NTT manager returns the peer manager per Wormhole chain id (`WORMHOLE_ID_TO_CHAIN`), resolved to the remote token via its `token()`. Only a per-token manager (whose `token()` is this token) is enumerated: the shared multi-token manager (`0x36878C6FCa7e0E8a88F90dc410CfBBcA5B695C95`, e.g. the Mento family) reverts `token()` and its peers are other shared managers, so its counterparts cannot be resolved. +- **Hyperlane Warp Route** - the `hyperlane-registry` warp config for the token's symbol lists its underlying address per chain (`collateralAddressOrDenom`, or the router for a synthetic route), matched to this token by its Monad address. This handles XERC20 routes (e.g. ezETH), where the token is separate from the router and on-chain discovery from the token does not work. On the home chain the config points at the XERC20 lockbox rather than the canonical token, so that entry fails on-chain verification and is dropped. + +A deterministic multi-chain deploy that no registry enumerates (a hub-and-spoke token often enrolls only its hub as a peer, yet is deployed identically on many chains) is **not** picked up automatically. Add such a chain only from official docs or an appropriate registry that names the address for this token - never from a same-address guess, even when the remote `symbol()` matches. Verify the docs/registry address on its own chain before writing it, exactly like any other candidate. + +Every candidate is then re-verified on its own chain via `get_web3_connection_for_chain(chain_id)`, reading `symbol()`/`decimals()` and emitting per-chain overrides when they differ from Monad. A candidate that fails to verify (unreachable RPC, or a different token at that address) is skipped and logged, not written. Two counterparts that verify to different addresses on one chain land in `conflicts` for you to resolve against the "Chain ID Reference" table in `CONTRIBUTING.md`. + +## Endpoint ids are not guessable + +A LayerZero peer entry is `eid -> address`. The eid is LayerZero's own chain id and has no relationship to the EVM chain id, so the mapping has to be looked up, never recalled. Neighbouring eids belong to unrelated chains: 30367 is HyperEVM, 30370 is Plume, 30383 is Plasma, 30390 is Monad. + +`find_cross_chain.py` takes the mapping from the LayerZero metadata API (`https://metadata.layerzero-api.com/v1/metadata`, which gives eid -> `nativeChainId` plus RPCs) and then confirms it per candidate: on the remote chain it reads the peer's `endpoint()` and that endpoint's `eid()`, and records the address only when that equals the eid the peer was enrolled under. `ENDPOINT_ID_TO_CHAIN_FALLBACK` is used only when the API is unreachable. + +This matters because deterministic multi-chain deploys put the same address on many chains, including chains the hub never enrolled. On such a chain the contract answers `symbol()` correctly and may even name the hub in its own `peers()`, since `peers` is set by whoever deployed it. Neither fact proves the hub bridges there. The endpoint check is what separates the two, and anything it rejects is reported under `rejected` rather than written. + +## Hub-and-spoke tokens + +A token bridged through a hub enrols only the hub in its own peer table. Reading just the Monad token's `peers()` for such a token returns a single entry, usually on a chain outside the supported set, which looks like "no counterparts" but is not. The script follows that peer one hop, reading the hub contract's peer table over an RPC from the metadata index, and enumerates the spokes from there; those sources are tagged `LayerZero peer via hub`. + +The hub's `token()` is worth reading as a sanity check: it names the canonical token the hub locks, which is what distinguishes sibling tokens whose hubs otherwise look alike. diff --git a/.claude/skills/add-token/references/logo-sources.md b/.claude/skills/add-token/references/logo-sources.md new file mode 100644 index 0000000..b2cc10c --- /dev/null +++ b/.claude/skills/add-token/references/logo-sources.md @@ -0,0 +1,76 @@ +# Sourcing a token logo + +The goal is a crisp, square logo that is provably *this* token's, not a look-alike's. The order below is about evidence quality, not convenience: address-keyed sources come first because a spam clone can copy a name and symbol but cannot put its address into the real project's app or docs. + +CoinGecko's `image` is listed last on purpose. It is a 250x250 PNG, and until you have address-verified the coin (step 3) it is only as trustworthy as a name match - which is exactly what a clone defeats. Reach for it only when the address-keyed sources below genuinely come up empty, not as the default. + +## Why official apps and integrations are the best source + +A DeFi token almost always appears in a web front-end: the project's own app, and often integrations that list it - Morpho and Euler (as collateral or a vault asset), Pendle (as an underlying), a DEX or lending market. These front-ends render a per-token icon, and they look that icon up **by contract address**. So once you have the exact address, the app hands you the right icon with high confidence - and it is usually an SVG (sharp at any size) rather than a rasterised PNG. + +The icon lives in one of two forms, both of which the bundled `fetch_app_logo.py` harvests: + +1. **A build asset the page links**, e.g. `/assets/PRIME-CI4jqIYf.svg` - the filename often starts with the token symbol. +2. **An inline `data:image/svg+xml` URI** embedded in a JS bundle and referenced from the token's config object, e.g. `{symbol:"jrUSDat", address:"0x011e...", asset:She, networks:[...]}` where elsewhere `She="data:image/svg+xml,"`. + +## Finding the app URL + +Start from a legitimate, address-verified origin - the same trust policy as the rest of the skill: + +- The address-verified CoinGecko coin's `homepage` (from `coingecko_lookup.py`) - the project's own site. +- The project's official docs / GitHub, reached from that homepage. +- A known integration's app when the token is a vault/collateral there (`app.morpho.org`, `app.euler.finance`, `app.pendle.finance`, ...). Use these when the token *is* a position in that protocol - its name often says so ("Pareto AA Tranche", "Morpho ... Vault"). + +**Do not assume the app lives at `app.`.** It might be the domain root itself, a path like `/app` or `/earn`, or a separate host reached by a "Launch App" link - and a marketing homepage is frequently just a JS shell with no static links at all. So dig for it rather than guessing: `fetch_app_logo.py` does this for you when you hand it the project homepage - it mines both the HTML and the homepage's JS bundles for same-domain URLs that point at an app (an `app.`/`dapp.` host, an app-ish path), follows what it finds, and only then falls back to conventional host guesses. In practice you can pass the homepage and let discovery reach the app; pass an explicit app/integration URL when you already know it. Do not take an app URL from general search results or an aggregator. If you cannot establish the official site from an address-verified coin or official docs, treat the logo as not-yet-found rather than guess. + +## Using `fetch_app_logo.py` + +```bash +uv run --env-file=.env python .claude/skills/add-token/scripts/fetch_app_logo.py \ + --app-url https:// \ + --address \ + --extra-address 1= \ + --symbol \ + --out mainnet/ +``` + +- Pass **any** address the app is likely to key on. The Monad address is the obvious first try, but an app may only list the token's Ethereum (or other home-chain) deployment - the cross-chain address you established in step 4. Give each via `--extra-address CHAINID=0x...` (repeatable), since the integrations key by the address on each chain. +- `--app-url` is repeatable; pass the project app and any integration app in one run to sweep them together. It is optional - the integrations below run from the address/symbol even with no app URL. +- Output is a JSON report with a ranked `candidates` list. Each candidate has: + - `match`: which signals tied it to the token - `symbol-filename` (asset file named after the symbol), `symbol-url` / `address-url` / `symbol-nearby` (an image URL keyed by the symbol in its filename, by the address next to it, or by the symbol as nearby text), `address-config` / `symbol-config` (an icon next to the address/symbol in a config object), the integration signals `morpho-address` / `euler-address` / `curvance-symbol` / `mento-symbol`, or `unmatched`. Multiple signals joined by `+` mean they agreed on the same icon. + - `confidence`: `strong` when an address-keyed registry hit, an official-app symbol lookup, or >=2 signals agree; `weak` otherwise. + - `ext`: `svg`, `png`, or `webp` - save with this extension. + - `dimensions`, `resize_note`, and `saved_path`. Candidates are listed best-first, so `candidates[0]` is the top pick. (The number in the filename is discovery order, not rank.) + +**Picking a candidate.** Take the top `strong` candidate. The ranking is by evidence quality: a candidate keyed to the contract address (the app's own address-anchored icon, then a trusted integration's per-address icon) ranks above one matched by symbol name or nearby text, and an SVG ranks above a raster. This is deliberate: symbol-name and symbol-text matches routinely grab a brand logo or an illustration rather than the coin icon, whereas the address-keyed icon is provably this token. Be deliberate when only `weak` candidates exist or several disagree: a token's config can reference a *sibling* token's address (paired tranches, a collateral link), so an address-only hit occasionally resolves to the wrong icon. Open the SVG and sanity-check it depicts this token before writing it. + +## Integrations (Morpho, Euler, Curvance, Mento) + +Some tokens have no icon in any static app bundle because the listing app loads its token list at runtime. For those, the script also queries integration front-ends that expose an address- or symbol-keyed logo source directly. These run automatically from `--address` / `--extra-address` / `--symbol` (no `--app-url` needed) unless you pass `--no-integrations`: + +- **Morpho** - `POST https://blue-api.morpho.org/graphql` returns `{address, logoURI}` per chain (queried one chain at a time, since a chainId Morpho does not index errors the whole request); matched logos live on `cdn.morpho.org` (SVG). Address-keyed, so it produces a `morpho-address` signal. +- **Euler** - `https://token-images.euler.finance/{chainId}/{address}` serves the logo directly (SVG/PNG/WebP); membership is confirmed against `https://app.euler.finance/api/public/metadata?chainId=` so the generic fallback image is skipped. `euler-address` signal. +- **Symbol-path apps** - some apps serve an icon per token at `//.svg`: **Curvance** (`app.curvance.com/tokens/.svg`) and **Mento** (`app.mento.org/tokens/.svg`). Every symbol casing is tried (Curvance is lowercase, Mento is exact-case); a miss returns a non-image page and is discarded. `-symbol` signal. Add another by appending one line to `SYMBOL_PATH_APPS`. + +Because Morpho and Euler key by the address *on each chain*, pass every cross-chain address (`--extra-address 1=0x...`) - a token often appears only under its Ethereum deployment. These hosts are hardcoded and vetted, so their fetches gate on the integration's own domain rather than the mined app's, which keeps the SSRF constraint intact. Address-keyed integration hits and the official-app symbol lookups all rank as `strong`. + +## Non-token images are filtered out + +The URL-icon matchers (`symbol-url` / `address-url` / `symbol-nearby`) would otherwise pull in share/preview banners, favicons, the app's own brand logo, or a chain-selector icon sitting near the token name. Two guards prevent that: a path denylist (`og`, `social`, `banner`, `favicon`, `logo`, `branding`, `network`, common chain names, ...) matched on the URL's alphabetic runs so a glued `Favicon32x32` still trips it, and an aspect/size check that drops anything not roughly square or under 100px. The symbol is matched as a bounded token, not a substring, so `EUL` does not match `euler-symbol`. + +## Resizing (why the script rewrites width/height) + +`validate_tokens.py` reads the SVG's `width`/`height` attributes and rejects anything under 200px, even though an SVG scales to any size. App icons are frequently declared at their display size (e.g. `40x40`). When the art has a square `viewBox`, the script bumps `width`/`height` up to 256 and leaves the `viewBox` untouched - the geometry and therefore the rendered image are identical, only the two declared attributes change. `resize_note` records what it did. Pass `--no-resize` to disable this if you want the untouched original. + +## Manual extraction (when the script cannot resolve it) + +Minified bundles vary; if the script returns only `unmatched` candidates or misses the icon, do it by hand - the technique is the same: + +1. Fetch the app page, note the linked JS/CSS assets (`