Skip to content
Open
Show file tree
Hide file tree
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 May 22, 2026
1da8efe
feat(ensdb-sdk): add EFP abstract schema and register PluginName.EFP
Quantumlyy May 23, 2026
86bf4ed
feat(ensindexer): add EFP byte decoders and id helpers
Quantumlyy May 25, 2026
6450193
feat(ensindexer): add EFP plugin handlers and config
Quantumlyy May 27, 2026
b6020ec
feat(ensindexer): activate EFP plugin handlers
Quantumlyy May 29, 2026
1e2f235
feat(ensapi): expose EFP via the Omnigraph API
Quantumlyy May 29, 2026
f1bf80e
chore(enssdk): regenerate Omnigraph SDL and introspection for EFP
Quantumlyy May 29, 2026
e3d0df9
chore: changeset for EFP Omnigraph API
Quantumlyy May 29, 2026
7bea134
style(efp): match ENSNode comment conventions
Quantumlyy May 29, 2026
fcae841
refactor(efp): drop the non-spec eth.efp.list ENS text record
Quantumlyy May 29, 2026
3c6aefc
feat(ensapi): resolve EFP primary list via the Omnigraph API
Quantumlyy May 29, 2026
df85110
chore(enssdk): regenerate Omnigraph SDL and introspection for EFP pri…
Quantumlyy May 29, 2026
9dfcd8c
fix(efp): skip reserved record types; normalize records to canonical …
Quantumlyy May 29, 2026
5fa1c5b
refactor(efp): key records canonically and embed tags for PK-only rem…
Quantumlyy May 29, 2026
3542613
fix(efp): reject ListOps, records, and storage locations with unsuppo…
Quantumlyy May 29, 2026
2786714
fix(efp): only reflect exactly-20-byte values onto list user/manager …
Quantumlyy May 29, 2026
82947b7
Merge branch 'main' into Quantumlyy/efp-plugin
Quantumlyy May 29, 2026
82e630b
fix(efp): enforce each schema's own version byte; name LSL offsets ex…
Quantumlyy May 29, 2026
f4f9232
fix(efp): drop list rows on NFT burn instead of storing a zero-addres…
Quantumlyy May 29, 2026
739c52b
fix(efp): warn on tag ops referencing an absent record
Quantumlyy May 29, 2026
f2f798b
fix(ensapi): compare primary-list user case-insensitively
Quantumlyy May 29, 2026
4e82fe4
refactor(ensapi): extract validated primary-list resolution to a shar…
Quantumlyy May 29, 2026
c566222
feat(ensapi): add account-rooted EFP access via Account.efp and EfpLi…
Quantumlyy May 29, 2026
777b00d
chore(enssdk): regenerate Omnigraph SDL and introspection for account…
Quantumlyy May 29, 2026
06b1de1
fix(efp): clear list user/manager roles when the storage location moves
Quantumlyy May 29, 2026
74900d0
chore(ensapi): scope the EFP Omnigraph changeset to delivered behavior
Quantumlyy May 29, 2026
f07849a
fix(ensapi): reject malformed primary-list metadata (require a 32-byt…
Quantumlyy May 29, 2026
660305c
fix(efp): lowercase and validate parsed record/tag prefixes; tidy LSL…
Quantumlyy May 29, 2026
a61a506
fix(efp): clear stale location on undecodable storage-location update…
Quantumlyy May 29, 2026
9206d43
docs(efp): clarify clear-on-malformed roles and bounded pending-metad…
Quantumlyy May 29, 2026
cd32b6d
docs(ensapi): reword EfpListRecord.recordData description
Quantumlyy May 29, 2026
1f2e514
fix(efp): reject storage-location chain ids outside the safe integer …
Quantumlyy May 29, 2026
49dee93
fix(efp): make list role metadata durable per storage location
Quantumlyy May 29, 2026
4d0c663
fix(ensapi): serve the Omnigraph API for EFP-only configs
Quantumlyy May 29, 2026
65a3c32
feat(datasources): add EFP datasources to the ens-test-env namespace
Quantumlyy Jun 1, 2026
f7abcca
feat(ensindexer): deploy and index EFP on the devnet stack
Quantumlyy Jun 1, 2026
dd57feb
test(efp): index EFP in the integration env + assert the demoGraph graph
Quantumlyy Jun 1, 2026
25677dc
test(efp): seed and assert EFP handler edge cases on the devnet
Quantumlyy Jun 1, 2026
0e00eda
Merge remote-tracking branch 'origin/main' into Quantumlyy/efp-plugin
Quantumlyy Jun 1, 2026
382f74d
fix(efp): address PR review feedback
Quantumlyy Jun 1, 2026
b7e642e
fix(efp): keep the ENSApi and indexer account-metadata id helpers in …
Quantumlyy Jun 1, 2026
dde22a2
Merge branch 'main' into Quantumlyy/efp-plugin
Quantumlyy Jun 1, 2026
a23cd99
perf(efp): batch the EfpListRecord.list back-reference
Quantumlyy Jun 1, 2026
e35c73b
docs(efp): document the verified cross-chain EFP mainnet addresses
Quantumlyy Jun 1, 2026
97518de
fix(efp): canonicalize the address in efpAccountMetadataId
Quantumlyy Jun 1, 2026
d1954e9
fix(efp): order and paginate EFP lists numerically by tokenId
Quantumlyy Jun 1, 2026
8d13ba0
Merge branch 'main' into Quantumlyy/efp-plugin
Quantumlyy Jun 1, 2026
bfb08c7
perf(efp): index-back the numeric tokenId list pagination
Quantumlyy Jun 1, 2026
f861760
docs(efp): clarify nftChainId chain + name the address record-type co…
Quantumlyy Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/efp-omnigraph.md
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.
8 changes: 8 additions & 0 deletions .changeset/efp-plugin.md
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.
25 changes: 24 additions & 1 deletion apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, asc, desc, gt, lt } from "drizzle-orm";
import { and, asc, desc, gt, lt, sql } from "drizzle-orm";
import z from "zod/v4";

import { cursors } from "@/omnigraph-api/lib/cursors";
Expand Down Expand Up @@ -42,6 +42,29 @@ export const paginateByInt = (
export const orderPaginationBy = (column: Column, inverted: boolean) =>
inverted ? desc(column) : asc(column);

/**
* Cursor pagination condition for a numeric value stored in a `text` column (e.g. an EFP `tokenId`,
* a sequential uint256 that does not fit a Postgres integer type). Compares with a `::numeric` cast
* so ordering is numeric rather than lexicographic (otherwise `"10"` sorts before `"2"`, breaking
* ordering and the `before`/`after` cursors once there are more than 9 rows).
*/
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,
);
Comment thread
Quantumlyy marked this conversation as resolved.

/**
* Order-by clause matching {@link paginateByNumericText}: orders a numeric value held in a `text`
* column by its numeric value (ascending, or descending when `inverted`).
*/
export const orderByNumericText = (column: Column, inverted: boolean) =>
inverted ? desc(sql`${column}::numeric`) : asc(sql`${column}::numeric`);
Comment thread
Quantumlyy marked this conversation as resolved.

/**
* An empty Relay Connection, used when short-circuiting connection resolvers.
*/
Expand Down
6 changes: 6 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { builder } from "@/omnigraph-api/builder";

import "./schema/account-efp";
import "./schema/account-id";
import "./schema/reverse-resolve";
import "./schema/connection";
import "./schema/domain";
import "./schema/domain-canonical";
import "./schema/domain-inputs";
import "./schema/efp";
import "./schema/efp-account-metadata";
import "./schema/efp-inputs";
import "./schema/efp-list";
import "./schema/efp-list-record";
import "./schema/event";
import "./schema/label";
import "./schema/name-or-node";
Expand Down
67 changes: 67 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/account-efp.ts
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),
),
});
},
}),
}),
});
12 changes: 12 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getModelId } from "@/omnigraph-api/lib/get-model-id";
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";
import { AccountIdInput } from "@/omnigraph-api/schema/account-id";
import {
ID_PAGINATED_CONNECTION_ARGS,
Expand Down Expand Up @@ -146,6 +147,17 @@ AccountRef.implement({
}),
}),

///////////////
// Account.efp
///////////////
efp: t.field({
description:
"This Account's Ethereum Follow Protocol (EFP) presence: its lists and validated primary list.",
type: AccountEfpRef,
nullable: false,
resolve: (parent) => parent.id,
}),

///////////////////////
// Account.permissions
///////////////////////
Expand Down
94 changes: 94 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts
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 }),
}),
});
22 changes: 22 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/efp-ids.ts
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, "")}`;
}
Comment thread
Quantumlyy marked this conversation as resolved.
Comment thread
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()}`;
}
45 changes: 45 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts
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." }),
}),
});
Loading