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 34 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: 4d0c663 The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 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. |
|
Warning Review limit reached
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 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds 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 ChangesEFP Indexer and API Integration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 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`.
| recordData: t.field({ | ||
| description: "The followed/target address. Valid for address records (recordType 1).", | ||
| type: "Address", | ||
| nullable: false, | ||
| resolve: (record) => record.recordData as NormalizedAddress, | ||
| }), |
| 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; | ||
| } |
| function decodePrimaryListTokenId(value: Hex): string | null { | ||
| if (!value || value === "0x") return null; | ||
| try { | ||
| return BigInt(value).toString(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
| addOnchainEventListener( | ||
| namespaceContract(pluginName, "ListRegistry:UpdateListStorageLocation"), | ||
| async ({ context, event }) => { | ||
| const parsed = parseListStorageLocation(event.args.listStorageLocation); | ||
| if (!parsed) return; |
| 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.
There was a problem hiding this comment.
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
ListRecordsaddress (0x5289…17ef) is identical to the BaseAccountMetadataaddress declared above. EFP'sListRecordsandAccountMetadataare different contracts with different ABIs, so this looks like a copy/paste from the Base entry rather than the real mainnetListRecordsdeployment. Please re-verify againstdocs.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.
| return { | ||
| version, | ||
| recordType, | ||
| record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex, | ||
| recordData: `0x${body}` as Hex, | ||
| }; |
| 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; |
| 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; | ||
| } |
| 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 }); |
| function decodePrimaryListTokenId(value: Hex): string | null { | ||
| if (!value || value === "0x") return null; | ||
| try { | ||
| return BigInt(value).toString(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
| 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 }); |
| 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 |
| 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 }); |
| 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 |
| createdAt: ts, | ||
| updatedAt: ts, | ||
| }) | ||
| .onConflictDoUpdate({ value: event.args.value, updatedAt: ts }); |
| recordType, | ||
| record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex, | ||
| recordData: `0x${body}` as Hex, | ||
| }; |
| createdAt: ts, | ||
| updatedAt: ts, | ||
| }) | ||
| .onConflictDoUpdate({ value: event.args.value, updatedAt: ts }); |
…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.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (2)
apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts (2)
106-111:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winLowercase hex components to ensure key normalization.
The
recordandrecordDatafields are used in database keys (vialistRecordId), 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 winValidate record headers and lowercase the record prefix.
parseTagOpextracts the 22-byte record prefix without validating theversionandrecordTypeheaders, and without lowercasing. This causes two issues:
- Missing header validation: Tag operations with invalid record headers (e.g.,
version !== 1orrecordType !== 1) pass validation but fail silently during record lookup, reducing defense-in-depth even though contracts may enforce correct headers.- Missing lowercase normalization: The
recordfield is used inlistRecordIdfor database lookups. Without lowercasing, mixed-case hex from different sources won't collide correctly, violating the design principle stated inids.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
⛔ Files ignored due to path filters (2)
packages/enssdk/src/omnigraph/generated/introspection.tsis excluded by!**/generated/**packages/enssdk/src/omnigraph/generated/schema.graphqlis excluded by!**/generated/**
📒 Files selected for processing (37)
.changeset/efp-omnigraph.md.changeset/efp-plugin.mdapps/ensapi/src/omnigraph-api/schema.tsapps/ensapi/src/omnigraph-api/schema/account-efp.tsapps/ensapi/src/omnigraph-api/schema/account.tsapps/ensapi/src/omnigraph-api/schema/efp-account-metadata.tsapps/ensapi/src/omnigraph-api/schema/efp-ids.tsapps/ensapi/src/omnigraph-api/schema/efp-inputs.tsapps/ensapi/src/omnigraph-api/schema/efp-list-record.tsapps/ensapi/src/omnigraph-api/schema/efp-list.tsapps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.tsapps/ensapi/src/omnigraph-api/schema/efp-primary-list.tsapps/ensapi/src/omnigraph-api/schema/efp.tsapps/ensindexer/ponder/src/register-handlers.tsapps/ensindexer/src/plugins/efp/README.mdapps/ensindexer/src/plugins/efp/constants.tsapps/ensindexer/src/plugins/efp/event-handlers.tsapps/ensindexer/src/plugins/efp/handlers/AccountMetadata.tsapps/ensindexer/src/plugins/efp/handlers/ListRecords.tsapps/ensindexer/src/plugins/efp/handlers/ListRegistry.tsapps/ensindexer/src/plugins/efp/lib/ids.tsapps/ensindexer/src/plugins/efp/lib/list-metadata.test.tsapps/ensindexer/src/plugins/efp/lib/list-metadata.tsapps/ensindexer/src/plugins/efp/lib/parse-list-op.test.tsapps/ensindexer/src/plugins/efp/lib/parse-list-op.tsapps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.tsapps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.tsapps/ensindexer/src/plugins/efp/plugin.tsapps/ensindexer/src/plugins/index.tspackages/datasources/src/abis/efp/AccountMetadata.tspackages/datasources/src/abis/efp/ListRecords.tspackages/datasources/src/abis/efp/ListRegistry.tspackages/datasources/src/lib/types.tspackages/datasources/src/mainnet.tspackages/ensdb-sdk/src/ensindexer-abstract/efp.schema.tspackages/ensdb-sdk/src/ensindexer-abstract/index.tspackages/ensnode-sdk/src/ensindexer/config/types.ts
| 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, | ||
| ); |
There was a problem hiding this comment.
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.
… 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.
…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.
There was a problem hiding this comment.
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 liftPotential N+1 in
EfpListRecord.listresolver
apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts(lines 116-134) performs a separateefpListStorageLocationslookup per record to resolvetokenIdviaefpStorageLocationId(chainId, contractAddress, slot). Records within the same list share that key, so the same lookup is repeated across many records;EfpListRefbatching doesn’t cover this mapping.No existing
loadableObjectRef/dataloader forefpListStorageLocations→tokenId(keyed byefpStorageLocationId(...)) exists inapps/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
⛔ Files ignored due to path filters (1)
packages/enssdk/src/omnigraph/generated/schema.graphqlis excluded by!**/generated/**
📒 Files selected for processing (10)
apps/ensapi/src/omnigraph-api/schema/efp-list-record.tsapps/ensindexer/src/plugins/efp/README.mdapps/ensindexer/src/plugins/efp/handlers/ListRecords.tsapps/ensindexer/src/plugins/efp/handlers/ListRegistry.tsapps/ensindexer/src/plugins/efp/lib/ids.tsapps/ensindexer/src/plugins/efp/lib/parse-list-op.test.tsapps/ensindexer/src/plugins/efp/lib/parse-list-op.tsapps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.tsapps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.tspackages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts
| 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(); | ||
| }); |
There was a problem hiding this comment.
🧹 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.
| 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.
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.
| 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 }); |
| 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 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 }); |
| 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), | ||
| }), |
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)