Skip to content

feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2296

Open
shrugs wants to merge 67 commits into
mainfrom
efp-omnigraph
Open

feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2296
shrugs wants to merge 67 commits into
mainfrom
efp-omnigraph

Conversation

@shrugs

@shrugs shrugs commented Jun 12, 2026

Copy link
Copy Markdown
Member

Brings the EFP feature (originally PR #2224, Quantumlyy/efp-plugin) onto a namehash/ensnode branch, with the Omnigraph API surface refactored.

What it does

  • ENSIndexer EFP plugin — indexes EFP list NFTs, records, tags, and account metadata across Base/Optimism/Ethereum into ENSDb's efp_* tables (enable via efp in PLUGINS).
  • Omnigraph API — exposes EFP under a single nullable efp root field:
    • Query.efp (protocol-rooted): list(by:), lists(where:), listRecords(where:) (each record exposing its owning list), cursor-paginated with owner/user/manager and recordData filters.
    • Account.efp (account-rooted): validated primaryList (EFP two-step user-role check), the social graph following / followers, the lists it is the user of, and account metadata(key:) / metadatas.
    • EfpListRecord.account links a record to the Account it points at.
    • Both efp fields are null when the indexer lacks the efp plugin.

Social graph (Account.efp.following / Account.efp.followers)

  • following — the address records in the account's validated primary list, excluding block/mute-tagged records; empty when the account has no validated primary list.
  • followers — the distinct users whose validated primary list holds this account as a non-block/mute record.
    • Implemented as a single three-table SQL join (efp_list_recordsefp_listsefp_account_metadata) that encodes the EFP two-step Primary List validation directly in SQL.

Surface refactor (vs. the original #2224 branch)

  • TokenId scalar (uint256 → decimal string); tokenId is a real bigint end-to-end, dropping the idx_tokenId_numeric workaround index and its numeric-text pagination.
  • by{} / where{} argument pattern across the EFP queries.
  • Account-rooted queries moved from Query.efp onto Account.efp (primaryList, following, followers, metadata/metadatas).
  • Shared EFP id helpers extracted into the enssdk/efp submodule (consumed by both ENSIndexer and ENSApi).
  • EFP libs relocated to apps/ensindexer/src/lib/efp/; efp.tsquery-efp.ts.

Areas that would benefit from EFP-expert review

These are deliberate decisions where ENSNode conventions and EFP/api-v2 behaviour diverge — worth a second opinion from someone who knows EFP semantics:

  • NULL-byte handling is split by field, on purpose. Account-metadata keys are rejected when they contain a NULL byte (interpretMetadataKeyhasNullByte, the standard ENSNode pattern), not stripped: a Postgres text column can't store a NULL byte, and stripping would silently collapse distinct on-chain keys onto one stored key (see AccountMetadata.ts, enssdk/efp/ids.ts). Tags, by contrast, still strip embedded NULL bytes to match api-v2 behaviour (parse-list-op.ts). Is the tag-strip api-v2 parity the right call, or should tags also reject? Are there other EFP key/value surfaces where a NULL byte should reject vs. strip?
  • Two-step Primary List validation as a SQL join. followersJoins() / resolveValidatedPrimaryListTokenId express "metadata's primaryListTokenId = the list's id AND the metadata owner = the list's user" as join predicates. Confirm this exactly matches the EFP Primary List validation spec (no missing leg, correct handling of re-points and stale metadata).
  • Follow-graph exclusion set. EFP_NON_FOLLOW_TAGS = ["block", "mute"] is the full set of tags that exclude a record from following/followers. Is that complete and correct per EFP, and should the address record type filter (EFP_ADDRESS_RECORD_TYPE = 1) be broadened?
  • Canonical record keying. Records are keyed on the canonical 22-byte version | type | address prefix with trailing junk truncated — confirm this is the right canonicalization for EFP list ops.

Validation

  • pnpm typecheck, pnpm lint, unit tests, pnpm generate (idempotent, SDL/introspection/SKILL regenerated).
  • pnpm test:integration:ci green with the EFP docker stack.

Quantumlyy and others added 30 commits May 22, 2026 11:23
Add the EFP ListRegistry, AccountMetadata, and ListRecords contracts to the
mainnet ENS namespace as three per-chain datasources (EFPBase, EFPOptimism,
EFPEthereum), plus an address-less Resolver subscription on Ethereum mainnet
used to index the eth.efp.list text record.

ABIs are event-only subsets scoped to the events the EFP plugin indexes.
Add the efp_* tables to the abstract ENSDb schema: efp_lists, a
efp_list_storage_locations reverse index (slot -> token id, so list-metadata
events resolve their list NFT by primary key), efp_list_records,
efp_list_record_tags, efp_account_metadata, efp_pending_list_metadata, and
efp_ens_list_pointers. Register PluginName.EFP in the SDK enum.
Pure decoders for EFP ListOp payloads, the onchain ListStorageLocation
(locationType 1 only, per the EFP spec), and the eth.efp.list text record,
plus composite-id helpers, the list-metadata value decoder, and EFP constants.
Colocated unit tests cover the decoders.
Event handlers for ListRegistry (Transfer, UpdateListStorageLocation),
ListRecords (ListOp, UpdateListMetadata), AccountMetadata, and the eth.efp.list
Resolver TextChanged, writing directly to context.ensDb by primary key. The
slot->tokenId mapping keeps the user/manager path PK-only; the lone non-PK op
(cascading tag deletes on record removal) uses the drizzle escape hatch. Adds
the Ponder plugin config and registers it in ALL_PLUGINS.
Conditionally attach the EFP event handlers when `efp` is in PLUGINS, and add a
changeset for the new plugin.
Add a single `efp` root field (an EfpQuery namespace) to the Omnigraph GraphQL API,
grouping EFP queries so they do not clutter the Query root. Exposes EfpList (with a
nested records connection), EfpListRecord (with tags), EfpAccountMetadata, and
EfpListPointer (the eth.efp.list correlation), with cursor-paginated connections and
where-filters (owner/user/manager, recordData, address, node/listTokenId). Resolvers
read ENSDb directly via di.context.ensDb.
Output of `pnpm generate` after adding the EFP Omnigraph types.
Communicates the new `efp` Omnigraph root field for release notes.
Add the per-field // Entity.field banner comments used across the Omnigraph entity files
(account/renewal/domain), the // Inputs banner in the dedicated inputs file, and leading
docstrings on the plugin handler files (matching tokenscope). Comments only — no SDL change.
The eth.efp.list text record is not part of the EFP spec (docs.efp.app); the canonical
account-to-list association is the `primary-list` account metadata. Remove the address-less
Resolver subscription, the eth.efp.list text-record parser, and the efp_ens_list_pointers table.
EFP now indexes only the spec contracts: ListRegistry, AccountMetadata, and ListRecords.
Replace the removed eth.efp.list `listPointers` query with `efp.primaryList(address)`, which
reads the account `primary-list` metadata and returns the list only when its `user` role matches
the account (the EFP two-step Primary List validation). Add `EfpListRecord.list` so a record
navigates to its list, and consolidate the cross-service id mirrors in `efp-ids.ts`.
…bytes

EFP defines only record type 1 (a 20-byte address); types 0 and 2-255 are reserved with no
defined data layout. `parseRecord` now returns null for them, so the indexer never stores a record
whose `recordData` is not an address (which the Omnigraph API exposes through a non-null `Address`
scalar).

For type-1 records it also exposes the canonical `version | type | address` 22-byte prefix,
truncating any trailing junk after the 20-byte address. Tag and remove ops carry that same prefix,
so keying records by it (next commit) makes them resolve to the same row.
…oval

Records are now keyed by the canonical 22-byte `version | type | address` prefix in both
ADD_RECORD and REMOVE_RECORD, so a clean remove op deletes a junk-suffixed record (and vice versa)
and tag ops resolve to the same row (completes the record-identity fix).

Tags move from the `efp_list_record_tags` join table onto an embedded `tags` array on
`efp_list_records`. REMOVE_RECORD is now a single primary-key delete — the tags travel with the row
— instead of a non-PK cascade in the indexing hot path, and a re-added record starts with no stale
tags. ADD_TAG/REMOVE_TAG read-modify-write the record tag set by primary key, and the Omnigraph
`EfpListRecord.tags` resolver reads the column directly (removing a per-record query). Tag ops for a
record not in the list are ignored, since ops may arrive in any order.
…rted version or length

The leading version byte defines each structure's decoding schema, so an unsupported version
(or an out-of-spec length) must not be decoded as v1. `parseListOp` and `parseRecord` now reject any
version != 1, and `parseListStorageLocation` requires version 1, locationType 1, and the exact
86-byte payload (it previously accepted >= 86 bytes of any version). A shared `EFP_VERSION` constant
documents the single protocol version EFP defines today.
…roles

The generic metadata setter can emit arbitrary bytes for the `user`/`manager` keys.
`metadataValueToAddress` now returns null for any value that is not exactly 20 bytes, clearing the
role rather than storing a truncated or empty `0x` address that would later surface through a
GraphQL `Address`. Both call sites write the nullable `user`/`manager` columns, so a malformed value
clears the role consistently on the live and pending-drain paths.
…plicitly

The list-op, list-record, and List Storage Location payloads each carry an independent leading
version byte that EFP can bump separately, so the single EFP_VERSION constant is replaced by
EFP_LIST_OP_VERSION, EFP_RECORD_VERSION, and EFP_LSL_VERSION (all 1 today) and each decoder enforces
its own. Also rename the LSL decoder's HEX_BYTES to HEX_CHARS_PER_BYTE and give each field an
explicit named hex-char offset so the fixed 86-byte layout is auditable by inspection.
…s owner

ERC-721 emits Transfer(to=0) on a burn; the handler upserted that as owner = 0x00..00, which
then surfaced through EfpList.owner (non-null Address) and lists(where: { owner }). Detect a burn
(to == zeroAddress) and delete the list row plus its storage-location reverse mapping instead.
Within a (chain, contract, slot) EFP ops are indexed in on-chain order, so an ADD_TAG/REMOVE_TAG
for a missing record means the record was removed earlier or, anomalously, never added. Log a
warning rather than dropping it silently, and correct the comment that wrongly cited out-of-order
arrival as the rationale.
primaryList lower-cased only the stored user before comparing it to the requested address. The
Address scalar already normalizes the argument, but lower-casing both sides removes the asymmetry
and keeps validation independent of input casing.
…ed helper

Move the `primary-list` decode and two-step user-role validation out of the root
`efp.primaryList` resolver into `resolveValidatedPrimaryListTokenId`, so `Account.efp.primaryList`
(next commit) reuses the same logic. No behavior change.
…stRecord.account

Add `Account.efp` (an account's validated `primaryList` and the `lists` it is the `user` of)
and an `EfpListRecord.account` edge that resolves a record's target address to its `Account`.
Together they let a single Omnigraph query walk from an account to whom it follows and on into their
ENS names and own EFP lists, while the root `efp` namespace stays the protocol-rooted entry point.
The `user`/`manager` roles come from `UpdateListMetadata` events scoped to a storage location.
When `UpdateListStorageLocation` moves a list to a different `(chainId, contract, slot)`, the old
roles no longer apply, so clear them in the same update; pending metadata for the new location
repopulates them in the drain step. Without this a moved list kept the previous location's `user`,
which `Account.efp.lists` and primary-list validation would wrongly attribute to that account until
new metadata arrived.
The `EfpListRecord.account` edge links a record to its `Account` but does not yet resolve a
usable Account for addresses with no ENS presence (most EFP followees); whether to make that
universal is an open question raised on the PR. Drop the changeset claim of a universal cross-user
walk so the release notes match what ships.
…e uint256)

`decodePrimaryListTokenId` coerced any non-empty hex via `BigInt`, so a malformed `primary-list`
value such as `0x01` resolved to token 1 and `efp.primaryList` / `Account.efp.primaryList` could
report a primary list for invalid metadata whenever that list's `user` matched. EFP defines
`primary-list` as `abi.encodePacked(uint256)` (exactly 32 bytes), so reject any other length before
converting. Adds a unit test for the decoder.
… length guard

parseRecord lowercases the canonical record and recordData (matching the List Storage Location
decoder), so ADD vs REMOVE/tag ops and the API recordData filter key into the same rows even if a
payload carries uppercase hex. parseTagOp now validates and canonicalizes its 22-byte prefix through
parseRecord (version/type checked, lowercased) rather than slicing raw bytes, so a tag for a
non-address record is rejected outright instead of silently missing a row. Drops the redundant
early-length guard in the LSL decoder for the exact-length check up front. Adds parser tests.
…; document burn semantics

UpdateListStorageLocation now finds the list row first and guards on its presence (skip rather
than update a missing row), and when the new payload is undecodable (future version, non-onchain
type, or malformed) it clears the stale decoded location, its reverse mapping, and its
location-scoped roles, logs a warning, and keeps the raw payload, rather than leaving the list
resolving its old slot. Documents that a burned list keeps its efp_list_records rows (they mirror
the on-chain ListRecords contract; the list back-ref resolves to null).
…ata rows

Comment why a malformed user/manager value clears the role (faithful to on-chain state), and
note in the schema that pending-metadata rows for a slot no list ever points at are a bounded,
low-volume artifact.
@shrugs

shrugs commented Jun 13, 2026

Copy link
Copy Markdown
Member Author

@greptile review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 69 changed files in this pull request and generated 1 comment.

Comment thread apps/ensapi/src/omnigraph-api/schema/scalars.ts
@Quantumlyy

Copy link
Copy Markdown

Starting review.
One thought I had was being able to have it run with just 3/6 chains (Ethereum Mainnet, Base, Optimism).

@vercel vercel Bot temporarily deployed to Preview – ensrainbow.io June 13, 2026 15:30 Inactive
@vercel vercel Bot temporarily deployed to Preview – ensnode.io June 13, 2026 15:30 Inactive
@vercel vercel Bot temporarily deployed to Preview – admin.ensnode.io June 13, 2026 15:30 Inactive
@shrugs

shrugs commented Jun 13, 2026

Copy link
Copy Markdown
Member Author

@greptile review

# Conflicts:
#	apps/ensapi/src/omnigraph-api/schema/account.ts
#	packages/ensskills/skills/omnigraph/SKILL.md
@shrugs

shrugs commented Jun 13, 2026

Copy link
Copy Markdown
Member Author

@greptile review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 69 changed files in this pull request and generated 2 comments.

Comment thread apps/ensapi/src/omnigraph-api/schema/account.ts
Comment thread packages/enssdk/src/lib/coin-type.ts
import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection";
import { buildAccountPrimaryNamesSelection } from "@/omnigraph-api/lib/resolution/account-primary-names-selection";
import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records";
import { AccountEfpRef } from "@/omnigraph-api/schema/account-efp";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circular module dependency between account.ts and account-efp.ts causes AccountEfpRef to be undefined when imported, breaking schema field definitions

Fix on Vercel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants