Skip to content

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

Draft
Quantumlyy wants to merge 34 commits into
namehash:mainfrom
Quantumlyy:Quantumlyy/efp-plugin
Draft

feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2224
Quantumlyy wants to merge 34 commits into
namehash:mainfrom
Quantumlyy:Quantumlyy/efp-plugin

Conversation

@Quantumlyy
Copy link
Copy Markdown

@Quantumlyy Quantumlyy commented May 29, 2026

Status: draft. Delivers the root efp namespace plus account-rooted access: account(by:) { efp { ... } } (an account's validated primaryList and its lists) and an EfpListRecord.account edge linking a record to the Account it points at.

Open question to discuss (please weigh in; blocks marking ready): EfpListRecord.account and account(by:) resolve to null for an address with no row in the accounts table, and that table only gets rows for addresses the ENS indexer touches (owners, registrants, and so on). Most EFP followees are arbitrary addresses with no ENS presence, so today the cross-user walk record.account.efp.primaryList is null for them. (The EFP data for any address is still reachable via the root efp.primaryList(recordData).)

To make the account-rooted walk universal, AccountRef.load could synthesize a bare { id } Account for a missing key: the accounts row carries no data beyond the address, so every Account field already derives from the id. That is a roughly 3-line change, but it flips core Account semantics, since account(by: { address }) would become non-null for every unseen address, affecting all consumers and not just EFP. Because Account is a shared core type, this is raised here rather than decided inside this PR. Do we want Account to be address-centric (always resolvable), or keep null-for-unknown?

Reviewer Focus (Read This First)

Spend the most time on three areas:

  1. Data model and handlers (packages/ensdb-sdk/.../efp.schema.ts, apps/ensindexer/src/plugins/efp/handlers/). The PK-only access pattern is the load-bearing design: a (chainId, contract, slot) to tokenId reverse index (efp_list_storage_locations) lets list-metadata events resolve the owning NFT by primary key, and a record's tags are an embedded text[] rather than a join table, so removing a record is a single primary-key delete with no cascade.
  2. Byte decoders (apps/ensindexer/src/plugins/efp/lib/parse-*.ts, list-metadata.ts). These are the trust boundary for arbitrary on-chain input: version, length, record-type, and canonical-key validation.
  3. Omnigraph surface (apps/ensapi/src/omnigraph-api/schema/efp*.ts), especially primaryList and its two-step validation.

Least confident: handler runtime behavior. The parsers and types are verified, but no integration test exercises the handlers against live EFP data yet (see Testing Evidence).

Problem & Motivation

The Omnigraph exists to be "the graph of all things": one API unifying indexed ENS data, dynamic ENS resolutions, ENSv1 and ENSv2, and highly-ENS-adjacent protocols in a single query. EFP is the canonical adjacent protocol that vision points to. It is the on-chain social graph keyed by Ethereum address, with ENS as the identity layer.

This PR delivers the first such integration. EFP is indexed as its own efp_* model and surfaced through the Omnigraph under a single efp root, so one query can resolve a name's address, its validated primary EFP list, and who follows it. It is fully opt-in and independent of the ENS surfaces: the efp namespace appears when the plugin is enabled, and is a no-op otherwise.

What Changed (Concrete)

  1. New opt-in efp plugin (apps/ensindexer/src/plugins/efp/): Ponder config, event handlers, pure byte decoders, constants, README. Enabled via PLUGINS, activatable only on the mainnet ENS namespace (enforced by datasource presence).
  2. Datasource catalog entries EFPBase, EFPOptimism, EFPEthereum with addresses, ABIs, and start blocks for ListRegistry, AccountMetadata, and ListRecords.
  3. ENSDb abstract schema (efp.schema.ts): efp_lists, efp_list_storage_locations, efp_list_records (tags embedded as text[]), efp_account_metadata, efp_pending_list_metadata.
  4. Indexing: list NFTs (ListRegistry, Base), list records and tags (ListRecords, Base, Optimism, Ethereum mainnet), account metadata (AccountMetadata, Base, including primary-list).
  5. Omnigraph API: a single efp root namespace (list / lists, listRecords with each record's tags, owning list, and target account, accountMetadata / accountMetadatas, validated primaryList(address)), plus account-rooted access: Account.efp (an account's validated primaryList and the lists it is the user of) and an EfpListRecord.account edge resolving a record's target address to its Account. Together these let one query walk from an account to whom it follows and on into ENS names and EFP lists. Cursor-paginated connections.
  6. Regenerated Omnigraph SDL and introspection in enssdk.
  7. Changesets for the affected packages.

Example queries

A forward join: an account, its validated primary list, and the accounts it follows.

# "Show me an account's primary list and everyone it follows."
query AccountGraph($account: Address!) {
  efp {
    primaryList(address: $account) {
      tokenId
      user
      records(first: 100) {
        totalCount
        edges {
          node {
            recordData   # each account this list follows
            tags         # e.g. "top8", "block", "mute"
          }
        }
      }
    }
  }
}

A reverse join, correlating one account to other users: who follows a target, and which user owns each following list.

# "Who follows this account, and which users own those lists?"
query Followers($target: Address!) {
  efp {
    listRecords(where: { recordData: $target }, first: 100) {
      totalCount
      edges {
        node {
          tags
          list {
            tokenId
            user    # the follower account behind this list
            owner
          }
        }
      }
    }
  }
}

The first traverses primaryList to its records; the second traverses listRecords to each record.list and its user. Both resolve in one request through object edges, with no client-side stitching.

Account-rooted, the same graph walks across users and into ENS names in a single query:

# An account's ENS name, its EFP lists, and (per followed account) their ENS name and own primary list.
query AccountEfp($address: Address!) {
  account(by: { address: $address }) {
    domains(first: 1) { edges { node { canonical { name { interpreted } } } } }
    efp {
      lists { totalCount }
      primaryList {
        tokenId
        records(first: 50) {
          edges {
            node {
              recordData
              tags
              account {
                domains(first: 1) { edges { node { canonical { name { interpreted } } } } }
                efp { primaryList { tokenId } }
              }
            }
          }
        }
      }
    }
  }
}

Today account is null for a followee with no ENS presence (see the open question at the top); efp.primaryList(recordData) resolves any address regardless.

Design & Planning

  • Structure mirrors the tokenscope plugin, the closest in-repo analog (a standalone plugin that indexes its own contracts and needs no ENS Registry/Resolver data). The implementation started from the efpnode proof-of-concept and was reworked to be idiomatic: contracts come from the datasource catalog, handlers write directly to context.ensDb by primary key, and the PoC's EFPStore abstraction, hard-coded addresses, and multi-column WHERE lookups were removed.
  • PK-only access (the repo hot-path rule) drove two model choices: the slot-to-tokenId reverse index, and embedding tags on the record. The embedded-array read-modify-write follows an existing production pattern (the subgraph Resolver texts column).
  • Alternatives considered and rejected:
    • A separate efp_list_record_tags join table with a cascade delete on REMOVE_RECORD: rejected because the cascade is a non-PK write in the indexing hot path, and re-adding a removed record would resurrect stale tags.
    • Rejecting junk-suffixed records outright: rejected because normalize-and-key keeps parity with the canonical api-v2 indexer at no cost, and avoids orphaning a clean add paired with a junk-suffixed remove.
  • Excluded as non-spec: the off-chain / locationType = 2 list storage, and the eth.efp.list ENS text-record convention. Neither is part of the EFP spec.
  • Planning artifacts: an implementation plan plus the EFP spec pages (List Ops, List Records, List Metadata, List Storage Location, Account Metadata). No separate shared design doc.
  • Reviewed / approved by: no formal human pre-review; see Self-Review.

Self-Review

Bugs caught while reviewing the diff end-to-end:

  1. Record id and record column used the untruncated payload while tag and remove ops referenced the canonical 22-byte prefix, so tags would not join and a junk-suffixed record would orphan on removal. Fixed by keying every record by the canonical prefix.
  2. Reserved (non-type-1) records were exposed through a non-null Address scalar. Now only type-1 address records are indexed.
  3. Non-version-1 ListOps, records, and storage locations were decoded as v1. Now rejected before dispatch.
  4. Non-20-byte role metadata stored a truncated or empty address. Now clears the role instead.
  5. ENSApi resolvers used the indexer's find() API where the client is plain Drizzle. Fixed to .select().limit(1).

Logic simplified: REMOVE_RECORD went from a record delete plus a non-PK tag cascade to a single primary-key delete (tags travel with the row).

Naming: aligned to "Unigraph" (data model) versus "Omnigraph" (API); consolidated all EFP queries under one efp root so they do not clutter the Query root.

Dead code removed: the eth.efp.list Resolver subscription, its parser, and the efp_ens_list_pointers table, when that feature was cut as non-spec.

Cross-Codebase Alignment

  • Search terms used: efpListRecordTags, metadataValueToAddress, .array( (onchainTable array columns), attach_Resolver, texts (array writes in handlers).
  • Reviewed but unchanged: the tokenscope plugin (structural model); the subgraph Resolver handler (confirmed the embedded-array read-modify-write pattern at Resolver.ts:202 and :414); the Omnigraph builder and connection helpers.
  • Confirmed EFP is independent: not in the alpha preset, no compatibility coupling, handlers and contracts gated on the plugin being enabled. Root typecheck confirms no other workspace consumes the efp_* tables.

Downstream & Consumer Impact

  • Public APIs: adds the efp root field to the Omnigraph GraphQL API. Additive and behind the plugin; no change to existing ENS queries. SDL and introspection regenerated.
  • Operators: enabling the plugin needs Base, Optimism, and Ethereum RPCs and the mainnet namespace. A re-index is implied by the new schema and handlers.
  • Readers and maintainers: new efp_* tables and the efp namespace. One term to call out: "primary list" is validated (the primary-list metadata plus a user-role match), distinct from the raw primary-list claim that accountMetadatas returns.
  • Docs updated: plugin README and changesets.

Testing Evidence

  • Testing performed: unit tests for all byte decoders (parse-list-op, parse-list-storage-location, list-metadata). Full repo verification passes: root typecheck across all workspaces, Biome, pnpm generate (no SDL or introspection diff), and the full test suite with no regressions.
  • Known gaps: handler runtime behavior is not integration-tested yet. No EFP devnet exists; one is planned, similar to the ENS devnet. The embedded-array read-modify-write is de-risked by the existing subgraph Resolver precedent, so the residual gap is EFP-specific handler wiring, not the text[] round-trip.
  • What breaks first if wrong: the tag set on a record (add, dedup, remove, re-add starts empty), canonical keying on a junk-suffixed remove, primaryList decode and validation, and the clear-on-malformed-role behavior. These are the queued devnet assertions.
  • Manual reasoning for reviewers: that the handlers persist what the parsers produce, since no test currently exercises the live or pending-drain paths.

Scope Reductions

  • Deferred follow-ups: the EFP devnet and integration tests (assertions above); an optional doc note on efp.accountMetadata steering consumers to the validated primaryList; a possible tag filter on listRecords with a supporting index, if demand appears.
  • Why the rest is deferred: keep this PR reviewable and shippable. The devnet is separate infrastructure.

Risk Analysis

  • Assumptions: user / manager metadata values are exactly 20-byte addresses; EFP structures are version 1; type-1 records carry a 20-byte address; ops for a given list are totally ordered within its chain, so a record is added before it is tagged.
  • Failure modes: a malformed input the parsers wrongly accept or reject; the array round-trip failing at runtime (mitigated by the production precedent).
  • Blast radius: contained. Opt-in, a no-op when disabled, isolated tables, no change to existing ENS data or queries. Worst case is incorrect EFP rows, recoverable by a re-index after a fix.
  • Mitigations and rollback: disable the efp plugin (removes the surface), then re-index.

Pre-Review Checklist (Blocking)

  • I reviewed every line of this diff and understand it end-to-end
  • I'm prepared to defend this PR line-by-line in review
  • I'm comfortable being the on-call owner for this change
  • Relevant changesets are included (or are not required)

Quantumlyy added 16 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.
Copilot AI review requested due to automatic review settings May 29, 2026 16:33
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: 4d0c663

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 22 packages
Name Type
ensapi Minor
enssdk Minor
@ensnode/datasources Minor
@ensnode/ensdb-sdk Minor
@ensnode/ensnode-sdk Minor
ensindexer Minor
ensadmin Minor
ensrainbow Minor
@docs/ensnode Minor
@namehash/ens-referrals Minor
enskit Minor
@ensnode/ensrainbow-sdk Minor
@namehash/namehash-ui Minor
fallback-ensapi Minor
@ensnode/integration-test-env Minor
@docs/ensrainbow Minor
enscli Minor
ensskills Minor
@ensnode/ponder-sdk Minor
@ensnode/ponder-subgraph Minor
@ensnode/shared-configs Minor
@ensnode/ensindexer-perf-testing Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 29, 2026

@Quantumlyy is attempting to deploy a commit to the NameHash Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Review Change Stack

Warning

Review limit reached

@Quantumlyy, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 44 minutes and 9 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d2a25213-676e-4630-8b32-079b20f7de08

📥 Commits

Reviewing files that changed from the base of the PR and between 49dee93 and 4d0c663.

📒 Files selected for processing (2)
  • packages/ensnode-sdk/src/omnigraph-api/prerequisites.test.ts
  • packages/ensnode-sdk/src/omnigraph-api/prerequisites.ts
📝 Walkthrough

Walkthrough

Adds end-to-end Ethereum Follow Protocol (EFP) support: datasources and ABIs, indexer plugin and event handlers, parsing and ID helpers, five ENSDb tables, and an Omnigraph GraphQL efp namespace with cursor-paginated queries and Account.efp integration.

Changes

EFP Indexer and API Integration

Layer / File(s) Summary
Changesets & plugin enum
.changeset/*, packages/ensnode-sdk/src/ensindexer/config/types.ts
Release notes for EFP features and minor bumps; PluginName.EFP enum entry added.
Datasources, ABIs, and mainnet registration
packages/datasources/src/abis/efp/*, packages/datasources/src/lib/types.ts, packages/datasources/src/mainnet.ts
Event-only ABIs for ListRegistry/ListRecords/AccountMetadata; added EFPBase/EFPOptimism/EFPEthereum datasource configs and addresses/start blocks.
ENSDb EFP schema
packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts, packages/ensdb-sdk/src/ensindexer-abstract/index.ts
Five on-chain table models: efpLists, efpListStorageLocations, efpListRecords, efpAccountMetadata, efpListMetadata, and re-exported from abstract index.
Indexer plugin, constants, and docs
apps/ensindexer/src/plugins/efp/plugin.ts, apps/ensindexer/src/plugins/efp/constants.ts, apps/ensindexer/src/plugins/efp/README.md
EFP Ponder plugin with required datasources, contract wiring, schema version/opcode constants and plugin README.
Handler orchestration & plugin registry
apps/ensindexer/ponder/src/register-handlers.ts, apps/ensindexer/src/plugins/index.ts, apps/ensindexer/src/plugins/efp/event-handlers.ts
Central attach function for EFP handlers; conditional registration when plugin enabled; efpPlugin added to ALL_PLUGINS.
ListRegistry handlers
apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts
Handles ERC-721 Transfer (mint/burn/transfer) and UpdateListStorageLocation: manages efp_lists, reverse storage-location mapping, and reapplies durable per-location metadata.
ListRecords handlers
apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts
ListOp opcode decoder applies ADD/REMOVE record and ADD/REMOVE tag operations with tag-array maintenance; UpdateListMetadata upserts metadata and updates efp_lists roles when location mapped.
AccountMetadata handler
apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts
Upserts per-address/key current metadata into efpAccountMetadata using deterministic composite id and block timestamps.
Parsing utilities & tests
apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts, apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts, apps/ensindexer/src/plugins/efp/lib/list-metadata.ts, *test.ts
Pure decoders for list op payloads, record/tag extraction, storage-location parsing with JS-safe chainId checks, metadata address extraction, slot zero-padding, and unit tests covering valid and malformed inputs.
ID generation helpers
apps/ensindexer/src/plugins/efp/lib/ids.ts
Deterministic composite-key helpers (storageLocationId, listRecordId, accountMetadataId, listMetadataId) normalizing Hex components to lowercase.
Omnigraph schema: imports & Account.efp
apps/ensapi/src/omnigraph-api/schema.ts, apps/ensapi/src/omnigraph-api/schema/account.ts, apps/ensapi/src/omnigraph-api/schema/account-efp.ts
EFP schema modules imported; Account gains non-null efp field exposing account-scoped EFP view.
GraphQL EFP types, inputs, and ID utilities
apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts, apps/ensapi/src/omnigraph-api/schema/efp-ids.ts, apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts, apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts
Where-inputs for lists/records/accountMetadatas, composite ID helpers, EfpAccountMetadata and EfpListRecord loadables mapping DB fields to GraphQL types with relational lookups.
GraphQL EfpList and records connection
apps/ensapi/src/omnigraph-api/schema/efp-list.ts
EfpList loadable with token metadata, decoded storage-location fields, timestamps, and a cursor-paginated records connection scoped by decoded storage location.
Primary-list decoder & validation
apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts, apps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.ts
Strict 32-byte uint256-to-decimal decoder for primary-list metadata and resolver that validates referenced list exists and user role matches address; tests for valid/invalid encodings.
EfpQuery root namespace
apps/ensapi/src/omnigraph-api/schema/efp.ts
Registers top-level efp query exposing list, lists (tokenId-paginated), listRecords, accountMetadata, accountMetadatas, and primaryList using cursor pagination and where-filters.
Schema entrypoint re-export
packages/ensdb-sdk/src/ensindexer-abstract/index.ts, apps/ensapi/src/omnigraph-api/schema.ts
ensdb-sdk re-exports EFP schema; omnigraph schema imports include new EFP modules.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble bytes and hop through chains,
lists and tags in tidy lanes,
efp joins ENS with gentle cheer,
now queries fetch what rabbits hear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically summarizes the main change: indexing the Ethereum Follow Protocol and exposing it via the Omnigraph API.
Description check ✅ Passed The description is comprehensive and follows the template structure with Summary, Why, Testing, Notes for Reviewer, and Pre-Review Checklist sections, though marked as draft.
Docstring Coverage ✅ Passed Docstring coverage is 93.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds an Ethereum Follow Protocol (EFP) indexing plugin to ENSIndexer and exposes the indexed data via a new efp namespace in the ENSApi Omnigraph. The plugin indexes EFP list NFTs (Base), records/tags (Base/Optimism/Ethereum), and account metadata (Base) into new efp_* ENSDb tables, with a corresponding GraphQL surface for lists, records, account metadata, and validated primary lists.

Changes:

  • Add EFP datasources, ABIs, schema tables, plugin handlers (with byte decoders and tests) and a new PluginName.EFP.
  • Add Omnigraph types/resolvers (EfpQuery, EfpList, EfpListRecord, EfpAccountMetadata, and where-input filters) plus regenerated SDK schema/introspection.
  • Add changesets and a plugin README documenting activation and the new tables.

Reviewed changes

Copilot reviewed 33 out of 35 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/enssdk/src/omnigraph/generated/schema.graphql Regenerated SDK GraphQL schema for the EFP types and root efp field.
packages/enssdk/src/omnigraph/generated/introspection.ts Regenerated introspection metadata matching the new EFP schema.
packages/ensnode-sdk/src/ensindexer/config/types.ts Adds PluginName.EFP.
packages/ensdb-sdk/src/ensindexer-abstract/index.ts Re-exports the new EFP abstract schema.
packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts Defines the efp_* Ponder tables and indexes.
packages/datasources/src/mainnet.ts Registers the three EFP datasources on Base/Optimism/Ethereum.
packages/datasources/src/lib/types.ts Adds EFP datasource name keys.
packages/datasources/src/abis/efp/{ListRegistry,ListRecords,AccountMetadata}.ts Event-only ABIs for the EFP contracts.
apps/ensindexer/src/plugins/index.ts Registers the EFP plugin in ALL_PLUGINS.
apps/ensindexer/src/plugins/efp/plugin.ts Builds the Ponder config for the EFP plugin across three chains.
apps/ensindexer/src/plugins/efp/constants.ts EFP version, opcodes, and well-known metadata keys.
apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.{ts,test.ts} Decoder + tests for UpdateListStorageLocation payloads.
apps/ensindexer/src/plugins/efp/lib/parse-list-op.{ts,test.ts} Decoders + tests for ListOp payloads, records, and tags.
apps/ensindexer/src/plugins/efp/lib/list-metadata.{ts,test.ts} Helper for interpreting list-metadata value as an address.
apps/ensindexer/src/plugins/efp/lib/ids.ts Composite primary-key builders for the indexer side.
apps/ensindexer/src/plugins/efp/handlers/{ListRegistry,ListRecords,AccountMetadata}.ts Event handlers that write into the new efp_* tables (with a pending-metadata staging mechanism for cross-contract ordering).
apps/ensindexer/src/plugins/efp/event-handlers.ts Attaches all EFP handlers.
apps/ensindexer/src/plugins/efp/README.md EFP plugin overview.
apps/ensindexer/ponder/src/register-handlers.ts Wires up EFP handlers when the plugin is active.
apps/ensapi/src/omnigraph-api/schema.ts Imports the new EFP schema modules.
apps/ensapi/src/omnigraph-api/schema/efp{,-list,-list-record,-account-metadata,-inputs,-ids}.ts Pothos types/resolvers for the efp namespace.
.changeset/efp-{plugin,omnigraph}.md Changesets describing the new feature for each package surface.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/ensindexer/src/plugins/efp/constants.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts
Comment thread apps/ensapi/src/omnigraph-api/schema/efp.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts Outdated
Comment thread apps/ensapi/src/omnigraph-api/schema/efp.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/constants.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts Outdated
…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.
Copilot AI review requested due to automatic review settings May 29, 2026 17:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 36 out of 38 changed files in this pull request and generated 5 comments.

Comment on lines +90 to +95
recordData: t.field({
description: "The followed/target address. Valid for address records (recordType 1).",
type: "Address",
nullable: false,
resolve: (record) => record.recordData as NormalizedAddress,
}),
Comment on lines +121 to +136
const address = metadataValueToAddress(event.args.value);

const mapping = await context.ensDb.find(ensIndexerSchema.efpListStorageLocations, {
id: storageLocationId(chainId, contractAddress, slot),
});

if (mapping) {
await context.ensDb
.update(ensIndexerSchema.efpLists, { tokenId: mapping.tokenId })
.set(
key === EFP_LIST_METADATA_KEYS.USER
? { user: address, updatedAt: ts }
: { manager: address, updatedAt: ts },
);
return;
}
Comment on lines +15 to +22
function decodePrimaryListTokenId(value: Hex): string | null {
if (!value || value === "0x") return null;
try {
return BigInt(value).toString();
} catch {
return null;
}
}
Comment on lines +64 to +68
addOnchainEventListener(
namespaceContract(pluginName, "ListRegistry:UpdateListStorageLocation"),
async ({ context, event }) => {
const parsed = parseListStorageLocation(event.args.listStorageLocation);
if (!parsed) return;
Comment on lines +118 to +123
export function parseTagOp(data: Hex | string | null | undefined): ParsedTagOp | null {
if (!data || typeof data !== "string" || !isHex(data)) return null;
if (data.length < RECORD_PREFIX_WITH_0X_LENGTH) return null;

const record = data.slice(0, RECORD_PREFIX_WITH_0X_LENGTH) as Hex;
const tagHex = data.slice(RECORD_PREFIX_WITH_0X_LENGTH);
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.
Copilot AI review requested due to automatic review settings May 29, 2026 17:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 36 out of 38 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (1)

packages/datasources/src/mainnet.ts:1

  • The mainnet ListRecords address (0x5289…17ef) is identical to the Base AccountMetadata address declared above. EFP's ListRecords and AccountMetadata are different contracts with different ABIs, so this looks like a copy/paste from the Base entry rather than the real mainnet ListRecords deployment. Please re-verify against docs.efp.app / ethereumfollowprotocol/api-v2; if it is wrong, the mainnet handler will subscribe to an unrelated contract and either index nothing or decode garbage.

Comment on lines +106 to +111
return {
version,
recordType,
record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex,
recordData: `0x${body}` as Hex,
};
Comment on lines +118 to +122
export function parseTagOp(data: Hex | string | null | undefined): ParsedTagOp | null {
if (!data || typeof data !== "string" || !isHex(data)) return null;
if (data.length < RECORD_PREFIX_WITH_0X_LENGTH) return null;

const record = data.slice(0, RECORD_PREFIX_WITH_0X_LENGTH) as Hex;
Comment on lines +29 to +46
if (isAddressEqual(event.args.to, zeroAddress)) {
const existing = await context.ensDb.find(ensIndexerSchema.efpLists, { tokenId });
if (
existing?.listStorageLocationChainId != null &&
existing.listStorageLocationContractAddress != null &&
existing.listStorageLocationSlot != null
) {
await context.ensDb.delete(ensIndexerSchema.efpListStorageLocations, {
id: storageLocationId(
existing.listStorageLocationChainId,
existing.listStorageLocationContractAddress,
existing.listStorageLocationSlot,
),
});
}
await context.ensDb.delete(ensIndexerSchema.efpLists, { tokenId });
return;
}
Comment on lines +48 to +59
const owner = event.args.to.toLowerCase() as Hex;
await context.ensDb
.insert(ensIndexerSchema.efpLists)
.values({
tokenId,
owner,
nftChainId: context.chain.id,
nftContractAddress: event.log.address.toLowerCase() as Hex,
createdAt: ts,
updatedAt: ts,
})
.onConflictDoUpdate({ owner, updatedAt: ts });
Comment on lines +15 to +22
function decodePrimaryListTokenId(value: Hex): string | null {
if (!value || value === "0x") return null;
try {
return BigInt(value).toString();
} catch {
return null;
}
}
Comment on lines +139 to +143
const id = pendingListMetadataId(chainId, contractAddress, slot, key);
await context.ensDb
.insert(ensIndexerSchema.efpPendingListMetadata)
.values({ id, chainId, contractAddress, slot, key, value: event.args.value, createdAt: ts })
.onConflictDoUpdate({ value: event.args.value, createdAt: ts });
Comment on lines +44 to +47
lsl: Hex | string | null | undefined,
): ParsedListStorageLocation | null {
if (!lsl || typeof lsl !== "string" || !isHex(lsl)) return null;
if (lsl.length < LOCATION_TYPE_END + 2) return null; // "0x" + version + locationType
Comment on lines +23 to +35
await context.ensDb
.insert(ensIndexerSchema.efpAccountMetadata)
.values({
id: accountMetadataId(address, event.args.key),
chainId: context.chain.id,
contractAddress: event.log.address.toLowerCase() as Hex,
address,
key: event.args.key,
value: event.args.value,
createdAt: ts,
updatedAt: ts,
})
.onConflictDoUpdate({ value: event.args.value, updatedAt: ts });
Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts
lsl: Hex | string | null | undefined,
): ParsedListStorageLocation | null {
if (!lsl || typeof lsl !== "string" || !isHex(lsl)) return null;
if (lsl.length < LOCATION_TYPE_END + 2) return null; // "0x" + version + locationType
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 29, 2026

Choose a reason for hiding this comment

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

Redundant early-length guard at line 47 checks payload length before the strict exact-length check at line 57

Fix on Vercel

Comment thread apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts
createdAt: ts,
updatedAt: ts,
})
.onConflictDoUpdate({ value: event.args.value, updatedAt: ts });
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 29, 2026

Choose a reason for hiding this comment

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

Metadata keys in AccountMetadata and ListRecords handlers are used directly in primary key construction without NUL byte normalization, unlike tags which are normalized for api-v2 parity

Fix on Vercel

Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts Outdated
Comment thread apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts
Comment thread apps/ensindexer/src/plugins/efp/lib/list-metadata.ts
Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts Outdated
recordType,
record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex,
recordData: `0x${body}` as Hex,
};
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 29, 2026

Choose a reason for hiding this comment

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

parseRecord and parseTagOp do not lowercase extracted record prefix or recordData hex, causing inconsistency with primary key generation and data normalization

Fix on Vercel

createdAt: ts,
updatedAt: ts,
})
.onConflictDoUpdate({ value: event.args.value, updatedAt: ts });
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 29, 2026

Choose a reason for hiding this comment

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

Account metadata and list metadata keys are normalized to prevent NUL byte corruption and unbounded primary keys

Fix on Vercel

…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.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts (2)

106-111: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Lowercase hex components to ensure key normalization.

The record and recordData fields are used in database keys (via listRecordId), but are not lowercased here. This violates the design principle that "hex components are lowercased so keys built from different event sources collide correctly" and can cause duplicate rows or lookup failures if contracts emit mixed-case hex.

🔧 Proposed fix
   return {
     version,
     recordType,
-    record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex,
-    recordData: `0x${body}` as Hex,
+    record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH).toLowerCase()}` as Hex,
+    recordData: `0x${body.toLowerCase()}` as Hex,
   };
 }

Based on learnings, hex normalization is critical for composite key correctness across EFP tables.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts` around lines 106 - 111,
The returned hex fields must be normalized to lowercase: update the construction
of record and recordData so the resulting strings include the "0x" prefix and
are lowercased (e.g., call .toLowerCase() on the composed `0x${...}` values).
Apply this change where `record` is built from `bytes.slice(0,
RECORD_PREFIX_HEX_LENGTH)` and where `recordData` is built from `body`, keeping
the Hex casts intact so keys derived by listRecordId consistently normalize
across sources.

118-132: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Validate record headers and lowercase the record prefix.

parseTagOp extracts the 22-byte record prefix without validating the version and recordType headers, and without lowercasing. This causes two issues:

  1. Missing header validation: Tag operations with invalid record headers (e.g., version !== 1 or recordType !== 1) pass validation but fail silently during record lookup, reducing defense-in-depth even though contracts may enforce correct headers.
  2. Missing lowercase normalization: The record field is used in listRecordId for database lookups. Without lowercasing, mixed-case hex from different sources won't collide correctly, violating the design principle stated in ids.ts.
🔧 Proposed fix
 export function parseTagOp(data: Hex | string | null | undefined): ParsedTagOp | null {
   if (!data || typeof data !== "string" || !isHex(data)) return null;
   if (data.length < RECORD_PREFIX_WITH_0X_LENGTH) return null;
 
+  // Validate the record header (version + recordType) for defense-in-depth.
+  const bytes = data.slice(2);
+  const version = parseInt(bytes.slice(0, 2), 16);
+  const recordType = parseInt(bytes.slice(2, 4), 16);
+  if (version !== EFP_RECORD_VERSION) return null;
+  if (recordType !== 1) return null;
+
-  const record = data.slice(0, RECORD_PREFIX_WITH_0X_LENGTH) as Hex;
+  const record = `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH).toLowerCase()}` as Hex;
   const tagHex = data.slice(RECORD_PREFIX_WITH_0X_LENGTH);
 
   // hex must contain whole bytes
   if (tagHex.length % 2 !== 0) return null;
 
   // Match api-v2: decode as UTF-8 and strip embedded NULs.
   const tag = hexToUtf8(tagHex).replace(/\0/g, "");
 
   return { record, tag };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts` around lines 118 - 132,
In parseTagOp, validate and normalize the 22-byte record prefix: parse the
header bytes from the extracted record (the value currently assigned to `record`
using `RECORD_PREFIX_WITH_0X_LENGTH`) and return null unless `version === 1` and
`recordType === 1`; also lowercase the `record` string before returning (so the
`record` used by `listRecordId` is normalized). Keep the existing hex/length
checks (`isHex`, tag byte-length) and return null on header mismatch; update the
return to provide the lowercased `record` and unchanged `tag` (the function and
symbol to change is `parseTagOp` and the constant
`RECORD_PREFIX_WITH_0X_LENGTH`).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/ensapi/src/omnigraph-api/schema/efp.ts`:
- Around line 51-55: The owner/user/manager filters use direct
eq(ensIndexerSchema.efpLists.owner, where.owner as Hex) which can mismatch
stored lowercased IDs; normalize both sides to the same casing (lowercase)
before comparison or use the existing EFP id helper/normalize function used
elsewhere (e.g., primaryList path) so comparisons succeed; update the
comparisons in efp.ts (where.owner/where.user/where.manager vs
efpLists.owner/efpLists.user/efpLists.manager) to compare lowercased values (or
call the id helper) and make the same change for efpLists.user in account-efp.ts
and efpAccountMetadata.address so all three places use identical normalization.

In `@apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts`:
- Around line 68-93: Add tests covering hex lowercasing and header validation
for parseTagOp: add a test that constructs a mixed-case 22-byte prefix (e.g.,
`0101${"AB".repeat(20)}`) and asserts parseTagOp(...).record is lowercased
(`0x0101${"ab".repeat(20)}`), and add tests that pass prefixes with invalid
record version (`0201...`) and invalid record type (`0102...`) and assert
parseTagOp(...) returns null; reference the parseTagOp function to locate where
to add these cases in parse-list-op.test.ts.
- Around line 34-66: Add a unit test to parse-list-op.test.ts that verifies
parseRecord lowercases hex: call parseRecord with uppercase hex input like
`0x0101${"AB".repeat(20)}` and assert the returned result.record equals
`0x0101${"ab".repeat(20)}` and result.recordData equals `0x${"ab".repeat(20)}`;
target the parseRecord function to ensure normalization for database keys.

---

Duplicate comments:
In `@apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts`:
- Around line 106-111: The returned hex fields must be normalized to lowercase:
update the construction of record and recordData so the resulting strings
include the "0x" prefix and are lowercased (e.g., call .toLowerCase() on the
composed `0x${...}` values). Apply this change where `record` is built from
`bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)` and where `recordData` is built from
`body`, keeping the Hex casts intact so keys derived by listRecordId
consistently normalize across sources.
- Around line 118-132: In parseTagOp, validate and normalize the 22-byte record
prefix: parse the header bytes from the extracted record (the value currently
assigned to `record` using `RECORD_PREFIX_WITH_0X_LENGTH`) and return null
unless `version === 1` and `recordType === 1`; also lowercase the `record`
string before returning (so the `record` used by `listRecordId` is normalized).
Keep the existing hex/length checks (`isHex`, tag byte-length) and return null
on header mismatch; update the return to provide the lowercased `record` and
unchanged `tag` (the function and symbol to change is `parseTagOp` and the
constant `RECORD_PREFIX_WITH_0X_LENGTH`).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 85d05edf-a162-4f27-a4b0-b2429155eebe

📥 Commits

Reviewing files that changed from the base of the PR and between 674d025 and f07849a.

⛔ Files ignored due to path filters (2)
  • packages/enssdk/src/omnigraph/generated/introspection.ts is excluded by !**/generated/**
  • packages/enssdk/src/omnigraph/generated/schema.graphql is excluded by !**/generated/**
📒 Files selected for processing (37)
  • .changeset/efp-omnigraph.md
  • .changeset/efp-plugin.md
  • apps/ensapi/src/omnigraph-api/schema.ts
  • apps/ensapi/src/omnigraph-api/schema/account-efp.ts
  • apps/ensapi/src/omnigraph-api/schema/account.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-ids.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-list.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.ts
  • apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts
  • apps/ensapi/src/omnigraph-api/schema/efp.ts
  • apps/ensindexer/ponder/src/register-handlers.ts
  • apps/ensindexer/src/plugins/efp/README.md
  • apps/ensindexer/src/plugins/efp/constants.ts
  • apps/ensindexer/src/plugins/efp/event-handlers.ts
  • apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts
  • apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts
  • apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts
  • apps/ensindexer/src/plugins/efp/lib/ids.ts
  • apps/ensindexer/src/plugins/efp/lib/list-metadata.test.ts
  • apps/ensindexer/src/plugins/efp/lib/list-metadata.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts
  • apps/ensindexer/src/plugins/efp/plugin.ts
  • apps/ensindexer/src/plugins/index.ts
  • packages/datasources/src/abis/efp/AccountMetadata.ts
  • packages/datasources/src/abis/efp/ListRecords.ts
  • packages/datasources/src/abis/efp/ListRegistry.ts
  • packages/datasources/src/lib/types.ts
  • packages/datasources/src/mainnet.ts
  • packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts
  • packages/ensdb-sdk/src/ensindexer-abstract/index.ts
  • packages/ensnode-sdk/src/ensindexer/config/types.ts

Comment on lines +51 to +55
const scope = and(
where?.owner ? eq(ensIndexerSchema.efpLists.owner, where.owner as Hex) : undefined,
where?.user ? eq(ensIndexerSchema.efpLists.user, where.user as Hex) : undefined,
where?.manager ? eq(ensIndexerSchema.efpLists.manager, where.manager as Hex) : undefined,
);
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Confirm address normalization matches DB-stored casing for the owner/user/manager filters.

These eq(...) comparisons cast the incoming where.{owner,user,manager} values as Hex and match them directly against stored columns. If the Address scalar's normalization (e.g. checksummed) differs from how the EFP indexer persists addresses (lowercased hex per the id helpers), these filters will silently return empty result sets. The analogous primaryList path explicitly lowercased both sides; the same guarantee must hold here. The same applies to efpLists.user in account-efp.ts (Line 43) and efpAccountMetadata.address (Line 134).

#!/bin/bash
# 1) How are EFP list addresses persisted (owner/user/manager)? Look for lowercasing.
fd -e ts . apps/ensindexer/src/plugins/efp | xargs rg -nP -C3 '\b(owner|user|manager)\b.*(toLowerCase|address|Hex)' 

# 2) Inspect the EFP id helpers for normalization of addresses.
fd 'ids.ts' apps/ensindexer/src/plugins/efp --exec cat {}

# 3) Resolve the Address scalar definition / NormalizedAddress normalization.
rg -nP -C3 '(NormalizedAddress|normalizeAddress|getAddress|toLowerCase)' --type=ts -g '*scalar*' -g '*address*'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensapi/src/omnigraph-api/schema/efp.ts` around lines 51 - 55, The
owner/user/manager filters use direct eq(ensIndexerSchema.efpLists.owner,
where.owner as Hex) which can mismatch stored lowercased IDs; normalize both
sides to the same casing (lowercase) before comparison or use the existing EFP
id helper/normalize function used elsewhere (e.g., primaryList path) so
comparisons succeed; update the comparisons in efp.ts
(where.owner/where.user/where.manager vs
efpLists.owner/efpLists.user/efpLists.manager) to compare lowercased values (or
call the id helper) and make the same change for efpLists.user in account-efp.ts
and efpAccountMetadata.address so all three places use identical normalization.

Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts
Comment thread apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts
… 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.
Drop the "valid for address records" hedge: the field is a non-null Address and EFP indexes
only recordType 1. Regenerated SDL.
Copilot AI review requested due to automatic review settings May 29, 2026 18:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…range

An UpdateListStorageLocation payload carries an opaque 32-byte chain id; Number(chainId) then
fed an out-of-range value into the int8 columns, which could lose precision (above 2^53) or overflow
(above 2^63) and crash the handler, halting indexing from one bad update. The decoder now rejects a
chain id outside (0, 2^53 - 1], so the handler's existing undecodable-location branch clears and
warns instead. Safe-but-unindexed chain ids are kept (they just yield empty records); only unsafe
values are rejected. Adds a test.
user/manager metadata is keyed by storage location, but it was stored transiently:
efp_pending_list_metadata was drained-and-deleted, and UpdateListMetadata skipped recording it when a
mapping already existed. Combined with clearing roles on a re-point, a list that moved away and back
to a slot (or a second list reusing a slot) lost its roles with nothing to restore. The per-location
metadata is now recorded durably on every UpdateListMetadata and re-applied to whichever list points
at the slot on each (re-)point. Renamed efp_pending_list_metadata to efp_list_metadata since it is no
longer transient staging.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts (1)

116-134: 🧹 Nitpick | 🔵 Trivial | 🏗️ Heavy lift

Potential N+1 in EfpListRecord.list resolver

apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts (lines 116-134) performs a separate efpListStorageLocations lookup per record to resolve tokenId via efpStorageLocationId(chainId, contractAddress, slot). Records within the same list share that key, so the same lookup is repeated across many records; EfpListRef batching doesn’t cover this mapping.

No existing loadableObjectRef/dataloader for efpListStorageLocationstokenId (keyed by efpStorageLocationId(...)) exists in apps/ensapi/src/omnigraph-api, so batching/deduping would require introducing a loadable ref for that mapping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts` around lines 116 -
134, The EfpListRecord.list resolver currently issues one DB query per record to
fetch tokenId from efpListStorageLocations using efpStorageLocationId(chainId,
contractAddress, slot); create a loadable object ref (using loadableObjectRef or
DataLoader-style pattern) keyed by the efpStorageLocationId value that batches
and dedups loads from ensDb/ensIndexerSchema.efpListStorageLocations, register
the new loader in di.context, and replace the inline select in the resolver
(EfpListRecord.list) to call the loader (by passing
efpStorageLocationId(record.chainId, record.contractAddress, record.slot)) and
return the resolved tokenId or null.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts`:
- Around line 62-75: Add explicit boundary tests around the safe-integer guard
in parseListStorageLocation: add assertions that a chainId exactly equal to the
maximum allowed safe chain ID (e.g., MAX_SAFE_CHAIN_ID or
Number.MAX_SAFE_INTEGER if used) is accepted (returns non-null) and that chainId
equal to that max + 1 is rejected (returns null). Locate the test block using
parseListStorageLocation and add two cases constructing hex inputs for chainId =
MAX_SAFE_CHAIN_ID and chainId = MAX_SAFE_CHAIN_ID + 1 so the edge behavior (> vs
>=) is pinned down.

---

Outside diff comments:
In `@apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts`:
- Around line 116-134: The EfpListRecord.list resolver currently issues one DB
query per record to fetch tokenId from efpListStorageLocations using
efpStorageLocationId(chainId, contractAddress, slot); create a loadable object
ref (using loadableObjectRef or DataLoader-style pattern) keyed by the
efpStorageLocationId value that batches and dedups loads from
ensDb/ensIndexerSchema.efpListStorageLocations, register the new loader in
di.context, and replace the inline select in the resolver (EfpListRecord.list)
to call the loader (by passing efpStorageLocationId(record.chainId,
record.contractAddress, record.slot)) and return the resolved tokenId or null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8153f741-b54a-4cfe-9579-645f8df55b52

📥 Commits

Reviewing files that changed from the base of the PR and between f07849a and 49dee93.

⛔ Files ignored due to path filters (1)
  • packages/enssdk/src/omnigraph/generated/schema.graphql is excluded by !**/generated/**
📒 Files selected for processing (10)
  • apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts
  • apps/ensindexer/src/plugins/efp/README.md
  • apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts
  • apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts
  • apps/ensindexer/src/plugins/efp/lib/ids.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts
  • apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts
  • packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts

Comment on lines +62 to +75
it("returns null for a chain id outside the JS-safe integer range", () => {
const addressHex = "ab".repeat(20);
const slotHex = "cd".repeat(32);
// chainId = 2^60, far above 2^53 - 1
const bigChainIdHex = (2n ** 60n).toString(16).padStart(64, "0");
expect(
parseListStorageLocation(`0x0101${bigChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
// chainId = 0 is not a valid chain
const zeroChainIdHex = "0".repeat(64);
expect(
parseListStorageLocation(`0x0101${zeroChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
});
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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding exact-boundary cases for the safe-integer guard.

The negative cases use 2^60 and 0, but the guard's precise edge (chainId === MAX_SAFE_CHAIN_ID accepted vs MAX_SAFE_CHAIN_ID + 1 rejected) is untested. Adding both pins the > MAX_SAFE_CHAIN_ID comparison against future off-by-one regressions.

💚 Suggested additional assertions
     const zeroChainIdHex = "0".repeat(64);
     expect(
       parseListStorageLocation(`0x0101${zeroChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
     ).toBeNull();
+    // boundary: 2^53 - 1 is the largest accepted chain id
+    const maxSafeHex = BigInt(Number.MAX_SAFE_INTEGER).toString(16).padStart(64, "0");
+    expect(
+      parseListStorageLocation(`0x0101${maxSafeHex}${addressHex}${slotHex}` as `0x${string}`),
+    ).not.toBeNull();
+    // boundary: 2^53 is rejected
+    const overSafeHex = (BigInt(Number.MAX_SAFE_INTEGER) + 1n).toString(16).padStart(64, "0");
+    expect(
+      parseListStorageLocation(`0x0101${overSafeHex}${addressHex}${slotHex}` as `0x${string}`),
+    ).toBeNull();
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("returns null for a chain id outside the JS-safe integer range", () => {
const addressHex = "ab".repeat(20);
const slotHex = "cd".repeat(32);
// chainId = 2^60, far above 2^53 - 1
const bigChainIdHex = (2n ** 60n).toString(16).padStart(64, "0");
expect(
parseListStorageLocation(`0x0101${bigChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
// chainId = 0 is not a valid chain
const zeroChainIdHex = "0".repeat(64);
expect(
parseListStorageLocation(`0x0101${zeroChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
});
it("returns null for a chain id outside the JS-safe integer range", () => {
const addressHex = "ab".repeat(20);
const slotHex = "cd".repeat(32);
// chainId = 2^60, far above 2^53 - 1
const bigChainIdHex = (2n ** 60n).toString(16).padStart(64, "0");
expect(
parseListStorageLocation(`0x0101${bigChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
// chainId = 0 is not a valid chain
const zeroChainIdHex = "0".repeat(64);
expect(
parseListStorageLocation(`0x0101${zeroChainIdHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
// boundary: 2^53 - 1 is the largest accepted chain id
const maxSafeHex = BigInt(Number.MAX_SAFE_INTEGER).toString(16).padStart(64, "0");
expect(
parseListStorageLocation(`0x0101${maxSafeHex}${addressHex}${slotHex}` as `0x${string}`),
).not.toBeNull();
// boundary: 2^53 is rejected
const overSafeHex = (BigInt(Number.MAX_SAFE_INTEGER) + 1n).toString(16).padStart(64, "0");
expect(
parseListStorageLocation(`0x0101${overSafeHex}${addressHex}${slotHex}` as `0x${string}`),
).toBeNull();
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts`
around lines 62 - 75, Add explicit boundary tests around the safe-integer guard
in parseListStorageLocation: add assertions that a chainId exactly equal to the
maximum allowed safe chain ID (e.g., MAX_SAFE_CHAIN_ID or
Number.MAX_SAFE_INTEGER if used) is accepted (returns non-null) and that chainId
equal to that max + 1 is rejected (returns null). Locate the test block using
parseListStorageLocation and add two cases constructing hex inputs for chainId =
MAX_SAFE_CHAIN_ID and chainId = MAX_SAFE_CHAIN_ID + 1 so the edge behavior (> vs
>=) is pinned down.

The Omnigraph endpoint was gated on `unigraph`/`ensv2`, so `PLUGINS=efp` (or `subgraph,efp`)
returned 503 and the indexed EFP data was unqueryable, since the `efp` namespace is its only API
surface. The shared `hasOmnigraphApiConfigSupport` prerequisite (in ensnode-sdk, also consumed by
ensadmin) now also accepts `efp`, keeping EFP independent of Unigraph as intended. With EFP alone the
ENS query fields are present but return no data. Adds a unit test for the gate.
Copilot AI review requested due to automatic review settings May 29, 2026 20:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 39 out of 41 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

packages/datasources/src/mainnet.ts:1

  • The EFP AccountMetadata contract on Base is configured at 0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef, which is the exact same address used below for the ListRecords contract on Ethereum mainnet (line 558). These are two different contract types, so having identical addresses across the two configurations is suspicious and at least one is likely incorrect. Please cross-check the canonical EFP deployments — Base AccountMetadata should be 0x5289…17EF only if that matches the docs; otherwise either the Base address or the Ethereum ListRecords address needs to be updated. An incorrect address will silently produce zero events and an empty efp_* dataset on that chain.
    packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts:1
  • efp_list_metadata is mutable (the ListRecords:UpdateListMetadata handler does onConflictDoUpdate({ value })) but only stores createdAt. Every other mutable EFP table (efp_lists, efp_list_storage_locations, efp_account_metadata) also tracks updatedAt, which is useful both for debugging and for operators who want to see when a role was last changed. Consider adding an updatedAt: t.bigint().notNull() column here and writing it in the update branch for consistency.

Comment on lines +64 to +68
case EFP_OPCODE.ADD_TAG: {
const tagOp = parseTagOp(parsed.data);
if (!tagOp) return;
const id = listRecordId(chainId, contractAddress, slot, tagOp.record);
const record = await context.ensDb.find(ensIndexerSchema.efpListRecords, { id });
Comment on lines +121 to +123
export function parseTagOp(data: Hex | string | null | undefined): ParsedTagOp | null {
if (!data || typeof data !== "string" || !isHex(data)) return null;
if (data.length < RECORD_PREFIX_WITH_0X_LENGTH) return null;
Comment on lines +52 to +63
const owner = event.args.to.toLowerCase() as Hex;
await context.ensDb
.insert(ensIndexerSchema.efpLists)
.values({
tokenId,
owner,
nftChainId: context.chain.id,
nftContractAddress: event.log.address.toLowerCase() as Hex,
createdAt: ts,
updatedAt: ts,
})
.onConflictDoUpdate({ owner, updatedAt: ts });
Comment on lines +158 to +165
primaryList: t.field({
description:
"The account's validated primary EFP list: the list named by the account's `primary-list` metadata, returned only if that list's `user` role matches the account (the EFP two-step Primary List validation). Null if unset, not indexed, or unvalidated.",
type: EfpListRef,
nullable: true,
args: { address: t.arg({ type: "Address", required: true }) },
resolve: (_parent, args) => resolveValidatedPrimaryListTokenId(args.address),
}),
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.

2 participants