For Agents

High-reuse reference. These are tool behaviors empirically verified during the MVP + Phase 2A implementation of contract-scanner (2026-05-30) — not theory. Several were disproven assumptions that caused silent false-negatives (the worst failure mode for a vuln scanner: it runs, exits 0, finds nothing, and you never know it analyzed the wrong thing). Read this before touching the Slither / source-fetch / compile path. Each item below is “what we believed → what is actually true → the fix.”

Slither + Etherscan standard-JSON: do NOT use --solc-standard-json

slither --solc-standard-json <file> does NOT consume a JSON file

Disproven during implementation. That flag tells Slither to treat the target as a directory to glob — it does not load a standard-JSON input file. Pointing it at Etherscan’s standard-JSON blob does not do what the name suggests.

Correct approach — compile in-process via crytic-compile’s SolcStandardJson Python API:

  1. Strip Etherscan’s double-brace wrapper. Etherscan returns verified standard-JSON sources wrapped in {{ ... }} (double braces). Strip the outer braces to get valid JSON before parsing.
  2. Inject the file-level AST into outputSelection. Etherscan ships outputSelection = {"*": {"*": ["*"]}}, which selects per-contract outputs but omits the file-level AST Slither needs. Add outputSelection["*"][""] = ["ast"] (empty-string key = file-level). Without this, Slither has no AST to analyze.
  3. Materialize sources to disk. crytic-compile’s SolcStandardJson does a filename existence check, so write each source key to a real file on disk before compiling.
  4. Guard against path traversal. Source keys in the JSON are attacker-controlled (they come from the verified contract submitter). A key like ../../etc/whatever.sol would write outside the work dir. Add a path-traversal guard when materializing — resolve and assert the path stays inside the intended directory.

The directory-target heuristic was tried and silently failed

An earlier approach used a “point Slither at a directory and let it figure out the contract” heuristic. It compiled the wrong subtree, missed the vulnerability, and exited 0 — a silent false-negative. Abandoned in favor of the in-process SolcStandardJson compile above. Do not reintroduce a directory-target heuristic.

solc version selection: per-process env var, never the global solc-select use

solc-select use is global and races in a ThreadPool

solc-select use <ver> writes a global version file. With Stage 4 running under a ThreadPoolExecutor (batch mode), concurrent workers stomp each other’s global selection → wrong compiler for the contract → silent miscompile.

Fix — two parts:

  • Set SOLC_VERSION per-process. solc-select reads the SOLC_VERSION env var before consulting its global version file. So set SOLC_VERSION in the per-analysis environment (e.g. per subprocess / per call) and never call solc-select use. This makes compiler selection race-free.
  • Serialize in-process Slither with a module lock. In-process Slither analysis is not thread-safe. Even with per-process solc selection, guard the analysis call with a module-level lock so only one Slither analysis runs at a time. The HTTP fetch / discovery stages still parallelize; only the analyze step serializes.

Etherscan V2 API for BSC

Endpoint + key facts (verified live against BSC)

  • Endpoint: https://api.etherscan.io/v2/api?chainid=56 (chainid 56 = BSC). One unified V2 key works across all chains — Stage 3 is already multi-chain-ready, just change chainid.
  • Free tier: 3 req/s, 100k/day.
  • getsourcecode returns Proxy and Implementation fields (used to follow proxies to their implementation).

result is polymorphic — guard before indexing

On error, the response has status == "0" and result is a STRING (an error message). On success, result is a LIST. Indexing result[0] on an error response throws. Branch on status / type before indexing, never assume a list.

Free-tier entitlement for BSC — confirmed, despite the scare

There was a documented fear (carried in the plan) that Etherscan V2 has no free tier for BSC. This was wrong in practice — the user’s free Etherscan key DID work for BSC; entitlement was confirmed live during validation. Standalone bscscan.com keys are deprecated — use the unified Etherscan V2 key, not a legacy BscScan key.

DeFiLlama: BSC TVL lives under chainTvls["Binance"]

BSC is "Binance", never "BSC" — and chainTvls.Binance ≠ global tvl

A protocol’s BSC TVL is chainTvls["Binance"] (the chain key is "Binance", never "BSC" — same gotcha as the discovery funnel, see funnel-from-raw-defillama-scan). This is distinct from the global tvl field, which sums all chains. Filter and score on chainTvls["Binance"], not global tvl — otherwise a multi-chain protocol with a tiny BSC port gets scored on its huge global TVL and wrongly included (or, if you filter the other way, wrongly excluded).

Slither JSON output shape

No code-snippet field; non-zero exit on BOTH failure and findings

  • No code snippet in the JSON. Slither’s JSON gives you filename + lines[] for each finding but not the source text. To populate the code_snippet CSV column, read the source lines yourself from the materialized file using filename + lines[].
  • Exit code is ambiguous. Slither exits non-zero on BOTH (a) compile failure AND (b) success-with-findings. You cannot branch on exit code to tell “broke” from “found bugs.” Parse the JSON and branch on its success flag, not the process exit code.

Docker on Apple Silicon: force linux/amd64

solc-select ships x86_64 solc only → arm64 image silently finds 0

solc-select downloads x86_64-only solc binaries. On Apple Silicon, an arm64 Docker image cannot exec an x86_64 solc → compile silently fails → 0 findings, exit looks fine. Another silent false-negative. Fix: pin the image to amd64 so solc runs under Rosetta:

FROM --platform=linux/amd64 python:3.12-slim

pytest + uv flat layout

Package import needs an explicit pythonpath

With the uv flat layout (package at repo root, not under src/), pytest can’t import the package by default. Add to pyproject.toml:

[tool.pytest.ini_options]
pythonpath = ["."]