feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2224
feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2224Quantumlyy wants to merge 46 commits into
Conversation
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`.
…mary-list Output of `pnpm generate`.
…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.
🦋 Changeset detectedLatest commit: d1954e9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 24 packages
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 |
|
@Quantumlyy is attempting to deploy a commit to the NameHash Team on Vercel. A member of the Team first needs to authorize it. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds EFP support across the stack: datasources/ABIs, ENSDb tables, EFP indexer plugin and handlers, parsing/ID helpers, Omnigraph GraphQL ChangesEFP Indexer and API Integration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
…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.
…-rooted EFP Output of `pnpm generate`.
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.
There was a problem hiding this comment.
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
AccountMetadatacontract on Base is configured at0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef, which is the exact same address used below for theListRecordscontract 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 — BaseAccountMetadatashould be0x5289…17EFonly if that matches the docs; otherwise either the Base address or the EthereumListRecordsaddress needs to be updated. An incorrect address will silently produce zero events and an emptyefp_*dataset on that chain.
packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts:1 efp_list_metadatais mutable (theListRecords:UpdateListMetadatahandler doesonConflictDoUpdate({ value })) but only storescreatedAt. Every other mutable EFP table (efp_lists,efp_list_storage_locations,efp_account_metadata) also tracksupdatedAt, which is useful both for debugging and for operators who want to see when a role was last changed. Consider adding anupdatedAt: t.bigint().notNull()column here and writing it in the update branch for consistency.
Define EFPBase, EFPOptimism, and EFPEthereum datasources for the ens-test-env (devnet) namespace, all pointing at the single Anvil chain (id 31337) where the EFP contracts are deployed. This unlocks the `efp` plugin on ens-test-env via the existing required-datasource check; the plugin's per-chain-id Ponder config then collapses the three datasources onto chain 31337 (their shared ListRecords address makes this a no-op merge rather than triple-indexing). Addresses are captured from the EFP devnet image deployed in attach mode on top of the pinned contracts-v2 ENS deployment, and verified stable across clean redeploys. Add a config test asserting `efp` activates on ens-test-env and indexes one chain.
Add an efp-devnet service that deploys the EFP contracts onto the ENS devnet's anvil node in attach mode (image pinned to a master build, mirroring the contracts-v2 pin), gate ensindexer on its health, and add `efp` to the devnet PLUGINS. The result of `docker compose -f docker/docker-compose.devnet.yml up` is one chain (id 31337) carrying both ENS v2 and EFP contracts, indexed together.
Bring the efp-devnet service up alongside the ENS devnet in the integration orchestrator, gate it on devnet health, and add `efp` to the indexer's plugins so the integration stack indexes ENS and EFP together on chain 31337. Add EFP Omnigraph integration assertions against the demoGraph scenario: the two-step-validated `efp.primaryList` (and its null case), the account-rooted `Account.efp` view, and the full `account -> primaryList.records -> node.account.efp.primaryList` deep walk. These double as the silent-failure guard for the hardcoded devnet addresses: wrong addresses index zero EFP rows and fail these loudly rather than passing vacuously.
Add a TypeScript EFP seeder (run during the integration seed phase) that mints
dedicated lists and drives the op sequences the demoGraph scenario does not:
- tag de-duplication (a repeated ADD_TAG is a no-op),
- the embedded-tags cascade on REMOVE_RECORD plus a fresh re-ADD,
- a junk-suffixed REMOVE_RECORD that still deletes the canonically-keyed record,
- a malformed `user` metadata value that clears the role (live path), and
- role durability across a storage-location re-point: a list moved away from its
slot and back recovers its role from the durable per-slot metadata.
Each record is anchored by a synthetic target address (shared via
`@ensnode/datasources`) so the assertions look records up by `recordData` without
depending on a token id; the targets double as the `EfpListRecord.account` null
path. Also exposes the EFPListMinter devnet address (not indexed) for the seeder.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@docker/services/efp-devnet.yml`:
- Line 24: Update the YAML array formatting for the healthcheck under the "test"
key: remove the extra spaces inside the bracketed array so it uses compact form
(e.g., change the current test: [ "CMD", "curl", "--fail", "-s",
"http://localhost:8000/health" ] to a compact bracket style) to satisfy YAMLlint
and maintain style consistency for the bracketed array containing "CMD", "curl",
"--fail", "-s", "http://localhost:8000/health".
🪄 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: d04c6be7-e458-473c-8762-f6fa52a4da34
📒 Files selected for processing (13)
.changeset/efp-plugin.mdapps/ensapi/src/omnigraph-api/schema/efp.integration.test.tsapps/ensindexer/src/config/config.test.tsdocker/docker-compose.devnet.ymldocker/docker-compose.orchestrator.ymldocker/envs/.env.docker.devnetdocker/services/efp-devnet.ymlpackages/datasources/src/devnet/constants.tspackages/datasources/src/ens-test-env.tspackages/ensnode-sdk/src/omnigraph-api/prerequisites.test.tspackages/ensnode-sdk/src/omnigraph-api/prerequisites.tspackages/integration-test-env/src/lifecycle.tspackages/integration-test-env/src/seed/efp.ts
# Conflicts: # apps/ensapi/src/omnigraph-api/schema/account.ts
- AccountMetadata: strip NUL bytes from the free-form on-chain `key` before using it as a primary-key component (Postgres text columns reject NUL; matches the tag path's api-v2 parity normalization). - Add a parse-list-storage-location test for the exact safe-integer chain-id boundary (2^53-1 accepted, 2^53 rejected). - Add an EFP integration assertion for the primaryList two-step validation's mismatch branch (metadata present but the list's `user` does not match -> null), with a seed actor address exposed for it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 51 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/datasources/src/mainnet.ts:1
- The
EFPEthereumdatasource config setsListRecords.addressto0x5289..., which is also used above as the BaseAccountMetadataaddress. That strongly suggests a copy/paste or wiring mistake and would cause the EFP plugin to index the wrong contract (or no relevant events) on Ethereum mainnet. Please verify the correct Ethereum mainnetListRecordsdeployment address (and its start block), and update this datasource accordingly.
…sync The indexer now strips NUL bytes from the AccountMetadata `key` before writing, so the ENSApi-side `efpAccountMetadataId` must do the same (and lowercase the address) to build a matching lookup id. Apply the identical normalization in both helpers.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 51 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/datasources/src/mainnet.ts:1
- The
EFPEthereumdatasource is configured with aListRecordsaddress that matches the BaseAccountMetadataaddress used earlier in this same file (0x5289...17ef). This is very likely a copy/paste error and would cause the EFP plugin to index the wrong contract on Ethereum mainnet (silently producing wrong/empty EFP data for that chain). UpdateDatasourceNames.EFPEthereum.contracts.ListRecords.addressto the actual Ethereum mainnetListRecordscontract address from EFP’s published deployment coordinates (and keep the ABI asListRecords).
`EfpListRecord.list` did one storage-location -> tokenId lookup per node, an N+1
on `efp.listRecords { node { list } }`. Convert it to a Pothos loadable field
(the dataloader plugin is already enabled) keyed by storage-location id, so a page
resolves all `list` back-refs in two batched queries instead of one per record.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 51 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
packages/datasources/src/mainnet.ts:1
- The
EFPEthereum.ListRecords.addressis set to the same value used for the BaseAccountMetadatacontract (0x5289...). That’s an internal inconsistency in this file (same address assigned to two different contract roles), and would cause the EthereumListRecordsdatasource to index the wrong contract/events. Please verify the Ethereum-mainnetListRecordsaddress and correct it (and consider adding a comment or link to the provenance for each chain’s address to avoid future copy/paste slips).
packages/datasources/src/mainnet.ts:1 - The
EFPEthereum.ListRecords.addressis set to the same value used for the BaseAccountMetadatacontract (0x5289...). That’s an internal inconsistency in this file (same address assigned to two different contract roles), and would cause the EthereumListRecordsdatasource to index the wrong contract/events. Please verify the Ethereum-mainnetListRecordsaddress and correct it (and consider adding a comment or link to the provenance for each chain’s address to avoid future copy/paste slips).
Greptile SummaryThis PR introduces an opt-in EFP (Ethereum Follow Protocol) plugin that indexes three on-chain contracts (
Confidence Score: 5/5Safe to merge. The change is fully opt-in, isolated to new efp_* tables, and additive to the Omnigraph API with no modifications to existing ENS queries or data. The byte decoders are well-guarded (version checks, type checks, length checks, no coercion of malformed input). The tokenId lexicographic-ordering issue that would have broken pagination is fixed via the new numeric-text helpers. Handler logic handles all four ListOp opcodes, burn/transfer/re-point cases, and the event-ordering ambiguity between UpdateListMetadata and UpdateListStorageLocation. The two-step primaryList validation is correct. The integration test seed covers the trickiest behavioral paths (tag dedup, cascade delete, junk-suffixed remove, malformed-role clear). The two findings are quality/performance notes, not correctness blockers. efp.schema.ts (missing compound index for recordData+id cursor pagination) and ListRegistry.ts (orphaned records on storage-location move worth a decision comment). Important Files Changed
Reviews (3): Last reviewed commit: "fix(efp): order and paginate EFP lists n..." | Re-trigger Greptile |
Annotate each EFP contract address in the mainnet namespace with its chain and a reference to https://docs.efp.app/production/deployments/. In particular, call out that EFPAccountMetadata (Base) and EFPListRecords (Ethereum mainnet) intentionally share 0x5289…0F17EF: EFP deploys via CREATE2, so one address maps to a different contract per chain. Confirmed against the EFP deployments doc and api-v2.
Lowercase the address in the ENSApi-side id helper so it builds the exact same key the indexer persists (which lowercases via `accountMetadataId`), regardless of the caller's casing. No behavior change for normalized input; makes the helper canonical.
`efpLists.tokenId` is a `text` column (a uint256 that does not fit a Postgres integer type), so `efp.lists` / `Account.efp.lists` ordered and cursor-compared it lexicographically: "10" sorted before "2", and the before/after cursors skipped or repeated rows once there were more than 9 lists. Add `paginateByNumericText` / `orderByNumericText` helpers that cast to `::numeric`, and use them for the tokenId-keyed list connections (the id-keyed record/metadata connections are unchanged). Verified by a new integration test that seeds >9 lists and paginates across the single->double-digit boundary in small pages.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 50 out of 52 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
docker/services/efp-devnet.yml:1
healthcheck.start_intervalis not supported uniformly across older Docker Engine / docker compose versions and can cause compose parsing failures in CI or developer environments. If you need quicker initial probes, prefer adjustinginterval/start_period(portable), or document/pin the minimum required Docker/Compose versions for running this stack.
apps/ensindexer/src/plugins/efp/plugin.ts:1- When multiple datasources share the same
chain.id(as in the devnet “collapse”), these object spreads will silently overwrite earlier per-chain entries if the keys collide, which can mask misconfiguration (e.g., if one datasource’sListRecordsaddress diverges, the last spread “wins” without an error). Consider deduplicating bychainIdfirst (or explicitly validating that collidingchainIds have identical contract coordinates) and throwing on mismatch so this failure mode becomes loud instead of silent.
| export const paginateByNumericText = ( | ||
| column: Column, | ||
| before: string | undefined, | ||
| after: string | undefined, | ||
| ) => | ||
| and( | ||
| before ? sql`${column}::numeric < ${cursors.decode<string>(before)}::numeric` : undefined, | ||
| after ? sql`${column}::numeric > ${cursors.decode<string>(after)}::numeric` : undefined, | ||
| ); |
| export const orderByNumericText = (column: Column, inverted: boolean) => | ||
| inverted ? desc(sql`${column}::numeric`) : asc(sql`${column}::numeric`); |
Reviewer Focus (Read This First)
Spend the most time on three areas:
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)totokenIdreverse index (efp_list_storage_locations) lets list-metadata events resolve the owning NFT by primary key, and a record's tags are an embeddedtext[]rather than a join table, so removing a record is a single primary-key delete with no cascade.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.apps/ensapi/src/omnigraph-api/schema/efp*.ts), especiallyprimaryListand 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 singleefproot, 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: theefpnamespace appears when the plugin is enabled, and is a no-op otherwise.What Changed (Concrete)
efpplugin (apps/ensindexer/src/plugins/efp/): Ponder config, event handlers, pure byte decoders, constants, README. Enabled viaPLUGINS, activatable only on themainnetENS namespace (enforced by datasource presence).EFPBase,EFPOptimism,EFPEthereumwith addresses, ABIs, and start blocks forListRegistry,AccountMetadata, andListRecords.efp.schema.ts):efp_lists,efp_list_storage_locations,efp_list_records(tags embedded astext[]),efp_account_metadata,efp_pending_list_metadata.ListRegistry, Base), list records and tags (ListRecords, Base, Optimism, Ethereum mainnet), account metadata (AccountMetadata, Base, includingprimary-list).efproot namespace (list/lists,listRecordswith each record'stags, owninglist, and targetaccount,accountMetadata/accountMetadatas, validatedprimaryList(address)), plus account-rooted access:Account.efp(an account's validatedprimaryListand thelistsit is theuserof) and anEfpListRecord.accountedge resolving a record's target address to itsAccount. Together these let one query walk from an account to whom it follows and on into ENS names and EFP lists. Cursor-paginated connections.enssdk.Example queries
A forward join: an account, its validated primary list, and the accounts it follows.
A reverse join, correlating one account to other users: who follows a target, and which user owns each following list.
The first traverses
primaryListto itsrecords; the second traverseslistRecordsto eachrecord.listand itsuser. 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:
Today
accountis null for a followee with no ENS presence (see the open question at the top);efp.primaryList(recordData)resolves any address regardless.Design & Planning
tokenscopeplugin, the closest in-repo analog (a standalone plugin that indexes its own contracts and needs no ENS Registry/Resolver data). The implementation started from theefpnodeproof-of-concept and was reworked to be idiomatic: contracts come from the datasource catalog, handlers write directly tocontext.ensDbby primary key, and the PoC'sEFPStoreabstraction, hard-coded addresses, and multi-column WHERE lookups were removed.textscolumn).efp_list_record_tagsjoin table with a cascade delete onREMOVE_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.locationType = 2list storage, and theeth.efp.listENS text-record convention. Neither is part of the EFP spec.Self-Review
Bugs caught while reviewing the diff end-to-end:
recordcolumn 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.Addressscalar. Now only type-1 address records are indexed.find()API where the client is plain Drizzle. Fixed to.select().limit(1).Logic simplified:
REMOVE_RECORDwent 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
efproot so they do not clutter the Query root.Dead code removed: the
eth.efp.listResolver subscription, its parser, and theefp_ens_list_pointerstable, when that feature was cut as non-spec.Cross-Codebase Alignment
efpListRecordTags,metadataValueToAddress,.array((onchainTable array columns),attach_Resolver,texts(array writes in handlers).tokenscopeplugin (structural model); the subgraph Resolver handler (confirmed the embedded-array read-modify-write pattern atResolver.ts:202and:414); the Omnigraph builder and connection helpers.alphapreset, no compatibility coupling, handlers and contracts gated on the plugin being enabled. Root typecheck confirms no other workspace consumes theefp_*tables.Downstream & Consumer Impact
efproot field to the Omnigraph GraphQL API. Additive and behind the plugin; no change to existing ENS queries. SDL and introspection regenerated.mainnetnamespace. A re-index is implied by the new schema and handlers.efp_*tables and theefpnamespace. One term to call out: "primary list" is validated (theprimary-listmetadata plus auser-role match), distinct from the rawprimary-listclaim thataccountMetadatasreturns.Testing Evidence
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.text[]round-trip.primaryListdecode and validation, and the clear-on-malformed-role behavior. These are the queued devnet assertions.Scope Reductions
efp.accountMetadatasteering consumers to the validatedprimaryList; a possible tag filter onlistRecordswith a supporting index, if demand appears.Risk Analysis
user/managermetadata 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.efpplugin (removes the surface), then re-index.Pre-Review Checklist (Blocking)