Lesson 25 · Admin-Role Discovery · Discovery Internals

Finding who can attack

How the graph learns who controls a token — and stamps each finding with a severity. ~13 min.

Builds on: L24 · L13 · L19 Anchor: OZ AccessControl, Ownable, Safe New: the probe battery New: severity at the source

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.

Your anchor: there is no standard for "admin"
You know this from auditing: one token uses OpenZeppelin 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.

1 · The probe battery (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.

ProbePatternEVM mechanism
1OZ AccessControlthe 4-stage ladder below
2EIP-1967 proxy admineth_getStorageAt on the admin slot
3–4Ownable + gettersowner(), masterMinter(), pauser(), blacklister(), governance()
5–6Maker DSAuth / Aragon ACLwards (DAI/USDS), ACL permissions (Lido stETH)
7–14protocol-specificSmartCoin operators (EURCV), FiatToken minters (USDC), Rocket Pool storage, USD0 ERC-7201 slot…
15–16proxy/Safe walksEIP-1967 admin chain (≤5 hops), Gnosis Safe-of-Safes owner BFS (≤depth 3)
17Chainlinkaggregator() + 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.

2 · The OZ AccessControl ladder — cheapest first

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):

1
supportsInterface(IAccessControlEnumerable) — fast path. If yes, enumerate members directly via getRoleMember(role, i) for each known role. cost: cheap, exact
2
Multicall3-batched count probeaggregate3 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 total
3
RoleGranted event scan — for contracts with hasRole/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 role
4
No AccessControl — neither enumerable nor hasRole. The contract doesn't use OZ roles; this probe yields nothing and the other 16 probes carry the load. cost: ~0
The batching win — coverage without bankruptcy
A naive enumeration was ~50 eth_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.)

3 · Severity is assigned at the source

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:

TierWeightExample roles
critical1.0DEFAULT_ADMIN_ROLE, MINTER_ROLE, MASTER_MINTER_ROLE, UPGRADER_ROLE
high0.7GUARDIAN_ROLE, POOL_ADMIN_ROLE, RISK_ADMIN_ROLE, BRIDGE_ROLE
moderate0.3PAUSER_ROLE, BLACKLISTER_ROLE, FREEZER_ROLE
low0.1OPERATOR_ROLE, CURATOR_ROLE, ALLOCATOR_ROLE
This is where L13's numbers are born
In L13, 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.

4 · From finding to edge to cell

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
The honest limit — and why deployer-fallback exists
No battery is complete. When all 17 probes come up empty (an immutable contract, an exotic pattern), discovery falls back to the deployer EOA as a last-resort admin. You met the downstream consequence in L19/L20: those deployer-fallback cells are forced to 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.

Check yourself

1. Why does admin discovery run 17 independent probes instead of one check?
2. A revert on probe 7 (SmartCoin operators) happens. What's the effect on the other probes?
3. The OZ AccessControl ladder is ordered cheapest → most expensive because…
4. Why does stage 2 treat a contract as AccessControl-compliant if ANY probed role has members, not just DEFAULT_ADMIN_ROLE?
5. The Multicall3 rewrite reduced ~50 eth_calls per token to…
6. Where does the severity on a discovered role come from?
7. A key is found holding MINTER_ROLE. What severity / weight does discovery stamp?
8. All 17 probes find nothing. The system falls back to the deployer EOA. What does L19/L20 then do with that cell?
↳ Ask your teacher
Try: "Show me types.ClassifyRole and the full role→severity table." · "How does the Gnosis Safe owner BFS (probe 16) bound its depth-3 walk?" · "What are 'shared delegated modules' in probe 1?" · "How are these findings written as ADMIN_CTRL edges (which stage)?" · "What's the EIP-1967 admin chain walk (probe 15) for?"

What you can now do

The admin attribution loop is now whole
You've now seen both ends: discovery finds who controls a token and stamps the severity (here), and the risk engine consumes that to build cells and dollar figures (L13/L19/L20/L22). The 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.