Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .changeset/upset-glasses-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@ensnode/datasources": minor
"@ensnode/ensnode-schema": minor
"@ensnode/ensnode-sdk": minor
"ensindexer": minor
---

Introduce initial `efp` plugin demo that indexes EFP List Tokens and EFP List Storage Locations.
4 changes: 4 additions & 0 deletions apps/ensindexer/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -140,4 +141,7 @@ app.use(
}),
);

// Use Hono EFP application for handling EFP-related requests
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Use Hono EFP application for handling EFP-related requests
// Activate the draft proof-of-concept Hono app for handling EFP-related API requests

app.route("/efp", honoEFP({ db }));

export default app;
17 changes: 17 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,22 @@ const makeApiDocumentation = (isSubgraph: boolean) => {
value: "Value of the text record",
},
),
/**
* The following is documentation for packages/ensnode-schema/src/efp.schema.ts
*/
...generateTypeDocSetWithTypeName("efp_listToken", "EFP List Token", {
Copy link
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
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",

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`.",
Copy link
Member

Choose a reason for hiding this comment

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

A few concerns / suggestions here:

  1. Suggest renaming this to just lsl rather than lslId. As I understand the value in this field is always the raw lsl value we index onchain, which may or may not be in a recognized format. Right? Suggest to not introduce "lsl id" as additional terminology if we can avoid it.
  2. My expectation is that this value should only be null if the list token never created a lsl. If the lsl was not in a recognized format then that unrecognized value should still be saved in this field. Right?
  3. As I understand it's possible to update the LSL associated with a List Token across time? Assuming so, is it possible to transition a LSL from non-null back to null? Appreciate your advice.

}),
...generateTypeDocSetWithTypeName("efp_listStorageLocation", "EFP List Storage Location", {
Copy link
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
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.

id: "ListStorageLocation ID, an `EncodedLsl` value (always lowercase).",
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
id: "ListStorageLocation ID, an `EncodedLsl` value (always lowercase).",
recognizedLsl: "ListStorageLocation with a recognized valid `EncodedLsl` value (always lowercase).",

What do you think?

I find it nice to avoid introducing additional terminology for "lsl id". Instead, in my current understanding, we have lsl, and a subset of lsl we index will be "recognized". All lsl we index will be found in the efp_listToken table, while only the recognized ones will be found in the efp_listStorageLocation table.

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.",
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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.",
slot: "A unique identifier distinguishing between multiple EFP lists stored in the same `EFPListRecords` smart contract.",

Is that fair?

}),
});
};
101 changes: 101 additions & 0 deletions apps/ensindexer/src/plugins/efp/handlers/EFPListRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { ponder } from "ponder:registry";
import { efp_listStorageLocation, efp_listToken } from "ponder:schema";

import config from "@/config";
import type { ENSIndexerPluginHandlerArgs } from "@/lib/plugin-helpers";
import { PluginName } from "@ensnode/ensnode-sdk";
import { zeroAddress } from "viem";
import {
decodeListStorageLocationContract,
isEncodedLslContract,
parseEncodedLsl,
} from "../lib/lsl";
import { parseEvmAddress } from "../lib/utils";

export default function ({ pluginNamespace: ns }: ENSIndexerPluginHandlerArgs<PluginName.EFP>) {
///
/// EFPListRegistry Handlers
///
ponder.on(
ns("EFPListRegistry:Transfer"),
async function handleEFPListTokenTransfer({ context, event }) {
const { tokenId, from: fromAddress, to: toAddress } = event.args;

// The mint event represents the transfer of ownership of
// an EFP List token from the zeroAddress to the new owner's address.
if (fromAddress === zeroAddress) {
// Create a new EFP List Token with the owner initialized as the token recipient
await context.db.insert(efp_listToken).values({
id: tokenId,
owner: parseEvmAddress(toAddress),
});
}

// The burn event represents the transfer of ownership of
// an EFP List token from the owner's address to the zero address.
else if (toAddress === zeroAddress) {
// Delete the burnt EFP List Token
await context.db.delete(efp_listToken, { id: tokenId });
Copy link
Member

Choose a reason for hiding this comment

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

What about other cascading relationships? For example, what if this burnt list token was also associated with a list storage location? Don't we need to ensure that record is also deleted?

If Postgres is automatically doing those cascading deletes for us, please document that here. Thanks

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd keep the LSL updates (and deletes) to be handled with UpdateListStorageLocation event.

I don't know the EFP protocol to the extent that would let me confidently say what needs to be done when a list token is burnt. This is a great question to ask to the EFP team. I'd like us to focus on optimising what we know. Things we don't know about the EFP protocol should not be optimised in this PR.

I think we can leave this example to highlight: here's how updates and deletions look like.

Copy link
Member

Choose a reason for hiding this comment

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

Revisiting this discussion. Please see my other comments suggesting that the primary key of both the efp_listStorageLocation and efp_listToken be the EFP List Token Id.

If you agree with that idea then I believe we should also perform a delete on efp_listStorageLocation here.

}

// If the transfer is not from the zeroAddress,
// and the transfer is not to the zeroAddress,
// this transfer represents an ownership change.
else {
// Update the owner of the the List Token that changed ownership
await context.db.update(efp_listToken, { id: tokenId }).set({
owner: parseEvmAddress(toAddress),
});
}
},
);

ponder.on(
ns("EFPListRegistry:UpdateListStorageLocation"),
async function handleEFPListStorageLocationUpdate({ context, event }) {
const { listStorageLocation: encodedListStorageLocation, tokenId } = event.args;
const listToken = await context.db.find(efp_listToken, { id: tokenId });

// invariant: List Token must exist before its List Storage Location can be updated
if (!listToken) {
throw new Error(
`Cannot update List Storage Location for nonexisting List Token (id: ${tokenId})`,
);
}

const encodedLsl = parseEncodedLsl(encodedListStorageLocation);
const lslId = encodedLsl;

// Update the List Token with the new List Storage Location
await context.db.update(efp_listToken, { id: tokenId }).set({
lslId,
});

// Update the List Storage Location associated with the List Token
// if the List Storage Location is in a recognized format
if (isEncodedLslContract(encodedLsl)) {
try {
const lslContract = decodeListStorageLocationContract(config.namespace, encodedLsl);

// Index the decoded List Storage Location data with a reference to the List Token
// created with the currently handled EVM event.
// Note: if the List Storage Location with the same ID already exists,
// no new changes will be made to the database.
await context.db
.insert(efp_listStorageLocation)
.values({
id: lslId,
chainId: lslContract.chainId,
listRecordsAddress: lslContract.listRecordsAddress,
slot: lslContract.slot,
})
.onConflictDoNothing();
Copy link
Member

Choose a reason for hiding this comment

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

Please see my other comments about suggested data model changes.

I think we want to remove this onConflictDoNothing and change this from an insert operation to an upsert operation.

} catch {
// The `encodedLsl` value could not be decoded.
Copy link
Member

Choose a reason for hiding this comment

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

Please see my other comments about suggested data model changes.

Based on those suggestions, I believe here we want to perform a delete operation in the efp_listStorageLocation table to handle the case that the previous LSL for the List Token was recognized but the new value isn't.

// We can ignore this case, as we have already captured the `encodedLsl` value
// in the `efp_listToken` table as the `lslId` value.
}
}
},
);
}
56 changes: 56 additions & 0 deletions apps/ensindexer/src/plugins/efp/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* This Hono application enables the ENSIndexer API to handle requests related to the Ethereum Follow Protocol.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* This Hono application enables the ENSIndexer API to handle requests related to the Ethereum Follow Protocol.
* This Hono application demonstrates a draft proof-of-concept for how custom API handlers can be defined to operate on indexed Ethereum Follow Protocol data.

*/

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<Record<string, unknown>>;
Copy link
Member

Choose a reason for hiding this comment

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

I'm not so familiar with Drizzle. Are we supposed to pass in the overall schema we define in another file here to help give Drizzle full type system knowledge?

Appreciate your advice.

}

/**
* 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;
}
29 changes: 29 additions & 0 deletions apps/ensindexer/src/plugins/efp/lib/chains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ENSNamespaceId } from "@ensnode/datasources";
import { base, baseSepolia, mainnet, optimism, optimismSepolia, sepolia } from "viem/chains";

/**
* The ChainId of an EFP Deployment..
*
* EFP has an allowlisted set of supported chains. This allowlisted set is a function of the ENSNamespace.
* See {@link getEFPDeploymentChainIds}.
*/
export type EFPDeploymentChainId = number;

/**
* Get the list of EFP Deployment Chain IDs for the ENS Namespace ID.
*
* @param ensNamespaceId - ENS Namespace ID to get the EFP Deployment Chain IDs for
* @returns list of EFP Deployment Chain IDs on the associated ENS Namespace
*/
export function getEFPDeploymentChainIds(ensNamespaceId: ENSNamespaceId): EFPDeploymentChainId[] {
switch (ensNamespaceId) {
case "mainnet":
return [base.id, optimism.id, mainnet.id];
case "sepolia":
return [baseSepolia.id, optimismSepolia.id, sepolia.id];
default:
throw new Error(
`EFP Deployment chainIds are not defined for the ${ensNamespaceId} ENS Namespace ID`,
);
}
}
Loading