From 5cb40d1874d2feb09f4c83300b83d76d36fc10f0 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 4 Jun 2025 11:32:35 +0200 Subject: [PATCH 01/28] feat(ensindexer): introduce the `efp` plugin This is a plugin demonstrating ENSNode's capability to index the Ethereum Follow Protocol data. --- apps/ensindexer/ponder.schema.ts | 3 + apps/ensindexer/src/lib/plugin-helpers.ts | 6 +- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 68 +++ apps/ensindexer/src/plugins/efp/efp.schema.ts | 15 + .../plugins/efp/handlers/EFPListRegistry.ts | 94 ++++ apps/ensindexer/src/plugins/index.ts | 2 + .../src/abis/efp/EFPListRegistry.ts | 468 ++++++++++++++++++ packages/ens-deployments/src/lib/types.ts | 6 + packages/ens-deployments/src/mainnet.ts | 19 + packages/ensnode-sdk/src/utils/types.ts | 1 + 10 files changed, 679 insertions(+), 3 deletions(-) create mode 100644 apps/ensindexer/src/plugins/efp/efp.plugin.ts create mode 100644 apps/ensindexer/src/plugins/efp/efp.schema.ts create mode 100644 apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts create mode 100644 packages/ens-deployments/src/abis/efp/EFPListRegistry.ts diff --git a/apps/ensindexer/ponder.schema.ts b/apps/ensindexer/ponder.schema.ts index 27bdf0a08..9904daa0c 100644 --- a/apps/ensindexer/ponder.schema.ts +++ b/apps/ensindexer/ponder.schema.ts @@ -1,2 +1,5 @@ // export the shared ponder schema export * from "@ensnode/ensnode-schema"; + +// export the EFP plugin ponder schema +export * from "@/plugins/efp/efp.schema"; diff --git a/apps/ensindexer/src/lib/plugin-helpers.ts b/apps/ensindexer/src/lib/plugin-helpers.ts index 452c3d1e3..4846099d3 100644 --- a/apps/ensindexer/src/lib/plugin-helpers.ts +++ b/apps/ensindexer/src/lib/plugin-helpers.ts @@ -110,13 +110,13 @@ export const activateHandlers = }; /** - * Get a list of unique datasources for selected plugin names. + * Get a dictionary of datasources for selected plugin names. * @param pluginNames * @returns */ export function getDatasources( config: Pick, -): Datasource[] { +): Record { const requiredDatasourceNames = getRequiredDatasourceNames(config.plugins); const ensDeployment = getENSDeployment(config.ensDeploymentChain); const ensDeploymentDatasources = Object.entries(ensDeployment) as Array< @@ -130,7 +130,7 @@ export function getDatasources( } } - return Object.values(datasources); + return datasources; } /** diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts new file mode 100644 index 000000000..7d541d095 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -0,0 +1,68 @@ +/** + * The EFP plugin describes indexing behavior for the Ethereum Follow Protocol. + */ + +import type { ENSIndexerConfig } from "@/config/types"; +import { + type ENSIndexerPlugin, + activateHandlers, + makePluginNamespace, + networkConfigForContract, + networksConfigForChain, +} from "@/lib/plugin-helpers"; +import { DatasourceName } from "@ensnode/ens-deployments"; +import { PluginName } from "@ensnode/ensnode-sdk"; +import { createConfig } from "ponder"; + +const pluginName = PluginName.EFP; + +// enlist datasources used within createPonderConfig function +// useful for config validation +const requiredDatasources = [DatasourceName.EFPBase]; + +// construct a unique contract namespace for this plugin +const namespace = makePluginNamespace(pluginName); + +// config object factory used to derive PluginConfig type +function createPonderConfig(appConfig: ENSIndexerConfig) { + const { ensDeployment } = appConfig; + // extract the chain and contract configs for root Datasource in order to build ponder config + const { chain, contracts } = ensDeployment[DatasourceName.EFPBase]; + + return createConfig({ + networks: networksConfigForChain(chain.id), + contracts: { + [namespace("EFPListRegistry")]: { + network: networkConfigForContract(chain, contracts.EFPListRegistry), + abi: contracts.EFPListRegistry.abi, + }, + }, + }); +} + +// construct a specific type for plugin configuration +type PonderConfig = ReturnType; + +export default { + /** + * Activate the plugin handlers for indexing. + */ + activate: activateHandlers({ + pluginName, + namespace, + handlers: () => [import("./handlers/EFPListRegistry")], + }), + + /** + * Load the plugin configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the plugin configuration + * is only built when the plugin is activated. + */ + createPonderConfig, + + /** The plugin name, used for identification */ + pluginName, + + /** A list of required datasources for the plugin */ + requiredDatasources, +} as const satisfies ENSIndexerPlugin; diff --git a/apps/ensindexer/src/plugins/efp/efp.schema.ts b/apps/ensindexer/src/plugins/efp/efp.schema.ts new file mode 100644 index 000000000..2e9b38f08 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/efp.schema.ts @@ -0,0 +1,15 @@ +/** + * Schema Definitions for optional EFP protocol entities. + */ + +import { onchainTable } from "ponder"; + +// NOTE: this schema type is based on information from +// https://docs.efp.app/production/interpreting-state/ +export const efpListStorageLocation = onchainTable("efp_list_storage_location", (p) => ({ + // list token ID — + tokenId: p.bigint().primaryKey(), + chainId: p.bigint().notNull(), + listRecordsAddress: p.hex().notNull(), + slot: p.bigint().notNull(), +})); 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..9f86e3623 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -0,0 +1,94 @@ +import { ponder } from "ponder:registry"; +import { efpListStorageLocation } from "ponder:schema"; + +import config from "@/config"; +import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers"; +import { DatasourceName } from "@ensnode/ens-deployments"; +import { PluginName } from "@ensnode/ensnode-sdk"; +import { zeroAddress } from "viem"; +import type { Address } from "viem/accounts"; +import { getAddress } from "viem/utils"; + +const efpListRegistryContract = + config.ensDeployment[DatasourceName.EFPBase].contracts["EFPListRegistry"]; + +export default function ({ namespace }: ENSIndexerPluginHandlerArgs) { + /// + /// EFPListRegistry Handlers + /// + ponder.on( + namespace("EFPListRegistry:Transfer"), + async function handleListTokenMint({ context, event }) { + const { tokenId, from } = event.args; + + if (from !== zeroAddress) { + // this is not a token mint transaction + return; + } + + const listStorageLocationRaw = await context.client.readContract({ + abi: efpListRegistryContract.abi, + address: efpListRegistryContract.address, + functionName: "getListStorageLocation", + args: [tokenId], + }); + + try { + const { version, type, chainId, listRecordsContract, slot } = + parseListStorageLocation(listStorageLocationRaw); + + await context.db.insert(efpListStorageLocation).values({ + chainId, + tokenId, + listRecordsAddress: listRecordsContract, + slot, + version, + type, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + console.error( + `Could not update list storage location for tx ${event.transaction.hash}. Error: ${errorMessage}`, + ); + } + }, + ); +} + +// NOTE: copied from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 +/** + * Parses a List Storage Location string and returns a ListStorageLocation object. + * + * @param lsl - The List Storage Location string to parse. + * @returns A ListStorageLocation object with parsed data. + */ +export function parseListStorageLocation(lsl: string): ListStorageLocation { + if (lsl.length < 174) { + throw new Error("List Storage Location value must be 174-character long string"); + } + + const lslVersion = lsl.slice(2, 4); // Extract the first byte after the 0x (2 hex characters = 1 byte) + const lslType = lsl.slice(4, 6); // Extract the second byte + const lslChainId = BigInt("0x" + lsl.slice(6, 70)); // Extract the next 32 bytes to get the chain id + // NOTE: updated parser + const lslListRecordsContract = getAddress("0x" + lsl.slice(70, 110)); // Extract the address (40 hex characters = 20 bytes) + const lslSlot = BigInt("0x" + lsl.slice(110, 174)); // Extract the slot + return { + version: lslVersion, + type: lslType, + chainId: lslChainId, + listRecordsContract: lslListRecordsContract, + slot: lslSlot, + }; +} + +// NOTE: copied from https://github.com/ethereumfollowprotocol/onchain/blob/598ab49/src/types.ts#L41-L47 +type ListStorageLocation = { + version: string; + type: string; + chainId: bigint; + // NOTE: updated type + listRecordsContract: Address; + slot: bigint; +}; diff --git a/apps/ensindexer/src/plugins/index.ts b/apps/ensindexer/src/plugins/index.ts index 84092d012..e80e384b0 100644 --- a/apps/ensindexer/src/plugins/index.ts +++ b/apps/ensindexer/src/plugins/index.ts @@ -2,6 +2,7 @@ import { uniq } from "@/lib/lib-helpers"; import { DatasourceName } from "@ensnode/ens-deployments"; import { PluginName } from "@ensnode/ensnode-sdk"; import basenamesPlugin from "./basenames/basenames.plugin"; +import efpPlugin from "./efp/efp.plugin"; import lineaNamesPlugin from "./lineanames/lineanames.plugin"; import subgraphPlugin from "./subgraph/subgraph.plugin"; import threednsPlugin from "./threedns/threedns.plugin"; @@ -11,6 +12,7 @@ export const ALL_PLUGINS = [ basenamesPlugin, lineaNamesPlugin, threednsPlugin, + efpPlugin, ] as const; export type AllPluginsConfig = MergedTypes< diff --git a/packages/ens-deployments/src/abis/efp/EFPListRegistry.ts b/packages/ens-deployments/src/abis/efp/EFPListRegistry.ts new file mode 100644 index 000000000..074999429 --- /dev/null +++ b/packages/ens-deployments/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/ens-deployments/src/lib/types.ts b/packages/ens-deployments/src/lib/types.ts index 40489d698..bd4a41920 100644 --- a/packages/ens-deployments/src/lib/types.ts +++ b/packages/ens-deployments/src/lib/types.ts @@ -33,6 +33,7 @@ export enum DatasourceName { Lineanames = "lineanames", ThreeDNSOptimism = "threedns-optimism", ThreeDNSBase = "threedns-base", + EFPBase = "efp-base", } /** @@ -98,4 +99,9 @@ export type ENSDeployment = { * The Datasource for 3DNS-Powered Names on Base */ [DatasourceName.ThreeDNSBase]?: Datasource; + + /** + * The Datasource for Ethereum Follow Protocol on Base + */ + [DatasourceName.EFPBase]?: Datasource; }; diff --git a/packages/ens-deployments/src/mainnet.ts b/packages/ens-deployments/src/mainnet.ts index 4e7487b86..69b235fc3 100644 --- a/packages/ens-deployments/src/mainnet.ts +++ b/packages/ens-deployments/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 EFP Datasource +import EFPListRegistry from "./abis/efp/EFPListRegistry"; + /** * The Mainnet ENSDeployment */ @@ -216,4 +219,20 @@ export default { }, }, }, + + /** + * The EFP Datasource on Base. + * Addresses, ABIs and start blocks defined based on a list of EFP deployments: + * https://docs.efp.app/production/deployments/ + */ + [DatasourceName.EFPBase]: { + chain: base, + contracts: { + EFPListRegistry: { + abi: EFPListRegistry, + address: "0x0E688f5DCa4a0a4729946ACbC44C792341714e08", + startBlock: 20197231, + }, + }, + }, } satisfies ENSDeployment; 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", } /** From 114021e601b75bd6765dbd6b8b5734de8ae3ffaf Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 4 Jun 2025 12:15:31 +0200 Subject: [PATCH 02/28] docs(ensindexer): efp plugin schema docs update --- apps/ensindexer/src/plugins/efp/efp.schema.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/plugins/efp/efp.schema.ts b/apps/ensindexer/src/plugins/efp/efp.schema.ts index 2e9b38f08..15c8c04fd 100644 --- a/apps/ensindexer/src/plugins/efp/efp.schema.ts +++ b/apps/ensindexer/src/plugins/efp/efp.schema.ts @@ -7,9 +7,15 @@ import { onchainTable } from "ponder"; // NOTE: this schema type is based on information from // https://docs.efp.app/production/interpreting-state/ export const efpListStorageLocation = onchainTable("efp_list_storage_location", (p) => ({ - // list token ID — + // List ID tokenId: p.bigint().primaryKey(), + + // EVM chain ID of the chain where the list is stored chainId: p.bigint().notNull(), + + // EVM address of the contract where the list is stored listRecordsAddress: p.hex().notNull(), + + // Specifies the storage slot of the list within the contract slot: p.bigint().notNull(), })); From 797e7de8ccb504fd3f9323e35dee828bff25982b Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 4 Jun 2025 15:57:55 +0200 Subject: [PATCH 03/28] docs(ensindexer): align code docs (comments) across all plugins --- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 11 ++++++----- .../src/plugins/lineanames/lineanames.plugin.ts | 9 ++++----- .../src/plugins/subgraph/subgraph.plugin.ts | 9 ++++----- .../src/plugins/threedns/threedns.plugin.ts | 9 ++++----- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts index 7d541d095..bde1da919 100644 --- a/apps/ensindexer/src/plugins/efp/efp.plugin.ts +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -1,5 +1,7 @@ /** * The EFP plugin describes indexing behavior for the Ethereum Follow Protocol. + * + * NOTE: this is an early version of the experimental EFP plugin and is not complete or production ready. */ import type { ENSIndexerConfig } from "@/config/types"; @@ -16,8 +18,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.EFP; -// enlist datasources used within createPonderConfig function -// useful for config validation +// Define the Datasources required by the plugin const requiredDatasources = [DatasourceName.EFPBase]; // construct a unique contract namespace for this plugin @@ -40,7 +41,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// construct a specific type for plugin configuration +// Implicitly define the type returned by createPluginConfig type PonderConfig = ReturnType; export default { @@ -60,9 +61,9 @@ export default { */ createPonderConfig, - /** The plugin name, used for identification */ + /** The unique plugin name */ pluginName, - /** A list of required datasources for the plugin */ + /** The plugin's required Datasources */ requiredDatasources, } as const satisfies ENSIndexerPlugin; diff --git a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts index a7ddf8bb5..5efaaa626 100644 --- a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts +++ b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts @@ -16,8 +16,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.Lineanames; -// enlist datasources used within createPonderConfig function -// useful for config validation +// Define the Datasources required by the plugin const requiredDatasources = [DatasourceName.Lineanames]; // construct a unique contract namespace for this plugin @@ -56,7 +55,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// construct a specific type for plugin configuration +// Implicitly define the type returned by createPluginConfig type PonderConfig = ReturnType; export default { @@ -81,9 +80,9 @@ export default { */ createPonderConfig, - /** The plugin name, used for identification */ + /** The unique plugin name */ pluginName, - /** A list of required datasources for the plugin */ + /** The plugin's required Datasources */ requiredDatasources, } as const satisfies ENSIndexerPlugin; diff --git a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts index fcfc3e611..e32f03266 100644 --- a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts @@ -17,8 +17,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.Subgraph; -// enlist datasources used within createPonderConfig function -// useful for config validation +// Define the Datasources required by the plugin const requiredDatasources = [DatasourceName.Root]; // construct a unique contract namespace for this plugin @@ -65,7 +64,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// construct a specific type for plugin configuration +// Implicitly define the type returned by createPluginConfig type PonderConfig = ReturnType; export default { @@ -90,9 +89,9 @@ export default { */ createPonderConfig, - /** The plugin name, used for identification */ + /** The unique plugin name */ pluginName, - /** A list of required datasources for the plugin */ + /** The plugin's required Datasources */ requiredDatasources, } as const satisfies ENSIndexerPlugin; diff --git a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts index d8e3d5482..0a34f25e9 100644 --- a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts +++ b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts @@ -16,8 +16,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.ThreeDNS; -// enlist datasources used within createPonderConfig function -// useful for config validation +// Define the Datasources required by the plugin const requiredDatasources = [DatasourceName.ThreeDNSOptimism, DatasourceName.ThreeDNSBase]; // construct a unique contract namespace for this plugin @@ -55,7 +54,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// construct a specific type for plugin configuration +// Implicitly define the type returned by createPluginConfig type PonderConfig = ReturnType; export default { @@ -75,9 +74,9 @@ export default { */ createPonderConfig, - /** The plugin name, used for identification */ + /** The unique plugin name */ pluginName, - /** A list of required datasources for the plugin */ + /** The plugin's required Datasources */ requiredDatasources, } as const satisfies ENSIndexerPlugin; From 4dc678c3fc709a8994bfff9bcec58c6c0d10bf69 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 4 Jun 2025 15:59:26 +0200 Subject: [PATCH 04/28] refactor(ensindexer): move efp schema into ensnode-schema package --- apps/ensindexer/ponder.schema.ts | 3 --- .../plugins/efp => packages/ensnode-schema/src}/efp.schema.ts | 0 packages/ensnode-schema/src/ponder.schema.ts | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) rename {apps/ensindexer/src/plugins/efp => packages/ensnode-schema/src}/efp.schema.ts (100%) diff --git a/apps/ensindexer/ponder.schema.ts b/apps/ensindexer/ponder.schema.ts index 9904daa0c..27bdf0a08 100644 --- a/apps/ensindexer/ponder.schema.ts +++ b/apps/ensindexer/ponder.schema.ts @@ -1,5 +1,2 @@ // export the shared ponder schema export * from "@ensnode/ensnode-schema"; - -// export the EFP plugin ponder schema -export * from "@/plugins/efp/efp.schema"; diff --git a/apps/ensindexer/src/plugins/efp/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts similarity index 100% rename from apps/ensindexer/src/plugins/efp/efp.schema.ts rename to packages/ensnode-schema/src/efp.schema.ts 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"; From 57ef002ceb8c8c80fa249e266ce2759c6d4e2bce Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 4 Jun 2025 16:04:18 +0200 Subject: [PATCH 05/28] feat(ensindexer): extends the efp schema and document it well --- apps/ensindexer/src/lib/api-documentation.ts | 15 +++ .../plugins/efp/handlers/EFPListRegistry.ts | 100 ++++++++++++++---- packages/ensnode-schema/src/efp.schema.ts | 100 +++++++++++++++--- 3 files changed, 181 insertions(+), 34 deletions(-) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index aa231d0c6..2d4cda7b7 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -305,5 +305,20 @@ 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", + ownerAddress: "EVM address of the owner of an EFP List Token", + listStorageLocation: "A reference to the related ListStorageLocation entity", + }), + ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { + chainId: "The 32-byte EVM chain ID of the chain where the list is stored", + listRecordsAddress: "The 20-byte EVM address of the contract where the list is stored", + slot: "A 32-byte value that specifies the storage slot of the list within the 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 Ethereum and inaccessible on L2s.", + listTokenId: "Unique identifier for this EFP list token", + listToken: "A reference to the related ListToken entity", + }), }); }; diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 9f86e3623..9f7ce0452 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -1,5 +1,5 @@ import { ponder } from "ponder:registry"; -import { efpListStorageLocation } from "ponder:schema"; +import { efp_listStorageLocation, efp_listToken } from "ponder:schema"; import config from "@/config"; import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers"; @@ -19,37 +19,53 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs ({ - // List ID - tokenId: p.bigint().primaryKey(), +/** + * EFP List Token + * + * Represents an onchain owned token minted with EFPListRegistry contract. + */ +export const efp_listToken = onchainTable("efp_list_token", (p) => ({ + /** + * List Token ID + * + * It's an ID of ERC-721A token minted with EFPListRegistry contract. + * It's a very important value as it enables + * `getListStorageLocation(tokenId)` call on the EFPListRegistry contract, + * which result allows querying data for related list records. + */ + id: p.bigint().primaryKey(), + + /** + * Owner address + * + * An address of the current owner of the EFP List Token. + */ + ownerAddress: p.hex().notNull(), +})); + +/** + * EFP List Storage Location + * + * @link https://docs.efp.app/design/list-storage-location/#onchain-storage + */ +export const efp_listStorageLocation = onchainTable( + "efp_list_storage_location", + (p) => ({ + /** + * Chain ID + * + * The 32-byte EVM chain ID of the chain where the list is stored. + */ + chainId: p.bigint().notNull(), + + /** + * List records contract address + * + * The 20-byte EVM address of the contract where the list is stored. + */ + listRecordsAddress: p.hex().notNull(), - // EVM chain ID of the chain where the list is stored - chainId: p.bigint().notNull(), + /** + * Slot + * + * A 32-byte value that specifies the storage slot of the list within the 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 Ethereum + * and inaccessible on L2s. + */ + slot: p.bigint().notNull(), - // EVM address of the contract where the list is stored - listRecordsAddress: p.hex().notNull(), + /** + * List Token ID + * + * A reference to EFP List Token entity. + */ + listTokenId: p.bigint().notNull(), + }), + (table) => ({ + /** + * An EFP Storage Location can be uniquely specified via three pieces of data: + * - `chain_id` (which is the `chainId` in the schema) + * - `contract_address` (which is the `listRecordsAddress` in the schema) + * - `slot` + */ + pk: primaryKey({ columns: [table.chainId, table.listRecordsAddress, table.slot] }), + }), +); + +// Define relationship between the "List Token" and the "List Storage Location" entities +// One List Token has exactly one List Storage Location. +export const efp_listTokenRelations = relations(efp_listToken, ({ one }) => ({ + listStorageLocation: one(efp_listStorageLocation, { + fields: [efp_listToken.id], + references: [efp_listStorageLocation.listTokenId], + }), +})); - // Specifies the storage slot of the list within the contract - slot: p.bigint().notNull(), +// Define relationship between the "List Storage Location" and the "List Token" entities +// One List Storage Location has exactly one List Token. +export const efp_listStorageLocationRelations = relations(efp_listStorageLocation, ({ one }) => ({ + listToken: one(efp_listToken, { + fields: [efp_listStorageLocation.listTokenId], + references: [efp_listToken.id], + }), })); From 2ac7ddbf7bdcc515b1a53cfc9b4de38089374bc3 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 12:57:58 +0200 Subject: [PATCH 06/28] feat(ensindexer): include EFPRoot datasource in Sepolia ENS Deployment --- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 4 +-- .../plugins/efp/handlers/EFPListRegistry.ts | 4 +-- packages/ens-deployments/src/lib/types.ts | 6 ++--- packages/ens-deployments/src/mainnet.ts | 6 ++--- packages/ens-deployments/src/sepolia.ts | 19 +++++++++++++- packages/ensnode-schema/src/efp.schema.ts | 26 +++++++++---------- 6 files changed, 40 insertions(+), 25 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts index bde1da919..aa70f8c3b 100644 --- a/apps/ensindexer/src/plugins/efp/efp.plugin.ts +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -19,7 +19,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.EFP; // Define the Datasources required by the plugin -const requiredDatasources = [DatasourceName.EFPBase]; +const requiredDatasources = [DatasourceName.EFPRoot]; // construct a unique contract namespace for this plugin const namespace = makePluginNamespace(pluginName); @@ -28,7 +28,7 @@ const namespace = makePluginNamespace(pluginName); function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; // extract the chain and contract configs for root Datasource in order to build ponder config - const { chain, contracts } = ensDeployment[DatasourceName.EFPBase]; + const { chain, contracts } = ensDeployment[DatasourceName.EFPRoot]; return createConfig({ networks: networksConfigForChain(chain.id), diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 9f7ce0452..30c4bd830 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -10,7 +10,7 @@ import type { Address } from "viem/accounts"; import { getAddress } from "viem/utils"; const efpListRegistryContract = - config.ensDeployment[DatasourceName.EFPBase].contracts["EFPListRegistry"]; + config.ensDeployment[DatasourceName.EFPRoot].contracts["EFPListRegistry"]; export default function ({ namespace }: ENSIndexerPluginHandlerArgs) { /// @@ -127,7 +127,7 @@ type ListStorageLocation = { type: string; /** - * The 32-byte EVM chain ID of the chain where the list is stored. + * 32-byte EVM chain ID of the chain where the EFP list records are stored. * A.k.a. `chain_id` */ chainId: bigint; diff --git a/packages/ens-deployments/src/lib/types.ts b/packages/ens-deployments/src/lib/types.ts index bd4a41920..847a45d57 100644 --- a/packages/ens-deployments/src/lib/types.ts +++ b/packages/ens-deployments/src/lib/types.ts @@ -33,7 +33,7 @@ export enum DatasourceName { Lineanames = "lineanames", ThreeDNSOptimism = "threedns-optimism", ThreeDNSBase = "threedns-base", - EFPBase = "efp-base", + EFPRoot = "efp-base", } /** @@ -101,7 +101,7 @@ export type ENSDeployment = { [DatasourceName.ThreeDNSBase]?: Datasource; /** - * The Datasource for Ethereum Follow Protocol on Base + * The Datasource for Ethereum Follow Protocol Root */ - [DatasourceName.EFPBase]?: Datasource; + [DatasourceName.EFPRoot]?: Datasource; }; diff --git a/packages/ens-deployments/src/mainnet.ts b/packages/ens-deployments/src/mainnet.ts index 69b235fc3..5a9d976b7 100644 --- a/packages/ens-deployments/src/mainnet.ts +++ b/packages/ens-deployments/src/mainnet.ts @@ -23,7 +23,7 @@ import { Registry as linea_Registry } from "./abis/lineanames/Registry"; import { ThreeDNSToken } from "./abis/threedns/ThreeDNSToken"; import { ResolverConfig } from "./lib/resolver"; -// ABIS for EFP Datasource +// ABIs for the EFP Datasource import EFPListRegistry from "./abis/efp/EFPListRegistry"; /** @@ -221,11 +221,11 @@ export default { }, /** - * The EFP Datasource on Base. + * The EFP Root Datasource. * Addresses, ABIs and start blocks defined based on a list of EFP deployments: * https://docs.efp.app/production/deployments/ */ - [DatasourceName.EFPBase]: { + [DatasourceName.EFPRoot]: { chain: base, contracts: { EFPListRegistry: { diff --git a/packages/ens-deployments/src/sepolia.ts b/packages/ens-deployments/src/sepolia.ts index 289d447ab..b2305f1ce 100644 --- a/packages/ens-deployments/src/sepolia.ts +++ b/packages/ens-deployments/src/sepolia.ts @@ -1,8 +1,9 @@ -import { sepolia } from "viem/chains"; +import { baseSepolia, sepolia } from "viem/chains"; import { ResolverConfig } from "./lib/resolver"; import { DatasourceName, type ENSDeployment } from "./lib/types"; +import EFPListRegistry from "./abis/efp/EFPListRegistry"; // ABIs for Root Datasource import { BaseRegistrar as root_BaseRegistrar } from "./abis/root/BaseRegistrar"; import { EthRegistrarController as root_EthRegistrarController } from "./abis/root/EthRegistrarController"; @@ -65,4 +66,20 @@ export default { * linea.eth's L1Resolver is deployed to Sepolia, but we do not index Linea Sepolia names here. * https://github.com/Consensys/linea-ens/tree/main/packages/linea-ens-resolver/deployments/sepolia */ + + /** + * The EFP Root Datasource. + * Addresses, ABIs and start blocks defined based on a list of EFP testnet deployments: + * https://docs.efp.app/production/deployments/ + */ + [DatasourceName.EFPRoot]: { + chain: baseSepolia, + contracts: { + EFPListRegistry: { + abi: EFPListRegistry, + address: "0xDdD39d838909bdFF7b067a5A42DC92Ad4823a26d", + startBlock: 14930823, + }, + }, + }, } satisfies ENSDeployment; diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 4f7600730..152bac2da 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -1,5 +1,5 @@ /** - * Schema Definitions for optional EFP protocol entities. + * Schema definitions for EFP entities. */ import { onchainTable, primaryKey, relations } from "ponder"; @@ -7,13 +7,13 @@ import { onchainTable, primaryKey, relations } from "ponder"; /** * EFP List Token * - * Represents an onchain owned token minted with EFPListRegistry contract. + * Represents an onchain ERC-721A NFT representing an EFP list minted with the EFPListRegistry contract. */ export const efp_listToken = onchainTable("efp_list_token", (p) => ({ /** - * List Token ID + * EFP List Token ID * - * It's an ID of ERC-721A token minted with EFPListRegistry contract. + * The ID of the ERC-721A NFT representing an EFP list minted with the EFPListRegistry contract. * It's a very important value as it enables * `getListStorageLocation(tokenId)` call on the EFPListRegistry contract, * which result allows querying data for related list records. @@ -23,7 +23,7 @@ export const efp_listToken = onchainTable("efp_list_token", (p) => ({ /** * Owner address * - * An address of the current owner of the EFP List Token. + * The address of the current owner of the EFP List Token. */ ownerAddress: p.hex().notNull(), })); @@ -39,21 +39,19 @@ export const efp_listStorageLocation = onchainTable( /** * Chain ID * - * The 32-byte EVM chain ID of the chain where the list is stored. + * 32-byte EVM chain ID of the chain where the EFP list records are stored. */ chainId: p.bigint().notNull(), /** - * List records contract address - * - * The 20-byte EVM address of the contract where the list is stored. + * Contract address where the EFP list records are stored. */ listRecordsAddress: p.hex().notNull(), /** * Slot * - * A 32-byte value that specifies the storage slot of the list within the contract. + * 32-byte value that specifies the storage slot of the list within the 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 Ethereum * and inaccessible on L2s. @@ -61,9 +59,9 @@ export const efp_listStorageLocation = onchainTable( slot: p.bigint().notNull(), /** - * List Token ID + * EFP List Token ID * - * A reference to EFP List Token entity. + * References the associated EFP List Token entity. */ listTokenId: p.bigint().notNull(), }), @@ -79,7 +77,7 @@ export const efp_listStorageLocation = onchainTable( ); // Define relationship between the "List Token" and the "List Storage Location" entities -// One List Token has exactly one List Storage Location. +// Each List Token has zero-to-one List Storage Location. export const efp_listTokenRelations = relations(efp_listToken, ({ one }) => ({ listStorageLocation: one(efp_listStorageLocation, { fields: [efp_listToken.id], @@ -88,7 +86,7 @@ export const efp_listTokenRelations = relations(efp_listToken, ({ one }) => ({ })); // Define relationship between the "List Storage Location" and the "List Token" entities -// One List Storage Location has exactly one List Token. +// Each List Storage Location is associated with exactly one List Token. export const efp_listStorageLocationRelations = relations(efp_listStorageLocation, ({ one }) => ({ listToken: one(efp_listToken, { fields: [efp_listStorageLocation.listTokenId], From 9ba059121e78b3433a04fcaff74cc354056a157e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 16:42:41 +0200 Subject: [PATCH 07/28] code docs updates --- apps/ensindexer/src/lib/api-documentation.ts | 7 ++++--- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 4 ++-- .../src/plugins/lineanames/lineanames.plugin.ts | 2 +- .../src/plugins/subgraph/subgraph.plugin.ts | 2 +- .../src/plugins/threedns/threedns.plugin.ts | 2 +- packages/ens-deployments/src/lib/types.ts | 2 +- packages/ens-deployments/src/mainnet.ts | 4 ++-- packages/ens-deployments/src/sepolia.ts | 12 +++++++----- 8 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index 2d4cda7b7..2d1e4ef0c 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -310,13 +310,14 @@ const makeApiDocumentation = (isSubgraph: boolean) => { */ ...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Token", { id: "Unique token ID for an EFP List Token", - ownerAddress: "EVM address of the owner of an EFP List Token", - listStorageLocation: "A reference to the related ListStorageLocation entity", + ownerAddress: "EVM address of the owner of the EFP List Token", + listStorageLocation: "A reference to an optional related ListStorageLocation entity", }), ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { + id: "A compound identifier based on the following values: chainId, listRecordsAddress, slot", chainId: "The 32-byte EVM chain ID of the chain where the list is stored", listRecordsAddress: "The 20-byte EVM address of the contract where the list is stored", - slot: "A 32-byte value that specifies the storage slot of the list within the 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 Ethereum and inaccessible on L2s.", + slot: "A 32-byte value that specifies the storage slot of the list within the 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 Root deployment chain and inaccessible on other chains.", listTokenId: "Unique identifier for this EFP list token", listToken: "A reference to the related ListToken entity", }), diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts index aa70f8c3b..c25215bb6 100644 --- a/apps/ensindexer/src/plugins/efp/efp.plugin.ts +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -27,7 +27,7 @@ const namespace = makePluginNamespace(pluginName); // config object factory used to derive PluginConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; - // extract the chain and contract configs for root Datasource in order to build ponder config + // extract the chain and contract configs for the EFP root Datasource in order to build ponder config const { chain, contracts } = ensDeployment[DatasourceName.EFPRoot]; return createConfig({ @@ -41,7 +41,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// Implicitly define the type returned by createPluginConfig +// Implicitly define the type returned by createPonderConfig type PonderConfig = ReturnType; export default { diff --git a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts index 5efaaa626..e7b358666 100644 --- a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts +++ b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts @@ -55,7 +55,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// Implicitly define the type returned by createPluginConfig +// Implicitly define the type returned by createPonderConfig type PonderConfig = ReturnType; export default { diff --git a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts index e32f03266..5adfeb32c 100644 --- a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts @@ -64,7 +64,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// Implicitly define the type returned by createPluginConfig +// Implicitly define the type returned by createPonderConfig type PonderConfig = ReturnType; export default { diff --git a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts index 0a34f25e9..77f351844 100644 --- a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts +++ b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts @@ -54,7 +54,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) { }); } -// Implicitly define the type returned by createPluginConfig +// Implicitly define the type returned by createPonderConfig type PonderConfig = ReturnType; export default { diff --git a/packages/ens-deployments/src/lib/types.ts b/packages/ens-deployments/src/lib/types.ts index 847a45d57..046b25f44 100644 --- a/packages/ens-deployments/src/lib/types.ts +++ b/packages/ens-deployments/src/lib/types.ts @@ -33,7 +33,7 @@ export enum DatasourceName { Lineanames = "lineanames", ThreeDNSOptimism = "threedns-optimism", ThreeDNSBase = "threedns-base", - EFPRoot = "efp-base", + EFPRoot = "efp-root", } /** diff --git a/packages/ens-deployments/src/mainnet.ts b/packages/ens-deployments/src/mainnet.ts index 5a9d976b7..85a36eb51 100644 --- a/packages/ens-deployments/src/mainnet.ts +++ b/packages/ens-deployments/src/mainnet.ts @@ -221,8 +221,8 @@ export default { }, /** - * The EFP Root Datasource. - * Addresses, ABIs and start blocks defined based on a list of EFP deployments: + * The Root EFP Datasource. + * Based on the list of EFP deployments: * https://docs.efp.app/production/deployments/ */ [DatasourceName.EFPRoot]: { diff --git a/packages/ens-deployments/src/sepolia.ts b/packages/ens-deployments/src/sepolia.ts index b2305f1ce..5cc6f2c22 100644 --- a/packages/ens-deployments/src/sepolia.ts +++ b/packages/ens-deployments/src/sepolia.ts @@ -61,15 +61,17 @@ export default { }, }, /** - * The Sepolia ENSDeployment has no known Datasource for Basenames. + * NOTE: * - * linea.eth's L1Resolver is deployed to Sepolia, but we do not index Linea Sepolia names here. - * https://github.com/Consensys/linea-ens/tree/main/packages/linea-ens-resolver/deployments/sepolia + * The Sepolia ENSDeployment has no known Datasource for Basenames. + * + * linea.eth's L1Resolver is deployed to Sepolia, but we do not index Linea Sepolia names here. + * https://github.com/Consensys/linea-ens/tree/main/packages/linea-ens-resolver/deployments/sepolia */ /** - * The EFP Root Datasource. - * Addresses, ABIs and start blocks defined based on a list of EFP testnet deployments: + * The Root EFP Datasource. + * Based on the list of EFP testnet deployments: * https://docs.efp.app/production/deployments/ */ [DatasourceName.EFPRoot]: { From cbfa373e7c5ee3d979af8d194c5e875801697ae4 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 16:44:34 +0200 Subject: [PATCH 08/28] feat(ensindexer): create schema-based EFP LSL parser --- .../plugins/efp/handlers/EFPListRegistry.ts | 207 +++++++----------- apps/ensindexer/src/plugins/efp/lib/ids.ts | 18 ++ .../src/plugins/efp/lib/lsl-parser.ts | 196 +++++++++++++++++ apps/ensindexer/src/plugins/efp/lib/types.ts | 40 ++++ packages/ensnode-schema/src/efp.schema.ts | 79 ++++--- 5 files changed, 378 insertions(+), 162 deletions(-) create mode 100644 apps/ensindexer/src/plugins/efp/lib/ids.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts create mode 100644 apps/ensindexer/src/plugins/efp/lib/types.ts diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 30c4bd830..f15d9f191 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -3,14 +3,10 @@ import { efp_listStorageLocation, efp_listToken } from "ponder:schema"; import config from "@/config"; import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers"; -import { DatasourceName } from "@ensnode/ens-deployments"; import { PluginName } from "@ensnode/ensnode-sdk"; import { zeroAddress } from "viem"; -import type { Address } from "viem/accounts"; -import { getAddress } from "viem/utils"; - -const efpListRegistryContract = - config.ensDeployment[DatasourceName.EFPRoot].contracts["EFPListRegistry"]; +import { makeListStorageLocationId } from "../lib/ids"; +import { getEFPChainIds, parseEncodedListStorageLocation } from "../lib/lsl-parser"; export default function ({ namespace }: ENSIndexerPluginHandlerArgs) { /// @@ -18,133 +14,100 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs `${chainId}-${listRecordsAddress}-${slot.toString()}`; diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts b/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts new file mode 100644 index 000000000..079d43c63 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts @@ -0,0 +1,196 @@ +/** + * EFP List Storage Location parser + */ + +import type { ENSDeploymentChain } from "@ensnode/ens-deployments"; +import { base, baseSepolia, mainnet, optimism, optimismSepolia, sepolia } from "viem/chains"; +import { getAddress } from "viem/utils"; +import { prettifyError, z } from "zod/v4"; +import { ListStorageLocation } from "./types"; + +// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 +/** + * Parses an encoded List Storage Location string and returns a decoded ListStorageLocation object. + * + * Each List Storage Location is encoded as a bytes array with the following structure: + * - `version`: A uint8 representing the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. + * - `location_type`: A uint8 indicating the type of list storage location. This identifies the kind of data the data field contains.. + * - `data:` A bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + * + * @param encodedLsl - The encoded List Storage Location string to parse. + * @param efpChainIds - The list of chain IDs where EFP is present + * @throws An error if parsing could not be completed successfully. + * @returns A decoded {@link ListStorageLocation} object. + */ +export function parseEncodedListStorageLocation( + encodedLsl: string, + efpChainIds: number[], +): ListStorageLocation { + const parserContext = { + inputLength: encodedLsl.length, + } satisfies LslParserContext; + + const slicedEncodedLsl = sliceEncodedLsl(encodedLsl); + + const efpLslSchema = createEfpLslSchema({ + efpChainIds, + }); + + const parsed = efpLslSchema.safeParse({ + ...slicedEncodedLsl, + ...parserContext, + }); + + if (!parsed.success) { + throw new Error( + "Failed to parse environment configuration: \n" + prettifyError(parsed.error) + "\n", + ); + } + return parsed.data; +} + +/** + * Get a list of EFP Chain IDs for the ENS Deployment Chain. + * + * @param ensDeploymentChain + * @returns list of EFP chain IDs + */ +export function getEFPChainIds(ensDeploymentChain: ENSDeploymentChain): number[] { + switch (ensDeploymentChain) { + case "mainnet": + return [base.id, optimism.id, mainnet.id]; + case "sepolia": + return [baseSepolia.id, optimismSepolia.id, sepolia.id]; + default: + throw new Error( + `LSL Chain IDs are not configured for ${ensDeploymentChain} ENS Deployment Chain`, + ); + } +} + +/** + * Data structure used as a subject of parsing with {@link createEfpLslSchema}. + */ +interface EncodedListStorageLocation { + /** + * 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. + * The version is always 1. + */ + 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. + * The location type is always 1. + */ + type: string; + + /** + * 32-byte EVM chain ID of the chain where the EFP list records are stored. + */ + chainId: string; + + /** + * The 20-byte EVM address of the contract where the list is stored. + */ + listRecordsAddress: string; + + /** + * The 32-byte value that specifies the storage slot of the list within the 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 Ethereum and + * inaccessible on L2s. + */ + slot: string; +} + +/** + * Slice encoded LSL into a dictionary of LSL params. + * @param encodedLsl + * @returns {EncodedListStorageLocation} LSL params. + */ +function sliceEncodedLsl(encodedLsl: string): EncodedListStorageLocation { + return { + // Extract the first byte after the 0x (2 hex characters = 1 byte) + version: encodedLsl.slice(2, 4), + + // Extract the second byte + type: encodedLsl.slice(4, 6), + + // Extract the next 32 bytes to get the chain id + chainId: encodedLsl.slice(6, 70), + + // Extract the address (40 hex characters = 20 bytes) + listRecordsAddress: encodedLsl.slice(70, 110), + + // Extract last 32 bytes to get the slot + slot: encodedLsl.slice(110, 174), + } satisfies EncodedListStorageLocation; +} + +/** + * Context object to be used by the {@link parseEncodedListStorageLocation} parser. + */ +interface LslParserContext { + /** + * The length of an encodedLsl string. Used for validation purposes only. + */ + inputLength: number; +} + +/** + * Options required for data parsing with {@link createEfpLslSchema}. + */ +interface CreateEfpLslSchemaOptions { + /** + * List of IDs for chains that the EFP protocol has been present on. + */ + efpChainIds: number[]; +} + +/** + * Create a zod schema covering validations and invariants enforced with {@link parseEncodedListStorageLocation} parser. + */ +const createEfpLslSchema = (options: CreateEfpLslSchemaOptions) => + z + .object({ + inputLength: z.literal(174), + + version: z.literal("01").transform(() => 1 as const), + + type: z.literal("01").transform(() => 1 as const), + + chainId: z + .string() + .length(64) + .transform((v) => BigInt("0x" + v)) + .refine((v) => v > BigInt(Number.MIN_SAFE_INTEGER) || v < BigInt(Number.MAX_SAFE_INTEGER), { + message: + "chainId must be a value between Number.MIN_SAFE_INTEGER and Number.MAX_SAFE_INTEGER", + }) + .transform((v) => Number(v)), + + listRecordsAddress: z + .string() + .length(40) + .transform((v) => `0x${v}`) + .transform((v) => getAddress(v)), + + slot: z + .string() + .length(64) + .transform((v) => BigInt("0x" + v)), + }) + // invariant: chainId is from one of the chains that EFP has been present on + // https://docs.efp.app/production/deployments/ + .refine((v) => options.efpChainIds.includes(v.chainId), { + message: `chainId must be one of the EFP Chain IDs: ${options.efpChainIds.join(", ")}`, + }) + // + // leave parser context props out of the output object + .transform(({ inputLength, ...decodedLsl }) => decodedLsl); diff --git a/apps/ensindexer/src/plugins/efp/lib/types.ts b/apps/ensindexer/src/plugins/efp/lib/types.ts new file mode 100644 index 000000000..947de75f0 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/types.ts @@ -0,0 +1,40 @@ +// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/598ab49/src/types.ts#L41-L47 + +import type { Address } from "viem/accounts"; + +// Documented based on https://docs.efp.app/design/list-storage-location/ +export interface ListStorageLocation { + /** + * The version of the List Storage Location. + * + * This is used to ensure compatibility and facilitate future upgrades. + * The version is always 1. + */ + version: 1; + + /** + * The type of the List Storage Location. + * + * This identifies the kind of data the data field contains. + * The location type is always 1. + */ + type: 1; + + /** + * 32-byte EVM chain ID of the chain where the EFP list records are stored. + */ + chainId: number; + + /** + * The 20-byte EVM address of the contract where the list is stored. + */ + listRecordsAddress: Address; + + /** + * The 32-byte value that specifies the storage slot of the list within the 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 Ethereum and + * inaccessible on L2s. + */ + slot: bigint; +} diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 152bac2da..69e357a55 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -2,7 +2,7 @@ * Schema definitions for EFP entities. */ -import { onchainTable, primaryKey, relations } from "ponder"; +import { onchainTable, relations } from "ponder"; /** * EFP List Token @@ -33,48 +33,47 @@ export const efp_listToken = onchainTable("efp_list_token", (p) => ({ * * @link https://docs.efp.app/design/list-storage-location/#onchain-storage */ -export const efp_listStorageLocation = onchainTable( - "efp_list_storage_location", - (p) => ({ - /** - * Chain ID - * - * 32-byte EVM chain ID of the chain where the EFP list records are stored. - */ - chainId: p.bigint().notNull(), +export const efp_listStorageLocation = onchainTable("efp_list_storage_location", (p) => ({ + /** + * ListStorageLocation ID + * + * This compound identifier that includes: + * - chainId + * - listRecordsAddress + * - slot + * + * NOTE: + * We use a compound identifier for database performance benefits. + */ + id: p.text().primaryKey(), - /** - * Contract address where the EFP list records are stored. - */ - listRecordsAddress: p.hex().notNull(), + /** + * EVM chain ID of the chain where the EFP list records are stored. + */ + chainId: p.integer().notNull(), - /** - * Slot - * - * 32-byte value that specifies the storage slot of the list within the 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 Ethereum - * and inaccessible on L2s. - */ - slot: p.bigint().notNull(), + /** + * Contract address where the EFP list records are stored. + */ + listRecordsAddress: p.hex().notNull(), - /** - * EFP List Token ID - * - * References the associated EFP List Token entity. - */ - listTokenId: p.bigint().notNull(), - }), - (table) => ({ - /** - * An EFP Storage Location can be uniquely specified via three pieces of data: - * - `chain_id` (which is the `chainId` in the schema) - * - `contract_address` (which is the `listRecordsAddress` in the schema) - * - `slot` - */ - pk: primaryKey({ columns: [table.chainId, table.listRecordsAddress, table.slot] }), - }), -); + /** + * Slot + * + * 32-byte value that specifies the storage slot of the list within the 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 Ethereum + * and inaccessible on L2s. + */ + slot: p.bigint().notNull(), + + /** + * EFP List Token ID + * + * References the associated EFP List Token entity. + */ + listTokenId: 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. From cd0e03018f7702735de1d046439765d1affaa54d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 16:51:07 +0200 Subject: [PATCH 09/28] code docs updates --- apps/ensindexer/src/plugins/basenames/basenames.plugin.ts | 8 ++++---- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 8 ++++---- .../src/plugins/lineanames/lineanames.plugin.ts | 8 ++++---- apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts | 8 ++++---- apps/ensindexer/src/plugins/threedns/threedns.plugin.ts | 8 ++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts b/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts index 32a5a1f99..c7a27637d 100644 --- a/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts +++ b/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts @@ -22,7 +22,7 @@ const requiredDatasources = [DatasourceName.Basenames]; // construct a unique contract namespace for this plugin const namespace = makePluginNamespace(pluginName); -// config object factory used to derive PluginConfig type +// config object factory used to derive PonderConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; // depending on the ENS Deployment, the chain and contracts for the Basenames Datasource can vary. For example, consider how the Basenames chain and contracts chain depending on the mainnet vs sepolia ENS Deployment @@ -73,9 +73,9 @@ export default { }), /** - * Load the plugin configuration lazily to prevent premature execution of - * nested factory functions, i.e. to ensure that the plugin configuration - * is only built when the plugin is activated. + * Create the ponder configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the ponder configuration + * is created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts index c25215bb6..e7d2080fd 100644 --- a/apps/ensindexer/src/plugins/efp/efp.plugin.ts +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -24,7 +24,7 @@ const requiredDatasources = [DatasourceName.EFPRoot]; // construct a unique contract namespace for this plugin const namespace = makePluginNamespace(pluginName); -// config object factory used to derive PluginConfig type +// config object factory used to derive PonderConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; // extract the chain and contract configs for the EFP root Datasource in order to build ponder config @@ -55,9 +55,9 @@ export default { }), /** - * Load the plugin configuration lazily to prevent premature execution of - * nested factory functions, i.e. to ensure that the plugin configuration - * is only built when the plugin is activated. + * Create the ponder configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the ponder configuration + * is created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts index e7b358666..324cb2f59 100644 --- a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts +++ b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts @@ -22,7 +22,7 @@ const requiredDatasources = [DatasourceName.Lineanames]; // construct a unique contract namespace for this plugin const namespace = makePluginNamespace(pluginName); -// config object factory used to derive PluginConfig type +// config object factory used to derive PonderConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; // extract the chain and contract configs for Lineanames Datasource in order to build ponder config @@ -74,9 +74,9 @@ export default { }), /** - * Load the plugin configuration lazily to prevent premature execution of - * nested factory functions, i.e. to ensure that the plugin configuration - * is only built when the plugin is activated. + * Create the ponder configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the ponder configuration + * is created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts index 5adfeb32c..6d4541e7a 100644 --- a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts @@ -23,7 +23,7 @@ const requiredDatasources = [DatasourceName.Root]; // construct a unique contract namespace for this plugin const namespace = makePluginNamespace(pluginName); -// config object factory used to derive PluginConfig type +// config object factory used to derive PonderConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; // extract the chain and contract configs for root Datasource in order to build ponder config @@ -83,9 +83,9 @@ export default { }), /** - * Load the plugin configuration lazily to prevent premature execution of - * nested factory functions, i.e. to ensure that the plugin configuration - * is only built when the plugin is activated. + * Create the ponder configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the ponder configuration + * is created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts index 77f351844..d133e2a98 100644 --- a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts +++ b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts @@ -22,7 +22,7 @@ const requiredDatasources = [DatasourceName.ThreeDNSOptimism, DatasourceName.Thr // construct a unique contract namespace for this plugin const namespace = makePluginNamespace(pluginName); -// config object factory used to derive PluginConfig type +// config object factory used to derive PonderConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; // extract the chain and contract configs for root Datasource in order to build ponder config @@ -68,9 +68,9 @@ export default { }), /** - * Load the plugin configuration lazily to prevent premature execution of - * nested factory functions, i.e. to ensure that the plugin configuration - * is only built when the plugin is activated. + * Create the ponder configuration lazily to prevent premature execution of + * nested factory functions, i.e. to ensure that the ponder configuration + * is created for this plugin when it is activated. */ createPonderConfig, From 2445b092f389429cb84bcf3fa347dd75376406f9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 18:44:39 +0200 Subject: [PATCH 10/28] code docs updates --- apps/ensindexer/src/lib/api-documentation.ts | 6 +++--- apps/ensindexer/src/plugins/basenames/basenames.plugin.ts | 2 +- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 2 +- apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts | 2 +- apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts | 2 +- apps/ensindexer/src/plugins/threedns/threedns.plugin.ts | 2 +- packages/ens-deployments/src/sepolia.ts | 1 + 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index 2d1e4ef0c..05a1a986f 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -315,9 +315,9 @@ const makeApiDocumentation = (isSubgraph: boolean) => { }), ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { id: "A compound identifier based on the following values: chainId, listRecordsAddress, slot", - chainId: "The 32-byte EVM chain ID of the chain where the list is stored", - listRecordsAddress: "The 20-byte EVM address of the contract where the list is stored", - slot: "A 32-byte value that specifies the storage slot of the list within the 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 Root deployment chain and inaccessible on other chains.", + chainId: "EVM chain ID of the chain where the list is stored", + listRecordsAddress: "EVM address of the contract where the list is stored", + slot: "The 32-byte value that specifies the storage slot of the list within the 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 Root deployment chain and inaccessible on other chains.", listTokenId: "Unique identifier for this EFP list token", listToken: "A reference to the related ListToken entity", }), diff --git a/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts b/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts index c7a27637d..54dab30dd 100644 --- a/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts +++ b/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts @@ -75,7 +75,7 @@ export default { /** * Create the ponder configuration lazily to prevent premature execution of * nested factory functions, i.e. to ensure that the ponder configuration - * is created for this plugin when it is activated. + * is only created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts index e7d2080fd..a761e31ba 100644 --- a/apps/ensindexer/src/plugins/efp/efp.plugin.ts +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -57,7 +57,7 @@ export default { /** * Create the ponder configuration lazily to prevent premature execution of * nested factory functions, i.e. to ensure that the ponder configuration - * is created for this plugin when it is activated. + * is only created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts index 324cb2f59..858fcce89 100644 --- a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts +++ b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts @@ -76,7 +76,7 @@ export default { /** * Create the ponder configuration lazily to prevent premature execution of * nested factory functions, i.e. to ensure that the ponder configuration - * is created for this plugin when it is activated. + * is only created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts index 6d4541e7a..9a9b95e1c 100644 --- a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts @@ -85,7 +85,7 @@ export default { /** * Create the ponder configuration lazily to prevent premature execution of * nested factory functions, i.e. to ensure that the ponder configuration - * is created for this plugin when it is activated. + * is only created for this plugin when it is activated. */ createPonderConfig, diff --git a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts index d133e2a98..8293f4702 100644 --- a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts +++ b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts @@ -70,7 +70,7 @@ export default { /** * Create the ponder configuration lazily to prevent premature execution of * nested factory functions, i.e. to ensure that the ponder configuration - * is created for this plugin when it is activated. + * is only created for this plugin when it is activated. */ createPonderConfig, diff --git a/packages/ens-deployments/src/sepolia.ts b/packages/ens-deployments/src/sepolia.ts index 5cc6f2c22..76e487b98 100644 --- a/packages/ens-deployments/src/sepolia.ts +++ b/packages/ens-deployments/src/sepolia.ts @@ -3,6 +3,7 @@ import { baseSepolia, sepolia } from "viem/chains"; import { ResolverConfig } from "./lib/resolver"; import { DatasourceName, type ENSDeployment } from "./lib/types"; +// ABIs for the EFP Datasource import EFPListRegistry from "./abis/efp/EFPListRegistry"; // ABIs for Root Datasource import { BaseRegistrar as root_BaseRegistrar } from "./abis/root/BaseRegistrar"; From 40a5c2e5aba638c73dba6e82ce0f8992c2d7b371 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 18:45:15 +0200 Subject: [PATCH 11/28] feat(ensindexer): use precise types while parsing LSL data --- .../src/plugins/efp/lib/lsl-parser.ts | 69 ++++++------------- apps/ensindexer/src/plugins/efp/lib/types.ts | 32 +++++++-- 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts b/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts index 079d43c63..1f5e4bfc4 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts @@ -6,7 +6,11 @@ import type { ENSDeploymentChain } from "@ensnode/ens-deployments"; import { base, baseSepolia, mainnet, optimism, optimismSepolia, sepolia } from "viem/chains"; import { getAddress } from "viem/utils"; import { prettifyError, z } from "zod/v4"; -import { ListStorageLocation } from "./types"; +import { + type ListStorageLocation, + ListStorageLocationType, + ListStorageLocationVersion, +} from "./types"; // NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 /** @@ -19,27 +23,17 @@ import { ListStorageLocation } from "./types"; * * @param encodedLsl - The encoded List Storage Location string to parse. * @param efpChainIds - The list of chain IDs where EFP is present - * @throws An error if parsing could not be completed successfully. * @returns A decoded {@link ListStorageLocation} object. + * @throws An error if parsing could not be completed successfully. */ export function parseEncodedListStorageLocation( encodedLsl: string, efpChainIds: number[], ): ListStorageLocation { - const parserContext = { - inputLength: encodedLsl.length, - } satisfies LslParserContext; - const slicedEncodedLsl = sliceEncodedLsl(encodedLsl); + const efpLslSchema = createEfpLslSchema(efpChainIds); - const efpLslSchema = createEfpLslSchema({ - efpChainIds, - }); - - const parsed = efpLslSchema.safeParse({ - ...slicedEncodedLsl, - ...parserContext, - }); + const parsed = efpLslSchema.safeParse(slicedEncodedLsl); if (!parsed.success) { throw new Error( @@ -69,7 +63,7 @@ export function getEFPChainIds(ensDeploymentChain: ENSDeploymentChain): number[] } /** - * Data structure used as a subject of parsing with {@link createEfpLslSchema}. + * Data structure used during parsing with {@link createEfpLslSchema} schema. */ interface EncodedListStorageLocation { /** @@ -77,7 +71,6 @@ interface EncodedListStorageLocation { * * Formatted as the string representation of a `uint8` value. * This is used to ensure compatibility and facilitate future upgrades. - * The version is always 1. */ version: string; @@ -86,7 +79,6 @@ interface EncodedListStorageLocation { * * Formatted as the string representation of a `uint8` value. * This identifies the kind of data the data field contains. - * The location type is always 1. */ type: string; @@ -113,8 +105,13 @@ interface EncodedListStorageLocation { * Slice encoded LSL into a dictionary of LSL params. * @param encodedLsl * @returns {EncodedListStorageLocation} LSL params. + * @throws {Error} when input param is not of expected length */ function sliceEncodedLsl(encodedLsl: string): EncodedListStorageLocation { + if (encodedLsl.length !== 174) { + throw new Error("Encoded List Storage Location values must be a 174-character long string"); + } + return { // Extract the first byte after the 0x (2 hex characters = 1 byte) version: encodedLsl.slice(2, 4), @@ -133,37 +130,16 @@ function sliceEncodedLsl(encodedLsl: string): EncodedListStorageLocation { } satisfies EncodedListStorageLocation; } -/** - * Context object to be used by the {@link parseEncodedListStorageLocation} parser. - */ -interface LslParserContext { - /** - * The length of an encodedLsl string. Used for validation purposes only. - */ - inputLength: number; -} - -/** - * Options required for data parsing with {@link createEfpLslSchema}. - */ -interface CreateEfpLslSchemaOptions { - /** - * List of IDs for chains that the EFP protocol has been present on. - */ - efpChainIds: number[]; -} - /** * Create a zod schema covering validations and invariants enforced with {@link parseEncodedListStorageLocation} parser. + * @param {number[]} efpChainIds List of IDs for chains that the EFP protocol has been present on. */ -const createEfpLslSchema = (options: CreateEfpLslSchemaOptions) => +const createEfpLslSchema = (efpChainIds: number[]) => z .object({ - inputLength: z.literal(174), + version: z.literal("01").transform(() => ListStorageLocationVersion.V1), - version: z.literal("01").transform(() => 1 as const), - - type: z.literal("01").transform(() => 1 as const), + type: z.literal("01").transform(() => ListStorageLocationType.EVMContract), chainId: z .string() @@ -188,9 +164,6 @@ const createEfpLslSchema = (options: CreateEfpLslSchemaOptions) => }) // invariant: chainId is from one of the chains that EFP has been present on // https://docs.efp.app/production/deployments/ - .refine((v) => options.efpChainIds.includes(v.chainId), { - message: `chainId must be one of the EFP Chain IDs: ${options.efpChainIds.join(", ")}`, - }) - // - // leave parser context props out of the output object - .transform(({ inputLength, ...decodedLsl }) => decodedLsl); + .refine((v) => efpChainIds.includes(v.chainId), { + message: `chainId must be one of the EFP Chain IDs: ${efpChainIds.join(", ")}`, + }); diff --git a/apps/ensindexer/src/plugins/efp/lib/types.ts b/apps/ensindexer/src/plugins/efp/lib/types.ts index 947de75f0..9983d66f4 100644 --- a/apps/ensindexer/src/plugins/efp/lib/types.ts +++ b/apps/ensindexer/src/plugins/efp/lib/types.ts @@ -10,7 +10,7 @@ export interface ListStorageLocation { * This is used to ensure compatibility and facilitate future upgrades. * The version is always 1. */ - version: 1; + version: ListStorageLocationVersion.V1; /** * The type of the List Storage Location. @@ -18,15 +18,15 @@ export interface ListStorageLocation { * This identifies the kind of data the data field contains. * The location type is always 1. */ - type: 1; + type: ListStorageLocationType.EVMContract; /** - * 32-byte EVM chain ID of the chain where the EFP list records are stored. + * EVM chain ID of the chain where the EFP list records are stored. */ chainId: number; /** - * The 20-byte EVM address of the contract where the list is stored. + * EVM address of the contract where the list is stored. */ listRecordsAddress: Address; @@ -38,3 +38,27 @@ export interface ListStorageLocation { */ slot: bigint; } + +/** + * Enum defining List Storage Location Types + * + * Based on documentation at: + * https://docs.efp.app/design/list-storage-location/#location-types + */ +export enum ListStorageLocationType { + /** + * EVMContract Data representation: + * 32-byte chain ID + 20-byte contract address + 32-byte slot + */ + EVMContract = 1, +} + +/** + * Enum defining List Storage Location Version + * + * Based on documentation at: + * https://docs.efp.app/design/list-storage-location/#serialization + */ +export enum ListStorageLocationVersion { + V1 = 1, +} From 45701e33d312a44752a93c9574b59ffc508345a2 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 5 Jun 2025 18:46:19 +0200 Subject: [PATCH 12/28] fix(ensindexer): make all EFP handlers wrapped within a function This helps avoid type errors when `efp` plugin was not activated --- .../plugins/efp/handlers/EFPListRegistry.ts | 107 ++++++++---------- 1 file changed, 50 insertions(+), 57 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index f15d9f191..8165fce99 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -45,69 +45,62 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs Date: Thu, 5 Jun 2025 18:56:54 +0200 Subject: [PATCH 13/28] code docs updates --- .../src/plugins/efp/handlers/EFPListRegistry.ts | 2 +- apps/ensindexer/src/plugins/efp/lib/ids.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 8165fce99..56870dce0 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -51,7 +51,7 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs Date: Thu, 5 Jun 2025 19:00:12 +0200 Subject: [PATCH 14/28] docs(changeset): Introduce initial `efp` plugin demo. --- .changeset/upset-glasses-strive.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/upset-glasses-strive.md diff --git a/.changeset/upset-glasses-strive.md b/.changeset/upset-glasses-strive.md new file mode 100644 index 000000000..612ad5ccb --- /dev/null +++ b/.changeset/upset-glasses-strive.md @@ -0,0 +1,8 @@ +--- +"@ensnode/ens-deployments": minor +"@ensnode/ensnode-schema": minor +"@ensnode/ensnode-sdk": minor +"ensindexer": minor +--- + +Introduce initial `efp` plugin demo. From d8ecb6e189f8b53a98b7207f62328c41b6be1cd9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Jun 2025 11:23:33 +0200 Subject: [PATCH 15/28] apply PR feedback --- apps/ensindexer/src/lib/api-documentation.ts | 8 +- .../src/plugins/basenames/basenames.plugin.ts | 2 +- apps/ensindexer/src/plugins/efp/efp.plugin.ts | 5 +- .../plugins/efp/handlers/EFPListRegistry.ts | 30 ++-- apps/ensindexer/src/plugins/efp/lib/ids.ts | 17 --- .../plugins/efp/lib/{lsl-parser.ts => lsl.ts} | 143 +++++++++++------- apps/ensindexer/src/plugins/efp/lib/types.ts | 42 +++-- .../plugins/lineanames/lineanames.plugin.ts | 2 +- .../src/plugins/subgraph/subgraph.plugin.ts | 2 +- .../src/plugins/threedns/threedns.plugin.ts | 2 +- packages/ens-deployments/src/lib/types.ts | 2 +- packages/ensnode-schema/src/efp.schema.ts | 18 +-- 12 files changed, 152 insertions(+), 121 deletions(-) delete mode 100644 apps/ensindexer/src/plugins/efp/lib/ids.ts rename apps/ensindexer/src/plugins/efp/lib/{lsl-parser.ts => lsl.ts} (53%) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index 05a1a986f..f0e0514a7 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -311,13 +311,15 @@ const makeApiDocumentation = (isSubgraph: boolean) => { ...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Token", { id: "Unique token ID for an EFP List Token", ownerAddress: "EVM address of the owner of the EFP List Token", - listStorageLocation: "A reference to an optional related ListStorageLocation entity", + listStorageLocation: + "A reference the related ListStorageLocation entity. Null if no related ListStorageLocation was ever created or the related ListStorageLocation is in an invalid format.", }), ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { id: "A compound identifier based on the following values: chainId, listRecordsAddress, slot", chainId: "EVM chain ID of the chain where the list is stored", - listRecordsAddress: "EVM address of the contract where the list is stored", - slot: "The 32-byte value that specifies the storage slot of the list within the 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 Root deployment chain and inaccessible on other chains.", + listRecordsAddress: + "EVM address of the contract on chainId where the EFP list records are stored", + slot: "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 Root deployment chain and inaccessible on other chains.", listTokenId: "Unique identifier for this EFP list token", listToken: "A reference to the related ListToken entity", }), diff --git a/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts b/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts index 54dab30dd..e03768298 100644 --- a/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts +++ b/apps/ensindexer/src/plugins/basenames/basenames.plugin.ts @@ -16,7 +16,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.Basenames; -// Define the Datasources required by the plugin +// Define the DatasourceNames for Datasources required by the plugin const requiredDatasources = [DatasourceName.Basenames]; // construct a unique contract namespace for this plugin diff --git a/apps/ensindexer/src/plugins/efp/efp.plugin.ts b/apps/ensindexer/src/plugins/efp/efp.plugin.ts index a761e31ba..5d586ddf1 100644 --- a/apps/ensindexer/src/plugins/efp/efp.plugin.ts +++ b/apps/ensindexer/src/plugins/efp/efp.plugin.ts @@ -18,7 +18,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.EFP; -// Define the Datasources required by the plugin +// Define the DatasourceNames for Datasources required by the plugin const requiredDatasources = [DatasourceName.EFPRoot]; // construct a unique contract namespace for this plugin @@ -27,7 +27,8 @@ const namespace = makePluginNamespace(pluginName); // config object factory used to derive PonderConfig type function createPonderConfig(appConfig: ENSIndexerConfig) { const { ensDeployment } = appConfig; - // extract the chain and contract configs for the EFP root Datasource in order to build ponder config + // Extract the chain and contract configs for the EFP root Datasource in order to build ponder config. + // This auto-selects the correct EFP chains and contracts depending if indexing testnet or not. const { chain, contracts } = ensDeployment[DatasourceName.EFPRoot]; return createConfig({ diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 56870dce0..e632229be 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -5,8 +5,7 @@ import config from "@/config"; import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers"; import { PluginName } from "@ensnode/ensnode-sdk"; import { zeroAddress } from "viem"; -import { makeListStorageLocationId } from "../lib/ids"; -import { getEFPChainIds, parseEncodedListStorageLocation } from "../lib/lsl-parser"; +import { decodeListStorageLocationContract, makeListStorageLocationId } from "../lib/lsl"; export default function ({ namespace }: ENSIndexerPluginHandlerArgs) { /// @@ -30,7 +29,7 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs `${chainId}-${listRecordsAddress}-${slot.toString()}`; diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts similarity index 53% rename from apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts rename to apps/ensindexer/src/plugins/efp/lib/lsl.ts index 1f5e4bfc4..b4cf29dac 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl-parser.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -1,43 +1,50 @@ /** - * EFP List Storage Location parser + * EFP List Storage Location utilities */ import type { ENSDeploymentChain } from "@ensnode/ens-deployments"; +import type { Address } from "viem"; import { base, baseSepolia, mainnet, optimism, optimismSepolia, sepolia } from "viem/chains"; import { getAddress } from "viem/utils"; import { prettifyError, z } from "zod/v4"; import { - type ListStorageLocation, + type ListStorageLocationContract, ListStorageLocationType, ListStorageLocationVersion, } from "./types"; -// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 /** - * Parses an encoded List Storage Location string and returns a decoded ListStorageLocation object. - * - * Each List Storage Location is encoded as a bytes array with the following structure: + * An encoded List Storage Location is a bytes array with the following structure: * - `version`: A uint8 representing the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. * - `location_type`: A uint8 indicating the type of list storage location. This identifies the kind of data the data field contains.. * - `data:` A bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + */ +type EncodedLsl = string; + +// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 +/** + * Parses an encoded List Storage Location string and returns a decoded ListStorageLocation object. * - * @param encodedLsl - The encoded List Storage Location string to parse. - * @param efpChainIds - The list of chain IDs where EFP is present - * @returns A decoded {@link ListStorageLocation} object. + * @param {ENSDeploymentChain} ensDeploymentChain - The ENS Deployment Chain + * @param {EncodedLsl} encodedLsl - The encoded List Storage Location string to parse. + * @returns A decoded {@link ListStorageLocationContract} object. * @throws An error if parsing could not be completed successfully. */ -export function parseEncodedListStorageLocation( - encodedLsl: string, - efpChainIds: number[], -): ListStorageLocation { +export function decodeListStorageLocationContract( + ensDeploymentChain: ENSDeploymentChain, + encodedLsl: EncodedLsl, +): ListStorageLocationContract { const slicedEncodedLsl = sliceEncodedLsl(encodedLsl); - const efpLslSchema = createEfpLslSchema(efpChainIds); + const efpChainIds = getEFPChainIds(ensDeploymentChain); + const efpLslContractSchema = createEfpLslContractSchema(efpChainIds); - const parsed = efpLslSchema.safeParse(slicedEncodedLsl); + const parsed = efpLslContractSchema.safeParse(slicedEncodedLsl); if (!parsed.success) { throw new Error( - "Failed to parse environment configuration: \n" + prettifyError(parsed.error) + "\n", + "Failed to decode the encoded List Storage Location contract object: \n" + + prettifyError(parsed.error) + + "\n", ); } return parsed.data; @@ -49,12 +56,12 @@ export function parseEncodedListStorageLocation( * @param ensDeploymentChain * @returns list of EFP chain IDs */ -export function getEFPChainIds(ensDeploymentChain: ENSDeploymentChain): number[] { +export function getEFPChainIds(ensDeploymentChain: ENSDeploymentChain): bigint[] { switch (ensDeploymentChain) { case "mainnet": - return [base.id, optimism.id, mainnet.id]; + return [BigInt(base.id), BigInt(optimism.id), BigInt(mainnet.id)]; case "sepolia": - return [baseSepolia.id, optimismSepolia.id, sepolia.id]; + return [BigInt(baseSepolia.id), BigInt(optimismSepolia.id), BigInt(sepolia.id)]; default: throw new Error( `LSL Chain IDs are not configured for ${ensDeploymentChain} ENS Deployment Chain`, @@ -88,12 +95,12 @@ interface EncodedListStorageLocation { chainId: string; /** - * The 20-byte EVM address of the contract where the list is stored. + * The 20-byte EVM address of the contract on chainId where the EFP list records are stored. */ listRecordsAddress: string; /** - * The 32-byte value that specifies the storage slot of the list within the contract. + * 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 Ethereum and * inaccessible on L2s. @@ -131,39 +138,63 @@ function sliceEncodedLsl(encodedLsl: string): EncodedListStorageLocation { } /** - * Create a zod schema covering validations and invariants enforced with {@link parseEncodedListStorageLocation} parser. - * @param {number[]} efpChainIds List of IDs for chains that the EFP protocol has been present on. + * Create a zod schema covering validations and invariants enforced with {@link decodeListStorageLocationContract} parser. + * @param {bigint[]} efpChainIds List of IDs for chains that the EFP protocol has been present on. + */ +const createEfpLslContractSchema = (efpChainIds: bigint[]) => + z.object({ + version: z.literal("01").transform(() => ListStorageLocationVersion.V1), + + type: z.literal("01").transform(() => ListStorageLocationType.EVMContract), + + chainId: z + .string() + .length(64) + .transform((v) => BigInt("0x" + v)) + // invariant: chainId is from one of the supported EFP deployment chains + // https://docs.efp.app/production/deployments/ + .refine((v) => efpChainIds.includes(v), { + message: `chainId must be one of the EFP Chain IDs: ${efpChainIds.join(", ")}`, + }), + + listRecordsAddress: z + .string() + .length(40) + .transform((v) => `0x${v}`) + // ensure EVM address correctness and map it into lowercase for increased data model safety + .transform((v) => getAddress(v).toLowerCase() as Address), + + slot: z + .string() + .length(64) + .transform((v) => BigInt("0x" + v)), + }); + +/** + * Unique EFP List Storage Location ID (lowercase) + * + * Example: + * `${chainId}-${listRecordsAddress}-${slot}-${type}-${version}` + */ +export type ListStorageLocationId = string; + +/** + * Makes a unique lowercase EFP List Storage Location ID. + * + * @param {ListStorageLocationContract} listStorageLocationContract a decoded List Storage Location Contract object + * @returns a unique lowercase List Storage Location ID */ -const createEfpLslSchema = (efpChainIds: number[]) => - z - .object({ - version: z.literal("01").transform(() => ListStorageLocationVersion.V1), - - type: z.literal("01").transform(() => ListStorageLocationType.EVMContract), - - chainId: z - .string() - .length(64) - .transform((v) => BigInt("0x" + v)) - .refine((v) => v > BigInt(Number.MIN_SAFE_INTEGER) || v < BigInt(Number.MAX_SAFE_INTEGER), { - message: - "chainId must be a value between Number.MIN_SAFE_INTEGER and Number.MAX_SAFE_INTEGER", - }) - .transform((v) => Number(v)), - - listRecordsAddress: z - .string() - .length(40) - .transform((v) => `0x${v}`) - .transform((v) => getAddress(v)), - - slot: z - .string() - .length(64) - .transform((v) => BigInt("0x" + v)), - }) - // invariant: chainId is from one of the chains that EFP has been present on - // https://docs.efp.app/production/deployments/ - .refine((v) => efpChainIds.includes(v.chainId), { - message: `chainId must be one of the EFP Chain IDs: ${efpChainIds.join(", ")}`, - }); +export const makeListStorageLocationId = ({ + chainId, + listRecordsAddress, + slot, + type, + version, +}: ListStorageLocationContract): ListStorageLocationId => + [ + chainId.toString(), + listRecordsAddress.toLowerCase(), + slot.toString(), + type.toString(), + version.toString(), + ].join("-"); diff --git a/apps/ensindexer/src/plugins/efp/lib/types.ts b/apps/ensindexer/src/plugins/efp/lib/types.ts index 9983d66f4..8b837afea 100644 --- a/apps/ensindexer/src/plugins/efp/lib/types.ts +++ b/apps/ensindexer/src/plugins/efp/lib/types.ts @@ -2,36 +2,54 @@ import type { Address } from "viem/accounts"; -// Documented based on https://docs.efp.app/design/list-storage-location/ -export interface ListStorageLocation { +/** + * 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. - * The version is always 1. */ - version: ListStorageLocationVersion.V1; + version: LSLVersion; /** * The type of the List Storage Location. * * This identifies the kind of data the data field contains. - * The location type is always 1. */ - type: ListStorageLocationType.EVMContract; + 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: number; + chainId: bigint; /** - * EVM address of the contract where the list is stored. + * EVM address of the contract on chainId where the EFP list records are stored. */ listRecordsAddress: Address; /** - * The 32-byte value that specifies the storage slot of the list within the contract. + * 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 Ethereum and * inaccessible on L2s. @@ -40,21 +58,21 @@ export interface ListStorageLocation { } /** - * Enum defining List Storage Location Types + * 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 representation: + * EVMContract Data List Storage Location Type encoding: * 32-byte chain ID + 20-byte contract address + 32-byte slot */ EVMContract = 1, } /** - * Enum defining List Storage Location Version + * Enum defining recognized List Storage Location Versions * * Based on documentation at: * https://docs.efp.app/design/list-storage-location/#serialization diff --git a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts index 858fcce89..0b4510494 100644 --- a/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts +++ b/apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts @@ -16,7 +16,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.Lineanames; -// Define the Datasources required by the plugin +// Define the DatasourceNames for Datasources required by the plugin const requiredDatasources = [DatasourceName.Lineanames]; // construct a unique contract namespace for this plugin diff --git a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts index 9a9b95e1c..cea6be4fa 100644 --- a/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts @@ -17,7 +17,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.Subgraph; -// Define the Datasources required by the plugin +// Define the DatasourceNames for Datasources required by the plugin const requiredDatasources = [DatasourceName.Root]; // construct a unique contract namespace for this plugin diff --git a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts index 8293f4702..83d77ea9a 100644 --- a/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts +++ b/apps/ensindexer/src/plugins/threedns/threedns.plugin.ts @@ -16,7 +16,7 @@ import { createConfig } from "ponder"; const pluginName = PluginName.ThreeDNS; -// Define the Datasources required by the plugin +// Define the DatasourceNames for Datasources required by the plugin const requiredDatasources = [DatasourceName.ThreeDNSOptimism, DatasourceName.ThreeDNSBase]; // construct a unique contract namespace for this plugin diff --git a/packages/ens-deployments/src/lib/types.ts b/packages/ens-deployments/src/lib/types.ts index 046b25f44..d05ce40b6 100644 --- a/packages/ens-deployments/src/lib/types.ts +++ b/packages/ens-deployments/src/lib/types.ts @@ -101,7 +101,7 @@ export type ENSDeployment = { [DatasourceName.ThreeDNSBase]?: Datasource; /** - * The Datasource for Ethereum Follow Protocol Root + * The Datasource for the Ethereum Follow Protocol Root */ [DatasourceName.EFPRoot]?: Datasource; }; diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 69e357a55..9dd1d88e1 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -37,10 +37,7 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * ListStorageLocation ID * - * This compound identifier that includes: - * - chainId - * - listRecordsAddress - * - slot + * This compound identifier is a value of ListStorageLocationId type. * * NOTE: * We use a compound identifier for database performance benefits. @@ -50,33 +47,34 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * EVM chain ID of the chain where the EFP list records are stored. */ - chainId: p.integer().notNull(), + chainId: p.bigint().notNull(), /** - * Contract address where the EFP list records are stored. + * Contract address on chainId where the EFP list records are stored. */ listRecordsAddress: p.hex().notNull(), /** * Slot * - * 32-byte value that specifies the storage slot of the list within the contract. + * 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 Ethereum - * and inaccessible on L2s. + * 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: p.bigint().notNull(), /** * EFP List Token ID * - * References the associated EFP List Token entity. + * Foreign key to the associated EFP List Token. */ listTokenId: 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 incorrectly formatted. export const efp_listTokenRelations = relations(efp_listToken, ({ one }) => ({ listStorageLocation: one(efp_listStorageLocation, { fields: [efp_listToken.id], From 68e6066fed7583a3ab8f37542b626ecfeab7425e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Jun 2025 11:29:27 +0200 Subject: [PATCH 16/28] refactor: merge `apps/ensindexer/src/plugins/efp/lib/types.ts` into `apps/ensindexer/src/plugins/efp/lib/lsl.ts` --- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 85 ++++++++++++++++++-- apps/ensindexer/src/plugins/efp/lib/types.ts | 82 ------------------- 2 files changed, 79 insertions(+), 88 deletions(-) delete mode 100644 apps/ensindexer/src/plugins/efp/lib/types.ts diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts index b4cf29dac..108ec2560 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -7,12 +7,6 @@ import type { Address } from "viem"; import { base, baseSepolia, mainnet, optimism, optimismSepolia, sepolia } from "viem/chains"; import { getAddress } from "viem/utils"; import { prettifyError, z } from "zod/v4"; -import { - type ListStorageLocationContract, - ListStorageLocationType, - ListStorageLocationVersion, -} from "./types"; - /** * An encoded List Storage Location is a bytes array with the following structure: * - `version`: A uint8 representing the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. @@ -198,3 +192,82 @@ export const makeListStorageLocationId = ({ type.toString(), version.toString(), ].join("-"); + +/** + * 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: bigint; + + /** + * EVM address of the contract on chainId where the EFP list records are stored. + */ + listRecordsAddress: Address; + + /** + * 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 Ethereum and + * inaccessible on L2s. + */ + slot: bigint; +} + +/** + * 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, +} diff --git a/apps/ensindexer/src/plugins/efp/lib/types.ts b/apps/ensindexer/src/plugins/efp/lib/types.ts deleted file mode 100644 index 8b837afea..000000000 --- a/apps/ensindexer/src/plugins/efp/lib/types.ts +++ /dev/null @@ -1,82 +0,0 @@ -// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/598ab49/src/types.ts#L41-L47 - -import type { Address } from "viem/accounts"; - -/** - * 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: bigint; - - /** - * EVM address of the contract on chainId where the EFP list records are stored. - */ - listRecordsAddress: Address; - - /** - * 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 Ethereum and - * inaccessible on L2s. - */ - slot: bigint; -} - -/** - * 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, -} From 5a90cc6d660d5808ee7ef94e9b66216cd70d1f6b Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Jun 2025 12:18:46 +0200 Subject: [PATCH 17/28] apply PR feedback --- apps/ensindexer/src/lib/api-documentation.ts | 2 +- .../plugins/efp/handlers/EFPListRegistry.ts | 22 ++++---- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 56 ++++++++++--------- packages/ens-deployments/src/sepolia.ts | 2 +- 4 files changed, 43 insertions(+), 39 deletions(-) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index f0e0514a7..28d2e8edb 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -315,7 +315,7 @@ const makeApiDocumentation = (isSubgraph: boolean) => { "A reference the related ListStorageLocation entity. Null if no related ListStorageLocation was ever created or the related ListStorageLocation is in an invalid format.", }), ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { - id: "A compound identifier based on the following values: chainId, listRecordsAddress, slot", + id: "A compound identifier based on the following values: version, type, chainId, listRecordsAddress, slot", chainId: "EVM chain ID of the chain where the list is stored", listRecordsAddress: "EVM address of the contract on chainId where the EFP list records are stored", diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index e632229be..85923e036 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -60,34 +60,34 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs // invariant: chainId is from one of the supported EFP deployment chains // https://docs.efp.app/production/deployments/ .refine((v) => efpChainIds.includes(v), { - message: `chainId must be one of the EFP Chain IDs: ${efpChainIds.join(", ")}`, + message: `chainId must be one of the EFP deployment Chain IDs: ${efpChainIds.join(", ")}`, }), listRecordsAddress: z @@ -165,32 +169,32 @@ const createEfpLslContractSchema = (efpChainIds: bigint[]) => }); /** - * Unique EFP List Storage Location ID (lowercase) + * Unique EFP List Storage Location ID * * Example: - * `${chainId}-${listRecordsAddress}-${slot}-${type}-${version}` + * `${version}-${type}-${chainId}-${listRecordsAddress}-${slot}` */ export type ListStorageLocationId = string; /** - * Makes a unique lowercase EFP List Storage Location ID. + * Makes a unique EFP List Storage Location ID. * * @param {ListStorageLocationContract} listStorageLocationContract a decoded List Storage Location Contract object - * @returns a unique lowercase List Storage Location ID + * @returns a unique List Storage Location ID */ export const makeListStorageLocationId = ({ + version, + type, chainId, listRecordsAddress, slot, - type, - version, }: ListStorageLocationContract): ListStorageLocationId => [ + version.toString(), + type.toString(), chainId.toString(), listRecordsAddress.toLowerCase(), slot.toString(), - type.toString(), - version.toString(), ].join("-"); /** diff --git a/packages/ens-deployments/src/sepolia.ts b/packages/ens-deployments/src/sepolia.ts index 6cac53f04..15f3d5697 100644 --- a/packages/ens-deployments/src/sepolia.ts +++ b/packages/ens-deployments/src/sepolia.ts @@ -178,7 +178,7 @@ export default { }, /** - * The Root EFP Datasource on testnet. + * The Root EFP Datasource on the Sepolia testnet. * Based on the list of EFP testnet deployments: * https://docs.efp.app/production/deployments/#testnet-deployments */ From 1d91624b68defbb512e2bea10b78a827c8560ed6 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Jun 2025 12:23:07 +0200 Subject: [PATCH 18/28] reorder definitions within `lsl.ts` file First define elements that will be needed down the file --- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 240 ++++++++++----------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts index 377d37bba..5a56779ce 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -9,61 +9,92 @@ import { getAddress } from "viem/utils"; import { prettifyError, z } from "zod/v4"; /** - * An encoded List Storage Location is a bytes array with the following structure: - * - `version`: A uint8 representing the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. - * - `type`: A uint8 indicating the type of list storage location. This identifies the kind of data the data field contains.. - * - `data:` A bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + * Enum defining recognized List Storage Location Types + * + * Based on documentation at: + * https://docs.efp.app/design/list-storage-location/#location-types */ -type EncodedLsl = string; +export enum ListStorageLocationType { + /** + * EVMContract Data List Storage Location Type encoding: + * 32-byte chain ID + 20-byte contract address + 32-byte slot + */ + EVMContract = 1, +} -// NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 /** - * Decodes an EncodedLsl into a ListStorageLocationContract. + * Enum defining recognized List Storage Location Versions * - * @param {ENSDeploymentChain} ensDeploymentChain - The ENS Deployment Chain - * @param {EncodedLsl} encodedLsl - The encoded List Storage Location string to parse. - * @returns A decoded {@link ListStorageLocationContract} object. - * @throws An error if parsing could not be completed successfully. + * Based on documentation at: + * https://docs.efp.app/design/list-storage-location/#serialization */ -export function decodeListStorageLocationContract( - ensDeploymentChain: ENSDeploymentChain, - encodedLsl: EncodedLsl, -): ListStorageLocationContract { - const slicedLslContract = sliceEncodedLslContract(encodedLsl); - const efpDeploymentChainIds = getEFPDeploymentChainIds(ensDeploymentChain); - const efpLslContractSchema = createEfpLslContractSchema(efpDeploymentChainIds); +export enum ListStorageLocationVersion { + V1 = 1, +} - const parsed = efpLslContractSchema.safeParse(slicedLslContract); +/** + * 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; - if (!parsed.success) { - throw new Error( - "Failed to decode the encoded List Storage Location contract object: \n" + - prettifyError(parsed.error) + - "\n", - ); - } - return parsed.data; + /** + * The type of the List Storage Location. + * + * This identifies the kind of data the data field contains. + */ + type: LSLType; } /** - * Get the list of EFP Deployment Chain IDs for the ENS Deployment Chain. + * List Storage Location Contract * - * @param ensDeploymentChain - * @returns list of EFP deployment chain IDs + * 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 function getEFPDeploymentChainIds(ensDeploymentChain: ENSDeploymentChain): bigint[] { - switch (ensDeploymentChain) { - case "mainnet": - return [BigInt(base.id), BigInt(optimism.id), BigInt(mainnet.id)]; - case "sepolia": - return [BigInt(baseSepolia.id), BigInt(optimismSepolia.id), BigInt(sepolia.id)]; - default: - throw new Error( - `EFP Deployment chainIds are not configured for the ${ensDeploymentChain} ENS Deployment Chain`, - ); - } +export interface ListStorageLocationContract + extends BaseListStorageLocation< + ListStorageLocationVersion.V1, + ListStorageLocationType.EVMContract + > { + /** + * EVM chain ID of the chain where the EFP list records are stored. + */ + chainId: bigint; + + /** + * EVM address of the contract on chainId where the EFP list records are stored. + */ + listRecordsAddress: Address; + + /** + * 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 Ethereum and + * inaccessible on L2s. + */ + slot: bigint; } +/** + * An encoded List Storage Location is a bytes array with the following structure: + * - `version`: A uint8 representing the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. + * - `type`: A uint8 indicating the type of list storage location. This identifies the kind of data the data field contains.. + * - `data:` A bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + */ +type EncodedLsl = string; + /** * Data structure helpful for parsing an EncodedLsl. */ @@ -168,6 +199,54 @@ const createEfpLslContractSchema = (efpChainIds: bigint[]) => .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 {ENSDeploymentChain} ensDeploymentChain - The ENS Deployment Chain + * @param {EncodedLsl} encodedLsl - The encoded 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( + ensDeploymentChain: ENSDeploymentChain, + encodedLsl: EncodedLsl, +): ListStorageLocationContract { + const slicedLslContract = sliceEncodedLslContract(encodedLsl); + const efpDeploymentChainIds = getEFPDeploymentChainIds(ensDeploymentChain); + const efpLslContractSchema = createEfpLslContractSchema(efpDeploymentChainIds); + + 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; +} + +/** + * Get the list of EFP Deployment Chain IDs for the ENS Deployment Chain. + * + * @param ensDeploymentChain + * @returns list of EFP deployment chain IDs + */ +export function getEFPDeploymentChainIds(ensDeploymentChain: ENSDeploymentChain): bigint[] { + switch (ensDeploymentChain) { + case "mainnet": + return [BigInt(base.id), BigInt(optimism.id), BigInt(mainnet.id)]; + case "sepolia": + return [BigInt(baseSepolia.id), BigInt(optimismSepolia.id), BigInt(sepolia.id)]; + default: + throw new Error( + `EFP Deployment chainIds are not configured for the ${ensDeploymentChain} ENS Deployment Chain`, + ); + } +} + /** * Unique EFP List Storage Location ID * @@ -196,82 +275,3 @@ export const makeListStorageLocationId = ({ listRecordsAddress.toLowerCase(), slot.toString(), ].join("-"); - -/** - * 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: bigint; - - /** - * EVM address of the contract on chainId where the EFP list records are stored. - */ - listRecordsAddress: Address; - - /** - * 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 Ethereum and - * inaccessible on L2s. - */ - slot: bigint; -} - -/** - * 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, -} From 7d714bc3ad290a44676f7ad89a6450ee3ef5854f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Jun 2025 14:20:18 +0200 Subject: [PATCH 19/28] split chainId data model into external and internal types --- apps/ensindexer/src/lib/api-documentation.ts | 7 +-- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 64 +++++++++++++------- packages/ensnode-schema/src/efp.schema.ts | 9 ++- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index 28d2e8edb..a5f19ef44 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -316,10 +316,9 @@ const makeApiDocumentation = (isSubgraph: boolean) => { }), ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { id: "A compound identifier based on the following values: version, type, chainId, listRecordsAddress, slot", - chainId: "EVM chain ID of the chain where the list is stored", - listRecordsAddress: - "EVM address of the contract on chainId where the EFP list records are stored", - slot: "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 Root deployment chain and inaccessible on other chains.", + chainId: "EVM chain ID of the chain where the EFP list records are stored", + listRecordsAddress: "Contract address on chainId where the EFP list records are stored", + slot: "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.", listTokenId: "Unique identifier for this EFP list token", listToken: "A reference to the related ListToken entity", }), diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts index 5a56779ce..ac8271630 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -32,6 +32,17 @@ export enum ListStorageLocationVersion { V1 = 1, } +/** + * Application data model for EFP Deployment Chain ID. + * + * EFP has an allowlisted set of supported chains. As of June 13, 2025 + * the max allowlisted EFP chain id is 11,155,420 (OP Sepolia) and + * therefore it is safe for us to use JavaScript number representing an integer value + * (an 8-byte IEEE 754 double storing an integer) to store this chainId, + * even though technically EVM chainIds are uint256 (32-bytes). + */ +type EFPDeploymentChainId = number; + /** * Base List Storage Location */ @@ -71,27 +82,27 @@ export interface ListStorageLocationContract /** * EVM chain ID of the chain where the EFP list records are stored. */ - chainId: bigint; + chainId: EFPDeploymentChainId; /** - * EVM address of the contract on chainId where the EFP list records are stored. + * Contract address on chainId where the EFP list records are stored. */ listRecordsAddress: Address; /** * 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 Ethereum and - * inaccessible on L2s. + * 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; } /** * An encoded List Storage Location is a bytes array with the following structure: - * - `version`: A uint8 representing the version of the List Storage Location. This is used to ensure compatibility and facilitate future upgrades. - * - `type`: A uint8 indicating the type of list storage location. This identifies the kind of data the data field contains.. - * - `data:` A bytes array containing the actual data of the list storage location. The structure of this data depends on the location type. + * - `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. */ type EncodedLsl = string; @@ -116,20 +127,27 @@ interface SlicedLslContract { type: string; /** - * 32-byte EVM chain ID of the chain where the EFP list records are stored. + * 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; /** - * The 20-byte EVM address of the contract on chainId where the EFP list records are stored. + * 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 32-byte value that specifies the storage slot of the EFP list records within the listRecordsAddress contract. + * 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 Ethereum and - * inaccessible on L2s. + * 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; } @@ -168,9 +186,9 @@ function sliceEncodedLslContract(encodedLsl: EncodedLsl): SlicedLslContract { /** * Create a zod schema covering validations and invariants enforced with {@link decodeListStorageLocationContract} parser. - * @param {bigint[]} efpChainIds List of IDs for chains that the EFP protocol has been present on. + * @param {EFPDeploymentChainId[]} efpDeploymentChainIds List of IDs for chains that the EFP protocol has been deployed on. */ -const createEfpLslContractSchema = (efpChainIds: bigint[]) => +const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[]) => z.object({ version: z.literal("01").transform(() => ListStorageLocationVersion.V1), @@ -180,10 +198,12 @@ const createEfpLslContractSchema = (efpChainIds: bigint[]) => .string() .length(64) .transform((v) => BigInt("0x" + v)) - // invariant: chainId is from one of the supported EFP deployment chains + // mapping EVM's Chain ID type into the `EFPDeploymentChainId` type + .transform((v) => Number(v) as EFPDeploymentChainId) + // invariant: chainId is from one of the EFP Deployment Chain IDs // https://docs.efp.app/production/deployments/ - .refine((v) => efpChainIds.includes(v), { - message: `chainId must be one of the EFP deployment Chain IDs: ${efpChainIds.join(", ")}`, + .refine((v) => efpDeploymentChainIds.includes(v), { + message: `chainId must be one of the EFP deployment Chain IDs: ${efpDeploymentChainIds.join(", ")}`, }), listRecordsAddress: z @@ -232,14 +252,16 @@ export function decodeListStorageLocationContract( * Get the list of EFP Deployment Chain IDs for the ENS Deployment Chain. * * @param ensDeploymentChain - * @returns list of EFP deployment chain IDs + * @returns list of EFP Deployment Chain IDs */ -export function getEFPDeploymentChainIds(ensDeploymentChain: ENSDeploymentChain): bigint[] { +export function getEFPDeploymentChainIds( + ensDeploymentChain: ENSDeploymentChain, +): EFPDeploymentChainId[] { switch (ensDeploymentChain) { case "mainnet": - return [BigInt(base.id), BigInt(optimism.id), BigInt(mainnet.id)]; + return [base.id, optimism.id, mainnet.id]; case "sepolia": - return [BigInt(baseSepolia.id), BigInt(optimismSepolia.id), BigInt(sepolia.id)]; + return [baseSepolia.id, optimismSepolia.id, sepolia.id]; default: throw new Error( `EFP Deployment chainIds are not configured for the ${ensDeploymentChain} ENS Deployment Chain`, diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 9dd1d88e1..bb11bfeba 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -46,8 +46,11 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * EVM chain ID of the chain where the EFP list records are stored. + * + * NOTE: + * This value is of `EFPDeploymentChainId` type. */ - chainId: p.bigint().notNull(), + chainId: p.integer().notNull(), /** * Contract address on chainId where the EFP list records are stored. @@ -59,8 +62,8 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", * * 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. + * 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: p.bigint().notNull(), From 9f422a52273be3816a6f0d0fd2d7b31a21adedb3 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Jun 2025 16:28:55 +0200 Subject: [PATCH 20/28] refine code docs --- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 13 +++++++++---- packages/ensnode-schema/src/efp.schema.ts | 3 +-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts index ac8271630..a82c58fb3 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -36,10 +36,15 @@ export enum ListStorageLocationVersion { * Application data model for EFP Deployment Chain ID. * * EFP has an allowlisted set of supported chains. As of June 13, 2025 - * the max allowlisted EFP chain id is 11,155,420 (OP Sepolia) and + * the max allowlisted EFP chain ID is 11,155,420 (OP Sepolia) and * therefore it is safe for us to use JavaScript number representing an integer value * (an 8-byte IEEE 754 double storing an integer) to store this chainId, - * even though technically EVM chainIds are uint256 (32-bytes). + * even though technically EVM chain IDs are uint256 (32-bytes). + * + * Note: + * In the future, there might be a case when the max allowlisted EFP chain ID + * requires setting `EFPDeploymentChainId` type to `bigint` to support + * every allowlisted EFP chain ID. */ type EFPDeploymentChainId = number; @@ -197,7 +202,7 @@ const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[ chainId: z .string() .length(64) - .transform((v) => BigInt("0x" + v)) + .transform((v) => BigInt(`0x${v}`)) // mapping EVM's Chain ID type into the `EFPDeploymentChainId` type .transform((v) => Number(v) as EFPDeploymentChainId) // invariant: chainId is from one of the EFP Deployment Chain IDs @@ -216,7 +221,7 @@ const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[ slot: z .string() .length(64) - .transform((v) => BigInt("0x" + v)), + .transform((v) => BigInt(`0x${v}`)), }); // NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index bb11bfeba..93942fa3b 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -37,7 +37,7 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * ListStorageLocation ID * - * This compound identifier is a value of ListStorageLocationId type. + * This compound identifier is a value of `ListStorageLocationId` type. * * NOTE: * We use a compound identifier for database performance benefits. @@ -47,7 +47,6 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * EVM chain ID of the chain where the EFP list records are stored. * - * NOTE: * This value is of `EFPDeploymentChainId` type. */ chainId: p.integer().notNull(), From c02f7c01e71f509b58c4b13099a203995c598da4 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Jun 2025 17:02:50 +0200 Subject: [PATCH 21/28] refine code docs --- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts index a82c58fb3..ebd6bb62d 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -32,19 +32,29 @@ export enum ListStorageLocationVersion { V1 = 1, } +/** + * Max value for the EFP Chain ID + * + * Matching the available range of the signed 4-byte `integer` type. + * + * @link https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NUMERIC + */ +const EFP_CHAIN_ID_TYPE_MAX_VALUE = 2_147_483_647; + /** * Application data model for EFP Deployment Chain ID. * * EFP has an allowlisted set of supported chains. As of June 13, 2025 * the max allowlisted EFP chain ID is 11,155,420 (OP Sepolia) and - * therefore it is safe for us to use JavaScript number representing an integer value - * (an 8-byte IEEE 754 double storing an integer) to store this chainId, - * even though technically EVM chain IDs are uint256 (32-bytes). + * therefore it is safe for us to use integer (a signed 4-byte integer) to + * store this chain ID, even though technically EVM chainIds are uint256 (32-bytes). * * Note: * In the future, there might be a case when the max allowlisted EFP chain ID * requires setting `EFPDeploymentChainId` type to `bigint` to support - * every allowlisted EFP chain ID. + * every allowlisted EFP chain ID. For example, it will happen when any of + * allowlisted EFP chain IDs is greater than the max value for + * a signed 4-byte integer (EFP_CHAIN_ID_TYPE_MAX_VALUE). */ type EFPDeploymentChainId = number; @@ -205,6 +215,13 @@ const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[ .transform((v) => BigInt(`0x${v}`)) // mapping EVM's Chain ID type into the `EFPDeploymentChainId` type .transform((v) => Number(v) as EFPDeploymentChainId) + // invariant: every allowlisted EFP chain ID is under the EFPDeploymentChainId value limit + .refine( + (v) => efpDeploymentChainIds.every((chainId) => chainId <= EFP_CHAIN_ID_TYPE_MAX_VALUE), + { + message: `Every chainId from efpDeploymentChainIds must fit into the signed 4-byte integer value range.`, + }, + ) // invariant: chainId is from one of the EFP Deployment Chain IDs // https://docs.efp.app/production/deployments/ .refine((v) => efpDeploymentChainIds.includes(v), { @@ -250,6 +267,7 @@ export function decodeListStorageLocationContract( "\n", ); } + return parsed.data; } From 6d6e956da466aa103afb74b88eedec89d211fe57 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 20 Jun 2025 21:08:53 +0200 Subject: [PATCH 22/28] apply pr feedback --- .changeset/upset-glasses-strive.md | 2 +- .../plugins/efp/handlers/EFPListRegistry.ts | 4 +- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 99 ++++++++++--------- packages/ensnode-schema/src/efp.schema.ts | 9 +- 4 files changed, 60 insertions(+), 54 deletions(-) diff --git a/.changeset/upset-glasses-strive.md b/.changeset/upset-glasses-strive.md index 612ad5ccb..9d92a261d 100644 --- a/.changeset/upset-glasses-strive.md +++ b/.changeset/upset-glasses-strive.md @@ -5,4 +5,4 @@ "ensindexer": minor --- -Introduce initial `efp` plugin demo. +Introduce initial `efp` plugin demo that indexes EFP List Tokens and EFP List Storage Locations. diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 85923e036..7c831b007 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -22,7 +22,7 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs - z.object({ +const createEfpLslContractSchema = (ensDeploymentChain: ENSDeploymentChain) => { + const efpDeploymentChainIds = getEFPDeploymentChainIds(ensDeploymentChain); + + return z.object({ version: z.literal("01").transform(() => ListStorageLocationVersion.V1), type: z.literal("01").transform(() => ListStorageLocationType.EVMContract), @@ -212,16 +224,14 @@ const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[ chainId: z .string() .length(64) + // prep: map string representation of .transform((v) => BigInt(`0x${v}`)) - // mapping EVM's Chain ID type into the `EFPDeploymentChainId` type - .transform((v) => Number(v) as EFPDeploymentChainId) - // invariant: every allowlisted EFP chain ID is under the EFPDeploymentChainId value limit - .refine( - (v) => efpDeploymentChainIds.every((chainId) => chainId <= EFP_CHAIN_ID_TYPE_MAX_VALUE), - { - message: `Every chainId from efpDeploymentChainIds must fit into the signed 4-byte integer value range.`, - }, - ) + // invariant: chainId is a safe integer value + .refine((v) => v <= Number.MAX_SAFE_INTEGER, { + message: "chainId must be a safe integer value", + }) + // prep: map bigint value into number + .transform((v) => Number(v)) // invariant: chainId is from one of the EFP Deployment Chain IDs // https://docs.efp.app/production/deployments/ .refine((v) => efpDeploymentChainIds.includes(v), { @@ -232,7 +242,7 @@ const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[ .string() .length(40) .transform((v) => `0x${v}`) - // ensure EVM address correctness and map it into lowercase for increased data model safety + // ensure EVM address correctness and map it into lowercase for ease of equality comparisons .transform((v) => getAddress(v).toLowerCase() as Address), slot: z @@ -240,6 +250,7 @@ const createEfpLslContractSchema = (efpDeploymentChainIds: EFPDeploymentChainId[ .length(64) .transform((v) => BigInt(`0x${v}`)), }); +}; // NOTE: based on code from https://github.com/ethereumfollowprotocol/onchain/blob/f3c970e/src/efp.ts#L95-L123 /** @@ -254,9 +265,9 @@ export function decodeListStorageLocationContract( ensDeploymentChain: ENSDeploymentChain, encodedLsl: EncodedLsl, ): ListStorageLocationContract { - const slicedLslContract = sliceEncodedLslContract(encodedLsl); - const efpDeploymentChainIds = getEFPDeploymentChainIds(ensDeploymentChain); - const efpLslContractSchema = createEfpLslContractSchema(efpDeploymentChainIds); + const encodedLslContract = parseEncodedLslContract(encodedLsl); + const slicedLslContract = sliceEncodedLslContract(encodedLslContract); + const efpLslContractSchema = createEfpLslContractSchema(ensDeploymentChain); const parsed = efpLslContractSchema.safeParse(slicedLslContract); @@ -277,9 +288,7 @@ export function decodeListStorageLocationContract( * @param ensDeploymentChain * @returns list of EFP Deployment Chain IDs */ -export function getEFPDeploymentChainIds( - ensDeploymentChain: ENSDeploymentChain, -): EFPDeploymentChainId[] { +function getEFPDeploymentChainIds(ensDeploymentChain: ENSDeploymentChain): EFPDeploymentChainId[] { switch (ensDeploymentChain) { case "mainnet": return [base.id, optimism.id, mainnet.id]; diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 93942fa3b..822ca0f5b 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -1,5 +1,5 @@ /** - * Schema definitions for EFP entities. + * Database schema definitions for indexed EFP entities. */ import { onchainTable, relations } from "ponder"; @@ -7,16 +7,13 @@ import { onchainTable, relations } from "ponder"; /** * EFP List Token * - * Represents an onchain ERC-721A NFT representing an EFP list minted with the EFPListRegistry contract. + * 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. - * It's a very important value as it enables - * `getListStorageLocation(tokenId)` call on the EFPListRegistry contract, - * which result allows querying data for related list records. */ id: p.bigint().primaryKey(), @@ -25,7 +22,7 @@ export const efp_listToken = onchainTable("efp_list_token", (p) => ({ * * The address of the current owner of the EFP List Token. */ - ownerAddress: p.hex().notNull(), + owner: p.hex().notNull(), })); /** From b52033f3bc6bc8382270bd9408d3c3c69c755a54 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 23 Jun 2025 15:36:10 +0200 Subject: [PATCH 23/28] refactor(efp): split schemas and lib --- .../plugins/efp/handlers/EFPListRegistry.ts | 59 ++++++++---- apps/ensindexer/src/plugins/efp/lib/chains.ts | 30 ++++++ apps/ensindexer/src/plugins/efp/lib/lsl.ts | 91 ++++-------------- .../test/plugins/efp/lib/lsl.test.ts | 93 +++++++++++++++++++ packages/ensnode-schema/src/efp.schema.ts | 53 +++++++++-- 5 files changed, 229 insertions(+), 97 deletions(-) create mode 100644 apps/ensindexer/src/plugins/efp/lib/chains.ts create mode 100644 apps/ensindexer/test/plugins/efp/lib/lsl.test.ts diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index 7c831b007..a7491f969 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -1,11 +1,15 @@ import { ponder } from "ponder:registry"; -import { efp_listStorageLocation, efp_listToken } from "ponder:schema"; +import { + efp_listStorageLocation, + efp_listToken, + efp_unrecognizedListStorageLocation, +} 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, makeListStorageLocationId } from "../lib/lsl"; +import { type ListStorageLocationContract, decodeListStorageLocationContract } from "../lib/lsl"; export default function ({ namespace }: ENSIndexerPluginHandlerArgs) { /// @@ -49,24 +53,33 @@ export default function ({ namespace }: ENSIndexerPluginHandlerArgs { chainId: z .string() .length(64) - // prep: map string representation of + // prep: map string representation of a `uint256` chainId value into bigint .transform((v) => BigInt(`0x${v}`)) - // invariant: chainId is a safe integer value - .refine((v) => v <= Number.MAX_SAFE_INTEGER, { - message: "chainId must be a safe integer value", + // invariant: chainId can be converted into a positive safe integer + .refine((v) => v > 0 && v <= Number.MAX_SAFE_INTEGER, { + message: "chainId must be a positive safe integer value", }) - // prep: map bigint value into number + // prep: map a bigint chainId value into number .transform((v) => Number(v)) // invariant: chainId is from one of the EFP Deployment Chain IDs // https://docs.efp.app/production/deployments/ @@ -281,51 +276,3 @@ export function decodeListStorageLocationContract( return parsed.data; } - -/** - * Get the list of EFP Deployment Chain IDs for the ENS Deployment Chain. - * - * @param ensDeploymentChain - * @returns list of EFP Deployment Chain IDs - */ -function getEFPDeploymentChainIds(ensDeploymentChain: ENSDeploymentChain): EFPDeploymentChainId[] { - switch (ensDeploymentChain) { - 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 configured for the ${ensDeploymentChain} ENS Deployment Chain`, - ); - } -} - -/** - * Unique EFP List Storage Location ID - * - * Example: - * `${version}-${type}-${chainId}-${listRecordsAddress}-${slot}` - */ -export type ListStorageLocationId = string; - -/** - * Makes a unique EFP List Storage Location ID. - * - * @param {ListStorageLocationContract} listStorageLocationContract a decoded List Storage Location Contract object - * @returns a unique List Storage Location ID - */ -export const makeListStorageLocationId = ({ - version, - type, - chainId, - listRecordsAddress, - slot, -}: ListStorageLocationContract): ListStorageLocationId => - [ - version.toString(), - type.toString(), - chainId.toString(), - listRecordsAddress.toLowerCase(), - slot.toString(), - ].join("-"); 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..a252900a0 --- /dev/null +++ b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts @@ -0,0 +1,93 @@ +import { + ListStorageLocationType, + ListStorageLocationVersion, + decodeListStorageLocationContract, +} from "@/plugins/efp/lib/lsl"; +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", () => { + const encodedLsl = + "0x010100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc"; + + expect(decodeListStorageLocationContract("mainnet", encodedLsl)).toEqual({ + type: ListStorageLocationType.EVMContract, + version: ListStorageLocationVersion.V1, + chainId: 1, + listRecordsAddress: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + slot: 31941687331316587819122038778003974753188806173854071805743471973008610429132n, + }); + }); + + it("should not decode encoded LSL when chainId value is out of bounds (too small)", () => { + const encodedLsl = + "0x0101000000000000000000000000000000000000000000000000000000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb"; + + expect(() => + decodeListStorageLocationContract("mainnet", encodedLsl), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be a positive safe integer value + → at chainId +✖ chainId must be one of the EFP deployment Chain IDs: 8453, 10, 1 + → at chainId +`); + }); + + it("should not decode encoded LSL when chainId value is out of bounds (too large)", () => { + const encodedLsl = + "0x0101000000000000000000000000000000000000000000000000002000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb"; + + expect(() => + decodeListStorageLocationContract("mainnet", encodedLsl), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be a positive safe integer value + → at chainId +✖ chainId must be one of the EFP deployment Chain IDs: 8453, 10, 1 + → at chainId +`); + }); + + it("should not decode encoded LSL when chainId value is not allowlisted", () => { + const encodedLsl = + "0x0101000000000000000000000000000000000000000000000000001fffffffffffff41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb"; + + expect(() => + decodeListStorageLocationContract("mainnet", encodedLsl), + ).toThrowError(`Failed to decode the encoded List Storage Location contract object: +✖ chainId must be one of the EFP deployment Chain IDs: 8453, 10, 1 + → at chainId +`); + }); + + it("should not decode encoded LSL when `version` is different than 1", () => { + const encodedLsl = + "0x020100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc"; + + expect(() => decodeListStorageLocationContract("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", () => { + const encodedLsl = + "0x010200000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc"; + + expect(() => decodeListStorageLocationContract("mainnet", encodedLsl)).toThrowError( + `Encoded List Storage Location type value for an EVMContract LSL must be set to 01`, + ); + }); + + it("should not decode encoded LSL when the value is not of the right length", () => { + const encodedLsl = "0xa22cb465"; + + expect(() => decodeListStorageLocationContract("mainnet", encodedLsl)).toThrowError( + `Encoded List Storage Location values for a LSL v1 Contract must be a 174-character long string`, + ); + }); + }); + }); +}); diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 822ca0f5b..f538b5ac4 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -34,19 +34,17 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * ListStorageLocation ID * - * This compound identifier is a value of `ListStorageLocationId` type. - * - * NOTE: - * We use a compound identifier for database performance benefits. + * Value of `EncodedLsl` type. */ - id: p.text().primaryKey(), + id: p.hex().primaryKey(), /** * EVM chain ID of the chain where the EFP list records are stored. * - * This value is of `EFPDeploymentChainId` type. + * This value is of `EFPDeploymentChainId` type which currently fits in the range of 1 to 2^53-1. + * The 16 digits of precision is sufficient to represent all possible chain IDs. */ - chainId: p.integer().notNull(), + chainId: p.numeric({ mode: "number", precision: 16, scale: 0 }).notNull(), /** * Contract address on chainId where the EFP list records are stored. @@ -71,9 +69,38 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", listTokenId: p.bigint().notNull(), })); +/** + * EFP Unrecognized List Storage Location + * + * Used to store EFP List Storage Locations that were not recognized or could not be decoded. + */ +export const efp_unrecognizedListStorageLocation = onchainTable( + "efp_list_storage_location_unrecognized", + (p) => ({ + /** + * ListStorageLocation ID + * + * Value of `EncodedLsl` type. + */ + id: p.hex().primaryKey(), + + /** + * EFP List Token ID + * + * Foreign key to the associated EFP List Token. + */ + listTokenId: 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 incorrectly formatted. +// Note: If the List Storage Location was created, but was not recognized, +// it would be stored in the `efp_unrecognizedListStorageLocation` schema. +// The List Storage Location would not be created for any of batch minted List Tokens: +// - 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.id], @@ -89,3 +116,15 @@ export const efp_listStorageLocationRelations = relations(efp_listStorageLocatio references: [efp_listToken.id], }), })); + +// Define relationship between the "Unrecognized List Storage Location" and the "List Token" entities +// Each Unrecognized List Storage Location is associated with exactly one List Token. +export const efp_unrecognizedListStorageLocationRelations = relations( + efp_unrecognizedListStorageLocation, + ({ one }) => ({ + listToken: one(efp_listToken, { + fields: [efp_unrecognizedListStorageLocation.listTokenId], + references: [efp_listToken.id], + }), + }), +); From c242463d58bc570594065cb625b3bbf55d2563b7 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 23 Jun 2025 16:16:59 +0200 Subject: [PATCH 24/28] extend api docs --- apps/ensindexer/src/lib/api-documentation.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index a5f19ef44..6ba4ad450 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -315,12 +315,21 @@ const makeApiDocumentation = (isSubgraph: boolean) => { "A reference the related ListStorageLocation entity. Null if no related ListStorageLocation was ever created or the related ListStorageLocation is in an invalid format.", }), ...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", { - id: "A compound identifier based on the following values: version, type, chainId, listRecordsAddress, slot", + id: "Encoded LSL value", chainId: "EVM chain ID of the chain where the EFP list records are stored", listRecordsAddress: "Contract address on chainId where the EFP list records are stored", slot: "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.", listTokenId: "Unique identifier for this EFP list token", listToken: "A reference to the related ListToken entity", }), + ...generateTypeDocSetWithTypeName( + "efp_unrecognizedListStorageLocation", + "EFP Unrecognized List Storage Location", + { + id: "Encoded LSL value", + listTokenId: "Unique identifier for this EFP list token", + listToken: "A reference to the related ListToken entity", + }, + ), }); }; From 90a85d1ac95b670c2db71be5d87d61bd85d43155 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 23 Jun 2025 19:27:52 +0200 Subject: [PATCH 25/28] feat(efp): use int8 column builder for chainId --- packages/ensnode-schema/src/efp.schema.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index f538b5ac4..33974c3f2 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -42,9 +42,8 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", * EVM chain ID of the chain where the EFP list records are stored. * * This value is of `EFPDeploymentChainId` type which currently fits in the range of 1 to 2^53-1. - * The 16 digits of precision is sufficient to represent all possible chain IDs. */ - chainId: p.numeric({ mode: "number", precision: 16, scale: 0 }).notNull(), + chainId: p.int8({ mode: "number" }).notNull(), /** * Contract address on chainId where the EFP list records are stored. From fd4bfc20e3c692ac6d120b57ef217167e581b521 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 24 Jun 2025 20:17:13 +0200 Subject: [PATCH 26/28] apply pr feedback --- apps/ensindexer/src/lib/api-documentation.ts | 27 +-- .../plugins/efp/handlers/EFPListRegistry.ts | 108 ++++------ apps/ensindexer/src/plugins/efp/lib/chains.ts | 9 +- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 63 ++++-- apps/ensindexer/src/plugins/efp/lib/utils.ts | 21 ++ apps/ensindexer/src/plugins/efp/plugin.ts | 4 +- .../test/plugins/efp/lib/lsl.test.ts | 201 +++++++++++++++--- .../test/plugins/efp/lib/utils.test.ts | 21 ++ packages/ensnode-schema/src/efp.schema.ts | 107 ++++------ 9 files changed, 356 insertions(+), 205 deletions(-) create mode 100644 apps/ensindexer/src/plugins/efp/lib/utils.ts create mode 100644 apps/ensindexer/test/plugins/efp/lib/utils.test.ts diff --git a/apps/ensindexer/src/lib/api-documentation.ts b/apps/ensindexer/src/lib/api-documentation.ts index 6ba4ad450..894879291 100644 --- a/apps/ensindexer/src/lib/api-documentation.ts +++ b/apps/ensindexer/src/lib/api-documentation.ts @@ -310,26 +310,17 @@ const makeApiDocumentation = (isSubgraph: boolean) => { */ ...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Token", { id: "Unique token ID for an EFP List Token", - ownerAddress: "EVM address of the owner of the EFP List Token", - listStorageLocation: - "A reference the related ListStorageLocation entity. Null if no related ListStorageLocation was ever created or the related ListStorageLocation is in an invalid format.", + 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: "Encoded LSL value", - chainId: "EVM chain ID of the chain where the EFP list records are stored", - listRecordsAddress: "Contract address on chainId where the EFP list records are stored", - slot: "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.", - listTokenId: "Unique identifier for this EFP list token", - listToken: "A reference to the related ListToken entity", + 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.", }), - ...generateTypeDocSetWithTypeName( - "efp_unrecognizedListStorageLocation", - "EFP Unrecognized List Storage Location", - { - id: "Encoded LSL value", - listTokenId: "Unique identifier for this EFP list token", - listToken: "A reference to the related ListToken entity", - }, - ), }); }; diff --git a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts index f3093fd98..3c4a158cd 100644 --- a/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts +++ b/apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts @@ -1,15 +1,16 @@ import { ponder } from "ponder:registry"; -import { - efp_listStorageLocation, - efp_listToken, - efp_unrecognizedListStorageLocation, -} from "ponder:schema"; +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 { type ListStorageLocationContract, decodeListStorageLocationContract } from "../lib/lsl"; +import { + decodeListStorageLocationContract, + isEncodedLslContract, + parseEncodedLsl, +} from "../lib/lsl"; +import { parseEvmAddress } from "../lib/utils"; export default function ({ pluginNamespace: ns }: ENSIndexerPluginHandlerArgs) { /// @@ -21,17 +22,17 @@ export default function ({ pluginNamespace: ns }: ENSIndexerPluginHandlerArgs { .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 a positive safe integer value", + 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 EFP Deployment Chain IDs + // 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: ${efpDeploymentChainIds.join(", ")}`, + message: `chainId must be one of the EFP deployment Chain IDs defined for the ENSNamespace "${ensNamespaceId}": ${efpDeploymentChainIds.join(", ")}`, }), listRecordsAddress: z @@ -237,7 +267,7 @@ const createEfpLslContractSchema = (ensNamespaceId: ENSNamespaceId) => { .length(40) .transform((v) => `0x${v}`) // ensure EVM address correctness and map it into lowercase for ease of equality comparisons - .transform((v) => getAddress(v).toLowerCase() as Address), + .transform((v) => parseEvmAddress(v) as Address), slot: z .string() @@ -251,15 +281,14 @@ const createEfpLslContractSchema = (ensNamespaceId: ENSNamespaceId) => { * Decodes an EncodedLsl into a ListStorageLocationContract. * * @param {ENSNamespaceId} ensNamespaceId - The ENS Namespace ID to use for decoding. - * @param {EncodedLsl} encodedLsl - The encoded List Storage Location string to parse. + * @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, - encodedLsl: EncodedLsl, + encodedLslContract: EncodedLslContract, ): ListStorageLocationContract { - const encodedLslContract = parseEncodedLslContract(encodedLsl); const slicedLslContract = sliceEncodedLslContract(encodedLslContract); const efpLslContractSchema = createEfpLslContractSchema(ensNamespaceId); 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..8a1f28440 --- /dev/null +++ b/apps/ensindexer/src/plugins/efp/lib/utils.ts @@ -0,0 +1,21 @@ +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; +} diff --git a/apps/ensindexer/src/plugins/efp/plugin.ts b/apps/ensindexer/src/plugins/efp/plugin.ts index d2355fcac..b1a2c7178 100644 --- a/apps/ensindexer/src/plugins/efp/plugin.ts +++ b/apps/ensindexer/src/plugins/efp/plugin.ts @@ -1,7 +1,7 @@ /** * The EFP plugin describes indexing behavior for the Ethereum Follow Protocol. * - * NOTE: this is an early version of the experimental EFP plugin and is not complete or production ready. + * NOTE: this is an early version of an experimental EFP plugin and is not complete or production ready. */ import type { ENSIndexerConfig } from "@/config/types"; @@ -22,7 +22,7 @@ const pluginName = PluginName.EFP; // Define the DatasourceNames for Datasources required by the plugin const requiredDatasources = [DatasourceNames.EFPRoot]; -// construct a unique contract namespace for this plugin +// Construct a unique plugin namespace to wrap contract names const pluginNamespace = makePluginNamespace(pluginName); // config object factory used to derive PonderConfig type diff --git a/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts index a252900a0..01c495b89 100644 --- a/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts +++ b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts @@ -1,93 +1,228 @@ import { + EncodedLslContract, + type ListStorageLocationContract, ListStorageLocationType, ListStorageLocationVersion, decodeListStorageLocationContract, + isEncodedLslContract, + parseEncodedLsl, + validateEncodedLslContract, } from "@/plugins/efp/lib/lsl"; +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", () => { - const encodedLsl = - "0x010100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc"; + it("should decode a valid V1 EVMContract List Storage Location for EFP mainnet", () => { + const encodedLsl = parseEncodedLsl( + "0x010100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); - expect(decodeListStorageLocationContract("mainnet", encodedLsl)).toEqual({ + expect( + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), + ).toEqual({ type: ListStorageLocationType.EVMContract, version: ListStorageLocationVersion.V1, chainId: 1, listRecordsAddress: "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: "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 = - "0x0101000000000000000000000000000000000000000000000000000000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb"; + const encodedLsl = parseEncodedLsl( + "0x0101000000000000000000000000000000000000000000000000000000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb", + ) as EncodedLslContract; + + expect(isEncodedLslContract(encodedLsl)).toBe(true); expect(() => - decodeListStorageLocationContract("mainnet", encodedLsl), + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), ).toThrowError(`Failed to decode the encoded List Storage Location contract object: -✖ chainId must be a positive safe integer value +✖ chainId must be in the accepted range → at chainId -✖ chainId must be one of the EFP deployment Chain IDs: 8453, 10, 1 +✖ 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 = - "0x0101000000000000000000000000000000000000000000000000002000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb"; + const encodedLsl = parseEncodedLsl( + "0x0101000000000000000000000000000000000000000000000000002000000000000041aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb", + ); + + expect(isEncodedLslContract(encodedLsl)).toBe(true); expect(() => - decodeListStorageLocationContract("mainnet", encodedLsl), + decodeListStorageLocationContract( + ENSNamespaceIds.Mainnet, + encodedLsl as EncodedLslContract, + ), ).toThrowError(`Failed to decode the encoded List Storage Location contract object: -✖ chainId must be a positive safe integer value +✖ chainId must be in the accepted range → at chainId -✖ chainId must be one of the EFP deployment Chain IDs: 8453, 10, 1 +✖ 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", () => { - const encodedLsl = - "0x0101000000000000000000000000000000000000000000000000001fffffffffffff41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33073b78bf041b622ff5f1b972b5839062cbe5ab0efb4917745873e00305d7c0cb"; + 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("mainnet", encodedLsl), + 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: 8453, 10, 1 +✖ 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 = - "0x020100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc"; + const encodedLsl = parseEncodedLsl( + "0x020100000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ) as EncodedLslContract; - expect(() => decodeListStorageLocationContract("mainnet", encodedLsl)).toThrowError( + 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", () => { - const encodedLsl = - "0x010200000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc"; + it("should not decode encoded LSL when `type` field is different than 1 (EVM Contract)", () => { + const encodedLsl = parseEncodedLsl( + "0x010200000000000000000000000000000000000000000000000000000000000000015289fe5dabc021d02fddf23d4a4df96f4e0f17ef469e5ab72064e89164e9792a7f05d5bc073c2dfb1bb6da7b801e1bb2192808cc", + ) as EncodedLslContract; - expect(() => decodeListStorageLocationContract("mainnet", encodedLsl)).toThrowError( - `Encoded List Storage Location type value for an EVMContract LSL must be set to 01`, + 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 right length", () => { - const encodedLsl = "0xa22cb465"; + it("should not decode encoded LSL when the value is not of the expected length", () => { + const encodedLsl = parseEncodedLsl("0xa22cb465"); - expect(() => decodeListStorageLocationContract("mainnet", encodedLsl)).toThrowError( - `Encoded List Storage Location values for a LSL v1 Contract must be a 174-character long string`, - ); + 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..0c57f8496 --- /dev/null +++ b/apps/ensindexer/test/plugins/efp/lib/utils.test.ts @@ -0,0 +1,21 @@ +import { parseEvmAddress } 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.`); + }); + }); +}); diff --git a/packages/ensnode-schema/src/efp.schema.ts b/packages/ensnode-schema/src/efp.schema.ts index 33974c3f2..7fe83fa75 100644 --- a/packages/ensnode-schema/src/efp.schema.ts +++ b/packages/ensnode-schema/src/efp.schema.ts @@ -1,5 +1,7 @@ /** * Database schema definitions for indexed EFP entities. + * + * Note: These schema definitions is also used to build autogenerated GraphQL API. */ import { onchainTable, relations } from "ponder"; @@ -14,6 +16,8 @@ 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(), @@ -21,12 +25,28 @@ export const efp_listToken = onchainTable("efp_list_token", (p) => ({ * 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 Location + * 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 */ @@ -34,96 +54,55 @@ export const efp_listStorageLocation = onchainTable("efp_list_storage_location", /** * ListStorageLocation ID * - * Value of `EncodedLsl` type. + * An `EncodedLsl` value (always lowercase). */ id: p.hex().primaryKey(), /** * EVM chain ID of the chain where the EFP list records are stored. * - * This value is of `EFPDeploymentChainId` type which currently fits in the range of 1 to 2^53-1. + * 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 * - * 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: p.bigint().notNull(), - - /** - * EFP List Token ID + * 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. * - * Foreign key to the associated EFP List Token. + * Represents a `unit256` value. */ - listTokenId: p.bigint().notNull(), + slot: p.bigint().notNull(), })); -/** - * EFP Unrecognized List Storage Location - * - * Used to store EFP List Storage Locations that were not recognized or could not be decoded. - */ -export const efp_unrecognizedListStorageLocation = onchainTable( - "efp_list_storage_location_unrecognized", - (p) => ({ - /** - * ListStorageLocation ID - * - * Value of `EncodedLsl` type. - */ - id: p.hex().primaryKey(), - - /** - * EFP List Token ID - * - * Foreign key to the associated EFP List Token. - */ - listTokenId: 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 incorrectly formatted. +// 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 would be stored in the `efp_unrecognizedListStorageLocation` schema. -// The List Storage Location would not be created for any of batch minted List Tokens: +// 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.id], - references: [efp_listStorageLocation.listTokenId], - }), -})); - -// Define relationship between the "List Storage Location" and the "List Token" entities -// Each List Storage Location is associated with exactly one List Token. -export const efp_listStorageLocationRelations = relations(efp_listStorageLocation, ({ one }) => ({ - listToken: one(efp_listToken, { - fields: [efp_listStorageLocation.listTokenId], - references: [efp_listToken.id], + fields: [efp_listToken.lslId], + references: [efp_listStorageLocation.id], }), })); - -// Define relationship between the "Unrecognized List Storage Location" and the "List Token" entities -// Each Unrecognized List Storage Location is associated with exactly one List Token. -export const efp_unrecognizedListStorageLocationRelations = relations( - efp_unrecognizedListStorageLocation, - ({ one }) => ({ - listToken: one(efp_listToken, { - fields: [efp_unrecognizedListStorageLocation.listTokenId], - references: [efp_listToken.id], - }), - }), -); From 03d55bc776b3da82a8b212ec551ae4640a0debf1 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 24 Jun 2025 21:08:36 +0200 Subject: [PATCH 27/28] feat(ensindexer): setup EFP API routes --- apps/ensindexer/src/api/index.ts | 4 ++ apps/ensindexer/src/plugins/efp/lib/api.ts | 56 +++++++++++++++++++ apps/ensindexer/src/plugins/efp/lib/utils.ts | 26 +++++++++ .../test/plugins/efp/lib/utils.test.ts | 27 ++++++++- 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 apps/ensindexer/src/plugins/efp/lib/api.ts diff --git a/apps/ensindexer/src/api/index.ts b/apps/ensindexer/src/api/index.ts index 4d3c4fb7b..de1aec8cf 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/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/utils.ts b/apps/ensindexer/src/plugins/efp/lib/utils.ts index 8a1f28440..1473beaf1 100644 --- a/apps/ensindexer/src/plugins/efp/lib/utils.ts +++ b/apps/ensindexer/src/plugins/efp/lib/utils.ts @@ -19,3 +19,29 @@ export type EvmAddress = Address & { __brand: "EvmAddress" }; 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/test/plugins/efp/lib/utils.test.ts b/apps/ensindexer/test/plugins/efp/lib/utils.test.ts index 0c57f8496..dc0fcddda 100644 --- a/apps/ensindexer/test/plugins/efp/lib/utils.test.ts +++ b/apps/ensindexer/test/plugins/efp/lib/utils.test.ts @@ -1,4 +1,4 @@ -import { parseEvmAddress } from "@/plugins/efp/lib/utils"; +import { parseEvmAddress, parseListTokenId } from "@/plugins/efp/lib/utils"; import { describe, expect, it } from "vitest"; describe("EFP utils", () => { @@ -18,4 +18,29 @@ describe("EFP utils", () => { - 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.`, + ); + }); + }); }); From a35833c5830de36a9e6c5451428921dd32bd0467 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 24 Jun 2025 21:15:45 +0200 Subject: [PATCH 28/28] update types --- apps/ensindexer/src/plugins/efp/lib/lsl.ts | 8 ++++---- apps/ensindexer/test/plugins/efp/lib/lsl.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/ensindexer/src/plugins/efp/lib/lsl.ts b/apps/ensindexer/src/plugins/efp/lib/lsl.ts index 9fdad2f81..ba4989686 100644 --- a/apps/ensindexer/src/plugins/efp/lib/lsl.ts +++ b/apps/ensindexer/src/plugins/efp/lib/lsl.ts @@ -3,10 +3,10 @@ */ import type { ENSNamespaceId } from "@ensnode/datasources"; -import type { Address, Hex } from "viem"; +import type { Hex } from "viem"; import { prettifyError, z } from "zod/v4"; import { type EFPDeploymentChainId, getEFPDeploymentChainIds } from "./chains"; -import { parseEvmAddress } from "./utils"; +import { type EvmAddress, parseEvmAddress } from "./utils"; /** * Enum defining recognized List Storage Location Types @@ -76,7 +76,7 @@ export interface ListStorageLocationContract /** * Contract address on chainId where the EFP list records are stored. */ - listRecordsAddress: Address; + listRecordsAddress: EvmAddress; /** * The 32-byte value that specifies the storage slot of the EFP list records within the listRecordsAddress contract. @@ -267,7 +267,7 @@ const createEfpLslContractSchema = (ensNamespaceId: ENSNamespaceId) => { .length(40) .transform((v) => `0x${v}`) // ensure EVM address correctness and map it into lowercase for ease of equality comparisons - .transform((v) => parseEvmAddress(v) as Address), + .transform((v) => parseEvmAddress(v)), slot: z .string() diff --git a/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts index 01c495b89..c0b989f95 100644 --- a/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts +++ b/apps/ensindexer/test/plugins/efp/lib/lsl.test.ts @@ -8,6 +8,7 @@ import { 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"; @@ -30,7 +31,7 @@ describe("EFP List Storage Location", () => { type: ListStorageLocationType.EVMContract, version: ListStorageLocationVersion.V1, chainId: 1, - listRecordsAddress: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + listRecordsAddress: parseEvmAddress("0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef"), slot: 31941687331316587819122038778003974753188806173854071805743471973008610429132n, } satisfies ListStorageLocationContract); }); @@ -51,7 +52,7 @@ describe("EFP List Storage Location", () => { type: ListStorageLocationType.EVMContract, version: ListStorageLocationVersion.V1, chainId: 84532, - listRecordsAddress: "0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef", + listRecordsAddress: parseEvmAddress("0x5289fe5dabc021d02fddf23d4a4df96f4e0f17ef"), slot: 31941687331316587819122038778003974753188806173854071805743471973008610429132n, } satisfies ListStorageLocationContract); });