How the graph learns who controls a token — and stamps each finding with a severity. ~13 min.
Every admin cell in L19, every grant.Severity in L13's admin_risk math, every signer fan-out
in L22 — they all start with one upstream question: who actually controls this token? On the EVM there's no
single answer. Control hides behind a dozen incompatible patterns. This lesson is the subsystem that hunts them all down
and tags each one with a severity — the birthplace of the ADMIN_CTRL edge.
AccessControl (getRoleMember,
DEFAULT_ADMIN_ROLE), another is plain Ownable (owner()), a proxy keeps its admin in the
EIP-1967 slot, USDC has a masterMinter, DAI uses Maker DSAuth wards, stETH uses Aragon ACL, and the
whole thing usually sits behind a Gnosis Safe. No interface unifies them. So the discoverer doesn't guess — it
runs a battery of independent probes and unions the results.
DiscoverFocusTokenAdmins)For a focus token, focus_token_admin_probes.go fires 17 independent probes, each
targeting one admin pattern. The cardinal rule: a revert or network error on one probe never aborts the rest
(fail-loop — the same discipline as L24/L8). It mirrors the Python batch pipeline's _discover_admin_controls so
the realtime indexer matches batch coverage.
| Probe | Pattern | EVM mechanism |
|---|---|---|
| 1 | OZ AccessControl | the 4-stage ladder below |
| 2 | EIP-1967 proxy admin | eth_getStorageAt on the admin slot |
| 3–4 | Ownable + getters | owner(), masterMinter(), pauser(), blacklister(), governance()… |
| 5–6 | Maker DSAuth / Aragon ACL | wards (DAI/USDS), ACL permissions (Lido stETH) |
| 7–14 | protocol-specific | SmartCoin operators (EURCV), FiatToken minters (USDC), Rocket Pool storage, USD0 ERC-7201 slot… |
| 15–16 | proxy/Safe walks | EIP-1967 admin chain (≤5 hops), Gnosis Safe-of-Safes owner BFS (≤depth 3) |
| 17 | Chainlink | aggregator() + accessController() |
Each probe yields FocusAdminFinding{Account, RoleHash, RoleName, Severity, SeverityWeight, Source}, and a final
dedup collapses cross-probe duplicates (e.g. the EIP-1967 admin found by both probe 2 and probe 15). The
IsAdminRole filter drops user-status roles so a DEPOSIT_VAULT_ADMIN_ROLE doesn't masquerade as control.
Probe 1 is itself a four-stage ladder (probeAdminRoles), ordered cheapest → most expensive — because every
rung is RPC, and RPC is the budget (L24's central tension):
getRoleMember(role, i) for each known role. cost: cheap, exactaggregate3 of getRoleMemberCount across all probed roles. If any role has members, treat the contract as AccessControl-compliant (a gate on DEFAULT_ADMIN_ROLE alone is too narrow — some contracts answer only to OPERATOR/MINTER). cost: 2 batched RPCs totalhasRole/grantRole but NOT Enumerable (no getRoleMember), scan Blockscout's historical RoleGranted logs, then verify each still holds with a live hasRole() call. cost: 1 getLogs + ≤1 hasRole per roleeth_calls per token (one getRoleMemberCount per role, then one
getRoleMember per member). The Multicall3 rewrite does it in exactly two aggregate3 RPCs
regardless of role count: one count batch (capped at maxMembersPerRole = 50, zero-count roles discarded), one
member batch. The probed-role list is deliberately bounded — a curated set hashed once at init —
so per-contract cost stays predictable. (admin_roles.go batchRoleMemberCounts / batchRoleMembers.)
Here's the connection that closes the loop with L13. Each discovered role isn't just an (account, role) pair — discovery classifies its severity right away, via the very same table you met in the risk math:
// admin_roles.go — severity stamped from the shared classifier name, severity, weight = types.ClassifyRole(roleHash) // → "MINTER_ROLE", critical, 1.0 // DEFAULT_ADMIN_ROLE (bytes32(0)) and minters classify as CRITICAL
The probed-role list is grouped by exactly the severity tiers from L13:
| Tier | Weight | Example roles |
|---|---|---|
| critical | 1.0 | DEFAULT_ADMIN_ROLE, MINTER_ROLE, MASTER_MINTER_ROLE, UPGRADER_ROLE |
| high | 0.7 | GUARDIAN_ROLE, POOL_ADMIN_ROLE, RISK_ADMIN_ROLE, BRIDGE_ROLE |
| moderate | 0.3 | PAUSER_ROLE, BLACKLISTER_ROLE, FREEZER_ROLE |
| low | 0.1 | OPERATOR_ROLE, CURATOR_ROLE, ALLOCATOR_ROLE |
admin_risk multiplied an attack channel by a severity weight (minter ⇒ critical ⇒ 100% of supply). You
now see the other end: discovery is what decides a given key holds MINTER_ROLE and stamps it
critical / 1.0. The discoverer and the risk engine share one types.ClassifyRole source — so the
severity the math consumes is the severity discovery wrote. No translation layer, no drift.
The findings become ADMIN_CTRL edges (account → token) carrying role name, capabilities, and severity. Trace
the whole arc you've now learned end to end:
discover admins (this lesson) → ADMIN_CTRL edge {role, severity}
→ L19/L20 cell construction reads adminCtrl[target] (classifyAdminRole → roleClass/outcome)
→ L13 admin_risk multiplies channel × severityWeight
→ L22 if the admin is a Safe, fan out to signers
→ the AT_RISK edge
extractable = 0 with discovery_gap = true — the system's way of
saying "we found a nominal admin but couldn't prove real control." That flag is, quite literally, this subsystem
admitting the edge of its own coverage.
MINTER_ROLE. What severity / weight does discovery stamp?types.ClassifyRole — the same source L13's math reads — and recite the four tiers.ADMIN_CTRL edge is the seam,
and types.ClassifyRole is the shared vocabulary across it.
Grounded in: pkg/enrichment/focus_token_admin_probes.go (DiscoverFocusTokenAdmins — the 17-probe battery, fail-independent, mirrors batch _discover_admin_controls, IsAdminRole filter, dedup), pkg/enrichment/admin_roles.go (probeAdminRoles 4-stage ladder, probedRoleNames tiered list, batchRoleMemberCounts/batchRoleMembers Multicall3 = 2 RPCs, maxMembersPerRole=50, severity via types.ClassifyRole), pkg/types role-severity table (shared with L13). Verify against source — the code is the truth.