Skip to content

ENS Omnigraph Core Concepts

The ENS Omnigraph API abstracts away most of ENS’s protocol complexity, but a handful of concepts explain how it presents ENSv1 and ENSv2 through a single unified schema. You don’t need these to send your first query, only to understand how the Omnigraph represents the state of the ENS protocol.

A Namegraph is the native onchain data model of ENSv2: it represents names not as a flat mapping of namehashes in a Nametable (as in ENSv1), but as a graph of Registry → Domain → Registry → Domain → …. This graph may be cyclic, and within the ENSv2 protocol an unbounded number of disjoint (not connected) Namegraphs will exist. Within the unified ENS protocol (v1+v2) there will exist many Namegraphs, at the very least those headed by the ENSv1 Root Registry, Basenames, Lineanames, 3DNS, and the ENSv2 Root Registry.

Because ENSv1 names do not actually have on-chain Subregistries, the Unigraph represents this relationship with an ENSv1VirtualRegistry.

flowchart TD
    root([ENSv1 Root Registry])
    eth["eth"]
    ethReg([".eth (virtual) Subregistry"])
    vitalik["vitalik.eth"]
    exampleName["example.eth"]
    vitalikReg(["vitalik.eth's (virtual) Subregistry"])
    blog["blog.vitalik.eth"]
    exampleReg(["example.eth's (virtual) Subregistry"])
    sub["sub.example.eth"]

    root --> eth
    eth --> ethReg
    ethReg --> vitalik
    ethReg --> exampleName
    vitalik --> vitalikReg
    vitalikReg --> blog
    exampleName --> exampleReg
    exampleReg --> sub

You navigate the graph by following a Domain to its Subregistry to its child Domains, and so on:

example.gql
query Namegraph {
# reference a Domain by name
domain(by: { name: "eth" }) {
# this is the Registry that "eth" exists within
registry { id contract { chainId address } }
# "eth"'s parent Domain (if any)
parent { id }
# the Subregistry that "eth" declares
subregistry {
domains {
edges {
node {
# get each domain's (beautified) name
canonical { name { beautified } }
}
}
}
}
# Domain.subdomains is short form of Domain.subregistry.domains
subdomains { edges { node { canonical { name { beautified } } } } }
}
}

The Unigraph is the entire collection of these disjoint ENSv2 Namegraphs and multiple ENSv1 Nametables, combined together into a single unified data model using ENS Resolution semantics. Navigating the Unigraph from "eth" down to "vitalik.eth" and beyond looks identical regardless of whether the underlying entities are ENSv1 or ENSv2.

The unigraph plugin in ENSIndexer is what builds this unified model. The Unigraph constructs two Namegraphs, one rooted at the ENSv1 Root Registry and another rooted at the ENSv2 Root Registry. It’s also where multichain coverage lives: Basenames (.base.eth), Lineanames (.linea.eth), and 3DNS names (.box) are all stitched into the ENSv1 Namegraph.

flowchart TD
    root([ENSv1 Root Registry])
    eth["eth"]
    ethReg([.eth Subregistry])
    vitalik["vitalik.eth"]
    baseName["base.eth"]
    vitalikReg([vitalik.eth Subregistry on Ethereum])
    blog["blog.vitalik.eth"]
    baseReg([Basenames Subregistry on Base Chain])
    jesse["jesse.base.eth"]

    root --> eth
    eth --> ethReg
    ethReg --> vitalik
    ethReg --> baseName
    vitalik --> vitalikReg
    vitalikReg --> blog
    baseName --> baseReg
    baseReg --> jesse

The same query shape works for any indexed name regardless of chain or protocol version — here, a Basename on Base:

example.gql
query Basenames {
domain(by: { name: "jesse.base.eth" }) {
canonical { name { interpreted } }
}
}

Once ENSv2 launches, the ENSv2 Namegraph will exist in parallel with the ENSv1 Namegraph. When referencing a Domain by name, the Omnigraph will start at the ENSv2 Root Registry, and traverse the Namegraph to find the appropriate Domain. Once .eth names are reserved in the ENSv2 EthRegistry, then the ENSv2 Domain will be returned, since that’s the Domain that would be referenced during resolution. This is part of why it’s important to reference specific Domains by id; once vitalik has been reserved in the ENSv2 EthRegistry, the ENS protocol considers the ENSv2 Domain (not the ENSv1 Domain) to be the ‘real’ one. That said, after the .eth names are reserved (but before they’re individually migrated), their resolver will be the ENSv1Resolver, forwarding resolution to the ENSv1 Namegraph.

The end result is that there are two Domains considered to be “vitalik.eth”, one in the ENSv1 Namegraph and one in the ENSv2 Namegraph.

example.gql
query ByProtocolVersion {
# before ENSv2 launches: returns ENSv1 vitalik.eth
# after ENSv2 launched: returns ENSv2 vitalik.eth
domain(by: { name: "vitalik.eth" }) { id }
# always returns the protocol-specific version of vitalik.eth
domains(where: { name: { eq: "vitalik.eth" }, version: ENSv1 }) {
edges { node { id } }
}
}

Given that a Domain entity (say, the sub in sub.example.eth) can be reached by infinitely many aliases (for example, sub.other.eth), it becomes important to determine a canonical reference to the Domain — this is the Canonical Name. Canonicality is also connected to nameability within the Unigraph; if an ENSv2 Domain exists on-chain but isn’t eventually connected to the ENSv1 or ENSv2 Root Registry via a series of canonical names, it doesn’t have a Canonical Name!

Within the Omnigraph API the complexity of the Namegraph is reduced, and all Canonical Domains are queryable, searchable, and addressable by said Canonical Name. Domains that are not canonical are still referenceable by id (eg. domain(by: { id: DomainId! })).

Canonical Domains have a Domain.canonical field hosting the canonicality-derived fields such as name, node, depth (i.e. 2 for vitalik.eth), and path from the ENS root.

example.gql
query Canonicality {
domain(by: { name: "vitalik.eth" }) {
canonical {
name {
interpreted # the InterpretedName
beautified # the ENSIP-15 BeautifiedName
}
node # namehash(name)
depth # i.e. 2 for "vitalik.eth"
path { id } # [Domain("eth"), Domain("vitalik.eth")]
}
}
}

Every entity in the Omnigraph has an id — a nominally-typed, stable reference to a specific on-chain entity (DomainId, RegistryId, RegistrationId, etc.).

For Domains, when you already have an id and want to reference the exact same on-chain entity, query it by id: domain(by: { id: "..." }) which is stable across time, even if its Canonical Name could change.

Addressing a Domain by name is a different operation. It’s forward traversal of the unified Namegraph: domain(by: { name: "vitalik.eth" }) walks from the Root Registry (ENSv1 or ENSv2 if defined) → "eth" in that Registry → the Registry that "eth" points at (the EthRegistry) → "vitalik" in that Registry. The Domain returned is whichever on-chain entity (if any) would be identified during Forward Resolution.

These two views are not interchangeable:

  • The id you receive from a name lookup is the stable reference to the on-chain entity that the Namegraph currently resolves "vitalik.eth" to.
  • But "vitalik.eth" is not a stable reference to that entity. The Namegraph can be re-parented or re-aliased — and tomorrow, "vitalik.eth" may resolve to an entirely different on-chain Domain, which may have a different resolver with different records.

Address the exact on-chain entity by id — stable across time:

example.gql
query ById {
domain(by: { id: "..." }) {
canonical { name { interpreted } }
}
}

Address by name to perform forward traversal — you get whichever entity the name resolves to right now:

example.gql
query ByName {
domain(by: { name: "vitalik.eth" }) {
__typename # could be ENSv1Domain or ENSv2Domain
id # vitalik.eth could refer to a different Domain over time, but id is always stable
canonical { name { interpreted } }
}
}

Domain, Registry, and Registration are GraphQL interfaces, with concrete types implementing each:

  • DomainENSv1Domain, ENSv2Domain
  • RegistryENSv1Registry, ENSv1VirtualRegistry, ENSv2Registry
  • RegistrationBaseRegistrarRegistration, NameWrapperRegistration, ThreeDNSRegistration, ENSv2RegistryRegistration, ENSv2RegistryReservation

Shared fields are available unconditionally on the interface. Protocol- or implementation-specific fields are reached via typed inline fragments — ... on ENSv1Domain { rootRegistryOwner }, ... on ENSv2Domain { tokenId }, ... on BaseRegistrarRegistration { wrapped { fuses } }, ... on NameWrapperRegistration { fuses }. The result is a single query that compiles, type-checks, and returns the right fields for whichever concrete type each record turns out to be.

example.gql
query Polymorphism {
domain(by: { name: "vitalik.eth" }) {
__typename
... on ENSv1Domain {
# the owner of the Domain in the ENSv1 Root Registry
rootRegistryOwner { address }
}
... on ENSv2Domain {
# ENSv2 Domains are identified by a `tokenId` within their Registry
tokenId
}
}
}

InterpretedNames and InterpretedLabels everywhere

Section titled “InterpretedNames and InterpretedLabels everywhere”

Every name and label crossing the Omnigraph surface is an Interpreted Name (or Interpreted Label). Each label in an Interpreted Name is either a normalized literal label or an Encoded LabelHash ([abc123…]) when the literal isn’t known or is unnormalized. This eliminates one of the most common ENS UI footguns — unnormalized labels, unhealed hashes, and rendering surprises — at the schema layer, making UI rendering trivial. See terminology for the full definition.

In addition, both provide a beautified variant, where the ENSIP-15 beautified form is rendered. This allows your UI to trivially render the best form of a name or label, without further logic.

example.gql
query InterpretedNames {
domain(by: { name: "vitalik.eth" }) {
label {
interpreted # "vitalik"
beautified # "vitalik"
hash # 0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc
}
canonical {
name {
interpreted # "vitalik.eth"
beautified # "vitalik.eth"
}
}
}
}
Example.tsx
<p>the Label for this domain is {domain.label.beautified}</p>

InterpretedName with Encoded LabelHash Example

Section titled “InterpretedName with Encoded LabelHash Example”

As noted above, an InterpretedName may contain Labels that are Encoded LabelHashes, meaning that the human-readable Label isn’t known or isn’t normalizable. The Omnigraph still supports referencing Domains by these InterpretedNames, like so:

example.gql
query ByInterpretedName {
domain(by: { name: "[af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc].eth" }) {
id # this is the same Domain as "vitalik.eth" above!
canonical {
name {
# in this case, the Omnigraph knows the fully healed Canonical Name of "vitalik.eth"
interpreted # "vitalik.eth"
}
}
}
}

For some labels and names, there exists an ENSIP-15 beautified form, where certain normalized characters are ‘beautified’ into their un-normalized but prettier forms.

For example, the InterpretedName ♾.eth (normalized) would be beautified to ♾️.eth (un-normalized). All name and label fields in the Omnigraph provide a beautified variant which can be used for display.

example.gql
query Beautified {
domain(by: { name: "♾.eth" }) {
canonical {
name {
interpreted # ♾.eth
beautified # ♾️.eth
}
}
}
}
Example.tsx
<p>the Domain's name is {domain.canonical?.name.beautified ?? 'unknown'}</p>

Collection and paginated relationship fields in the schema follow the Relay-spec Connection pattern with edges, pageInfo, and totalCount. Cursor-based pagination is idiomatic in urql, Apollo, Relay, and most modern GraphQL clients — infinite scroll and stable pagination work out of the box, with no per-endpoint plumbing.

example.gql
query RelayConnections {
domain(by: { name: "eth" }) {
subdomains(first: 10, order: { by: NAME }) {
totalCount
pageInfo {
hasNextPage
endCursor
}
edges {
node {
canonical {
name {
interpreted
}
}
}
}
}
}
}

The Omnigraph indexes every onchain Event relevant to ENS and exposes it from the entities each Event relates to:

  • Domain.events — every Event for a specific Domain
  • Resolver.events — every Event emitted by a specific Resolver
  • Account.events — every Event for which an Account is the HCA-aware sender
  • Permissions.events, PermissionsUser.events — Permission grant and revocation history

Each Event carries chain, block, transaction, and log metadata, plus an HCA-aware sender field distinct from the raw tx.from for HCA-mediated transactions.

example.gql
query DomainEvents {
domain(by: { name: "vitalik.eth" }) {
events(first: 5) {
totalCount
edges {
node {
timestamp
transactionHash
}
}
}
}
}

In ENSv2, many contracts (like Registry and PermissionedResolver) have Permissions indicating who can do what on a given resource within the contract. It’s a very flexible system, and the Omnigraph gives developers the power to write the necessary queries to drive UI.

Permissions are modeled as top-level entities. Permissions represents a contract that manages role grants; PermissionsResource is an addressable resource within that contract; PermissionsUser is a specific user’s role bitmap on a specific resource.

Registries, Resolvers, and ENSv2 Domains all expose their Permissions directly (Registry.permissions, Resolver.permissions, ENSv2Domain.permissions), and an Account can be queried for every Permission it’s been granted (Account.permissions, Account.registryPermissions, Account.resolverPermissions). Access-aware UIs — “which Domains can this address manage?”, “who can update this Registry?” — become a single query.

Query an Account for every Permission it’s been granted:

example.gql
query PermissionsByUser($address: Address!) {
account(by: { address: $address }) {
permissions {
edges {
node {
resource
roles
}
}
}
}
}

Address a Permissions entity by the contract that manages it, then walk its resources and the users granted roles on each:

example.gql
query PermissionsByContract($contract: AccountIdInput!) {
permissions(by: { contract: $contract }) {
resources {
edges {
node {
resource
users {
edges {
node {
user { address }
roles
}
}
}
}
}
}
}
}

Start from a Domain by name and read the roles users hold on that Domain’s token (ENSv2 Domains manage Permissions per-token):

example.gql
query DomainPermissions {
domain(by: { name: "vitalik.eth" }) {
... on ENSv2Domain {
permissions {
edges {
node {
user { address }
roles
}
}
}
}
}
}