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
162 changes: 27 additions & 135 deletions .claude/skills/add-explorer/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,155 +1,47 @@
---
name: add-explorer
description: Add support for a new blockchain explorer API to diffyscan. Use when a new chain or explorer type needs to be supported.
description: Adds or repairs Diffyscan explorer API routing and response adapters for a new host, chain or payload format. Use when explorer support is missing or dispatches incorrectly. If an existing adapter supports the deployment, continue with new-config instead of changing code.
argument-hint: "[explorer-name]"
disable-model-invocation: true
argument-hint: [explorer-name]
---

Guide through adding support for a new blockchain explorer to diffyscan.
Find the smallest change that supports the requested explorer. Run commands from the repository root.

## Decision tree: do you need code changes?
## 1. Decide whether code is needed

Most new chains need NO code changes. Work through this list in order:
Read the supported-explorer section of [configuration](../../../docs/configuration.md) and `_get_explorer_fetcher` in `diffyscan/utils/explorer.py`. Check the actual host and current official API documentation or a response fixture; a legacy config does not prove current service availability.

1. **Etherscan v2 API (preferred)** -- If the chain is listed on Etherscan's v2 supported chains, create a config with `"explorer_hostname": "api.etherscan.io"` and `"explorer_chain_id": <chain-id>`. Done. No code changes. This uses the endpoint `https://api.etherscan.io/v2/api?chainid=<id>&module=contract&action=getsourcecode&address=<addr>`.
1. Existing Etherscan-compatible API: use its supported endpoint configuration. Etherscan v2 uses `api.etherscan.io` plus `explorer_chain_id`.
2. Existing Blockscout host match: config-only. Unrecognized domains fall through to Etherscan, which may return incomplete sources rather than an immediate error.
3. New Blockscout host: use exact hostname equality for a single requested host. Add a separate exact-match branch if necessary; this does not require refactoring existing routes. Use a domain suffix only when subdomain support is part of the task, with a dot boundary. Test near-misses including `evil-<hostname>`; copying a permissive existing `endswith` pattern would silently broaden the new route.
4. Different response format: add an adapter and dispatch rule, with token behavior based on the API requirements.

2. **Legacy Etherscan-compatible hostname** -- If the chain has its own Etherscan-style API (e.g. `api.basescan.org`, `api-optimistic.etherscan.io`), create a config with just `"explorer_hostname"` (no `explorer_chain_id`). The default fetcher handles it. No code changes.
For config-only work use [new-config](../new-config/SKILL.md). Avoid adding a fetcher for a chain already served by an existing one.

3. **Existing Blockscout domain** -- If the chain uses a Blockscout instance whose domain already ends with one of the recognized suffixes (see dispatcher below), create a config with that hostname. No code changes.
Read [response mapping](references/response-mapping.md) when implementing a fetcher or diagnosing lost source/settings fields; it covers payload keys, argument shapes and normalization pitfalls.

4. **New Blockscout domain** -- If the chain uses Blockscout but with an unrecognized domain, add the domain suffix to `_get_explorer_fetcher()`. One-line change.
## 2. Implement and test

5. **Entirely new API format** -- Only if the explorer has a non-Etherscan, non-Blockscout API do you need a new fetcher function.
Inspect `get_contract_from_explorer`, the selected fetcher and helpers in `diffyscan/utils/explorer.py`. Dispatch returns `(fetcher, requires_token)`; the caller chooses arguments from that flag. Runtime token loading happens before dispatch, even when an adapter does not send a token.

## Architecture: `diffyscan/utils/explorer.py`
Use `fetch` from `diffyscan/utils/http_client.py` to retain shared User-Agent and error handling. Normalize through existing helpers where applicable:

All explorer logic lives in one file. The call flow is:
- `_build_source_files`: primary and additional sources;
- `_build_solc_input`: sources and compiler settings;
- `_build_contract_payload` and `_attach_contract_metadata`: name, compiler, `solcInput`, constructor arguments, EVM version and linked libraries.

```
get_contract_from_explorer() # public entry point, handles caching
-> _get_explorer_fetcher() # dispatcher: hostname -> (fetcher_fn, requires_token)
-> fetcher(...) # one of the four fetchers below
-> _validate_contract_name() # verify name matches config
```

### The dispatcher: `_get_explorer_fetcher(explorer_hostname)`

This function maps hostnames to fetcher functions using prefix/suffix matching. It returns a tuple of `(fetcher_function, requires_token: bool)`.

Current routing rules (checked in order):

| Condition | Fetcher | Token required? |
|---|---|---|
| `hostname.startswith("zksync")` | `_get_contract_from_zksync` | No |
| `hostname.endswith("mantle.xyz")` | `_get_contract_from_mantle` | No |
| `hostname.endswith("lineascan.build")` | `_get_contract_from_etherscan` (token forced to `None`) | No |
| `hostname.endswith(...)` any of: `mode.network`, `blockscout.com`, `swellnetwork.io`, `lisk.com`, `inkonchain.com`, `routescan.io`, `monadvision.com` | `_get_contract_from_blockscout` | No |
| **Default (everything else)** | `_get_contract_from_etherscan` | **Yes** |

When `requires_token` is True, the caller passes `(token, hostname, address, chain_id)`. When False, the caller passes `(hostname, address)` only.

### Fetcher 1: `_get_contract_from_etherscan(token, hostname, address, chain_id=None)`

Handles both Etherscan v2 and legacy Etherscan APIs.

- **v2 endpoint** (when `chain_id` is set): `https://{hostname}/v2/api?chainid={chain_id}&module=contract&action=getsourcecode&address={address}&apikey={token}`
- **Legacy endpoint** (when `chain_id` is None): `https://{hostname}/api?module=contract&action=getsourcecode&address={address}&apikey={token}`
- Has built-in rate-limit retry logic (up to 5 retries with linear backoff)
- Response format: `{"message": "OK", "result": [{"ContractName": ..., "SourceCode": ..., "CompilerVersion": ..., ...}]}`
- If `SourceCode` starts with `{{`, it is treated as a JSON solc standard input (stripped of outer braces and parsed)
- Otherwise, `_build_source_files()` + `_build_solc_input()` construct the solc input from flat source
- **Limitation:** Flattened (single-file) contracts and contracts verified without standard JSON input may produce incomplete solc inputs. The flat-source path creates a minimal solc input from the single source file plus any `AdditionalSources`, but without the original compiler settings (e.g. remappings, via-IR). This can cause bytecode mismatches even when source diffs are clean.

### Fetcher 2: `_get_contract_from_blockscout(hostname, address)`

- Endpoint: `https://{hostname}/api/v2/smart-contracts/{address}`
- No API token needed
- Response is a flat JSON object with fields: `name`, `file_path`, `source_code`, `additional_sources` (list of `{file_path, source_code}`), `compiler_version`, `compiler_settings`, `optimization_enabled`, `optimization_runs`, `constructor_args`, `evm_version`, `external_libraries` (list of `{name, address, ...}`)
- Note: the response uses both `optimization_runs` and `optimizations_runs` (typo in some Blockscout versions); the code checks both

### Fetcher 3: `_get_contract_from_zksync(hostname, address)`

- Endpoint: `https://{hostname}/contract_verification/info/{address}`
- No API token needed
- Response: `{"verifiedAt": ..., "request": {"ContractName": ..., "CompilerVersion": ..., "sourceCode": {"sources": ...}}}`
- Returns a minimal contract dict with `name`, `sources`, `compiler` -- does NOT go through `_build_contract_payload` or `_attach_contract_metadata`
- **Outlier:** Unlike the other fetchers, this does not return `solcInput`. Downstream code (`run_source_diff`, `run_bytecode_diff`) expects `contract["solcInput"]`, so zkSync contracts use a different code path. New fetchers should follow the standard shape returned by `_build_contract_payload` (`name`, `compiler`, `solcInput`, plus optional `constructor_arguments`, `evm_version`, `libraries`).

### Fetcher 4: `_get_contract_from_mantle(hostname, address)`
Preserve available settings and source paths. Distinguish missing constructor metadata from an empty value; resolve libraries to defining files. The zkSync adapter currently returns `sources` without the `solcInput` required downstream: treat this as an existing compatibility gap, not a template.

- Endpoint: `https://{hostname}/api?module=contract&action=getsourcecode&address={address}`
- No API token needed
- Etherscan-like response but uses `FileName` (not `ContractName`) as primary path, and `AdditionalSources` list uses `Filename`/`SourceCode` keys (note capitalization differences)
Add mocked tests in `tests/test_explorer_utils.py` for dispatch, normalized payload, error/unverified responses and relevant metadata. Isolate cache use so prior responses cannot make the tests pass. A routing-only change still needs a regression test.

## Shared helpers

- **`_build_source_files(primary_path, primary_source, additional_sources, *, path_key, content_key)`** -- Assembles a `{path: {"content": source}}` dict. The `path_key` and `content_key` parameters handle the field name differences between explorers.
- **`_build_solc_input(source_files, *, optimizer_enabled, optimizer_runs, settings=None)`** -- Wraps source files into a standard solc JSON input with optimizer config and output selection.
- **`_build_contract_payload(name, compiler, solc_input, *, constructor_arguments, evm_version, libraries)`** -- Assembles the final contract dict and calls `_attach_contract_metadata()`.
- **`_attach_contract_metadata(contract, source_files, constructor_arguments, evm_version, libraries)`** -- Normalizes and attaches constructor args (hex string without 0x prefix), EVM version (None if "default"), and libraries (resolved to `{path: {name: address}}` format). Libraries can come from explorer response OR from `solcInput.settings.libraries`; both are merged.

## Steps: adding a new Etherscan v2 chain (no code changes)

1. Find the chain ID (e.g. from chainlist.org)
2. Create `configs/<project>/<mainnet|testnet>/<config>.yaml`:
```yaml
contracts:
"<contract-address>": <contract-name>
explorer_hostname: api.etherscan.io
explorer_token_env_var: ETHERSCAN_EXPLORER_TOKEN
explorer_chain_id: <chain-id>
github_repo:
url: https://github.com/<org>/<repo>
commit: <commit>
relative_root: ""
dependencies: {}
```
3. Test: `uv run diffyscan <config> --yes --cache-explorer`

## Steps: adding a new Blockscout domain

1. Add the domain suffix to the tuple in `_get_explorer_fetcher()` in `diffyscan/utils/explorer.py` (the `any(explorer_hostname.endswith(domain) for domain in [...])` block)
2. Create a config with `"explorer_hostname": "<blockscout-hostname>"`
3. Test: `uv run diffyscan <config> --yes --cache-explorer`

## Steps: adding a completely new explorer type

1. **Understand the API**: Document the endpoint URL, response shape, and which fields map to contract name, source code, compiler version, optimizer settings, constructor args, EVM version, and libraries.

2. **Add hostname detection** in `_get_explorer_fetcher()`: Add a new `elif` branch with `startswith()` or `endswith()` matching. Return `(your_fetcher, False)` -- most non-Etherscan explorers do not use API tokens.

3. **Implement the fetcher** `_get_contract_from_<name>(hostname, address)`:
- Call the explorer API via `fetch(url).json()`
- Validate response (check for verification status, required fields)
- Use `_build_source_files()` to assemble sources (pass the correct `path_key`/`content_key` for the response format)
- Use `_build_solc_input()` to wrap into solc input
- Return via `_build_contract_payload()` to get normalized metadata
- If the fetcher does not need a token, its signature should be `(hostname, address)` (two args). If it does need a token, use `(token, hostname, address, chain_id=None)` (four args) and set `requires_token=True` in the dispatcher.

4. **Add tests** in `tests/test_explorer_utils.py` -- follow the existing pattern: monkeypatch `fetch` to return a `DummyResponse`, call `get_contract_from_explorer()`, assert the result.

5. **Add a config** in `configs/<project>/<mainnet|testnet>/`.

## Config fields reference

| Field | Required | Description |
|---|---|---|
| `explorer_hostname` | Yes | API hostname (e.g. `api.etherscan.io`, `explorer.mode.network`) |
| `explorer_chain_id` | No | Chain ID for Etherscan v2 API; omit for legacy or non-Etherscan explorers |
| `explorer_token_env_var` | No | Env var name holding the API key (e.g. `ETHERSCAN_EXPLORER_TOKEN`) |

## Testing
```sh
uv run pytest -q tests/test_explorer_utils.py tests/test_http_client.py
uv run mypy
uv run black --check diffyscan tests
```

- Unit tests: `tests/test_explorer_utils.py` -- monkeypatch `diffyscan.utils.explorer.fetch` and `CACHE_DIR`
- Integration: `uv run diffyscan <config> --yes --cache-explorer`
- The `--cache-explorer` flag caches responses to `.diffyscan_cache/` so repeated runs do not hit the API
For broader adapter changes run the full suite. With credentials and a deployment fixture, run `uv run diffyscan path/to/config.yaml --json` and inspect source and bytecode results using [JSON output](../../../docs/json-output.md). Mocked parsing tests do not prove end-to-end chain support.

## Checklist
## 3. Document the supported behavior

- [ ] Determined whether code changes are actually needed (most chains: no)
- [ ] If Etherscan v2: config-only with `explorer_hostname` + `explorer_chain_id`
- [ ] If new Blockscout domain: added suffix to `_get_explorer_fetcher()` tuple
- [ ] If new API type: implemented fetcher, added dispatcher rule, used shared helpers
- [ ] Config created in `configs/<project>/<mainnet|testnet>/`
- [ ] Tests added in `tests/test_explorer_utils.py`
- [ ] `.env.example` updated if a new token env var is needed
Update [configuration](../../../docs/configuration.md) when routing changes. Add a deployment config or `.env.example` entry only if needed for the task. Report behavior, tests and any unverified live integration.
41 changes: 41 additions & 0 deletions .claude/skills/add-explorer/references/response-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Explorer response mapping

Use this checklist when adapting a response fixture. Confirm behavior in `diffyscan/utils/explorer.py` before editing: these are the current parser assumptions, not a guarantee that an external endpoint remains available.

## Call and return contract

`get_contract_from_explorer` handles cache lookup, selects the fetcher, checks the contract name and saves the result. A token-using fetcher receives `(token, hostname, address, chain_id)`; a token-free fetcher receives `(hostname, address)`.

Return `name`, `compiler` and `solcInput` with `sources` and `settings`. Shared helpers attach `constructor_arguments`, `evm_version` and `libraries` when available. Test through the public entry point as well as any new parser helper, with an isolated cache.

## Etherscan

The adapter uses `/api` without chain ID and `/v2/api` with chain ID. It reads the first item in `result`, checks `message: NOTOK` and empty results, and handles rate limits through `_fetch_etherscan_response`.

| Response field | Use |
| --- | --- |
| `ContractName` | Expected name; also the source key for flattened input |
| `CompilerVersion` | Compiler selection |
| `SourceCode` starting with `{{` | Remove one outer brace pair and parse standard JSON |
| Other string `SourceCode` | Build one source under `ContractName`, with `OptimizationUsed` and `Runs` |
| `ConstructorArguments`, `EVMVersion`, `Library` | Normalize through `_build_contract_payload` |

The flattened Etherscan path does not retain arbitrary original settings or parse `AdditionalSources`. Do not assume it preserves remappings or via-IR. A source key without a file extension is a clue to inspect the original payload, not proof that bytecode cannot match.

## Blockscout

The adapter requests `/api/v2/smart-contracts/{address}`. Map primary `file_path` / `source_code` and each `additional_sources` item using those same keys. `name` and `compiler_version` supply identity and compiler.

Preserve `compiler_settings`; handle both `optimization_runs` and the fallback spelling `optimizations_runs`. Other inputs are `optimization_enabled`, `constructor_args`, `evm_version` and `external_libraries`.

Test absent name, absent primary source fields, multiple source files and relevant metadata. `constructor_args` being absent and being empty are different cases.

## Mantle and zkSync

Mantle reads Etherscan-style `result[0]`, but the primary source path is `FileName`; `AdditionalSources` entries use `Filename` and `SourceCode`. Preserve this capitalization distinction in fixtures.

The existing zkSync fetcher returns a different shape without `solcInput` and has inconsistent `contractName` / `ContractName` access. Do not copy these assumptions into a new adapter or claim the common comparison flow supports them without a reproducing test and a fix.

## Library normalization

`_attach_contract_metadata` merges libraries from explorer fields and `solcInput.settings.libraries`. Resolve a library to the file containing its definition, not a file importing it. Add fixtures for any new library response format and check that solc link references are satisfied.
Loading