Adversarial on-chain forensics for EVM tokens using raw RPC: sell simulation via eth_call state
overrides, LP-custody classification from bytecode, funder-tree holder clustering and wash-ring
detection.
Most token-safety tooling (honeypot checkers, holder-graph tools, security scanners) only covers the dozen or so largest chains. On a new chain they either return nothing or return an empty result that looks like a clean bill of health. This toolkit and its method were developed against a new Arbitrum Orbit L2 that none of the mainstream scanners indexed, where the question "can a holder actually sell this token?" had no tool that could answer it.
The approach is to fall back to primitives every EVM chain has (eth_call, eth_getCode,
eth_getStorageAt, eth_getLogs), treat third-party indexers as leads rather than truth, and
make every conclusion carry its evidence level.
- Verification over reporting. Every load-bearing claim is tagged
VERIFIED,REPORTED,PLAUSIBLEorREFUTED, and a missing or empty data source maps to UNKNOWN, never SAFE. - Simulation as evidence. Sellability is proven by simulating a real holder's sell against
the live pool inside one
eth_call, with nothing broadcast, instead of trusting a scanner'ssell_tax: 0field. - Data modelling of ownership. A flat holder list is collapsed into clusters by funding source, and supply sitting in pools, lockers and burn addresses is separated from supply owned by people before any concentration number is read.
- Governance of the method itself. A gated, phased process (pin the real contract first, quick-kill checks in a fixed order, a refutation pass before anything is published) plus a written log of the failure modes that produced wrong answers and how each was fixed.
- Honest limits. Reports state what could not be checked (bridge-funded wallets, point-in-time snapshots, v4 settlement) rather than implying certainty.
flowchart LR
subgraph sources [Data sources]
RPC[(EVM JSON-RPC)]
IDX[GeckoTerminal / DexScreener]
GP[GoPlus]
BS[Blockscout explorer]
end
P0[Phase 0: pin the real contract] --> H[harvest.py: market and security snapshot]
H --> S[simulate.py: sell simulation]
S --> F[forensics.py: holder clusters and LP custody]
F --> SO[social.py: propagation metrics]
SO --> V{Refutation pass}
V --> R[report.py: HTML report]
RPC --> H
RPC --> S
RPC --> F
IDX --> H
IDX --> S
GP --> H
GP --> F
BS --> F
How the sell simulation works (the core technique):
sequenceDiagram
participant C as eth_call (from holder)
participant P as Uniswap V3 pool
participant Q as quote token (e.g. WETH)
participant H as holder address (code overridden)
participant T as token under audit
C->>P: swap(recipient = holder, exact input)
P->>Q: transfer quote asset to holder
P->>H: uniswapV3SwapCallback(amount0, amount1)
H->>T: transfer(pool, owed) [injected 69-byte callback]
P->>T: balanceOf(pool): reverts "IIA" if the pool received less than owed
P-->>C: (amount0, amount1) = the pool's own settlement
The holder's code is replaced for the duration of the call, but its storage is not, so the token still sees the holder's real balance. A settled V3 swap is positive proof that no transfer tax applied on that path, because the pool re-checks its own balance after the callback.
| File | What it does |
|---|---|
METHOD.md |
The phased method, prime directives, observed patterns and failure modes |
scripts/harvest.py |
Deterministic snapshot: all pools, aggregate liquidity, V/L turnover, flow, OHLCV, GoPlus, bytecode identity, EIP-1967 proxy slots |
scripts/simulate.py |
Sell simulation: ERC-20 transfer probe, laddered V3 swaps ($100 to $50k), ERC-20 fee-on-transfer measurement for v4-only tokens |
scripts/forensics.py |
Holder identity (contract vs EOA vs burn), first-funder tracing, cluster collapse, LP-holder custody classification |
scripts/social.py |
Handle resolution, free Telegram counts, narrative share, copy-paste clustering, top-poster (bot) share over a scraped post file |
scripts/report.py |
Renders a standalone HTML report from a JSON spec |
scripts/chains.py |
Chain registry and EVM_* environment overrides |
references/ |
Quick-kill thresholds, endpoint map and RPC-only fallbacks, report format |
Not every step in the method is automated. What the scripts do today, and what is still done by
hand following METHOD.md:
| Step | Status |
|---|---|
| Aggregate liquidity, V/L turnover, txs-per-buyer | Scripted (harvest.py) |
EIP-1967 proxy check, contract-vs-EOA via eth_getCode |
Scripted (harvest.py, forensics.py) |
| Sell simulation (V3 swap ladder, ERC-20 fee-on-transfer delta) | Scripted (simulate.py) |
| First-funder tracing and cluster collapse (one hop) | Scripted (forensics.py) |
| LP holder classification (burned / contract / naked EOA) | Scripted (forensics.py) |
| Copy-paste rings and bot share in social data | Scripted (social.py) |
| PUSH4 selector scan to prove a locker cannot withdraw | Manual (method in METHOD.md) |
| Transfer-graph SCC / short-cycle wash-ring detection | Manual |
| Launch-block (B0 to B0+3) sniper and bundler analysis | Manual |
Multi-hop funder recursion, multi-block anvil fork sweeps |
Manual |
| Phase 0 contract pinning and impostor census | Manual |
- Tri-state verdicts, and tooling failure is never a pass. A probe that fails for a tooling
reason (no V3 pool, RPC refuses overrides, holder too small) reports UNKNOWN. Only an executed
swap earns
SELLABLE. This is enforced insimulate.pyand in howharvest.pyflags empty GoPlus results. - State overrides instead of a local fork. Replacing code at a real holder's address inside
a single
eth_callneeds no node, no fork and no private key, and works on any RPC that supports overrides.anvilforks are reserved for multi-block checks. - Measure inside one call when there is no tracer. Public L2 RPCs rarely expose
debug_traceCall, so the fee-on-transfer probe injects a small contract that readsbalanceOf(pool), transfers, and reads again, all in one call against one state. - Size probes to the real balance. An oversized transfer reverts for insufficient balance, which looks exactly like a blacklist. Every probe is capped at the impersonated holder's actual balance, so the simulator does not invent honeypots.
- Say what the sell pays out in. A swap that settles into another illiquid token proves the token moves, not that it converts to money, so the verdict names the quote asset.
- Liquidity is the sum of all pools, and the counter-asset is the truth. Main-pool-only numbers produced false alarms, and aggregator USD figures can be inflated by one-sided positions. The method reads the WETH or stablecoin side of the pool directly.
- Standard library only. No dependencies (
urllib,json), so it runs anywhere Python does. Function selectors are hardcoded because keccak is not in the standard library.
Requires Python 3.11 or newer. No third-party packages.
Offline checks (no network):
python3 -m py_compile scripts/*.py
python3 -m unittest discover -s tests -v
python3 scripts/report.py examples/report-spec.example.json /tmp/example-report.htmlThe unit tests run the simulator's hand-assembled bytecode (the swap callback and the fee-on-transfer probe) through a minimal EVM interpreter against a fake ERC-20, and check calldata encoding, chain config, report rendering and social metrics.
Against a live chain, configure it in scripts/chains.py or with environment variables, then
run the phases in order. The address below is illustrative:
export EVM_RPC_URL=https://<YOUR_RPC_URL> # must support eth_call state overrides for simulate.py
export EVM_CHAIN_ID=<CHAIN_ID> # optional, enables GoPlus
export EVM_BLOCKSCOUT_URL=https://<EXPLORER_HOST> # required by forensics.py
export EVM_GT_NETWORK=<GECKOTERMINAL_SLUG> # defaults to the <chain> argument
export EVM_V4_DEX_LABELS=<DEX_ID>,... # optional, extra dex ids to treat as Uniswap v4
export EVM_MONEY_QUOTES=<SYMBOL>,... # optional, extra quote symbols that count as money
python3 scripts/harvest.py mychain 0x000000000000000000000000000000000000dEaD out
python3 scripts/simulate.py mychain 0x000000000000000000000000000000000000dEaD --json out/sim.json
python3 scripts/forensics.py mychain 0x000000000000000000000000000000000000dEaD 40
python3 scripts/social.py handles mychain 0x000000000000000000000000000000000000dEaDEvery script prints its usage with --help.
- Personal research toolkit, not a product. Not packaged, not deployed, no users other than the author. It was used in August 2026 to audit a batch of 16 live tokens on one Orbit L2.
- The published version has not been re-run against a live chain. For publication, the
chain-specific configuration was moved into
scripts/chains.pyand token-specific material was removed. The offline tests cover the bytecode, encoding, config, report and social-metrics logic; the network paths are exercised only by a live run. - Uniswap v4 settlement is not simulated. For v4-only tokens the simulator falls back to the ERC-20 layer and reports pool settlement as UNVERIFIED.
- Funder traces stop at bridges and exchanges, and wallet-age heuristics are weak on a young chain where every wallet is new. Reports say so rather than implying a clean result.
- Every check is a point-in-time snapshot. An owner can arm a trap after the check.
- Thresholds are triage triggers from one chain, not calibrated verdicts.
- Social scraping depends on a paid third-party service and is not included.
- The output is research, not investment advice.
Built by directing AI coding agents (Claude Code / Cursor). Wol wrote the specs, architecture decisions, gates and reviews.
MIT. See LICENSE.