-
Notifications
You must be signed in to change notification settings - Fork 15
Refactor ENSv2 Plugin #356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
shrugs
wants to merge
15
commits into
feat/ens-v2-support
Choose a base branch
from
feat/refactor-ens-v2
base: feat/ens-v2-support
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
b347e05
refactor: ens-deployment type names and variable names for clarity
shrugs 31d265a
feat: implement ens-v2-friendly basic schema
shrugs a5cd7cd
feat: implement basic indexing of ENSv2
shrugs b02d5a3
feat: improve types, fix cyclical bug
shrugs c1dba60
feat: confirm records by node relationship works
shrugs d48b9f8
feat: demonstrate ensnode-api service with custom endpoints/sql
shrugs d4fe25d
docs: subgraph-compat spec
shrugs 01c3a0e
feat: rename Label to Domain
shrugs 75ba921
chore: rename ensnode-api folder to api
shrugs 1dea38c
fix: lockfile
shrugs f0afac2
fix: add bun
shrugs a1955bb
feat: move name construction to api layer, implement domain finding
shrugs d9fca73
fix: key by maskedTokenId but store full tokenId
shrugs 9001d38
fix: store checksummed addresses
shrugs e34e279
feat: reconstruct name and node for domain
shrugs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # deps | ||
| node_modules/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| MIT License | ||
|
|
||
| Copyright (c) 2025 NameHash | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # ENSNode API | ||
|
|
||
| API Server for ENSNode | ||
|
|
||
| ## Documentation | ||
|
|
||
| For detailed documentation and guides, see the [ENSNode Documentation](https://ensnode.io/ensnode). | ||
|
|
||
| ## License | ||
|
|
||
| Licensed under the MIT License, Copyright © 2025-present [NameHash Labs](https://namehashlabs.org). | ||
|
|
||
| See [LICENSE](./LICENSE) for more information. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "name": "@ensnode/api", | ||
| "type": "module", | ||
| "scripts": { | ||
| "start": "bun run src/index.ts", | ||
| "dev": "bun run --hot src/index.ts" | ||
| }, | ||
| "dependencies": { | ||
| "@ensdomains/ensjs": "^4.0.2", | ||
| "@ensnode/ponder-schema": "workspace:*", | ||
| "@ensnode/utils": "workspace:*", | ||
| "@ponder/client": "catalog:", | ||
| "@ponder/utils": "catalog:", | ||
| "bun": "^1.2.2", | ||
| "drizzle-orm": "catalog:", | ||
| "hono": "catalog:", | ||
| "viem": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@ensnode/shared-configs": "workspace:*", | ||
| "@types/bun": "latest" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { Hono } from "hono"; | ||
| import { cors } from "hono/cors"; | ||
| import { proxy } from "hono/proxy"; | ||
|
|
||
| import v1 from "./v1"; | ||
|
|
||
| const app = new Hono(); | ||
|
|
||
| // use cors | ||
| app.use(cors({ origin: "*" })); | ||
|
|
||
| // TODO: ENSNode-api should be the exclusive api entrypoint for ENSNode | ||
| // https://hono.dev/examples/proxy | ||
| // - proxy /ponder, /subgraph, /sql/* endpoints to ensindexer | ||
|
|
||
| app.route("/api/v1", v1); | ||
|
|
||
| export default { | ||
| port: 3289, | ||
| fetch: app.fetch, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import * as _schema from "@ensnode/ponder-schema"; | ||
| import { Table, is } from "drizzle-orm"; | ||
| // import { setDatabaseSchema } from "@ponder/client"; | ||
| import { drizzle } from "drizzle-orm/node-postgres"; | ||
|
|
||
| const setDatabaseSchema = <T extends { [name: string]: unknown }>( | ||
| schema: T, | ||
| schemaName: string, | ||
| ): T => { | ||
| for (const table of Object.values(schema)) { | ||
| if (is(table, Table)) { | ||
| // Use type assertion to fix the TypeScript error | ||
| (table as any)[Symbol.for("drizzle:Schema")] = schemaName; | ||
| } | ||
| } | ||
| return schema; | ||
| }; | ||
|
|
||
| export const schema = setDatabaseSchema(_schema, Bun.env.DATABASE_SCHEMA || "public"); | ||
| export const db = drizzle(Bun.env.DATABASE_URL, { schema, casing: "snake_case", logger: true }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { sql } from "drizzle-orm"; | ||
|
|
||
| import { CAIP10AccountId, LabelHash } from "@ensnode/utils/types"; | ||
| import { HTTPException } from "hono/http-exception"; | ||
| import { hexToBigInt } from "viem"; | ||
| import { db, schema } from "./db"; | ||
| import { parseName } from "./parse-name"; | ||
|
|
||
| // TODO: configure this correctly, likely constructing the root registry id from the relevant ens deployment | ||
| const ROOT_REGISTRY = "eip155:11155111:0xc44D7201065190B290Aaaf6efaDFD49d530547A3"; | ||
|
|
||
| // TODO: de-duplicate these helpers with @ensnode/utils | ||
| const LABEL_HASH_MASK = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000n; | ||
| const maskTokenId = (tokenId: bigint) => tokenId & LABEL_HASH_MASK; | ||
| const labelHashToTokenId = (labelHash: LabelHash) => hexToBigInt(labelHash, { size: 32 }); | ||
|
|
||
| /** | ||
| * gets a Domain from the tree if it exists using recursive CTE, traversing from RootRegistry | ||
| */ | ||
| export async function getDomainAndPath(name: string) { | ||
| const tokenIds = parseName(name) // given a set of labelhashes | ||
| .toReversed() // reverse for path | ||
| .map((labelHash) => maskTokenId(labelHashToTokenId(labelHash))); // convert to masked bigint tokenId | ||
|
|
||
| if (tokenIds.length === 0) { | ||
| throw new Error(`getDomainAndPath: name "${name}" did not contain any segments?`); | ||
| } | ||
|
|
||
| console.log({ | ||
| name, | ||
| tokenIdsReversed: tokenIds, | ||
| }); | ||
|
|
||
| // https://github.com/drizzle-team/drizzle-orm/issues/1289 | ||
| // https://github.com/drizzle-team/drizzle-orm/issues/1589 | ||
| const rawTokenIdsArray = sql.raw(`ARRAY[${tokenIds.join(", ")}]::numeric[]`); | ||
|
|
||
| const result = await db.execute(sql` | ||
| WITH RECURSIVE path_traversal AS ( | ||
| -- Base case: Start with RootRegistry | ||
| SELECT | ||
| r.id AS "registry_id", | ||
| NULL::text AS "domain_id", | ||
| NULL::numeric(78,0) AS "masked_token_id", | ||
| NULL::numeric(78,0) AS "token_id", | ||
| NULL::text AS "label", | ||
| 0 AS depth | ||
| -- ARRAY[]::numeric[] AS traversed_path | ||
| FROM | ||
| ${schema.v2_registry} r | ||
| WHERE | ||
| r.id = ${ROOT_REGISTRY} | ||
|
|
||
| UNION ALL | ||
|
|
||
| -- Recursive case: Find matching domain | ||
| SELECT | ||
| d."subregistry_id" AS "registry_id", | ||
| d.id AS "domain_id", | ||
| d."masked_token_id", | ||
| d."token_id", | ||
| d.label, | ||
| pt.depth + 1 AS depth | ||
| -- pt.traversed_path || d."masked_token_id": :numeric AS traversed_path | ||
| FROM | ||
| path_traversal pt | ||
| JOIN | ||
| ${schema.v2_domain} d ON d."registry_id" = pt."registry_id" | ||
| WHERE | ||
| d."masked_token_id" = (${rawTokenIdsArray})[pt.depth + 1] | ||
| AND pt.depth < array_length(${rawTokenIdsArray}, 1) | ||
| ) | ||
|
|
||
| SELECT * FROM path_traversal | ||
| WHERE domain_id IS NOT NULL -- only return domains, not root registry | ||
| ORDER BY depth | ||
| `); | ||
|
|
||
| // TODO: idk type this correctly | ||
| const rows = result.rows as { | ||
| registry_id: CAIP10AccountId; | ||
| domain_id: string; | ||
| masked_token_id: string; | ||
| token_id: string; | ||
| label: string; | ||
| depth: number; | ||
| }[]; | ||
|
|
||
| // the domain in question was found iff the path has exactly the correct number of nodes | ||
| const exists = rows.length > 0 && rows.length === tokenIds.length; | ||
| if (!exists) throw new HTTPException(404, { message: "Domain not found." }); | ||
|
|
||
| const lastRow = rows[rows.length - 1]!; // NOTE: must exist given length check above | ||
| if (lastRow.domain_id === null) throw new Error(`Expected domain_id`); | ||
|
|
||
| // the last element is the node and it exists in the tree | ||
| return { | ||
| path: rows, | ||
| domain: await db.query.v2_domain.findFirst({ | ||
| where: (t, { eq }) => eq(t.id, lastRow.domain_id), | ||
| }), | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { Node } from "@ensnode/utils/types"; | ||
| import { db } from "./db"; | ||
|
|
||
| export async function getResolverRecords(resolverId: string, node: Node) { | ||
| return await db.query.v2_resolverRecords.findFirst({ | ||
| // TODO: put id generation into @ensnode/utils and re-use it here for faster lookups | ||
| where: (t, { eq, and }) => and(eq(t.resolverId, resolverId), eq(t.node, node)), | ||
| with: { addresses: true }, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { LabelHash } from "@ensnode/utils/types"; | ||
| import { Hex, isHex } from "viem"; | ||
| import { labelhash } from "viem/ens"; | ||
|
|
||
| // https://github.com/wevm/viem/blob/main/src/utils/ens/encodedLabelToLabelhash.ts | ||
| export function encodedLabelToLabelhash(label: string): Hex | null { | ||
| if (label.length !== 66) return null; | ||
| if (label.indexOf("[") !== 0) return null; | ||
| if (label.indexOf("]") !== 65) return null; | ||
| const hash = `0x${label.slice(1, 65)}`; | ||
| if (!isHex(hash)) return null; | ||
| return hash; | ||
| } | ||
|
|
||
| /** | ||
| * parses a name into labelHash segments. name may contain encoded labelHashes | ||
| */ | ||
| export function parseName(name: string): LabelHash[] { | ||
| return name.split(".").map((segment) => { | ||
| const labelHash = segment.startsWith("[") | ||
| ? encodedLabelToLabelhash(segment) | ||
| : labelhash(segment); | ||
|
|
||
| if (!labelHash) { | ||
| throw new Error( | ||
| `parseName: name "${name}" segment "${segment}" is not a valid encoded labelHash`, | ||
| ); | ||
| } | ||
|
|
||
| return labelHash; | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { encodeLabelhash } from "@ensdomains/ensjs/utils"; | ||
| import { uint256ToHex32 } from "@ensnode/utils/subname-helpers"; | ||
| import { replaceBigInts } from "@ponder/utils"; | ||
| import { Hono } from "hono"; | ||
| import { namehash } from "viem"; | ||
|
|
||
| import { getDomainAndPath } from "./lib/get-domain.js"; | ||
|
|
||
| const app = new Hono(); | ||
|
|
||
| /** | ||
| * Finds a Domain by its `name` in the nametree. | ||
| */ | ||
| app.get("/domain/:name", async (c) => { | ||
| const nameParam = c.req.param("name"); | ||
|
|
||
| // fetches a domain by name and the concrete path in the nametree | ||
| const { domain, path } = await getDomainAndPath(nameParam); | ||
|
|
||
| // identify any unknown labels in the name | ||
| const unknownSegments = path.filter((segment) => segment.label === undefined); | ||
|
|
||
| // TODO: attempt heal with ENSRainbow batch | ||
| const knownOrEncodedSegments = (await Promise.all(unknownSegments)).reduce< | ||
| Record<string, string> | ||
| >((memo, segment) => { | ||
| memo[segment.token_id] === encodeLabelhash(uint256ToHex32(BigInt(segment.token_id))); | ||
| return memo; | ||
| }, {}); | ||
|
|
||
| // construct the domain's name to the best of our abilities | ||
| const name = path | ||
| // reverse to name-order | ||
| .toReversed() | ||
| // return known label or ens rainbow result | ||
| .map((segment) => segment.label ?? knownOrEncodedSegments[segment.token_id]) | ||
| // join into name | ||
| .join("."); | ||
|
|
||
| const node = namehash(name); | ||
|
|
||
| // TODO: type this when we're more confident in what we want | ||
| const result = { | ||
| domain: { | ||
| ...domain, | ||
| // add constructed name and node to domain response | ||
| name, | ||
| node, | ||
| }, | ||
| path, | ||
| }; | ||
|
|
||
| return c.json(replaceBigInts(result, (v) => String(v))); | ||
| }); | ||
|
|
||
| export default app; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "extends": "@ensnode/shared-configs/tsconfig.ponder.json", | ||
| "include": ["./**/*.ts"], | ||
| "exclude": ["node_modules"], | ||
| "compilerOptions": { | ||
| "strict": true, | ||
| "jsx": "react-jsx", | ||
| "jsxImportSource": "hono/jsx" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps race condition where this domain is no longer valid in the tree if the indexed state changed between these two queries — would be good to place them in a transaction or something idk, to ensure atomicity