From a6605c999b181ac046704f9bc9c642d314584423 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 22 May 2026 11:23:14 +0200 Subject: [PATCH 01/45] feat(datasources): add EFP (Ethereum Follow Protocol) datasources Add the EFP ListRegistry, AccountMetadata, and ListRecords contracts to the mainnet ENS namespace as three per-chain datasources (EFPBase, EFPOptimism, EFPEthereum), plus an address-less Resolver subscription on Ethereum mainnet used to index the eth.efp.list text record. ABIs are event-only subsets scoped to the events the EFP plugin indexes. --- .../src/abis/efp/AccountMetadata.ts | 20 +++++ .../datasources/src/abis/efp/ListRecords.ts | 29 ++++++++ .../datasources/src/abis/efp/ListRegistry.ts | 30 ++++++++ packages/datasources/src/lib/types.ts | 3 + packages/datasources/src/mainnet.ts | 73 +++++++++++++++++++ 5 files changed, 155 insertions(+) create mode 100644 packages/datasources/src/abis/efp/AccountMetadata.ts create mode 100644 packages/datasources/src/abis/efp/ListRecords.ts create mode 100644 packages/datasources/src/abis/efp/ListRegistry.ts 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/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..a24ed7112c 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,73 @@ 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`). + * + * Addresses and start blocks cross-checked against https://docs.efp.app and + * ethereumfollowprotocol/api-v2. + */ + [DatasourceNames.EFPBase]: { + chain: base, + contracts: { + ListRegistry: { + abi: efp_ListRegistry, + address: "0x0e688f5dca4a0a4729946acbc44c792341714e08", + startBlock: 20180000, + }, + AccountMetadata: { + abi: efp_AccountMetadata, + address: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + startBlock: 20180000, + }, + ListRecords: { + abi: efp_ListRecords, + address: "0x41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33", + startBlock: 20180000, + }, + }, + }, + + /** + * EFP `ListRecords` Datasource on Optimism. + */ + [DatasourceNames.EFPOptimism]: { + chain: optimism, + contracts: { + ListRecords: { + abi: efp_ListRecords, + address: "0x4ca00413d850dcfa3516e14d21dae2772f2acb85", + startBlock: 125792000, + }, + }, + }, + + /** + * EFP `ListRecords` Datasource on Ethereum mainnet, plus the `Resolver` subscription used to + * index the `eth.efp.list` ENS text record into `efp_ens_list_pointers`. + * + * The `Resolver` has no pinned address: any contract that emits the standard `TextChanged` event + * is in scope (the EFP plugin narrows to the `eth.efp.list` key), mirroring how Protocol + * Acceleration indexes Resolvers. + */ + [DatasourceNames.EFPEthereum]: { + chain: mainnet, + contracts: { + ListRecords: { + abi: efp_ListRecords, + address: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + startBlock: 20820000, + }, + Resolver: { + abi: ResolverABI, + // EFP launch on mainnet; `eth.efp.list` text records are not expected before this. + startBlock: 20820000, + }, + }, + }, } satisfies ENSNamespace; From 1da8efe95a6e8a0be352ee1dad9386870d40da52 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 23 May 2026 16:48:02 +0200 Subject: [PATCH 02/45] feat(ensdb-sdk): add EFP abstract schema and register PluginName.EFP Add the efp_* tables to the abstract ENSDb schema: efp_lists, a efp_list_storage_locations reverse index (slot -> token id, so list-metadata events resolve their list NFT by primary key), efp_list_records, efp_list_record_tags, efp_account_metadata, efp_pending_list_metadata, and efp_ens_list_pointers. Register PluginName.EFP in the SDK enum. --- .../src/ensindexer-abstract/efp.schema.ts | 217 ++++++++++++++++++ .../src/ensindexer-abstract/index.ts | 1 + .../src/ensindexer/config/types.ts | 1 + 3 files changed, 219 insertions(+) create mode 100644 packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts 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..aa28792c7a --- /dev/null +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -0,0 +1,217 @@ +/** + * EFP (Ethereum Follow Protocol) abstract schema. + * + * Tables are prefixed `efp_` and indexed by the EFP plugin + * (`apps/ensindexer/src/plugins/efp`). The first five tables mirror the + * ethereumfollowprotocol/api-v2 reference indexer's data model; `efp_list_storage_locations` + * is added so list-metadata events can resolve the owning list NFT by primary key + * (rather than scanning `efp_lists` by storage location). + * + * Timestamps are Unix-seconds `bigint`s (the block timestamp), matching ENSNode convention. + */ + +import { index, onchainTable } 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 (always Base / 8453). */ + 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, + ), + }), +); + +/** + * 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 full record payload + * (`version | type | data`), so two records that decode to the same address but with different + * headers remain distinct. + */ +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(), + /** Full record payload (`version | type | data`). */ + 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(), + createdAt: t.bigint().notNull(), + }), + (t) => ({ + idx_slot: index().on(t.chainId, t.contractAddress, t.slot), + idx_recordData: index().on(t.recordData), + }), +); + +/** + * Many-to-many between records and UTF-8 tags. + */ +export const efpListRecordTags = onchainTable( + "efp_list_record_tags", + (t) => ({ + /** Composite key "chainId-contractAddress-slot-record-tag". */ + id: t.text().primaryKey(), + chainId: t.int8({ mode: "number" }).notNull(), + contractAddress: t.hex().notNull(), + slot: t.hex().notNull(), + /** Record prefix `version | type | address` (22 bytes). */ + record: t.hex().notNull(), + /** UTF-8 tag (NUL bytes stripped). */ + tag: t.text().notNull(), + createdAt: t.bigint().notNull(), + }), + (t) => ({ + idx_slot: index().on(t.chainId, t.contractAddress, t.slot), + idx_record: index().on(t.record), + idx_tag: index().on(t.tag), + }), +); + +/** + * 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), + }), +); + +/** + * Staging area for `UpdateListMetadata` (`user`/`manager`) events that arrive before the matching + * list row's storage location is known. `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. The + * storage-location handler drains matching rows when it runs. + */ +export const efpPendingListMetadata = onchainTable( + "efp_pending_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), + }), +); + +/** + * Cross-correlation between an ENS namehash and a specific EFP list NFT, populated from + * `Resolver.TextChanged` events whose key is `eth.efp.list`. The same node can have pointers via + * multiple resolvers, so they are not collapsed at write time. An empty / unparseable text record + * deletes the row, matching the ENS convention that an empty text record is unset. + */ +export const efpEnsListPointers = onchainTable( + "efp_ens_list_pointers", + (t) => ({ + /** Composite key "chainId-resolver-node-ensKey". */ + id: t.text().primaryKey(), + /** Chain id of the resolver contract that emitted the TextChanged event. */ + chainId: t.int8({ mode: "number" }).notNull(), + /** Resolver contract address. */ + resolver: t.hex().notNull(), + /** ENS namehash of the name whose text record this is. */ + node: t.hex().notNull(), + /** The ENS text-record key matched on (e.g. "eth.efp.list"). */ + ensKey: t.text().notNull(), + /** Raw text-record value, kept verbatim for re-parsing. */ + rawValue: t.text().notNull(), + /** Decoded list token id (decimal string). */ + listTokenId: t.text().notNull(), + /** Decoded list contract address (defaults to the EFP ListRegistry on Base). */ + listContract: t.hex().notNull(), + /** Decoded list chain id (defaults to 8453 / Base for plain decimal values). */ + listChainId: t.int8({ mode: "number" }).notNull(), + createdAt: t.bigint().notNull(), + updatedAt: t.bigint().notNull(), + }), + (t) => ({ + idx_node: index().on(t.node), + idx_listTokenId: index().on(t.listTokenId), + }), +); 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", } /** From 86bf4eddd1e17f64749ab7deff40a0384c98066a Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 25 May 2026 10:12:39 +0200 Subject: [PATCH 03/45] feat(ensindexer): add EFP byte decoders and id helpers Pure decoders for EFP ListOp payloads, the onchain ListStorageLocation (locationType 1 only, per the EFP spec), and the eth.efp.list text record, plus composite-id helpers, the list-metadata value decoder, and EFP constants. Colocated unit tests cover the decoders. --- apps/ensindexer/src/plugins/efp/constants.ts | 38 +++++ apps/ensindexer/src/plugins/efp/lib/ids.ts | 57 ++++++++ .../src/plugins/efp/lib/list-metadata.ts | 9 ++ .../lib/parse-efp-list-text-record.test.ts | 47 ++++++ .../efp/lib/parse-efp-list-text-record.ts | 56 ++++++++ .../src/plugins/efp/lib/parse-list-op.test.ts | 96 +++++++++++++ .../src/plugins/efp/lib/parse-list-op.ts | 134 ++++++++++++++++++ .../lib/parse-list-storage-location.test.ts | 47 ++++++ .../efp/lib/parse-list-storage-location.ts | 54 +++++++ 9 files changed, 538 insertions(+) create mode 100644 apps/ensindexer/src/plugins/efp/constants.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/ids.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/list-metadata.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts diff --git a/apps/ensindexer/src/plugins/efp/constants.ts b/apps/ensindexer/src/plugins/efp/constants.ts new file mode 100644 index 0000000000..a921c59ba4 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/constants.ts @@ -0,0 +1,38 @@ +import type { Hex } from "viem"; + +/** + * 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; + +/** The well-known ENS text-record key an ENS name sets to point at its EFP list. */ +export const DEFAULT_EFP_LIST_TEXT_RECORD_KEY = "eth.efp.list"; + +/** + * The canonical EFP `ListRegistry` (Base / 8453), used as the default target when an + * `eth.efp.list` text record is a bare decimal token id (rather than a CAIP-19 asset id). + * + * Mirrors the `EFPBase` > `ListRegistry` entry in `packages/datasources/src/mainnet.ts`. + */ +export const DEFAULT_EFP_LIST_REGISTRY: { chainId: number; address: Hex } = { + chainId: 8453, + address: "0x0e688f5dca4a0a4729946acbc44c792341714e08", +}; 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..986f9c7eda --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/ids.ts @@ -0,0 +1,57 @@ +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_list_record_tags` key: a `(record, tag)` pair within a list. */ +export function listRecordTagId( + chainId: number, + contractAddress: Hex, + slot: Hex, + record: Hex, + tag: string, +): string { + return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${record.toLowerCase()}-${tag}`; +} + +/** `efp_account_metadata` key: an `(address, key)` pair. */ +export function accountMetadataId(address: Hex, key: string): string { + return `${address.toLowerCase()}-${key}`; +} + +/** `efp_pending_list_metadata` key: a staged metadata `(storage location, key)`. */ +export function pendingListMetadataId( + chainId: number, + contractAddress: Hex, + slot: Hex, + key: string, +): string { + return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${key}`; +} + +/** `efp_ens_list_pointers` key: a `(resolver, node, ensKey)` on a chain. */ +export function ensListPointerId( + chainId: number, + resolver: Hex, + node: Hex, + ensKey: string, +): string { + return `${chainId}-${resolver.toLowerCase()}-${node.toLowerCase()}-${ensKey}`; +} 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..58cac13fc4 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/list-metadata.ts @@ -0,0 +1,9 @@ +import type { Hex } from "viem"; + +/** + * Interpret an EFP list-metadata `value` payload as an address: the well-known `user` and + * `manager` metadata keys carry a 20-byte address in the leading bytes of the value. + */ +export function metadataValueToAddress(value: Hex): Hex { + return `0x${value.slice(2, 42).toLowerCase()}` as Hex; +} diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts b/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts new file mode 100644 index 0000000000..5b1cf8aaee --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_EFP_LIST_REGISTRY, DEFAULT_EFP_LIST_TEXT_RECORD_KEY } from "../constants"; +import { parseEfpListTextRecord } from "./parse-efp-list-text-record"; + +describe("parseEfpListTextRecord", () => { + it("interprets a decimal value as a list on the default ListRegistry", () => { + expect(parseEfpListTextRecord("12345")).toEqual({ + listTokenId: "12345", + listChainId: DEFAULT_EFP_LIST_REGISTRY.chainId, + listContract: DEFAULT_EFP_LIST_REGISTRY.address.toLowerCase(), + }); + }); + + it("trims whitespace before validating", () => { + expect(parseEfpListTextRecord(" 42 ")?.listTokenId).toBe("42"); + }); + + it("decodes a CAIP-19 erc721 asset id", () => { + const value = "eip155:8453/erc721:0x0E688f5DCa4a0a4729946ACbC44C792341714e08/9001"; + expect(parseEfpListTextRecord(value)).toEqual({ + listTokenId: "9001", + listChainId: 8453, + listContract: "0x0e688f5dca4a0a4729946acbc44c792341714e08", + }); + }); + + it("returns null for empty / whitespace / null / undefined", () => { + expect(parseEfpListTextRecord("")).toBeNull(); + expect(parseEfpListTextRecord(" ")).toBeNull(); + // @ts-expect-error — explicitly test invalid runtime input + expect(parseEfpListTextRecord(null)).toBeNull(); + // @ts-expect-error — explicitly test invalid runtime input + expect(parseEfpListTextRecord(undefined)).toBeNull(); + }); + + it("returns null for invalid CAIP-19 shapes", () => { + expect(parseEfpListTextRecord("eip155:8453/erc721:notanaddress/1")).toBeNull(); + expect(parseEfpListTextRecord("eip155:abc/erc721:0x0E68...4e08/1")).toBeNull(); + expect(parseEfpListTextRecord("foo")).toBeNull(); + expect(parseEfpListTextRecord("0x1234")).toBeNull(); + }); + + it("exposes the well-known key constant", () => { + expect(DEFAULT_EFP_LIST_TEXT_RECORD_KEY).toBe("eth.efp.list"); + }); +}); diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts b/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts new file mode 100644 index 0000000000..a72734eb6f --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts @@ -0,0 +1,56 @@ +/** + * Parser for the well-known ENS text-record `eth.efp.list`, which an ENS name's owner sets on their + * resolver to declare which EFP list NFT belongs to that name. + * + * Two value formats are accepted: + * 1. Decimal token id — `"1234"` — interpreted as a list on the default EFP ListRegistry + * (Base / 8453). + * 2. CAIP-19 asset identifier — `"eip155:8453/erc721:0x0E68…4e08/1234"` — explicit chain id, + * contract address, and token id. + * + * Returns `null` on any shape mismatch — the caller then deletes the pointer (matching the ENS + * convention that "set to garbage" is equivalent to "unset"). + */ + +import { type Hex, isAddress } from "viem"; + +import { DEFAULT_EFP_LIST_REGISTRY } from "../constants"; + +export interface ParsedEfpListPointer { + listTokenId: string; + /** EFP `ListRegistry` chain id. Defaults to 8453 (Base) for plain decimal values. */ + listChainId: number; + /** EFP `ListRegistry` contract address, always lowercased. */ + listContract: Hex; +} + +const DECIMAL_RE = /^[0-9]+$/; +const CAIP19_RE = + /^eip155:(?[0-9]+)\/erc721:(?
0x[0-9a-fA-F]{40})\/(?[0-9]+)$/; + +export function parseEfpListTextRecord(value: string): ParsedEfpListPointer | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + if (DECIMAL_RE.test(trimmed)) { + return { + listTokenId: trimmed, + listChainId: DEFAULT_EFP_LIST_REGISTRY.chainId, + listContract: DEFAULT_EFP_LIST_REGISTRY.address.toLowerCase() as Hex, + }; + } + + const m = CAIP19_RE.exec(trimmed); + if (!m?.groups) return null; + const { chainId, address, tokenId } = m.groups; + if (!chainId || !address || !tokenId) return null; + // Tolerate mixed-case addresses (no EIP-55 checksum enforcement): an ENS text record is often + // hand-edited. The regex already restricts it to 40 hex chars. + if (!isAddress(address, { strict: false })) return null; + return { + listTokenId: tokenId, + listChainId: Number(chainId), + listContract: address.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..ba503a4b0b --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts @@ -0,0 +1,96 @@ +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(); + }); +}); + +describe("parseRecord", () => { + it("decodes an address record (recordType=1) and truncates to 20 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, + recordData: `0x${"aa".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("keeps the full body for non-address record types", () => { + const data = ("0x0102" + "01020304") as `0x${string}`; + expect(parseRecord(data)).toEqual({ + version: 1, + recordType: 2, + recordData: "0x01020304", + }); + }); + + 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("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..44cfbb762e --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -0,0 +1,134 @@ +/** + * 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) + * + * `recordData` is the address-only payload for the only record type EFP uses in production + * (`recordType === 1`). The api-v2 reference indexer truncates that payload to exactly 20 bytes + * because users sometimes append junk after the address; we preserve that behaviour. + * + * Tag operations use `record (22 bytes) | tag (UTF-8 bytes)` inside `data`. The 22-byte record + * prefix is the `recordVersion (1) | recordType (1) | address (20)` triple. + * + * @see https://docs.efp.app/design/list-ops/ + */ + +import { type Hex, isHex } from "viem"; + +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; + /** 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); + return { + version: parseInt(bytes.slice(0, 2), 16), + 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 list op. For `recordType === 1` the + * returned `recordData` is exactly 20 bytes, matching api-v2's truncation rule. + */ +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); + + let body: string; + if (recordType === 1) { + // Truncate to 20 bytes (40 hex chars) and reject short inputs. + 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; + } else { + body = bytes.slice(RECORD_HEADER_HEX_LENGTH); + } + + return { + version, + recordType, + recordData: `0x${body}` 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. + */ +export function parseTagOp(data: Hex | string | null | undefined): ParsedTagOp | null { + if (!data || typeof data !== "string" || !isHex(data)) return null; + if (data.length < RECORD_PREFIX_WITH_0X_LENGTH) return null; + + const record = data.slice(0, RECORD_PREFIX_WITH_0X_LENGTH) as Hex; + const tagHex = data.slice(RECORD_PREFIX_WITH_0X_LENGTH); + + // hex must contain whole bytes + if (tagHex.length % 2 !== 0) return null; + + // Match api-v2: decode as UTF-8 and strip embedded NULs. + const tag = hexToUtf8(tagHex).replace(/\0/g, ""); + + return { record, tag }; +} + +/** + * 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..b9d24f9eeb --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.test.ts @@ -0,0 +1,47 @@ +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(); + }); +}); 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..d42c23043e --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts @@ -0,0 +1,54 @@ +/** + * 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. Any other `locationType` is reserved/unknown and decodes to `null`. + * + * @see https://docs.efp.app/design/list-storage-location/ + */ + +import { type Hex, isHex } from "viem"; + +export interface ParsedListStorageLocation { + version: number; + chainId: bigint; + contractAddress: Hex; + slot: Hex; +} + +const HEX_BYTES = 2; +const HEADER_END = 2 * HEX_BYTES; // version + locationType +const CHAIN_END = HEADER_END + 32 * HEX_BYTES; +const ADDRESS_END = CHAIN_END + 20 * HEX_BYTES; +const SLOT_END = ADDRESS_END + 32 * HEX_BYTES; + +/** The only `locationType` EFP defines: an onchain EVM contract location. */ +const LOCATION_TYPE_ONCHAIN = 1; + +export function parseListStorageLocation( + lsl: Hex | string | null | undefined, +): ParsedListStorageLocation | null { + if (!lsl || typeof lsl !== "string" || !isHex(lsl)) return null; + if (lsl.length < HEADER_END + 2) return null; // need at least version + locationType + + const bytes = lsl.slice(2); + const version = parseInt(bytes.slice(0, HEADER_END / 2), 16); + const locationType = parseInt(bytes.slice(HEADER_END / 2, HEADER_END), 16); + + if (locationType !== LOCATION_TYPE_ONCHAIN) return null; + if (bytes.length < SLOT_END) return null; + + return { + version, + chainId: BigInt(`0x${bytes.slice(HEADER_END, CHAIN_END)}`), + contractAddress: `0x${bytes.slice(CHAIN_END, ADDRESS_END).toLowerCase()}` as Hex, + slot: `0x${bytes.slice(ADDRESS_END, SLOT_END).toLowerCase()}` as Hex, + }; +} From 6450193a89080d71d5f4cc4403d6d86443057247 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 27 May 2026 19:31:47 +0200 Subject: [PATCH 04/45] feat(ensindexer): add EFP plugin handlers and config Event handlers for ListRegistry (Transfer, UpdateListStorageLocation), ListRecords (ListOp, UpdateListMetadata), AccountMetadata, and the eth.efp.list Resolver TextChanged, writing directly to context.ensDb by primary key. The slot->tokenId mapping keeps the user/manager path PK-only; the lone non-PK op (cascading tag deletes on record removal) uses the drizzle escape hatch. Adds the Ponder plugin config and registers it in ALL_PLUGINS. --- apps/ensindexer/src/plugins/efp/README.md | 37 +++++ .../src/plugins/efp/event-handlers.ts | 11 ++ .../plugins/efp/handlers/AccountMetadata.ts | 35 +++++ .../src/plugins/efp/handlers/ListRecords.ts | 147 ++++++++++++++++++ .../src/plugins/efp/handlers/ListRegistry.ts | 103 ++++++++++++ .../src/plugins/efp/handlers/Resolver.ts | 70 +++++++++ apps/ensindexer/src/plugins/efp/plugin.ts | 108 +++++++++++++ apps/ensindexer/src/plugins/index.ts | 2 + 8 files changed, 513 insertions(+) create mode 100644 apps/ensindexer/src/plugins/efp/README.md create mode 100644 apps/ensindexer/src/plugins/efp/event-handlers.ts create mode 100644 apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts create mode 100644 apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts create mode 100644 apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts create mode 100644 apps/ensindexer/src/plugins/efp/handlers/Resolver.ts create mode 100644 apps/ensindexer/src/plugins/efp/plugin.ts diff --git a/apps/ensindexer/src/plugins/efp/README.md b/apps/ensindexer/src/plugins/efp/README.md new file mode 100644 index 0000000000..5304017c6d --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/README.md @@ -0,0 +1,37 @@ +# 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` | +| `Resolver` | Ethereum mainnet (address-less) | `TextChanged` (filtered to `eth.efp.list`) | + +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` / `efp_list_record_tags` — the records in each list and their tags. +- `efp_account_metadata` — `(address, key) → value` (today only `primary-list`). +- `efp_pending_list_metadata` — staging for `user`/`manager` updates that arrive before the list's + storage location is known (the `ListRecords` and `ListRegistry` contracts emit independently). +- `efp_ens_list_pointers` — `eth.efp.list` text record → EFP list NFT, joinable to ENS names by `node`. + +## 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 `eth.efp.list` Resolver subscription indexes `TextChanged` across every mainnet resolver + (address-less, like Protocol Acceleration) and filters to the well-known key in the handler. +- Byte decoders for list ops, storage locations, and the text record live in `lib/` with unit tests. 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..6feb506011 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/event-handlers.ts @@ -0,0 +1,11 @@ +import attach_AccountMetadata from "./handlers/AccountMetadata"; +import attach_ListRecords from "./handlers/ListRecords"; +import attach_ListRegistry from "./handlers/ListRegistry"; +import attach_Resolver from "./handlers/Resolver"; + +export default function () { + attach_ListRegistry(); + attach_ListRecords(); + attach_AccountMetadata(); + attach_Resolver(); +} 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..473eceb66b --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts @@ -0,0 +1,35 @@ +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; + +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; + + await context.ensDb + .insert(ensIndexerSchema.efpAccountMetadata) + .values({ + id: accountMetadataId(address, event.args.key), + chainId: context.chain.id, + contractAddress: event.log.address.toLowerCase() as Hex, + address, + key: event.args.key, + value: event.args.value, + createdAt: ts, + updatedAt: ts, + }) + .onConflictDoUpdate({ value: event.args.value, updatedAt: ts }); + }, + ); +} 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..bae8e7a1f7 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -0,0 +1,147 @@ +import { and, eq } from "drizzle-orm"; +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 { EFP_LIST_METADATA_KEYS, EFP_OPCODE } from "../constants"; +import { + listRecordId, + listRecordTagId, + pendingListMetadataId, + storageLocationId, +} from "../lib/ids"; +import { metadataValueToAddress } from "../lib/list-metadata"; +import { parseListOp, parseRecord, parseTagOp, slotToBytes32 } from "../lib/parse-list-op"; + +const pluginName = PluginName.EFP; + +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, parsed.data), + chainId, + contractAddress, + slot, + record: parsed.data, + recordVersion: record.version, + recordType: record.recordType, + recordData: record.recordData, + createdAt: ts, + }) + .onConflictDoNothing(); + return; + } + + case EFP_OPCODE.REMOVE_RECORD: { + await context.ensDb.delete(ensIndexerSchema.efpListRecords, { + id: listRecordId(chainId, contractAddress, slot, parsed.data), + }); + // Cascade-delete this record's tags. A record has many (record, tag) rows, so this is not + // expressible via the PK-only Store API — use the raw drizzle escape hatch. This flushes + // ponder's cache to Postgres, accepted because record removals are infrequent relative to + // additions (cf. protocol-acceleration Resolver VersionChanged). + await context.ensDb.sql + .delete(ensIndexerSchema.efpListRecordTags) + .where( + and( + eq(ensIndexerSchema.efpListRecordTags.chainId, chainId), + eq(ensIndexerSchema.efpListRecordTags.contractAddress, contractAddress), + eq(ensIndexerSchema.efpListRecordTags.slot, slot), + eq(ensIndexerSchema.efpListRecordTags.record, parsed.data.toLowerCase() as Hex), + ), + ); + return; + } + + case EFP_OPCODE.ADD_TAG: { + const tagOp = parseTagOp(parsed.data); + if (!tagOp) return; + await context.ensDb + .insert(ensIndexerSchema.efpListRecordTags) + .values({ + id: listRecordTagId(chainId, contractAddress, slot, tagOp.record, tagOp.tag), + chainId, + contractAddress, + slot, + record: tagOp.record, + tag: tagOp.tag, + createdAt: ts, + }) + .onConflictDoNothing(); + return; + } + + case EFP_OPCODE.REMOVE_TAG: { + const tagOp = parseTagOp(parsed.data); + if (!tagOp) return; + await context.ensDb.delete(ensIndexerSchema.efpListRecordTags, { + id: listRecordTagId(chainId, contractAddress, slot, tagOp.record, 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); + const address = metadataValueToAddress(event.args.value); + + const mapping = await context.ensDb.find(ensIndexerSchema.efpListStorageLocations, { + id: storageLocationId(chainId, contractAddress, slot), + }); + + if (mapping) { + await context.ensDb + .update(ensIndexerSchema.efpLists, { tokenId: mapping.tokenId }) + .set( + key === EFP_LIST_METADATA_KEYS.USER + ? { user: address, updatedAt: ts } + : { manager: address, updatedAt: ts }, + ); + return; + } + + // No list points at this storage location yet — stage it for the storage-location handler. + const id = pendingListMetadataId(chainId, contractAddress, slot, key); + await context.ensDb + .insert(ensIndexerSchema.efpPendingListMetadata) + .values({ id, chainId, contractAddress, slot, key, value: event.args.value, createdAt: ts }) + .onConflictDoUpdate({ value: event.args.value, createdAt: ts }); + }, + ); +} 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..df5702ded3 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -0,0 +1,103 @@ +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 { EFP_LIST_METADATA_KEYS } from "../constants"; +import { pendingListMetadataId, storageLocationId } from "../lib/ids"; +import { metadataValueToAddress } from "../lib/list-metadata"; +import { parseListStorageLocation } from "../lib/parse-list-storage-location"; + +const pluginName = PluginName.EFP; + +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(); + const owner = event.args.to.toLowerCase() as Hex; + + await context.ensDb + .insert(ensIndexerSchema.efpLists) + .values({ + tokenId, + owner, + nftChainId: context.chain.id, + nftContractAddress: event.log.address.toLowerCase() as Hex, + createdAt: ts, + updatedAt: ts, + }) + .onConflictDoUpdate({ owner, updatedAt: ts }); + }, + ); + + // UpdateListStorageLocation — (re-)points a list at its record store. + addOnchainEventListener( + namespaceContract(pluginName, "ListRegistry:UpdateListStorageLocation"), + async ({ context, event }) => { + const parsed = parseListStorageLocation(event.args.listStorageLocation); + if (!parsed) return; + + const ts = event.block.timestamp; + const tokenId = event.args.tokenId.toString(); + 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. (Relies on the mint Transfer preceding this event — both fire on the ListRegistry + // on Base, so the list row already exists.) + const existing = await context.ensDb.find(ensIndexerSchema.efpLists, { tokenId }); + if ( + existing?.listStorageLocationChainId != null && + existing.listStorageLocationContractAddress != null && + existing.listStorageLocationSlot != null + ) { + const oldLocationId = storageLocationId( + existing.listStorageLocationChainId, + existing.listStorageLocationContractAddress, + existing.listStorageLocationSlot, + ); + if (oldLocationId !== newLocationId) { + 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, + updatedAt: ts, + }); + + await context.ensDb + .insert(ensIndexerSchema.efpListStorageLocations) + .values({ id: newLocationId, chainId, contractAddress, slot, tokenId, updatedAt: ts }) + .onConflictDoUpdate({ tokenId, updatedAt: ts }); + + // Drain any user/manager metadata staged before this storage location was known. + for (const key of [EFP_LIST_METADATA_KEYS.USER, EFP_LIST_METADATA_KEYS.MANAGER] as const) { + const id = pendingListMetadataId(chainId, contractAddress, slot, key); + const pending = await context.ensDb.find(ensIndexerSchema.efpPendingListMetadata, { id }); + if (!pending) continue; + + const address = metadataValueToAddress(pending.value); + await context.ensDb + .update(ensIndexerSchema.efpLists, { tokenId }) + .set( + key === EFP_LIST_METADATA_KEYS.USER + ? { user: address, updatedAt: ts } + : { manager: address, updatedAt: ts }, + ); + await context.ensDb.delete(ensIndexerSchema.efpPendingListMetadata, { id }); + } + }, + ); +} diff --git a/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts b/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts new file mode 100644 index 0000000000..7ad7c2d3d9 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts @@ -0,0 +1,70 @@ +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 { DEFAULT_EFP_LIST_TEXT_RECORD_KEY } from "../constants"; +import { ensListPointerId } from "../lib/ids"; +import { parseEfpListTextRecord } from "../lib/parse-efp-list-text-record"; + +const pluginName = PluginName.EFP; + +export default function () { + // TextChanged — index the `eth.efp.list` text record into efp_ens_list_pointers. + // + // We subscribe to the modern (with-value) TextChanged overload across every mainnet Resolver + // (the contract is address-less), mirroring how Protocol Acceleration indexes Resolvers, and + // filter to the well-known key here. The merged Resolver ABI's overloaded TextChanged precludes + // a contract-level `indexedKey` topic filter, so the key check is the filter. + addOnchainEventListener( + namespaceContract( + pluginName, + "Resolver:TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value)", + ), + async ({ context, event }) => { + if (event.args.key !== DEFAULT_EFP_LIST_TEXT_RECORD_KEY) return; + + const ts = event.block.timestamp; + const chainId = context.chain.id; + const resolver = event.log.address.toLowerCase() as Hex; + const node = event.args.node.toLowerCase() as Hex; + const id = ensListPointerId(chainId, resolver, node, DEFAULT_EFP_LIST_TEXT_RECORD_KEY); + + // An empty or unparseable value clears the pointer (ENS convention: empty == unset). + if (!event.args.value) { + await context.ensDb.delete(ensIndexerSchema.efpEnsListPointers, { id }); + return; + } + const parsed = parseEfpListTextRecord(event.args.value); + if (!parsed) { + await context.ensDb.delete(ensIndexerSchema.efpEnsListPointers, { id }); + return; + } + + await context.ensDb + .insert(ensIndexerSchema.efpEnsListPointers) + .values({ + id, + chainId, + resolver, + node, + ensKey: DEFAULT_EFP_LIST_TEXT_RECORD_KEY, + rawValue: event.args.value, + listTokenId: parsed.listTokenId, + listContract: parsed.listContract, + listChainId: parsed.listChainId, + createdAt: ts, + updatedAt: ts, + }) + .onConflictDoUpdate({ + rawValue: event.args.value, + listTokenId: parsed.listTokenId, + listContract: parsed.listContract, + listChainId: parsed.listChainId, + updatedAt: ts, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/efp/plugin.ts b/apps/ensindexer/src/plugins/efp/plugin.ts new file mode 100644 index 0000000000..878aed3ab1 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/plugin.ts @@ -0,0 +1,108 @@ +/** + * The EFP plugin indexes the Ethereum Follow Protocol: + * - list NFTs (`ListRegistry` on Base), + * - list records & tags (`ListRecords` on Base, Optimism, and Ethereum mainnet), + * - account metadata (`AccountMetadata` on Base), and + * - the `eth.efp.list` ENS text record (any Resolver on Ethereum mainnet). + * + * 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, + }, + // Address-less Resolver subscription on Ethereum mainnet for the `eth.efp.list` text record. + // Matches any contract emitting the standard TextChanged event (Ponder factory-mode); the + // handler filters to the well-known key. + [namespaceContract(pluginName, "Resolver")]: { + chain: { + ...chainConfigForContract( + config.globalBlockrange, + efpEthereum.chain.id, + efpEthereum.contracts.Resolver, + ), + }, + abi: efpEthereum.contracts.Resolver.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; /** From b6020ec1d769888a8a58b67461dda642cc624e7b Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 08:25:00 +0200 Subject: [PATCH 05/45] feat(ensindexer): activate EFP plugin handlers Conditionally attach the EFP event handlers when `efp` is in PLUGINS, and add a changeset for the new plugin. --- .changeset/efp-plugin.md | 8 ++++++++ apps/ensindexer/ponder/src/register-handlers.ts | 6 ++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/efp-plugin.md diff --git a/.changeset/efp-plugin.md b/.changeset/efp-plugin.md new file mode 100644 index 0000000000..999a2e8033 --- /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 (mainnet ENS namespace) to index EFP list NFTs, records, tags, and account metadata — plus the `eth.efp.list` ENS text record — into ENSDb's `efp_*` tables. 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 From 1e2f2356ae32cc54b31d798e405e9f2e662da0de Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 13:53:54 +0200 Subject: [PATCH 06/45] feat(ensapi): expose EFP via the Omnigraph API Add a single `efp` root field (an EfpQuery namespace) to the Omnigraph GraphQL API, grouping EFP queries so they do not clutter the Query root. Exposes EfpList (with a nested records connection), EfpListRecord (with tags), EfpAccountMetadata, and EfpListPointer (the eth.efp.list correlation), with cursor-paginated connections and where-filters (owner/user/manager, recordData, address, node/listTokenId). Resolvers read ENSDb directly via di.context.ensDb. --- apps/ensapi/src/omnigraph-api/schema.ts | 6 + .../schema/efp-account-metadata.ts | 76 +++++++ .../src/omnigraph-api/schema/efp-inputs.ts | 55 +++++ .../omnigraph-api/schema/efp-list-pointer.ts | 91 ++++++++ .../omnigraph-api/schema/efp-list-record.ts | 96 ++++++++ .../src/omnigraph-api/schema/efp-list.ts | 164 ++++++++++++++ apps/ensapi/src/omnigraph-api/schema/efp.ts | 207 ++++++++++++++++++ 7 files changed, 695 insertions(+) create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-list.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp.ts diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index 824a680a18..e59ab2096c 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -5,6 +5,12 @@ 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-pointer"; +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/efp-account-metadata.ts b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts new file mode 100644 index 0000000000..463e0eb976 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts @@ -0,0 +1,76 @@ +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; + +/** + * The synthetic primary key used by `efp_account_metadata`. Mirrors the EFP plugin's + * `accountMetadataId` (`${address}-${key}`, with a lowercased address). + */ +export const efpAccountMetadataId = (address: NormalizedAddress, key: string): string => + `${address}-${key}`; + +////////////////////// +// EfpAccountMetadata +////////////////////// +EfpAccountMetadataRef.implement({ + description: 'An EFP `(address, key) -> value` account-metadata entry (e.g. "primary-list").', + fields: (t) => ({ + id: t.field({ type: "String", nullable: false, resolve: (metadata) => metadata.id }), + + chainId: t.field({ + description: "Chain id of the AccountMetadata contract.", + type: "ChainId", + nullable: false, + resolve: (metadata) => metadata.chainId as ChainId, + }), + + contractAddress: t.field({ + description: "Address of the AccountMetadata contract.", + type: "Address", + nullable: false, + resolve: (metadata) => metadata.contractAddress as NormalizedAddress, + }), + + address: t.field({ + description: "The account this metadata belongs to.", + type: "Address", + nullable: false, + resolve: (metadata) => metadata.address as NormalizedAddress, + }), + + key: t.field({ + description: "The metadata key (UTF-8 string).", + type: "String", + nullable: false, + resolve: (metadata) => metadata.key, + }), + + value: t.field({ + description: "The metadata value (raw bytes).", + type: "Hex", + nullable: false, + resolve: (metadata) => metadata.value, + }), + + createdAt: t.field({ type: "BigInt", nullable: false, resolve: (m) => m.createdAt }), + updatedAt: t.field({ type: "BigInt", nullable: false, resolve: (m) => m.updatedAt }), + }), +}); 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..7f980792e2 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts @@ -0,0 +1,55 @@ +import { builder } from "@/omnigraph-api/builder"; + +/** + * 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." }), + }), +}); + +/** + * Filters for the `efp.listPointers` connection. + */ +export const EfpListPointersWhereInput = builder.inputType("EfpListPointersWhereInput", { + description: "Filter EFP ENS list pointers (the `eth.efp.list` text-record correlation).", + fields: (t) => ({ + node: t.field({ type: "Node", description: "The ENS namehash that claims a list." }), + listTokenId: t.field({ + type: "String", + description: "Find the ENS names that point at this list token id.", + }), + }), +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts new file mode 100644 index 0000000000..b375153789 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts @@ -0,0 +1,91 @@ +import { inArray } from "drizzle-orm"; +import type { ChainId, Node, NormalizedAddress } from "enssdk"; + +import di from "@/di"; +import { builder } from "@/omnigraph-api/builder"; +import { getModelId } from "@/omnigraph-api/lib/get-model-id"; + +export const EfpListPointerRef = builder.loadableObjectRef("EfpListPointer", { + load: (ids: string[]) => { + const { ensDb, ensIndexerSchema } = di.context; + return ensDb + .select() + .from(ensIndexerSchema.efpEnsListPointers) + .where(inArray(ensIndexerSchema.efpEnsListPointers.id, ids)); + }, + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type EfpListPointer = Exclude; + +////////////////// +// EfpListPointer +////////////////// +EfpListPointerRef.implement({ + description: + "A correlation between an ENS name (via its `eth.efp.list` text record) and an EFP list.", + fields: (t) => ({ + id: t.field({ type: "String", nullable: false, resolve: (pointer) => pointer.id }), + + chainId: t.field({ + description: "Chain id of the resolver that emitted the text record.", + type: "ChainId", + nullable: false, + resolve: (pointer) => pointer.chainId as ChainId, + }), + + resolver: t.field({ + description: "The resolver contract address.", + type: "Address", + nullable: false, + resolve: (pointer) => pointer.resolver as NormalizedAddress, + }), + + node: t.field({ + description: "The ENS namehash whose `eth.efp.list` text record points at a list.", + type: "Node", + nullable: false, + resolve: (pointer) => pointer.node as Node, + }), + + ensKey: t.field({ + description: "The matched ENS text-record key (e.g. `eth.efp.list`).", + type: "String", + nullable: false, + resolve: (pointer) => pointer.ensKey, + }), + + rawValue: t.field({ + description: "The raw text-record value, kept verbatim.", + type: "String", + nullable: false, + resolve: (pointer) => pointer.rawValue, + }), + + listTokenId: t.field({ + description: "Decoded list token id (decimal string).", + type: "String", + nullable: false, + resolve: (pointer) => pointer.listTokenId, + }), + + listContract: t.field({ + description: "Decoded list contract address (defaults to the EFP ListRegistry on Base).", + type: "Address", + nullable: false, + resolve: (pointer) => pointer.listContract as NormalizedAddress, + }), + + listChainId: t.field({ + description: "Decoded list chain id (defaults to 8453 / Base).", + type: "ChainId", + nullable: false, + resolve: (pointer) => pointer.listChainId as ChainId, + }), + + createdAt: t.field({ type: "BigInt", nullable: false, resolve: (p) => p.createdAt }), + updatedAt: t.field({ type: "BigInt", nullable: false, resolve: (p) => p.updatedAt }), + }), +}); 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..a8d9b2549c --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -0,0 +1,96 @@ +import { and, eq, 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 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) => ({ + id: t.field({ type: "String", nullable: false, resolve: (record) => record.id }), + + chainId: t.field({ + description: "Chain id of the ListRecords contract holding this record.", + type: "ChainId", + nullable: false, + resolve: (record) => record.chainId as ChainId, + }), + + contractAddress: t.field({ + description: "Address of the ListRecords contract holding this record.", + type: "Address", + nullable: false, + resolve: (record) => record.contractAddress as NormalizedAddress, + }), + + slot: t.field({ + description: "The list's storage slot (bytes32) within the ListRecords contract.", + type: "Hex", + nullable: false, + resolve: (record) => record.slot, + }), + + record: t.field({ + description: "The full record payload (version | type | data).", + type: "Hex", + nullable: false, + resolve: (record) => record.record, + }), + + recordType: t.field({ + description: "The EFP record type (1 = address).", + type: "Int", + nullable: false, + resolve: (record) => record.recordType, + }), + + recordData: t.field({ + description: "The followed/target address. Valid for address records (recordType 1).", + type: "Address", + nullable: false, + resolve: (record) => record.recordData as NormalizedAddress, + }), + + tags: t.field({ + description: 'UTF-8 tags attached to this record (e.g. "close-friend", "block").', + type: ["String"], + nullable: false, + resolve: async (record) => { + const { ensDb, ensIndexerSchema } = di.context; + const rows = await ensDb + .select({ tag: ensIndexerSchema.efpListRecordTags.tag }) + .from(ensIndexerSchema.efpListRecordTags) + .where( + and( + eq(ensIndexerSchema.efpListRecordTags.chainId, record.chainId), + eq(ensIndexerSchema.efpListRecordTags.contractAddress, record.contractAddress), + eq(ensIndexerSchema.efpListRecordTags.slot, record.slot), + eq(ensIndexerSchema.efpListRecordTags.record, record.record), + ), + ); + return rows.map((row) => row.tag); + }, + }), + + createdAt: t.field({ type: "BigInt", nullable: false, resolve: (record) => record.createdAt }), + }), +}); 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..ff4a18e2ff --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts @@ -0,0 +1,164 @@ +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) => ({ + tokenId: t.field({ + description: "The ERC-721 token id of the list NFT (decimal string).", + type: "String", + nullable: false, + resolve: (list) => list.tokenId, + }), + + owner: t.field({ + description: "The current ERC-721 owner of the list NFT.", + type: "Address", + nullable: false, + resolve: (list) => list.owner as NormalizedAddress, + }), + + 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, + }), + + manager: t.field({ + description: "The address allowed to administer this list.", + type: "Address", + nullable: true, + resolve: (list) => (list.manager ?? null) as NormalizedAddress | null, + }), + + nftChainId: t.field({ + description: "Chain id of the ListRegistry NFT (always Base / 8453).", + type: "ChainId", + nullable: false, + resolve: (list) => list.nftChainId as ChainId, + }), + + nftContractAddress: t.field({ + description: "The ListRegistry contract address.", + type: "Address", + nullable: false, + resolve: (list) => list.nftContractAddress as NormalizedAddress, + }), + + listStorageLocationChainId: t.field({ + description: "Decoded list storage location: target chain id.", + type: "ChainId", + nullable: true, + resolve: (list) => (list.listStorageLocationChainId ?? null) as ChainId | null, + }), + + listStorageLocationContractAddress: t.field({ + description: "Decoded list storage location: target contract address.", + type: "Address", + nullable: true, + resolve: (list) => + (list.listStorageLocationContractAddress ?? null) as NormalizedAddress | null, + }), + + listStorageLocationSlot: t.field({ + description: "Decoded list storage location: target slot (bytes32).", + type: "Hex", + nullable: true, + resolve: (list) => list.listStorageLocationSlot, + }), + + createdAt: t.field({ type: "BigInt", nullable: false, resolve: (list) => list.createdAt }), + 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.ts b/apps/ensapi/src/omnigraph-api/schema/efp.ts new file mode 100644 index 0000000000..3ac4bd7a9b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp.ts @@ -0,0 +1,207 @@ +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 { orderPaginationBy, paginateBy } 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, + efpAccountMetadataId, +} from "@/omnigraph-api/schema/efp-account-metadata"; +import { + EfpAccountMetadatasWhereInput, + EfpListPointersWhereInput, + EfpListRecordsWhereInput, + EfpListsWhereInput, +} from "@/omnigraph-api/schema/efp-inputs"; +import { EfpListRef, TOKEN_ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/efp-list"; +import { EfpListPointerRef } from "@/omnigraph-api/schema/efp-list-pointer"; +import { EfpListRecordRef } from "@/omnigraph-api/schema/efp-list-record"; + +/** + * `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, paginateBy(ensIndexerSchema.efpLists.tokenId, before, after))) + .orderBy(orderPaginationBy(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.listPointers + /////////////////////// + listPointers: t.connection({ + description: + "Find ENS -> EFP list correlations (the `eth.efp.list` text record), by ENS node or list token id.", + type: EfpListPointerRef, + args: { where: t.arg({ type: EfpListPointersWhereInput }) }, + resolve: (_parent, args) => { + const { ensDb, ensIndexerSchema } = di.context; + const where = args.where; + const scope = and( + where?.node ? eq(ensIndexerSchema.efpEnsListPointers.node, where.node as Hex) : undefined, + where?.listTokenId + ? eq(ensIndexerSchema.efpEnsListPointers.listTokenId, where.listTokenId) + : undefined, + ); + + return lazyConnection({ + totalCount: () => ensDb.$count(ensIndexerSchema.efpEnsListPointers, scope), + connection: () => + resolveCursorConnection( + { ...ID_PAGINATED_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + ensDb + .select() + .from(ensIndexerSchema.efpEnsListPointers) + .where( + and(scope, paginateBy(ensIndexerSchema.efpEnsListPointers.id, before, after)), + ) + .orderBy(orderPaginationBy(ensIndexerSchema.efpEnsListPointers.id, inverted)) + .limit(limit), + ), + }); + }, + }), + }), +}); + +/////////////////////////////////////// +// Query.efp — the single EFP namespace +/////////////////////////////////////// +builder.queryField("efp", (t) => + t.field({ + description: "Ethereum Follow Protocol (EFP) queries.", + type: EfpQueryRef, + nullable: false, + resolve: () => ({}), + }), +); From f1bf80ed83764763354a839a958c2d8b5c5c1b6b Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 13:53:54 +0200 Subject: [PATCH 07/45] chore(enssdk): regenerate Omnigraph SDL and introspection for EFP Output of `pnpm generate` after adding the EFP Omnigraph types. --- .../src/omnigraph/generated/introspection.ts | 1264 +++++++++++++++++ .../src/omnigraph/generated/schema.graphql | 244 ++++ 2 files changed, 1508 insertions(+) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 5ff06f0a70..d372704473 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -3238,6 +3238,1258 @@ const introspection = { } ] }, + { + "kind": "OBJECT", + "name": "EfpAccountMetadata", + "fields": [ + { + "name": "address", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "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": "key", + "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": "value", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Hex" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "EfpAccountMetadatasWhereInput", + "inputFields": [ + { + "name": "address", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + } + } + ], + "isOneOf": false + }, + { + "kind": "OBJECT", + "name": "EfpList", + "fields": [ + { + "name": "createdAt", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "listStorageLocationChainId", + "type": { + "kind": "SCALAR", + "name": "ChainId" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "listStorageLocationContractAddress", + "type": { + "kind": "SCALAR", + "name": "Address" + }, + "args": [], + "isDeprecated": false + }, + { + "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": "SCALAR", + "name": "ChainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "nftContractAddress", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "owner", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "records", + "type": { + "kind": "OBJECT", + "name": "EfpListRecordsConnection" + }, + "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": "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": "EfpListPointer", + "fields": [ + { + "name": "chainId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ChainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "createdAt", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "ensKey", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "id", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "listChainId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ChainId" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "listContract", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "listTokenId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Node" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "rawValue", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "resolver", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "updatedAt", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "BigInt" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "EfpListPointersWhereInput", + "inputFields": [ + { + "name": "listTokenId", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "node", + "type": { + "kind": "SCALAR", + "name": "Node" + } + } + ], + "isOneOf": false + }, + { + "kind": "OBJECT", + "name": "EfpListRecord", + "fields": [ + { + "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": "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": "listPointers", + "type": { + "kind": "OBJECT", + "name": "EfpQueryListPointersConnection" + }, + "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": "EfpListPointersWhereInput" + } + } + ], + "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 + } + ], + "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": "EfpQueryListPointersConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "EfpQueryListPointersConnectionEdge" + } + } + } + }, + "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": "EfpQueryListPointersConnectionEdge", + "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": "EfpListPointer" + } + }, + "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", @@ -4671,6 +5923,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 c11ac881ed..18177838be 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -723,6 +723,247 @@ 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 (always Base / 8453).""" + 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 correlation between an ENS name (via its `eth.efp.list` text record) and an EFP list. +""" +type EfpListPointer { + """Chain id of the resolver that emitted the text record.""" + chainId: ChainId! + createdAt: BigInt! + + """The matched ENS text-record key (e.g. `eth.efp.list`).""" + ensKey: String! + id: String! + + """Decoded list chain id (defaults to 8453 / Base).""" + listChainId: ChainId! + + """ + Decoded list contract address (defaults to the EFP ListRegistry on Base). + """ + listContract: Address! + + """Decoded list token id (decimal string).""" + listTokenId: String! + + """The ENS namehash whose `eth.efp.list` text record points at a list.""" + node: Node! + + """The raw text-record value, kept verbatim.""" + rawValue: String! + + """The resolver contract address.""" + resolver: Address! + updatedAt: BigInt! +} + +""" +Filter EFP ENS list pointers (the `eth.efp.list` text-record correlation). +""" +input EfpListPointersWhereInput { + """Find the ENS names that point at this list token id.""" + listTokenId: String + + """The ENS namehash that claims a list.""" + node: Node +} + +""" +A single record within an EFP list (an address it follows), with its tags. +""" +type EfpListRecord { + """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 full record payload (version | type | data).""" + record: Hex! + + """The followed/target address. Valid for 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 ENS -> EFP list correlations (the `eth.efp.list` text record), by ENS node or list token id. + """ + listPointers(after: String, before: String, first: Int, last: Int, where: EfpListPointersWhereInput): EfpQueryListPointersConnection + + """ + 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 +} + +type EfpQueryAccountMetadatasConnection { + edges: [EfpQueryAccountMetadatasConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryAccountMetadatasConnectionEdge { + cursor: String! + node: EfpAccountMetadata! +} + +type EfpQueryListPointersConnection { + edges: [EfpQueryListPointersConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryListPointersConnectionEdge { + cursor: String! + node: EfpListPointer! +} + +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. """ @@ -1084,6 +1325,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 From e3d0df960fc0c3b9cbadad10d5e7e02bef5eeb33 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 13:57:33 +0200 Subject: [PATCH 08/45] chore: changeset for EFP Omnigraph API Communicates the new `efp` Omnigraph root field for release notes. --- .changeset/efp-omnigraph.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/efp-omnigraph.md diff --git a/.changeset/efp-omnigraph.md b/.changeset/efp-omnigraph.md new file mode 100644 index 0000000000..d4d7f66951 --- /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`, `accountMetadata` / `accountMetadatas`, and `listPointers` (the `eth.efp.list` ENS↔list correlation), with cursor-paginated connections and where-filters (owner/user/manager, recordData, address, node/listTokenId). Requires the `efp` plugin to be enabled on the indexer. From 7bea1349426f247d1a827f0e78fb2be7dcd8affd Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 14:21:45 +0200 Subject: [PATCH 09/45] style(efp): match ENSNode comment conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the per-field // Entity.field banner comments used across the Omnigraph entity files (account/renewal/domain), the // Inputs banner in the dedicated inputs file, and leading docstrings on the plugin handler files (matching tokenscope). Comments only — no SDL change. --- .../schema/efp-account-metadata.ts | 25 ++++++++++++ .../src/omnigraph-api/schema/efp-inputs.ts | 4 ++ .../omnigraph-api/schema/efp-list-pointer.ts | 34 +++++++++++++++++ .../omnigraph-api/schema/efp-list-record.ts | 31 ++++++++++++++- .../src/omnigraph-api/schema/efp-list.ts | 38 ++++++++++++++++++- .../plugins/efp/handlers/AccountMetadata.ts | 3 ++ .../src/plugins/efp/handlers/ListRecords.ts | 3 ++ .../src/plugins/efp/handlers/ListRegistry.ts | 3 ++ .../src/plugins/efp/handlers/Resolver.ts | 3 ++ 9 files changed, 140 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts index 463e0eb976..8b5922ebf4 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts @@ -33,8 +33,14 @@ export const efpAccountMetadataId = (address: NormalizedAddress, key: string): s 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", @@ -42,6 +48,9 @@ EfpAccountMetadataRef.implement({ resolve: (metadata) => metadata.chainId as ChainId, }), + /////////////////////////////////////// + // EfpAccountMetadata.contractAddress + /////////////////////////////////////// contractAddress: t.field({ description: "Address of the AccountMetadata contract.", type: "Address", @@ -49,6 +58,9 @@ EfpAccountMetadataRef.implement({ resolve: (metadata) => metadata.contractAddress as NormalizedAddress, }), + /////////////////////////////// + // EfpAccountMetadata.address + /////////////////////////////// address: t.field({ description: "The account this metadata belongs to.", type: "Address", @@ -56,6 +68,9 @@ EfpAccountMetadataRef.implement({ resolve: (metadata) => metadata.address as NormalizedAddress, }), + /////////////////////////// + // EfpAccountMetadata.key + /////////////////////////// key: t.field({ description: "The metadata key (UTF-8 string).", type: "String", @@ -63,6 +78,9 @@ EfpAccountMetadataRef.implement({ resolve: (metadata) => metadata.key, }), + ///////////////////////////// + // EfpAccountMetadata.value + ///////////////////////////// value: t.field({ description: "The metadata value (raw bytes).", type: "Hex", @@ -70,7 +88,14 @@ EfpAccountMetadataRef.implement({ 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-inputs.ts b/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts index 7f980792e2..f26b79d6ef 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts @@ -1,5 +1,9 @@ import { builder } from "@/omnigraph-api/builder"; +////////////////////// +// Inputs +////////////////////// + /** * Filters for the `efp.lists` connection. */ diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts index b375153789..6c63ad6d16 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts @@ -27,8 +27,14 @@ EfpListPointerRef.implement({ description: "A correlation between an ENS name (via its `eth.efp.list` text record) and an EFP list.", fields: (t) => ({ + //////////////////////// + // EfpListPointer.id + //////////////////////// id: t.field({ type: "String", nullable: false, resolve: (pointer) => pointer.id }), + ///////////////////////////// + // EfpListPointer.chainId + ///////////////////////////// chainId: t.field({ description: "Chain id of the resolver that emitted the text record.", type: "ChainId", @@ -36,6 +42,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.chainId as ChainId, }), + ////////////////////////////// + // EfpListPointer.resolver + ////////////////////////////// resolver: t.field({ description: "The resolver contract address.", type: "Address", @@ -43,6 +52,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.resolver as NormalizedAddress, }), + ////////////////////////// + // EfpListPointer.node + ////////////////////////// node: t.field({ description: "The ENS namehash whose `eth.efp.list` text record points at a list.", type: "Node", @@ -50,6 +62,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.node as Node, }), + //////////////////////////// + // EfpListPointer.ensKey + //////////////////////////// ensKey: t.field({ description: "The matched ENS text-record key (e.g. `eth.efp.list`).", type: "String", @@ -57,6 +72,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.ensKey, }), + ////////////////////////////// + // EfpListPointer.rawValue + ////////////////////////////// rawValue: t.field({ description: "The raw text-record value, kept verbatim.", type: "String", @@ -64,6 +82,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.rawValue, }), + ///////////////////////////////// + // EfpListPointer.listTokenId + ///////////////////////////////// listTokenId: t.field({ description: "Decoded list token id (decimal string).", type: "String", @@ -71,6 +92,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.listTokenId, }), + ///////////////////////////////// + // EfpListPointer.listContract + ///////////////////////////////// listContract: t.field({ description: "Decoded list contract address (defaults to the EFP ListRegistry on Base).", type: "Address", @@ -78,6 +102,9 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.listContract as NormalizedAddress, }), + //////////////////////////////// + // EfpListPointer.listChainId + //////////////////////////////// listChainId: t.field({ description: "Decoded list chain id (defaults to 8453 / Base).", type: "ChainId", @@ -85,7 +112,14 @@ EfpListPointerRef.implement({ resolve: (pointer) => pointer.listChainId as ChainId, }), + //////////////////////////////// + // EfpListPointer.createdAt + //////////////////////////////// createdAt: t.field({ type: "BigInt", nullable: false, resolve: (p) => p.createdAt }), + + //////////////////////////////// + // EfpListPointer.updatedAt + //////////////////////////////// updatedAt: t.field({ type: "BigInt", nullable: false, resolve: (p) => p.updatedAt }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts index a8d9b2549c..29787ce932 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -20,14 +20,20 @@ export const EfpListRecordRef = builder.loadableObjectRef("EfpListRecord", { 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", @@ -35,6 +41,9 @@ EfpListRecordRef.implement({ resolve: (record) => record.chainId as ChainId, }), + ////////////////////////////////// + // EfpListRecord.contractAddress + ////////////////////////////////// contractAddress: t.field({ description: "Address of the ListRecords contract holding this record.", type: "Address", @@ -42,6 +51,9 @@ EfpListRecordRef.implement({ resolve: (record) => record.contractAddress as NormalizedAddress, }), + //////////////////////// + // EfpListRecord.slot + //////////////////////// slot: t.field({ description: "The list's storage slot (bytes32) within the ListRecords contract.", type: "Hex", @@ -49,6 +61,9 @@ EfpListRecordRef.implement({ resolve: (record) => record.slot, }), + ////////////////////////// + // EfpListRecord.record + ////////////////////////// record: t.field({ description: "The full record payload (version | type | data).", type: "Hex", @@ -56,6 +71,9 @@ EfpListRecordRef.implement({ resolve: (record) => record.record, }), + ////////////////////////////// + // EfpListRecord.recordType + ////////////////////////////// recordType: t.field({ description: "The EFP record type (1 = address).", type: "Int", @@ -63,6 +81,9 @@ EfpListRecordRef.implement({ resolve: (record) => record.recordType, }), + ////////////////////////////// + // EfpListRecord.recordData + ////////////////////////////// recordData: t.field({ description: "The followed/target address. Valid for address records (recordType 1).", type: "Address", @@ -70,6 +91,9 @@ EfpListRecordRef.implement({ 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"], @@ -91,6 +115,9 @@ EfpListRecordRef.implement({ }, }), + ///////////////////////////// + // EfpListRecord.createdAt + ///////////////////////////// createdAt: t.field({ type: "BigInt", nullable: false, resolve: (record) => record.createdAt }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts index ff4a18e2ff..016bdff5f5 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts @@ -49,6 +49,9 @@ export const TOKEN_ID_PAGINATED_CONNECTION_ARGS = { 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", @@ -56,6 +59,9 @@ EfpListRef.implement({ resolve: (list) => list.tokenId, }), + //////////////// + // EfpList.owner + //////////////// owner: t.field({ description: "The current ERC-721 owner of the list NFT.", type: "Address", @@ -63,6 +69,9 @@ EfpListRef.implement({ resolve: (list) => list.owner as NormalizedAddress, }), + /////////////// + // EfpList.user + /////////////// user: t.field({ description: "The address allowed to post records to this list.", type: "Address", @@ -70,6 +79,9 @@ EfpListRef.implement({ resolve: (list) => (list.user ?? null) as NormalizedAddress | null, }), + ////////////////// + // EfpList.manager + ////////////////// manager: t.field({ description: "The address allowed to administer this list.", type: "Address", @@ -77,6 +89,9 @@ EfpListRef.implement({ resolve: (list) => (list.manager ?? null) as NormalizedAddress | null, }), + ///////////////////// + // EfpList.nftChainId + ///////////////////// nftChainId: t.field({ description: "Chain id of the ListRegistry NFT (always Base / 8453).", type: "ChainId", @@ -84,6 +99,9 @@ EfpListRef.implement({ resolve: (list) => list.nftChainId as ChainId, }), + ///////////////////////////// + // EfpList.nftContractAddress + ///////////////////////////// nftContractAddress: t.field({ description: "The ListRegistry contract address.", type: "Address", @@ -91,6 +109,9 @@ EfpListRef.implement({ resolve: (list) => list.nftContractAddress as NormalizedAddress, }), + ////////////////////////////////////// + // EfpList.listStorageLocationChainId + ////////////////////////////////////// listStorageLocationChainId: t.field({ description: "Decoded list storage location: target chain id.", type: "ChainId", @@ -98,6 +119,9 @@ EfpListRef.implement({ resolve: (list) => (list.listStorageLocationChainId ?? null) as ChainId | null, }), + ////////////////////////////////////////////// + // EfpList.listStorageLocationContractAddress + ////////////////////////////////////////////// listStorageLocationContractAddress: t.field({ description: "Decoded list storage location: target contract address.", type: "Address", @@ -106,6 +130,9 @@ EfpListRef.implement({ (list.listStorageLocationContractAddress ?? null) as NormalizedAddress | null, }), + /////////////////////////////////// + // EfpList.listStorageLocationSlot + /////////////////////////////////// listStorageLocationSlot: t.field({ description: "Decoded list storage location: target slot (bytes32).", type: "Hex", @@ -113,12 +140,19 @@ EfpListRef.implement({ 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, diff --git a/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts index 473eceb66b..80160690c5 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts @@ -9,6 +9,9 @@ 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( diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts index bae8e7a1f7..36cc95db07 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -18,6 +18,9 @@ import { parseListOp, parseRecord, parseTagOp, slotToBytes32 } from "../lib/pars 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( diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts index df5702ded3..f1a77b78a5 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -12,6 +12,9 @@ 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( diff --git a/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts b/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts index 7ad7c2d3d9..7069fbfe64 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts @@ -11,6 +11,9 @@ import { parseEfpListTextRecord } from "../lib/parse-efp-list-text-record"; const pluginName = PluginName.EFP; +/** + * Registers the EFP `Resolver` event handler for the `eth.efp.list` text record (TextChanged). + */ export default function () { // TextChanged — index the `eth.efp.list` text record into efp_ens_list_pointers. // From fcae841b778f547f3c07545b5840c394342e5cfc Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 16:44:34 +0200 Subject: [PATCH 10/45] refactor(efp): drop the non-spec eth.efp.list ENS text record The eth.efp.list text record is not part of the EFP spec (docs.efp.app); the canonical account-to-list association is the `primary-list` account metadata. Remove the address-less Resolver subscription, the eth.efp.list text-record parser, and the efp_ens_list_pointers table. EFP now indexes only the spec contracts: ListRegistry, AccountMetadata, and ListRecords. --- .changeset/efp-plugin.md | 2 +- apps/ensindexer/src/plugins/efp/README.md | 20 ++--- apps/ensindexer/src/plugins/efp/constants.ts | 16 ---- .../src/plugins/efp/event-handlers.ts | 2 - .../src/plugins/efp/handlers/Resolver.ts | 73 ------------------- apps/ensindexer/src/plugins/efp/lib/ids.ts | 10 --- .../lib/parse-efp-list-text-record.test.ts | 47 ------------ .../efp/lib/parse-efp-list-text-record.ts | 56 -------------- apps/ensindexer/src/plugins/efp/plugin.ts | 18 +---- packages/datasources/src/mainnet.ts | 12 +-- .../src/ensindexer-abstract/efp.schema.ts | 36 --------- 11 files changed, 14 insertions(+), 278 deletions(-) delete mode 100644 apps/ensindexer/src/plugins/efp/handlers/Resolver.ts delete mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts delete mode 100644 apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts diff --git a/.changeset/efp-plugin.md b/.changeset/efp-plugin.md index 999a2e8033..5042aeb03e 100644 --- a/.changeset/efp-plugin.md +++ b/.changeset/efp-plugin.md @@ -5,4 +5,4 @@ "ensindexer": minor --- -Add an EFP (Ethereum Follow Protocol) indexer plugin. Enable it by including `efp` in the `PLUGINS` environment variable (mainnet ENS namespace) to index EFP list NFTs, records, tags, and account metadata — plus the `eth.efp.list` ENS text record — into ENSDb's `efp_*` tables. +Add an EFP (Ethereum Follow Protocol) indexer plugin. Enable it by including `efp` in the `PLUGINS` environment variable (mainnet ENS namespace) to index EFP list NFTs, records, tags, and account metadata into ENSDb's `efp_*` tables. diff --git a/apps/ensindexer/src/plugins/efp/README.md b/apps/ensindexer/src/plugins/efp/README.md index 5304017c6d..3a178a7153 100644 --- a/apps/ensindexer/src/plugins/efp/README.md +++ b/apps/ensindexer/src/plugins/efp/README.md @@ -6,12 +6,11 @@ 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` | -| `Resolver` | Ethereum mainnet (address-less) | `TextChanged` (filtered to `eth.efp.list`) | +| 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`). @@ -25,13 +24,14 @@ Contract coordinates live in the `EFPBase` / `EFPOptimism` / `EFPEthereum` datas - `efp_account_metadata` — `(address, key) → value` (today only `primary-list`). - `efp_pending_list_metadata` — staging for `user`/`manager` updates that arrive before the list's storage location is known (the `ListRecords` and `ListRegistry` contracts emit independently). -- `efp_ens_list_pointers` — `eth.efp.list` text record → EFP list NFT, joinable to ENS names by `node`. ## 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 `eth.efp.list` Resolver subscription indexes `TextChanged` across every mainnet resolver - (address-less, like Protocol Acceleration) and filters to the well-known key in the handler. -- Byte decoders for list ops, storage locations, and the text record live in `lib/` with unit tests. +- 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 index a921c59ba4..b43522ec47 100644 --- a/apps/ensindexer/src/plugins/efp/constants.ts +++ b/apps/ensindexer/src/plugins/efp/constants.ts @@ -1,5 +1,3 @@ -import type { Hex } from "viem"; - /** * EFP `ListOp` opcodes (op version 0x01), encoded as `version | opcode | data`. * @@ -22,17 +20,3 @@ export const EFP_LIST_METADATA_KEYS = { USER: "user", MANAGER: "manager", } as const; - -/** The well-known ENS text-record key an ENS name sets to point at its EFP list. */ -export const DEFAULT_EFP_LIST_TEXT_RECORD_KEY = "eth.efp.list"; - -/** - * The canonical EFP `ListRegistry` (Base / 8453), used as the default target when an - * `eth.efp.list` text record is a bare decimal token id (rather than a CAIP-19 asset id). - * - * Mirrors the `EFPBase` > `ListRegistry` entry in `packages/datasources/src/mainnet.ts`. - */ -export const DEFAULT_EFP_LIST_REGISTRY: { chainId: number; address: Hex } = { - chainId: 8453, - address: "0x0e688f5dca4a0a4729946acbc44c792341714e08", -}; diff --git a/apps/ensindexer/src/plugins/efp/event-handlers.ts b/apps/ensindexer/src/plugins/efp/event-handlers.ts index 6feb506011..f42664a454 100644 --- a/apps/ensindexer/src/plugins/efp/event-handlers.ts +++ b/apps/ensindexer/src/plugins/efp/event-handlers.ts @@ -1,11 +1,9 @@ import attach_AccountMetadata from "./handlers/AccountMetadata"; import attach_ListRecords from "./handlers/ListRecords"; import attach_ListRegistry from "./handlers/ListRegistry"; -import attach_Resolver from "./handlers/Resolver"; export default function () { attach_ListRegistry(); attach_ListRecords(); attach_AccountMetadata(); - attach_Resolver(); } diff --git a/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts b/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts deleted file mode 100644 index 7069fbfe64..0000000000 --- a/apps/ensindexer/src/plugins/efp/handlers/Resolver.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 { DEFAULT_EFP_LIST_TEXT_RECORD_KEY } from "../constants"; -import { ensListPointerId } from "../lib/ids"; -import { parseEfpListTextRecord } from "../lib/parse-efp-list-text-record"; - -const pluginName = PluginName.EFP; - -/** - * Registers the EFP `Resolver` event handler for the `eth.efp.list` text record (TextChanged). - */ -export default function () { - // TextChanged — index the `eth.efp.list` text record into efp_ens_list_pointers. - // - // We subscribe to the modern (with-value) TextChanged overload across every mainnet Resolver - // (the contract is address-less), mirroring how Protocol Acceleration indexes Resolvers, and - // filter to the well-known key here. The merged Resolver ABI's overloaded TextChanged precludes - // a contract-level `indexedKey` topic filter, so the key check is the filter. - addOnchainEventListener( - namespaceContract( - pluginName, - "Resolver:TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value)", - ), - async ({ context, event }) => { - if (event.args.key !== DEFAULT_EFP_LIST_TEXT_RECORD_KEY) return; - - const ts = event.block.timestamp; - const chainId = context.chain.id; - const resolver = event.log.address.toLowerCase() as Hex; - const node = event.args.node.toLowerCase() as Hex; - const id = ensListPointerId(chainId, resolver, node, DEFAULT_EFP_LIST_TEXT_RECORD_KEY); - - // An empty or unparseable value clears the pointer (ENS convention: empty == unset). - if (!event.args.value) { - await context.ensDb.delete(ensIndexerSchema.efpEnsListPointers, { id }); - return; - } - const parsed = parseEfpListTextRecord(event.args.value); - if (!parsed) { - await context.ensDb.delete(ensIndexerSchema.efpEnsListPointers, { id }); - return; - } - - await context.ensDb - .insert(ensIndexerSchema.efpEnsListPointers) - .values({ - id, - chainId, - resolver, - node, - ensKey: DEFAULT_EFP_LIST_TEXT_RECORD_KEY, - rawValue: event.args.value, - listTokenId: parsed.listTokenId, - listContract: parsed.listContract, - listChainId: parsed.listChainId, - createdAt: ts, - updatedAt: ts, - }) - .onConflictDoUpdate({ - rawValue: event.args.value, - listTokenId: parsed.listTokenId, - listContract: parsed.listContract, - listChainId: parsed.listChainId, - updatedAt: ts, - }); - }, - ); -} diff --git a/apps/ensindexer/src/plugins/efp/lib/ids.ts b/apps/ensindexer/src/plugins/efp/lib/ids.ts index 986f9c7eda..3d92e8d7c7 100644 --- a/apps/ensindexer/src/plugins/efp/lib/ids.ts +++ b/apps/ensindexer/src/plugins/efp/lib/ids.ts @@ -45,13 +45,3 @@ export function pendingListMetadataId( ): string { return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${key}`; } - -/** `efp_ens_list_pointers` key: a `(resolver, node, ensKey)` on a chain. */ -export function ensListPointerId( - chainId: number, - resolver: Hex, - node: Hex, - ensKey: string, -): string { - return `${chainId}-${resolver.toLowerCase()}-${node.toLowerCase()}-${ensKey}`; -} diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts b/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts deleted file mode 100644 index 5b1cf8aaee..0000000000 --- a/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_EFP_LIST_REGISTRY, DEFAULT_EFP_LIST_TEXT_RECORD_KEY } from "../constants"; -import { parseEfpListTextRecord } from "./parse-efp-list-text-record"; - -describe("parseEfpListTextRecord", () => { - it("interprets a decimal value as a list on the default ListRegistry", () => { - expect(parseEfpListTextRecord("12345")).toEqual({ - listTokenId: "12345", - listChainId: DEFAULT_EFP_LIST_REGISTRY.chainId, - listContract: DEFAULT_EFP_LIST_REGISTRY.address.toLowerCase(), - }); - }); - - it("trims whitespace before validating", () => { - expect(parseEfpListTextRecord(" 42 ")?.listTokenId).toBe("42"); - }); - - it("decodes a CAIP-19 erc721 asset id", () => { - const value = "eip155:8453/erc721:0x0E688f5DCa4a0a4729946ACbC44C792341714e08/9001"; - expect(parseEfpListTextRecord(value)).toEqual({ - listTokenId: "9001", - listChainId: 8453, - listContract: "0x0e688f5dca4a0a4729946acbc44c792341714e08", - }); - }); - - it("returns null for empty / whitespace / null / undefined", () => { - expect(parseEfpListTextRecord("")).toBeNull(); - expect(parseEfpListTextRecord(" ")).toBeNull(); - // @ts-expect-error — explicitly test invalid runtime input - expect(parseEfpListTextRecord(null)).toBeNull(); - // @ts-expect-error — explicitly test invalid runtime input - expect(parseEfpListTextRecord(undefined)).toBeNull(); - }); - - it("returns null for invalid CAIP-19 shapes", () => { - expect(parseEfpListTextRecord("eip155:8453/erc721:notanaddress/1")).toBeNull(); - expect(parseEfpListTextRecord("eip155:abc/erc721:0x0E68...4e08/1")).toBeNull(); - expect(parseEfpListTextRecord("foo")).toBeNull(); - expect(parseEfpListTextRecord("0x1234")).toBeNull(); - }); - - it("exposes the well-known key constant", () => { - expect(DEFAULT_EFP_LIST_TEXT_RECORD_KEY).toBe("eth.efp.list"); - }); -}); diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts b/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts deleted file mode 100644 index a72734eb6f..0000000000 --- a/apps/ensindexer/src/plugins/efp/lib/parse-efp-list-text-record.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Parser for the well-known ENS text-record `eth.efp.list`, which an ENS name's owner sets on their - * resolver to declare which EFP list NFT belongs to that name. - * - * Two value formats are accepted: - * 1. Decimal token id — `"1234"` — interpreted as a list on the default EFP ListRegistry - * (Base / 8453). - * 2. CAIP-19 asset identifier — `"eip155:8453/erc721:0x0E68…4e08/1234"` — explicit chain id, - * contract address, and token id. - * - * Returns `null` on any shape mismatch — the caller then deletes the pointer (matching the ENS - * convention that "set to garbage" is equivalent to "unset"). - */ - -import { type Hex, isAddress } from "viem"; - -import { DEFAULT_EFP_LIST_REGISTRY } from "../constants"; - -export interface ParsedEfpListPointer { - listTokenId: string; - /** EFP `ListRegistry` chain id. Defaults to 8453 (Base) for plain decimal values. */ - listChainId: number; - /** EFP `ListRegistry` contract address, always lowercased. */ - listContract: Hex; -} - -const DECIMAL_RE = /^[0-9]+$/; -const CAIP19_RE = - /^eip155:(?[0-9]+)\/erc721:(?
0x[0-9a-fA-F]{40})\/(?[0-9]+)$/; - -export function parseEfpListTextRecord(value: string): ParsedEfpListPointer | null { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - - if (DECIMAL_RE.test(trimmed)) { - return { - listTokenId: trimmed, - listChainId: DEFAULT_EFP_LIST_REGISTRY.chainId, - listContract: DEFAULT_EFP_LIST_REGISTRY.address.toLowerCase() as Hex, - }; - } - - const m = CAIP19_RE.exec(trimmed); - if (!m?.groups) return null; - const { chainId, address, tokenId } = m.groups; - if (!chainId || !address || !tokenId) return null; - // Tolerate mixed-case addresses (no EIP-55 checksum enforcement): an ENS text record is often - // hand-edited. The regex already restricts it to 40 hex chars. - if (!isAddress(address, { strict: false })) return null; - return { - listTokenId: tokenId, - listChainId: Number(chainId), - listContract: address.toLowerCase() as Hex, - }; -} diff --git a/apps/ensindexer/src/plugins/efp/plugin.ts b/apps/ensindexer/src/plugins/efp/plugin.ts index 878aed3ab1..764e8d7d8d 100644 --- a/apps/ensindexer/src/plugins/efp/plugin.ts +++ b/apps/ensindexer/src/plugins/efp/plugin.ts @@ -1,9 +1,8 @@ /** * The EFP plugin indexes the Ethereum Follow Protocol: * - list NFTs (`ListRegistry` on Base), - * - list records & tags (`ListRecords` on Base, Optimism, and Ethereum mainnet), - * - account metadata (`AccountMetadata` on Base), and - * - the `eth.efp.list` ENS text record (any Resolver on Ethereum mainnet). + * - 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 @@ -89,19 +88,6 @@ export default createPlugin({ }, abi: efpBase.contracts.ListRecords.abi, }, - // Address-less Resolver subscription on Ethereum mainnet for the `eth.efp.list` text record. - // Matches any contract emitting the standard TextChanged event (Ponder factory-mode); the - // handler filters to the well-known key. - [namespaceContract(pluginName, "Resolver")]: { - chain: { - ...chainConfigForContract( - config.globalBlockrange, - efpEthereum.chain.id, - efpEthereum.contracts.Resolver, - ), - }, - abi: efpEthereum.contracts.Resolver.abi, - }, }, }); }, diff --git a/packages/datasources/src/mainnet.ts b/packages/datasources/src/mainnet.ts index a24ed7112c..95d8d34c1e 100644 --- a/packages/datasources/src/mainnet.ts +++ b/packages/datasources/src/mainnet.ts @@ -548,12 +548,7 @@ export default { }, /** - * EFP `ListRecords` Datasource on Ethereum mainnet, plus the `Resolver` subscription used to - * index the `eth.efp.list` ENS text record into `efp_ens_list_pointers`. - * - * The `Resolver` has no pinned address: any contract that emits the standard `TextChanged` event - * is in scope (the EFP plugin narrows to the `eth.efp.list` key), mirroring how Protocol - * Acceleration indexes Resolvers. + * EFP `ListRecords` Datasource on Ethereum mainnet. */ [DatasourceNames.EFPEthereum]: { chain: mainnet, @@ -563,11 +558,6 @@ export default { address: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", startBlock: 20820000, }, - Resolver: { - abi: ResolverABI, - // EFP launch on mainnet; `eth.efp.list` text records are not expected before this. - 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 index aa28792c7a..be32fcc45f 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -179,39 +179,3 @@ export const efpPendingListMetadata = onchainTable( idx_slot: index().on(t.chainId, t.contractAddress, t.slot), }), ); - -/** - * Cross-correlation between an ENS namehash and a specific EFP list NFT, populated from - * `Resolver.TextChanged` events whose key is `eth.efp.list`. The same node can have pointers via - * multiple resolvers, so they are not collapsed at write time. An empty / unparseable text record - * deletes the row, matching the ENS convention that an empty text record is unset. - */ -export const efpEnsListPointers = onchainTable( - "efp_ens_list_pointers", - (t) => ({ - /** Composite key "chainId-resolver-node-ensKey". */ - id: t.text().primaryKey(), - /** Chain id of the resolver contract that emitted the TextChanged event. */ - chainId: t.int8({ mode: "number" }).notNull(), - /** Resolver contract address. */ - resolver: t.hex().notNull(), - /** ENS namehash of the name whose text record this is. */ - node: t.hex().notNull(), - /** The ENS text-record key matched on (e.g. "eth.efp.list"). */ - ensKey: t.text().notNull(), - /** Raw text-record value, kept verbatim for re-parsing. */ - rawValue: t.text().notNull(), - /** Decoded list token id (decimal string). */ - listTokenId: t.text().notNull(), - /** Decoded list contract address (defaults to the EFP ListRegistry on Base). */ - listContract: t.hex().notNull(), - /** Decoded list chain id (defaults to 8453 / Base for plain decimal values). */ - listChainId: t.int8({ mode: "number" }).notNull(), - createdAt: t.bigint().notNull(), - updatedAt: t.bigint().notNull(), - }), - (t) => ({ - idx_node: index().on(t.node), - idx_listTokenId: index().on(t.listTokenId), - }), -); From 3c6aefc08148c52824cc83e7e9f98f6d85471612 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 16:44:34 +0200 Subject: [PATCH 11/45] feat(ensapi): resolve EFP primary list via the Omnigraph API Replace the removed eth.efp.list `listPointers` query with `efp.primaryList(address)`, which reads the account `primary-list` metadata and returns the list only when its `user` role matches the account (the EFP two-step Primary List validation). Add `EfpListRecord.list` so a record navigates to its list, and consolidate the cross-service id mirrors in `efp-ids.ts`. --- .changeset/efp-omnigraph.md | 2 +- apps/ensapi/src/omnigraph-api/schema.ts | 1 - .../schema/efp-account-metadata.ts | 7 - .../src/omnigraph-api/schema/efp-ids.ts | 18 +++ .../src/omnigraph-api/schema/efp-inputs.ts | 14 -- .../omnigraph-api/schema/efp-list-pointer.ts | 125 ------------------ .../omnigraph-api/schema/efp-list-record.ts | 25 ++++ apps/ensapi/src/omnigraph-api/schema/efp.ts | 82 +++++++----- 8 files changed, 92 insertions(+), 182 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-ids.ts delete mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts diff --git a/.changeset/efp-omnigraph.md b/.changeset/efp-omnigraph.md index d4d7f66951..3daa3502c2 100644 --- a/.changeset/efp-omnigraph.md +++ b/.changeset/efp-omnigraph.md @@ -3,4 +3,4 @@ "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`, `accountMetadata` / `accountMetadatas`, and `listPointers` (the `eth.efp.list` ENS↔list correlation), with cursor-paginated connections and where-filters (owner/user/manager, recordData, address, node/listTokenId). Requires the `efp` plugin to be enabled on the indexer. +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). Requires the `efp` plugin to be enabled on the indexer. diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index e59ab2096c..0f070fc811 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -9,7 +9,6 @@ import "./schema/efp"; import "./schema/efp-account-metadata"; import "./schema/efp-inputs"; import "./schema/efp-list"; -import "./schema/efp-list-pointer"; import "./schema/efp-list-record"; import "./schema/event"; import "./schema/label"; diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts index 8b5922ebf4..114111dc67 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-account-metadata.ts @@ -20,13 +20,6 @@ export const EfpAccountMetadataRef = builder.loadableObjectRef("EfpAccountMetada export type EfpAccountMetadata = Exclude; -/** - * The synthetic primary key used by `efp_account_metadata`. Mirrors the EFP plugin's - * `accountMetadataId` (`${address}-${key}`, with a lowercased address). - */ -export const efpAccountMetadataId = (address: NormalizedAddress, key: string): string => - `${address}-${key}`; - ////////////////////// // EfpAccountMetadata ////////////////////// 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..76b939fb9e --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts @@ -0,0 +1,18 @@ +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}` (lowercased address). */ +export function efpAccountMetadataId(address: NormalizedAddress, key: string): string { + return `${address}-${key}`; +} + +/** `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 index f26b79d6ef..a4908dac08 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-inputs.ts @@ -43,17 +43,3 @@ export const EfpAccountMetadatasWhereInput = builder.inputType("EfpAccountMetada address: t.field({ type: "Address", required: true, description: "The account address." }), }), }); - -/** - * Filters for the `efp.listPointers` connection. - */ -export const EfpListPointersWhereInput = builder.inputType("EfpListPointersWhereInput", { - description: "Filter EFP ENS list pointers (the `eth.efp.list` text-record correlation).", - fields: (t) => ({ - node: t.field({ type: "Node", description: "The ENS namehash that claims a list." }), - listTokenId: t.field({ - type: "String", - description: "Find the ENS names that point at this list token id.", - }), - }), -}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts deleted file mode 100644 index 6c63ad6d16..0000000000 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-pointer.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { inArray } from "drizzle-orm"; -import type { ChainId, Node, NormalizedAddress } from "enssdk"; - -import di from "@/di"; -import { builder } from "@/omnigraph-api/builder"; -import { getModelId } from "@/omnigraph-api/lib/get-model-id"; - -export const EfpListPointerRef = builder.loadableObjectRef("EfpListPointer", { - load: (ids: string[]) => { - const { ensDb, ensIndexerSchema } = di.context; - return ensDb - .select() - .from(ensIndexerSchema.efpEnsListPointers) - .where(inArray(ensIndexerSchema.efpEnsListPointers.id, ids)); - }, - toKey: getModelId, - cacheResolved: true, - sort: true, -}); - -export type EfpListPointer = Exclude; - -////////////////// -// EfpListPointer -////////////////// -EfpListPointerRef.implement({ - description: - "A correlation between an ENS name (via its `eth.efp.list` text record) and an EFP list.", - fields: (t) => ({ - //////////////////////// - // EfpListPointer.id - //////////////////////// - id: t.field({ type: "String", nullable: false, resolve: (pointer) => pointer.id }), - - ///////////////////////////// - // EfpListPointer.chainId - ///////////////////////////// - chainId: t.field({ - description: "Chain id of the resolver that emitted the text record.", - type: "ChainId", - nullable: false, - resolve: (pointer) => pointer.chainId as ChainId, - }), - - ////////////////////////////// - // EfpListPointer.resolver - ////////////////////////////// - resolver: t.field({ - description: "The resolver contract address.", - type: "Address", - nullable: false, - resolve: (pointer) => pointer.resolver as NormalizedAddress, - }), - - ////////////////////////// - // EfpListPointer.node - ////////////////////////// - node: t.field({ - description: "The ENS namehash whose `eth.efp.list` text record points at a list.", - type: "Node", - nullable: false, - resolve: (pointer) => pointer.node as Node, - }), - - //////////////////////////// - // EfpListPointer.ensKey - //////////////////////////// - ensKey: t.field({ - description: "The matched ENS text-record key (e.g. `eth.efp.list`).", - type: "String", - nullable: false, - resolve: (pointer) => pointer.ensKey, - }), - - ////////////////////////////// - // EfpListPointer.rawValue - ////////////////////////////// - rawValue: t.field({ - description: "The raw text-record value, kept verbatim.", - type: "String", - nullable: false, - resolve: (pointer) => pointer.rawValue, - }), - - ///////////////////////////////// - // EfpListPointer.listTokenId - ///////////////////////////////// - listTokenId: t.field({ - description: "Decoded list token id (decimal string).", - type: "String", - nullable: false, - resolve: (pointer) => pointer.listTokenId, - }), - - ///////////////////////////////// - // EfpListPointer.listContract - ///////////////////////////////// - listContract: t.field({ - description: "Decoded list contract address (defaults to the EFP ListRegistry on Base).", - type: "Address", - nullable: false, - resolve: (pointer) => pointer.listContract as NormalizedAddress, - }), - - //////////////////////////////// - // EfpListPointer.listChainId - //////////////////////////////// - listChainId: t.field({ - description: "Decoded list chain id (defaults to 8453 / Base).", - type: "ChainId", - nullable: false, - resolve: (pointer) => pointer.listChainId as ChainId, - }), - - //////////////////////////////// - // EfpListPointer.createdAt - //////////////////////////////// - createdAt: t.field({ type: "BigInt", nullable: false, resolve: (p) => p.createdAt }), - - //////////////////////////////// - // EfpListPointer.updatedAt - //////////////////////////////// - updatedAt: t.field({ type: "BigInt", nullable: false, resolve: (p) => p.updatedAt }), - }), -}); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts index 29787ce932..130245e116 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -4,6 +4,8 @@ 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 { efpStorageLocationId } from "@/omnigraph-api/schema/efp-ids"; +import { EfpListRef } from "@/omnigraph-api/schema/efp-list"; export const EfpListRecordRef = builder.loadableObjectRef("EfpListRecord", { load: (ids: string[]) => { @@ -119,5 +121,28 @@ EfpListRecordRef.implement({ // EfpListRecord.createdAt ///////////////////////////// createdAt: t.field({ type: "BigInt", nullable: false, resolve: (record) => record.createdAt }), + + /////////////////////// + // EfpListRecord.list + /////////////////////// + list: t.field({ + description: "The EFP list this record belongs to.", + type: EfpListRef, + nullable: true, + resolve: async (record) => { + const { ensDb, ensIndexerSchema } = di.context; + const [mapping] = await ensDb + .select({ tokenId: ensIndexerSchema.efpListStorageLocations.tokenId }) + .from(ensIndexerSchema.efpListStorageLocations) + .where( + eq( + ensIndexerSchema.efpListStorageLocations.id, + efpStorageLocationId(record.chainId, record.contractAddress, record.slot), + ), + ) + .limit(1); + return mapping?.tokenId ?? null; + }, + }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/efp.ts b/apps/ensapi/src/omnigraph-api/schema/efp.ts index 3ac4bd7a9b..a62974ec9f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.ts @@ -7,20 +7,32 @@ import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } 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, - efpAccountMetadataId, -} from "@/omnigraph-api/schema/efp-account-metadata"; +import { EfpAccountMetadataRef } from "@/omnigraph-api/schema/efp-account-metadata"; +import { efpAccountMetadataId } from "@/omnigraph-api/schema/efp-ids"; import { EfpAccountMetadatasWhereInput, - EfpListPointersWhereInput, EfpListRecordsWhereInput, EfpListsWhereInput, } from "@/omnigraph-api/schema/efp-inputs"; import { EfpListRef, TOKEN_ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/efp-list"; -import { EfpListPointerRef } from "@/omnigraph-api/schema/efp-list-pointer"; import { EfpListRecordRef } from "@/omnigraph-api/schema/efp-list-record"; +/** The EFP AccountMetadata key whose value is an account's primary-list token id. */ +const EFP_PRIMARY_LIST_KEY = "primary-list"; + +/** + * Decode a `primary-list` account-metadata value (an abi-encoded `uint256` token id) into a + * decimal token-id string, or `null` if it isn't a well-formed value. + */ +function decodePrimaryListTokenId(value: Hex): string | null { + if (!value || value === "0x") return null; + try { + return BigInt(value).toString(); + } catch { + return null; + } +} + /** * `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. @@ -156,39 +168,41 @@ EfpQueryRef.implement({ }), /////////////////////// - // efp.listPointers + // efp.primaryList /////////////////////// - listPointers: t.connection({ + primaryList: t.field({ description: - "Find ENS -> EFP list correlations (the `eth.efp.list` text record), by ENS node or list token id.", - type: EfpListPointerRef, - args: { where: t.arg({ type: EfpListPointersWhereInput }) }, - resolve: (_parent, args) => { + "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: async (_parent, args) => { const { ensDb, ensIndexerSchema } = di.context; - const where = args.where; - const scope = and( - where?.node ? eq(ensIndexerSchema.efpEnsListPointers.node, where.node as Hex) : undefined, - where?.listTokenId - ? eq(ensIndexerSchema.efpEnsListPointers.listTokenId, where.listTokenId) - : undefined, - ); - return lazyConnection({ - totalCount: () => ensDb.$count(ensIndexerSchema.efpEnsListPointers, scope), - connection: () => - resolveCursorConnection( - { ...ID_PAGINATED_CONNECTION_ARGS, args }, - ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => - ensDb - .select() - .from(ensIndexerSchema.efpEnsListPointers) - .where( - and(scope, paginateBy(ensIndexerSchema.efpEnsListPointers.id, before, after)), - ) - .orderBy(orderPaginationBy(ensIndexerSchema.efpEnsListPointers.id, inverted)) - .limit(limit), + const [metadata] = await ensDb + .select({ value: ensIndexerSchema.efpAccountMetadata.value }) + .from(ensIndexerSchema.efpAccountMetadata) + .where( + eq( + ensIndexerSchema.efpAccountMetadata.id, + efpAccountMetadataId(args.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. + const [list] = await ensDb + .select({ user: ensIndexerSchema.efpLists.user }) + .from(ensIndexerSchema.efpLists) + .where(eq(ensIndexerSchema.efpLists.tokenId, tokenId)) + .limit(1); + if (!list || list.user?.toLowerCase() !== args.address) return null; + + return tokenId; }, }), }), From df85110200ec4cf0607d759a37a8407aa8692d94 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 16:44:34 +0200 Subject: [PATCH 12/45] chore(enssdk): regenerate Omnigraph SDL and introspection for EFP primary-list Output of `pnpm generate`. --- .../src/omnigraph/generated/introspection.ts | 300 ++---------------- .../src/omnigraph/generated/schema.graphql | 69 +--- 2 files changed, 30 insertions(+), 339 deletions(-) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index d372704473..a45508d19c 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -3522,7 +3522,7 @@ const introspection = { }, { "kind": "OBJECT", - "name": "EfpListPointer", + "name": "EfpListRecord", "fields": [ { "name": "chainId", @@ -3537,103 +3537,7 @@ const introspection = { "isDeprecated": false }, { - "name": "createdAt", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "BigInt" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "ensKey", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "id", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "listChainId", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "ChainId" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "listContract", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Address" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "listTokenId", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "node", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "Node" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "rawValue", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } - }, - "args": [], - "isDeprecated": false - }, - { - "name": "resolver", + "name": "contractAddress", "type": { "kind": "NON_NULL", "ofType": { @@ -3645,7 +3549,7 @@ const introspection = { "isDeprecated": false }, { - "name": "updatedAt", + "name": "createdAt", "type": { "kind": "NON_NULL", "ofType": { @@ -3655,79 +3559,24 @@ const introspection = { }, "args": [], "isDeprecated": false - } - ], - "interfaces": [] - }, - { - "kind": "INPUT_OBJECT", - "name": "EfpListPointersWhereInput", - "inputFields": [ - { - "name": "listTokenId", - "type": { - "kind": "SCALAR", - "name": "String" - } - }, - { - "name": "node", - "type": { - "kind": "SCALAR", - "name": "Node" - } - } - ], - "isOneOf": false - }, - { - "kind": "OBJECT", - "name": "EfpListRecord", - "fields": [ - { - "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", + "name": "id", "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", - "name": "BigInt" + "name": "String" } }, "args": [], "isDeprecated": false }, { - "name": "id", + "name": "list", "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "SCALAR", - "name": "String" - } + "kind": "OBJECT", + "name": "EfpList" }, "args": [], "isDeprecated": false @@ -4033,10 +3882,10 @@ const introspection = { "isDeprecated": false }, { - "name": "listPointers", + "name": "listRecords", "type": { "kind": "OBJECT", - "name": "EfpQueryListPointersConnection" + "name": "EfpQueryListRecordsConnection" }, "args": [ { @@ -4071,17 +3920,17 @@ const introspection = { "name": "where", "type": { "kind": "INPUT_OBJECT", - "name": "EfpListPointersWhereInput" + "name": "EfpListRecordsWhereInput" } } ], "isDeprecated": false }, { - "name": "listRecords", + "name": "lists", "type": { "kind": "OBJECT", - "name": "EfpQueryListRecordsConnection" + "name": "EfpQueryListsConnection" }, "args": [ { @@ -4116,52 +3965,27 @@ const introspection = { "name": "where", "type": { "kind": "INPUT_OBJECT", - "name": "EfpListRecordsWhereInput" + "name": "EfpListsWhereInput" } } ], "isDeprecated": false }, { - "name": "lists", + "name": "primaryList", "type": { "kind": "OBJECT", - "name": "EfpQueryListsConnection" + "name": "EfpList" }, "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", + "name": "address", "type": { - "kind": "INPUT_OBJECT", - "name": "EfpListsWhereInput" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Address" + } } } ], @@ -4250,86 +4074,6 @@ const introspection = { ], "interfaces": [] }, - { - "kind": "OBJECT", - "name": "EfpQueryListPointersConnection", - "fields": [ - { - "name": "edges", - "type": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "OBJECT", - "name": "EfpQueryListPointersConnectionEdge" - } - } - } - }, - "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": "EfpQueryListPointersConnectionEdge", - "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": "EfpListPointer" - } - }, - "args": [], - "isDeprecated": false - } - ], - "interfaces": [] - }, { "kind": "OBJECT", "name": "EfpQueryListRecordsConnection", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 18177838be..fa59e03131 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -788,51 +788,6 @@ type EfpList { user: Address } -""" -A correlation between an ENS name (via its `eth.efp.list` text record) and an EFP list. -""" -type EfpListPointer { - """Chain id of the resolver that emitted the text record.""" - chainId: ChainId! - createdAt: BigInt! - - """The matched ENS text-record key (e.g. `eth.efp.list`).""" - ensKey: String! - id: String! - - """Decoded list chain id (defaults to 8453 / Base).""" - listChainId: ChainId! - - """ - Decoded list contract address (defaults to the EFP ListRegistry on Base). - """ - listContract: Address! - - """Decoded list token id (decimal string).""" - listTokenId: String! - - """The ENS namehash whose `eth.efp.list` text record points at a list.""" - node: Node! - - """The raw text-record value, kept verbatim.""" - rawValue: String! - - """The resolver contract address.""" - resolver: Address! - updatedAt: BigInt! -} - -""" -Filter EFP ENS list pointers (the `eth.efp.list` text-record correlation). -""" -input EfpListPointersWhereInput { - """Find the ENS names that point at this list token id.""" - listTokenId: String - - """The ENS namehash that claims a list.""" - node: Node -} - """ A single record within an EFP list (an address it follows), with its tags. """ @@ -845,6 +800,9 @@ type EfpListRecord { createdAt: BigInt! id: String! + """The EFP list this record belongs to.""" + list: EfpList + """The full record payload (version | type | data).""" record: Hex! @@ -906,11 +864,6 @@ type EfpQuery { """Get an EFP list by its NFT token id.""" list(tokenId: String!): EfpList - """ - Find ENS -> EFP list correlations (the `eth.efp.list` text record), by ENS node or list token id. - """ - listPointers(after: String, before: String, first: Int, last: Int, where: EfpListPointersWhereInput): EfpQueryListPointersConnection - """ Find EFP list records. Filter by `recordData` to answer 'which lists follow this address?'. """ @@ -918,6 +871,11 @@ type EfpQuery { """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 { @@ -931,17 +889,6 @@ type EfpQueryAccountMetadatasConnectionEdge { node: EfpAccountMetadata! } -type EfpQueryListPointersConnection { - edges: [EfpQueryListPointersConnectionEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type EfpQueryListPointersConnectionEdge { - cursor: String! - node: EfpListPointer! -} - type EfpQueryListRecordsConnection { edges: [EfpQueryListRecordsConnectionEdge!]! pageInfo: PageInfo! From 9dfcd8c949bb9b896f8455e2c4472a0257c1fcb0 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 17:20:57 +0200 Subject: [PATCH 13/45] fix(efp): skip reserved record types; normalize records to canonical bytes EFP defines only record type 1 (a 20-byte address); types 0 and 2-255 are reserved with no defined data layout. `parseRecord` now returns null for them, so the indexer never stores a record whose `recordData` is not an address (which the Omnigraph API exposes through a non-null `Address` scalar). For type-1 records it also exposes the canonical `version | type | address` 22-byte prefix, truncating any trailing junk after the 20-byte address. Tag and remove ops carry that same prefix, so keying records by it (next commit) makes them resolve to the same row. --- .../src/plugins/efp/lib/parse-list-op.test.ts | 11 ++--- .../src/plugins/efp/lib/parse-list-op.ts | 44 +++++++++++-------- 2 files changed, 29 insertions(+), 26 deletions(-) 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 index ba503a4b0b..2879952157 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts @@ -26,12 +26,13 @@ describe("parseListOp", () => { }); describe("parseRecord", () => { - it("decodes an address record (recordType=1) and truncates to 20 bytes", () => { + 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}`, }); }); @@ -41,13 +42,9 @@ describe("parseRecord", () => { expect(parseRecord(data)).toBeNull(); }); - it("keeps the full body for non-address record types", () => { + it("returns null for reserved (non-address) record types", () => { const data = ("0x0102" + "01020304") as `0x${string}`; - expect(parseRecord(data)).toEqual({ - version: 1, - recordType: 2, - recordData: "0x01020304", - }); + expect(parseRecord(data)).toBeNull(); }); it("returns null for unparseable input", () => { diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts index 44cfbb762e..8c3aaef250 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -6,12 +6,10 @@ * ListOp.op := version (1) | opcode (1) | data (variable) * record := recordVersion (1) | recordType (1) | recordData (variable) * - * `recordData` is the address-only payload for the only record type EFP uses in production - * (`recordType === 1`). The api-v2 reference indexer truncates that payload to exactly 20 bytes - * because users sometimes append junk after the address; we preserve that behaviour. - * - * Tag operations use `record (22 bytes) | tag (UTF-8 bytes)` inside `data`. The 22-byte record - * prefix is the `recordVersion (1) | recordType (1) | address (20)` triple. + * 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/ */ @@ -32,6 +30,12 @@ export interface ParsedRecord { 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; } @@ -66,8 +70,11 @@ export function parseListOp(op: Hex | string | null | undefined): ParsedListOp | } /** - * Decode a record payload as it appears inside an ADD_RECORD list op. For `recordType === 1` the - * returned `recordData` is exactly 20 bytes, matching api-v2's truncation rule. + * 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; @@ -77,21 +84,20 @@ export function parseRecord(data: Hex | string | null | undefined): ParsedRecord const version = parseInt(bytes.slice(0, 2), 16); const recordType = parseInt(bytes.slice(2, 4), 16); - let body: string; - if (recordType === 1) { - // Truncate to 20 bytes (40 hex chars) and reject short inputs. - 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; - } else { - body = bytes.slice(RECORD_HEADER_HEX_LENGTH); - } + // EFP defines only record type 1 (a 20-byte address); types 0 and 2-255 are reserved. + if (recordType !== 1) 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, + record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex, recordData: `0x${body}` as Hex, }; } From 5fa1c5ba74e37775957b8ecb3d05d9466b5e9978 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 17:25:01 +0200 Subject: [PATCH 14/45] refactor(efp): key records canonically and embed tags for PK-only removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records are now keyed by the canonical 22-byte `version | type | address` prefix in both ADD_RECORD and REMOVE_RECORD, so a clean remove op deletes a junk-suffixed record (and vice versa) and tag ops resolve to the same row (completes the record-identity fix). Tags move from the `efp_list_record_tags` join table onto an embedded `tags` array on `efp_list_records`. REMOVE_RECORD is now a single primary-key delete — the tags travel with the row — instead of a non-PK cascade in the indexing hot path, and a re-added record starts with no stale tags. ADD_TAG/REMOVE_TAG read-modify-write the record tag set by primary key, and the Omnigraph `EfpListRecord.tags` resolver reads the column directly (removing a per-record query). Tag ops for a record not in the list are ignored, since ops may arrive in any order. --- .../omnigraph-api/schema/efp-list-record.ts | 18 +----- apps/ensindexer/src/plugins/efp/README.md | 3 +- .../src/plugins/efp/handlers/ListRecords.ts | 59 +++++++------------ apps/ensindexer/src/plugins/efp/lib/ids.ts | 11 ---- .../src/ensindexer-abstract/efp.schema.ts | 45 ++++---------- 5 files changed, 38 insertions(+), 98 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts index 130245e116..0b08c3d95f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import type { ChainId, NormalizedAddress } from "enssdk"; import di from "@/di"; @@ -100,21 +100,7 @@ EfpListRecordRef.implement({ description: 'UTF-8 tags attached to this record (e.g. "close-friend", "block").', type: ["String"], nullable: false, - resolve: async (record) => { - const { ensDb, ensIndexerSchema } = di.context; - const rows = await ensDb - .select({ tag: ensIndexerSchema.efpListRecordTags.tag }) - .from(ensIndexerSchema.efpListRecordTags) - .where( - and( - eq(ensIndexerSchema.efpListRecordTags.chainId, record.chainId), - eq(ensIndexerSchema.efpListRecordTags.contractAddress, record.contractAddress), - eq(ensIndexerSchema.efpListRecordTags.slot, record.slot), - eq(ensIndexerSchema.efpListRecordTags.record, record.record), - ), - ); - return rows.map((row) => row.tag); - }, + resolve: (record) => record.tags, }), ///////////////////////////// diff --git a/apps/ensindexer/src/plugins/efp/README.md b/apps/ensindexer/src/plugins/efp/README.md index 3a178a7153..b8c8872256 100644 --- a/apps/ensindexer/src/plugins/efp/README.md +++ b/apps/ensindexer/src/plugins/efp/README.md @@ -20,7 +20,8 @@ Contract coordinates live in the `EFPBase` / `EFPOptimism` / `EFPEthereum` datas - `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` / `efp_list_record_tags` — the records in each list and their tags. +- `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_pending_list_metadata` — staging for `user`/`manager` updates that arrive before the list's storage location is known (the `ListRecords` and `ListRegistry` contracts emit independently). diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts index 36cc95db07..56df7d3b4d 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -1,4 +1,3 @@ -import { and, eq } from "drizzle-orm"; import type { Hex } from "viem"; import { PluginName } from "@ensnode/ensnode-sdk"; @@ -7,12 +6,7 @@ import { addOnchainEventListener, ensIndexerSchema } from "@/lib/indexing-engine import { namespaceContract } from "@/lib/plugin-helpers"; import { EFP_LIST_METADATA_KEYS, EFP_OPCODE } from "../constants"; -import { - listRecordId, - listRecordTagId, - pendingListMetadataId, - storageLocationId, -} from "../lib/ids"; +import { listRecordId, pendingListMetadataId, storageLocationId } from "../lib/ids"; import { metadataValueToAddress } from "../lib/list-metadata"; import { parseListOp, parseRecord, parseTagOp, slotToBytes32 } from "../lib/parse-list-op"; @@ -41,14 +35,15 @@ export default function () { await context.ensDb .insert(ensIndexerSchema.efpListRecords) .values({ - id: listRecordId(chainId, contractAddress, slot, parsed.data), + id: listRecordId(chainId, contractAddress, slot, record.record), chainId, contractAddress, slot, - record: parsed.data, + record: record.record, recordVersion: record.version, recordType: record.recordType, recordData: record.recordData, + tags: [], createdAt: ts, }) .onConflictDoNothing(); @@ -56,50 +51,38 @@ export default function () { } 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, parsed.data), + id: listRecordId(chainId, contractAddress, slot, record.record), }); - // Cascade-delete this record's tags. A record has many (record, tag) rows, so this is not - // expressible via the PK-only Store API — use the raw drizzle escape hatch. This flushes - // ponder's cache to Postgres, accepted because record removals are infrequent relative to - // additions (cf. protocol-acceleration Resolver VersionChanged). - await context.ensDb.sql - .delete(ensIndexerSchema.efpListRecordTags) - .where( - and( - eq(ensIndexerSchema.efpListRecordTags.chainId, chainId), - eq(ensIndexerSchema.efpListRecordTags.contractAddress, contractAddress), - eq(ensIndexerSchema.efpListRecordTags.slot, slot), - eq(ensIndexerSchema.efpListRecordTags.record, parsed.data.toLowerCase() as Hex), - ), - ); 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 }); + // Tags attach to a record that is in the list; ops can arrive in any order, so ignore a + // tag for an absent record. A record's tags are a set — skip duplicates. + if (!record || record.tags.includes(tagOp.tag)) return; await context.ensDb - .insert(ensIndexerSchema.efpListRecordTags) - .values({ - id: listRecordTagId(chainId, contractAddress, slot, tagOp.record, tagOp.tag), - chainId, - contractAddress, - slot, - record: tagOp.record, - tag: tagOp.tag, - createdAt: ts, - }) - .onConflictDoNothing(); + .update(ensIndexerSchema.efpListRecords, { id }) + .set({ tags: [...record.tags, tagOp.tag] }); return; } case EFP_OPCODE.REMOVE_TAG: { const tagOp = parseTagOp(parsed.data); if (!tagOp) return; - await context.ensDb.delete(ensIndexerSchema.efpListRecordTags, { - id: listRecordTagId(chainId, contractAddress, slot, tagOp.record, tagOp.tag), - }); + const id = listRecordId(chainId, contractAddress, slot, tagOp.record); + const record = await context.ensDb.find(ensIndexerSchema.efpListRecords, { id }); + if (!record || !record.tags.includes(tagOp.tag)) return; + await context.ensDb + .update(ensIndexerSchema.efpListRecords, { id }) + .set({ tags: record.tags.filter((existing) => existing !== tagOp.tag) }); return; } diff --git a/apps/ensindexer/src/plugins/efp/lib/ids.ts b/apps/ensindexer/src/plugins/efp/lib/ids.ts index 3d92e8d7c7..6da606dd81 100644 --- a/apps/ensindexer/src/plugins/efp/lib/ids.ts +++ b/apps/ensindexer/src/plugins/efp/lib/ids.ts @@ -20,17 +20,6 @@ export function listRecordId( return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${record.toLowerCase()}`; } -/** `efp_list_record_tags` key: a `(record, tag)` pair within a list. */ -export function listRecordTagId( - chainId: number, - contractAddress: Hex, - slot: Hex, - record: Hex, - tag: string, -): string { - return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${record.toLowerCase()}-${tag}`; -} - /** `efp_account_metadata` key: an `(address, key)` pair. */ export function accountMetadataId(address: Hex, key: string): string { return `${address.toLowerCase()}-${key}`; diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts index be32fcc45f..e8989bf863 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -2,10 +2,12 @@ * EFP (Ethereum Follow Protocol) abstract schema. * * Tables are prefixed `efp_` and indexed by the EFP plugin - * (`apps/ensindexer/src/plugins/efp`). The first five tables mirror the - * ethereumfollowprotocol/api-v2 reference indexer's data model; `efp_list_storage_locations` - * is added so list-metadata events can resolve the owning list NFT by primary key - * (rather than scanning `efp_lists` by storage location). + * (`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. */ @@ -80,9 +82,10 @@ export const efpListStorageLocations = onchainTable( ); /** - * One row per record currently in a list. The `record` column is the full record payload - * (`version | type | data`), so two records that decode to the same address but with different - * headers remain distinct. + * 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", @@ -92,7 +95,7 @@ export const efpListRecords = onchainTable( chainId: t.int8({ mode: "number" }).notNull(), contractAddress: t.hex().notNull(), slot: t.hex().notNull(), - /** Full record payload (`version | type | data`). */ + /** Canonical record prefix `version | type | address` (22 bytes). */ record: t.hex().notNull(), /** Decoded record header — version byte. */ recordVersion: t.integer().notNull(), @@ -100,6 +103,8 @@ export const efpListRecords = onchainTable( 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) => ({ @@ -108,30 +113,6 @@ export const efpListRecords = onchainTable( }), ); -/** - * Many-to-many between records and UTF-8 tags. - */ -export const efpListRecordTags = onchainTable( - "efp_list_record_tags", - (t) => ({ - /** Composite key "chainId-contractAddress-slot-record-tag". */ - id: t.text().primaryKey(), - chainId: t.int8({ mode: "number" }).notNull(), - contractAddress: t.hex().notNull(), - slot: t.hex().notNull(), - /** Record prefix `version | type | address` (22 bytes). */ - record: t.hex().notNull(), - /** UTF-8 tag (NUL bytes stripped). */ - tag: t.text().notNull(), - createdAt: t.bigint().notNull(), - }), - (t) => ({ - idx_slot: index().on(t.chainId, t.contractAddress, t.slot), - idx_record: index().on(t.record), - idx_tag: index().on(t.tag), - }), -); - /** * Most-recent `value` per `(address, key)` account-metadata pair (today only `primary-list`). */ From 354261387463affdee74fc772f718be6db100f65 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 17:59:14 +0200 Subject: [PATCH 15/45] fix(efp): reject ListOps, records, and storage locations with unsupported version or length The leading version byte defines each structure's decoding schema, so an unsupported version (or an out-of-spec length) must not be decoded as v1. `parseListOp` and `parseRecord` now reject any version != 1, and `parseListStorageLocation` requires version 1, locationType 1, and the exact 86-byte payload (it previously accepted >= 86 bytes of any version). A shared `EFP_VERSION` constant documents the single protocol version EFP defines today. --- apps/ensindexer/src/plugins/efp/constants.ts | 9 +++++++++ .../src/plugins/efp/lib/parse-list-op.test.ts | 12 ++++++++++++ .../src/plugins/efp/lib/parse-list-op.ts | 11 ++++++++++- .../efp/lib/parse-list-storage-location.test.ts | 14 ++++++++++++++ .../plugins/efp/lib/parse-list-storage-location.ts | 11 +++++++++-- 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/constants.ts b/apps/ensindexer/src/plugins/efp/constants.ts index b43522ec47..7e05d0e0c3 100644 --- a/apps/ensindexer/src/plugins/efp/constants.ts +++ b/apps/ensindexer/src/plugins/efp/constants.ts @@ -1,3 +1,12 @@ +/** + * The only EFP protocol version defined today. The leading `version` byte of a `ListOp`, a + * `ListRecord`, and a List Storage Location must all equal this — other versions use a schema this + * indexer doesn't understand, so they are skipped rather than decoded as v1. + * + * @see https://docs.efp.app/design/list-ops/ + */ +export const EFP_VERSION = 1; + /** * EFP `ListOp` opcodes (op version 0x01), encoded as `version | opcode | data`. * 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 index 2879952157..8e35192eb2 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts @@ -23,6 +23,12 @@ describe("parseListOp", () => { 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", () => { @@ -47,6 +53,12 @@ describe("parseRecord", () => { 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(); diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts index 8c3aaef250..fad8405a55 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -16,6 +16,8 @@ import { type Hex, isHex } from "viem"; +import { EFP_VERSION } from "../constants"; + export interface ParsedListOp { /** Top-level list-op version. Always 1 today. */ version: number; @@ -62,8 +64,13 @@ export function parseListOp(op: Hex | string | null | undefined): ParsedListOp | 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_VERSION) return null; + return { - version: parseInt(bytes.slice(0, 2), 16), + version, opcode: parseInt(bytes.slice(2, 4), 16), data: `0x${bytes.slice(4)}` as Hex, }; @@ -84,6 +91,8 @@ export function parseRecord(data: Hex | string | null | undefined): ParsedRecord 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_VERSION) return null; // EFP defines only record type 1 (a 20-byte address); types 0 and 2-255 are reserved. if (recordType !== 1) return null; 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 index b9d24f9eeb..241672a7b9 100644 --- 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 @@ -44,4 +44,18 @@ describe("parseListStorageLocation", () => { // 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(); + }); }); 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 index d42c23043e..2253a84bd1 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts @@ -9,13 +9,16 @@ * contractAddress (20 bytes) * slot (32 bytes) * - * Total: 86 bytes. Any other `locationType` is reserved/unknown and decodes to `null`. + * Total: 86 bytes. Decodes to `null` unless the payload is exactly this 86-byte, version-1, + * `locationType == 1` shape; other versions, location types, or lengths are reserved/unknown. * * @see https://docs.efp.app/design/list-storage-location/ */ import { type Hex, isHex } from "viem"; +import { EFP_VERSION } from "../constants"; + export interface ParsedListStorageLocation { version: number; chainId: bigint; @@ -42,8 +45,12 @@ export function parseListStorageLocation( const version = parseInt(bytes.slice(0, HEADER_END / 2), 16); const locationType = parseInt(bytes.slice(HEADER_END / 2, HEADER_END), 16); + // The version byte defines the payload schema, and a locationType-1 location is a fixed 86-byte + // shape; reject other versions, location types, or lengths rather than remap the list from a + // partially- or over-decoded payload. + if (version !== EFP_VERSION) return null; if (locationType !== LOCATION_TYPE_ONCHAIN) return null; - if (bytes.length < SLOT_END) return null; + if (bytes.length !== SLOT_END) return null; return { version, From 27867143a74aa354f9e0b64bd6cc9a58b61f9a89 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 17:59:14 +0200 Subject: [PATCH 16/45] fix(efp): only reflect exactly-20-byte values onto list user/manager roles The generic metadata setter can emit arbitrary bytes for the `user`/`manager` keys. `metadataValueToAddress` now returns null for any value that is not exactly 20 bytes, clearing the role rather than storing a truncated or empty `0x` address that would later surface through a GraphQL `Address`. Both call sites write the nullable `user`/`manager` columns, so a malformed value clears the role consistently on the live and pending-drain paths. --- .../src/plugins/efp/lib/list-metadata.test.ts | 21 +++++++++++++++++++ .../src/plugins/efp/lib/list-metadata.ts | 12 +++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 apps/ensindexer/src/plugins/efp/lib/list-metadata.test.ts 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 index 58cac13fc4..3f888ae3bd 100644 --- a/apps/ensindexer/src/plugins/efp/lib/list-metadata.ts +++ b/apps/ensindexer/src/plugins/efp/lib/list-metadata.ts @@ -1,9 +1,13 @@ import type { Hex } from "viem"; /** - * Interpret an EFP list-metadata `value` payload as an address: the well-known `user` and - * `manager` metadata keys carry a 20-byte address in the leading bytes of the value. + * 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 { - return `0x${value.slice(2, 42).toLowerCase()}` as Hex; +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; } From 82e630b7a19ee39d2dfe77e5530725803151397d Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 18:53:22 +0200 Subject: [PATCH 17/45] fix(efp): enforce each schema's own version byte; name LSL offsets explicitly The list-op, list-record, and List Storage Location payloads each carry an independent leading version byte that EFP can bump separately, so the single EFP_VERSION constant is replaced by EFP_LIST_OP_VERSION, EFP_RECORD_VERSION, and EFP_LSL_VERSION (all 1 today) and each decoder enforces its own. Also rename the LSL decoder's HEX_BYTES to HEX_CHARS_PER_BYTE and give each field an explicit named hex-char offset so the fixed 86-byte layout is auditable by inspection. --- apps/ensindexer/src/plugins/efp/constants.ts | 11 ++++--- .../src/plugins/efp/lib/parse-list-op.ts | 10 +++--- .../efp/lib/parse-list-storage-location.ts | 33 +++++++++++-------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/constants.ts b/apps/ensindexer/src/plugins/efp/constants.ts index 7e05d0e0c3..c868679013 100644 --- a/apps/ensindexer/src/plugins/efp/constants.ts +++ b/apps/ensindexer/src/plugins/efp/constants.ts @@ -1,11 +1,14 @@ /** - * The only EFP protocol version defined today. The leading `version` byte of a `ListOp`, a - * `ListRecord`, and a List Storage Location must all equal this — other versions use a schema this - * indexer doesn't understand, so they are skipped rather than decoded as v1. + * 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_VERSION = 1; +export const EFP_LIST_OP_VERSION = 1; +export const EFP_RECORD_VERSION = 1; +export const EFP_LSL_VERSION = 1; /** * EFP `ListOp` opcodes (op version 0x01), encoded as `version | opcode | data`. diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts index fad8405a55..c760803ebb 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -8,15 +8,15 @@ * * 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. + * `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_VERSION } from "../constants"; +import { EFP_LIST_OP_VERSION, EFP_RECORD_VERSION } from "../constants"; export interface ParsedListOp { /** Top-level list-op version. Always 1 today. */ @@ -67,7 +67,7 @@ export function parseListOp(op: Hex | string | null | undefined): ParsedListOp | 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_VERSION) return null; + if (version !== EFP_LIST_OP_VERSION) return null; return { version, @@ -92,7 +92,7 @@ export function parseRecord(data: Hex | string | null | undefined): ParsedRecord 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_VERSION) return null; + if (version !== EFP_RECORD_VERSION) return null; // EFP defines only record type 1 (a 20-byte address); types 0 and 2-255 are reserved. if (recordType !== 1) return null; 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 index 2253a84bd1..60f7a7b733 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts @@ -1,7 +1,7 @@ /** * Decoder for EFP `UpdateListStorageLocation.listStorageLocation` payloads. * - * EFP defines a single location type — `locationType == 1` (onchain EVM contract): + * EFP defines a single location type, `locationType == 1` (onchain EVM contract): * * version (1 byte) * locationType (1 byte) // == 0x01 @@ -17,7 +17,7 @@ import { type Hex, isHex } from "viem"; -import { EFP_VERSION } from "../constants"; +import { EFP_LSL_VERSION } from "../constants"; export interface ParsedListStorageLocation { version: number; @@ -26,11 +26,16 @@ export interface ParsedListStorageLocation { slot: Hex; } -const HEX_BYTES = 2; -const HEADER_END = 2 * HEX_BYTES; // version + locationType -const CHAIN_END = HEADER_END + 32 * HEX_BYTES; -const ADDRESS_END = CHAIN_END + 20 * HEX_BYTES; -const SLOT_END = ADDRESS_END + 32 * HEX_BYTES; +/** 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; @@ -39,23 +44,23 @@ export function parseListStorageLocation( lsl: Hex | string | null | undefined, ): ParsedListStorageLocation | null { if (!lsl || typeof lsl !== "string" || !isHex(lsl)) return null; - if (lsl.length < HEADER_END + 2) return null; // need at least version + locationType + if (lsl.length < LOCATION_TYPE_END + 2) return null; // "0x" + version + locationType const bytes = lsl.slice(2); - const version = parseInt(bytes.slice(0, HEADER_END / 2), 16); - const locationType = parseInt(bytes.slice(HEADER_END / 2, HEADER_END), 16); + 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, and a locationType-1 location is a fixed 86-byte // shape; reject other versions, location types, or lengths rather than remap the list from a // partially- or over-decoded payload. - if (version !== EFP_VERSION) return null; + if (version !== EFP_LSL_VERSION) return null; if (locationType !== LOCATION_TYPE_ONCHAIN) return null; if (bytes.length !== SLOT_END) return null; return { version, - chainId: BigInt(`0x${bytes.slice(HEADER_END, CHAIN_END)}`), - contractAddress: `0x${bytes.slice(CHAIN_END, ADDRESS_END).toLowerCase()}` as Hex, - slot: `0x${bytes.slice(ADDRESS_END, SLOT_END).toLowerCase()}` as Hex, + chainId: BigInt(`0x${bytes.slice(LOCATION_TYPE_END, CHAIN_ID_END)}`), + contractAddress: `0x${bytes.slice(CHAIN_ID_END, CONTRACT_END).toLowerCase()}` as Hex, + slot: `0x${bytes.slice(CONTRACT_END, SLOT_END).toLowerCase()}` as Hex, }; } From f4f9232fa0a8e1636e85ddfa6a8cdcd0f4a75c40 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 18:53:22 +0200 Subject: [PATCH 18/45] fix(efp): drop list rows on NFT burn instead of storing a zero-address owner ERC-721 emits Transfer(to=0) on a burn; the handler upserted that as owner = 0x00..00, which then surfaced through EfpList.owner (non-null Address) and lists(where: { owner }). Detect a burn (to == zeroAddress) and delete the list row plus its storage-location reverse mapping instead. --- .../src/plugins/efp/handlers/ListRegistry.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts index f1a77b78a5..6aed9eaf21 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -1,4 +1,4 @@ -import type { Hex } from "viem"; +import { type Hex, isAddressEqual, zeroAddress } from "viem"; import { PluginName } from "@ensnode/ensnode-sdk"; @@ -22,8 +22,30 @@ export default function () { async ({ context, event }) => { const ts = event.block.timestamp; const tokenId = event.args.tokenId.toString(); - const owner = event.args.to.toLowerCase() as Hex; + // 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 }); + return; + } + + const owner = event.args.to.toLowerCase() as Hex; await context.ensDb .insert(ensIndexerSchema.efpLists) .values({ From 739c52bc40c126181c693371090f3ef1cf939273 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 18:53:22 +0200 Subject: [PATCH 19/45] fix(efp): warn on tag ops referencing an absent record Within a (chain, contract, slot) EFP ops are indexed in on-chain order, so an ADD_TAG/REMOVE_TAG for a missing record means the record was removed earlier or, anomalously, never added. Log a warning rather than dropping it silently, and correct the comment that wrongly cited out-of-order arrival as the rationale. --- .../src/plugins/efp/handlers/ListRecords.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts index 56df7d3b4d..bb26d93795 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -3,6 +3,7 @@ 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"; @@ -65,9 +66,15 @@ export default function () { if (!tagOp) return; const id = listRecordId(chainId, contractAddress, slot, tagOp.record); const record = await context.ensDb.find(ensIndexerSchema.efpListRecords, { id }); - // Tags attach to a record that is in the list; ops can arrive in any order, so ignore a - // tag for an absent record. A record's tags are a set — skip duplicates. - if (!record || record.tags.includes(tagOp.tag)) return; + // 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] }); @@ -79,7 +86,13 @@ export default function () { if (!tagOp) return; const id = listRecordId(chainId, contractAddress, slot, tagOp.record); const record = await context.ensDb.find(ensIndexerSchema.efpListRecords, { id }); - if (!record || !record.tags.includes(tagOp.tag)) return; + 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) }); From f2f798b5c2f0180a1873a77ccdcd4049d771db96 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 18:53:22 +0200 Subject: [PATCH 20/45] fix(ensapi): compare primary-list user case-insensitively primaryList lower-cased only the stored user before comparing it to the requested address. The Address scalar already normalizes the argument, but lower-casing both sides removes the asymmetry and keeps validation independent of input casing. --- apps/ensapi/src/omnigraph-api/schema/efp.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp.ts b/apps/ensapi/src/omnigraph-api/schema/efp.ts index a62974ec9f..5e9c54d66f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.ts @@ -200,7 +200,9 @@ EfpQueryRef.implement({ .from(ensIndexerSchema.efpLists) .where(eq(ensIndexerSchema.efpLists.tokenId, tokenId)) .limit(1); - if (!list || list.user?.toLowerCase() !== args.address) return null; + // Compare case-insensitively: although the Address scalar already normalizes `args.address`, + // lower-casing both sides keeps validation independent of input casing. + if (!list?.user || list.user.toLowerCase() !== args.address.toLowerCase()) return null; return tokenId; }, From 4e82fe45b4847d0b7095842c2d4084b34a7bde9f Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 19:11:52 +0200 Subject: [PATCH 21/45] refactor(ensapi): extract validated primary-list resolution to a shared helper Move the `primary-list` decode and two-step user-role validation out of the root `efp.primaryList` resolver into `resolveValidatedPrimaryListTokenId`, so `Account.efp.primaryList` (next commit) reuses the same logic. No behavior change. --- .../omnigraph-api/schema/efp-primary-list.ts | 61 +++++++++++++++++++ apps/ensapi/src/omnigraph-api/schema/efp.ts | 48 +-------------- 2 files changed, 63 insertions(+), 46 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts 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..4354d8fcbc --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts @@ -0,0 +1,61 @@ +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"; + +/** + * Decode a `primary-list` account-metadata value (an abi-encoded `uint256` token id) into a decimal + * token-id string, or `null` if it isn't a well-formed value. + */ +function decodePrimaryListTokenId(value: Hex): string | null { + if (!value || value === "0x") 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.ts b/apps/ensapi/src/omnigraph-api/schema/efp.ts index 5e9c54d66f..bf82237ef6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.ts @@ -16,22 +16,7 @@ import { } 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"; - -/** The EFP AccountMetadata key whose value is an account's primary-list token id. */ -const EFP_PRIMARY_LIST_KEY = "primary-list"; - -/** - * Decode a `primary-list` account-metadata value (an abi-encoded `uint256` token id) into a - * decimal token-id string, or `null` if it isn't a well-formed value. - */ -function decodePrimaryListTokenId(value: Hex): string | null { - if (!value || value === "0x") return null; - try { - return BigInt(value).toString(); - } catch { - return null; - } -} +import { resolveValidatedPrimaryListTokenId } from "@/omnigraph-api/schema/efp-primary-list"; /** * `EfpQuery` namespaces all Ethereum Follow Protocol (EFP) queries under a single root `efp` field, @@ -176,36 +161,7 @@ EfpQueryRef.implement({ type: EfpListRef, nullable: true, args: { address: t.arg({ type: "Address", required: true }) }, - resolve: async (_parent, args) => { - const { ensDb, ensIndexerSchema } = di.context; - - const [metadata] = await ensDb - .select({ value: ensIndexerSchema.efpAccountMetadata.value }) - .from(ensIndexerSchema.efpAccountMetadata) - .where( - eq( - ensIndexerSchema.efpAccountMetadata.id, - efpAccountMetadataId(args.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. - const [list] = await ensDb - .select({ user: ensIndexerSchema.efpLists.user }) - .from(ensIndexerSchema.efpLists) - .where(eq(ensIndexerSchema.efpLists.tokenId, tokenId)) - .limit(1); - // Compare case-insensitively: although the Address scalar already normalizes `args.address`, - // lower-casing both sides keeps validation independent of input casing. - if (!list?.user || list.user.toLowerCase() !== args.address.toLowerCase()) return null; - - return tokenId; - }, + resolve: (_parent, args) => resolveValidatedPrimaryListTokenId(args.address), }), }), }); From c566222e43a34b6273a0bb7a4b27f055d3ed6795 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 19:11:52 +0200 Subject: [PATCH 22/45] feat(ensapi): add account-rooted EFP access via Account.efp and EfpListRecord.account Add `Account.efp` (an account's validated `primaryList` and the `lists` it is the `user` of) and an `EfpListRecord.account` edge that resolves a record's target address to its `Account`. Together they let a single Omnigraph query walk from an account to whom it follows and on into their ENS names and own EFP lists, while the root `efp` namespace stays the protocol-rooted entry point. --- .changeset/efp-omnigraph.md | 2 +- apps/ensapi/src/omnigraph-api/schema.ts | 1 + .../src/omnigraph-api/schema/account-efp.ts | 62 +++++++++++++++++++ .../src/omnigraph-api/schema/account.ts | 12 ++++ .../omnigraph-api/schema/efp-list-record.ts | 12 ++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/account-efp.ts diff --git a/.changeset/efp-omnigraph.md b/.changeset/efp-omnigraph.md index 3daa3502c2..bea792771d 100644 --- a/.changeset/efp-omnigraph.md +++ b/.changeset/efp-omnigraph.md @@ -3,4 +3,4 @@ "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). Requires the `efp` plugin to be enabled on the indexer. +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` resolves a record's target address to its `Account`, so one query can walk from an account to whom it follows and on into their ENS names and EFP lists. Requires the `efp` plugin to be enabled on the indexer. diff --git a/apps/ensapi/src/omnigraph-api/schema.ts b/apps/ensapi/src/omnigraph-api/schema.ts index 0f070fc811..9de410e510 100644 --- a/apps/ensapi/src/omnigraph-api/schema.ts +++ b/apps/ensapi/src/omnigraph-api/schema.ts @@ -1,5 +1,6 @@ import { builder } from "@/omnigraph-api/builder"; +import "./schema/account-efp"; import "./schema/account-id"; import "./schema/connection"; import "./schema/domain"; 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..61e6dfab01 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/account-efp.ts @@ -0,0 +1,62 @@ +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 { orderPaginationBy, paginateBy } 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, paginateBy(ensIndexerSchema.efpLists.tokenId, before, after))) + .orderBy(orderPaginationBy(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 1783d89bb2..778e49809b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -9,6 +9,7 @@ import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domain import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { AccountEfpRef } from "@/omnigraph-api/schema/account-efp"; import { AccountIdInput } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; @@ -94,6 +95,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-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts index 0b08c3d95f..fb6e481c02 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -4,6 +4,7 @@ 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"; @@ -130,5 +131,16 @@ EfpListRecordRef.implement({ return mapping?.tokenId ?? 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, + }), }), }); From 777b00d2ffa62c3712000825dafdc89db8d95b90 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 19:11:52 +0200 Subject: [PATCH 23/45] chore(enssdk): regenerate Omnigraph SDL and introspection for account-rooted EFP Output of `pnpm generate`. --- .../src/omnigraph/generated/introspection.ts | 155 ++++++++++++++++++ .../src/omnigraph/generated/schema.graphql | 34 ++++ 2 files changed, 189 insertions(+) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index a45508d19c..d5047311f1 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -98,6 +98,18 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "efp", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "AccountEfp" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "events", "type": { @@ -408,6 +420,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", @@ -3524,6 +3670,15 @@ const introspection = { "kind": "OBJECT", "name": "EfpListRecord", "fields": [ + { + "name": "account", + "type": { + "kind": "OBJECT", + "name": "Account" + }, + "args": [], + "isDeprecated": false + }, { "name": "chainId", "type": { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index fa59e03131..cd18cbdfa4 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -6,6 +6,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`). """ @@ -59,6 +64,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! @@ -792,6 +821,11 @@ type EfpList { 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! From 06b1de1454458cd5a7f85663ff644c09955f48d5 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 19:23:34 +0200 Subject: [PATCH 24/45] fix(efp): clear list user/manager roles when the storage location moves The `user`/`manager` roles come from `UpdateListMetadata` events scoped to a storage location. When `UpdateListStorageLocation` moves a list to a different `(chainId, contract, slot)`, the old roles no longer apply, so clear them in the same update; pending metadata for the new location repopulates them in the drain step. Without this a moved list kept the previous location's `user`, which `Account.efp.lists` and primary-list validation would wrongly attribute to that account until new metadata arrived. --- apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts index 6aed9eaf21..8239d78ca4 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -77,6 +77,7 @@ export default function () { // mapping. (Relies on the mint Transfer preceding this event — both fire on the ListRegistry // on Base, so the list row already exists.) const existing = await context.ensDb.find(ensIndexerSchema.efpLists, { tokenId }); + let moved = false; if ( existing?.listStorageLocationChainId != null && existing.listStorageLocationContractAddress != null && @@ -88,6 +89,7 @@ export default function () { existing.listStorageLocationSlot, ); if (oldLocationId !== newLocationId) { + moved = true; await context.ensDb.delete(ensIndexerSchema.efpListStorageLocations, { id: oldLocationId, }); @@ -99,6 +101,10 @@ export default function () { 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, }); From 74900d0cf66bd1605433b646707b35769825c648 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 19:27:53 +0200 Subject: [PATCH 25/45] chore(ensapi): scope the EFP Omnigraph changeset to delivered behavior The `EfpListRecord.account` edge links a record to its `Account` but does not yet resolve a usable Account for addresses with no ENS presence (most EFP followees); whether to make that universal is an open question raised on the PR. Drop the changeset claim of a universal cross-user walk so the release notes match what ships. --- .changeset/efp-omnigraph.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/efp-omnigraph.md b/.changeset/efp-omnigraph.md index bea792771d..d2e49c993a 100644 --- a/.changeset/efp-omnigraph.md +++ b/.changeset/efp-omnigraph.md @@ -3,4 +3,4 @@ "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` resolves a record's target address to its `Account`, so one query can walk from an account to whom it follows and on into their ENS names and EFP lists. Requires the `efp` plugin to be enabled on the indexer. +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. From f07849a184d6bc63fb141f518da20b10f7b9c97c Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 19:49:31 +0200 Subject: [PATCH 26/45] fix(ensapi): reject malformed primary-list metadata (require a 32-byte uint256) `decodePrimaryListTokenId` coerced any non-empty hex via `BigInt`, so a malformed `primary-list` value such as `0x01` resolved to token 1 and `efp.primaryList` / `Account.efp.primaryList` could report a primary list for invalid metadata whenever that list's `user` matched. EFP defines `primary-list` as `abi.encodePacked(uint256)` (exactly 32 bytes), so reject any other length before converting. Adds a unit test for the decoder. --- .../schema/efp-primary-list.test.ts | 21 +++++++++++++++++++ .../omnigraph-api/schema/efp-primary-list.ts | 13 ++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp-primary-list.test.ts 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 index 4354d8fcbc..6635c359d5 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-primary-list.ts @@ -8,12 +8,17 @@ 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 (an abi-encoded `uint256` token id) into a decimal - * token-id string, or `null` if it isn't a well-formed value. + * 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. */ -function decodePrimaryListTokenId(value: Hex): string | null { - if (!value || value === "0x") return null; +export function decodePrimaryListTokenId(value: Hex): string | null { + if (value.length !== PRIMARY_LIST_VALUE_HEX_LENGTH) return null; try { return BigInt(value).toString(); } catch { From 660305cc3aa0db332a400bb97b6049b7f635e5eb Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 20:09:03 +0200 Subject: [PATCH 27/45] fix(efp): lowercase and validate parsed record/tag prefixes; tidy LSL length guard parseRecord lowercases the canonical record and recordData (matching the List Storage Location decoder), so ADD vs REMOVE/tag ops and the API recordData filter key into the same rows even if a payload carries uppercase hex. parseTagOp now validates and canonicalizes its 22-byte prefix through parseRecord (version/type checked, lowercased) rather than slicing raw bytes, so a tag for a non-address record is rejected outright instead of silently missing a row. Drops the redundant early-length guard in the LSL decoder for the exact-length check up front. Adds parser tests. --- .../src/plugins/efp/lib/parse-list-op.test.ts | 20 +++++++++++++++++++ .../src/plugins/efp/lib/parse-list-op.ts | 18 +++++++++++------ .../efp/lib/parse-list-storage-location.ts | 11 +++++----- 3 files changed, 38 insertions(+), 11 deletions(-) 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 index 8e35192eb2..bf730b4734 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.test.ts @@ -43,6 +43,16 @@ describe("parseRecord", () => { }); }); + 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(); @@ -86,6 +96,16 @@ describe("parseTagOp", () => { 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(); diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts index c760803ebb..0917f4551b 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -106,29 +106,35 @@ export function parseRecord(data: Hex | string | null | undefined): ParsedRecord return { version, recordType, - record: `0x${bytes.slice(0, RECORD_PREFIX_HEX_LENGTH)}` as Hex, - recordData: `0x${body}` as Hex, + // 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. + * `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; - const record = data.slice(0, RECORD_PREFIX_WITH_0X_LENGTH) as Hex; - const tagHex = data.slice(RECORD_PREFIX_WITH_0X_LENGTH); + // 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, tag }; + return { record: parsed.record, tag }; } /** 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 index 60f7a7b733..e37339dd8f 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts @@ -44,18 +44,19 @@ export function parseListStorageLocation( lsl: Hex | string | null | undefined, ): ParsedListStorageLocation | null { if (!lsl || typeof lsl !== "string" || !isHex(lsl)) return null; - if (lsl.length < LOCATION_TYPE_END + 2) return null; // "0x" + version + locationType 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, and a locationType-1 location is a fixed 86-byte - // shape; reject other versions, location types, or lengths rather than remap the list from a - // partially- or over-decoded payload. + // 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; - if (bytes.length !== SLOT_END) return null; return { version, From a61a5065215f317c65b38706626d5652f88d330a Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 20:09:03 +0200 Subject: [PATCH 28/45] fix(efp): clear stale location on undecodable storage-location update; document burn semantics UpdateListStorageLocation now finds the list row first and guards on its presence (skip rather than update a missing row), and when the new payload is undecodable (future version, non-onchain type, or malformed) it clears the stale decoded location, its reverse mapping, and its location-scoped roles, logs a warning, and keeps the raw payload, rather than leaving the list resolving its old slot. Documents that a burned list keeps its efp_list_records rows (they mirror the on-chain ListRecords contract; the list back-ref resolves to null). --- .../src/plugins/efp/handlers/ListRegistry.ts | 71 +++++++++++++------ 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts index 8239d78ca4..39514251e0 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -3,6 +3,7 @@ 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"; @@ -42,6 +43,9 @@ export default function () { }); } 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; } @@ -64,36 +68,63 @@ export default function () { addOnchainEventListener( namespaceContract(pluginName, "ListRegistry:UpdateListStorageLocation"), async ({ context, event }) => { - const parsed = parseListStorageLocation(event.args.listStorageLocation); - if (!parsed) return; - const ts = event.block.timestamp; const tokenId = event.args.tokenId.toString(); - 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. (Relies on the mint Transfer preceding this event — both fire on the ListRegistry - // on Base, so the list row already exists.) + // 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 }); - let moved = false; - if ( - existing?.listStorageLocationChainId != null && + if (!existing) return; + + const oldLocationId = + existing.listStorageLocationChainId != null && existing.listStorageLocationContractAddress != null && existing.listStorageLocationSlot != null - ) { - const oldLocationId = storageLocationId( - existing.listStorageLocationChainId, - existing.listStorageLocationContractAddress, - existing.listStorageLocationSlot, - ); - if (oldLocationId !== newLocationId) { - moved = true; + ? 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({ From 9206d433f3fa4e2e36f728cfb7f35b1d7fcd13bf Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 20:09:03 +0200 Subject: [PATCH 29/45] docs(efp): clarify clear-on-malformed roles and bounded pending-metadata rows Comment why a malformed user/manager value clears the role (faithful to on-chain state), and note in the schema that pending-metadata rows for a slot no list ever points at are a bounded, low-volume artifact. --- apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts | 5 ++++- packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts index bb26d93795..7456520078 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -118,6 +118,9 @@ export default function () { const chainId = context.chain.id; const contractAddress = event.log.address.toLowerCase() as Hex; const slot = slotToBytes32(event.args.slot); + // `metadataValueToAddress` returns null for a non-20-byte value, which is written through + // below to clear the role: a malformed `user`/`manager` value is no longer a valid address, + // so reflecting "no role" is faithful to on-chain state (intentional clear-on-malformed). const address = metadataValueToAddress(event.args.value); const mapping = await context.ensDb.find(ensIndexerSchema.efpListStorageLocations, { @@ -135,7 +138,7 @@ export default function () { return; } - // No list points at this storage location yet — stage it for the storage-location handler. + // No list points at this storage location yet; stage it for the storage-location handler. const id = pendingListMetadataId(chainId, contractAddress, slot, key); await context.ensDb .insert(ensIndexerSchema.efpPendingListMetadata) diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts index e8989bf863..c9c2ef2e3a 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -141,8 +141,10 @@ export const efpAccountMetadata = onchainTable( * Staging area for `UpdateListMetadata` (`user`/`manager`) events that arrive before the matching * list row's storage location is known. `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. The - * storage-location handler drains matching rows when it runs. + * `ListRegistry` contract (a different contract, sometimes on a different chain). The + * storage-location handler drains matching rows when it runs. Rows staged for a slot that no list + * NFT ever points at are never drained; this is a bounded, low-volume artifact (at most one row + * per abandoned slot and key), not a growth concern in practice. */ export const efpPendingListMetadata = onchainTable( "efp_pending_list_metadata", From cd32b6dbe94dac723fdaa13ace4ca6ee0a19774c Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 20:09:03 +0200 Subject: [PATCH 30/45] docs(ensapi): reword EfpListRecord.recordData description Drop the "valid for address records" hedge: the field is a non-null Address and EFP indexes only recordType 1. Regenerated SDL. --- apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts | 3 ++- packages/enssdk/src/omnigraph/generated/schema.graphql | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts index fb6e481c02..bf224ad9d1 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -88,7 +88,8 @@ EfpListRecordRef.implement({ // EfpListRecord.recordData ////////////////////////////// recordData: t.field({ - description: "The followed/target address. Valid for address records (recordType 1).", + 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, diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index cd18cbdfa4..d67b66f6b0 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -840,7 +840,9 @@ type EfpListRecord { """The full record payload (version | type | data).""" record: Hex! - """The followed/target address. Valid for address records (recordType 1).""" + """ + 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).""" From 1f2e5140f4771bea8fcc54c116895e0b641cc930 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 22:16:23 +0200 Subject: [PATCH 31/45] fix(efp): reject storage-location chain ids outside the safe integer range An UpdateListStorageLocation payload carries an opaque 32-byte chain id; Number(chainId) then fed an out-of-range value into the int8 columns, which could lose precision (above 2^53) or overflow (above 2^63) and crash the handler, halting indexing from one bad update. The decoder now rejects a chain id outside (0, 2^53 - 1], so the handler's existing undecodable-location branch clears and warns instead. Safe-but-unindexed chain ids are kept (they just yield empty records); only unsafe values are rejected. Adds a test. --- .../efp/lib/parse-list-storage-location.test.ts | 15 +++++++++++++++ .../efp/lib/parse-list-storage-location.ts | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 index 241672a7b9..fdc8550ac5 100644 --- 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 @@ -58,4 +58,19 @@ describe("parseListStorageLocation", () => { 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(); + }); }); 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 index e37339dd8f..7144a7a975 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-storage-location.ts @@ -10,7 +10,8 @@ * slot (32 bytes) * * Total: 86 bytes. Decodes to `null` unless the payload is exactly this 86-byte, version-1, - * `locationType == 1` shape; other versions, location types, or lengths are reserved/unknown. + * `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/ */ @@ -40,6 +41,9 @@ const SLOT_END = CONTRACT_END + 32 * HEX_CHARS_PER_BYTE; // slot (32 bytes); als /** 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 { @@ -58,9 +62,15 @@ export function parseListStorageLocation( 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: BigInt(`0x${bytes.slice(LOCATION_TYPE_END, CHAIN_ID_END)}`), + chainId, contractAddress: `0x${bytes.slice(CHAIN_ID_END, CONTRACT_END).toLowerCase()}` as Hex, slot: `0x${bytes.slice(CONTRACT_END, SLOT_END).toLowerCase()}` as Hex, }; From 49dee93174e5609246e37133cf7e98ada7e193b4 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 22:16:23 +0200 Subject: [PATCH 32/45] fix(efp): make list role metadata durable per storage location user/manager metadata is keyed by storage location, but it was stored transiently: efp_pending_list_metadata was drained-and-deleted, and UpdateListMetadata skipped recording it when a mapping already existed. Combined with clearing roles on a re-point, a list that moved away and back to a slot (or a second list reusing a slot) lost its roles with nothing to restore. The per-location metadata is now recorded durably on every UpdateListMetadata and re-applied to whichever list points at the slot on each (re-)point. Renamed efp_pending_list_metadata to efp_list_metadata since it is no longer transient staging. --- apps/ensindexer/src/plugins/efp/README.md | 5 ++- .../src/plugins/efp/handlers/ListRecords.ts | 43 ++++++++++--------- .../src/plugins/efp/handlers/ListRegistry.ts | 17 +++++--- apps/ensindexer/src/plugins/efp/lib/ids.ts | 4 +- .../src/ensindexer-abstract/efp.schema.ts | 19 ++++---- 5 files changed, 47 insertions(+), 41 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/README.md b/apps/ensindexer/src/plugins/efp/README.md index b8c8872256..cca6781486 100644 --- a/apps/ensindexer/src/plugins/efp/README.md +++ b/apps/ensindexer/src/plugins/efp/README.md @@ -23,8 +23,9 @@ Contract coordinates live in the `EFPBase` / `EFPOptimism` / `EFPEthereum` datas - `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_pending_list_metadata` — staging for `user`/`manager` updates that arrive before the list's - storage location is known (the `ListRecords` and `ListRegistry` contracts emit independently). +- `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 diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts index 7456520078..f193953863 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRecords.ts @@ -7,7 +7,7 @@ import { logger } from "@/lib/logger"; import { namespaceContract } from "@/lib/plugin-helpers"; import { EFP_LIST_METADATA_KEYS, EFP_OPCODE } from "../constants"; -import { listRecordId, pendingListMetadataId, storageLocationId } from "../lib/ids"; +import { listMetadataId, listRecordId, storageLocationId } from "../lib/ids"; import { metadataValueToAddress } from "../lib/list-metadata"; import { parseListOp, parseRecord, parseTagOp, slotToBytes32 } from "../lib/parse-list-op"; @@ -118,32 +118,33 @@ export default function () { const chainId = context.chain.id; const contractAddress = event.log.address.toLowerCase() as Hex; const slot = slotToBytes32(event.args.slot); - // `metadataValueToAddress` returns null for a non-20-byte value, which is written through - // below to clear the role: a malformed `user`/`manager` value is no longer a valid address, - // so reflecting "no role" is faithful to on-chain state (intentional clear-on-malformed). - const address = metadataValueToAddress(event.args.value); + // 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; - if (mapping) { - await context.ensDb - .update(ensIndexerSchema.efpLists, { tokenId: mapping.tokenId }) - .set( - key === EFP_LIST_METADATA_KEYS.USER - ? { user: address, updatedAt: ts } - : { manager: address, updatedAt: ts }, - ); - return; - } - - // No list points at this storage location yet; stage it for the storage-location handler. - const id = pendingListMetadataId(chainId, contractAddress, slot, key); + const address = metadataValueToAddress(event.args.value); await context.ensDb - .insert(ensIndexerSchema.efpPendingListMetadata) - .values({ id, chainId, contractAddress, slot, key, value: event.args.value, createdAt: ts }) - .onConflictDoUpdate({ value: event.args.value, createdAt: ts }); + .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 index 39514251e0..92520d9886 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -7,7 +7,7 @@ import { logger } from "@/lib/logger"; import { namespaceContract } from "@/lib/plugin-helpers"; import { EFP_LIST_METADATA_KEYS } from "../constants"; -import { pendingListMetadataId, storageLocationId } from "../lib/ids"; +import { listMetadataId, storageLocationId } from "../lib/ids"; import { metadataValueToAddress } from "../lib/list-metadata"; import { parseListStorageLocation } from "../lib/parse-list-storage-location"; @@ -144,13 +144,17 @@ export default function () { .values({ id: newLocationId, chainId, contractAddress, slot, tokenId, updatedAt: ts }) .onConflictDoUpdate({ tokenId, updatedAt: ts }); - // Drain any user/manager metadata staged before this storage location was known. + // (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 id = pendingListMetadataId(chainId, contractAddress, slot, key); - const pending = await context.ensDb.find(ensIndexerSchema.efpPendingListMetadata, { id }); - if (!pending) continue; + const meta = await context.ensDb.find(ensIndexerSchema.efpListMetadata, { + id: listMetadataId(chainId, contractAddress, slot, key), + }); + if (!meta) continue; - const address = metadataValueToAddress(pending.value); + const address = metadataValueToAddress(meta.value); await context.ensDb .update(ensIndexerSchema.efpLists, { tokenId }) .set( @@ -158,7 +162,6 @@ export default function () { ? { user: address, updatedAt: ts } : { manager: address, updatedAt: ts }, ); - await context.ensDb.delete(ensIndexerSchema.efpPendingListMetadata, { id }); } }, ); diff --git a/apps/ensindexer/src/plugins/efp/lib/ids.ts b/apps/ensindexer/src/plugins/efp/lib/ids.ts index 6da606dd81..8cab3e89aa 100644 --- a/apps/ensindexer/src/plugins/efp/lib/ids.ts +++ b/apps/ensindexer/src/plugins/efp/lib/ids.ts @@ -25,8 +25,8 @@ export function accountMetadataId(address: Hex, key: string): string { return `${address.toLowerCase()}-${key}`; } -/** `efp_pending_list_metadata` key: a staged metadata `(storage location, key)`. */ -export function pendingListMetadataId( +/** `efp_list_metadata` key: per-location metadata `(storage location, key)`. */ +export function listMetadataId( chainId: number, contractAddress: Hex, slot: Hex, diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts index c9c2ef2e3a..4eab7c9754 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -138,16 +138,17 @@ export const efpAccountMetadata = onchainTable( ); /** - * Staging area for `UpdateListMetadata` (`user`/`manager`) events that arrive before the matching - * list row's storage location is known. `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). The - * storage-location handler drains matching rows when it runs. Rows staged for a slot that no list - * NFT ever points at are never drained; this is a bounded, low-volume artifact (at most one row - * per abandoned slot and key), not a growth concern in practice. + * 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 efpPendingListMetadata = onchainTable( - "efp_pending_list_metadata", +export const efpListMetadata = onchainTable( + "efp_list_metadata", (t) => ({ /** Composite key "chainId-contractAddress-slot-key". */ id: t.text().primaryKey(), From 4d0c6631943a0d9e52e867aa614a5e2073569761 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Fri, 29 May 2026 22:32:18 +0200 Subject: [PATCH 33/45] fix(ensapi): serve the Omnigraph API for EFP-only configs The Omnigraph endpoint was gated on `unigraph`/`ensv2`, so `PLUGINS=efp` (or `subgraph,efp`) returned 503 and the indexed EFP data was unqueryable, since the `efp` namespace is its only API surface. The shared `hasOmnigraphApiConfigSupport` prerequisite (in ensnode-sdk, also consumed by ensadmin) now also accepts `efp`, keeping EFP independent of Unigraph as intended. With EFP alone the ENS query fields are present but return no data. Adds a unit test for the gate. --- .../src/omnigraph-api/prerequisites.test.ts | 29 +++++++++++++++++++ .../src/omnigraph-api/prerequisites.ts | 11 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 packages/ensnode-sdk/src/omnigraph-api/prerequisites.test.ts 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.`, }; } From 65a3c323b6b9f8c4083765d742edc79e4d56ef58 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 08:55:46 +0200 Subject: [PATCH 34/45] feat(datasources): add EFP datasources to the ens-test-env namespace Define EFPBase, EFPOptimism, and EFPEthereum datasources for the ens-test-env (devnet) namespace, all pointing at the single Anvil chain (id 31337) where the EFP contracts are deployed. This unlocks the `efp` plugin on ens-test-env via the existing required-datasource check; the plugin's per-chain-id Ponder config then collapses the three datasources onto chain 31337 (their shared ListRecords address makes this a no-op merge rather than triple-indexing). Addresses are captured from the EFP devnet image deployed in attach mode on top of the pinned contracts-v2 ENS deployment, and verified stable across clean redeploys. Add a config test asserting `efp` activates on ens-test-env and indexes one chain. --- .changeset/efp-plugin.md | 2 +- apps/ensindexer/src/config/config.test.ts | 14 +++++ packages/datasources/src/devnet/constants.ts | 21 +++++++ packages/datasources/src/ens-test-env.ts | 64 +++++++++++++++++++- 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.changeset/efp-plugin.md b/.changeset/efp-plugin.md index 5042aeb03e..cd5308d240 100644 --- a/.changeset/efp-plugin.md +++ b/.changeset/efp-plugin.md @@ -5,4 +5,4 @@ "ensindexer": minor --- -Add an EFP (Ethereum Follow Protocol) indexer plugin. Enable it by including `efp` in the `PLUGINS` environment variable (mainnet ENS namespace) to index EFP list NFTs, records, tags, and account metadata into ENSDb's `efp_*` tables. +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/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/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index e1c942f07f..556694b186 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -93,6 +93,27 @@ 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. Only the three contracts the EFP plugin indexes are listed. + * + * 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", +} 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 diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/ens-test-env.ts index f864cd921d..54d8fa0cfc 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -2,6 +2,10 @@ import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; import { Registry } from "./abis/ensv2/Registry"; import { UniversalResolverV2 } from "./abis/ensv2/UniversalResolverV2"; +// 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"; // ABIs for ENSRoot Datasource import { BaseRegistrar as root_BaseRegistrar } from "./abis/root/BaseRegistrar"; import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } from "./abis/root/LegacyEthRegistrarController"; @@ -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; From f7abccafbf1110d70b23f5eb5b84e7e664c97d81 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 08:55:54 +0200 Subject: [PATCH 35/45] feat(ensindexer): deploy and index EFP on the devnet stack Add an efp-devnet service that deploys the EFP contracts onto the ENS devnet's anvil node in attach mode (image pinned to a master build, mirroring the contracts-v2 pin), gate ensindexer on its health, and add `efp` to the devnet PLUGINS. The result of `docker compose -f docker/docker-compose.devnet.yml up` is one chain (id 31337) carrying both ENS v2 and EFP contracts, indexed together. --- docker/docker-compose.devnet.yml | 15 +++++++++++++++ docker/envs/.env.docker.devnet | 2 +- docker/services/efp-devnet.yml | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 docker/services/efp-devnet.yml 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/envs/.env.docker.devnet b/docker/envs/.env.docker.devnet index 9f3fc99897..d27f014f81 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 ENSApi 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 From dd57feb7765ef0c61b030682d97fd04c90ffe3ce Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 09:17:41 +0200 Subject: [PATCH 36/45] test(efp): index EFP in the integration env + assert the demoGraph graph Bring the efp-devnet service up alongside the ENS devnet in the integration orchestrator, gate it on devnet health, and add `efp` to the indexer's plugins so the integration stack indexes ENS and EFP together on chain 31337. Add EFP Omnigraph integration assertions against the demoGraph scenario: the two-step-validated `efp.primaryList` (and its null case), the account-rooted `Account.efp` view, and the full `account -> primaryList.records -> node.account.efp.primaryList` deep walk. These double as the silent-failure guard for the hardcoded devnet addresses: wrong addresses index zero EFP rows and fail these loudly rather than passing vacuously. --- .../schema/efp.integration.test.ts | 162 ++++++++++++++++++ docker/docker-compose.orchestrator.yml | 11 ++ .../integration-test-env/src/lifecycle.ts | 7 +- 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts 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..5be98f09ce --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { accounts } 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"); + }); +}); 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/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts index b535636d8b..e22077efb2 100644 --- a/packages/integration-test-env/src/lifecycle.ts +++ b/packages/integration-test-env/src/lifecycle.ts @@ -291,9 +291,10 @@ export async function bringUp(options: { only?: Set } = {}): Promise } = {}): Promise Date: Mon, 1 Jun 2026 09:35:09 +0200 Subject: [PATCH 37/45] test(efp): seed and assert EFP handler edge cases on the devnet Add a TypeScript EFP seeder (run during the integration seed phase) that mints dedicated lists and drives the op sequences the demoGraph scenario does not: - tag de-duplication (a repeated ADD_TAG is a no-op), - the embedded-tags cascade on REMOVE_RECORD plus a fresh re-ADD, - a junk-suffixed REMOVE_RECORD that still deletes the canonically-keyed record, - a malformed `user` metadata value that clears the role (live path), and - role durability across a storage-location re-point: a list moved away from its slot and back recovers its role from the durable per-slot metadata. Each record is anchored by a synthetic target address (shared via `@ensnode/datasources`) so the assertions look records up by `recordData` without depending on a token id; the targets double as the `EfpListRecord.account` null path. Also exposes the EFPListMinter devnet address (not indexed) for the seeder. --- .../schema/efp.integration.test.ts | 67 ++++- packages/datasources/src/devnet/constants.ts | 24 +- packages/datasources/src/ens-test-env.ts | 8 +- .../integration-test-env/src/lifecycle.ts | 2 + packages/integration-test-env/src/seed/efp.ts | 271 ++++++++++++++++++ 5 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 packages/integration-test-env/src/seed/efp.ts diff --git a/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts index 5be98f09ce..ee5f33903f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts, efpSeedRoleUser, efpSeedTargets } from "@ensnode/datasources/devnet"; -import { flattenConnection, type GraphQLConnection, request } from "@/test/integration/graphql-utils"; +import { + flattenConnection, + type GraphQLConnection, + request, +} from "@/test/integration/graphql-utils"; import { gql } from "@/test/integration/omnigraph-api-client"; /** @@ -160,3 +164,62 @@ describe("Account.efp deep walk (demoGraph)", () => { 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("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); + }); +}); diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index 556694b186..4ca85f882a 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -96,7 +96,8 @@ export const contracts = { /** * 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. Only the three contracts the EFP plugin indexes are listed. + * 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 @@ -112,6 +113,7 @@ export const efpContracts = { EFPAccountMetadata: "0xd5ac451b0c50b9476107823af206ed814a2e2580", EFPListRegistry: "0xf8e31cb472bc70500f08cd84917e5a1912ec8397", EFPListRecords: "0xc0f115a19107322cfbf1cdbc7ea011c19ebdb4f8", + EFPListMinter: "0xc96304e3c037f81da488ed9dea1d8f2a48278a75", } as const satisfies Record; /** @@ -150,6 +152,26 @@ 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)}`); + 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 54d8fa0cfc..45691d989f 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -1,11 +1,11 @@ -import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; -import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; -import { Registry } from "./abis/ensv2/Registry"; -import { UniversalResolverV2 } from "./abis/ensv2/UniversalResolverV2"; // 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"; +import { UniversalResolverV2 } from "./abis/ensv2/UniversalResolverV2"; // ABIs for ENSRoot Datasource import { BaseRegistrar as root_BaseRegistrar } from "./abis/root/BaseRegistrar"; import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } from "./abis/root/LegacyEthRegistrarController"; diff --git a/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts index e22077efb2..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, "../../.."); @@ -314,6 +315,7 @@ export async function bringUp(options: { only?: Set } = {}): 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: [], + }, +] 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)], + }), + ); + } +} From 382f74d5c60c254dabb45b4b83158334aa9c3bde Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 10:13:39 +0200 Subject: [PATCH 38/45] fix(efp): address PR review feedback - AccountMetadata: strip NUL bytes from the free-form on-chain `key` before using it as a primary-key component (Postgres text columns reject NUL; matches the tag path's api-v2 parity normalization). - Add a parse-list-storage-location test for the exact safe-integer chain-id boundary (2^53-1 accepted, 2^53 rejected). - Add an EFP integration assertion for the primaryList two-step validation's mismatch branch (metadata present but the list's `user` does not match -> null), with a seed actor address exposed for it. --- .../schema/efp.integration.test.ts | 38 ++++++++++++++++++- .../plugins/efp/handlers/AccountMetadata.ts | 8 +++- .../lib/parse-list-storage-location.test.ts | 10 +++++ packages/datasources/src/devnet/constants.ts | 9 +++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts index ee5f33903f..d578a96e40 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { accounts, efpSeedRoleUser, efpSeedTargets } from "@ensnode/datasources/devnet"; +import { + accounts, + efpSeedActorAddress, + efpSeedRoleUser, + efpSeedTargets, +} from "@ensnode/datasources/devnet"; import { flattenConnection, @@ -215,6 +220,37 @@ describe("EFP handler edge cases (seeded)", () => { 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); diff --git a/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts index 80160690c5..bcc9f72837 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/AccountMetadata.ts @@ -19,15 +19,19 @@ export default function () { 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, event.args.key), + id: accountMetadataId(address, key), chainId: context.chain.id, contractAddress: event.log.address.toLowerCase() as Hex, address, - key: event.args.key, + key, value: event.args.value, createdAt: ts, updatedAt: ts, 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 index fdc8550ac5..13b7bd3418 100644 --- 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 @@ -72,5 +72,15 @@ describe("parseListStorageLocation", () => { 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/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index 4ca85f882a..abea3cb0c3 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -172,6 +172,15 @@ export const efpSeedTargets = { /** 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", From b7e642efc32a9affde97b08490c2f6bf2f2901cc Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 10:23:55 +0200 Subject: [PATCH 39/45] fix(efp): keep the ENSApi and indexer account-metadata id helpers in sync The indexer now strips NUL bytes from the AccountMetadata `key` before writing, so the ENSApi-side `efpAccountMetadataId` must do the same (and lowercase the address) to build a matching lookup id. Apply the identical normalization in both helpers. --- apps/ensapi/src/omnigraph-api/schema/efp-ids.ts | 8 ++++++-- apps/ensindexer/src/plugins/efp/lib/ids.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts index 76b939fb9e..dcec2e26fb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts @@ -7,9 +7,13 @@ import type { Hex } from "viem"; * formats MUST stay in sync with the indexer. */ -/** `efp_account_metadata` key: `${address}-${key}` (lowercased address). */ +/** + * `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}-${key}`; + return `${address}-${key.replace(/\0/g, "")}`; } /** `efp_list_storage_locations` key: `${chainId}-${contractAddress}-${slot}` (lowercased hex). */ diff --git a/apps/ensindexer/src/plugins/efp/lib/ids.ts b/apps/ensindexer/src/plugins/efp/lib/ids.ts index 8cab3e89aa..4f69a42723 100644 --- a/apps/ensindexer/src/plugins/efp/lib/ids.ts +++ b/apps/ensindexer/src/plugins/efp/lib/ids.ts @@ -20,9 +20,9 @@ export function listRecordId( return `${chainId}-${contractAddress.toLowerCase()}-${slot.toLowerCase()}-${record.toLowerCase()}`; } -/** `efp_account_metadata` key: an `(address, key)` pair. */ +/** `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}`; + return `${address.toLowerCase()}-${key.replace(/\0/g, "")}`; } /** `efp_list_metadata` key: per-location metadata `(storage location, key)`. */ From a23cd9978da4ce99e84c5858c8b27b34f5cec64d Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 12:17:26 +0200 Subject: [PATCH 40/45] perf(efp): batch the EfpListRecord.list back-reference `EfpListRecord.list` did one storage-location -> tokenId lookup per node, an N+1 on `efp.listRecords { node { list } }`. Convert it to a Pothos loadable field (the dataloader plugin is already enabled) keyed by storage-location id, so a page resolves all `list` back-refs in two batched queries instead of one per record. --- .../omnigraph-api/schema/efp-list-record.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts index bf224ad9d1..0c69f3eba8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list-record.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; +import { inArray } from "drizzle-orm"; import type { ChainId, NormalizedAddress } from "enssdk"; import di from "@/di"; @@ -113,23 +113,40 @@ EfpListRecordRef.implement({ /////////////////////// // EfpListRecord.list /////////////////////// - list: t.field({ + list: t.loadable({ description: "The EFP list this record belongs to.", type: EfpListRef, nullable: true, - resolve: async (record) => { + // 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 [mapping] = await ensDb - .select({ tokenId: ensIndexerSchema.efpListStorageLocations.tokenId }) + + const mappings = await ensDb + .select({ + id: ensIndexerSchema.efpListStorageLocations.id, + tokenId: ensIndexerSchema.efpListStorageLocations.tokenId, + }) .from(ensIndexerSchema.efpListStorageLocations) - .where( - eq( - ensIndexerSchema.efpListStorageLocations.id, - efpStorageLocationId(record.chainId, record.contractAddress, record.slot), - ), - ) - .limit(1); - return mapping?.tokenId ?? null; + .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; + }); }, }), From e35c73b51bbf515f74f53f4ab041a81eb9fdd0d9 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 15:21:33 +0200 Subject: [PATCH 41/45] docs(efp): document the verified cross-chain EFP mainnet addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate each EFP contract address in the mainnet namespace with its chain and a reference to https://docs.efp.app/production/deployments/. In particular, call out that EFPAccountMetadata (Base) and EFPListRecords (Ethereum mainnet) intentionally share 0x5289…0F17EF: EFP deploys via CREATE2, so one address maps to a different contract per chain. Confirmed against the EFP deployments doc and api-v2. --- packages/datasources/src/mainnet.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/datasources/src/mainnet.ts b/packages/datasources/src/mainnet.ts index 95d8d34c1e..82426b77b8 100644 --- a/packages/datasources/src/mainnet.ts +++ b/packages/datasources/src/mainnet.ts @@ -509,22 +509,27 @@ export default { * The `ListRecords` contract is also deployed on Base (one of the three "list storage location" * chains a list NFT may point at via `UpdateListStorageLocation`). * - * Addresses and start blocks cross-checked against https://docs.efp.app and - * ethereumfollowprotocol/api-v2. + * 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", @@ -539,6 +544,7 @@ export default { [DatasourceNames.EFPOptimism]: { chain: optimism, contracts: { + // EFPListRecords, Optimism. ListRecords: { abi: efp_ListRecords, address: "0x4ca00413d850dcfa3516e14d21dae2772f2acb85", @@ -553,6 +559,11 @@ export default { [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", From 97518de0f2f4acd99cca1d9123a1a22bb78cc132 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 15:21:33 +0200 Subject: [PATCH 42/45] fix(efp): canonicalize the address in efpAccountMetadataId Lowercase the address in the ENSApi-side id helper so it builds the exact same key the indexer persists (which lowercases via `accountMetadataId`), regardless of the caller's casing. No behavior change for normalized input; makes the helper canonical. --- apps/ensapi/src/omnigraph-api/schema/efp-ids.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts index dcec2e26fb..550ef4b191 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-ids.ts @@ -13,7 +13,7 @@ import type { Hex } from "viem"; * (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}-${key.replace(/\0/g, "")}`; + return `${address.toLowerCase()}-${key.replace(/\0/g, "")}`; } /** `efp_list_storage_locations` key: `${chainId}-${contractAddress}-${slot}` (lowercased hex). */ From d1954e9e751756c1d8b8d37e177696f201e2fe1f Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 15:43:37 +0200 Subject: [PATCH 43/45] fix(efp): order and paginate EFP lists numerically by tokenId `efpLists.tokenId` is a `text` column (a uint256 that does not fit a Postgres integer type), so `efp.lists` / `Account.efp.lists` ordered and cursor-compared it lexicographically: "10" sorted before "2", and the before/after cursors skipped or repeated rows once there were more than 9 lists. Add `paginateByNumericText` / `orderByNumericText` helpers that cast to `::numeric`, and use them for the tokenId-keyed list connections (the id-keyed record/metadata connections are unchanged). Verified by a new integration test that seeds >9 lists and paginates across the single->double-digit boundary in small pages. --- .../omnigraph-api/lib/connection-helpers.ts | 25 +++++++++++- .../src/omnigraph-api/schema/account-efp.ts | 11 +++-- .../schema/efp.integration.test.ts | 40 +++++++++++++++++++ apps/ensapi/src/omnigraph-api/schema/efp.ts | 16 ++++++-- packages/integration-test-env/src/seed/efp.ts | 30 ++++++++++++++ 5 files changed, 115 insertions(+), 7 deletions(-) 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/account-efp.ts b/apps/ensapi/src/omnigraph-api/schema/account-efp.ts index 61e6dfab01..30f2e4e737 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account-efp.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account-efp.ts @@ -5,7 +5,7 @@ import type { Hex } from "viem"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; -import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; +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"; @@ -51,8 +51,13 @@ AccountEfpRef.implement({ ensDb .select() .from(ensIndexerSchema.efpLists) - .where(and(scope, paginateBy(ensIndexerSchema.efpLists.tokenId, before, after))) - .orderBy(orderPaginationBy(ensIndexerSchema.efpLists.tokenId, inverted)) + .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/efp.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts index d578a96e40..370581e905 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.integration.test.ts @@ -259,3 +259,43 @@ describe("EFP handler edge cases (seeded)", () => { 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 index bf82237ef6..1e87949d10 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp.ts @@ -4,7 +4,12 @@ import type { Hex } from "viem"; import di from "@/di"; import { builder } from "@/omnigraph-api/builder"; -import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; +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"; @@ -63,8 +68,13 @@ EfpQueryRef.implement({ ensDb .select() .from(ensIndexerSchema.efpLists) - .where(and(scope, paginateBy(ensIndexerSchema.efpLists.tokenId, before, after))) - .orderBy(orderPaginationBy(ensIndexerSchema.efpLists.tokenId, inverted)) + .where( + and( + scope, + paginateByNumericText(ensIndexerSchema.efpLists.tokenId, before, after), + ), + ) + .orderBy(orderByNumericText(ensIndexerSchema.efpLists.tokenId, inverted)) .limit(limit), ), }); diff --git a/packages/integration-test-env/src/seed/efp.ts b/packages/integration-test-env/src/seed/efp.ts index 026d734456..bcce0155c7 100644 --- a/packages/integration-test-env/src/seed/efp.ts +++ b/packages/integration-test-env/src/seed/efp.ts @@ -112,6 +112,15 @@ const registryAbi = [ ], 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 = [ @@ -268,4 +277,25 @@ export async function seedEfpDevnet(rpcUrl: string): Promise { }), ); } + + // 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; + } } From bfb08c74f1a5f2a3a27d4e5ea20b7ae92ee8254f Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 21:51:00 +0200 Subject: [PATCH 44/45] perf(efp): index-back the numeric tokenId list pagination The numeric ordering fix cast `tokenId::numeric`, which the text primary-key btree index could not serve, so unfiltered `efp.lists` pagination fell back to a sort at scale. Add an expression index on `(tokenId::numeric)` to `efp_lists` matching that cast, so ordering and the cursor comparison are index-backed. tokenId stays a text PK (uint256); no separate column or stored-format change. --- packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts index 4eab7c9754..b6fe943843 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -12,7 +12,7 @@ * Timestamps are Unix-seconds `bigint`s (the block timestamp), matching ENSNode convention. */ -import { index, onchainTable } from "ponder"; +import { index, onchainTable, sql } from "ponder"; /** * One row per minted `ListRegistry` NFT (a "list"). EFP separates the NFT `owner`, the @@ -55,6 +55,11 @@ export const efpLists = onchainTable( 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)`), }), ); From f861760d20fe150510d6eafda600e87ccf72214b Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 1 Jun 2026 22:21:29 +0200 Subject: [PATCH 45/45] docs(efp): clarify nftChainId chain + name the address record-type constant - EfpList.nftChainId (and the efp_lists column) no longer claim "always Base": the value is the active namespace's EFP deployment chain (Base on mainnet, 31337 on the ens-test-env devnet). Regenerated the Omnigraph schema to match. - Add `EFP_RECORD_TYPE_ADDRESS = 0x01` alongside the other EFP schema constants and use it in `parseRecord` instead of a magic `1`. - Document that the ListRegistry Transfer upsert leaves createdAt / nftChainId / nftContractAddress untouched on conflict because an ERC-721 tokenId is unique for the contract's lifetime and the NFT never changes chain/contract. --- apps/ensapi/src/omnigraph-api/schema/efp-list.ts | 3 ++- apps/ensindexer/src/plugins/efp/constants.ts | 3 +++ apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts | 4 ++++ apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts | 6 +++--- packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts | 2 +- packages/enssdk/src/omnigraph/generated/schema.graphql | 4 +++- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/efp-list.ts b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts index 016bdff5f5..275f17cdf2 100644 --- a/apps/ensapi/src/omnigraph-api/schema/efp-list.ts +++ b/apps/ensapi/src/omnigraph-api/schema/efp-list.ts @@ -93,7 +93,8 @@ EfpListRef.implement({ // EfpList.nftChainId ///////////////////// nftChainId: t.field({ - description: "Chain id of the ListRegistry NFT (always Base / 8453).", + 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, diff --git a/apps/ensindexer/src/plugins/efp/constants.ts b/apps/ensindexer/src/plugins/efp/constants.ts index c868679013..be47f0188e 100644 --- a/apps/ensindexer/src/plugins/efp/constants.ts +++ b/apps/ensindexer/src/plugins/efp/constants.ts @@ -10,6 +10,9 @@ 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`. * diff --git a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts index 92520d9886..ce31eb904d 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/ListRegistry.ts @@ -50,6 +50,10 @@ export default function () { } 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({ diff --git a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts index 0917f4551b..ce8384cc16 100644 --- a/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts +++ b/apps/ensindexer/src/plugins/efp/lib/parse-list-op.ts @@ -16,7 +16,7 @@ import { type Hex, isHex } from "viem"; -import { EFP_LIST_OP_VERSION, EFP_RECORD_VERSION } from "../constants"; +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. */ @@ -93,8 +93,8 @@ export function parseRecord(data: Hex | string | null | undefined): ParsedRecord // 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 record type 1 (a 20-byte address); types 0 and 2-255 are reserved. - if (recordType !== 1) 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( diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts index b6fe943843..ce58f34e9e 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts @@ -27,7 +27,7 @@ export const efpLists = onchainTable( tokenId: t.text().primaryKey(), /** Current ERC-721 owner of the list NFT. */ owner: t.hex().notNull(), - /** Chain id of the `ListRegistry` NFT (always Base / 8453). */ + /** 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(), diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 6e672a7ee8..6d945a33ac 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -873,7 +873,9 @@ type EfpList { """The address allowed to administer this list.""" manager: Address - """Chain id of the ListRegistry NFT (always Base / 8453).""" + """ + 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."""