Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .claude/skills/add-token/SKILL.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions .claude/skills/add-token/references/bridge-protocols.md
Original file line number Diff line number Diff line change
@@ -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> 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.
76 changes: 76 additions & 0 deletions .claude/skills/add-token/references/logo-sources.md
Original file line number Diff line number Diff line change
@@ -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,<svg ...>"`.

## 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.<domain>`.** 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://<app domain> \
--address <monad address> \
--extra-address 1=<eth address> \
--symbol <SYMBOL> \
--out mainnet/<SYMBOL>
```

- 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=<c>` so the generic fallback image is skipped. `euler-address` signal.
- **Symbol-path apps** - some apps serve an icon per token at `/<path>/<symbol>.svg`: **Curvance** (`app.curvance.com/tokens/<symbol>.svg`) and **Mento** (`app.mento.org/tokens/<symbol>.svg`). Every symbol casing is tried (Curvance is lowercase, Mento is exact-case); a miss returns a non-image page and is discarded. `<name>-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 (`<script src>`, `<link href>`), and download them.
2. Search a bundle for the contract address or `symbol:"<SYMBOL>"` to find the token's config object, and read off its `asset:`/`icon:` identifier.
3. Find that identifier's definition. If it is `="data:image/svg+xml,..."`, URL-decode (or base64-decode) the part after the comma into an `.svg`. If it is a `.svg` path, download it from the app origin.
4. Confirm the SVG is square (equal `width`/`height`, or a square `viewBox`) and bump the declared size to >=200 if needed, then save as `logo.svg`.

## Finishing

Save the chosen candidate as `mainnet/<SYMBOL>/logo.<ext>` and remove any other candidate files (and any earlier PNG) so only the final logo remains. If every avenue - official app, integrations, official brand assets, and finally the address-verified CoinGecko image - comes up empty, leave the logo missing and say so in the report. Validation will fail until a human adds one, which is the intended behavior.
Loading
Loading