-
Notifications
You must be signed in to change notification settings - Fork 17
feat: index the Ethereum Follow Protocol (EFP) and expose it via the Omnigraph API #2224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Quantumlyy
wants to merge
49
commits into
namehash:main
Choose a base branch
from
Quantumlyy:Quantumlyy/efp-plugin
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
49 commits
Select commit
Hold shift + click to select a range
a6605c9
feat(datasources): add EFP (Ethereum Follow Protocol) datasources
Quantumlyy 1da8efe
feat(ensdb-sdk): add EFP abstract schema and register PluginName.EFP
Quantumlyy 86bf4ed
feat(ensindexer): add EFP byte decoders and id helpers
Quantumlyy 6450193
feat(ensindexer): add EFP plugin handlers and config
Quantumlyy b6020ec
feat(ensindexer): activate EFP plugin handlers
Quantumlyy 1e2f235
feat(ensapi): expose EFP via the Omnigraph API
Quantumlyy f1bf80e
chore(enssdk): regenerate Omnigraph SDL and introspection for EFP
Quantumlyy e3d0df9
chore: changeset for EFP Omnigraph API
Quantumlyy 7bea134
style(efp): match ENSNode comment conventions
Quantumlyy fcae841
refactor(efp): drop the non-spec eth.efp.list ENS text record
Quantumlyy 3c6aefc
feat(ensapi): resolve EFP primary list via the Omnigraph API
Quantumlyy df85110
chore(enssdk): regenerate Omnigraph SDL and introspection for EFP pri…
Quantumlyy 9dfcd8c
fix(efp): skip reserved record types; normalize records to canonical …
Quantumlyy 5fa1c5b
refactor(efp): key records canonically and embed tags for PK-only rem…
Quantumlyy 3542613
fix(efp): reject ListOps, records, and storage locations with unsuppo…
Quantumlyy 2786714
fix(efp): only reflect exactly-20-byte values onto list user/manager …
Quantumlyy 82947b7
Merge branch 'main' into Quantumlyy/efp-plugin
Quantumlyy 82e630b
fix(efp): enforce each schema's own version byte; name LSL offsets ex…
Quantumlyy f4f9232
fix(efp): drop list rows on NFT burn instead of storing a zero-addres…
Quantumlyy 739c52b
fix(efp): warn on tag ops referencing an absent record
Quantumlyy f2f798b
fix(ensapi): compare primary-list user case-insensitively
Quantumlyy 4e82fe4
refactor(ensapi): extract validated primary-list resolution to a shar…
Quantumlyy c566222
feat(ensapi): add account-rooted EFP access via Account.efp and EfpLi…
Quantumlyy 777b00d
chore(enssdk): regenerate Omnigraph SDL and introspection for account…
Quantumlyy 06b1de1
fix(efp): clear list user/manager roles when the storage location moves
Quantumlyy 74900d0
chore(ensapi): scope the EFP Omnigraph changeset to delivered behavior
Quantumlyy f07849a
fix(ensapi): reject malformed primary-list metadata (require a 32-byt…
Quantumlyy 660305c
fix(efp): lowercase and validate parsed record/tag prefixes; tidy LSL…
Quantumlyy a61a506
fix(efp): clear stale location on undecodable storage-location update…
Quantumlyy 9206d43
docs(efp): clarify clear-on-malformed roles and bounded pending-metad…
Quantumlyy cd32b6d
docs(ensapi): reword EfpListRecord.recordData description
Quantumlyy 1f2e514
fix(efp): reject storage-location chain ids outside the safe integer …
Quantumlyy 49dee93
fix(efp): make list role metadata durable per storage location
Quantumlyy 4d0c663
fix(ensapi): serve the Omnigraph API for EFP-only configs
Quantumlyy 65a3c32
feat(datasources): add EFP datasources to the ens-test-env namespace
Quantumlyy f7abcca
feat(ensindexer): deploy and index EFP on the devnet stack
Quantumlyy dd57feb
test(efp): index EFP in the integration env + assert the demoGraph graph
Quantumlyy 25677dc
test(efp): seed and assert EFP handler edge cases on the devnet
Quantumlyy 0e00eda
Merge remote-tracking branch 'origin/main' into Quantumlyy/efp-plugin
Quantumlyy 382f74d
fix(efp): address PR review feedback
Quantumlyy b7e642e
fix(efp): keep the ENSApi and indexer account-metadata id helpers in …
Quantumlyy dde22a2
Merge branch 'main' into Quantumlyy/efp-plugin
Quantumlyy a23cd99
perf(efp): batch the EfpListRecord.list back-reference
Quantumlyy e35c73b
docs(efp): document the verified cross-chain EFP mainnet addresses
Quantumlyy 97518de
fix(efp): canonicalize the address in efpAccountMetadataId
Quantumlyy d1954e9
fix(efp): order and paginate EFP lists numerically by tokenId
Quantumlyy 8d13ba0
Merge branch 'main' into Quantumlyy/efp-plugin
Quantumlyy bfb08c7
perf(efp): index-back the numeric tokenId list pagination
Quantumlyy f861760
docs(efp): clarify nftChainId chain + name the address record-type co…
Quantumlyy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "ensapi": minor | ||
| "enssdk": minor | ||
| --- | ||
|
|
||
| Expose EFP (Ethereum Follow Protocol) data through the Omnigraph API under a single `efp` root field. The `EfpQuery` namespace provides `list` / `lists`, `listRecords` (with each record's owning `list`), `accountMetadata` / `accountMetadatas`, and `primaryList` (an account's validated primary list, applying the EFP two-step user-role check), with cursor-paginated connections and where-filters (owner/user/manager, recordData, address). EFP is also reachable from the ENS graph: `Account.efp` exposes an account's validated `primaryList` and the `lists` it is the `user` of, and `EfpListRecord.account` links a record to the `Account` it points at. Requires the `efp` plugin to be enabled on the indexer. |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| "@ensnode/datasources": minor | ||
| "@ensnode/ensdb-sdk": minor | ||
| "@ensnode/ensnode-sdk": minor | ||
| "ensindexer": minor | ||
| --- | ||
|
|
||
| Add an EFP (Ethereum Follow Protocol) indexer plugin. Enable it by including `efp` in the `PLUGINS` environment variable (on the `mainnet` ENS namespace, or the `ens-test-env` devnet) to index EFP list NFTs, records, tags, and account metadata into ENSDb's `efp_*` tables. |
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; | ||
| import { and, eq } from "drizzle-orm"; | ||
| import type { NormalizedAddress } from "enssdk"; | ||
| import type { Hex } from "viem"; | ||
|
|
||
| import di from "@/di"; | ||
| import { builder } from "@/omnigraph-api/builder"; | ||
| import { orderByNumericText, paginateByNumericText } from "@/omnigraph-api/lib/connection-helpers"; | ||
| import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; | ||
| import { EfpListRef, TOKEN_ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/efp-list"; | ||
| import { resolveValidatedPrimaryListTokenId } from "@/omnigraph-api/schema/efp-primary-list"; | ||
|
|
||
| /** | ||
| * `AccountEfp` is the account-rooted view of Ethereum Follow Protocol data, reached via | ||
| * `Account.efp`. Its parent is the account's address, and every field is keyed on the EFP `user` | ||
| * role (the account a list represents). Protocol-rooted queries (a list by token id, "who follows | ||
| * this address") remain on the root `efp` namespace. | ||
| */ | ||
| export const AccountEfpRef = builder.objectRef<NormalizedAddress>("AccountEfp"); | ||
|
|
||
| AccountEfpRef.implement({ | ||
| description: "An account's Ethereum Follow Protocol (EFP) presence.", | ||
| fields: (t) => ({ | ||
| ///////////////////////////// | ||
| // AccountEfp.primaryList | ||
| ///////////////////////////// | ||
| primaryList: t.field({ | ||
| description: | ||
| "The account's validated primary EFP list: the list named by its `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, | ||
| resolve: (address) => resolveValidatedPrimaryListTokenId(address), | ||
| }), | ||
|
|
||
| //////////////////////// | ||
| // AccountEfp.lists | ||
| //////////////////////// | ||
| lists: t.connection({ | ||
| description: "The EFP lists this account is the `user` of (the lists representing it).", | ||
| type: EfpListRef, | ||
| resolve: (address, args) => { | ||
| const { ensDb, ensIndexerSchema } = di.context; | ||
| const scope = eq(ensIndexerSchema.efpLists.user, address as Hex); | ||
|
|
||
| return lazyConnection({ | ||
| totalCount: () => ensDb.$count(ensIndexerSchema.efpLists, scope), | ||
| connection: () => | ||
| resolveCursorConnection( | ||
| { ...TOKEN_ID_PAGINATED_CONNECTION_ARGS, args }, | ||
| ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => | ||
| ensDb | ||
| .select() | ||
| .from(ensIndexerSchema.efpLists) | ||
| .where( | ||
| and( | ||
| scope, | ||
| paginateByNumericText(ensIndexerSchema.efpLists.tokenId, before, after), | ||
| ), | ||
| ) | ||
| .orderBy(orderByNumericText(ensIndexerSchema.efpLists.tokenId, inverted)) | ||
| .limit(limit), | ||
| ), | ||
| }); | ||
| }, | ||
| }), | ||
| }), | ||
| }); |
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
94 changes: 94 additions & 0 deletions
94
apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { inArray } from "drizzle-orm"; | ||
| import type { ChainId, NormalizedAddress } from "enssdk"; | ||
|
|
||
| import di from "@/di"; | ||
| import { builder } from "@/omnigraph-api/builder"; | ||
| import { getModelId } from "@/omnigraph-api/lib/get-model-id"; | ||
|
|
||
| export const EfpAccountMetadataRef = builder.loadableObjectRef("EfpAccountMetadata", { | ||
| load: (ids: string[]) => { | ||
| const { ensDb, ensIndexerSchema } = di.context; | ||
| return ensDb | ||
| .select() | ||
| .from(ensIndexerSchema.efpAccountMetadata) | ||
| .where(inArray(ensIndexerSchema.efpAccountMetadata.id, ids)); | ||
| }, | ||
| toKey: getModelId, | ||
| cacheResolved: true, | ||
| sort: true, | ||
| }); | ||
|
|
||
| export type EfpAccountMetadata = Exclude<typeof EfpAccountMetadataRef.$inferType, string>; | ||
|
|
||
| ////////////////////// | ||
| // EfpAccountMetadata | ||
| ////////////////////// | ||
| EfpAccountMetadataRef.implement({ | ||
| description: 'An EFP `(address, key) -> value` account-metadata entry (e.g. "primary-list").', | ||
| fields: (t) => ({ | ||
| ////////////////////////// | ||
| // EfpAccountMetadata.id | ||
| ////////////////////////// | ||
| id: t.field({ type: "String", nullable: false, resolve: (metadata) => metadata.id }), | ||
|
|
||
| /////////////////////////////// | ||
| // EfpAccountMetadata.chainId | ||
| /////////////////////////////// | ||
| chainId: t.field({ | ||
| description: "Chain id of the AccountMetadata contract.", | ||
| type: "ChainId", | ||
| nullable: false, | ||
| resolve: (metadata) => metadata.chainId as ChainId, | ||
| }), | ||
|
|
||
| /////////////////////////////////////// | ||
| // EfpAccountMetadata.contractAddress | ||
| /////////////////////////////////////// | ||
| contractAddress: t.field({ | ||
| description: "Address of the AccountMetadata contract.", | ||
| type: "Address", | ||
| nullable: false, | ||
| resolve: (metadata) => metadata.contractAddress as NormalizedAddress, | ||
| }), | ||
|
|
||
| /////////////////////////////// | ||
| // EfpAccountMetadata.address | ||
| /////////////////////////////// | ||
| address: t.field({ | ||
| description: "The account this metadata belongs to.", | ||
| type: "Address", | ||
| nullable: false, | ||
| resolve: (metadata) => metadata.address as NormalizedAddress, | ||
| }), | ||
|
|
||
| /////////////////////////// | ||
| // EfpAccountMetadata.key | ||
| /////////////////////////// | ||
| key: t.field({ | ||
| description: "The metadata key (UTF-8 string).", | ||
| type: "String", | ||
| nullable: false, | ||
| resolve: (metadata) => metadata.key, | ||
| }), | ||
|
|
||
| ///////////////////////////// | ||
| // EfpAccountMetadata.value | ||
| ///////////////////////////// | ||
| value: t.field({ | ||
| description: "The metadata value (raw bytes).", | ||
| type: "Hex", | ||
| nullable: false, | ||
| resolve: (metadata) => metadata.value, | ||
| }), | ||
|
|
||
| ///////////////////////////////// | ||
| // EfpAccountMetadata.createdAt | ||
| ///////////////////////////////// | ||
| createdAt: t.field({ type: "BigInt", nullable: false, resolve: (m) => m.createdAt }), | ||
|
|
||
| ///////////////////////////////// | ||
| // EfpAccountMetadata.updatedAt | ||
| ///////////////////////////////// | ||
| updatedAt: t.field({ type: "BigInt", nullable: false, resolve: (m) => m.updatedAt }), | ||
| }), | ||
| }); |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { NormalizedAddress } from "enssdk"; | ||
| import type { Hex } from "viem"; | ||
|
|
||
| /** | ||
| * Synthetic composite primary keys for the EFP tables, mirroring the EFP plugin's | ||
| * `apps/ensindexer/src/plugins/efp/lib/ids.ts`. ENSApi reads ENSDb rows by these ids, so these | ||
| * formats MUST stay in sync with the indexer. | ||
| */ | ||
|
|
||
| /** | ||
| * `efp_account_metadata` key: `${address}-${key}`. Mirrors the indexer's `accountMetadataId`: the | ||
| * address is lowercase (a NormalizedAddress) and NUL bytes are stripped from the free-form `key` | ||
| * (the indexer strips them before writing, so a lookup must strip them too to match the stored id). | ||
| */ | ||
| export function efpAccountMetadataId(address: NormalizedAddress, key: string): string { | ||
| return `${address.toLowerCase()}-${key.replace(/\0/g, "")}`; | ||
| } | ||
|
Quantumlyy marked this conversation as resolved.
Quantumlyy marked this conversation as resolved.
|
||
|
|
||
| /** `efp_list_storage_locations` key: `${chainId}-${contractAddress}-${slot}` (lowercased hex). */ | ||
| export function efpStorageLocationId(chainId: number, contractAddress: Hex, slot: Hex): string { | ||
| return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}`; | ||
| } | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { builder } from "@/omnigraph-api/builder"; | ||
|
|
||
| ////////////////////// | ||
| // Inputs | ||
| ////////////////////// | ||
|
|
||
| /** | ||
| * Filters for the `efp.lists` connection. | ||
| */ | ||
| export const EfpListsWhereInput = builder.inputType("EfpListsWhereInput", { | ||
| description: "Filter EFP lists by their owner, user, or manager address.", | ||
| fields: (t) => ({ | ||
| owner: t.field({ type: "Address", description: "The ERC-721 owner of the list NFT." }), | ||
| user: t.field({ type: "Address", description: "The address allowed to post records." }), | ||
| manager: t.field({ | ||
| type: "Address", | ||
| description: "The address allowed to administer the list.", | ||
| }), | ||
| }), | ||
| }); | ||
|
|
||
| /** | ||
| * Filters for the `efp.listRecords` connection. | ||
| */ | ||
| export const EfpListRecordsWhereInput = builder.inputType("EfpListRecordsWhereInput", { | ||
| description: "Filter EFP list records.", | ||
| fields: (t) => ({ | ||
| recordData: t.field({ | ||
| type: "Address", | ||
| description: | ||
| "The target address of an address record (recordType 1). Filtering by this answers 'which lists follow this address?'.", | ||
| }), | ||
| recordType: t.field({ type: "Int", description: "The EFP record type (1 = address)." }), | ||
| }), | ||
| }); | ||
|
|
||
| /** | ||
| * Filters for the `efp.accountMetadatas` connection. | ||
| */ | ||
| export const EfpAccountMetadatasWhereInput = builder.inputType("EfpAccountMetadatasWhereInput", { | ||
| description: "Filter EFP account metadata.", | ||
| fields: (t) => ({ | ||
| address: t.field({ type: "Address", required: true, description: "The account address." }), | ||
| }), | ||
| }); |
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.