Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5cb40d1
feat(ensindexer): introduce the `efp` plugin
tk-o Jun 4, 2025
114021e
docs(ensindexer): efp plugin schema docs update
tk-o Jun 4, 2025
797e7de
docs(ensindexer): align code docs (comments) across all plugins
tk-o Jun 4, 2025
4dc678c
refactor(ensindexer): move efp schema into ensnode-schema package
tk-o Jun 4, 2025
57ef002
feat(ensindexer): extends the efp schema and document it well
tk-o Jun 4, 2025
2ac7ddb
feat(ensindexer): include EFPRoot datasource in Sepolia ENS Deployment
tk-o Jun 5, 2025
9ba0591
code docs updates
tk-o Jun 5, 2025
cbfa373
feat(ensindexer): create schema-based EFP LSL parser
tk-o Jun 5, 2025
cd0e030
code docs updates
tk-o Jun 5, 2025
2445b09
code docs updates
tk-o Jun 5, 2025
40a5c2e
feat(ensindexer): use precise types while parsing LSL data
tk-o Jun 5, 2025
45701e3
fix(ensindexer): make all EFP handlers wrapped within a function
tk-o Jun 5, 2025
dcaf69b
code docs updates
tk-o Jun 5, 2025
6f7443e
docs(changeset): Introduce initial `efp` plugin demo.
tk-o Jun 5, 2025
260bca0
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 11, 2025
d8ecb6e
apply PR feedback
tk-o Jun 12, 2025
68e6066
refactor: merge `apps/ensindexer/src/plugins/efp/lib/types.ts` into `…
tk-o Jun 12, 2025
5a90cc6
apply PR feedback
tk-o Jun 13, 2025
1d91624
reorder definitions within `lsl.ts` file
tk-o Jun 13, 2025
0509953
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 13, 2025
7d714bc
split chainId data model into external and internal types
tk-o Jun 13, 2025
9f422a5
refine code docs
tk-o Jun 13, 2025
c02f7c0
refine code docs
tk-o Jun 13, 2025
ee6c1b3
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 20, 2025
6d6e956
apply pr feedback
tk-o Jun 20, 2025
b52033f
refactor(efp): split schemas and lib
tk-o Jun 23, 2025
c49360f
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 23, 2025
c242463
extend api docs
tk-o Jun 23, 2025
d1942ac
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 23, 2025
90a85d1
feat(efp): use int8 column builder for chainId
tk-o Jun 23, 2025
a1962a7
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 23, 2025
42e18e0
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 24, 2025
d57197f
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 24, 2025
fd4bfc2
apply pr feedback
tk-o Jun 24, 2025
03d55bc
feat(ensindexer): setup EFP API routes
tk-o Jun 24, 2025
0040037
Merge remote-tracking branch 'origin/main' into feat/efp-plugin
tk-o Jun 24, 2025
a35833c
update types
tk-o Jun 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/ensindexer/src/lib/api-documentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Comment thread
lightwalker-eth marked this conversation as resolved.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Token", {
...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Tokens", {

id: "Unique token ID for an EFP List Token",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: "Unique token ID for an EFP List Token",
id: "Unique token ID for the ERC-721A NFT representing the EFP List Token",

ownerAddress: "EVM address of the owner of an EFP List Token",
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
listStorageLocation: "A reference to the related ListStorageLocation entity",
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
}),
...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", {
Comment thread
lightwalker-eth marked this conversation as resolved.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", {
...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Locations with recognized formatting", {

Is that fair?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Maybe we aren't defining the data model here correctly?

Previously I was thinking we would build a foreign key relationship from efp_listToken to efp_listStorageLocation using the lsl.

However now I'm thinking that's not right and that we should actually build this foreign key relationship using the EFP List Token Id.

In other words, the primary key of both efp_listToken and efp_listStorageLocation should be the same: the id of the List Token NFT.

What do you think?

I think we still want to keep the suggested recognizedLsl field on the efp_listStorageLocation table (see my other comment on this) as this can be nice for performing queries where we only want to perform matches on recognized lsl, rather than all lsl. For a query on all lsl (recognized or not) that can be done on the lsl field in the efp_listToken table.

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.",
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
listTokenId: "Unique identifier for this EFP list token",
listToken: "A reference to the related ListToken entity",
}),
});
};
6 changes: 3 additions & 3 deletions apps/ensindexer/src/lib/plugin-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ENSIndexerConfig, "ensDeploymentChain" | "plugins">,
): Datasource[] {
): Record<DatasourceName, Datasource> {
Comment thread
tk-o marked this conversation as resolved.
Outdated
const requiredDatasourceNames = getRequiredDatasourceNames(config.plugins);
const ensDeployment = getENSDeployment(config.ensDeploymentChain);
const ensDeploymentDatasources = Object.entries(ensDeployment) as Array<
Expand All @@ -130,7 +130,7 @@ export function getDatasources(
}
}

return Object.values(datasources);
return datasources;
}

/**
Expand Down
69 changes: 69 additions & 0 deletions apps/ensindexer/src/plugins/efp/efp.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* 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";
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;

// Define the Datasources required by the plugin
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
const requiredDatasources = [DatasourceName.EFPBase];

// construct a unique contract namespace for this plugin
const namespace = makePluginNamespace(pluginName);

// config object factory used to derive PluginConfig type
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
function createPonderConfig(appConfig: ENSIndexerConfig) {
const { ensDeployment } = appConfig;
// extract the chain and contract configs for root Datasource in order to build ponder config
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
const { chain, contracts } = ensDeployment[DatasourceName.EFPBase];

return createConfig({
networks: networksConfigForChain(chain.id),
contracts: {
[namespace("EFPListRegistry")]: {
network: networkConfigForContract(chain, contracts.EFPListRegistry),
abi: contracts.EFPListRegistry.abi,
},
},
});
}

// Implicitly define the type returned by createPluginConfig
type PonderConfig = ReturnType<typeof createPonderConfig>;

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
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* nested factory functions, i.e. to ensure that the plugin configuration
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* is only built when the plugin is activated.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*/
createPonderConfig,

/** The unique plugin name */
pluginName,

/** The plugin's required Datasources */
requiredDatasources,
} as const satisfies ENSIndexerPlugin<PluginName.EFP, PonderConfig>;
150 changes: 150 additions & 0 deletions apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { ponder } from "ponder:registry";
import { efp_listStorageLocation, efp_listToken } from "ponder:schema";

import config from "@/config";
import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers";
import { 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<PluginName.EFP>) {
///
/// EFPListRegistry Handlers
///
ponder.on(
namespace("EFPListRegistry:Transfer"),
async function handleListTokenMint({ context, event }) {
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
// We can only index a new List Token after a mint event happened.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
// This means the `from` address of the Transfer event points to
// the zero address. Otherwise, we skip event handling
if (event.args.from !== zeroAddress) {
return;
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
}

// As this is a token mint event, create a new List Token with an owner
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
const listToken = await context.db.insert(efp_listToken).values({
id: event.args.tokenId,
ownerAddress: event.args.to,
});

// Read the List Storage Location for that newly created List Token
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
const listStorageLocationRaw = await context.client.readContract({
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
abi: efpListRegistryContract.abi,
address: efpListRegistryContract.address,
functionName: "getListStorageLocation",
args: [listToken.id],
});

// Index the List Storage Location linked to the List Token
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
try {
const parsedListStorageLocation = parseListStorageLocation(listStorageLocationRaw);

// Index the parsed List Storage Location data with a reference to the List Token
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
// created with the currently handled EVM event
await context.db
.insert(efp_listStorageLocation)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah very important!! We need to make very special note of how it is possible to have "orphaned" LSL.

In other words, some LSL may exist but not have any active relationship with a List Token.

Additionally, as I understand this means it is possible for a List Token to update its LSL so that multiple List Tokens point to the same LSL? Is that correct?

It's really important for us to completely master this data model.

Appreciate your advice!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like us to ask such questions to the EFP team. Will note it down 👍

.values({
...parsedListStorageLocation,
listTokenId: listToken.id,
})
// TODO: decide what needs to do in a case of violated unique constraint
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
// For example, it happens for List Storage Location fetched after
// this transaction
// https://basescan.org/tx/0x5f64037fedd56a3a874f598a38a48ea6f8f5f9815223dac955e2c18eff1ab173
//
// NOTE: For now, we do update the reference to the List Token
.onConflictDoUpdate({
listTokenId: listToken.id,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";

console.error(
`Could not create 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.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*
* @param lsl - The List Storage Location string to parse.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* @throws An error if parsing could not be completed successfully.
* @returns A {@link ListStorageLocation} object with parsed data.
*/
export function parseListStorageLocation(lsl: string): ListStorageLocation {
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
if (lsl.length != 174) {
throw new Error("List Storage Location value must be 174-character long string");
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
}

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 {
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
version: lslVersion,
type: lslType,
chainId: lslChainId,
listRecordsAddress: lslListRecordsContract,
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
slot: lslSlot,
};
}

// NOTE: copied from https://github.com/ethereumfollowprotocol/onchain/blob/598ab49/src/types.ts#L41-L47
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
// Documented based on https://docs.efp.app/design/list-storage-location/
/**
* List Storage Location is encoded in a versioned, flexible data structure.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*
* Each List Storage Location is encoded as a bytes array with the following structure:
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* - `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 serves as an identifier for 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.
*
* The version is always 1.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* The location type is always 1.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*/
type ListStorageLocation = {
/**
* A `uint8` representing the version of the List Storage Location.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* This is used to ensure compatibility and facilitate future upgrades.
*/
version: string;

/**
* A uint8 indicating the type of list storage location.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* This serves as an identifier for the kind of data the data field contains.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*/
type: string;
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated

/**
* The 32-byte EVM chain ID of the chain where the list is stored.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* A.k.a. `chain_id`
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*/
chainId: bigint;
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated

/**
* The 20-byte EVM address of the contract where the list is stored.
* A.k.a. `contract_address`
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*
* NOTE: updated type from `string` to `Address`
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
*/
listRecordsAddress: Address;

/**
* A 32-byte value that specifies the storage slot of the list within the contract.
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
* 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;
};
2 changes: 2 additions & 0 deletions apps/ensindexer/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -11,6 +12,7 @@ export const ALL_PLUGINS = [
basenamesPlugin,
lineaNamesPlugin,
threednsPlugin,
efpPlugin,
] as const;

export type AllPluginsConfig = MergedTypes<
Expand Down
9 changes: 4 additions & 5 deletions apps/ensindexer/src/plugins/lineanames/lineanames.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof createPonderConfig>;

export default {
Expand All @@ -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<PluginName.Lineanames, PonderConfig>;
9 changes: 4 additions & 5 deletions apps/ensindexer/src/plugins/subgraph/subgraph.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof createPonderConfig>;

export default {
Expand All @@ -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<PluginName.Subgraph, PonderConfig>;
9 changes: 4 additions & 5 deletions apps/ensindexer/src/plugins/threedns/threedns.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,7 +54,7 @@ function createPonderConfig(appConfig: ENSIndexerConfig) {
});
}

// construct a specific type for plugin configuration
// Implicitly define the type returned by createPluginConfig
Comment thread
lightwalker-eth marked this conversation as resolved.
type PonderConfig = ReturnType<typeof createPonderConfig>;

export default {
Expand All @@ -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<PluginName.ThreeDNS, PonderConfig>;
Loading