diff --git a/.changeset/efp-omnigraph.md b/.changeset/efp-omnigraph.md new file mode 100644 index 0000000000..d2e49c993a --- /dev/null +++ b/.changeset/efp-omnigraph.md @@ -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. diff --git a/.changeset/efp-plugin.md b/.changeset/efp-plugin.md new file mode 100644 index 0000000000..cd5308d240 --- /dev/null +++ b/.changeset/efp-plugin.md @@ -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. diff --git a/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts index 4609774095..8647dcffec 100644 --- a/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts @@ -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"; @@ -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(before)}::numeric` : undefined, + after ? sql`${column}::numeric > ${cursors.decode(after)}::numeric` : undefined, + ); + +/** + * 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`); + /** * An empty Relay Connection, used when short-circuiting connection resolvers. */ diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index 112ac3f20e..6771ae40a5 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -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"; diff --git a/apps/ensapi/src/omnigraph-api/schema/account-efp.ts b/apps/ensapi/src/omnigraph-api/schema/account-efp.ts new file mode 100644 index 0000000000..30f2e4e737 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/account-efp.ts @@ -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("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), + ), + }); + }, + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 783d832b3f..162e6caae3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -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, @@ -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 /////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts new file mode 100644 index 0000000000..114111dc67 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts @@ -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; + +////////////////////// +// 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 }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts new file mode 100644 index 0000000000..550ef4b191 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts @@ -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, "")}`; +} + +/** `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()}`; +} diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts b/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts new file mode 100644 index 0000000000..a4908dac08 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts @@ -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." }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts new file mode 100644 index 0000000000..0c69f3eba8 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -0,0 +1,164 @@ +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"; +import { AccountRef } from "@/omnigraph-api/schema/account"; +import { efpStorageLocationId } from "@/omnigraph-api/schema/efp-ids"; +import { EfpListRef } from "@/omnigraph-api/schema/efp-list"; + +export const EfpListRecordRef = builder.loadableObjectRef("EfpListRecord", { + load: (ids: string[]) => { + const { ensDb, ensIndexerSchema } = di.context; + return ensDb + .select() + .from(ensIndexerSchema.efpListRecords) + .where(inArray(ensIndexerSchema.efpListRecords.id, ids)); + }, + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type EfpListRecord = Exclude; + +///////////////// +// EfpListRecord +///////////////// +EfpListRecordRef.implement({ + description: "A single record within an EFP list (an address it follows), with its tags.", + fields: (t) => ({ + ///////////////////// + // EfpListRecord.id + ///////////////////// + id: t.field({ type: "String", nullable: false, resolve: (record) => record.id }), + + ////////////////////////// + // EfpListRecord.chainId + ////////////////////////// + chainId: t.field({ + description: "Chain id of the ListRecords contract holding this record.", + type: "ChainId", + nullable: false, + resolve: (record) => record.chainId as ChainId, + }), + + ////////////////////////////////// + // EfpListRecord.contractAddress + ////////////////////////////////// + contractAddress: t.field({ + description: "Address of the ListRecords contract holding this record.", + type: "Address", + nullable: false, + resolve: (record) => record.contractAddress as NormalizedAddress, + }), + + //////////////////////// + // EfpListRecord.slot + //////////////////////// + slot: t.field({ + description: "The list's storage slot (bytes32) within the ListRecords contract.", + type: "Hex", + nullable: false, + resolve: (record) => record.slot, + }), + + ////////////////////////// + // EfpListRecord.record + ////////////////////////// + record: t.field({ + description: "The full record payload (version | type | data).", + type: "Hex", + nullable: false, + resolve: (record) => record.record, + }), + + ////////////////////////////// + // EfpListRecord.recordType + ////////////////////////////// + recordType: t.field({ + description: "The EFP record type (1 = address).", + type: "Int", + nullable: false, + resolve: (record) => record.recordType, + }), + + ////////////////////////////// + // EfpListRecord.recordData + ////////////////////////////// + recordData: t.field({ + description: + "The followed/target address (the record's 20-byte payload). EFP indexes only address records (recordType 1).", + type: "Address", + nullable: false, + resolve: (record) => record.recordData as NormalizedAddress, + }), + + //////////////////////// + // EfpListRecord.tags + //////////////////////// + tags: t.field({ + description: 'UTF-8 tags attached to this record (e.g. "close-friend", "block").', + type: ["String"], + nullable: false, + resolve: (record) => record.tags, + }), + + ///////////////////////////// + // EfpListRecord.createdAt + ///////////////////////////// + createdAt: t.field({ type: "BigInt", nullable: false, resolve: (record) => record.createdAt }), + + /////////////////////// + // EfpListRecord.list + /////////////////////// + list: t.loadable({ + description: "The EFP list this record belongs to.", + type: EfpListRef, + nullable: true, + // Resolve each record to its storage-location id, then batch the location -> list lookup in + // `load` so a page of records resolves all `list` back-refs in two queries rather than one per + // node (avoids an N+1 on `efp.listRecords { node { list } }`). + resolve: (record) => + efpStorageLocationId(record.chainId, record.contractAddress, record.slot), + load: async (locationIds: string[]) => { + const { ensDb, ensIndexerSchema } = di.context; + + const mappings = await ensDb + .select({ + id: ensIndexerSchema.efpListStorageLocations.id, + tokenId: ensIndexerSchema.efpListStorageLocations.tokenId, + }) + .from(ensIndexerSchema.efpListStorageLocations) + .where(inArray(ensIndexerSchema.efpListStorageLocations.id, locationIds)); + const tokenIdByLocation = new Map(mappings.map((m) => [m.id, m.tokenId])); + + const tokenIds = [...new Set(tokenIdByLocation.values())]; + const lists = tokenIds.length + ? await ensDb + .select() + .from(ensIndexerSchema.efpLists) + .where(inArray(ensIndexerSchema.efpLists.tokenId, tokenIds)) + : []; + const listByTokenId = new Map(lists.map((l) => [l.tokenId, l])); + + return locationIds.map((locationId) => { + const tokenId = tokenIdByLocation.get(locationId); + return tokenId != null ? (listByTokenId.get(tokenId) ?? null) : null; + }); + }, + }), + + /////////////////////////// + // EfpListRecord.account + /////////////////////////// + account: t.field({ + description: + "The account this record points to (its `recordData`). Null if that address is not an indexed account.", + type: AccountRef, + nullable: true, + resolve: (record) => record.recordData as NormalizedAddress, + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts new file mode 100644 index 0000000000..275f17cdf2 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts @@ -0,0 +1,199 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and, eq, inArray } from "drizzle-orm"; +import type { ChainId, NormalizedAddress } from "enssdk"; + +import di from "@/di"; +import { builder } from "@/omnigraph-api/builder"; +import { + EMPTY_CONNECTION, + orderPaginationBy, + paginateBy, +} from "@/omnigraph-api/lib/connection-helpers"; +import { cursors } from "@/omnigraph-api/lib/cursors"; +import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { + ID_PAGINATED_CONNECTION_ARGS, + PAGINATION_DEFAULT_MAX_SIZE, + PAGINATION_DEFAULT_PAGE_SIZE, +} from "@/omnigraph-api/schema/constants"; +import { EfpListRecordRef } from "@/omnigraph-api/schema/efp-list-record"; + +export const EfpListRef = builder.loadableObjectRef("EfpList", { + load: (tokenIds: string[]) => { + const { ensDb, ensIndexerSchema } = di.context; + return ensDb + .select() + .from(ensIndexerSchema.efpLists) + .where(inArray(ensIndexerSchema.efpLists.tokenId, tokenIds)); + }, + // efp_lists is keyed by `tokenId`, not a synthetic `id`. + toKey: (list) => list.tokenId, + cacheResolved: true, + sort: true, +}); + +export type EfpList = Exclude; + +/** + * Connection args paginated by a list NFT's `tokenId` (efp_lists has no synthetic `id` column). + */ +export const TOKEN_ID_PAGINATED_CONNECTION_ARGS = { + toCursor: (list: { tokenId: string }) => cursors.encode(list.tokenId), + defaultSize: PAGINATION_DEFAULT_PAGE_SIZE, + maxSize: PAGINATION_DEFAULT_MAX_SIZE, +} as const; + +/////////// +// EfpList +/////////// +EfpListRef.implement({ + description: "An EFP list NFT (a ListRegistry token) and the records it holds.", + fields: (t) => ({ + ////////////////// + // EfpList.tokenId + ////////////////// + tokenId: t.field({ + description: "The ERC-721 token id of the list NFT (decimal string).", + type: "String", + nullable: false, + resolve: (list) => list.tokenId, + }), + + //////////////// + // EfpList.owner + //////////////// + owner: t.field({ + description: "The current ERC-721 owner of the list NFT.", + type: "Address", + nullable: false, + resolve: (list) => list.owner as NormalizedAddress, + }), + + /////////////// + // EfpList.user + /////////////// + user: t.field({ + description: "The address allowed to post records to this list.", + type: "Address", + nullable: true, + resolve: (list) => (list.user ?? null) as NormalizedAddress | null, + }), + + ////////////////// + // EfpList.manager + ////////////////// + manager: t.field({ + description: "The address allowed to administer this list.", + type: "Address", + nullable: true, + resolve: (list) => (list.manager ?? null) as NormalizedAddress | null, + }), + + ///////////////////// + // EfpList.nftChainId + ///////////////////// + nftChainId: t.field({ + description: + "Chain id of the ListRegistry NFT (Base / 8453 on the mainnet namespace; otherwise the active namespace's EFP deployment chain, e.g. 31337 on the ens-test-env devnet).", + type: "ChainId", + nullable: false, + resolve: (list) => list.nftChainId as ChainId, + }), + + ///////////////////////////// + // EfpList.nftContractAddress + ///////////////////////////// + nftContractAddress: t.field({ + description: "The ListRegistry contract address.", + type: "Address", + nullable: false, + resolve: (list) => list.nftContractAddress as NormalizedAddress, + }), + + ////////////////////////////////////// + // EfpList.listStorageLocationChainId + ////////////////////////////////////// + listStorageLocationChainId: t.field({ + description: "Decoded list storage location: target chain id.", + type: "ChainId", + nullable: true, + resolve: (list) => (list.listStorageLocationChainId ?? null) as ChainId | null, + }), + + ////////////////////////////////////////////// + // EfpList.listStorageLocationContractAddress + ////////////////////////////////////////////// + listStorageLocationContractAddress: t.field({ + description: "Decoded list storage location: target contract address.", + type: "Address", + nullable: true, + resolve: (list) => + (list.listStorageLocationContractAddress ?? null) as NormalizedAddress | null, + }), + + /////////////////////////////////// + // EfpList.listStorageLocationSlot + /////////////////////////////////// + listStorageLocationSlot: t.field({ + description: "Decoded list storage location: target slot (bytes32).", + type: "Hex", + nullable: true, + resolve: (list) => list.listStorageLocationSlot, + }), + + //////////////////// + // EfpList.createdAt + //////////////////// + createdAt: t.field({ type: "BigInt", nullable: false, resolve: (list) => list.createdAt }), + + //////////////////// + // EfpList.updatedAt + //////////////////// + updatedAt: t.field({ type: "BigInt", nullable: false, resolve: (list) => list.updatedAt }), + + ////////////////// + // EfpList.records + ////////////////// + records: t.connection({ + description: "The records currently in this list (the addresses it follows).", + type: EfpListRecordRef, + resolve: (list, args) => { + const { ensDb, ensIndexerSchema } = di.context; + + // A list's records live at its decoded onchain storage location. If the list has not yet + // emitted an UpdateListStorageLocation, it has no records to resolve. + if ( + list.listStorageLocationChainId === null || + list.listStorageLocationContractAddress === null || + list.listStorageLocationSlot === null + ) { + return EMPTY_CONNECTION; + } + + const scope = and( + eq(ensIndexerSchema.efpListRecords.chainId, list.listStorageLocationChainId), + eq( + ensIndexerSchema.efpListRecords.contractAddress, + list.listStorageLocationContractAddress, + ), + eq(ensIndexerSchema.efpListRecords.slot, list.listStorageLocationSlot), + ); + + return lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.efpListRecords, scope), + connection: () => + resolveCursorConnection( + { ...ID_PAGINATED_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + ensDb + .select() + .from(ensIndexerSchema.efpListRecords) + .where(and(scope, paginateBy(ensIndexerSchema.efpListRecords.id, before, after))) + .orderBy(orderPaginationBy(ensIndexerSchema.efpListRecords.id, inverted)) + .limit(limit), + ), + }); + }, + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.ts b/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.ts new file mode 100644 index 0000000000..eeb383fa91 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { decodePrimaryListTokenId } from "./efp-primary-list"; + +describe("decodePrimaryListTokenId", () => { + it("decodes a well-formed 32-byte uint256 value to a decimal token id", () => { + // abi.encodePacked(uint256 1) + expect(decodePrimaryListTokenId(`0x${"0".repeat(63)}1` as `0x${string}`)).toBe("1"); + // max uint256 + expect(decodePrimaryListTokenId(`0x${"f".repeat(64)}` as `0x${string}`)).toBe( + ((1n << 256n) - 1n).toString(), + ); + }); + + it("returns null for values that are not exactly 32 bytes", () => { + expect(decodePrimaryListTokenId("0x")).toBeNull(); + expect(decodePrimaryListTokenId("0x01")).toBeNull(); // 1 byte: must not coerce to token 1 + expect(decodePrimaryListTokenId(`0x${"0".repeat(62)}1` as `0x${string}`)).toBeNull(); // 31 bytes + expect(decodePrimaryListTokenId(`0x${"00".repeat(33)}` as `0x${string}`)).toBeNull(); // 33 bytes + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts b/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts new file mode 100644 index 0000000000..6635c359d5 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts @@ -0,0 +1,66 @@ +import { eq } from "drizzle-orm"; +import type { NormalizedAddress } from "enssdk"; +import type { Hex } from "viem"; + +import di from "@/di"; +import { efpAccountMetadataId } from "@/omnigraph-api/schema/efp-ids"; + +/** The EFP AccountMetadata key whose value is an account's primary-list token id. */ +export const EFP_PRIMARY_LIST_KEY = "primary-list"; + +/** A valid `primary-list` value is `abi.encodePacked(uint256 tokenId)`: "0x" + 32 bytes. */ +const PRIMARY_LIST_VALUE_HEX_LENGTH = 2 + 32 * 2; + +/** + * Decode a `primary-list` account-metadata value (`abi.encodePacked(uint256 tokenId)`) into a + * decimal token-id string, or `null` if it isn't well-formed. EFP defines the value as exactly a + * 32-byte uint256, so reject any other length rather than let `BigInt` coerce a malformed value + * (e.g. `0x01`) into a real token id. + */ +export function decodePrimaryListTokenId(value: Hex): string | null { + if (value.length !== PRIMARY_LIST_VALUE_HEX_LENGTH) return null; + try { + return BigInt(value).toString(); + } catch { + return null; + } +} + +/** + * Resolve an account's validated primary EFP list token id: the list named by the account's + * `primary-list` metadata, returned only when that list's `user` role matches the account (the EFP + * two-step Primary List validation). `null` if unset, not indexed, or unvalidated. + * + * Shared by `efp.primaryList(address)` and `Account.efp.primaryList`. + */ +export async function resolveValidatedPrimaryListTokenId( + address: NormalizedAddress, +): Promise { + const { ensDb, ensIndexerSchema } = di.context; + + const [metadata] = await ensDb + .select({ value: ensIndexerSchema.efpAccountMetadata.value }) + .from(ensIndexerSchema.efpAccountMetadata) + .where( + eq( + ensIndexerSchema.efpAccountMetadata.id, + efpAccountMetadataId(address, EFP_PRIMARY_LIST_KEY), + ), + ) + .limit(1); + if (!metadata) return null; + + const tokenId = decodePrimaryListTokenId(metadata.value); + if (tokenId === null) return null; + + // EFP "Primary List" is only valid when the named list's `user` role matches the account. + // Compare case-insensitively so validation is independent of input casing. + const [list] = await ensDb + .select({ user: ensIndexerSchema.efpLists.user }) + .from(ensIndexerSchema.efpLists) + .where(eq(ensIndexerSchema.efpLists.tokenId, tokenId)) + .limit(1); + if (!list?.user || list.user.toLowerCase() !== address.toLowerCase()) return null; + + return tokenId; +} diff --git a/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts new file mode 100644 index 0000000000..370581e905 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "vitest"; + +import { + accounts, + efpSeedActorAddress, + efpSeedRoleUser, + efpSeedTargets, +} from "@ensnode/datasources/devnet"; + +import { + flattenConnection, + type GraphQLConnection, + request, +} from "@/test/integration/graphql-utils"; +import { gql } from "@/test/integration/omnigraph-api-client"; + +/** + * EFP integration assertions against the devnet stack: the `efp` plugin indexing the EFP contracts + * deployed onto the ens-test-env chain (id 31337), seeded by the EFP devnet image's `demoGraph` + * scenario (alice / bob / carol each mint a primary list and follow the other two). + * + * The EFP devnet's named accounts are derived from the same Anvil mnemonic as the ENS devnet, so + * EFP "alice" / "bob" / "carol" (indices 1-3) are the ENS devnet's `owner` / `user` / `user2`. + * Notably alice (== the ENS `owner`) has ENS names, so it is the one EFP actor with an `account` + * row — which is what makes the `EfpListRecord.account` row-gating observable below. + * + * This file also acts as the silent-failure guard for the wiring: if the hardcoded EFP devnet + * addresses in `@ensnode/datasources` are wrong, the indexer comes up healthy but indexes no EFP + * data and these assertions fail loudly rather than passing vacuously. + */ +const alice = accounts.owner.address; // Anvil idx 1 == EFP "alice" +const bob = accounts.user.address; // Anvil idx 2 == EFP "bob" +const carol = accounts.user2.address; // Anvil idx 3 == EFP "carol" +const deployer = accounts.deployer.address; // Anvil idx 0; mints no list in demoGraph + +const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +describe("efp.primaryList (demoGraph)", () => { + type PrimaryListResult = { + efp: { + primaryList: { + tokenId: string; + user: string | null; + records: GraphQLConnection<{ recordData: string }>; + } | null; + }; + }; + + const EfpPrimaryList = gql` + query EfpPrimaryList($address: Address!) { + efp { + primaryList(address: $address) { + tokenId + user + records { + edges { node { recordData } } + } + } + } + } + `; + + it("resolves alice's validated primary list, following bob and carol", async () => { + const result = await request(EfpPrimaryList, { address: alice }); + + const list = result.efp.primaryList; + expect(list, "alice should have an indexed, validated primary list").not.toBeNull(); + // Two-step Primary List validation: the named list's `user` role must match the queried account. + expect(list?.user && eq(list.user, alice)).toBe(true); + expect(list?.tokenId).toMatch(/^\d+$/); + + // demoGraph has alice follow the other two peers. + const followed = flattenConnection(list!.records).map((r) => r.recordData); + expect(followed.some((a) => eq(a, bob))).toBe(true); + expect(followed.some((a) => eq(a, carol))).toBe(true); + }); + + it("returns null for an account with no primary-list metadata", async () => { + // The deployer opens minting but never mints a list, so it has no primary-list metadata. + const result = await request(EfpPrimaryList, { address: deployer }); + expect(result.efp.primaryList).toBeNull(); + }); +}); + +describe("Account.efp (demoGraph)", () => { + type AccountEfpResult = { + account: { + efp: { + primaryList: { tokenId: string } | null; + lists: GraphQLConnection<{ tokenId: string; user: string | null }>; + }; + } | null; + }; + + const AccountEfp = gql` + query AccountEfp($address: Address!) { + account(by: { address: $address }) { + efp { + primaryList { tokenId } + lists { edges { node { tokenId user } } } + } + } + } + `; + + it("exposes an account's validated primary list and the lists it is the user of", async () => { + // alice == the ENS devnet `owner`, so it has an `account` row to root the query on. + const result = await request(AccountEfp, { address: alice }); + expect(result.account, "alice should have an indexed ENS account").not.toBeNull(); + + expect(result.account?.efp.primaryList?.tokenId).toMatch(/^\d+$/); + + const lists = flattenConnection(result.account!.efp.lists); + expect(lists.length, "alice should be the user of at least one list").toBeGreaterThanOrEqual(1); + expect(lists.every((l) => l.user && eq(l.user, alice))).toBe(true); + }); +}); + +describe("Account.efp deep walk (demoGraph)", () => { + type DeepWalkResult = { + account: { + efp: { + primaryList: { + tokenId: string; + records: GraphQLConnection<{ + recordData: string; + account: { id: string; efp: { primaryList: { tokenId: string } | null } } | null; + }>; + } | null; + }; + } | null; + }; + + const EfpDeepWalk = gql` + query EfpDeepWalk($address: Address!) { + account(by: { address: $address }) { + efp { + primaryList { + tokenId + records { + edges { + node { + recordData + account { id efp { primaryList { tokenId } } } + } + } + } + } + } + } + } + `; + + it("walks account -> primaryList.records -> node.account.efp.primaryList", async () => { + // alice (token 0) follows bob (token 1) and carol (token 2); all three are indexed accounts. + const result = await request(EfpDeepWalk, { address: alice }); + const primaryList = result.account?.efp.primaryList; + expect(primaryList?.tokenId).toBe("0"); + + const records = flattenConnection(primaryList!.records); + // Every followed peer is an indexed ENS account, so each record links to an Account + // (the `EfpListRecord.account` row-gating, positive direction). + expect(records.every((r) => r.account !== null)).toBe(true); + + // The deep walk resolves end to end: alice's list follows bob, and bob's own validated + // primary list (token 1) is reachable through the followed record's account. + const toBob = records.find((r) => eq(r.recordData, bob)); + expect(toBob?.account && eq(toBob.account.id, bob)).toBe(true); + expect(toBob?.account?.efp.primaryList?.tokenId).toBe("1"); + }); +}); + +describe("EFP handler edge cases (seeded)", () => { + type SeededRecordsResult = { + efp: { + listRecords: GraphQLConnection<{ + recordData: string; + tags: string[]; + account: { id: string } | null; + list: { user: string | null } | null; + }>; + }; + }; + + const EfpSeededRecords = gql` + query EfpSeededRecords($target: Address!) { + efp { + listRecords(where: { recordData: $target }) { + edges { node { recordData tags account { id } list { user } } } + } + } + } + `; + + const recordsFor = async (target: string) => + flattenConnection( + (await request(EfpSeededRecords, { target })).efp.listRecords, + ); + + it("de-duplicates a repeated ADD_TAG and clears the user role on a malformed value", async () => { + const records = await recordsFor(efpSeedTargets.dedup); + expect(records).toHaveLength(1); + // The tag was added twice; the embedded-tags set must hold it once. + expect(records[0].tags).toEqual(["block"]); + // The synthetic target is not an indexed account, so `account` is null. + expect(records[0].account).toBeNull(); + // The owning list's `user` was set to a malformed (non-20-byte) value, clearing it to null. + expect(records[0].list?.user).toBeNull(); + }); + + it("cascades tags on REMOVE_RECORD and starts fresh on re-ADD", async () => { + const records = await recordsFor(efpSeedTargets.cascade); + // Re-added after a REMOVE that dropped the record and its tags. + expect(records).toHaveLength(1); + expect(records[0].tags).toEqual([]); + }); + + it("deletes a record via a junk-suffixed REMOVE_RECORD (canonical 22-byte keying)", async () => { + const records = await recordsFor(efpSeedTargets.junk); + expect(records).toHaveLength(0); + }); + + it("rejects a primary list when metadata is present but the list's user does not match", async () => { + // The seed actor has `primary-list` metadata (set by easyMintTo), but the referenced list's + // `user` is never the actor, so the two-step validation must reject it (return null) rather than + // resolve the list. This exercises the mismatch branch distinctly from the unset-metadata case. + type MetaResult = { efp: { accountMetadata: { value: string } | null } }; + type PrimaryListByIdResult = { efp: { primaryList: { tokenId: string } | null } }; + + const meta = await request( + gql` + query EfpActorMetadata($address: Address!) { + efp { accountMetadata(address: $address, key: "primary-list") { value } } + } + `, + { address: efpSeedActorAddress }, + ); + expect( + meta.efp.accountMetadata, + "the seed actor should have primary-list metadata", + ).not.toBeNull(); + + const result = await request( + gql` + query EfpActorPrimaryList($address: Address!) { + efp { primaryList(address: $address) { tokenId } } + } + `, + { address: efpSeedActorAddress }, + ); + expect(result.efp.primaryList, "validation must reject a user mismatch").toBeNull(); + }); + + it("recovers a list's user role after a storage-location re-point (durable metadata)", async () => { + const records = await recordsFor(efpSeedTargets.durable); + expect(records).toHaveLength(1); + // The list moved away from its slot (clearing the role) and back; the role must be re-derived + // from the durable per-slot metadata rather than staying null. + expect(records[0].list?.user && eq(records[0].list.user, efpSeedRoleUser)).toBe(true); + }); +}); + +describe("efp.lists pagination (seeded > 9 lists)", () => { + type ListsResult = { + efp: { + lists: { + edges: { node: { tokenId: string } }[]; + pageInfo: { endCursor: string | null; hasNextPage: boolean }; + }; + }; + }; + + const EfpLists = gql` + query EfpLists($first: Int!, $after: String) { + efp { + lists(first: $first, after: $after) { + edges { node { tokenId } } + pageInfo { endCursor hasNextPage } + } + } + } + `; + + it("orders lists numerically by tokenId across cursor pages (not lexicographically)", async () => { + // Page in small pages so both the ORDER BY and the cursor `where` are exercised. The seeder + // mints > 9 lists, so a double-digit tokenId exists — the case where lexicographic ("10" < "2") + // and numeric ordering diverge. + const collected: number[] = []; + let after: string | null = null; + for (let page = 0; page < 20; page++) { + const result: ListsResult = await request(EfpLists, { first: 4, after }); + collected.push(...result.efp.lists.edges.map((e) => Number(e.node.tokenId))); + if (!result.efp.lists.pageInfo.hasNextPage) break; + after = result.efp.lists.pageInfo.endCursor; + } + + expect(Math.max(...collected)).toBeGreaterThanOrEqual(10); + // Numerically ascending, with no rows skipped or repeated across page boundaries. + expect(collected).toEqual([...new Set(collected)].sort((a, b) => a - b)); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp.ts b/apps/ensapi/src/omnigraph-api/schema/efp.ts new file mode 100644 index 0000000000..1e87949d10 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp.ts @@ -0,0 +1,189 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and, eq } from "drizzle-orm"; +import type { Hex } from "viem"; + +import di from "@/di"; +import { builder } from "@/omnigraph-api/builder"; +import { + orderByNumericText, + orderPaginationBy, + paginateBy, + paginateByNumericText, +} from "@/omnigraph-api/lib/connection-helpers"; +import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; +import { EfpAccountMetadataRef } from "@/omnigraph-api/schema/efp-account-metadata"; +import { efpAccountMetadataId } from "@/omnigraph-api/schema/efp-ids"; +import { + EfpAccountMetadatasWhereInput, + EfpListRecordsWhereInput, + EfpListsWhereInput, +} from "@/omnigraph-api/schema/efp-inputs"; +import { EfpListRef, TOKEN_ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/efp-list"; +import { EfpListRecordRef } from "@/omnigraph-api/schema/efp-list-record"; +import { resolveValidatedPrimaryListTokenId } from "@/omnigraph-api/schema/efp-primary-list"; + +/** + * `EfpQuery` namespaces all Ethereum Follow Protocol (EFP) queries under a single root `efp` field, + * keeping the EFP surface self-contained and the Query root uncluttered. + */ +const EfpQueryRef = builder.objectRef>("EfpQuery"); + +EfpQueryRef.implement({ + description: "Queries for Ethereum Follow Protocol (EFP) data.", + fields: (t) => ({ + /////////////// + // efp.list + /////////////// + list: t.field({ + description: "Get an EFP list by its NFT token id.", + type: EfpListRef, + nullable: true, + args: { tokenId: t.arg({ type: "String", required: true }) }, + resolve: (_parent, args) => args.tokenId, + }), + + /////////////// + // efp.lists + /////////////// + lists: t.connection({ + description: "Find EFP lists, optionally filtered by owner / user / manager.", + type: EfpListRef, + args: { where: t.arg({ type: EfpListsWhereInput }) }, + resolve: (_parent, args) => { + const { ensDb, ensIndexerSchema } = di.context; + const where = args.where; + 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, + ); + + 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), + ), + }); + }, + }), + + ///////////////////// + // efp.listRecords + ///////////////////// + listRecords: t.connection({ + description: + "Find EFP list records. Filter by `recordData` to answer 'which lists follow this address?'.", + type: EfpListRecordRef, + args: { where: t.arg({ type: EfpListRecordsWhereInput }) }, + resolve: (_parent, args) => { + const { ensDb, ensIndexerSchema } = di.context; + const where = args.where; + const scope = and( + where?.recordData + ? eq(ensIndexerSchema.efpListRecords.recordData, where.recordData as Hex) + : undefined, + where?.recordType != null + ? eq(ensIndexerSchema.efpListRecords.recordType, where.recordType) + : undefined, + ); + + return lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.efpListRecords, scope), + connection: () => + resolveCursorConnection( + { ...ID_PAGINATED_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + ensDb + .select() + .from(ensIndexerSchema.efpListRecords) + .where(and(scope, paginateBy(ensIndexerSchema.efpListRecords.id, before, after))) + .orderBy(orderPaginationBy(ensIndexerSchema.efpListRecords.id, inverted)) + .limit(limit), + ), + }); + }, + }), + + ///////////////////////// + // efp.accountMetadata + ///////////////////////// + accountMetadata: t.field({ + description: "Get an EFP account-metadata value by address and key.", + type: EfpAccountMetadataRef, + nullable: true, + args: { + address: t.arg({ type: "Address", required: true }), + key: t.arg({ type: "String", required: true }), + }, + resolve: (_parent, args) => efpAccountMetadataId(args.address, args.key), + }), + + ////////////////////////// + // efp.accountMetadatas + ////////////////////////// + accountMetadatas: t.connection({ + description: "Find all EFP account-metadata entries for an address.", + type: EfpAccountMetadataRef, + args: { where: t.arg({ type: EfpAccountMetadatasWhereInput, required: true }) }, + resolve: (_parent, args) => { + const { ensDb, ensIndexerSchema } = di.context; + const scope = eq(ensIndexerSchema.efpAccountMetadata.address, args.where.address as Hex); + + return lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.efpAccountMetadata, scope), + connection: () => + resolveCursorConnection( + { ...ID_PAGINATED_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + ensDb + .select() + .from(ensIndexerSchema.efpAccountMetadata) + .where( + and(scope, paginateBy(ensIndexerSchema.efpAccountMetadata.id, before, after)), + ) + .orderBy(orderPaginationBy(ensIndexerSchema.efpAccountMetadata.id, inverted)) + .limit(limit), + ), + }); + }, + }), + + /////////////////////// + // efp.primaryList + /////////////////////// + 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), + }), + }), +}); + +/////////////////////////////////////// +// Query.efp — the single EFP namespace +/////////////////////////////////////// +builder.queryField("efp", (t) => + t.field({ + description: "Ethereum Follow Protocol (EFP) queries.", + type: EfpQueryRef, + nullable: false, + resolve: () => ({}), + }), +); diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 5edfbe13a2..0dd8cf4cbb 100644 --- a/apps/ensindexer/ponder/src/register-handlers.ts +++ b/apps/ensindexer/ponder/src/register-handlers.ts @@ -7,6 +7,7 @@ import config from "@/config"; import { PluginName } from "@ensnode/ensnode-sdk"; +import attach_EFPHandlers from "@/plugins/efp/event-handlers"; import attach_protocolAccelerationHandlers from "@/plugins/protocol-acceleration/event-handlers"; import attach_NodeMigrationHandlers from "@/plugins/protocol-acceleration/handlers/node-migration"; import attach_RegistrarsHandlers from "@/plugins/registrars/event-handlers"; @@ -47,6 +48,11 @@ if (config.plugins.includes(PluginName.TokenScope)) { attach_TokenscopeHandlers(); } +// EFP Plugin +if (config.plugins.includes(PluginName.EFP)) { + attach_EFPHandlers(); +} + // IMPORTANT: the order of these attach_*() calls does NOT control the order Ponder dispatches // handlers. Ponder orders events by checkpoint (chainId, blockNumber, transactionIndex, logIndex). // Two handlers registered against the SAME log (e.g. Unigraph and ProtocolAcceleration both on diff --git a/apps/ensindexer/src/config/config.test.ts b/apps/ensindexer/src/config/config.test.ts index 7bd1e3aff1..1c9e8e5c2f 100644 --- a/apps/ensindexer/src/config/config.test.ts +++ b/apps/ensindexer/src/config/config.test.ts @@ -531,6 +531,20 @@ describe("config (with base env)", () => { expect(mainnetConfig.indexedChainIds).not.toEqual(sepoliaConfig.indexedChainIds); }); + + // EFP exists on `mainnet` and `ens-test-env`. On the devnet all three EFP datasources + // (Base/Optimism/Ethereum) point at the single Anvil chain, so the plugin must activate and + // index exactly one chain — guarding both the datasource-presence unlock and the collapse. + it("derives the single devnet chain id for efp on ens-test-env", async () => { + vi.stubEnv("NAMESPACE", ENSNamespaceIds.EnsTestEnv); + vi.stubEnv("PLUGINS", PluginName.EFP); + stubRpcUrlsForNamespace(ENSNamespaceIds.EnsTestEnv); + + const config = await getConfig(); + + expect(config.plugins).toContain(PluginName.EFP); + expect(config.indexedChainIds).toEqual(new Set([ensTestEnvChain.id])); + }); }); describe("additional checks", () => { diff --git a/apps/ensindexer/src/plugins/efp/README.md b/apps/ensindexer/src/plugins/efp/README.md new file mode 100644 index 0000000000..cca6781486 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/README.md @@ -0,0 +1,39 @@ +# EFP Plugin + +Indexes the [Ethereum Follow Protocol](https://docs.efp.app) (EFP) — onchain "follow lists" — into +ENSDb, so a single ENSNode process serves both ENS and EFP data. Activate it by including `efp` in +the `PLUGINS` environment variable (mainnet ENS namespace only). + +## Contracts indexed + +| Contract | Chain(s) | Events | +| ----------------- | -------------------------------- | --------------------------------------- | +| `ListRegistry` | Base | `Transfer`, `UpdateListStorageLocation` | +| `AccountMetadata` | Base | `UpdateAccountMetadata` | +| `ListRecords` | Base, Optimism, Ethereum mainnet | `ListOp`, `UpdateListMetadata` | + +Contract coordinates live in the `EFPBase` / `EFPOptimism` / `EFPEthereum` datasources +(`packages/datasources/src/mainnet.ts`). + +## Tables (`packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts`) + +- `efp_lists` — one row per list NFT (owner / user / manager + decoded storage location). +- `efp_list_storage_locations` — reverse index from a storage location `(chainId, contract, slot)` + to its list NFT, so `UpdateListMetadata` resolves the owning list by primary key. +- `efp_list_records` — the records in each list, each carrying its set of UTF-8 `tags` as an + embedded array (so removing a record drops its tags in the same primary-key delete). +- `efp_account_metadata` — `(address, key) → value` (today only `primary-list`). +- `efp_list_metadata` — durable `user`/`manager` metadata keyed by storage location, (re-)applied to + whichever list points at the slot (the `ListRecords` and `ListRegistry` contracts emit + independently, and a list can re-point to a previously-used slot). + +## Notes + +- EFP defines a single List Storage Location type (onchain EVM contract); see + [the spec](https://docs.efp.app/design/list-storage-location/). Other location types decode to + `null` and are skipped. +- The canonical association of an Ethereum account with an EFP list is its **primary list**: the + `primary-list` account-metadata value, valid only when the named list's `user` role matches the + account (see [Account Metadata](https://docs.efp.app/design/account-metadata/)). ENSApi's Omnigraph + `efp.primaryList(address)` resolves and validates it. +- Byte decoders for list ops and storage locations live in `lib/` with unit tests. diff --git a/apps/ensindexer/src/plugins/efp/constants.ts b/apps/ensindexer/src/plugins/efp/constants.ts new file mode 100644 index 0000000000..be47f0188e --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/constants.ts @@ -0,0 +1,37 @@ +/** + * EFP versions its payload schemas independently with a leading `version` byte. Each is `1` today, + * and a decoder rejects any other value rather than decode a future or unknown schema as v1. They + * are separate constants because a bump to one schema (say `ListOp`) does not imply a bump to the + * others (`ListRecord`, List Storage Location). + * + * @see https://docs.efp.app/design/list-ops/ + */ +export const EFP_LIST_OP_VERSION = 1; +export const EFP_RECORD_VERSION = 1; +export const EFP_LSL_VERSION = 1; + +/** The only EFP `ListRecord` type EFP defines: a 20-byte address. Types 0 and 2-255 are reserved. */ +export const EFP_RECORD_TYPE_ADDRESS = 0x01; + +/** + * EFP `ListOp` opcodes (op version 0x01), encoded as `version | opcode | data`. + * + * @see https://docs.efp.app/design/list-ops/ + */ +export const EFP_OPCODE = { + ADD_RECORD: 0x01, + REMOVE_RECORD: 0x02, + ADD_TAG: 0x03, + REMOVE_TAG: 0x04, +} as const; + +/** + * Well-known list-metadata keys emitted by the `ListRecords` contract. Both carry a 20-byte + * address as their value and are reflected onto the `user` / `manager` columns of `efp_lists`. + * + * @see https://docs.efp.app/design/list-metadata/ + */ +export const EFP_LIST_METADATA_KEYS = { + USER: "user", + MANAGER: "manager", +} as const; diff --git a/apps/ensindexer/src/plugins/efp/event-handlers.ts b/apps/ensindexer/src/plugins/efp/event-handlers.ts new file mode 100644 index 0000000000..f42664a454 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/event-handlers.ts @@ -0,0 +1,9 @@ +import attach_AccountMetadata from "./handlers/AccountMetadata"; +import attach_ListRecords from "./handlers/ListRecords"; +import attach_ListRegistry from "./handlers/ListRegistry"; + +export default function () { + attach_ListRegistry(); + attach_ListRecords(); + attach_AccountMetadata(); +} diff --git a/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts new file mode 100644 index 0000000000..bcc9f72837 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts @@ -0,0 +1,42 @@ +import type { Hex } from "viem"; + +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { addOnchainEventListener, ensIndexerSchema } from "@/lib/indexing-engines/ponder"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { accountMetadataId } from "../lib/ids"; + +const pluginName = PluginName.EFP; + +/** + * Registers the EFP `AccountMetadata` event handler (UpdateAccountMetadata). + */ +export default function () { + // UpdateAccountMetadata — writes a single (key, value) pair for an account (today: `primary-list`). + addOnchainEventListener( + namespaceContract(pluginName, "AccountMetadata:UpdateAccountMetadata"), + async ({ context, event }) => { + const ts = event.block.timestamp; + const address = event.args.addr.toLowerCase() as Hex; + // `key` is a free-form on-chain string used as a primary-key component. Strip NUL bytes: a + // Postgres text column rejects them (a NUL key would crash the insert), and the tag path + // strips them too for api-v2 parity. + const key = event.args.key.replace(/\0/g, ""); + + await context.ensDb + .insert(ensIndexerSchema.efpAccountMetadata) + .values({ + id: accountMetadataId(address, key), + chainId: context.chain.id, + contractAddress: event.log.address.toLowerCase() as Hex, + address, + key, + value: event.args.value, + createdAt: ts, + updatedAt: ts, + }) + .onConflictDoUpdate({ value: event.args.value, updatedAt: ts }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts new file mode 100644 index 0000000000..f193953863 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -0,0 +1,150 @@ +import type { Hex } from "viem"; + +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { addOnchainEventListener, ensIndexerSchema } from "@/lib/indexing-engines/ponder"; +import { logger } from "@/lib/logger"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { EFP_LIST_METADATA_KEYS, EFP_OPCODE } from "../constants"; +import { listMetadataId, listRecordId, storageLocationId } from "../lib/ids"; +import { metadataValueToAddress } from "../lib/list-metadata"; +import { parseListOp, parseRecord, parseTagOp, slotToBytes32 } from "../lib/parse-list-op"; + +const pluginName = PluginName.EFP; + +/** + * Registers the EFP `ListRecords` event handlers (ListOp, UpdateListMetadata). + */ +export default function () { + // ListOp — opcode-dispatched add/remove of records and tags. + addOnchainEventListener( + namespaceContract(pluginName, "ListRecords:ListOp"), + async ({ context, event }) => { + const parsed = parseListOp(event.args.op); + if (!parsed) return; + + const ts = event.block.timestamp; + const chainId = context.chain.id; + const contractAddress = event.log.address.toLowerCase() as Hex; + const slot = slotToBytes32(event.args.slot); + + switch (parsed.opcode) { + case EFP_OPCODE.ADD_RECORD: { + const record = parseRecord(parsed.data); + if (!record) return; + await context.ensDb + .insert(ensIndexerSchema.efpListRecords) + .values({ + id: listRecordId(chainId, contractAddress, slot, record.record), + chainId, + contractAddress, + slot, + record: record.record, + recordVersion: record.version, + recordType: record.recordType, + recordData: record.recordData, + tags: [], + createdAt: ts, + }) + .onConflictDoNothing(); + return; + } + + case EFP_OPCODE.REMOVE_RECORD: { + const record = parseRecord(parsed.data); + if (!record) return; + // The record's embedded `tags` are removed with the row, so this is a single PK delete. + await context.ensDb.delete(ensIndexerSchema.efpListRecords, { + id: listRecordId(chainId, contractAddress, slot, record.record), + }); + return; + } + + 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 }); + // Ops for a (chain, contract, slot) are indexed in on-chain order, so a tag with no record + // means the record is not in the list: removed earlier, or (anomalously) never added. + // Either way there is no row to tag; warn rather than drop it silently. + if (!record) { + logger.warn({ msg: `EFP ADD_TAG references absent record ${id} (tag "${tagOp.tag}")` }); + return; + } + // A record's tags are a set, so skip a tag it already carries. + if (record.tags.includes(tagOp.tag)) return; + await context.ensDb + .update(ensIndexerSchema.efpListRecords, { id }) + .set({ tags: [...record.tags, tagOp.tag] }); + return; + } + + case EFP_OPCODE.REMOVE_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 }); + if (!record) { + logger.warn({ + msg: `EFP REMOVE_TAG references absent record ${id} (tag "${tagOp.tag}")`, + }); + return; + } + if (!record.tags.includes(tagOp.tag)) return; + await context.ensDb + .update(ensIndexerSchema.efpListRecords, { id }) + .set({ tags: record.tags.filter((existing) => existing !== tagOp.tag) }); + return; + } + + default: + // Unknown opcode — skip (resilient to future op versions). + return; + } + }, + ); + + // UpdateListMetadata — updates a list's user/manager, keyed by storage location (slot). + addOnchainEventListener( + namespaceContract(pluginName, "ListRecords:UpdateListMetadata"), + async ({ context, event }) => { + const key = event.args.key; + // Only `user` / `manager` are reflected onto efp_lists today; ignore any other key. + if (key !== EFP_LIST_METADATA_KEYS.USER && key !== EFP_LIST_METADATA_KEYS.MANAGER) return; + + const ts = event.block.timestamp; + const chainId = context.chain.id; + const contractAddress = event.log.address.toLowerCase() as Hex; + const slot = slotToBytes32(event.args.slot); + + // Record the location's latest metadata durably (keyed by location + key) so it can be + // (re-)applied to whichever list points at this slot, now or after a future re-point, and so + // it survives whichever of UpdateListMetadata / UpdateListStorageLocation arrives first. + const id = listMetadataId(chainId, contractAddress, slot, key); + await context.ensDb + .insert(ensIndexerSchema.efpListMetadata) + .values({ id, chainId, contractAddress, slot, key, value: event.args.value, createdAt: ts }) + .onConflictDoUpdate({ value: event.args.value }); + + // If a list currently points at this storage location, apply the role to it now. + // `metadataValueToAddress` returns null for a non-20-byte value, intentionally clearing the + // role: a malformed `user`/`manager` value is no longer a valid address, so reflecting "no + // role" is faithful to on-chain state. + const mapping = await context.ensDb.find(ensIndexerSchema.efpListStorageLocations, { + id: storageLocationId(chainId, contractAddress, slot), + }); + if (!mapping) return; + + const address = metadataValueToAddress(event.args.value); + await context.ensDb + .update(ensIndexerSchema.efpLists, { tokenId: mapping.tokenId }) + .set( + key === EFP_LIST_METADATA_KEYS.USER + ? { user: address, updatedAt: ts } + : { manager: address, updatedAt: ts }, + ); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts new file mode 100644 index 0000000000..ce31eb904d --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -0,0 +1,172 @@ +import { type Hex, isAddressEqual, zeroAddress } from "viem"; + +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { addOnchainEventListener, ensIndexerSchema } from "@/lib/indexing-engines/ponder"; +import { logger } from "@/lib/logger"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { EFP_LIST_METADATA_KEYS } from "../constants"; +import { listMetadataId, storageLocationId } from "../lib/ids"; +import { metadataValueToAddress } from "../lib/list-metadata"; +import { parseListStorageLocation } from "../lib/parse-list-storage-location"; + +const pluginName = PluginName.EFP; + +/** + * Registers the EFP `ListRegistry` event handlers (Transfer, UpdateListStorageLocation). + */ +export default function () { + // Transfer — mints/transfers a list NFT. Upsert the list row keyed by tokenId. + addOnchainEventListener( + namespaceContract(pluginName, "ListRegistry:Transfer"), + async ({ context, event }) => { + const ts = event.block.timestamp; + const tokenId = event.args.tokenId.toString(); + + // ERC-721 mints are Transfer(from=0) and burns are Transfer(to=0). On a burn the list NFT no + // longer exists, so drop the list row (and its storage-location reverse mapping) rather than + // record a zero-address owner that would surface through `EfpList.owner` and `lists(where:)`. + 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 }); + // The list's `efp_list_records` rows are intentionally left in place: they mirror the + // on-chain `ListRecords` contract (a burn does not clear them), and their + // `EfpListRecord.list` back-ref resolves to null once the reverse mapping above is gone. + return; + } + + const owner = event.args.to.toLowerCase() as Hex; + // A mint inserts the row; a later transfer of the same NFT updates only `owner`/`updatedAt`. + // `createdAt`, `nftChainId`, and `nftContractAddress` are mint-time values left untouched on + // conflict: an ERC-721 tokenId is unique for the ListRegistry's lifetime (a burned id is not + // re-minted) and the NFT never changes chain or contract. + 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 }); + }, + ); + + // UpdateListStorageLocation — (re-)points a list at its record store. + addOnchainEventListener( + namespaceContract(pluginName, "ListRegistry:UpdateListStorageLocation"), + async ({ context, event }) => { + const ts = event.block.timestamp; + const tokenId = event.args.tokenId.toString(); + + // The mint Transfer always precedes this event (both fire on the ListRegistry on Base, in + // order), so the list row exists. Guard anyway so an unexpected ordering skips rather than + // updating a non-existent row. + const existing = await context.ensDb.find(ensIndexerSchema.efpLists, { tokenId }); + if (!existing) return; + + const oldLocationId = + existing.listStorageLocationChainId != null && + existing.listStorageLocationContractAddress != null && + existing.listStorageLocationSlot != null + ? storageLocationId( + existing.listStorageLocationChainId, + existing.listStorageLocationContractAddress, + existing.listStorageLocationSlot, + ) + : null; + + const parsed = parseListStorageLocation(event.args.listStorageLocation); + + // An undecodable payload (future version, non-onchain location type, or malformed) replaces + // the on-chain location with something this indexer can't represent. Drop the stale decoded + // location, its reverse mapping, and its location-scoped roles rather than keep resolving the + // old slot; keep the raw payload for debugging. + if (!parsed) { + logger.warn({ + msg: `EFP UpdateListStorageLocation(tokenId=${tokenId}) has an undecodable payload; clearing the list's location`, + }); + if (oldLocationId !== null) { + await context.ensDb.delete(ensIndexerSchema.efpListStorageLocations, { + id: oldLocationId, + }); + } + await context.ensDb.update(ensIndexerSchema.efpLists, { tokenId }).set({ + listStorageLocation: event.args.listStorageLocation, + listStorageLocationChainId: null, + listStorageLocationContractAddress: null, + listStorageLocationSlot: null, + user: null, + manager: null, + updatedAt: ts, + }); + return; + } + + const chainId = Number(parsed.chainId); + const { contractAddress, slot } = parsed; + const newLocationId = storageLocationId(chainId, contractAddress, slot); + + // If this list previously pointed at a different storage location, drop the stale reverse + // mapping. + let moved = false; + if (oldLocationId !== null && oldLocationId !== newLocationId) { + moved = true; + await context.ensDb.delete(ensIndexerSchema.efpListStorageLocations, { id: oldLocationId }); + } + + await context.ensDb.update(ensIndexerSchema.efpLists, { tokenId }).set({ + listStorageLocation: event.args.listStorageLocation, + listStorageLocationChainId: chainId, + listStorageLocationContractAddress: contractAddress, + listStorageLocationSlot: slot, + // `user`/`manager` are scoped to the storage location. On a move, clear them so the list is + // not attributed to the old location's roles; pending metadata for the new location + // repopulates them in the drain below. + ...(moved ? { user: null, manager: null } : {}), + updatedAt: ts, + }); + + await context.ensDb + .insert(ensIndexerSchema.efpListStorageLocations) + .values({ id: newLocationId, chainId, contractAddress, slot, tokenId, updatedAt: ts }) + .onConflictDoUpdate({ tokenId, updatedAt: ts }); + + // (Re-)apply this storage location's durable user/manager metadata to the list. Keyed by + // location, so it restores roles whenever a list points at (or re-points to) a slot whose + // metadata was already recorded; it is not deleted, as it stays valid for the slot. Combined + // with the role clear on a move above, this rederives the list's roles from its location. + for (const key of [EFP_LIST_METADATA_KEYS.USER, EFP_LIST_METADATA_KEYS.MANAGER] as const) { + const meta = await context.ensDb.find(ensIndexerSchema.efpListMetadata, { + id: listMetadataId(chainId, contractAddress, slot, key), + }); + if (!meta) continue; + + const address = metadataValueToAddress(meta.value); + await context.ensDb + .update(ensIndexerSchema.efpLists, { tokenId }) + .set( + key === EFP_LIST_METADATA_KEYS.USER + ? { user: address, updatedAt: ts } + : { manager: address, updatedAt: ts }, + ); + } + }, + ); +} diff --git a/apps/ensindexer/src/plugins/efp/lib/ids.ts b/apps/ensindexer/src/plugins/efp/lib/ids.ts new file mode 100644 index 0000000000..4f69a42723 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/ids.ts @@ -0,0 +1,36 @@ +import type { Hex } from "viem"; + +/** + * Deterministic composite primary keys for the EFP tables. Hex components are lowercased so keys + * built from different event sources (e.g. an LSL payload vs. a `ListOp` slot) collide correctly. + */ + +/** `efp_list_storage_locations` key: a storage location `(chainId, contractAddress, slot)`. */ +export function storageLocationId(chainId: number, contractAddress: Hex, slot: Hex): string { + return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}`; +} + +/** `efp_list_records` key: a record within a list. */ +export function listRecordId( + chainId: number, + contractAddress: Hex, + slot: Hex, + record: Hex, +): string { + return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${record.toLowerCase()}`; +} + +/** `efp_account_metadata` key: an `(address, key)` pair (lowercased address; NUL bytes stripped from the key). */ +export function accountMetadataId(address: Hex, key: string): string { + return `${address.toLowerCase()}-${key.replace(/\0/g, "")}`; +} + +/** `efp_list_metadata` key: per-location metadata `(storage location, key)`. */ +export function listMetadataId( + chainId: number, + contractAddress: Hex, + slot: Hex, + key: string, +): string { + return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${key}`; +} diff --git a/apps/ensindexer/src/plugins/efp/lib/list-metadata.test.ts b/apps/ensindexer/src/plugins/efp/lib/list-metadata.test.ts new file mode 100644 index 0000000000..20df4e6d88 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/list-metadata.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { metadataValueToAddress } from "./list-metadata"; + +describe("metadataValueToAddress", () => { + it("returns the lower-cased address for an exactly-20-byte value", () => { + expect(metadataValueToAddress(`0x${"AB".repeat(20)}`)).toBe(`0x${"ab".repeat(20)}`); + }); + + it("returns null for an empty value", () => { + expect(metadataValueToAddress("0x")).toBeNull(); + }); + + it("returns null for a value shorter than 20 bytes", () => { + expect(metadataValueToAddress(`0x${"ab".repeat(10)}`)).toBeNull(); + }); + + it("returns null for a value longer than 20 bytes (e.g. an abi-encoded address)", () => { + expect(metadataValueToAddress(`0x${"ab".repeat(32)}`)).toBeNull(); + }); +}); diff --git a/apps/ensindexer/src/plugins/efp/lib/list-metadata.ts b/apps/ensindexer/src/plugins/efp/lib/list-metadata.ts new file mode 100644 index 0000000000..3f888ae3bd --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/list-metadata.ts @@ -0,0 +1,13 @@ +import type { Hex } from "viem"; + +/** + * Interpret an EFP list-metadata `value` as a role address. The well-known `user` / `manager` keys + * carry exactly a 20-byte address; the generic metadata setter can emit arbitrary bytes, so any + * other length is malformed and returns `null` to clear the role rather than store a truncated or + * empty address (which would later surface through a GraphQL `Address`). + */ +export function metadataValueToAddress(value: Hex): Hex | null { + // Exactly 20 bytes: "0x" + 40 hex chars. + if (value.length !== 42) return null; + return value.toLowerCase() as Hex; +} diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts new file mode 100644 index 0000000000..bf730b4734 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { parseListOp, parseRecord, parseTagOp, slotToBytes32 } from "./parse-list-op"; + +const A20 = "0x".padEnd(42, "a"); // 0xaaa…aaa (20 bytes) + +describe("parseListOp", () => { + it("decodes version, opcode, and data payload", () => { + // version=0x01, opcode=0x01 (ADD_RECORD), data = recordVersion(01) + recordType(01) + 20-byte address + const op = `0x01010101${A20.slice(2)}` as `0x${string}`; + expect(parseListOp(op)).toEqual({ + version: 1, + opcode: 1, + data: `0x0101${A20.slice(2)}` as `0x${string}`, + }); + }); + + it("returns null for non-hex, too-short, empty, or nullish input", () => { + expect(parseListOp(null)).toBeNull(); + expect(parseListOp(undefined)).toBeNull(); + expect(parseListOp("")).toBeNull(); + expect(parseListOp("0x")).toBeNull(); + expect(parseListOp("0x01")).toBeNull(); // only version, no opcode + expect(parseListOp("hello")).toBeNull(); + }); + + it("returns null for unsupported op versions (only version 1 is defined)", () => { + // version=0x02, opcode=0x01 (ADD_RECORD) — must not be dispatched through v1 handlers + const op = `0x0201${"0101"}${A20.slice(2)}` as `0x${string}`; + expect(parseListOp(op)).toBeNull(); + }); +}); + +describe("parseRecord", () => { + it("decodes an address record (recordType=1), truncating trailing junk to the canonical 22 bytes", () => { + // recordVersion=01, recordType=01, address=20 bytes of 0xaa, then junk + const data = `0x0101${"aa".repeat(20)}deadbeef` as `0x${string}`; + expect(parseRecord(data)).toEqual({ + version: 1, + recordType: 1, + record: `0x0101${"aa".repeat(20)}` as `0x${string}`, + recordData: `0x${"aa".repeat(20)}` as `0x${string}`, + }); + }); + + it("lower-cases the canonical record and recordData", () => { + const data = `0x0101${"AB".repeat(20)}` as `0x${string}`; + expect(parseRecord(data)).toEqual({ + version: 1, + recordType: 1, + record: `0x0101${"ab".repeat(20)}` as `0x${string}`, + recordData: `0x${"ab".repeat(20)}` as `0x${string}`, + }); + }); + + it("returns null when an address record is shorter than 20 bytes", () => { + const data = `0x0101${"aa".repeat(10)}` as `0x${string}`; + expect(parseRecord(data)).toBeNull(); + }); + + it("returns null for reserved (non-address) record types", () => { + const data = ("0x0102" + "01020304") as `0x${string}`; + expect(parseRecord(data)).toBeNull(); + }); + + it("returns null for unsupported record versions (only version 1 is defined)", () => { + // recordVersion=0x02, recordType=0x01, 20-byte address + const data = `0x0201${"aa".repeat(20)}` as `0x${string}`; + expect(parseRecord(data)).toBeNull(); + }); + + it("returns null for unparseable input", () => { + expect(parseRecord(null)).toBeNull(); + expect(parseRecord("0x")).toBeNull(); + }); +}); + +describe("parseTagOp", () => { + it("splits the 22-byte record prefix from the UTF-8 tag", () => { + // record = recordVersion(01) + recordType(01) + 20 bytes address + const recordPrefixHex = `0101${"aa".repeat(20)}`; + const tagBytes = Buffer.from("top8", "utf8").toString("hex"); + const data = `0x${recordPrefixHex}${tagBytes}` as `0x${string}`; + + expect(parseTagOp(data)).toEqual({ + record: `0x${recordPrefixHex}` as `0x${string}`, + tag: "top8", + }); + }); + + it("strips NUL bytes inside the decoded tag (api-v2 parity)", () => { + const recordPrefixHex = `0101${"bb".repeat(20)}`; + // 'a' (0x61) + NUL (0x00) + 'b' (0x62) + const tagBytes = "610062"; + const data = `0x${recordPrefixHex}${tagBytes}` as `0x${string}`; + expect(parseTagOp(data)?.tag).toBe("ab"); + }); + + it("lower-cases the record prefix and rejects non-address prefixes", () => { + const tagBytes = Buffer.from("top8", "utf8").toString("hex"); + // uppercase hex in the record prefix is lower-cased to match the stored record key + const upper = `0x0101${"AB".repeat(20)}${tagBytes}` as `0x${string}`; + expect(parseTagOp(upper)?.record).toBe(`0x0101${"ab".repeat(20)}`); + // a non-version-1 / non-type-1 record prefix is rejected (no such record is ever indexed) + const reserved = `0x0102${"ab".repeat(20)}${tagBytes}` as `0x${string}`; + expect(parseTagOp(reserved)).toBeNull(); + }); + + it("returns null for inputs shorter than the record prefix", () => { + expect(parseTagOp("0x0101")).toBeNull(); + expect(parseTagOp(null)).toBeNull(); + }); +}); + +describe("slotToBytes32", () => { + it("zero-pads small slot values to 32 bytes", () => { + expect(slotToBytes32(0n)).toBe(`0x${"0".repeat(64)}`); + expect(slotToBytes32(1n)).toBe(`0x${"0".repeat(63)}1`); + }); + + it("preserves full-width slot values", () => { + const max = (1n << 256n) - 1n; + expect(slotToBytes32(max)).toBe(`0x${"f".repeat(64)}`); + }); +}); diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts new file mode 100644 index 0000000000..ce8384cc16 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -0,0 +1,155 @@ +/** + * Pure decoders for EFP `ListOp.op` payloads. + * + * Wire format (one byte = two hex chars): + * + * ListOp.op := version (1) | opcode (1) | data (variable) + * record := recordVersion (1) | recordType (1) | recordData (variable) + * + * EFP defines only `recordType === 1` (a 20-byte address); reserved types are skipped. Managers + * sometimes append junk after the 20-byte address, so type-1 records are truncated to the canonical + * `recordVersion (1) | recordType (1) | address (20)` 22-byte prefix (api-v2 parity) and keyed by it, + * so tag/remove ops, which carry that same prefix, always resolve to the same record. + * + * @see https://docs.efp.app/design/list-ops/ + */ + +import { type Hex, isHex } from "viem"; + +import { EFP_LIST_OP_VERSION, EFP_RECORD_TYPE_ADDRESS, EFP_RECORD_VERSION } from "../constants"; + +export interface ParsedListOp { + /** Top-level list-op version. Always 1 today. */ + version: number; + /** Opcode — see {@link import("../constants").EFP_OPCODE}. */ + opcode: number; + /** Opcode-specific payload, still 0x-prefixed. */ + data: Hex; +} + +export interface ParsedRecord { + /** Record encoding version. Always 1 today. */ + version: number; + /** Record type (1 = 20-byte address). */ + recordType: number; + /** + * Canonical record bytes `recordVersion | recordType | address` (0x-prefixed, exactly 22 bytes), + * with any trailing junk after the 20-byte address truncated. Records are keyed by this so tag + * and remove ops, which carry the same 22-byte prefix, resolve to the same row. + */ + record: Hex; + /** Record payload (0x-prefixed). For `recordType === 1` this is exactly 20 bytes. */ + recordData: Hex; +} + +export interface ParsedTagOp { + /** Full record prefix `recordVersion | recordType | address`, 0x-prefixed (22 bytes). */ + record: Hex; + /** UTF-8 decoded tag (NUL bytes stripped, matching api-v2 behaviour). */ + tag: string; +} + +const ADDRESS_RECORD_HEX_BODY_LENGTH = 40; // 20 bytes +const RECORD_HEADER_HEX_LENGTH = 4; // 2 bytes (version + type) +const RECORD_PREFIX_HEX_LENGTH = RECORD_HEADER_HEX_LENGTH + ADDRESS_RECORD_HEX_BODY_LENGTH; // 44 hex chars +const RECORD_PREFIX_WITH_0X_LENGTH = RECORD_PREFIX_HEX_LENGTH + 2; // 46 chars (includes "0x") + +/** + * Decode a `ListOp.op` payload. Returns `null` for malformed input rather than throwing, matching + * the resilient behaviour of the api-v2 indexer (which logs and skips bad ops). + */ +export function parseListOp(op: Hex | string | null | undefined): ParsedListOp | null { + if (!op || typeof op !== "string" || !isHex(op)) return null; + // Minimum: "0x" + 2 (version) + 2 (opcode) = 6 chars + if (op.length < 6) return null; + + const bytes = op.slice(2); + const version = parseInt(bytes.slice(0, 2), 16); + // The version byte defines the op schema; EFP defines only version 1. Reject other versions so a + // future/unknown schema is never dispatched through the v1 opcode handlers. + if (version !== EFP_LIST_OP_VERSION) return null; + + return { + version, + opcode: parseInt(bytes.slice(2, 4), 16), + data: `0x${bytes.slice(4)}` as Hex, + }; +} + +/** + * Decode a record payload as it appears inside an ADD_RECORD / REMOVE_RECORD list op. Returns + * `null` for anything other than an address record (`recordType === 1`) carrying a full 20-byte + * address: reserved record types and shorter payloads are unrecoverable and skipped, since EFP + * defines only the address record and the API has no representation for the others. Trailing junk + * after the 20-byte address is truncated, and `record` is the resulting canonical 22-byte prefix. + */ +export function parseRecord(data: Hex | string | null | undefined): ParsedRecord | null { + if (!data || typeof data !== "string" || !isHex(data)) return null; + if (data.length < 6) return null; // "0x" + 4 hex chars of header + + const bytes = data.slice(2); + const version = parseInt(bytes.slice(0, 2), 16); + const recordType = parseInt(bytes.slice(2, 4), 16); + + // The version byte is part of the record's decoding contract; EFP defines only version 1. + if (version !== EFP_RECORD_VERSION) return null; + // EFP defines only the address record type (a 20-byte address); types 0 and 2-255 are reserved. + if (recordType !== EFP_RECORD_TYPE_ADDRESS) return null; + + // Truncate any trailing junk to the 20-byte address; reject inputs missing the full address. + const body = bytes.slice( + RECORD_HEADER_HEX_LENGTH, + RECORD_HEADER_HEX_LENGTH + ADDRESS_RECORD_HEX_BODY_LENGTH, + ); + if (body.length < ADDRESS_RECORD_HEX_BODY_LENGTH) return null; + + return { + version, + recordType, + // Lowercase so the canonical key matches across ADD / REMOVE / tag ops and the API's + // `recordData` filters, mirroring the List Storage Location decoder. + record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH).toLowerCase()}` as Hex, + recordData: `0x${body.toLowerCase()}` as Hex, + }; +} + +/** + * Decode an ADD_TAG / REMOVE_TAG payload, where the data layout is + * `record (22 bytes) | tag (UTF-8 bytes)`. Returns `null` when the record prefix is missing or is + * not a valid address record. + */ +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 and canonicalize the 22-byte record prefix exactly as `parseRecord` does (version / + // type checked, lowercased), so the tag keys into the same row the record was stored under. + const parsed = parseRecord(data.slice(0, RECORD_PREFIX_WITH_0X_LENGTH) as Hex); + if (!parsed) return null; + + 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: parsed.record, tag }; +} + +/** + * Zero-pad a `uint256` slot value (as emitted by the contracts) to a `bytes32` Hex for consistent + * key lookups across the EFP data model. + */ +export function slotToBytes32(slot: bigint): Hex { + return `0x${slot.toString(16).padStart(64, "0")}` as Hex; +} + +/** Pure-JS hex → UTF-8 string (no Buffer dependency). */ +function hexToUtf8(hex: string): string { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return new TextDecoder("utf-8").decode(bytes); +} diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts new file mode 100644 index 0000000000..13b7bd3418 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { parseListStorageLocation } from "./parse-list-storage-location"; + +describe("parseListStorageLocation", () => { + it("decodes a well-formed 86-byte onchain (locationType=1) payload", () => { + const chainIdHex = (8453).toString(16).padStart(64, "0"); + const addressHex = "ab".repeat(20); + const slotHex = "cd".repeat(32); + const payload = `0x0101${chainIdHex}${addressHex}${slotHex}` as `0x${string}`; + + expect(parseListStorageLocation(payload)).toEqual({ + version: 1, + chainId: 8453n, + contractAddress: `0x${addressHex}` as `0x${string}`, + slot: `0x${slotHex}` as `0x${string}`, + }); + }); + + it("lower-cases the decoded contract address and slot", () => { + const chainIdHex = (1).toString(16).padStart(64, "0"); + const payload = `0x0101${chainIdHex}${"AB".repeat(20)}${"CD".repeat(32)}` as `0x${string}`; + + expect(parseListStorageLocation(payload)).toEqual({ + version: 1, + chainId: 1n, + contractAddress: `0x${"ab".repeat(20)}` as `0x${string}`, + slot: `0x${"cd".repeat(32)}` as `0x${string}`, + }); + }); + + it("returns null for short, nullish, or non-hex inputs", () => { + expect(parseListStorageLocation(null)).toBeNull(); + expect(parseListStorageLocation(undefined)).toBeNull(); + expect(parseListStorageLocation("0x")).toBeNull(); + expect(parseListStorageLocation("not-hex")).toBeNull(); + expect(parseListStorageLocation(`0x${"11".repeat(50)}`)).toBeNull(); // 50 bytes < 86 bytes + }); + + it("returns null for any non-onchain locationType (EFP defines only type 1)", () => { + const tail = "00".repeat(84); // 84 bytes of body, well-formed length + // locationType = 0x02 — the offline/HTTP variant is NOT part of the EFP spec. + expect(parseListStorageLocation(`0x0102${tail}` as `0x${string}`)).toBeNull(); + // locationType = 0xff — reserved/unknown. + expect(parseListStorageLocation(`0x01ff${tail}` as `0x${string}`)).toBeNull(); + }); + + it("returns null for unsupported versions (only version 1 is defined)", () => { + const chainIdHex = (1).toString(16).padStart(64, "0"); + // version = 0x02 — a future schema must not remap the list via the v1 decoder. + const payload = `0x0201${chainIdHex}${"ab".repeat(20)}${"cd".repeat(32)}` as `0x${string}`; + expect(parseListStorageLocation(payload)).toBeNull(); + }); + + it("returns null for overlong payloads (must be exactly 86 bytes)", () => { + const chainIdHex = (1).toString(16).padStart(64, "0"); + // 86 well-formed bytes + 1 trailing byte = 87 bytes. + const overlong = `0x0101${chainIdHex}${"ab".repeat(20)}${"cd".repeat(32)}ff` as `0x${string}`; + expect(parseListStorageLocation(overlong)).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 (Number.MAX_SAFE_INTEGER) 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 one past the safe range and 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(); + }); +}); diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts new file mode 100644 index 0000000000..7144a7a975 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts @@ -0,0 +1,77 @@ +/** + * Decoder for EFP `UpdateListStorageLocation.listStorageLocation` payloads. + * + * EFP defines a single location type, `locationType == 1` (onchain EVM contract): + * + * version (1 byte) + * locationType (1 byte) // == 0x01 + * chainId (32 bytes, big-endian uint256) + * contractAddress (20 bytes) + * slot (32 bytes) + * + * Total: 86 bytes. Decodes to `null` unless the payload is exactly this 86-byte, version-1, + * `locationType == 1` shape with a JS-safe chain id; other versions, location types, lengths, or + * out-of-range chain ids are treated as unrepresentable. + * + * @see https://docs.efp.app/design/list-storage-location/ + */ + +import { type Hex, isHex } from "viem"; + +import { EFP_LSL_VERSION } from "../constants"; + +export interface ParsedListStorageLocation { + version: number; + chainId: bigint; + contractAddress: Hex; + slot: Hex; +} + +/** Each byte is two hex characters. */ +const HEX_CHARS_PER_BYTE = 2; + +// The locationType-1 payload is a fixed 86-byte layout. The boundaries below are hex-char offsets +// into the 0x-stripped payload (each field's byte length times two). +const VERSION_END = 1 * HEX_CHARS_PER_BYTE; // version (1 byte) +const LOCATION_TYPE_END = VERSION_END + 1 * HEX_CHARS_PER_BYTE; // locationType (1 byte) +const CHAIN_ID_END = LOCATION_TYPE_END + 32 * HEX_CHARS_PER_BYTE; // chainId (32 bytes) +const CONTRACT_END = CHAIN_ID_END + 20 * HEX_CHARS_PER_BYTE; // contractAddress (20 bytes) +const SLOT_END = CONTRACT_END + 32 * HEX_CHARS_PER_BYTE; // slot (32 bytes); also the full 86-byte payload length + +/** The only `locationType` EFP defines: an onchain EVM contract location. */ +const LOCATION_TYPE_ONCHAIN = 1; + +/** Largest chain id storable in the `int8` columns without JS precision loss (2^53 - 1). */ +const MAX_SAFE_CHAIN_ID = BigInt(Number.MAX_SAFE_INTEGER); + +export function parseListStorageLocation( + lsl: Hex | string | null | undefined, +): ParsedListStorageLocation | null { + if (!lsl || typeof lsl !== "string" || !isHex(lsl)) return null; + + const bytes = lsl.slice(2); + // A locationType-1 location is a fixed 86-byte payload; reject any other length up front, which + // also guarantees every field slice below is fully present. + if (bytes.length !== SLOT_END) return null; + + const version = parseInt(bytes.slice(0, VERSION_END), 16); + const locationType = parseInt(bytes.slice(VERSION_END, LOCATION_TYPE_END), 16); + + // The version byte defines the payload schema; reject other versions or location types rather + // than remap the list from a payload this indexer can't represent. + if (version !== EFP_LSL_VERSION) return null; + if (locationType !== LOCATION_TYPE_ONCHAIN) return null; + + // The chain id is an opaque 32-byte field, but it must fit a JS-safe integer to land in the + // `int8` columns without precision loss or overflow; reject anything outside (0, 2^53 - 1] so a + // bad value is treated as an undecodable location rather than crashing a write downstream. + const chainId = BigInt(`0x${bytes.slice(LOCATION_TYPE_END, CHAIN_ID_END)}`); + if (chainId <= 0n || chainId > MAX_SAFE_CHAIN_ID) return null; + + return { + version, + chainId, + contractAddress: `0x${bytes.slice(CHAIN_ID_END, CONTRACT_END).toLowerCase()}` as Hex, + slot: `0x${bytes.slice(CONTRACT_END, SLOT_END).toLowerCase()}` as Hex, + }; +} diff --git a/apps/ensindexer/src/plugins/efp/plugin.ts b/apps/ensindexer/src/plugins/efp/plugin.ts new file mode 100644 index 0000000000..764e8d7d8d --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/plugin.ts @@ -0,0 +1,94 @@ +/** + * The EFP plugin indexes the Ethereum Follow Protocol: + * - list NFTs (`ListRegistry` on Base), + * - list records & tags (`ListRecords` on Base, Optimism, and Ethereum mainnet), and + * - account metadata (`AccountMetadata` on Base). + * + * EFP does not consume ENS protocol data; it indexes its own contracts, sourced from the EFP + * datasources, which exist only on the `mainnet` ENS namespace — so the plugin can only be + * activated there (datasource-presence validation enforces this). + */ + +import * as ponder from "ponder"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; +import { + chainConfigForContract, + chainsConnectionConfig, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; + +const pluginName = PluginName.EFP; + +const REQUIRED_DATASOURCE_NAMES = [ + DatasourceNames.EFPBase, + DatasourceNames.EFPOptimism, + DatasourceNames.EFPEthereum, +]; + +export default createPlugin({ + name: pluginName, + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, + allDatasourceNames: REQUIRED_DATASOURCE_NAMES, + createPonderConfig(config) { + const { efpBase, efpOptimism, efpEthereum } = getRequiredDatasources( + config.namespace, + REQUIRED_DATASOURCE_NAMES, + ); + + return ponder.createConfig({ + chains: { + ...chainsConnectionConfig(config.rpcConfigs, efpBase.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, efpOptimism.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, efpEthereum.chain.id), + }, + contracts: { + // ListRegistry + AccountMetadata are deployed only on Base. + [namespaceContract(pluginName, "ListRegistry")]: { + chain: { + ...chainConfigForContract( + config.globalBlockrange, + efpBase.chain.id, + efpBase.contracts.ListRegistry, + ), + }, + abi: efpBase.contracts.ListRegistry.abi, + }, + [namespaceContract(pluginName, "AccountMetadata")]: { + chain: { + ...chainConfigForContract( + config.globalBlockrange, + efpBase.chain.id, + efpBase.contracts.AccountMetadata, + ), + }, + abi: efpBase.contracts.AccountMetadata.abi, + }, + // ListRecords is deployed on Base, Optimism, and Ethereum mainnet (identical ABI). + [namespaceContract(pluginName, "ListRecords")]: { + chain: { + ...chainConfigForContract( + config.globalBlockrange, + efpBase.chain.id, + efpBase.contracts.ListRecords, + ), + ...chainConfigForContract( + config.globalBlockrange, + efpOptimism.chain.id, + efpOptimism.contracts.ListRecords, + ), + ...chainConfigForContract( + config.globalBlockrange, + efpEthereum.chain.id, + efpEthereum.contracts.ListRecords, + ), + }, + abi: efpBase.contracts.ListRecords.abi, + }, + }, + }); + }, +}); diff --git a/apps/ensindexer/src/plugins/index.ts b/apps/ensindexer/src/plugins/index.ts index 8c8f068f4e..1d8f7d8343 100644 --- a/apps/ensindexer/src/plugins/index.ts +++ b/apps/ensindexer/src/plugins/index.ts @@ -2,6 +2,7 @@ import type { PluginName } from "@ensnode/ensnode-sdk"; import type { MergedTypes } from "@/lib/lib-helpers"; +import efpPlugin from "./efp/plugin"; import protocolAccelerationPlugin from "./protocol-acceleration/plugin"; import registrarsPlugin from "./registrars/plugin"; import basenamesPlugin from "./subgraph/plugins/basenames/plugin"; @@ -20,6 +21,7 @@ export const ALL_PLUGINS = [ protocolAccelerationPlugin, registrarsPlugin, unigraphPlugin, + efpPlugin, ] as const; /** diff --git a/docker/docker-compose.devnet.yml b/docker/docker-compose.devnet.yml index c6c66c7ba9..5dd1f1afa4 100644 --- a/docker/docker-compose.devnet.yml +++ b/docker/docker-compose.devnet.yml @@ -19,6 +19,8 @@ services: condition: service_healthy devnet: condition: service_healthy + efp-devnet: + condition: service_healthy ensapi: extends: @@ -77,6 +79,16 @@ services: file: services/devnet.yml service: devnet + efp-devnet: + extends: + file: services/efp-devnet.yml + service: efp-devnet + ports: + - "8001:8000" + depends_on: + devnet: + condition: service_healthy + volumes: # Docker Compose requires volumes used by services to be declared in each # compose file that references them — they cannot be inherited via `extends`. @@ -88,3 +100,6 @@ volumes: ensrainbow_data: name: ensnode_devnet_ensrainbow_data driver: local + efp-deployments: + name: ensnode_devnet_efp_deployments + driver: local diff --git a/docker/docker-compose.orchestrator.yml b/docker/docker-compose.orchestrator.yml index bb11b89582..96b3cff79c 100644 --- a/docker/docker-compose.orchestrator.yml +++ b/docker/docker-compose.orchestrator.yml @@ -14,6 +14,15 @@ services: service: devnet container_name: devnet-orchestrator + efp-devnet: + extends: + file: services/efp-devnet.yml + service: efp-devnet + container_name: efp-devnet-orchestrator + depends_on: + devnet: + condition: service_healthy + ensdb: extends: file: services/ensdb.yml @@ -28,3 +37,5 @@ services: volumes: ensdb_data: driver: local + efp-deployments: + driver: local diff --git a/docker/envs/.env.docker.devnet b/docker/envs/.env.docker.devnet index 09c4a40614..93d9175d55 100644 --- a/docker/envs/.env.docker.devnet +++ b/docker/envs/.env.docker.devnet @@ -2,7 +2,7 @@ # These values work out of the box — override by creating .env.docker.local. # ENSIndexer -PLUGINS=subgraph,unigraph,protocol-acceleration +PLUGINS=subgraph,unigraph,protocol-acceleration,efp # ENSIndexer and ENSApi ENSINDEXER_SCHEMA_NAME=docker_devnet_v1 # ENSIndexer and ENSRainbow diff --git a/docker/services/efp-devnet.yml b/docker/services/efp-devnet.yml new file mode 100644 index 0000000000..6eb124c2f6 --- /dev/null +++ b/docker/services/efp-devnet.yml @@ -0,0 +1,29 @@ +# Deploys (and seeds) the EFP contracts onto the ENS devnet's anvil node, so that one chain +# (id 31337) carries both ENS v2 and EFP contracts. Attaches to the already-running node via +# DEVNET_RPC_URL instead of spawning its own anvil; EFP addresses are written to the shared +# `efp-deployments` volume as /app/deployments/devnet-31337.json. +# +# The image is pinned to a specific master build, mirroring how the ENS devnet pins contracts-v2. +# Re-capture the EFP addresses in packages/datasources/src/devnet/constants.ts whenever this pin +# (or the ENS devnet image in services/devnet.yml) is bumped. +# +# @see https://github.com/ethereumfollowprotocol/contracts/pull/7 +services: + efp-devnet: + container_name: efp-devnet + image: ghcr.io/ethereumfollowprotocol/contracts/devnet:master-c76225c + pull_policy: always + environment: + DEVNET_HOST: "0.0.0.0" + DEVNET_RPC_URL: "http://devnet:8545" + DEVNET_SCENARIO: "demoGraph" + DEVNET_SAVE_DEPLOYMENTS: "true" + volumes: + - efp-deployments:/app/deployments + healthcheck: + test: [ "CMD", "curl", "--fail", "-s", "http://localhost:8000/health" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s + start_interval: 1s diff --git a/packages/datasources/src/abis/efp/AccountMetadata.ts b/packages/datasources/src/abis/efp/AccountMetadata.ts new file mode 100644 index 0000000000..d60abea2b2 --- /dev/null +++ b/packages/datasources/src/abis/efp/AccountMetadata.ts @@ -0,0 +1,20 @@ +import type { Abi } from "viem"; + +/** + * Event-only ABI for the EFP `AccountMetadata` contract, which stores arbitrary + * `(addr, key) -> value` metadata for accounts (today only `primary-list`). + * + * `value` is `bytes` (not `string`): the contract emits raw bytes, and most + * keys store binary payloads. + */ +export const AccountMetadata = [ + { + type: "event", + name: "UpdateAccountMetadata", + inputs: [ + { indexed: true, name: "addr", type: "address" }, + { indexed: false, name: "key", type: "string" }, + { indexed: false, name: "value", type: "bytes" }, + ], + }, +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/efp/ListRecords.ts b/packages/datasources/src/abis/efp/ListRecords.ts new file mode 100644 index 0000000000..9d52dd6885 --- /dev/null +++ b/packages/datasources/src/abis/efp/ListRecords.ts @@ -0,0 +1,29 @@ +import type { Abi } from "viem"; + +/** + * Event-only ABI for the EFP `ListRecords` contract (deployed on Base, Optimism, + * and Ethereum mainnet), which holds the records of each list. + * + * `slot` is emitted as `uint256` (matching the live contracts); the EFP plugin + * zero-pads it to `bytes32` for storage. `ListOp.op` and `UpdateListMetadata.value` + * are `bytes` packed payloads decoded by the plugin's parsers. + */ +export const ListRecords = [ + { + type: "event", + name: "ListOp", + inputs: [ + { indexed: true, name: "slot", type: "uint256" }, + { indexed: false, name: "op", type: "bytes" }, + ], + }, + { + type: "event", + name: "UpdateListMetadata", + inputs: [ + { indexed: true, name: "slot", type: "uint256" }, + { indexed: false, name: "key", type: "string" }, + { indexed: false, name: "value", type: "bytes" }, + ], + }, +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/efp/ListRegistry.ts b/packages/datasources/src/abis/efp/ListRegistry.ts new file mode 100644 index 0000000000..fde53da1f7 --- /dev/null +++ b/packages/datasources/src/abis/efp/ListRegistry.ts @@ -0,0 +1,30 @@ +import type { Abi } from "viem"; + +/** + * Event-only ABI for the EFP `ListRegistry` contract (the ERC-721 contract that + * mints "list" NFTs). + * + * `UpdateListStorageLocation.listStorageLocation` is `bytes`: a packed + * `version | locationType | chainId | contractAddress | slot` payload (decoded + * by the EFP plugin). EFP defines a single `locationType` (1, EVM contract) — + * see https://docs.efp.app/design/list-storage-location/. + */ +export const ListRegistry = [ + { + type: "event", + name: "Transfer", + inputs: [ + { indexed: true, name: "from", type: "address" }, + { indexed: true, name: "to", type: "address" }, + { indexed: true, name: "tokenId", type: "uint256" }, + ], + }, + { + type: "event", + name: "UpdateListStorageLocation", + inputs: [ + { indexed: true, name: "tokenId", type: "uint256" }, + { indexed: false, name: "listStorageLocation", type: "bytes" }, + ], + }, +] as const satisfies Abi; diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index e1c942f07f..abea3cb0c3 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -93,6 +93,29 @@ export const contracts = { MockDAI: "0xcbeaf3bde82155f56486fb5a1072cb8baaf547cc", } as const satisfies Record; +/** + * Deterministic addresses of the EFP (Ethereum Follow Protocol) contracts, deployed onto the + * ens-test-env devnet (chain 31337) by the EFP devnet image in attach mode, on top of the + * contracts-v2 ENS deployment. The three contracts the EFP plugin indexes, plus the `EFPListMinter` + * (not indexed) which the integration-test EFP seeder uses to mint lists. + * + * These addresses depend on the deployer's (Anvil account 0) nonce after the ENS deployment, so + * they are a function of BOTH the pinned ENS devnet image (`docker/services/devnet.yml`, run with + * `--testNames`) and the pinned EFP devnet image (`docker/services/efp-devnet.yml`). They were + * verified stable across clean redeploys. Re-capture whenever either image pin is bumped: + * + * docker compose -f docker/docker-compose.devnet.yml up devnet efp-devnet + * docker exec efp-devnet cat /app/deployments/devnet-31337.json + * + * @see https://github.com/ethereumfollowprotocol/contracts/pull/7 + */ +export const efpContracts = { + EFPAccountMetadata: "0xd5ac451b0c50b9476107823af206ed814a2e2580", + EFPListRegistry: "0xf8e31cb472bc70500f08cd84917e5a1912ec8397", + EFPListRecords: "0xc0f115a19107322cfbf1cdbc7ea011c19ebdb4f8", + EFPListMinter: "0xc96304e3c037f81da488ed9dea1d8f2a48278a75", +} as const satisfies Record; + /** * Must match the devnet mnemonic in contracts-v2 (Anvil named accounts). * @see https://github.com/ensdomains/contracts-v2/blob/69bde1b345c47caf3d55a105b9f922280ba55f00/contracts/script/setup.ts#L56 @@ -129,6 +152,35 @@ export const addresses = { one: asNormalizedAddress(`0x${"1".repeat(40)}`), } as const satisfies Record; +/** + * Synthetic EFP follow targets used by the integration EFP seeder (`integration-test-env`) and the + * EFP integration tests. Each anchors a distinct seeded record so tests can look it up by + * `recordData`; none is an indexed ENS account, so they also exercise `EfpListRecord.account`'s null + * path. + */ +export const efpSeedTargets = { + /** ADD + ADD_TAG("block") + ADD_TAG("block") -> tags === ["block"] (dedup). */ + dedup: asNormalizedAddress(`0x${"d1".repeat(20)}`), + /** ADD + ADD_TAG("vip") + REMOVE + ADD -> record present, tags === [] (embed cascade + fresh). */ + cascade: asNormalizedAddress(`0x${"ca".repeat(20)}`), + /** ADD + REMOVE(target + junk) -> record gone (canonical 22-byte keying). */ + junk: asNormalizedAddress(`0x${"1c".repeat(20)}`), + /** Anchors a list whose `user` role must survive a storage-location re-point away and back. */ + durable: asNormalizedAddress(`0x${"d0".repeat(20)}`), +} as const satisfies Record; + +/** The `user` role set on the {@link efpSeedTargets.durable} list, re-derived after the re-point. */ +export const efpSeedRoleUser = asNormalizedAddress(`0x${"ab".repeat(20)}`); + +/** + * The Anvil account (mnemonic index 6) the EFP seeder mints its lists from. It has `primary-list` + * metadata (set by easyMintTo) but its lists' `user` is never itself, so it exercises the + * `primaryList` two-step validation's mismatch (rejection) branch. + */ +export const efpSeedActorAddress = asNormalizedAddress( + "0x976ea74026e726554db657fa54763abd0c3a0aa9", +); + export const fixtures = { abiBytes: `0x${"01".repeat(32)}`, fourBytesInterface: "0x11100111", diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/ens-test-env.ts index f864cd921d..45691d989f 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -1,3 +1,7 @@ +// ABIs for EFP Datasources +import { AccountMetadata as efp_AccountMetadata } from "./abis/efp/AccountMetadata"; +import { ListRecords as efp_ListRecords } from "./abis/efp/ListRecords"; +import { ListRegistry as efp_ListRegistry } from "./abis/efp/ListRegistry"; import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; import { Registry } from "./abis/ensv2/Registry"; @@ -11,7 +15,7 @@ import { UniversalResolverV1 } from "./abis/root/UniversalResolverV1"; import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; import { StandaloneReverseRegistrar } from "./abis/shared/StandaloneReverseRegistrar"; -import { contracts } from "./devnet/constants"; +import { contracts, efpContracts } from "./devnet/constants"; import { ensTestEnvChain } from "./lib/chains"; // Shared ABIs import { ResolverABI } from "./lib/ResolverABI"; @@ -164,4 +168,62 @@ export default { }, }, }, + + /** + * EFP Datasources + * + * The Ethereum Follow Protocol contracts, deployed onto the ens-test-env devnet by the EFP devnet + * image (attach mode) on top of the ENS deployment. On the `mainnet` namespace EFP spans Base, + * Optimism, and Ethereum; the single-chain devnet has only one EFP deployment, so all three EFP + * datasources resolve to `ensTestEnvChain` (id 31337) with the same `ListRecords` contract. + * + * The EFP plugin's Ponder config keys each contract by chain id, so the three datasources collapse + * to a single set of contracts on chain 31337 (no double-indexing). This relies on the + * `ListRecords` address being IDENTICAL across all three datasources below — if they diverge, the + * per-chain-id merge silently keeps only `EFPEthereum`'s. + * + * @see docker/services/efp-devnet.yml for how these contracts are deployed and addresses captured. + */ + [DatasourceNames.EFPBase]: { + chain: ensTestEnvChain, + contracts: { + ListRegistry: { + abi: efp_ListRegistry, + address: efpContracts.EFPListRegistry, + startBlock: 0, + }, + AccountMetadata: { + abi: efp_AccountMetadata, + address: efpContracts.EFPAccountMetadata, + startBlock: 0, + }, + ListRecords: { + abi: efp_ListRecords, + address: efpContracts.EFPListRecords, + startBlock: 0, + }, + }, + }, + + [DatasourceNames.EFPOptimism]: { + chain: ensTestEnvChain, + contracts: { + ListRecords: { + abi: efp_ListRecords, + address: efpContracts.EFPListRecords, + startBlock: 0, + }, + }, + }, + + [DatasourceNames.EFPEthereum]: { + chain: ensTestEnvChain, + contracts: { + ListRecords: { + abi: efp_ListRecords, + address: efpContracts.EFPListRecords, + startBlock: 0, + }, + }, + }, } satisfies ENSNamespace; diff --git a/packages/datasources/src/lib/types.ts b/packages/datasources/src/lib/types.ts index 027974dbf9..e56ccc5f41 100644 --- a/packages/datasources/src/lib/types.ts +++ b/packages/datasources/src/lib/types.ts @@ -65,6 +65,9 @@ export const DatasourceNames = { ReverseResolverArbitrum: "rrArbitrum", ReverseResolverScroll: "rrScroll", ENSv2Root: "ENSv2Root", + EFPBase: "efpBase", + EFPOptimism: "efpOptimism", + EFPEthereum: "efpEthereum", } as const; export type DatasourceName = (typeof DatasourceNames)[keyof typeof DatasourceNames]; diff --git a/packages/datasources/src/mainnet.ts b/packages/datasources/src/mainnet.ts index 0e69340380..82426b77b8 100644 --- a/packages/datasources/src/mainnet.ts +++ b/packages/datasources/src/mainnet.ts @@ -6,6 +6,10 @@ import { EarlyAccessRegistrarController as base_EARegistrarController } from "./ import { RegistrarController as base_RegistrarController } from "./abis/basenames/RegistrarController"; import { Registry as base_Registry } from "./abis/basenames/Registry"; import { UpgradeableRegistrarController as base_UpgradeableRegistrarController } from "./abis/basenames/UpgradeableRegistrarController"; +// ABIs for EFP Datasource +import { AccountMetadata as efp_AccountMetadata } from "./abis/efp/AccountMetadata"; +import { ListRecords as efp_ListRecords } from "./abis/efp/ListRecords"; +import { ListRegistry as efp_ListRegistry } from "./abis/efp/ListRegistry"; // ABIs for Lineanames Datasource import { BaseRegistrar as linea_BaseRegistrar } from "./abis/lineanames/BaseRegistrar"; import { EthRegistrarController as linea_EthRegistrarController } from "./abis/lineanames/EthRegistrarController"; @@ -497,4 +501,74 @@ export default { }, }, }, + + /** + * EFP (Ethereum Follow Protocol) Datasource on Base. + * + * The `ListRegistry` (list NFTs) and `AccountMetadata` contracts are deployed only on Base. + * The `ListRecords` contract is also deployed on Base (one of the three "list storage location" + * chains a list NFT may point at via `UpdateListStorageLocation`). + * + * Every address below is cross-checked against the official EFP deployments, + * https://docs.efp.app/production/deployments/ (and ethereumfollowprotocol/api-v2). + */ + [DatasourceNames.EFPBase]: { + chain: base, + contracts: { + // EFPListRegistry, Base. + ListRegistry: { + abi: efp_ListRegistry, + address: "0x0e688f5dca4a0a4729946acbc44c792341714e08", + startBlock: 20180000, + }, + // EFPAccountMetadata, Base. NOTE: this is the SAME address as EFPListRecords on Ethereum + // mainnet (see EFPEthereum below). EFP deploys via CREATE2, so one address can map to a + // different contract on each chain; this is not a copy-paste error. + AccountMetadata: { + abi: efp_AccountMetadata, + address: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + startBlock: 20180000, + }, + // EFPListRecords, Base. + ListRecords: { + abi: efp_ListRecords, + address: "0x41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33", + startBlock: 20180000, + }, + }, + }, + + /** + * EFP `ListRecords` Datasource on Optimism. + */ + [DatasourceNames.EFPOptimism]: { + chain: optimism, + contracts: { + // EFPListRecords, Optimism. + ListRecords: { + abi: efp_ListRecords, + address: "0x4ca00413d850dcfa3516e14d21dae2772f2acb85", + startBlock: 125792000, + }, + }, + }, + + /** + * EFP `ListRecords` Datasource on Ethereum mainnet. + */ + [DatasourceNames.EFPEthereum]: { + chain: mainnet, + contracts: { + // EFPListRecords, Ethereum mainnet. This shares the 0x5289…0F17EF address with + // EFPAccountMetadata on Base (above): EFP deploys via CREATE2, so the same address appears on + // multiple chains for different contracts. Confirmed as ListRecords on Ethereum mainnet at + // https://docs.efp.app/production/deployments/ (and ethereumfollowprotocol/api-v2); it is NOT + // a copy-paste of the Base AccountMetadata address. + ListRecords: { + abi: efp_ListRecords, + address: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + startBlock: 20820000, + }, + }, + }, } satisfies ENSNamespace; diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts new file mode 100644 index 0000000000..ce58f34e9e --- /dev/null +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -0,0 +1,170 @@ +/** + * EFP (Ethereum Follow Protocol) abstract schema. + * + * Tables are prefixed `efp_` and indexed by the EFP plugin + * (`apps/ensindexer/src/plugins/efp`). The model mirrors the ethereumfollowprotocol/api-v2 + * reference indexer with two adaptations for ENSNode's primary-key-only access pattern: + * `efp_list_storage_locations` is a reverse index so list-metadata events resolve the owning list + * NFT by primary key (rather than scanning `efp_lists` by storage location), and a record's tags + * are embedded as an array on `efp_list_records` (rather than a separate join table) so removing a + * record is a single primary-key delete instead of a non-PK cascade. + * + * Timestamps are Unix-seconds `bigint`s (the block timestamp), matching ENSNode convention. + */ + +import { index, onchainTable, sql } from "ponder"; + +/** + * One row per minted `ListRegistry` NFT (a "list"). EFP separates the NFT `owner`, the + * `user` allowed to post records, and the `manager` allowed to administer the list. The + * `listStorageLocation*` columns describe which `(chainId, contractAddress, slot)` tuple in + * `efp_list_records` stores this list's records. + */ +export const efpLists = onchainTable( + "efp_lists", + (t) => ({ + /** ERC-721 token id of the list NFT, as a decimal string. */ + tokenId: t.text().primaryKey(), + /** Current ERC-721 owner of the list NFT. */ + owner: t.hex().notNull(), + /** Chain id of the `ListRegistry` NFT (Base / 8453 on mainnet; the active namespace's EFP deployment chain otherwise). */ + nftChainId: t.int8({ mode: "number" }).notNull(), + /** `ListRegistry` contract address on `nftChainId`. */ + nftContractAddress: t.hex().notNull(), + /** Raw `UpdateListStorageLocation` payload. */ + listStorageLocation: t.hex(), + /** Decoded list storage location: target chain id. */ + listStorageLocationChainId: t.int8({ mode: "number" }), + /** Decoded list storage location: target contract address. */ + listStorageLocationContractAddress: t.hex(), + /** Decoded list storage location: target slot (bytes32). */ + listStorageLocationSlot: t.hex(), + /** Address allowed to post records to this list (the EFP "user"). */ + user: t.hex(), + /** Address allowed to administer this list (the EFP "manager"). */ + manager: t.hex(), + createdAt: t.bigint().notNull(), + updatedAt: t.bigint().notNull(), + }), + (t) => ({ + idx_owner: index().on(t.owner), + idx_user: index().on(t.user), + idx_manager: index().on(t.manager), + idx_storageLocation: index().on( + t.listStorageLocationChainId, + t.listStorageLocationContractAddress, + t.listStorageLocationSlot, + ), + // Numeric (not lexicographic) ordering of the text `tokenId` (a uint256, too large for an + // integer column) is index-backed via this expression index, so `efp.lists` / + // `Account.efp.lists` pagination — which compares and orders by `tokenId::numeric` — stays + // index-backed at mainnet-scale list counts instead of falling back to a sort. + idx_tokenId_numeric: index("efp_lists_token_id_numeric").on(sql`(${t.tokenId}::numeric)`), + }), +); + +/** + * Reverse index from a storage location `(chainId, contractAddress, slot)` to the list NFT that + * points at it. Written by the `UpdateListStorageLocation` handler so that `UpdateListMetadata` + * events (emitted by the `ListRecords` contract, keyed only by slot) can find the owning list NFT + * by primary key instead of scanning `efp_lists`. + */ +export const efpListStorageLocations = onchainTable( + "efp_list_storage_locations", + (t) => ({ + /** Composite key "chainId-contractAddress-slot". */ + id: t.text().primaryKey(), + chainId: t.int8({ mode: "number" }).notNull(), + contractAddress: t.hex().notNull(), + slot: t.hex().notNull(), + /** Token id of the list NFT currently pointing at this storage location. */ + tokenId: t.text().notNull(), + updatedAt: t.bigint().notNull(), + }), + (t) => ({ + idx_tokenId: index().on(t.tokenId), + }), +); + +/** + * One row per record currently in a list. The `record` column is the canonical + * `version | type | address` 22-byte prefix (any trailing junk after the address truncated), which + * is also what tag and remove ops reference. A record's `tags` are embedded here as a set of UTF-8 + * strings, so removing a record drops its tags in the same primary-key delete. + */ +export const efpListRecords = onchainTable( + "efp_list_records", + (t) => ({ + /** Composite key "chainId-contractAddress-slot-record". */ + id: t.text().primaryKey(), + chainId: t.int8({ mode: "number" }).notNull(), + contractAddress: t.hex().notNull(), + slot: t.hex().notNull(), + /** Canonical record prefix `version | type | address` (22 bytes). */ + record: t.hex().notNull(), + /** Decoded record header — version byte. */ + recordVersion: t.integer().notNull(), + /** Decoded record header — type byte. */ + recordType: t.integer().notNull(), + /** Decoded record data. For address records (type 1), exactly 20 bytes. */ + recordData: t.hex().notNull(), + /** UTF-8 tags attached to this record (a set; NUL bytes stripped). */ + tags: t.text().array().notNull(), + createdAt: t.bigint().notNull(), + }), + (t) => ({ + idx_slot: index().on(t.chainId, t.contractAddress, t.slot), + idx_recordData: index().on(t.recordData), + }), +); + +/** + * Most-recent `value` per `(address, key)` account-metadata pair (today only `primary-list`). + */ +export const efpAccountMetadata = onchainTable( + "efp_account_metadata", + (t) => ({ + /** Composite key "address-key". */ + id: t.text().primaryKey(), + chainId: t.int8({ mode: "number" }).notNull(), + contractAddress: t.hex().notNull(), + /** Account whose metadata this is. */ + address: t.hex().notNull(), + /** Metadata key (UTF-8 string). */ + key: t.text().notNull(), + /** Metadata value (raw bytes). */ + value: t.hex().notNull(), + createdAt: t.bigint().notNull(), + updatedAt: t.bigint().notNull(), + }), + (t) => ({ + idx_address: index().on(t.address), + }), +); + +/** + * EFP List Metadata (`user` / `manager`), keyed by the storage location it is set at + * (`chainId-contractAddress-slot-key`), not by list NFT. `UpdateListMetadata` is emitted on the + * `ListRecords` contract while the storage-location mapping is created by `UpdateListStorageLocation` + * on the `ListRegistry` contract (a different contract, sometimes on a different chain), so the two + * can arrive in either order. The value here is durable: it survives a list re-pointing its storage + * location, and the storage-location handler reads it to (re-)populate `efp_lists.user` / `manager` + * for whichever list points at the location. One row per `(location, key)`, bounded by the number + * of distinct locations seen. + */ +export const efpListMetadata = onchainTable( + "efp_list_metadata", + (t) => ({ + /** Composite key "chainId-contractAddress-slot-key". */ + id: t.text().primaryKey(), + chainId: t.int8({ mode: "number" }).notNull(), + contractAddress: t.hex().notNull(), + slot: t.hex().notNull(), + key: t.text().notNull(), + value: t.hex().notNull(), + createdAt: t.bigint().notNull(), + }), + (t) => ({ + idx_slot: index().on(t.chainId, t.contractAddress, t.slot), + }), +); diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/index.ts b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts index 3e2d08a61d..5da1951a74 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/index.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts @@ -4,6 +4,7 @@ * for ENSDb, which is then used to build the ENSDb Schema for a Drizzle client for ENSDb. */ +export * from "./efp.schema"; export * from "./migrated-nodes.schema"; export * from "./protocol-acceleration.schema"; export * from "./registrars.schema"; diff --git a/packages/ensnode-sdk/src/ensindexer/config/types.ts b/packages/ensnode-sdk/src/ensindexer/config/types.ts index 39c99beb08..fafd0290b4 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/types.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/types.ts @@ -19,6 +19,7 @@ export enum PluginName { /** @deprecated use {@link PluginName.Unigraph} instead */ ENSv2 = "ensv2", Unigraph = "unigraph", + EFP = "efp", } /** diff --git a/packages/ensnode-sdk/src/omnigraph-api/prerequisites.test.ts b/packages/ensnode-sdk/src/omnigraph-api/prerequisites.test.ts new file mode 100644 index 0000000000..71c4ca65d1 --- /dev/null +++ b/packages/ensnode-sdk/src/omnigraph-api/prerequisites.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { type EnsIndexerPublicConfig, PluginName } from "../ensindexer/config/types"; +import { hasOmnigraphApiConfigSupport } from "./prerequisites"; + +// The gate only reads `config.plugins`, so a minimal cast suffices. +const configWithPlugins = (plugins: PluginName[]) => + ({ plugins }) as unknown as EnsIndexerPublicConfig; + +describe("hasOmnigraphApiConfigSupport", () => { + it("is supported by unigraph, ensv2, or efp (efp alone counts)", () => { + expect(hasOmnigraphApiConfigSupport(configWithPlugins([PluginName.Unigraph])).supported).toBe( + true, + ); + expect(hasOmnigraphApiConfigSupport(configWithPlugins([PluginName.ENSv2])).supported).toBe( + true, + ); + expect(hasOmnigraphApiConfigSupport(configWithPlugins([PluginName.EFP])).supported).toBe(true); + expect( + hasOmnigraphApiConfigSupport(configWithPlugins([PluginName.Subgraph, PluginName.EFP])) + .supported, + ).toBe(true); + }); + + it("is unsupported without one of those plugins", () => { + const result = hasOmnigraphApiConfigSupport(configWithPlugins([PluginName.Subgraph])); + expect(result.supported).toBe(false); + }); +}); diff --git a/packages/ensnode-sdk/src/omnigraph-api/prerequisites.ts b/packages/ensnode-sdk/src/omnigraph-api/prerequisites.ts index 432ab8ae0f..44eabd1232 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/prerequisites.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/prerequisites.ts @@ -5,15 +5,22 @@ import type { PrerequisiteResult } from "../shared/prerequisites"; /** * Check if provided EnsIndexerPublicConfig supports the Omnigraph API. + * + * The Omnigraph API is served whenever the config indexes data it can expose: the ENS data model + * (`unigraph` / `ensv2`) or the highly-ENS-adjacent EFP protocol (`efp`). EFP qualifies on its own + * so an EFP-only config can still query the `efp` namespace (the ENS query fields are present but + * return no data without `unigraph`). */ export function hasOmnigraphApiConfigSupport(config: EnsIndexerPublicConfig): PrerequisiteResult { const supported = - config.plugins.includes(PluginName.Unigraph) || config.plugins.includes(PluginName.ENSv2); + config.plugins.includes(PluginName.Unigraph) || + config.plugins.includes(PluginName.ENSv2) || + config.plugins.includes(PluginName.EFP); if (supported) return { supported }; return { supported: false, - reason: `The connected ENSNode's Config must have the '${PluginName.Unigraph}' plugin enabled.`, + reason: `The connected ENSNode's Config must have one of the '${PluginName.Unigraph}', '${PluginName.ENSv2}', or '${PluginName.EFP}' plugins enabled.`, }; } diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 294f4202fd..bf30560400 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -129,6 +129,18 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "efp", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountEfp" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "events", "type": { @@ -460,6 +472,140 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "AccountEfp", + "fields": [ + { + "name": "lists", + "type": { + "kind": "OBJECT", + "name": "AccountEfpListsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, + { + "name": "primaryList", + "type": { + "kind": "OBJECT", + "name": "EfpList" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "AccountEfpListsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountEfpListsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "AccountEfpListsConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpList" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "AccountEventsConnection", @@ -3458,7 +3604,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "Event", + "name": "EfpAccountMetadata", "fields": [ { "name": "address", @@ -3473,103 +3619,115 @@ const introspection = { "isDeprecated": false }, { - "name": "blockHash", + "name": "chainId", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Hex" + "name": "ChainId" } }, "args": [], "isDeprecated": false }, { - "name": "blockNumber", + "name": "contractAddress", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "BigInt" + "name": "Address" } }, "args": [], "isDeprecated": false }, { - "name": "chainId", + "name": "createdAt", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "ChainId" + "name": "BigInt" } }, "args": [], "isDeprecated": false }, { - "name": "data", + "name": "id", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Hex" + "name": "String" } }, "args": [], "isDeprecated": false }, { - "name": "from", + "name": "key", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Address" + "name": "String" } }, "args": [], "isDeprecated": false }, { - "name": "id", + "name": "updatedAt", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "ID" + "name": "BigInt" } }, "args": [], "isDeprecated": false }, { - "name": "logIndex", + "name": "value", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Int" + "name": "Hex" } }, "args": [], "isDeprecated": false - }, + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "EfpAccountMetadatasWhereInput", + "inputFields": [ { - "name": "sender", + "name": "address", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "Address" } - }, - "args": [], - "isDeprecated": false - }, + } + } + ], + "isOneOf": false + }, + { + "kind": "OBJECT", + "name": "EfpList", + "fields": [ { - "name": "timestamp", + "name": "createdAt", "type": { "kind": "NON_NULL", "ofType": { @@ -3581,7 +3739,16 @@ const introspection = { "isDeprecated": false }, { - "name": "to", + "name": "listStorageLocationChainId", + "type": { + "kind": "SCALAR", + "name": "ChainId" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "listStorageLocationContractAddress", "type": { "kind": "SCALAR", "name": "Address" @@ -3590,73 +3757,1057 @@ const introspection = { "isDeprecated": false }, { - "name": "topics", + "name": "listStorageLocationSlot", + "type": { + "kind": "SCALAR", + "name": "Hex" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "manager", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "nftChainId", "type": { "kind": "NON_NULL", "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Hex" - } - } + "kind": "SCALAR", + "name": "ChainId" } }, "args": [], "isDeprecated": false }, { - "name": "transactionHash", + "name": "nftContractAddress", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Hex" + "name": "Address" } }, "args": [], "isDeprecated": false }, { - "name": "transactionIndex", + "name": "owner", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "Int" + "name": "Address" } }, "args": [], "isDeprecated": false - } - ], - "interfaces": [] - }, - { - "kind": "INPUT_OBJECT", - "name": "EventsFromFilter", - "inputFields": [ - { - "name": "eq", - "type": { - "kind": "SCALAR", - "name": "Address" - } }, { - "name": "in", + "name": "records", "type": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { + "kind": "OBJECT", + "name": "EfpListRecordsConnection" + }, + "args": [ + { + "name": "after", + "type": { "kind": "SCALAR", - "name": "Address" + "name": "String" } - } - } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isDeprecated": false + }, + { + "name": "tokenId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "updatedAt", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "user", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpListRecord", + "fields": [ + { + "name": "account", + "type": { + "kind": "OBJECT", + "name": "Account" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "chainId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ChainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "contractAddress", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "createdAt", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "list", + "type": { + "kind": "OBJECT", + "name": "EfpList" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "record", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "recordData", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "recordType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "slot", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "tags", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpListRecordsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpListRecordsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpListRecordsConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpListRecord" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "EfpListRecordsWhereInput", + "inputFields": [ + { + "name": "recordData", + "type": { + "kind": "SCALAR", + "name": "Address" + } + }, + { + "name": "recordType", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "isOneOf": false + }, + { + "kind": "INPUT_OBJECT", + "name": "EfpListsWhereInput", + "inputFields": [ + { + "name": "manager", + "type": { + "kind": "SCALAR", + "name": "Address" + } + }, + { + "name": "owner", + "type": { + "kind": "SCALAR", + "name": "Address" + } + }, + { + "name": "user", + "type": { + "kind": "SCALAR", + "name": "Address" + } + } + ], + "isOneOf": false + }, + { + "kind": "OBJECT", + "name": "EfpQuery", + "fields": [ + { + "name": "accountMetadata", + "type": { + "kind": "OBJECT", + "name": "EfpAccountMetadata" + }, + "args": [ + { + "name": "address", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + } + }, + { + "name": "key", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "accountMetadatas", + "type": { + "kind": "OBJECT", + "name": "EfpQueryAccountMetadatasConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "where", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EfpAccountMetadatasWhereInput" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "list", + "type": { + "kind": "OBJECT", + "name": "EfpList" + }, + "args": [ + { + "name": "tokenId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + ], + "isDeprecated": false + }, + { + "name": "listRecords", + "type": { + "kind": "OBJECT", + "name": "EfpQueryListRecordsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "EfpListRecordsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "lists", + "type": { + "kind": "OBJECT", + "name": "EfpQueryListsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "EfpListsWhereInput" + } + } + ], + "isDeprecated": false + }, + { + "name": "primaryList", + "type": { + "kind": "OBJECT", + "name": "EfpList" + }, + "args": [ + { + "name": "address", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + } + } + ], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpQueryAccountMetadatasConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpQueryAccountMetadatasConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpQueryAccountMetadatasConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpAccountMetadata" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpQueryListRecordsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpQueryListRecordsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpQueryListRecordsConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpListRecord" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpQueryListsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpQueryListsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "EfpQueryListsConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpList" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "Event", + "fields": [ + { + "name": "address", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "blockHash", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "blockNumber", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "chainId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ChainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "data", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "from", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ID" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "logIndex", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "sender", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "timestamp", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "to", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "topics", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "transactionHash", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "transactionIndex", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "EventsFromFilter", + "inputFields": [ + { + "name": "eq", + "type": { + "kind": "SCALAR", + "name": "Address" + } + }, + { + "name": "in", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + } + } } ], "isOneOf": true @@ -5187,6 +6338,18 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "efp", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpQuery" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "permissions", "type": { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index e5ea4f9174..6d945a33ac 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -15,6 +15,11 @@ type Account { """The Domains that are owned by the Account.""" domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection + """ + This Account's Ethereum Follow Protocol (EFP) presence: its lists and validated primary list. + """ + efp: AccountEfp! + """ All Events for which this Account is the HCA-aware `sender` (i.e. `Event.sender`). """ @@ -77,6 +82,30 @@ input AccountDomainsWhereInput { version: ENSProtocolVersion } +"""An account's Ethereum Follow Protocol (EFP) presence.""" +type AccountEfp { + """ + The EFP lists this account is the `user` of (the lists representing it). + """ + lists(after: String, before: String, first: Int, last: Int): AccountEfpListsConnection + + """ + 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. + """ + primaryList: EfpList +} + +type AccountEfpListsConnection { + edges: [AccountEfpListsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type AccountEfpListsConnectionEdge { + cursor: String! + node: EfpList! +} + type AccountEventsConnection { edges: [AccountEventsConnectionEdge!]! pageInfo: PageInfo! @@ -799,6 +828,203 @@ type ENSv2RegistryReservation implements Registration { unregistrant: Account } +""" +An EFP `(address, key) -> value` account-metadata entry (e.g. "primary-list"). +""" +type EfpAccountMetadata { + """The account this metadata belongs to.""" + address: Address! + + """Chain id of the AccountMetadata contract.""" + chainId: ChainId! + + """Address of the AccountMetadata contract.""" + contractAddress: Address! + createdAt: BigInt! + id: String! + + """The metadata key (UTF-8 string).""" + key: String! + updatedAt: BigInt! + + """The metadata value (raw bytes).""" + value: Hex! +} + +"""Filter EFP account metadata.""" +input EfpAccountMetadatasWhereInput { + """The account address.""" + address: Address! +} + +"""An EFP list NFT (a ListRegistry token) and the records it holds.""" +type EfpList { + createdAt: BigInt! + + """Decoded list storage location: target chain id.""" + listStorageLocationChainId: ChainId + + """Decoded list storage location: target contract address.""" + listStorageLocationContractAddress: Address + + """Decoded list storage location: target slot (bytes32).""" + listStorageLocationSlot: Hex + + """The address allowed to administer this list.""" + manager: Address + + """ + Chain id of the ListRegistry NFT (Base / 8453 on the mainnet namespace; otherwise the active namespace's EFP deployment chain, e.g. 31337 on the ens-test-env devnet). + """ + nftChainId: ChainId! + + """The ListRegistry contract address.""" + nftContractAddress: Address! + + """The current ERC-721 owner of the list NFT.""" + owner: Address! + + """The records currently in this list (the addresses it follows).""" + records(after: String, before: String, first: Int, last: Int): EfpListRecordsConnection + + """The ERC-721 token id of the list NFT (decimal string).""" + tokenId: String! + updatedAt: BigInt! + + """The address allowed to post records to this list.""" + user: Address +} + +""" +A single record within an EFP list (an address it follows), with its tags. +""" +type EfpListRecord { + """ + The account this record points to (its `recordData`). Null if that address is not an indexed account. + """ + account: Account + + """Chain id of the ListRecords contract holding this record.""" + chainId: ChainId! + + """Address of the ListRecords contract holding this record.""" + contractAddress: Address! + createdAt: BigInt! + id: String! + + """The EFP list this record belongs to.""" + list: EfpList + + """The full record payload (version | type | data).""" + record: Hex! + + """ + The followed/target address (the record's 20-byte payload). EFP indexes only address records (recordType 1). + """ + recordData: Address! + + """The EFP record type (1 = address).""" + recordType: Int! + + """The list's storage slot (bytes32) within the ListRecords contract.""" + slot: Hex! + + """UTF-8 tags attached to this record (e.g. "close-friend", "block").""" + tags: [String!]! +} + +type EfpListRecordsConnection { + edges: [EfpListRecordsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpListRecordsConnectionEdge { + cursor: String! + node: EfpListRecord! +} + +"""Filter EFP list records.""" +input EfpListRecordsWhereInput { + """ + The target address of an address record (recordType 1). Filtering by this answers 'which lists follow this address?'. + """ + recordData: Address + + """The EFP record type (1 = address).""" + recordType: Int +} + +"""Filter EFP lists by their owner, user, or manager address.""" +input EfpListsWhereInput { + """The address allowed to administer the list.""" + manager: Address + + """The ERC-721 owner of the list NFT.""" + owner: Address + + """The address allowed to post records.""" + user: Address +} + +"""Queries for Ethereum Follow Protocol (EFP) data.""" +type EfpQuery { + """Get an EFP account-metadata value by address and key.""" + accountMetadata(address: Address!, key: String!): EfpAccountMetadata + + """Find all EFP account-metadata entries for an address.""" + accountMetadatas(after: String, before: String, first: Int, last: Int, where: EfpAccountMetadatasWhereInput!): EfpQueryAccountMetadatasConnection + + """Get an EFP list by its NFT token id.""" + list(tokenId: String!): EfpList + + """ + Find EFP list records. Filter by `recordData` to answer 'which lists follow this address?'. + """ + listRecords(after: String, before: String, first: Int, last: Int, where: EfpListRecordsWhereInput): EfpQueryListRecordsConnection + + """Find EFP lists, optionally filtered by owner / user / manager.""" + lists(after: String, before: String, first: Int, last: Int, where: EfpListsWhereInput): EfpQueryListsConnection + + """ + 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. + """ + primaryList(address: Address!): EfpList +} + +type EfpQueryAccountMetadatasConnection { + edges: [EfpQueryAccountMetadatasConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryAccountMetadatasConnectionEdge { + cursor: String! + node: EfpAccountMetadata! +} + +type EfpQueryListRecordsConnection { + edges: [EfpQueryListRecordsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryListRecordsConnectionEdge { + cursor: String! + node: EfpListRecord! +} + +type EfpQueryListsConnection { + edges: [EfpQueryListsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryListsConnectionEdge { + cursor: String! + node: EfpList! +} + """ An Event represents a discrete Log Event that was emitted on an EVM chain, including associated metadata. """ @@ -1288,6 +1514,9 @@ type Query { """Find Canonical Domains by Name.""" domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection + """Ethereum Follow Protocol (EFP) queries.""" + efp: EfpQuery! + """Identify Permissions by ID or AccountId.""" permissions(by: PermissionsIdInput!): Permissions diff --git a/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts index b535636d8b..47d941b5b7 100644 --- a/packages/integration-test-env/src/lifecycle.ts +++ b/packages/integration-test-env/src/lifecycle.ts @@ -26,6 +26,7 @@ import { PluginName, } from "@ensnode/ensnode-sdk"; +import { seedEfpDevnet } from "./seed/efp"; import { seedDevnet } from "./seed/index"; const MONOREPO_ROOT = resolve(import.meta.dirname, "../../.."); @@ -291,9 +292,10 @@ export async function bringUp(options: { only?: Set } = {}): Promise } = {}): Promise } = {}): Promise numberToHex(value, { size: 1 }); + +/** version(1) | type(1) | chainId(32) | listRecords(20) | slot(32) = 86 bytes. */ +function encodeListStorageLocation(chainId: number, listRecords: Address, slot: bigint): Hex { + return concatHex([ + byte(LSL_VERSION), + byte(LSL_TYPE_L1), + numberToHex(BigInt(chainId), { size: 32 }), + pad(listRecords, { size: 20 }), + numberToHex(slot, { size: 32 }), + ]); +} + +/** version(1) | type(1) | address(20). */ +const encodeAddressRecord = (address: Address): Hex => + concatHex([byte(RECORD_VERSION), byte(RECORD_TYPE_ADDRESS), pad(address, { size: 20 })]); + +/** version(1) | opcode(1) | data. */ +const encodeListOp = (opcode: number, data: Hex): Hex => + concatHex([byte(LIST_OP_VERSION), byte(opcode), data]); + +const addRecordOp = (target: Address): Hex => + encodeListOp(Opcode.ADD_RECORD, encodeAddressRecord(target)); +const removeRecordOp = (data: Hex): Hex => encodeListOp(Opcode.REMOVE_RECORD, data); +const addTagOp = (target: Address, tag: string): Hex => + encodeListOp(Opcode.ADD_TAG, concatHex([encodeAddressRecord(target), stringToHex(tag)])); + +// --- minimal write ABIs (the datasources EFP ABIs declare only indexed events) --- + +const minterAbi = [ + { + type: "function", + name: "easyMintTo", + stateMutability: "payable", + inputs: [ + { name: "to", type: "address" }, + { name: "listStorageLocation", type: "bytes" }, + ], + outputs: [], + }, +] as const; + +const registryAbi = [ + { + type: "function", + name: "totalSupply", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "setListStorageLocation", + stateMutability: "nonpayable", + inputs: [ + { name: "tokenId", type: "uint256" }, + { name: "listStorageLocation", type: "bytes" }, + ], + outputs: [], + }, + // Plain mint (mints to msg.sender, sets the storage location) — unlike easyMintTo it does NOT set + // the minter's primary-list/user metadata, so it can add filler lists without perturbing fixtures. + { + type: "function", + name: "mint", + stateMutability: "payable", + inputs: [{ name: "listStorageLocation", type: "bytes" }], + outputs: [], + }, +] as const; + +const recordsAbi = [ + { + type: "function", + name: "applyListOps", + stateMutability: "nonpayable", + inputs: [ + { name: "slot", type: "uint256" }, + { name: "ops", type: "bytes[]" }, + ], + outputs: [], + }, + { + type: "function", + name: "setMetadataValue", + stateMutability: "nonpayable", + inputs: [ + { name: "slot", type: "uint256" }, + { name: "key", type: "string" }, + { name: "value", type: "bytes" }, + ], + outputs: [], + }, +] as const; + +/** + * Mints one list (owned + managed by {@link seedActor}) and applies the op sequences above, then + * clears the list's `user` role with a malformed metadata value. Must run after the EFP devnet + * image has deployed the contracts and opened public minting (the `demoGraph` scenario does both). + */ +export async function seedEfpDevnet(rpcUrl: string): Promise { + const client = createWalletClient({ + chain: ensTestEnvChain, + transport: http(rpcUrl), + account: seedActor, + }).extend(publicActions); + + const send = async (hash: Hex) => { + await client.waitForTransactionReceipt({ hash, ...seedReceiptWaitOptions }); + }; + + // The devnet's anvil funds only its named accounts (0-4); top up this dedicated actor so it can + // pay for gas. + const testClient = createTestClient({ + chain: ensTestEnvChain, + mode: "anvil", + transport: http(rpcUrl), + }); + await testClient.setBalance({ address: seedActor.address, value: parseEther("100") }); + + // A new list's slot is its token id; the next token id is the current total supply. + const slot = await client.readContract({ + address: efpContracts.EFPListRegistry as Address, + abi: registryAbi, + functionName: "totalSupply", + }); + + const listStorageLocation = encodeListStorageLocation( + ensTestEnvChain.id, + efpContracts.EFPListRecords as Address, + slot, + ); + + // Mint the list to the seed actor; easyMintTo also makes the actor the slot manager and `user`. + await send( + await client.writeContract({ + address: efpContracts.EFPListMinter as Address, + abi: minterAbi, + functionName: "easyMintTo", + args: [seedActor.address, listStorageLocation], + }), + ); + + const { dedup, cascade, junk } = efpSeedTargets; + const ops: Hex[] = [ + // dedup: a second identical ADD_TAG must not duplicate the tag. + addRecordOp(dedup), + addTagOp(dedup, "block"), + addTagOp(dedup, "block"), + // cascade: REMOVE drops the record and its tags; the re-ADD starts with no tags. + addRecordOp(cascade), + addTagOp(cascade, "vip"), + removeRecordOp(encodeAddressRecord(cascade)), + addRecordOp(cascade), + // junk: a REMOVE carrying trailing junk after the 20-byte address still deletes the record. + addRecordOp(junk), + removeRecordOp(concatHex([encodeAddressRecord(junk), "0xdeadbeef"])), + ]; + + await send( + await client.writeContract({ + address: efpContracts.EFPListRecords as Address, + abi: recordsAbi, + functionName: "applyListOps", + args: [slot, ops], + }), + ); + + // Clear the `user` role: a malformed (non-20-byte) value is not an address, so it clears to null. + await send( + await client.writeContract({ + address: efpContracts.EFPListRecords as Address, + abi: recordsAbi, + functionName: "setMetadataValue", + args: [slot, "user", "0xdead"], + }), + ); + + // Role durability across a storage-location re-point: roles live in durable per-slot metadata, so + // a list that moves away from its slot and back must recover the role (not stay cleared by the + // move). Mint a second list, set its `user`, anchor it with a record, then move it away and back. + const recordsAddress = efpContracts.EFPListRecords as Address; + const lsl = (s: bigint) => encodeListStorageLocation(ensTestEnvChain.id, recordsAddress, s); + + const durableTokenId = await client.readContract({ + address: efpContracts.EFPListRegistry as Address, + abi: registryAbi, + functionName: "totalSupply", + }); + + await send( + await client.writeContract({ + address: efpContracts.EFPListMinter as Address, + abi: minterAbi, + functionName: "easyMintTo", + args: [seedActor.address, lsl(durableTokenId)], + }), + ); + await send( + await client.writeContract({ + address: recordsAddress, + abi: recordsAbi, + functionName: "setMetadataValue", + args: [durableTokenId, "user", efpSeedRoleUser], + }), + ); + await send( + await client.writeContract({ + address: recordsAddress, + abi: recordsAbi, + functionName: "applyListOps", + args: [durableTokenId, [addRecordOp(efpSeedTargets.durable)]], + }), + ); + // Move the list to an empty slot, then back to its original one. + for (const targetSlot of [durableTokenId + 1000n, durableTokenId]) { + await send( + await client.writeContract({ + address: efpContracts.EFPListRegistry as Address, + abi: registryAbi, + functionName: "setListStorageLocation", + args: [durableTokenId, lsl(targetSlot)], + }), + ); + } + + // Mint extra (bare) lists so the devnet holds more than 9 (token ids reach double digits). This + // makes `efp.lists` / `Account.efp.lists` pagination exercise numeric-vs-lexicographic ordering: + // a text tokenId would otherwise sort "10" before "2" and break the cursor. Use a plain `mint` + // (not easyMintTo) so these fillers do not reset the actor's primary-list/user fixtures above. + let nextTokenId = await client.readContract({ + address: efpContracts.EFPListRegistry as Address, + abi: registryAbi, + functionName: "totalSupply", + }); + while (nextTokenId < 12n) { + await send( + await client.writeContract({ + address: efpContracts.EFPListRegistry as Address, + abi: registryAbi, + functionName: "mint", + args: [lsl(nextTokenId)], + }), + ); + nextTokenId += 1n; + } +}