# Spec: Resolving Unindexed Names & Addresses in the Omnigraph API
**Status:** Draft (review round 1 integrated) · **Owners:** Matt (shrugs), Lightwalker · **Authored with:** ai:claude-code
> Terminology: we use **virtualize** (not "materialize") for synthesizing an entity that has no indexed row, to avoid collision with the indexing module's "materialized" concept.
## Problem
Some ENS names and addresses are **resolvable but not indexed**:
* **Off-chain names** (e.g. `example.cb.id`): a CCIP-Read resolver delegates to an HTTP gateway backed by an arbitrary database. The indexer never observes these records.
* **On-chain but unindexed names** (e.g. 3DNS, `.box`): a CCIP-Read wildcard resolver exists and is wired up on-chain (delegating to the Base/Optimism 3DNS contracts), but protocol-acceleration/unigraph does not yet index *that resolver*. So from the root-anchored resolution walk's perspective these are off-chain names — identical in kind to `cb.id`. (See Research findings below for why the L2 token indexing doesn't change this.)
* **Unobserved addresses**: an address with a valid primary name the indexer never directly saw.
The current API gates protocol resolution behind indexed entities:
* `domain(by: { name: "example.cb.id" }) { resolve { records } }` → `{ domain: null }`, because no `Domain` row exists. The records are real but unreachable.
* `account(by: { address }) { ...primaryNames }` → `{ account: null }` when the address was never observed, even if a valid primary name exists.
**Root cause:** `resolve` (forward) and `primaryNames` (reverse) are *protocol operations keyed on a name / address*. They work for any valid name or address; the index only *accelerates* them. Modeling them as fields on indexed entities makes them unreachable whenever the entity isn't indexed. This is a **correctness issue**, not just DX.
## Constraint (decided)
The clean fix — top-level `Query.resolve(name:)` / `Query.reverse(address:)` decoupled from entities (Option B) — is **rejected**. Resolution must stay reachable through the existing `domain(by:{name})` and `account(by:{address})` DX so the critical `address → primary name → forward resolve` composition keeps working in one query. Preference: **the backend does more work to preserve correctness + DX**.
So both entry points must return a **non-null** result for resolvable-but-unindexed inputs.
## Decision summary
| Area | Decision | Status |
| :----------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------- |
| Account | Always **virtualize** the single `Account` object from the address. No new type. | ✅ Accepted |
| Domain | Add a **`MaybeUnindexedDomain`** concrete type implementing the `Domain` interface, returned by `domain(by:{name})` when a usable resolver exists but no indexed row does. | ✅ Name decided |
| Predicate | "Usable resolver exists" = `UniversalResolver.findResolver` yields a usable resolver (exact match, or ENSIP-10 wildcard for descendants). **Fully index-backed** — no RPC. | ✅ (cb.id case) |
| `registry` on `MaybeUnindexedDomain` | Keep **non-null on the interface**; **virtualize** to the registry that holds the effective (wildcard) resolver. | ✅ Approach decided |
| `Query.resolve` / `Query.reverse` | Not added. | ✅ |
## Account: always virtualize
`Account` is a single object type keyed purely by address — every address is a valid account. Make `account(by:{address})` **non-null**, virtualized from the address:
* Indexed relations (events, owned domains, etc.) return empty when unobserved.
* `primaryNames` runs protocol reverse resolution (UniversalResolver), accelerated when possible.
* No new type; no interface change.
`null` today leaks "no observed activity" as "doesn't exist" — a wrong signal, since an account always conceptually exists. If "has this address been observed" matters, expose it as an explicit boolean, not via null-ness.
**Status: accepted.** Dependency: this is only correct if `primaryNames` delegates to the UniversalResolver for unindexed/off-chain reverse (mirror of the forward path). If reverse is still index-only, that delegation is prerequisite work — see open items.
**Rejected alternative:** pre-indexing every `addr.reverse` / ENSIP-19 variant. It doesn't close the hole — off-chain / unindexed-L2 reverse records still wouldn't be indexed, so protocol reverse is needed anyway.
## Domain: `MaybeUnindexedDomain`
The name carries the semantics: a **usable effective resolver exists** for the name (possibly CCIP-Read), but that does **not** prove the name exists — records may or may not be there. "Maybe" = existence-uncertain; "Unindexed" = certain (the index has no row).
### The predicate is the whole game
An unindexed name is resolvable in exactly one way: `UniversalResolver.findResolver` returns a **usable** resolver for it.
* **On-chain but unindexed (3DNS):** the node has a resolver set directly; `findResolver` exact-matches it.
* **Off-chain wildcard (`example.cb.id`):** no direct entry; `findResolver` walks up, finds `cb.id`'s resolver, and it is usable for the deeper name **only if it supports ENSIP-10** (`resolve()`). Non-wildcard resolvers are rejected for descendants by the UR itself.
So the rule for "return non-null instead of null" is: *did* *`findResolver`* *yield a usable resolver?* This is the necessary condition for the name to resolve to anything.
**Predicate caveat — protocol-bridge wildcards (ENSv2** **`.eth`** **→ ENSv1).** A naive read of the rule over-returns. In ENSv2 the `.eth` registry carries a wildcard resolver (the `ENSv1Resolver` bridge), so `findResolver("doesnotexist.eth")` finds a usable wildcard resolver *at* *`.eth`* (`requiresWildcardSupport = true`) and would wrongly yield a non-null `MaybeUnindexedDomain` — even though `doesnotexist.eth` has **no** resolver on the ENSv1 protocol the bridge delegates to. The bridge resolver proves *a path exists*, not that *the node resolves*. So the walk must **follow the bridge hop** into the backing (ENSv1) namegraph and re-evaluate `findResolver` for the same node; only a resolver that genuinely serves the node post-hop — or a true CCIP-Read wildcard (cb.id) that isn't a same-system bridge — yields non-null. Mechanism TBD: likely the hop variant of the forward namegraph walk (cf. `forwardWalkDisjointNamegraph` / `findResolverWithIndexENSv2` in `protocol-acceleration`). This is the difference between "a wildcard resolver was found" and "this name actually resolves."
### Research findings — DRR, 3DNS, and the existing walk
Investigation of the protocol-acceleration plugin and `get-domain-by-interpreted-name.ts`:
**DRR = Domain-Resolver Relation.** `domainResolverRelation` table (`packages/ensdb-sdk/.../protocol-acceleration.schema.ts`), keyed `(chainId, registryAddress, domainId) → resolver`. Populated by `NewResolver` (ENSv1), `ResolverUpdated` (ENSv2), and `ThreeDNSToken:NewOwner` (L2). Records a resolver at **any** node depth (TLD → leaf). Absence of a row = no resolver (unset is a delete; `resolver` is never `zeroAddress`). **No** wildcard/CCIP/ENSIP-10 flag is stored — wildcard-need is inferred at query time from depth (`requiresWildcardSupport = active.depth < path.length`). The `supportsInterface` sync-availability we want is the separate [#1991](https://github.com/namehash/ensnode/issues/1991) work.
**3DNS reality.** ThreeDNSToken on Base + Optimism are datasources; the protocol-acceleration `ThreeDNSToken:NewOwner` handler writes a DRR for each registered node — but keyed to the **L2 registry account** (`getThisAccountId`), with the protocol-wide hardcoded L2 resolver (`0xf97aac…`). These DRR rows are **disconnected from the mainnet ENS root**: the root-anchored walk follows `domain.subregistry_id` edges and there is **no L1** **`.box`** **resolver/registry edge** in the datasources (unlike Basenames/Lineanames, which *do* have indexed L1 bridged resolvers). The L1 CCIP-Read wildcard resolver for `.box` exists and is wired up on-chain, but protocol-acceleration/unigraph doesn't index it yet — so `getDomainIdByInterpretedName("foo.box")` returns `null` today. **Conclusion (per Matt): unindexed 3DNS names are off-chain names** — same kind as `cb.id`, resolvable via UR/CCIP-Read, and they'll flow through the identical `MaybeUnindexedDomain` path once the L1 resolver is indexed (or via the non-accelerated UR fallback).
**The bridge-hop logic already exists.** `domain(by:{name})` is backed by `getDomainIdByInterpretedName` → `forwardWalkNamegraph` (`get-domain-by-interpreted-name.ts`), which already:
* returns the exact indexed leaf when it has a resolver;
* follows **Bridged Resolvers** (Basenames/Lineanames L1) by recursing into the target registry with the remaining path;
* follows **ENSv1Resolver / ENSv2Resolver fallbacks** across disjoint namegraphs (`MAX_HOP_DEPTH = 3`);
* **returns** **`null`** **when there is no exact leaf**, even if an ancestor has a wildcard resolver.
So the `doesnotexist.eth` over-return fear (caveat above / open item #5) is **already handled**: that name hits the `.eth` ENSv1Resolver, falls back into the ENSv1 namegraph, finds no leaf, returns `null`. The function does not over-return today.
**Where** **`MaybeUnindexedDomain`** **slots in.** Exactly at the final `return exact ? deepest.domainId : null` in `forwardWalkNamegraph` (the no-exact-leaf fallthrough). When there's no exact leaf **and** `deepestResolver` exists, sits above the leaf (`depth < path.length`), and is **not** a Bridged/ENSv1/ENSv2 resolver (i.e. a genuine terminal wildcard/off-chain resolver like `cb.id`'s or `.box`'s), return a virtualized `MaybeUnindexedDomain` id = `(deepestResolver.registryId, node)` instead of `null`. The existing bridge/fallback branches already strip the false-positives, so this fires only for true terminal wildcard resolvers.
**Caveat — index-residency of the terminal resolver.** The accelerated predicate fires only if the terminal wildcard resolver is itself in a DRR. Today that holds for resolvers set via observed `NewResolver`/`ResolverUpdated` events on indexed registries; it does **not** yet hold for `.box`'s L1 resolver or for `cb.id` (not yet indexed). Until those L1 resolvers are indexed, the accelerated walk returns `null` for them and only the non-accelerated UR path resolves — so virtualizing them requires either indexing the L1 resolver or falling through to UR in the `domain()` resolver.
**Fully index-backed (no RPC):** the ancestor resolver lookups already run through index-accelerated `find-resolver.ts` (`protocol-acceleration`), and `supportsInterface(ENSIP-10)` is sync-available on the indexed resolver object — so the predicate needs no RPC on the `domain()` path for the off-chain-wildcard (indexed-ancestor) case like `cb.id`. This sync-availability lands with [namehash/ensnode#1991](https://github.com/namehash/ensnode/issues/1991) — until then the flag isn't index-resident.
> Caveat — on-chain-but-unindexed registries (3DNS today): if the registry holding the resolver isn't indexed, there is no DomainResolver relation (DRR) for index-accelerated `find-resolver` to use, so the predicate can't be satisfied from the index. Those names are only reachable via UR/RPC. Whether a DRR is producible for them is an open confirmation item.
### Why this dissolves the wildcard-false-positive fear
For `sub1.sub2.example.eth`: ordinary 2LDs use the standard PublicResolver, which does **not** support ENSIP-10. `findResolver` finds `example.eth`'s resolver but it's not usable for the deeper name → resolution fails → `domain()` correctly stays `null`. The "9 of 10 don't exist" names are excluded for free. Non-null results only appear under genuinely wildcard-capable ancestors (cb.id, uni.eth, …) — exactly the interesting names.
Residual false-positive: a wildcard resolver exists but returns nothing for *this* label. That's irreducibly a "maybe" (only resolving reveals the truth) — which is exactly what `MaybeUnindexedDomain` communicates. Don't try to eliminate it; doing so would require a full resolve inside `domain()` even when the caller didn't select `resolve` (expensive, couples existence to records).
**New semantics:** `domain(by:{name})` is non-null ⟺ "indexed, OR has a usable resolver." Cheaper, and *more* correct than today (today a wildcard subname with real records wrongly returns null).
### Interface: virtualize `registry`, keep the contract intact
The only hard non-null blocker on the `Domain` interface is `registry`. Resolution: **keep it non-null on the interface and virtualize it**, rather than relocating it to ENSv1/ENSv2 or making it nullable.
Rationale (per review): GraphQL has no sub-interfaces, so relocating `registry` off the shared interface would force every client to write `... on ENSv1Domain { registry { … } } ... on ENSv2Domain { registry { … } }` instead of a single `... on IndexedDomain { registry { … } }`. Keeping `registry` in the `Domain` interface preserves the one-fragment DX.
Virtualized value: the registry that holds the effective (wildcard) resolver — i.e. the ancestor's registry (e.g. `cb.id`'s registry for `example.cb.id`). **Not strictly equivalent** (it's the ancestor's registry, not this node's own), but the next-best truthful value. (`registry`-nullable remains a lighter fallback if the ancestor-registry semantics prove misleading.)
Consequently the virtual `DomainId`:
```
MaybeUnindexedDomain.id = (wildcardResolverRegistryId, node)
```
Remaining interface fields virtualize cleanly:
* `resolver` (non-null `DomainResolver`) — the effective resolver `findResolver` returned (`DomainResolver.effective` already exists). Real, not fabricated.
* `label` from the name · `parent` → nearest indexed ancestor (cb.id) or null · `canonical`/`owner`/`subregistry` → null · `registration(s)`/`subdomains`/`events` → empty (can't enumerate `*.cb.id` anyway) · `resolve` → the point.
### Client-side caching impact (`cache-exchange.ts`)
Adding `MaybeUnindexedDomain` touches the Omnigraph client's normalized cache (`packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts`, urql graphcache):
* graphcache keys entities by `id`; `MaybeUnindexedDomain` must be keyed by its virtual `DomainId` `(wildcardResolverRegistryId, node)` so repeated references dedupe correctly.
* the by-id local lookup resolver (`by-id-lookup-resolvers`) needs to recognize the new type so cache-only reads of `domain(by:{id})` resolve it.
* Open: confirm a virtualized `MaybeUnindexedDomain` returned by `domain(by:{name})` normalizes consistently with one fetched by `id`, and that local connection/bigint resolvers don't choke on the empty/virtual fields.
## Open items
1. **Index the L1 off-chain wildcard resolvers** — `.box` (3DNS) and `cb.id` have on-chain CCIP-Read resolvers that protocol-acceleration/unigraph doesn't index yet. The DRR model already supports them (any-depth resolver rows); they just need to be observed (e.g. the L1 `.box` `NewResolver`, linked into the root namegraph). Until then the accelerated `MaybeUnindexedDomain` predicate returns `null` for them and only the non-accelerated UR path resolves. *Decision: index them, or fall through to UR in* *`domain()`?* (3DNS = off-chain confirmed; the L2 token DRR rows are keyed to the L2 registry and don't connect to the root walk.)
2. **Reverse = UR-backed?** — confirm `primaryNames` delegates to the UniversalResolver for unindexed/off-chain reverse (forward already does, per `forward-resolution.ts`). Prerequisite for the Account decision being fully correct. *Needs confirmation.*
3. **cache-exchange design** — settle graphcache keying + local resolvers for `MaybeUnindexedDomain` (see section above). *Needs discussion.*
4. **registry virtualization semantics** — is returning the ancestor (wildcard-resolver) registry acceptable, or do we prefer nullable-`registry` specifically for the virtual case? (Default: virtualize.)
5. **Predicate bridge-hop** — *largely resolved by existing code.* `forwardWalkNamegraph` already follows Bridged/ENSv1/ENSv2 hops and returns `null` for `doesnotexist.eth`. `MaybeUnindexedDomain` slots into its no-exact-leaf fallthrough, firing only on a terminal non-bridge wildcard resolver above the leaf. Remaining work: implement that branch + return the virtual id. *Design known.*
6. **supportsInterface index-residency ([#1991](https://github.com/namehash/ensnode/issues/1991))** — the no-RPC predicate depends on an index-resident `resolver.extended` flag. *Plan agreed — see Appendix (`extended`* *only;* *`underlying`* *deferred). Ready to implement.*
## Resolved (this round)
* Terminology: "materialize" → "virtualize" (avoid indexing-module overload).
* Account: always virtualize — accepted.
* Name: `MaybeUnindexedDomain`.
* `registry`: keep non-null on interface, virtualize to wildcard-resolver registry.
* `id` = `(wildcardResolverRegistryId, node)`.
* Predicate is index-backed; `supportsInterface` sync on the resolver object → no RPC (cb.id case).
***
## Appendix — #1991 implementation plan (agreed; `extended` only, `underlying` deferred)
Goal: make `supportsInterface(IExtendedResolver)` sync-available from the index so the
`MaybeUnindexedDomain` predicate (and `findResolver`) runs fully from index — no query-time RPC.
Approach: upsert a `resolver` row wherever a resolver is *referenced* (not just where it self-emits),
and compute `extended` once per resolver via a single cached RPC at first sight.
Scope: **`extended`** **flag only.** The broader #1991 ideas (removing manual bridged-resolver config by
indexing bridge metadata, and recursive `underlying {}` resolver entries) are **deferred** — not part
of this change.
### 1. Schema — add `extended` (`packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts`)
```ts
export const resolver = onchainTable("resolvers", (t) => ({
id: t.text().primaryKey().$type<ResolverId>(),
chainId: t.int8({ mode: "number" }).notNull().$type<ChainId>(),
address: t.hex().notNull().$type<Address>(),
// ENSIP-10 IExtendedResolver support, fetched once via supportsInterface(0x9061b923) at first sight.
extended: t.boolean().notNull().default(false),
}), (t) => ({ byId: uniqueIndex().on(t.chainId, t.address) }));
```
### 2. Helper — `upsertResolver` (`apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts`)
Reuse the existing helper — `isExtendedResolver({ publicClient, address })`
(`packages/ensnode-sdk/src/rpc/is-extended-resolver.ts`, exported from `@ensnode/ensnode-sdk/internal`).
It wraps EIP-165 `supportsInterface(0x9061b923)`, accepts the Ponder `context.client` as `publicClient`,
and already returns `false` on revert/non-165/EOA. No new RPC helper needed.
```ts
import { type AccountId, makeResolverId } from "enssdk";
import { isExtendedResolver } from "@ensnode/ensnode-sdk/internal";
/** Ensure a Resolver row exists; compute `extended` exactly once (single cached RPC) on first sight. */
export async function upsertResolver(context: IndexingEngineContext, r: AccountId) {
const id = makeResolverId(r);
const existing = await context.ensDb.find(ensIndexerSchema.resolver, { id }); // PK read, cheap
if (existing) return existing; // already known → no RPC
const extended = await isExtendedResolver({ publicClient: context.client, address: r.address });
await context.ensDb
.insert(ensIndexerSchema.resolver)
.values({ id, ...r, extended })
.onConflictDoNothing();
return { id, ...r, extended };
}
```
Route the existing record-event path through it (path **c**): in `ensureResolverAndRecords`, replace
the bare `insert(resolver).onConflictDoNothing()` with `await upsertResolver(context, resolver)`.
### 3. Cover the DRR-only resolvers (paths **a**, **b**, 3DNS) in one place
Fold the upsert into `ensureDomainResolverRelation` so ENSv1 `NewResolver`, ENSv2 `ResolverUpdated`,
and `ThreeDNSToken:NewOwner` all get a `resolver` row + `extended` for free (it already branches on
zeroAddress):
```ts
export async function ensureDomainResolverRelation(context, registry: AccountId, domainId, resolver) {
if (isAddressEqual(zeroAddress, resolver)) {
await context.ensDb.delete(ensIndexerSchema.domainResolverRelation, { ...registry, domainId });
return;
}
// resolver lives on the registry's chain
await upsertResolver(context, { chainId: registry.chainId, address: resolver }); // <-- new
await context.ensDb
.insert(ensIndexerSchema.domainResolverRelation)
.values({ ...registry, domainId, resolver })
.onConflictDoUpdate({ resolver });
}
```
Net: every actively-assigned resolver — including a CCIP `.box`/`cb.id` resolver that never emits
record events — now has a row with a correct `extended` flag. Two touch points
(`ensureDomainResolverRelation`, `ensureResolverAndRecords`) cover all of a/b/c + 3DNS.
### 4. Consume `extended` — make `findResolver` wildcard-correct from index
ENSv1 (`find-resolver.ts findResolverWithIndexENSv1`): pull `extended` via the DRR→resolver relation
and, among the hierarchy-sorted records, select the deepest **usable** one — exact match, or an
ancestor whose resolver is `extended`:
```ts
const records = await ensDb.query.domainResolverRelation.findMany({
where: ...,
with: { resolver: { columns: { extended: true } } },
});
// ...existing sort...
const active = records.find((r) => domainIds.indexOf(r.domainId) === 0 || r.resolver?.extended === true);
if (!active) return NULL_RESULT;
```
ENSv2 (`forwardWalkDisjointNamegraph`): the recursive CTE already `LEFT JOIN`s DRR→resolver; add
`resolver.extended` to the selected columns + `WalkResultRow`, then in `forwardWalkNamegraph` /
`findResolverWithIndexENSv2` pick `rows.find(r => hasResolver(r) && (r.depth === path.length || r.extended))`.
This is the moment the wildcard predicate becomes exact: an ancestor resolver is only usable for a
descendant if it's `extended`. Combined with the existing Bridged/ENSv1Resolver/ENSv2Resolver hop
logic in `forwardWalkNamegraph`, the `MaybeUnindexedDomain` predicate now runs entirely from index.
### Caveats / notes
* **One RPC per distinct resolver address**, deduped by the `find`-then-skip guard and cached by
Ponder — bounded by resolver count, not event count.
* **First-seen immutability:** `extended` is computed once at the first-seen block and never
recomputed. `supportsInterface` is effectively immutable per deployed contract, so this is safe
except for the rare assign-before-deploy ordering (resolver address had no code at first sight →
stuck `false`). Add a recompute-on-assignment only if that bites.
* **Necessary, not sufficient for** **`.box`/`cb.id`:** `extended` makes the predicate *decidable* from
index; the L1 off-chain resolver still has to be DRR-linked under the root namegraph (open item #1)
for the accelerated walk to reach it.
* **Out of scope here** (broader #1991): removing manual bridged-resolver config by indexing bridge
metadata, and recursive `underlying {}` resolver entries — follow-on.
This is a collaborative document on Proof. To read or edit it programmatically:
Accept: application/json to get content + API links.Accept: text/markdown to get raw markdown.GET /api/agent/62hz5t0w/snapshotPOST /api/agent/62hz5t0w/edit/v2POST /api/agent/62hz5t0w/opsPOST /api/bridge/report_bug (or /d/62hz5t0w/bridge/report_bug)Auth: If this URL includes ?token=, send it as Authorization: Bearer <token>.