diff --git a/.changeset/upset-glasses-strive.md b/.changeset/upset-glasses-strive.md new file mode 100644 index 000000000..dd54b118c --- /dev/null +++ b/.changeset/upset-glasses-strive.md @@ -0,0 +1,8 @@ +--- +"@ensnode/datasources": minor +"@ensnode/ensnode-schema": minor +"@ensnode/ensnode-sdk": minor +"ensindexer": minor +--- + +Introduce initial `efp` plugin demo that indexes EFP List Tokens and EFP List Storage Locations. diff --git a/apps/ensindexer/src/api/index.ts b/apps/ensindexer/src/api/index.ts index ae971a07f..d1067646d 100644 --- a/apps/ensindexer/src/api/index.ts +++ b/apps/ensindexer/src/api/index.ts @@ -16,6 +16,7 @@ import { fetchPrometheusMetrics, makePonderMetdataProvider, } from "@/lib/ponder-metadata-provider"; +import { honoEFP } from "@/plugins/efp/lib/api"; import { ponderMetadata } from "@ensnode/ponder-metadata"; import { buildGraphQLSchema as buildSubgraphGraphQLSchema, @@ -140,4 +141,7 @@ app.use( }), ); +// Use Hono EFP application for handling EFP-related requests +app.route("/efp", honoEFP({ db })); + export default app; diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index aa231d0c6..894879291 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -305,5 +305,22 @@ const makeApiDocumentation = (isSubgraph: boolean) => { value: "Value of the text record", }, ), + /** + * The following is documentation for packages/ensnode-schema/src/efp.schema.ts + */ + ...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Token", { + id: "Unique token ID for an EFP List Token", + owner: "The address of the current owner of the EFP List Token (always lowercase)", + lslId: + "Value of `EncodedLsl` type (optional, lowercase if present). Stores the ID of the List Storage Location. If the List Storage Location was never created or not in a recognized format, this field value will be `null`.", + }), + ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { + id: "ListStorageLocation ID, an `EncodedLsl` value (always lowercase).", + chainId: + "EVM chain ID of the chain where the EFP list records are stored, an `EFPDeploymentChainId` value.", + listRecordsAddress: + "Contract address on chainId where the EFP list records are stored (always lowercase).", + slot: "A unique identifier within the List Storage Location, distinguishes between multiple EFP lists stored in the same `EFPListRecords` smart contract by serving as the key in mappings for list operations and metadata.", + }), }); }; diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts new file mode 100644 index 000000000..3c4a158cd --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -0,0 +1,101 @@ +import { ponder } from "ponder:registry"; +import { efp_listStorageLocation, efp_listToken } from "ponder:schema"; + +import config from "@/config"; +import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers"; +import { PluginName } from "@ensnode/ensnode-sdk"; +import { zeroAddress } from "viem"; +import { + decodeListStorageLocationContract, + isEncodedLslContract, + parseEncodedLsl, +} from "../lib/lsl"; +import { parseEvmAddress } from "../lib/utils"; + +export default function ({ pluginNamespace: ns }: ENSIndexerPluginHandlerArgs) { + /// + /// EFPListRegistry Handlers + /// + ponder.on( + ns("EFPListRegistry:Transfer"), + async function handleEFPListTokenTransfer({ context, event }) { + const { tokenId, from: fromAddress, to: toAddress } = event.args; + + // The mint event represents the transfer of ownership of + // an EFP List token from the zeroAddress to the new owner's address. + if (fromAddress === zeroAddress) { + // Create a new EFP List Token with the owner initialized as the token recipient + await context.db.insert(efp_listToken).values({ + id: tokenId, + owner: parseEvmAddress(toAddress), + }); + } + + // The burn event represents the transfer of ownership of + // an EFP List token from the owner's address to the zero address. + else if (toAddress === zeroAddress) { + // Delete the burnt EFP List Token + await context.db.delete(efp_listToken, { id: tokenId }); + } + + // If the transfer is not from the zeroAddress, + // and the transfer is not to the zeroAddress, + // this transfer represents an ownership change. + else { + // Update the owner of the the List Token that changed ownership + await context.db.update(efp_listToken, { id: tokenId }).set({ + owner: parseEvmAddress(toAddress), + }); + } + }, + ); + + ponder.on( + ns("EFPListRegistry:UpdateListStorageLocation"), + async function handleEFPListStorageLocationUpdate({ context, event }) { + const { listStorageLocation: encodedListStorageLocation, tokenId } = event.args; + const listToken = await context.db.find(efp_listToken, { id: tokenId }); + + // invariant: List Token must exist before its List Storage Location can be updated + if (!listToken) { + throw new Error( + `Cannot update List Storage Location for nonexisting List Token (id: ${tokenId})`, + ); + } + + const encodedLsl = parseEncodedLsl(encodedListStorageLocation); + const lslId = encodedLsl; + + // Update the List Token with the new List Storage Location + await context.db.update(efp_listToken, { id: tokenId }).set({ + lslId, + }); + + // Update the List Storage Location associated with the List Token + // if the List Storage Location is in a recognized format + if (isEncodedLslContract(encodedLsl)) { + try { + const lslContract = decodeListStorageLocationContract(config.namespace, encodedLsl); + + // Index the decoded List Storage Location data with a reference to the List Token + // created with the currently handled EVM event. + // Note: if the List Storage Location with the same ID already exists, + // no new changes will be made to the database. + await context.db + .insert(efp_listStorageLocation) + .values({ + id: lslId, + chainId: lslContract.chainId, + listRecordsAddress: lslContract.listRecordsAddress, + slot: lslContract.slot, + }) + .onConflictDoNothing(); + } catch { + // The `encodedLsl` value could not be decoded. + // We can ignore this case, as we have already captured the `encodedLsl` value + // in the `efp_listToken` table as the `lslId` value. + } + } + }, + ); +} diff --git a/apps/ensindexer/src/plugins/efp/lib/api.ts b/apps/ensindexer/src/plugins/efp/lib/api.ts new file mode 100644 index 000000000..0f152f5e5 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/api.ts @@ -0,0 +1,56 @@ +/** + * This Hono application enables the ENSIndexer API to handle requests related to the Ethereum Follow Protocol. + */ + +import { efp_listToken } from "ponder:schema"; +import { Hono } from "hono"; +import { type ReadonlyDrizzle, eq } from "ponder"; +import { type ListTokenId, parseListTokenId } from "./utils"; + +interface HonoEFP { + db: ReadonlyDrizzle>; +} + +/** + * Creates a Hono application with EFP routes. + * @returns + */ +export function honoEFP({ db }: HonoEFP): Hono { + const app = new Hono(); + + // Route to handle EFP-related requests + app.get("/list/:listTokenId", async function getListTokenById(ctx) { + let listTokenId: ListTokenId; + + // Parse the listTokenId from the request parameter + try { + listTokenId = parseListTokenId(ctx.req.param("listTokenId")); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return ctx.json({ error: `Invalid list token ID. ${errorMessage}` }, 400); + } + + // Fetch the EFP List Token from the database + const listTokens = await db + .select() + .from(efp_listToken) + .where(eq(efp_listToken.id, listTokenId)); + + const listToken = listTokens[0]; + + if (!listToken) { + return ctx.json({ error: `List token with ID ${listTokenId} not found.` }, 404); + } + + // Prepare the response DTO + // Note: The DTO is a simplified version of the EFP List Token schema. + const listTokenDto = { + owner: listToken.owner, + encodedLsl: listToken.lslId, + }; + + return ctx.json(listTokenDto, 200); + }); + + return app; +} diff --git a/apps/ensindexer/src/plugins/efp/lib/chains.ts b/apps/ensindexer/src/plugins/efp/lib/chains.ts new file mode 100644 index 000000000..41d5901c0 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/chains.ts @@ -0,0 +1,29 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { base, baseSepolia, mainnet, optimism, optimismSepolia, sepolia } from "viem/chains"; + +/** + * The ChainId of an EFP Deployment.. + * + * EFP has an allowlisted set of supported chains. This allowlisted set is a function of the ENSNamespace. + * See {@link getEFPDeploymentChainIds}. + */ +export type EFPDeploymentChainId = number; + +/** + * Get the list of EFP Deployment Chain IDs for the ENS Namespace ID. + * + * @param ensNamespaceId - ENS Namespace ID to get the EFP Deployment Chain IDs for + * @returns list of EFP Deployment Chain IDs on the associated ENS Namespace + */ +export function getEFPDeploymentChainIds(ensNamespaceId: ENSNamespaceId): EFPDeploymentChainId[] { + switch (ensNamespaceId) { + case "mainnet": + return [base.id, optimism.id, mainnet.id]; + case "sepolia": + return [baseSepolia.id, optimismSepolia.id, sepolia.id]; + default: + throw new Error( + `EFP Deployment chainIds are not defined for the ${ensNamespaceId} ENS Namespace ID`, + ); + } +} diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts new file mode 100644 index 000000000..ba4989686 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -0,0 +1,306 @@ +/** + * EFP List Storage Location utilities + */ + +import type { ENSNamespaceId } from "@ensnode/datasources"; +import type { Hex } from "viem"; +import { prettifyError, z } from "zod/v4"; +import { type EFPDeploymentChainId, getEFPDeploymentChainIds } from "./chains"; +import { type EvmAddress, parseEvmAddress } from "./utils"; + +/** + * Enum defining recognized List Storage Location Types + * + * Based on documentation at: + * https://docs.efp.app/design/list-storage-location/#location-types + */ +export enum ListStorageLocationType { + /** + * EVMContract Data List Storage Location Type encoding: + * 32-byte chain ID + 20-byte contract address + 32-byte slot + */ + EVMContract = 1, +} + +/** + * Enum defining recognized List Storage Location Versions + * + * Based on documentation at: + * https://docs.efp.app/design/list-storage-location/#serialization + */ +export enum ListStorageLocationVersion { + V1 = 1, +} + +/** + * Base List Storage Location + */ +interface BaseListStorageLocation< + LSLVersion extends ListStorageLocationVersion, + LSLType extends ListStorageLocationType, +> { + /** + * The version of the List Storage Location. + * + * This is used to ensure compatibility and facilitate future upgrades. + */ + version: LSLVersion; + + /** + * The type of the List Storage Location. + * + * This identifies the kind of data the data field contains. + */ + type: LSLType; +} + +/** + * List Storage Location Contract + * + * Describes data model for the EVM contract Location Type as + * a specialized version of BaseListStorageLocation interface, + * where the location type is always 1, and, for now, the version is always 1. + * + * Documented based on https://docs.efp.app/design/list-storage-location/ + */ +export interface ListStorageLocationContract + extends BaseListStorageLocation< + ListStorageLocationVersion.V1, + ListStorageLocationType.EVMContract + > { + /** + * EVM chain ID of the chain where the EFP list records are stored. + */ + chainId: EFPDeploymentChainId; + + /** + * Contract address on chainId where the EFP list records are stored. + */ + listRecordsAddress: EvmAddress; + + /** + * The 32-byte value that specifies the storage slot of the EFP list records within the listRecordsAddress contract. + * This disambiguates multiple lists stored within the same contract and + * de-couples it from the EFP List NFT token id which is stored on the EFP deployment root chain and + * inaccessible on other chains. + */ + slot: bigint; +} + +/** + * Encoded List Storage Location + * + * An encoded List Storage Location is a string formatted as the lowercase hexadecimal representation of a bytes array with the following structure: + * - `version`: A string representation of `uint8` value indicating the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. + * - `type`: A string representation of `uint8` value indicating the type of list storage location. This identifies the kind of data the data field contains.. + * - `data:` A string representation of a bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + * + * Note: To prevent any `Hex` value from being able to be represented as an Encoded LSL, we apply a brand to the type. + */ +export type EncodedLsl = Hex & { readonly __brand: "EncodedLsl" }; + +/** + * Parse a string value into an Encoded LSL. + */ +export function parseEncodedLsl(value: string): EncodedLsl { + if (!value.startsWith("0x")) { + throw new Error("Encoded LSL must start with '0x'"); + } + + return value.toLowerCase() as EncodedLsl; +} + +/** + * An encoded representation of an EVMContract List Storage Location. + * + * - `version`: A string representation of `uint8` value indicating the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. + * - `type`: A string representation of `uint8` value set to {@link ListStorageLocationType.EVMContract} + * - `data:` A string representation of a bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + * + * Note: To prevent any `Hex` value from being able to be represented as an Encoded LSL, we apply a brand to the type. + */ +export type EncodedLslContract = `0x0101${string}` & EncodedLsl; + +/** + * Intermediate data structure to help parse an {@link EncodedLsl} into an {@link ListStorageLocationContract}. + */ +interface SlicedLslContract { + /** + * The version of the List Storage Location. + * + * Formatted as the string representation of a `uint8` value. + * This is used to ensure compatibility and facilitate future upgrades. + */ + version: string; + + /** + * The type of the List Storage Location. + * + * Formatted as the string representation of a `uint8` value. + * This identifies the kind of data the data field contains. + */ + type: string; + + /** + * The EVM chain ID of the chain where the EFP list records are stored. + * + * Formatted as the string representation of a `uint256` value. + */ + chainId: string; + + /** + * Contract address on chainId where the EFP list records are stored. + * + * Formatted as the string representation of a 20-byte unsigned integer value. + */ + listRecordsAddress: string; + + /** + * The storage slot of the EFP list records within the listRecordsAddress contract. + * + * Formatted as the string representation of a `uint256` value. + * + * This disambiguates multiple lists stored within the same contract and + * de-couples it from the EFP List NFT token id which is stored on the EFP deployment root chain and + * inaccessible on other chains. + */ + slot: string; +} + +/** + * Validate encoded representation of an EVMContract LSL. + * + * @param {EncodedLsl} encodedLsl + * @throws {Error} when the encodedLsl is not of expected length of a V1 LSL for an EVM Contract + * @throws {Error} when the encodedLsl is not of expected EVMContract type + */ +export function validateEncodedLslContract(encodedLsl: EncodedLsl): void { + if (encodedLsl.length !== 174) { + throw new Error( + "Encoded List Storage Location values for a LSL v1 Contract must be a 174-character long string", + ); + } + + const evmContractTypePadded = ListStorageLocationType.EVMContract.toString(2).padStart(2, "0"); + const encodedLslType = encodedLsl.slice(4, 6); + + if (encodedLslType !== evmContractTypePadded) { + throw new Error( + `Encoded List Storage Location type value for an EVMContract LSL must be set to ${evmContractTypePadded}`, + ); + } +} + +/** + * Check if the provided encodedLsl is a valid {@link EncodedLslContract}. + * + * @param {EncodedLsl} encodedLsl - Encoded representation of an EVMContract List Storage Location. + * @returns {boolean} true if the encodedLsl is a valid EncodedLslContract, false otherwise. + */ +export function isEncodedLslContract(encodedLsl: EncodedLsl): encodedLsl is EncodedLslContract { + try { + validateEncodedLslContract(encodedLsl); + return true; + } catch { + return false; + } +} + +/** + * Convert an encoded representation of a EMVContract LSL into a SlicedLslContract. + * + * @param encodedLslContract encoded representation of an EVMContract LSL. + * @returns {SlicedLslContract} Sliced LSL EVMContract. + */ +function sliceEncodedLslContract(encodedLslContract: EncodedLslContract): SlicedLslContract { + return { + // Extract the first byte after the 0x (2 hex characters = 1 byte) + version: encodedLslContract.slice(2, 4), + + // Extract the second byte + type: encodedLslContract.slice(4, 6), + + // Extract the next 32 bytes to get the chain id + chainId: encodedLslContract.slice(6, 70), + + // Extract the address (40 hex characters = 20 bytes) + listRecordsAddress: encodedLslContract.slice(70, 110), + + // Extract last 32 bytes to get the slot + slot: encodedLslContract.slice(110, 174), + } satisfies SlicedLslContract; +} + +/** + * Create a zod schema covering validations and invariants enforced with {@link decodeListStorageLocationContract} parser. + * This schema will be used to parse value of the {@link SlicedLslContract} type into {@link ListStorageLocationContract} type. + * + * @param {ENSNamespaceId} ensNamespaceID Selected ENS Namespace ID + */ +const createEfpLslContractSchema = (ensNamespaceId: ENSNamespaceId) => { + const efpDeploymentChainIds = getEFPDeploymentChainIds(ensNamespaceId); + + return z.object({ + version: z.literal("01").transform(() => ListStorageLocationVersion.V1), + + type: z.literal("01").transform(() => ListStorageLocationType.EVMContract), + + chainId: z + .string() + .length(64) + // prep: map string representation of a `uint256` chainId value into bigint + .transform((v) => BigInt(`0x${v}`)) + // invariant: chainId can be converted into a positive safe integer + .refine((v) => v > 0 && v <= Number.MAX_SAFE_INTEGER, { + message: "chainId must be in the accepted range", + }) + // prep: map a bigint chainId value into number + .transform((v) => Number(v)) + // invariant: chainId is from one of the allowlisted EFP Deployment Chain IDs for the ENS Namespace ID + // https://docs.efp.app/production/deployments/ + .refine((v) => efpDeploymentChainIds.includes(v), { + message: `chainId must be one of the EFP deployment Chain IDs defined for the ENSNamespace "${ensNamespaceId}": ${efpDeploymentChainIds.join(", ")}`, + }), + + listRecordsAddress: z + .string() + .length(40) + .transform((v) => `0x${v}`) + // ensure EVM address correctness and map it into lowercase for ease of equality comparisons + .transform((v) => parseEvmAddress(v)), + + slot: z + .string() + .length(64) + .transform((v) => BigInt(`0x${v}`)), + }); +}; + +// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 +/** + * Decodes an EncodedLsl into a ListStorageLocationContract. + * + * @param {ENSNamespaceId} ensNamespaceId - The ENS Namespace ID to use for decoding. + * @param {EncodedLslContract} encodedLslContract - The encoded V1 EVMContract List Storage Location string to parse. + * @returns A decoded {@link ListStorageLocationContract} object. + * @throws An error if parsing could not be completed successfully. + */ +export function decodeListStorageLocationContract( + ensNamespaceId: ENSNamespaceId, + encodedLslContract: EncodedLslContract, +): ListStorageLocationContract { + const slicedLslContract = sliceEncodedLslContract(encodedLslContract); + const efpLslContractSchema = createEfpLslContractSchema(ensNamespaceId); + + const parsed = efpLslContractSchema.safeParse(slicedLslContract); + + if (!parsed.success) { + throw new Error( + "Failed to decode the encoded List Storage Location contract object: \n" + + prettifyError(parsed.error) + + "\n", + ); + } + + return parsed.data; +} diff --git a/apps/ensindexer/src/plugins/efp/lib/utils.ts b/apps/ensindexer/src/plugins/efp/lib/utils.ts new file mode 100644 index 000000000..1473beaf1 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/utils.ts @@ -0,0 +1,47 @@ +import { type Address, getAddress } from "viem"; + +/** + * EVM Address type. + * + * Represents a normalized EVM address, which is always in lowercase. + * This type is used to ensure that EVM addresses are consistently formatted. + * + * Note: To prevent any `Address` value from being able to be represented as an EvmAddress, we apply a brand to the type. + */ +export type EvmAddress = Address & { __brand: "EvmAddress" }; + +/** + * Parses an EVM address value. + * + * @param {string} value - The EVM address string to parse. + * @returns {EvmAddress} The normalized EVM address in lowercase. + */ +export function parseEvmAddress(value: string): EvmAddress { + return getAddress(value).toLowerCase() as EvmAddress; +} + +export type ListTokenId = bigint & { __brand: "ListTokenId" }; + +/** + * Parses a List Token ID from a string representation. + * + * @param value - The string representation of a List Token ID (uint256). + * @returns {ListTokenId} The parsed List Token ID. + */ +export function parseListTokenId(value: string): ListTokenId { + let listTokenId: bigint; + + try { + listTokenId = BigInt(value); + } catch (error) { + throw new Error( + `List Token ID "${value}" is invalid. It must be a string representation of uint256 value.`, + ); + } + + if (listTokenId < 0) { + throw new Error(`List Token ID "${value}" is invalid. It must be a non-negative value.`); + } + + return listTokenId as ListTokenId; +} diff --git a/apps/ensindexer/src/plugins/efp/plugin.ts b/apps/ensindexer/src/plugins/efp/plugin.ts new file mode 100644 index 000000000..b1a2c7178 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/plugin.ts @@ -0,0 +1,71 @@ +/** + * The EFP plugin describes indexing behavior for the Ethereum Follow Protocol. + * + * NOTE: this is an early version of an experimental EFP plugin and is not complete or production ready. + */ + +import type { ENSIndexerConfig } from "@/config/types"; +import { + type ENSIndexerPlugin, + activateHandlers, + getDatasourceAsFullyDefinedAtCompileTime, + makePluginNamespace, + networkConfigForContract, + networksConfigForChain, +} from "@/lib/plugin-helpers"; +import { DatasourceNames } from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; +import { createConfig } from "ponder"; + +const pluginName = PluginName.EFP; + +// Define the DatasourceNames for Datasources required by the plugin +const requiredDatasources = [DatasourceNames.EFPRoot]; + +// Construct a unique plugin namespace to wrap contract names +const pluginNamespace = makePluginNamespace(pluginName); + +// config object factory used to derive PonderConfig type +function createPonderConfig(config: ENSIndexerConfig) { + const { chain, contracts } = getDatasourceAsFullyDefinedAtCompileTime( + config.namespace, + DatasourceNames.EFPRoot, + ); + + return createConfig({ + networks: networksConfigForChain(config, chain.id), + contracts: { + [pluginNamespace("EFPListRegistry")]: { + network: networkConfigForContract(config, chain, contracts.EFPListRegistry), + abi: contracts.EFPListRegistry.abi, + }, + }, + }); +} + +// Implicitly define the type returned by createPonderConfig +type PonderConfig = ReturnType; + +export default { + /** + * Activate the plugin handlers for indexing. + */ + activate: activateHandlers({ + pluginName, + pluginNamespace, + handlers: () => [import("./handlers/EFPListRegistry")], + }), + + /** + * Create the ponder configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the ponder configuration + * is only created for this plugin when it is activated. + */ + createPonderConfig, + + /** The unique plugin name */ + pluginName, + + /** The plugin's required Datasources */ + requiredDatasources, +} as const satisfies ENSIndexerPlugin; diff --git a/apps/ensindexer/src/plugins/index.ts b/apps/ensindexer/src/plugins/index.ts index 740d8f588..2ed9ff52f 100644 --- a/apps/ensindexer/src/plugins/index.ts +++ b/apps/ensindexer/src/plugins/index.ts @@ -1,6 +1,7 @@ import { PluginName } from "@ensnode/ensnode-sdk"; import basenamesPlugin from "./basenames/plugin"; +import efpPlugin from "./efp/plugin"; import lineaNamesPlugin from "./lineanames/plugin"; import subgraphPlugin from "./subgraph/plugin"; import threednsPlugin from "./threedns/plugin"; @@ -10,6 +11,7 @@ export const ALL_PLUGINS = [ basenamesPlugin, lineaNamesPlugin, threednsPlugin, + efpPlugin, ] as const; export type AllPluginsConfig = MergedTypes< diff --git a/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts new file mode 100644 index 000000000..c0b989f95 --- /dev/null +++ b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts @@ -0,0 +1,229 @@ +import { + EncodedLslContract, + type ListStorageLocationContract, + ListStorageLocationType, + ListStorageLocationVersion, + decodeListStorageLocationContract, + isEncodedLslContract, + parseEncodedLsl, + validateEncodedLslContract, +} from "@/plugins/efp/lib/lsl"; +import { parseEvmAddress } from "@/plugins/efp/lib/utils"; +import { ENSNamespaceIds } from "@ensnode/datasources"; +import { describe, expect, it } from "vitest"; + +describe("EFP List Storage Location", () => { + describe("V1 EVMContract", () => { + describe("decodeListStorageLocationContract", () => { + it("should decode a valid V1 EVMContract List Storage Location for EFP mainnet", () => { + const encodedLsl = parseEncodedLsl( + "0x010100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect( + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), + ).toEqual({ + type: ListStorageLocationType.EVMContract, + version: ListStorageLocationVersion.V1, + chainId: 1, + listRecordsAddress: parseEvmAddress("0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef"), + slot: 31941687331316587819122038778003974753188806173854071805743471973008610429132n, + } satisfies ListStorageLocationContract); + }); + + it("should decode a valid V1 EVMContract List Storage Location for EFP testnet", () => { + const encodedLsl = parseEncodedLsl( + "0x01010000000000000000000000000000000000000000000000000000000000014a345289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect( + decodeListStorageLocationContract( + ENSNamespaceIds.Sepolia, + encodedLsl as EncodedLslContract, + ), + ).toEqual({ + type: ListStorageLocationType.EVMContract, + version: ListStorageLocationVersion.V1, + chainId: 84532, + listRecordsAddress: parseEvmAddress("0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef"), + slot: 31941687331316587819122038778003974753188806173854071805743471973008610429132n, + } satisfies ListStorageLocationContract); + }); + + it("should output lowercase list record address", () => { + const testListRecordsAddress = "5289fE5daBC021D02FDDf23d4a4DF96F4E0F17EF"; + const encodedLsl = parseEncodedLsl( + `0x01010000000000000000000000000000000000000000000000000000000000000001${testListRecordsAddress}469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc`, + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect( + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ).listRecordsAddress, + ).toEqual(`0x${testListRecordsAddress.toLowerCase()}`); + }); + + it("should not decode encoded LSL when chainId value is out of bounds (too small)", () => { + const encodedLsl = parseEncodedLsl( + "0x0101000000000000000000000000000000000000000000000000000000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb", + ) as EncodedLslContract; + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect(() => + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be in the accepted range + → at chainId +✖ chainId must be one of the EFP deployment Chain IDs defined for the ENSNamespace "mainnet": 8453, 10, 1 + → at chainId +`); + }); + + it("should not decode encoded LSL when chainId value is out of bounds (too large)", () => { + const encodedLsl = parseEncodedLsl( + "0x0101000000000000000000000000000000000000000000000000002000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb", + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect(() => + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be in the accepted range + → at chainId +✖ chainId must be one of the EFP deployment Chain IDs defined for the ENSNamespace "mainnet": 8453, 10, 1 + → at chainId +`); + }); + + it("should not decode encoded LSL when chainId value is not allowlisted for EFP mainnet", () => { + const encodedLsl = parseEncodedLsl( + "0x0101000000000000000000000000000000000000000000000000001fffffffffffff41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb", + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect(() => + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be one of the EFP deployment Chain IDs defined for the ENSNamespace "mainnet": 8453, 10, 1 + → at chainId +`); + }); + + it("should not decode encoded LSL when chainId value is not allowlisted for EFP testnet", () => { + const encodedLsl = parseEncodedLsl( + "0x0101000000000000000000000000000000000000000000000000001fffffffffffff41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb", + ) as EncodedLslContract; + + expect(isEncodedLslContract(encodedLsl)).toBe(true); + + expect(() => + decodeListStorageLocationContract(ENSNamespaceIds.Sepolia, encodedLsl), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be one of the EFP deployment Chain IDs defined for the ENSNamespace "sepolia": 84532, 11155420, 11155111 + → at chainId +`); + }); + + it("should not decode encoded LSL when `version` is different than 1", () => { + const encodedLsl = parseEncodedLsl( + "0x020100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ) as EncodedLslContract; + + expect(() => + decodeListStorageLocationContract(ENSNamespaceIds.Mainnet, encodedLsl), + ).toThrowError( + `Failed to decode the encoded List Storage Location contract object: +✖ Invalid input: expected "01" + → at version`, + ); + }); + + it("should not decode encoded LSL when `type` field is different than 1 (EVM Contract)", () => { + const encodedLsl = parseEncodedLsl( + "0x010200000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ) as EncodedLslContract; + + expect(() => + decodeListStorageLocationContract(ENSNamespaceIds.Mainnet, encodedLsl), + ).toThrowError( + `Failed to decode the encoded List Storage Location contract object: +✖ Invalid input: expected "01" + → at type`, + ); + }); + + it("should not decode encoded LSL when the value is not of the expected length", () => { + const encodedLsl = parseEncodedLsl("0xa22cb465"); + + expect(isEncodedLslContract(encodedLsl)).toBe(false); + }); + }); + }); + + describe("parseEncodedLsl", () => { + it("should normalize a valid encoded LSL", () => { + const encodedLsl = "0xAbCD123"; + expect(parseEncodedLsl(encodedLsl)).toBe("0xabcd123"); + }); + + it("should throw an error if value was not a hex string", () => { + const invalidEncodedLsl = "not-a-hex-string"; + expect(() => parseEncodedLsl(invalidEncodedLsl)).toThrowError( + `Encoded LSL must start with '0x'`, + ); + }); + }); + + describe("validateEncodedLslContract", () => { + it("should not throw any error for a valid encoded V1 EVMContract LSL", () => { + const encodedLsl = parseEncodedLsl( + "0x010100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ); + + expect(() => validateEncodedLslContract(encodedLsl)).to.not.Throw(); + expect(isEncodedLslContract(encodedLsl)).toBe(true); + }); + + it("should throw an error when encoded LSL was not of expected length", () => { + const encodedLsl = parseEncodedLsl("0x010100"); + + expect(() => validateEncodedLslContract(encodedLsl)).toThrowError( + `Encoded List Storage Location values for a LSL v1 Contract must be a 174-character long string`, + ); + expect(isEncodedLslContract(encodedLsl)).toBe(false); + }); + + it("should throw an error when encoded LSL was not of expected type", () => { + const encodedLsl = parseEncodedLsl( + "0x010200000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ); + + expect(() => validateEncodedLslContract(encodedLsl)).toThrowError( + `Encoded List Storage Location type value for an EVMContract LSL must be set to 01`, + ); + expect(isEncodedLslContract(encodedLsl)).toBe(false); + }); + }); +}); diff --git a/apps/ensindexer/test/plugins/efp/lib/utils.test.ts b/apps/ensindexer/test/plugins/efp/lib/utils.test.ts new file mode 100644 index 000000000..dc0fcddda --- /dev/null +++ b/apps/ensindexer/test/plugins/efp/lib/utils.test.ts @@ -0,0 +1,46 @@ +import { parseEvmAddress, parseListTokenId } from "@/plugins/efp/lib/utils"; +import { describe, expect, it } from "vitest"; + +describe("EFP utils", () => { + describe("normalizeAddress", () => { + it("should parse a valid EVM address", () => { + const evmAddress = "0x5289fE5daBC021D02FDDf23d4a4DF96F4E0F17EF"; + + expect(parseEvmAddress(evmAddress)).toBe("0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef"); + }); + + it("should throw an error for an invalid EVM address", () => { + const invalidEvmAddress = "0x12345"; + + expect(() => parseEvmAddress(invalidEvmAddress)).toThrowError(`Address "0x12345" is invalid. + +- Address must be a hex value of 20 bytes (40 hex characters). +- Address must match its checksum counterpart.`); + }); + }); + + describe("parseListTokenId", () => { + it("should parse a valid List Token ID", () => { + const listTokenId = "1234567890123456789012345678901234567890123456789012345678901234567890"; + const parsedListTokenId = parseListTokenId(listTokenId); + expect(parsedListTokenId).toBe( + 1234567890123456789012345678901234567890123456789012345678901234567890n, + ); + }); + + it("should throw an error for an invalid List Token ID", () => { + const invalidListTokenId = "invalid-token-id"; + expect(() => parseListTokenId(invalidListTokenId)).toThrowError( + `List Token ID "invalid-token-id" is invalid. It must be a string representation of uint256 value.`, + ); + }); + + it("should throw an error for a negative List Token ID", () => { + const negativeListTokenId = + "-1234567890123456789012345678901234567890123456789012345678901234567890"; + expect(() => parseListTokenId(negativeListTokenId)).toThrowError( + `List Token ID "-1234567890123456789012345678901234567890123456789012345678901234567890" is invalid. It must be a non-negative value.`, + ); + }); + }); +}); diff --git a/packages/datasources/src/abis/efp/EFPListRegistry.ts b/packages/datasources/src/abis/efp/EFPListRegistry.ts new file mode 100644 index 000000000..074999429 --- /dev/null +++ b/packages/datasources/src/abis/efp/EFPListRegistry.ts @@ -0,0 +1,468 @@ +export default [ + { inputs: [], stateMutability: "nonpayable", type: "constructor" }, + { inputs: [], name: "ApprovalCallerNotOwnerNorApproved", type: "error" }, + { inputs: [], name: "ApprovalQueryForNonexistentToken", type: "error" }, + { inputs: [], name: "BalanceQueryForZeroAddress", type: "error" }, + { inputs: [], name: "InvalidQueryRange", type: "error" }, + { inputs: [], name: "MintERC2309QuantityExceedsLimit", type: "error" }, + { inputs: [], name: "MintToZeroAddress", type: "error" }, + { inputs: [], name: "MintZeroQuantity", type: "error" }, + { inputs: [], name: "OwnerQueryForNonexistentToken", type: "error" }, + { inputs: [], name: "OwnershipNotInitializedForExtraData", type: "error" }, + { inputs: [], name: "TransferCallerNotOwnerNorApproved", type: "error" }, + { inputs: [], name: "TransferFromIncorrectOwner", type: "error" }, + { inputs: [], name: "TransferToNonERC721ReceiverImplementer", type: "error" }, + { inputs: [], name: "TransferToZeroAddress", type: "error" }, + { inputs: [], name: "URIQueryForNonexistentToken", type: "error" }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "owner", type: "address" }, + { indexed: true, internalType: "address", name: "approved", type: "address" }, + { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "Approval", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "owner", type: "address" }, + { indexed: true, internalType: "address", name: "operator", type: "address" }, + { indexed: false, internalType: "bool", name: "approved", type: "bool" }, + ], + name: "ApprovalForAll", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "uint256", name: "fromTokenId", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "toTokenId", type: "uint256" }, + { indexed: true, internalType: "address", name: "from", type: "address" }, + { indexed: true, internalType: "address", name: "to", type: "address" }, + ], + name: "ConsecutiveTransfer", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: "uint256", name: "maxMintBatchSize", type: "uint256" }, + ], + name: "MaxMintBatchSizeChange", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "enum IEFPListRegistry.MintState", + name: "mintState", + type: "uint8", + }, + ], + name: "MintStateChange", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "previousOwner", type: "address" }, + { indexed: true, internalType: "address", name: "newOwner", type: "address" }, + ], + name: "OwnershipTransferred", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "address", name: "account", type: "address" }], + name: "Paused", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "address", name: "priceOracle", type: "address" }], + name: "PriceOracleChange", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: "address", name: "tokenURIProvider", type: "address" }, + ], + name: "TokenURIProviderChange", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "from", type: "address" }, + { indexed: true, internalType: "address", name: "to", type: "address" }, + { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "Transfer", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "address", name: "account", type: "address" }], + name: "Unpaused", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, + { indexed: false, internalType: "bytes", name: "listStorageLocation", type: "bytes" }, + ], + name: "UpdateListStorageLocation", + type: "event", + }, + { + inputs: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "approve", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "contract ENS", name: "ens", type: "address" }, + { internalType: "address", name: "claimant", type: "address" }, + ], + name: "claimReverseENS", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "explicitOwnershipOf", + outputs: [ + { + components: [ + { internalType: "address", name: "addr", type: "address" }, + { internalType: "uint64", name: "startTimestamp", type: "uint64" }, + { internalType: "bool", name: "burned", type: "bool" }, + { internalType: "uint24", name: "extraData", type: "uint24" }, + ], + internalType: "struct IERC721A.TokenOwnership", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256[]", name: "tokenIds", type: "uint256[]" }], + name: "explicitOwnershipsOf", + outputs: [ + { + components: [ + { internalType: "address", name: "addr", type: "address" }, + { internalType: "uint64", name: "startTimestamp", type: "uint64" }, + { internalType: "bool", name: "burned", type: "bool" }, + { internalType: "uint24", name: "extraData", type: "uint24" }, + ], + internalType: "struct IERC721A.TokenOwnership[]", + name: "", + type: "tuple[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "getApproved", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "getListStorageLocation", + outputs: [{ internalType: "bytes", name: "", type: "bytes" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getMaxMintBatchSize", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getMintState", + outputs: [{ internalType: "enum IEFPListRegistry.MintState", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getPriceOracle", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "address", name: "operator", type: "address" }, + ], + name: "isApprovedForAll", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "bytes", name: "listStorageLocation", type: "bytes" }], + name: "mint", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "quantity", type: "uint256" }], + name: "mintBatch", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "recipient", type: "address" }, + { internalType: "uint256", name: "quantity", type: "uint256" }, + ], + name: "mintBatchTo", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "recipient", type: "address" }, + { internalType: "bytes", name: "listStorageLocation", type: "bytes" }, + ], + name: "mintTo", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "name", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "owner", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "ownerOf", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { inputs: [], name: "pause", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [], + name: "paused", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "renounceOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + { internalType: "bytes", name: "_data", type: "bytes" }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "operator", type: "address" }, + { internalType: "bool", name: "approved", type: "bool" }, + ], + name: "setApprovalForAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "tokenId", type: "uint256" }, + { internalType: "bytes", name: "listStorageLocation", type: "bytes" }, + ], + name: "setListStorageLocation", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_maxMintBatchSize", type: "uint256" }], + name: "setMaxMintBatchSize", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "enum IEFPListRegistry.MintState", name: "_mintState", type: "uint8" }, + ], + name: "setMintState", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "priceOracle_", type: "address" }], + name: "setPriceOracle", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "contract ENS", name: "ens", type: "address" }, + { internalType: "string", name: "name", type: "string" }, + ], + name: "setReverseENS", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "tokenURIProvider_", type: "address" }], + name: "setTokenURIProvider", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "bytes4", name: "interfaceId", type: "bytes4" }], + name: "supportsInterface", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "tokenURI", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "tokenURIProvider", + outputs: [{ internalType: "contract ITokenURIProvider", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "tokensOfOwner", + outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "uint256", name: "start", type: "uint256" }, + { internalType: "uint256", name: "stop", type: "uint256" }, + ], + name: "tokensOfOwnerIn", + outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "totalSupply", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "transferFrom", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "newOwner", type: "address" }], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { inputs: [], name: "unpause", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [ + { internalType: "address payable", name: "recipient", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + ], + name: "withdraw", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/packages/datasources/src/lib/types.ts b/packages/datasources/src/lib/types.ts index 93fc7207e..a1f39c338 100644 --- a/packages/datasources/src/lib/types.ts +++ b/packages/datasources/src/lib/types.ts @@ -57,6 +57,7 @@ export const DatasourceNames = { Lineanames: "lineanames", ThreeDNSOptimism: "threedns-optimism", ThreeDNSBase: "threedns-base", + EFPRoot: "efp-root", } 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 09fa94573..ba6c49c96 100644 --- a/packages/datasources/src/mainnet.ts +++ b/packages/datasources/src/mainnet.ts @@ -23,6 +23,9 @@ import { Registry as linea_Registry } from "./abis/lineanames/Registry"; import { ThreeDNSToken } from "./abis/threedns/ThreeDNSToken"; import { ResolverConfig } from "./lib/resolver"; +// ABIs for the EFP Datasource +import EFPListRegistry from "./abis/efp/EFPListRegistry"; + /** * The Mainnet ENSNamespace */ @@ -216,4 +219,20 @@ export default { }, }, }, + + /** + * The Root EFP Datasource. + * Based on the list of EFP deployments: + * https://docs.efp.app/production/deployments/ + */ + [DatasourceNames.EFPRoot]: { + chain: base, + contracts: { + EFPListRegistry: { + abi: EFPListRegistry, + address: "0x0E688f5DCa4a0a4729946ACbC44C792341714e08", + startBlock: 20197231, + }, + }, + }, } satisfies ENSNamespace; diff --git a/packages/datasources/src/sepolia.ts b/packages/datasources/src/sepolia.ts index 9b2deb2f9..ae0093ec6 100644 --- a/packages/datasources/src/sepolia.ts +++ b/packages/datasources/src/sepolia.ts @@ -22,6 +22,9 @@ import { EthRegistrarController as linea_EthRegistrarController } from "./abis/l import { NameWrapper as linea_NameWrapper } from "./abis/lineanames/NameWrapper"; import { Registry as linea_Registry } from "./abis/lineanames/Registry"; +// ABIs for the EFP Datasource +import EFPListRegistry from "./abis/efp/EFPListRegistry"; + /** * The Sepolia ENSNamespace */ @@ -173,4 +176,20 @@ export default { }, }, }, + + /** + * The Root EFP Datasource on the Sepolia testnet. + * Based on the list of EFP testnet deployments: + * https://docs.efp.app/production/deployments/#testnet-deployments + */ + [DatasourceNames.EFPRoot]: { + chain: baseSepolia, + contracts: { + EFPListRegistry: { + abi: EFPListRegistry, + address: "0xDdD39d838909bdFF7b067a5A42DC92Ad4823a26d", + startBlock: 14930823, + }, + }, + }, } satisfies ENSNamespace; diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts new file mode 100644 index 000000000..7fe83fa75 --- /dev/null +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -0,0 +1,108 @@ +/** + * Database schema definitions for indexed EFP entities. + * + * Note: These schema definitions is also used to build autogenerated GraphQL API. + */ + +import { onchainTable, relations } from "ponder"; + +/** + * EFP List Token + * + * An onchain ERC-721A NFT representing an EFP list minted with the EFPListRegistry contract. + */ +export const efp_listToken = onchainTable("efp_list_token", (p) => ({ + /** + * EFP List Token ID + * + * The ID of the ERC-721A NFT representing an EFP list minted with the EFPListRegistry contract. + * + * Represents a `unit256` value. + */ + id: p.bigint().primaryKey(), + + /** + * Owner address + * + * The address of the current owner of the EFP List Token. + * * An `EvmAddress` value, which is a lowercase EVM address. + */ + owner: p.hex().notNull(), + + /** + * ListStorageLocation ID + * + * Value of `EncodedLsl` type (optional, lowercase if present). + * + * Stores the ID of the List Storage Location. + * If the List Storage Location was never created or not in a recognized format, + * this field value will be `null`. + * + * Represents a `unit256` value. + */ + lslId: p.hex(), +})); + +/** + * EFP List Storage Locations with a recognized format. + * + * For now, all are implicitly LSL of type v1 EVM Contract. + * + * @link https://docs.efp.app/design/list-storage-location/#onchain-storage + */ +export const efp_listStorageLocation = onchainTable("efp_list_storage_location", (p) => ({ + /** + * ListStorageLocation ID + * + * An `EncodedLsl` value (always lowercase). + */ + id: p.hex().primaryKey(), + + /** + * EVM chain ID of the chain where the EFP list records are stored. + * + * An `EFPDeploymentChainId` value. Guaranteed to be an integer in + * the inclusive range of 1 to 2^53-1 (Number.MAX_SAFE_INTEGER in Javascript). + */ + chainId: p.int8({ mode: "number" }).notNull(), + + /** + * Contract address on chainId where the EFP list records are stored. + * An `EvmAddress` value, which is a lowercase EVM address. + */ + listRecordsAddress: p.hex().notNull(), + + /** + * Slot + * + * A unique identifier within the List Storage Location, distinguishes + * between multiple EFP lists stored in the same `EFPListRecords` smart contract + * by serving as the key in mappings for list operations and metadata. + * It also separates the list's data from the EFP List NFT's token ID, which + * is managed by the `EFPListRegistry` contract on Base (chain ID 8453) and + * may not be directly accessible on other Layer 2 chains (e.g., Optimism) or + * Ethereum Mainnet due to cross-chain data isolation, thereby enabling + * flexible, chain-agnostic management of list data. + * + * Represents a `unit256` value. + */ + slot: p.bigint().notNull(), +})); + +// Define relationship between the "List Token" and the "List Storage Location" entities +// Each List Token has zero-to-one List Storage Location. +// If zero it means the associated List Storage Location was never created or not in a recognized format. +// +// Note: If the List Storage Location was created, but was not recognized, +// it will be stored in the `efp_listToken.lslId` field, but there will be no +// corresponding `efp_listStorageLocation` entity in the database. +// +// List Tokens minted as a batch are created without any List Storage Location: +// - https://github.com/ethereumfollowprotocol/contracts/blob/e8d2f5c/src/EFPListRegistry.sol#L281 +// - https://github.com/ethereumfollowprotocol/contracts/blob/e8d2f5c/src/EFPListRegistry.sol#L294 +export const efp_listTokenRelations = relations(efp_listToken, ({ one }) => ({ + listStorageLocation: one(efp_listStorageLocation, { + fields: [efp_listToken.lslId], + references: [efp_listStorageLocation.id], + }), +})); diff --git a/packages/ensnode-schema/src/ponder.schema.ts b/packages/ensnode-schema/src/ponder.schema.ts index 4657c863f..8339af5af 100644 --- a/packages/ensnode-schema/src/ponder.schema.ts +++ b/packages/ensnode-schema/src/ponder.schema.ts @@ -3,3 +3,4 @@ */ export * from "./subgraph.schema"; export * from "./resolver-records.schema"; +export * from "./efp.schema"; diff --git a/packages/ensnode-sdk/src/utils/types.ts b/packages/ensnode-sdk/src/utils/types.ts index ca8ef986d..6f495ea10 100644 --- a/packages/ensnode-sdk/src/utils/types.ts +++ b/packages/ensnode-sdk/src/utils/types.ts @@ -9,6 +9,7 @@ export enum PluginName { Basenames = "basenames", Lineanames = "lineanames", ThreeDNS = "threedns", + EFP = "efp", } /**