feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2296
Open
shrugs wants to merge 67 commits into
Open
feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API#2296shrugs wants to merge 67 commits into
shrugs wants to merge 67 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.
…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 `user`/`manager` roles come from `UpdateListMetadata` events scoped to a storage location. When `UpdateListStorageLocation` moves a list to a different `(chainId, contract, slot)`, the old roles no longer apply, so clear them in the same update; pending metadata for the new location repopulates them in the drain step. Without this a moved list kept the previous location's `user`, which `Account.efp.lists` and primary-list validation would wrongly attribute to that account until new metadata arrived.
The `EfpListRecord.account` edge links a record to its `Account` but does not yet resolve a usable Account for addresses with no ENS presence (most EFP followees); whether to make that universal is an open question raised on the PR. Drop the changeset claim of a universal cross-user walk so the release notes match what ships.
…e uint256) `decodePrimaryListTokenId` coerced any non-empty hex via `BigInt`, so a malformed `primary-list` value such as `0x01` resolved to token 1 and `efp.primaryList` / `Account.efp.primaryList` could report a primary list for invalid metadata whenever that list's `user` matched. EFP defines `primary-list` as `abi.encodePacked(uint256)` (exactly 32 bytes), so reject any other length before converting. Adds a unit test for the decoder.
… length guard parseRecord lowercases the canonical record and recordData (matching the List Storage Location decoder), so ADD vs REMOVE/tag ops and the API recordData filter key into the same rows even if a payload carries uppercase hex. parseTagOp now validates and canonicalizes its 22-byte prefix through parseRecord (version/type checked, lowercased) rather than slicing raw bytes, so a tag for a non-address record is rejected outright instead of silently missing a row. Drops the redundant early-length guard in the LSL decoder for the exact-length check up front. Adds parser tests.
…; document burn semantics UpdateListStorageLocation now finds the list row first and guards on its presence (skip rather than update a missing row), and when the new payload is undecodable (future version, non-onchain type, or malformed) it clears the stale decoded location, its reverse mapping, and its location-scoped roles, logs a warning, and keeps the raw payload, rather than leaving the list resolving its old slot. Documents that a burned list keeps its efp_list_records rows (they mirror the on-chain ListRecords contract; the list back-ref resolves to null).
…ata rows Comment why a malformed user/manager value clears the role (faithful to on-chain state), and note in the schema that pending-metadata rows for a slot no list ever points at are a bounded, low-volume artifact.
Member
Author
|
@greptile review |
|
Starting review. |
Member
Author
|
@greptile review |
# Conflicts: # apps/ensapi/src/omnigraph-api/schema/account.ts # packages/ensskills/skills/omnigraph/SKILL.md
Member
Author
|
@greptile review |
| import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; | ||
| import { buildAccountPrimaryNamesSelection } from "@/omnigraph-api/lib/resolution/account-primary-names-selection"; | ||
| import { resolvePrimaryNameRecords } from "@/omnigraph-api/lib/resolution/resolve-primary-name-records"; | ||
| import { AccountEfpRef } from "@/omnigraph-api/schema/account-efp"; |
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the EFP feature (originally PR #2224,
Quantumlyy/efp-plugin) onto anamehash/ensnodebranch, with the Omnigraph API surface refactored.What it does
efp_*tables (enable viaefpinPLUGINS).efproot field:Query.efp(protocol-rooted):list(by:),lists(where:),listRecords(where:)(each record exposing its owninglist), cursor-paginated with owner/user/manager and recordData filters.Account.efp(account-rooted): validatedprimaryList(EFP two-step user-role check), the social graphfollowing/followers, thelistsit is theuserof, and accountmetadata(key:)/metadatas.EfpListRecord.accountlinks a record to theAccountit points at.efpfields are null when the indexer lacks theefpplugin.Social graph (
Account.efp.following/Account.efp.followers)following— the address records in the account's validated primary list, excludingblock/mute-tagged records; empty when the account has no validated primary list.followers— the distinctusers whose validated primary list holds this account as a non-block/muterecord.efp_list_records→efp_lists→efp_account_metadata) that encodes the EFP two-step Primary List validation directly in SQL.Surface refactor (vs. the original #2224 branch)
TokenIdscalar (uint256 → decimal string);tokenIdis a realbigintend-to-end, dropping theidx_tokenId_numericworkaround index and its numeric-text pagination.by{}/where{}argument pattern across the EFP queries.Query.efpontoAccount.efp(primaryList,following,followers,metadata/metadatas).enssdk/efpsubmodule (consumed by both ENSIndexer and ENSApi).apps/ensindexer/src/lib/efp/;efp.ts→query-efp.ts.Areas that would benefit from EFP-expert review
These are deliberate decisions where ENSNode conventions and EFP/
api-v2behaviour diverge — worth a second opinion from someone who knows EFP semantics:interpretMetadataKey→hasNullByte, the standard ENSNode pattern), not stripped: a Postgrestextcolumn can't store a NULL byte, and stripping would silently collapse distinct on-chain keys onto one stored key (seeAccountMetadata.ts,enssdk/efp/ids.ts). Tags, by contrast, still strip embedded NULL bytes to matchapi-v2behaviour (parse-list-op.ts). Is the tag-stripapi-v2parity the right call, or should tags also reject? Are there other EFP key/value surfaces where a NULL byte should reject vs. strip?followersJoins()/resolveValidatedPrimaryListTokenIdexpress "metadata'sprimaryListTokenId= the list'sidAND the metadata owner = the list'suser" as join predicates. Confirm this exactly matches the EFP Primary List validation spec (no missing leg, correct handling of re-points and stale metadata).EFP_NON_FOLLOW_TAGS = ["block", "mute"]is the full set of tags that exclude a record fromfollowing/followers. Is that complete and correct per EFP, and should the address record type filter (EFP_ADDRESS_RECORD_TYPE = 1) be broadened?version | type | addressprefix with trailing junk truncated — confirm this is the right canonicalization for EFP list ops.Validation
pnpm typecheck,pnpm lint, unit tests,pnpm generate(idempotent, SDL/introspection/SKILL regenerated).pnpm test:integration:cigreen with the EFP docker stack.