diff --git a/.changeset/big-impalas-brush.md b/.changeset/big-impalas-brush.md new file mode 100644 index 000000000..76b3b34fd --- /dev/null +++ b/.changeset/big-impalas-brush.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Extended `EnsDbClient` with `EnsDbClientMigration` interface implementation. diff --git a/.changeset/breezy-corners-tickle.md b/.changeset/breezy-corners-tickle.md new file mode 100644 index 000000000..33a03a8df --- /dev/null +++ b/.changeset/breezy-corners-tickle.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +ENSNode GraphQL API: `Registration.start` is now available, indicating the start timestamp of the Registration. diff --git a/.changeset/clever-chicken-listen.md b/.changeset/clever-chicken-listen.md new file mode 100644 index 000000000..94561b92d --- /dev/null +++ b/.changeset/clever-chicken-listen.md @@ -0,0 +1,6 @@ +--- +"ensapi": minor +"ensindexer": minor +--- + +The ENSv2 Plugin can now be safely activated for ENSv1-only namespaces (ex: 'mainnet', 'sepolia'). diff --git a/.changeset/light-ducks-thank.md b/.changeset/light-ducks-thank.md new file mode 100644 index 000000000..6f49a113f --- /dev/null +++ b/.changeset/light-ducks-thank.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Introduces integration testing for the ENSv2 Plugin and GraphQL API against the ENSv2 devnet. diff --git a/.changeset/seven-hands-ask.md b/.changeset/seven-hands-ask.md new file mode 100644 index 000000000..e7caf6318 --- /dev/null +++ b/.changeset/seven-hands-ask.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Introduced `EnsDbClientMigration` interface. diff --git a/.changeset/shy-wolves-judge.md b/.changeset/shy-wolves-judge.md new file mode 100644 index 000000000..20d4639ba --- /dev/null +++ b/.changeset/shy-wolves-judge.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +ENSNode GraphQL API: Add `where` filters to all `*.events` connections (`Domain.events`, `Resolver.events`, `Permissions.events`, `Account.events`). Supports filtering by `selector_in`, `timestamp_gte`, `timestamp_lte`, and `from` (where applicable). Also adds `Account.events` field to find events by transaction sender. diff --git a/.changeset/silly-bats-smell.md b/.changeset/silly-bats-smell.md new file mode 100644 index 000000000..d46ccd11e --- /dev/null +++ b/.changeset/silly-bats-smell.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Introduced database migration toolkit based on `drizzle-kit`. diff --git a/.changeset/wide-chicken-dream.md b/.changeset/wide-chicken-dream.md new file mode 100644 index 000000000..7a356873b --- /dev/null +++ b/.changeset/wide-chicken-dream.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +ENSNode GraphQL API: `Account.events` now provides the set of Events for which this Account is the sender (i.e. `Transaction.from`). diff --git a/.gitignore b/.gitignore index c9e71eba7..e7b333e66 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,9 @@ generated .terraform* terraform.tfstate* -# CLAUDE.md +# Claude CLAUDE.md +.claude # ENSRainbow data apps/ensrainbow/data* diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index e12b99ca8..6de2ee84b 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -12,12 +12,9 @@ ENSINDEXER_URL=http://localhost:42069 # It should be in the format of `postgresql://:@:/` # # See https://ensnode.io/ensindexer/usage/configuration/ for additional information. +# NOTE that ENSApi does NOT need to define DATABASE_SCHEMA, as it is inferred from the connected ENSIndexer's Config. DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database -# ENSDb: Database Schema name for ENSIndexer Schema -# Required. Should match the DATABASE_SCHEMA used by the connected ENSIndexer. -DATABASE_SCHEMA=public - # ENSApi: RPC Configuration # Required. ENSApi requires an HTTP RPC to the connected ENSIndexer's ENS Root Chain, which depends # on ENSIndexer's NAMESPACE (ex: mainnet, sepolia, ens-test-env). This ENS Root Chain RPC diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 7060e7602..407effcb2 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -19,20 +19,12 @@ vi.mock("@/lib/logger", () => ({ error: vi.fn(), info: vi.fn(), }, - makeLogger: vi.fn(() => ({ - error: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - trace: vi.fn(), - })), })); const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; const BASE_ENV = { DATABASE_URL: "postgresql://user:password@localhost:5432/mydb", - DATABASE_SCHEMA: "public", ENSINDEXER_URL: "http://localhost:42069", RPC_URL_1: VALID_RPC_URL, } satisfies EnsApiEnvironment; @@ -58,20 +50,6 @@ const ENSINDEXER_PUBLIC_CONFIG = { }, } satisfies ENSIndexerPublicConfig; -// Mock EnsDbClient - must be defined after ENSINDEXER_PUBLIC_CONFIG since vi.mock is hoisted -// We'll use a simple class mock and configure it in beforeEach -const mockGetVersion = vi.fn().mockResolvedValue("1.0.0"); -const mockGetEnsIndexerPublicConfig = vi.fn().mockResolvedValue(ENSINDEXER_PUBLIC_CONFIG); -const mockGetIndexingStatusSnapshot = vi.fn().mockResolvedValue(null); - -vi.mock("@/lib/ensdb-client/ensdb-client", () => ({ - EnsDbClient: class MockEnsDbClient { - getVersion = mockGetVersion; - getEnsIndexerPublicConfig = mockGetEnsIndexerPublicConfig; - getIndexingStatusSnapshot = mockGetIndexingStatusSnapshot; - }, -})); - const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -138,7 +116,6 @@ describe("buildConfigFromEnvironment", () => { const TEST_ENV: EnsApiEnvironment = { DATABASE_URL: BASE_ENV.DATABASE_URL, - DATABASE_SCHEMA: BASE_ENV.DATABASE_SCHEMA, ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL, }; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index e9fab8285..ec402ba89 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,5 +1,6 @@ import packageJson from "@/../package.json" with { type: "json" }; +import pRetry from "p-retry"; import { parse as parseConnectionString } from "pg-connection-string"; import { prettifyError, ZodError, z } from "zod/v4"; @@ -20,7 +21,7 @@ import { import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; -import { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; +import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config"; import logger from "@/lib/logger"; export const DatabaseUrlSchema = z.string().refine( @@ -76,19 +77,6 @@ const EnsApiConfigSchema = z export type EnsApiConfig = z.infer; -/** - * Builds an instance of {@link EnsDbClient} using environment variables. - * - * @returns instance of {@link EnsDbClient} - * @throws Error with formatted validation messages if environment parsing fails - */ -function buildEnsDbClientFromEnvironment(env: EnsApiEnvironment): EnsDbClient { - const databaseUrl = DatabaseUrlSchema.parse(env.DATABASE_URL); - const ensIndexerSchemaName = DatabaseSchemaNameSchema.parse(env.DATABASE_SCHEMA); - - return new EnsDbClient(databaseUrl, ensIndexerSchemaName); -} - /** * Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig. * @@ -97,13 +85,16 @@ function buildEnsDbClientFromEnvironment(env: EnsApiEnvironment): EnsDbClient { */ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise { try { - const ensDbClient = buildEnsDbClientFromEnvironment(env); - - const ensIndexerPublicConfig = await ensDbClient.getEnsIndexerPublicConfig(); - - if (!ensIndexerPublicConfig) { - throw new Error("Failed to load EnsIndexerPublicConfig from ENSDb."); - } + const ensIndexerUrl = EnsIndexerUrlSchema.parse(env.ENSINDEXER_URL); + + const ensIndexerPublicConfig = await pRetry(() => fetchENSIndexerConfig(ensIndexerUrl), { + retries: 3, + onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { + logger.info( + `ENSIndexer Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, + ); + }, + }); const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); diff --git a/apps/ensapi/src/config/environment.ts b/apps/ensapi/src/config/environment.ts index 550a1c4b3..119490fdf 100644 --- a/apps/ensapi/src/config/environment.ts +++ b/apps/ensapi/src/config/environment.ts @@ -15,7 +15,7 @@ import type { * their state in `process.env`. This interface is intended to be the source type which then gets * mapped/parsed into a structured configuration object like `EnsApiConfig`. */ -export type EnsApiEnvironment = DatabaseEnvironment & +export type EnsApiEnvironment = Omit & EnsIndexerUrlEnvironment & RpcEnvironment & PortEnvironment & diff --git a/apps/ensapi/src/graphql-api/builder.ts b/apps/ensapi/src/graphql-api/builder.ts index f1c71aaca..183277edd 100644 --- a/apps/ensapi/src/graphql-api/builder.ts +++ b/apps/ensapi/src/graphql-api/builder.ts @@ -9,8 +9,14 @@ import type { DomainId, InterpretedName, Node, + PermissionsId, + PermissionsResourceId, + PermissionsUserId, + RegistrationId, RegistryId, + RenewalId, ResolverId, + ResolverRecordsId, } from "@ensnode/ensnode-sdk"; import type { context } from "@/graphql-api/context"; @@ -28,7 +34,12 @@ export const builder = new SchemaBuilder<{ DomainId: { Input: DomainId; Output: DomainId }; RegistryId: { Input: RegistryId; Output: RegistryId }; ResolverId: { Input: ResolverId; Output: ResolverId }; - // PermissionsId: { Input: PermissionsId; Output: PermissionsId }; + PermissionsId: { Input: PermissionsId; Output: PermissionsId }; + PermissionsResourceId: { Input: PermissionsResourceId; Output: PermissionsResourceId }; + PermissionsUserId: { Input: PermissionsUserId; Output: PermissionsUserId }; + RegistrationId: { Input: RegistrationId; Output: RegistrationId }; + RenewalId: { Input: RenewalId; Output: RenewalId }; + ResolverRecordsId: { Input: ResolverRecordsId; Output: ResolverRecordsId }; }; // the following ensures via typechecker that every t.connection returns a totalCount field diff --git a/apps/ensapi/src/graphql-api/lib/connection-helpers.ts b/apps/ensapi/src/graphql-api/lib/connection-helpers.ts index 33bba2464..962e49050 100644 --- a/apps/ensapi/src/graphql-api/lib/connection-helpers.ts +++ b/apps/ensapi/src/graphql-api/lib/connection-helpers.ts @@ -1,7 +1,7 @@ import { and, asc, desc, gt, lt } from "drizzle-orm"; import z from "zod/v4"; -import { cursors } from "@/graphql-api/schema/cursors"; +import { cursors } from "@/graphql-api/lib/cursors"; type Column = Parameters[0]; diff --git a/apps/ensapi/src/graphql-api/lib/cursors.ts b/apps/ensapi/src/graphql-api/lib/cursors.ts new file mode 100644 index 000000000..07b9deb7f --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/cursors.ts @@ -0,0 +1,20 @@ +import superjson from "superjson"; + +/** + * It's considered good practice to provide cursors as opaque strings exclusively useful for + * paginating sets, so we encode/decode values using base64'd superjson. + * + * superjson handles BigInt and other non-JSON-native types transparently. + */ +export const cursors = { + encode: (value: T) => Buffer.from(superjson.stringify(value), "utf8").toString("base64"), + decode: (cursor: string): T => { + try { + return superjson.parse(Buffer.from(cursor, "base64").toString("utf8")); + } catch { + throw new Error( + "Invalid cursor: failed to decode cursor. The cursor may be malformed or from an incompatible query.", + ); + } + }, +}; diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts b/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts index ac5bb16e8..1561a6695 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts @@ -3,7 +3,7 @@ import config from "@/config"; import { sql } from "drizzle-orm"; import * as schema from "@ensnode/ensnode-schema"; -import { getENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; import { db } from "@/lib/db"; @@ -22,6 +22,8 @@ import { db } from "@/lib/db"; */ const CANONICAL_REGISTRIES_MAX_DEPTH = 16; +const ENSV2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace); + /** * Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all * Canonical Registries. A Canonical Registry is an ENSv2 Registry that is the Root Registry or the @@ -29,8 +31,16 @@ const CANONICAL_REGISTRIES_MAX_DEPTH = 16; * * TODO: could this be optimized further, perhaps as a materialized view? */ -export const getCanonicalRegistriesCTE = () => - db +export const getCanonicalRegistriesCTE = () => { + // if ENSv2 is not defined, return an empty set with identical structure to below + if (!ENSV2_ROOT_REGISTRY_ID) { + return db + .select({ id: sql`registry_id`.as("id") }) + .from(sql`(SELECT NULL::text AS registry_id WHERE FALSE) AS canonical_registries_cte`) + .as("canonical_registries"); + } + + return db .select({ // NOTE: using `id` here to avoid clobbering `registryId` in consuming queries, which would // result in '_ is ambiguous' error messages from postgres because drizzle isn't scoping the @@ -38,17 +48,19 @@ export const getCanonicalRegistriesCTE = () => id: sql`registry_id`.as("id"), }) .from( - sql`( - WITH RECURSIVE canonical_registries AS ( - SELECT ${getENSv2RootRegistryId(config.namespace)}::text AS registry_id, 0 AS depth - UNION ALL - SELECT rcd.registry_id, cr.depth + 1 - FROM ${schema.registryCanonicalDomain} rcd - JOIN ${schema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id - JOIN canonical_registries cr ON cr.registry_id = parent.registry_id - WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH} - ) - SELECT registry_id FROM canonical_registries - ) AS canonical_registries_cte`, + sql` + ( + WITH RECURSIVE canonical_registries AS ( + SELECT ${ENSV2_ROOT_REGISTRY_ID}::text AS registry_id, 0 AS depth + UNION ALL + SELECT rcd.registry_id, cr.depth + 1 + FROM ${schema.registryCanonicalDomain} rcd + JOIN ${schema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id + JOIN canonical_registries cr ON cr.registry_id = parent.registry_id + WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH} + ) + SELECT registry_id FROM canonical_registries + ) AS canonical_registries_cte`, ) .as("canonical_registries"); +}; diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts index 38e2d7f85..1572f2737 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { DomainId } from "@ensnode/ensnode-sdk"; -import { DomainCursor } from "./domain-cursor"; +import { type DomainCursor, DomainCursors } from "./domain-cursor"; describe("DomainCursor", () => { describe("roundtrip encode/decode", () => { @@ -13,7 +13,7 @@ describe("DomainCursor", () => { dir: "ASC", value: "example", }; - expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor); }); it("roundtrips with a bigint value (REGISTRATION_TIMESTAMP ordering)", () => { @@ -23,7 +23,7 @@ describe("DomainCursor", () => { dir: "DESC", value: 1234567890n, }; - expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor); }); it("roundtrips with a bigint value (REGISTRATION_EXPIRY ordering)", () => { @@ -33,7 +33,7 @@ describe("DomainCursor", () => { dir: "ASC", value: 9999999999n, }; - expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor); }); it("roundtrips with a null value", () => { @@ -43,22 +43,22 @@ describe("DomainCursor", () => { dir: "ASC", value: null, }; - expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor); }); }); describe("decode error handling", () => { it("throws on garbage input", () => { - expect(() => DomainCursor.decode("not-valid-base64!!!")).toThrow("Invalid cursor"); + expect(() => DomainCursors.decode("not-valid-base64!!!")).toThrow("Invalid cursor"); }); it("throws on valid base64 but invalid json", () => { const notJson = Buffer.from("not json", "utf8").toString("base64"); - expect(() => DomainCursor.decode(notJson)).toThrow("Invalid cursor"); + expect(() => DomainCursors.decode(notJson)).toThrow("Invalid cursor"); }); it("throws on empty string", () => { - expect(() => DomainCursor.decode("")).toThrow("Invalid cursor"); + expect(() => DomainCursors.decode("")).toThrow("Invalid cursor"); }); }); }); diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts index aa2ff724d..7197bd101 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -1,7 +1,6 @@ -import superjson from "superjson"; - import type { DomainId } from "@ensnode/ensnode-sdk"; +import { cursors } from "@/graphql-api/lib/cursors"; import type { DomainOrderValue } from "@/graphql-api/lib/find-domains/types"; import type { DomainsOrderBy } from "@/graphql-api/schema/domain"; import type { OrderDirection } from "@/graphql-api/schema/order-direction"; @@ -40,13 +39,12 @@ export interface DomainCursor { * * @dev it's base64'd (super)json */ -export const DomainCursor = { - encode: (cursor: DomainCursor) => - Buffer.from(superjson.stringify(cursor), "utf8").toString("base64"), +export const DomainCursors = { + encode: (cursor: DomainCursor): string => cursors.encode(cursor), // TODO: in the future, validate the cursor format matches DomainCursor decode: (cursor: string): DomainCursor => { try { - return superjson.parse(Buffer.from(cursor, "base64").toString("utf8")); + return cursors.decode(cursor); } catch { throw new Error( "Invalid cursor: failed to decode cursor. The cursor may be malformed or from an incompatible query.", diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts index a17aaae92..37ef33dbf 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -8,7 +8,10 @@ import type { } from "@/graphql-api/lib/find-domains/layers/with-ordering-metadata"; import { lazyConnection } from "@/graphql-api/lib/lazy-connection"; import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; -import { ID_PAGINATED_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { + PAGINATION_DEFAULT_MAX_SIZE, + PAGINATION_DEFAULT_PAGE_SIZE, +} from "@/graphql-api/schema/constants"; import { DOMAINS_DEFAULT_ORDER_BY, DOMAINS_DEFAULT_ORDER_DIR, @@ -20,7 +23,7 @@ import type { OrderDirection } from "@/graphql-api/schema/order-direction"; import { db } from "@/lib/db"; import { makeLogger } from "@/lib/logger"; -import { DomainCursor } from "./domain-cursor"; +import { DomainCursors } from "./domain-cursor"; import { cursorFilter, orderFindDomains } from "./find-domains-resolver-helpers"; import type { DomainOrderValue } from "./types"; @@ -103,23 +106,24 @@ export function resolveFindDomains( connection: () => resolveCursorConnection( { - ...ID_PAGINATED_CONNECTION_ARGS, - args: connectionArgs, toCursor: (domain: DomainWithOrderValue) => - DomainCursor.encode({ + DomainCursors.encode({ id: domain.id, by: orderBy, dir: orderDir, value: domain.__orderValue, }), + defaultSize: PAGINATION_DEFAULT_PAGE_SIZE, + maxSize: PAGINATION_DEFAULT_MAX_SIZE, + args: connectionArgs, }, async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { // build order clauses const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); // decode cursors for keyset pagination - const beforeCursor = before ? DomainCursor.decode(before) : undefined; - const afterCursor = after ? DomainCursor.decode(after) : undefined; + const beforeCursor = before ? DomainCursors.decode(before) : undefined; + const afterCursor = after ? DomainCursors.decode(after) : undefined; // build query with pagination constraints const query = db diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/layers/with-ordering-metadata.ts b/apps/ensapi/src/graphql-api/lib/find-domains/layers/with-ordering-metadata.ts index 74ff239c8..7c512bda9 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/layers/with-ordering-metadata.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/layers/with-ordering-metadata.ts @@ -24,7 +24,7 @@ export type DomainsWithOrderingMetadataResult = { /** * Enrich a base domain set with ordering metadata. * - * Joins latestRegistrationIndex → registration → event for registration-based ordering. + * Joins latestRegistrationIndex → registration for registration-based ordering. * Uses sortableLabel from the base set for NAME ordering. * * Returns a CTE with columns: {id, sortableLabel, registrationTimestamp, registrationExpiry} @@ -40,8 +40,8 @@ export function withOrderingMetadata(base: BaseDomainSet) { // for NAME ordering sortableLabel: base.sortableLabel, - // for REGISTRATION_TIMESTAMP ordering - registrationTimestamp: schema.event.timestamp, + // for REGISTRATION_TIMESTAMP ordering (materialized on registration) + registrationTimestamp: schema.registration.start, // for REGISTRATION_EXPIRY ordering registrationExpiry: schema.registration.expiry, @@ -59,9 +59,7 @@ export function withOrderingMetadata(base: BaseDomainSet) { eq(schema.registration.domainId, base.domainId), eq(schema.registration.index, schema.latestRegistrationIndex.index), ), - ) - // join (latest) Registration's Event - .leftJoin(schema.event, eq(schema.event.id, schema.registration.eventId)); + ); return db.$with("domains").as(domains); } diff --git a/apps/ensapi/src/graphql-api/lib/find-events/find-events-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-events/find-events-resolver.ts new file mode 100644 index 000000000..9c08dbfef --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-events/find-events-resolver.ts @@ -0,0 +1,116 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and, count, eq, getTableColumns, gte, inArray, lte, type SQL, sql } from "drizzle-orm"; +import type { Address, Hex } from "viem"; + +import * as schema from "@ensnode/ensnode-schema"; + +import { orderPaginationBy, paginateBy } from "@/graphql-api/lib/connection-helpers"; +import { lazyConnection } from "@/graphql-api/lib/lazy-connection"; +import { ID_PAGINATED_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { db } from "@/lib/db"; + +/** + * A join table that relates some entity to events via an `eventId` column. + */ +type EventJoinTable = + | typeof schema.domainEvent + | typeof schema.resolverEvent + | typeof schema.permissionsEvent; + +/** + * Available filter options for find-events queries. + */ +interface EventsWhere { + /** Filter to events whose selector (event signature) matches any of the provided values. */ + selector_in?: Hex[] | null; + /** Filter to events at or after this timestamp. */ + timestamp_gte?: bigint | null; + /** Filter to events at or before this timestamp. */ + timestamp_lte?: bigint | null; + /** Filter to events sent by this address. */ + from?: Address | null; +} + +/** + * Build SQL conditions from EventsWhere filters. + */ +function eventsWhereConditions(where?: EventsWhere | null): SQL | undefined { + if (!where) return undefined; + + return and( + where.selector_in + ? where.selector_in.length + ? inArray(schema.event.selector, where.selector_in) + : sql`false` + : undefined, + typeof where.timestamp_gte === "bigint" + ? gte(schema.event.timestamp, where.timestamp_gte) + : undefined, + typeof where.timestamp_lte === "bigint" + ? lte(schema.event.timestamp, where.timestamp_lte) + : undefined, + where.from ? eq(schema.event.from, where.from) : undefined, + ); +} + +/** + * Resolves a paginated events connection. Always queries the events table directly, with an + * optional join table to narrow results through a relation (e.g. domainEvent, resolverEvent). + * + * @param args - Relay connection args (first/last/before/after) + * @param options.through - Optional join table with an `eventId` column and scope condition to narrow results through a relation + * @param options.where - Optional user-facing filters applied to event columns + */ +export function resolveFindEvents( + { + where, + ...args + }: { + where?: EventsWhere | null; + + before?: string | null; + after?: string | null; + first?: number | null; + last?: number | null; + }, + options?: { + through?: { table: EventJoinTable; scope: SQL }; + }, +) { + const through = options?.through; + const whereConditions = eventsWhereConditions(where); + + // combine join scope (if specified) + event table conditions + const conditions = and(through?.scope, whereConditions); + + return lazyConnection({ + totalCount: () => { + // note: not possible to dynamically change the .select() columns so we make a new query + let query = db.select({ count: count() }).from(schema.event).$dynamic(); + if (through) { + query = query.innerJoin(through.table, eq(through.table.eventId, schema.event.id)); + } + + return query.where(conditions).then((rows) => rows[0].count); + }, + connection: () => + resolveCursorConnection( + { + ...ID_PAGINATED_CONNECTION_ARGS, + args, + }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + // note: not possible to dynamically change the .select() columns so we make a new query + let query = db.select(getTableColumns(schema.event)).from(schema.event).$dynamic(); + if (through) { + query = query.innerJoin(through.table, eq(through.table.eventId, schema.event.id)); + } + + return query + .where(and(conditions, paginateBy(schema.event.id, before, after))) + .orderBy(orderPaginationBy(schema.event.id, inverted)) + .limit(limit); + }, + ), + }); +} diff --git a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts index b14e819f4..3f2302c55 100644 --- a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts @@ -8,7 +8,7 @@ import { type DomainId, type ENSv1DomainId, type ENSv2DomainId, - getENSv2RootRegistryId, + maybeGetENSv2RootRegistryId, type RegistryId, ROOT_NODE, } from "@ensnode/ensnode-sdk"; @@ -16,7 +16,7 @@ import { import { db } from "@/lib/db"; const MAX_DEPTH = 16; -const ENSv2_ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace); +const ENSv2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace); /** * Provide the canonical parents for an ENSv1 Domain. @@ -74,6 +74,9 @@ export async function getV1CanonicalPath(domainId: ENSv1DomainId): Promise { + // if the ENSv2 Root Registry is not defined, null + if (!ENSv2_ROOT_REGISTRY_ID) return null; + const result = await db.execute(sql` WITH RECURSIVE upward AS ( -- Base case: start from the target domain diff --git a/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts index 4131791b7..8501bc43e 100644 --- a/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts @@ -7,19 +7,19 @@ import * as schema from "@ensnode/ensnode-schema"; import { type DomainId, type ENSv2DomainId, - getENSv2RootRegistryId, type InterpretedName, interpretedLabelsToLabelHashPath, interpretedNameToInterpretedLabels, type LabelHash, makeENSv1DomainId, + maybeGetENSv2RootRegistryId, type RegistryId, } from "@ensnode/ensnode-sdk"; import { db } from "@/lib/db"; import { makeLogger } from "@/lib/logger"; -const ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace); +const ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace); const logger = makeLogger("get-domain-by-interpreted-name"); const v1Logger = makeLogger("get-domain-by-interpreted-name:v1"); @@ -64,7 +64,8 @@ export async function getDomainIdByInterpretedName( // Domains addressable in v2 are preferred, but v1 lookups are cheap, so just do them both ahead of time const [v1DomainId, v2DomainId] = await Promise.all([ v1_getDomainIdByInterpretedName(name), - v2_getDomainIdByInterpretedName(ROOT_REGISTRY_ID, name), + // only resolve v2Domain if ENSv2 Root Registry is defined + ROOT_REGISTRY_ID ? v2_getDomainIdByInterpretedName(ROOT_REGISTRY_ID, name) : null, ]); logger.debug({ v1DomainId, v2DomainId }); diff --git a/apps/ensapi/src/graphql-api/lib/lazy-connection.ts b/apps/ensapi/src/graphql-api/lib/lazy-connection.ts index addb58af1..840d2da65 100644 --- a/apps/ensapi/src/graphql-api/lib/lazy-connection.ts +++ b/apps/ensapi/src/graphql-api/lib/lazy-connection.ts @@ -12,8 +12,7 @@ export const lazyConnection = ({ }) => { let _conn: ReturnType | null = null; const memoizedConnection = () => { - if (_conn !== null) return _conn; - _conn = connection(); + if (_conn === null) _conn = connection(); return _conn; }; diff --git a/apps/ensapi/src/graphql-api/schema.ts b/apps/ensapi/src/graphql-api/schema.ts index 326d8b5f2..cca3d1644 100644 --- a/apps/ensapi/src/graphql-api/schema.ts +++ b/apps/ensapi/src/graphql-api/schema.ts @@ -1,8 +1,6 @@ import { builder } from "@/graphql-api/builder"; import "./schema/account-id"; -import "./schema/account-registries-permissions"; -import "./schema/account-resolver-permissions"; import "./schema/connection"; import "./schema/domain"; import "./schema/event"; diff --git a/apps/ensapi/src/graphql-api/schema/account-registries-permissions.ts b/apps/ensapi/src/graphql-api/schema/account-registries-permissions.ts deleted file mode 100644 index 7143b1419..000000000 --- a/apps/ensapi/src/graphql-api/schema/account-registries-permissions.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type * as schema from "@ensnode/ensnode-schema"; - -import { builder } from "@/graphql-api/builder"; -import { RegistryRef } from "@/graphql-api/schema/registry"; - -/** - * Represents an account-specific reference to a `registry` and the account's PermissionsUser for - * that registry. - */ -export interface AccountRegistryPermissionsRef { - permissionsUser: typeof schema.permissionsUser.$inferSelect; - registry: typeof schema.registry.$inferSelect; -} - -export const AccountRegistryPermissionsRef = builder.objectRef( - "AccountRegistryPermissions", -); - -AccountRegistryPermissionsRef.implement({ - fields: (t) => ({ - /////////////////////////////////////// - // AccountRegistryPermissions.registry - /////////////////////////////////////// - registry: t.field({ - description: "The Registry in which this Permission is granted.", - type: RegistryRef, - nullable: false, - resolve: (parent) => parent.registry, - }), - - /////////////////////////////////////// - // AccountRegistryPermissions.resource - /////////////////////////////////////// - resource: t.field({ - description: "The Resource for which this Permission is granted.", - type: "BigInt", - nullable: false, - resolve: (parent) => parent.permissionsUser.resource, - }), - - //////////////////////////////////// - // AccountRegistryPermissions.roles - //////////////////////////////////// - roles: t.field({ - description: "The Roles that this Permission grants.", - type: "BigInt", - nullable: false, - resolve: (parent) => parent.permissionsUser.roles, - }), - }), -}); diff --git a/apps/ensapi/src/graphql-api/schema/account-resolver-permissions.ts b/apps/ensapi/src/graphql-api/schema/account-resolver-permissions.ts deleted file mode 100644 index a3fb7664e..000000000 --- a/apps/ensapi/src/graphql-api/schema/account-resolver-permissions.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type * as schema from "@ensnode/ensnode-schema"; - -import { builder } from "@/graphql-api/builder"; -import { ResolverRef } from "@/graphql-api/schema/resolver"; - -/** - * Represents an account-specific reference to a `resolver` and the account's PermissionsUser for - * that resolver. - */ -export interface AccountResolverPermissions { - permissionsUser: typeof schema.permissionsUser.$inferSelect; - resolver: typeof schema.resolver.$inferSelect; -} - -export const AccountResolverPermissionsRef = builder.objectRef( - "AccountResolverPermissions", -); - -AccountResolverPermissionsRef.implement({ - fields: (t) => ({ - /////////////////////////////////////// - // AccountResolverPermissions.resolver - /////////////////////////////////////// - resolver: t.field({ - description: "The Resolver in which this Permission is granted.", - type: ResolverRef, - nullable: false, - resolve: (parent) => parent.resolver, - }), - - /////////////////////////////////////// - // AccountResolverPermissions.resource - /////////////////////////////////////// - resource: t.field({ - description: "The Resource for which this Permission is granted.", - type: "BigInt", - nullable: false, - resolve: (parent) => parent.permissionsUser.resource, - }), - - //////////////////////////////////// - // AccountResolverPermissions.roles - //////////////////////////////////// - roles: t.field({ - description: "The Roles that this Permission grants.", - type: "BigInt", - nullable: false, - resolve: (parent) => parent.permissionsUser.roles, - }), - }), -}); diff --git a/apps/ensapi/src/graphql-api/schema/account.integration.test.ts b/apps/ensapi/src/graphql-api/schema/account.integration.test.ts index c934a111b..7dd780369 100644 --- a/apps/ensapi/src/graphql-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/graphql-api/schema/account.integration.test.ts @@ -1,44 +1,41 @@ import type { Address } from "viem"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { Name } from "@ensnode/ensnode-sdk"; +import { gql } from "@/test/integration/ensnode-graphql-api-client"; import { AccountDomainsPaginated, type PaginatedDomainResult, -} from "@/test/integration/domain-pagination-queries"; -import { gql } from "@/test/integration/ensnode-graphql-api-client"; +} from "@/test/integration/find-domains/domain-pagination-queries"; +import { testDomainPagination } from "@/test/integration/find-domains/test-domain-pagination"; +import { + AccountEventsPaginated, + EventFragment, + type EventResult, +} from "@/test/integration/find-events/event-pagination-queries"; +import { testEventPagination } from "@/test/integration/find-events/test-event-pagination"; import { flattenConnection, type GraphQLConnection, type PaginatedGraphQLConnection, request, } from "@/test/integration/graphql-utils"; -import { testDomainPagination } from "@/test/integration/test-domain-pagination"; // via devnet +const DEVNET_DEPLOYER: Address = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; const DEFAULT_OWNER: Address = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"; const NEW_OWNER: Address = "0x90f79bf6eb2c4f870365e785982e1f101e93b906"; describe("Account.domains", () => { type AccountDomainsResult = { - account: { - domains: GraphQLConnection<{ - name: Name | null; - }>; - }; + account: { domains: GraphQLConnection<{ name: Name | null }> }; }; const AccountDomains = gql` query AccountDomains($address: Address!) { account(address: $address) { - domains(order: { by: NAME, dir: ASC }) { - edges { - node { - name - } - } - } + domains(order: { by: NAME, dir: ASC }) { edges { node { name } } } } } `; @@ -85,3 +82,173 @@ describe("Account.domains pagination", () => { return result.account.domains; }); }); + +describe("Account.events", () => { + type AccountEventsResult = { account: { events: GraphQLConnection } }; + + const AccountEvents = gql` + query AccountEvents($address: Address!) { + account(address: $address) { events { edges { node { ...EventFragment } } } } + } + ${EventFragment} + `; + + it("returns events for the devnet deployer", async () => { + const result = await request(AccountEvents, { + address: DEVNET_DEPLOYER, + }); + const events = flattenConnection(result.account.events); + + expect(events.length).toBeGreaterThan(0); + + // all events should have from === deployer address (case-insensitive) + for (const event of events) { + expect(event.from.toLowerCase()).toBe(DEVNET_DEPLOYER.toLowerCase()); + } + }); +}); + +describe("Account.events pagination", () => { + testEventPagination(async (variables) => { + const result = await request<{ + account: { events: PaginatedGraphQLConnection }; + }>(AccountEventsPaginated, { address: DEVNET_DEPLOYER, ...variables }); + return result.account.events; + }); +}); + +describe("Account.events filtering (AccountEventsWhereInput)", () => { + type AccountEventsResult = { account: { events: GraphQLConnection } }; + + const AccountEventsFiltered = gql` + query AccountEventsFiltered($address: Address!, $where: AccountEventsWhereInput, $first: Int) { + account(address: $address) { events(where: $where, first: $first) { edges { node { ...EventFragment } } } } + } + ${EventFragment} + `; + + let allEvents: EventResult[]; + + beforeAll(async () => { + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + first: 1000, + }); + // events are returned in ascending order, so first/last access yields min/max values + allEvents = flattenConnection(result.account.events); + expect(allEvents.length).toBeGreaterThan(0); + }); + + it("filters by selector_in", async () => { + const targetSelector = allEvents[0].topics[0]; + + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { selector_in: [targetSelector] }, + }); + const events = flattenConnection(result.account.events); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.topics[0]?.toLowerCase()).toBe(targetSelector.toLowerCase()); + } + }); + + it("filters by selector_in with unknown topic returns no results", async () => { + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { + selector_in: ["0x0000000000000000000000000000000000000000000000000000000000000001"], + }, + }); + const events = flattenConnection(result.account.events); + expect(events.length).toBe(0); + }); + + it("filters by empty selector_in returns no results", async () => { + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { selector_in: [] }, + }); + const events = flattenConnection(result.account.events); + expect(events.length).toBe(0); + }); + + it("filters by timestamp_gte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { timestamp_gte: midTimestamp }, + }); + const events = flattenConnection(result.account.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(BigInt(event.timestamp)).toBeGreaterThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("filters by timestamp_lte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { timestamp_lte: midTimestamp }, + }); + const events = flattenConnection(result.account.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(BigInt(event.timestamp)).toBeLessThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("filters by timestamp range", async () => { + const minTs = allEvents[0].timestamp; + const maxTs = allEvents[allEvents.length - 1].timestamp; + + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { timestamp_gte: minTs, timestamp_lte: maxTs }, + first: 1000, + }); + const events = flattenConnection(result.account.events); + expect(events.length).toBe(allEvents.length); + }); + + it("combines selector_in and timestamp_gte", async () => { + // pick a seed event from the second half so its selector is guaranteed to + // appear at or after midTimestamp, avoiding flaky empty-result failures + const midIndex = Math.floor(allEvents.length / 2); + const seedEvent = allEvents[midIndex]; + const targetSelector = seedEvent.topics[0]; + const midTimestamp = seedEvent.timestamp; + + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { selector_in: [targetSelector], timestamp_gte: midTimestamp }, + }); + const events = flattenConnection(result.account.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(event.topics[0]?.toLowerCase()).toBe(targetSelector.toLowerCase()); + expect(BigInt(event.timestamp)).toBeGreaterThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("excludes all events with a future timestamp", async () => { + const maxTimestamp = BigInt(allEvents[allEvents.length - 1].timestamp); + + const result = await request(AccountEventsFiltered, { + address: DEVNET_DEPLOYER, + where: { timestamp_gte: (maxTimestamp + 1n).toString() }, + }); + const events = flattenConnection(result.account.events); + expect(events.length).toBe(0); + }); +}); diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index bd19aa2d8..b17e80ba7 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -1,5 +1,5 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, getTableColumns } from "drizzle-orm"; import type { Address } from "viem"; import * as schema from "@ensnode/ensnode-schema"; @@ -14,18 +14,20 @@ import { filterByOwner, withOrderingMetadata, } from "@/graphql-api/lib/find-domains/layers"; +import { resolveFindEvents } from "@/graphql-api/lib/find-events/find-events-resolver"; import { getModelId } from "@/graphql-api/lib/get-model-id"; import { lazyConnection } from "@/graphql-api/lib/lazy-connection"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; -import { AccountRegistryPermissionsRef } from "@/graphql-api/schema/account-registries-permissions"; -import { AccountResolverPermissionsRef } from "@/graphql-api/schema/account-resolver-permissions"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { AccountDomainsWhereInput, DomainInterfaceRef, DomainsOrderInput, } from "@/graphql-api/schema/domain"; +import { AccountEventsWhereInput, EventRef } from "@/graphql-api/schema/event"; import { PermissionsUserRef } from "@/graphql-api/schema/permissions"; +import { RegistryPermissionsUserRef } from "@/graphql-api/schema/registry-permissions-user"; +import { ResolverPermissionsUserRef } from "@/graphql-api/schema/resolver-permissions-user"; import { db } from "@/lib/db"; export const AccountRef = builder.loadableObjectRef("Account", { @@ -86,11 +88,25 @@ AccountRef.implement({ }, }), + ////////////////// + // Account.events + ////////////////// + events: t.connection({ + description: "All Events for which this Account is the sender (i.e. `Transaction.from`).", + type: EventRef, + args: { + where: t.arg({ type: AccountEventsWhereInput }), + }, + resolve: (parent, args) => + resolveFindEvents({ ...args, where: { ...args.where, from: parent.id } }), + }), + /////////////////////// // Account.permissions /////////////////////// permissions: t.connection({ - description: "The Permissions granted to this Account.", + description: + "The Permissions granted to this Account, optionally filtered to Permissions in a specific contract.", type: PermissionsUserRef, args: { in: t.arg({ type: AccountIdInput }), @@ -128,10 +144,9 @@ AccountRef.implement({ /////////////////////////////// // Account.registryPermissions /////////////////////////////// - // TODO: this returns all permissions in a registry, perhaps can provide api for non-token resources... registryPermissions: t.connection({ description: "The Permissions on Registries granted to this Account.", - type: AccountRegistryPermissionsRef, + type: RegistryPermissionsUserRef, resolve: (parent, args) => { const scope = eq(schema.permissionsUser.user, parent.id); const join = and( @@ -152,16 +167,12 @@ AccountRef.implement({ { ...ID_PAGINATED_CONNECTION_ARGS, args }, ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => db - .select({ - permissionsUser: schema.permissionsUser, - registry: schema.registry, - }) + .select(getTableColumns(schema.permissionsUser)) .from(schema.permissionsUser) .innerJoin(schema.registry, join) .where(and(scope, paginateBy(schema.permissionsUser.id, before, after))) .orderBy(orderPaginationBy(schema.permissionsUser.id, inverted)) - .limit(limit) - .then((rows) => rows.map((r) => ({ id: r.permissionsUser.id, ...r }))), + .limit(limit), ), }); }, @@ -172,7 +183,7 @@ AccountRef.implement({ /////////////////////////////// resolverPermissions: t.connection({ description: "The Permissions on Resolvers granted to this Account.", - type: AccountResolverPermissionsRef, + type: ResolverPermissionsUserRef, resolve: (parent, args) => { const scope = eq(schema.permissionsUser.user, parent.id); const join = and( @@ -193,16 +204,12 @@ AccountRef.implement({ { ...ID_PAGINATED_CONNECTION_ARGS, args }, ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => db - .select({ - permissionsUser: schema.permissionsUser, - resolver: schema.resolver, - }) + .select(getTableColumns(schema.permissionsUser)) .from(schema.permissionsUser) .innerJoin(schema.resolver, join) .where(and(scope, paginateBy(schema.permissionsUser.id, before, after))) .orderBy(orderPaginationBy(schema.permissionsUser.id, inverted)) - .limit(limit) - .then((rows) => rows.map((r) => ({ id: r.permissionsUser.id, ...r }))), + .limit(limit), ), }); }, diff --git a/apps/ensapi/src/graphql-api/schema/constants.ts b/apps/ensapi/src/graphql-api/schema/constants.ts index 39cbc9847..6e3d5b090 100644 --- a/apps/ensapi/src/graphql-api/schema/constants.ts +++ b/apps/ensapi/src/graphql-api/schema/constants.ts @@ -1,13 +1,16 @@ +import { cursors } from "@/graphql-api/lib/cursors"; import { getModelId } from "@/graphql-api/lib/get-model-id"; -import { cursors } from "@/graphql-api/schema/cursors"; + +export const PAGINATION_DEFAULT_PAGE_SIZE = 100; +export const PAGINATION_DEFAULT_MAX_SIZE = 1000; /** * Default Connection field arguments for use with the Relay plugin. */ export const ID_PAGINATED_CONNECTION_ARGS = { toCursor: (model: T) => cursors.encode(getModelId(model)), - defaultSize: 100, - maxSize: 1000, + defaultSize: PAGINATION_DEFAULT_PAGE_SIZE, + maxSize: PAGINATION_DEFAULT_MAX_SIZE, } as const; /** @@ -18,6 +21,6 @@ export const ID_PAGINATED_CONNECTION_ARGS = { */ export const INDEX_PAGINATED_CONNECTION_ARGS = { toCursor: (model: T) => cursors.encode(String(model.index)), - defaultSize: 100, - maxSize: 1000, + defaultSize: PAGINATION_DEFAULT_PAGE_SIZE, + maxSize: PAGINATION_DEFAULT_MAX_SIZE, } as const; diff --git a/apps/ensapi/src/graphql-api/schema/cursors.ts b/apps/ensapi/src/graphql-api/schema/cursors.ts deleted file mode 100644 index 6545bf53c..000000000 --- a/apps/ensapi/src/graphql-api/schema/cursors.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * It's considered good practice to provide cursors as opaque strings exclusively useful for - * paginating sets, so we encode/decode entity ids using base64. - */ -export const cursors = { - encode: (id: string) => Buffer.from(id, "utf8").toString("base64"), - decode: (cursor: string) => Buffer.from(cursor, "base64").toString("utf8") as T, -}; diff --git a/apps/ensapi/src/graphql-api/schema/domain.integration.test.ts b/apps/ensapi/src/graphql-api/schema/domain.integration.test.ts index 7006e0de3..bb3848bfb 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.integration.test.ts @@ -1,20 +1,28 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { InterpretedLabel, Name } from "@ensnode/ensnode-sdk"; import { DEVNET_ETH_LABELS } from "@/test/integration/devnet-names"; +import { gql } from "@/test/integration/ensnode-graphql-api-client"; import { DomainSubdomainsPaginated, type PaginatedDomainResult, -} from "@/test/integration/domain-pagination-queries"; -import { gql } from "@/test/integration/ensnode-graphql-api-client"; +} from "@/test/integration/find-domains/domain-pagination-queries"; +import { testDomainPagination } from "@/test/integration/find-domains/test-domain-pagination"; +import { + DomainEventsPaginated, + EventFragment, + type EventResult, +} from "@/test/integration/find-events/event-pagination-queries"; +import { testEventPagination } from "@/test/integration/find-events/test-event-pagination"; import { flattenConnection, type GraphQLConnection, type PaginatedGraphQLConnection, request, } from "@/test/integration/graphql-utils"; -import { testDomainPagination } from "@/test/integration/test-domain-pagination"; + +const NAME_WITH_EVENTS = "newowner.eth"; describe("Domain.subdomains", () => { type SubdomainsResult = { @@ -29,16 +37,7 @@ describe("Domain.subdomains", () => { const DomainSubdomains = gql` query DomainSubdomains($name: Name!) { domain(by: { name: $name }) { - subdomains { - edges { - node { - name - label { - interpreted - } - } - } - } + subdomains { edges { node { name label { interpreted } } } } } } `; @@ -62,3 +61,195 @@ describe("Domain.subdomains pagination", () => { return result.domain.subdomains; }); }); + +describe("Domain.events", () => { + type DomainEventsResult = { + domain: { events: GraphQLConnection }; + }; + + const DomainEvents = gql` + query DomainEvents($name: Name!) { + domain(by: { name: $name }) { events { edges { node { ...EventFragment } } } } + } + ${EventFragment} + `; + + it("returns events for a domain with known activity", async () => { + const result = await request(DomainEvents, { name: NAME_WITH_EVENTS }); + const events = flattenConnection(result.domain.events); + + expect(events.length).toBeGreaterThan(0); + }); + + it("returns events for multiple domains", async () => { + const names = [NAME_WITH_EVENTS, "example.eth", "demo.eth"]; + + for (const name of names) { + const result = await request(DomainEvents, { name }); + const events = flattenConnection(result.domain.events); + expect(events.length, `expected events for domain '${name}'`).toBeGreaterThan(0); + } + }); +}); + +describe("Domain.events pagination", () => { + testEventPagination(async (variables) => { + const result = await request<{ + domain: { events: PaginatedGraphQLConnection }; + }>(DomainEventsPaginated, { name: NAME_WITH_EVENTS, ...variables }); + return result.domain.events; + }); +}); + +describe("Domain.events filtering (EventsWhereInput)", () => { + type DomainEventsResult = { + domain: { events: GraphQLConnection }; + }; + + const DomainEventsFiltered = gql` + query DomainEventsFiltered($name: Name!, $where: EventsWhereInput, $first: Int) { + domain(by: { name: $name }) { events(where: $where, first: $first) { edges { node { ...EventFragment } } } } + } + ${EventFragment} + `; + + let allEvents: EventResult[]; + + beforeAll(async () => { + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + first: 1000, + }); + // events are returned in ascending order, so first/last access yields min/max values + allEvents = flattenConnection(result.domain.events); + expect(allEvents.length).toBeGreaterThan(0); + }); + + it("filters by selector_in", async () => { + const targetSelector = allEvents[0].topics[0]; + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { selector_in: [targetSelector] }, + }); + const events = flattenConnection(result.domain.events); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.topics[0]?.toLowerCase()).toBe(targetSelector.toLowerCase()); + } + }); + + it("filters by selector_in with unknown topic returns no results", async () => { + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { + selector_in: ["0x0000000000000000000000000000000000000000000000000000000000000001"], + }, + }); + const events = flattenConnection(result.domain.events); + expect(events.length).toBe(0); + }); + + it("filters by empty selector_in returns no results", async () => { + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { selector_in: [] }, + }); + const events = flattenConnection(result.domain.events); + expect(events.length).toBe(0); + }); + + it("filters by timestamp_gte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { timestamp_gte: midTimestamp }, + }); + const events = flattenConnection(result.domain.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(BigInt(event.timestamp)).toBeGreaterThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("filters by timestamp_lte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { timestamp_lte: midTimestamp }, + }); + const events = flattenConnection(result.domain.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(BigInt(event.timestamp)).toBeLessThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("filters by timestamp range", async () => { + const minTs = allEvents[0].timestamp; + const maxTs = allEvents[allEvents.length - 1].timestamp; + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { timestamp_gte: minTs, timestamp_lte: maxTs }, + first: 1000, + }); + const events = flattenConnection(result.domain.events); + expect(events.length).toBe(allEvents.length); + }); + + it("filters by from address", async () => { + const targetFrom = allEvents[0].from; + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { from: targetFrom }, + }); + const events = flattenConnection(result.domain.events); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.from.toLowerCase()).toBe(targetFrom.toLowerCase()); + } + }); + + it("combines selector_in and timestamp_gte", async () => { + // pick a seed event from the second half so its selector is guaranteed to + // appear at or after midTimestamp, avoiding flaky empty-result failures + const midIndex = Math.floor(allEvents.length / 2); + const seedEvent = allEvents[midIndex]; + const targetSelector = seedEvent.topics[0]; + const midTimestamp = seedEvent.timestamp; + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { selector_in: [targetSelector], timestamp_gte: midTimestamp }, + }); + const events = flattenConnection(result.domain.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(event.topics[0]?.toLowerCase()).toBe(targetSelector.toLowerCase()); + expect(BigInt(event.timestamp)).toBeGreaterThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("excludes all events with a future timestamp", async () => { + const maxTimestamp = BigInt(allEvents[allEvents.length - 1].timestamp); + + const result = await request(DomainEventsFiltered, { + name: NAME_WITH_EVENTS, + where: { timestamp_gte: (maxTimestamp + 1n).toString() }, + }); + const events = flattenConnection(result.domain.events); + expect(events.length).toBe(0); + }); +}); diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 272a5b7a6..e99cb76ad 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -1,5 +1,5 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and, eq } from "drizzle-orm"; +import { and, count, eq, getTableColumns } from "drizzle-orm"; import * as schema from "@ensnode/ensnode-schema"; import { @@ -11,7 +11,7 @@ import { } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; -import { orderPaginationBy, paginateByInt } from "@/graphql-api/lib/connection-helpers"; +import { orderPaginationBy, paginateBy, paginateByInt } from "@/graphql-api/lib/connection-helpers"; import { resolveFindDomains } from "@/graphql-api/lib/find-domains/find-domains-resolver"; import { domainsBase, @@ -19,15 +19,21 @@ import { filterByParent, withOrderingMetadata, } from "@/graphql-api/lib/find-domains/layers"; +import { resolveFindEvents } from "@/graphql-api/lib/find-events/find-events-resolver"; import { getDomainResolver } from "@/graphql-api/lib/get-domain-resolver"; import { getLatestRegistration } from "@/graphql-api/lib/get-latest-registration"; import { getModelId } from "@/graphql-api/lib/get-model-id"; import { lazyConnection } from "@/graphql-api/lib/lazy-connection"; import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; import { AccountRef } from "@/graphql-api/schema/account"; -import { INDEX_PAGINATED_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { + ID_PAGINATED_CONNECTION_ARGS, + INDEX_PAGINATED_CONNECTION_ARGS, +} from "@/graphql-api/schema/constants"; +import { EventRef, EventsWhereInput } from "@/graphql-api/schema/event"; import { LabelRef } from "@/graphql-api/schema/label"; import { OrderDirection } from "@/graphql-api/schema/order-direction"; +import { PermissionsUserRef } from "@/graphql-api/schema/permissions"; import { RegistrationInterfaceRef } from "@/graphql-api/schema/registration"; import { RegistryRef } from "@/graphql-api/schema/registry"; import { ResolverRef } from "@/graphql-api/schema/resolver"; @@ -242,6 +248,24 @@ DomainInterfaceRef.implement({ return resolveFindDomains(context, { domains, order, ...connectionArgs }); }, }), + + ////////////////// + // Domain.events + ////////////////// + events: t.connection({ + description: "All Events associated with this Domain.", + type: EventRef, + args: { + where: t.arg({ type: EventsWhereInput }), + }, + resolve: (parent, args) => + resolveFindEvents(args, { + through: { + table: schema.domainEvent, + scope: eq(schema.domainEvent.domainId, parent.id), + }, + }), + }), }), }); @@ -323,6 +347,55 @@ ENSv2DomainRef.implement({ nullable: true, resolve: (parent) => parent.subregistryId, }), + + /////////////////////////// + // ENSv2Domain.permissions + /////////////////////////// + permissions: t.connection({ + description: + "Permissions for this Domain within its Registry, representing the roles granted to users for this Domain's token.", + type: PermissionsUserRef, + args: { + where: t.arg({ type: DomainPermissionsWhereInput }), + }, + resolve: (parent, args) => { + const scope = and( + // filter by resource === tokenId + eq(schema.permissionsUser.resource, parent.tokenId), + // optionally filter by user + args.where?.user ? eq(schema.permissionsUser.user, args.where.user) : undefined, + ); + + // inner join against this Domain's registry to filter Permissions by those in said registry + const join = and( + eq(schema.permissionsUser.chainId, schema.registry.chainId), + eq(schema.permissionsUser.address, schema.registry.address), + eq(schema.registry.id, parent.registryId), + ); + + return lazyConnection({ + totalCount: () => + db + .select({ count: count() }) + .from(schema.permissionsUser) + .innerJoin(schema.registry, join) + .where(scope) + .then((r) => r[0].count), + connection: () => + resolveCursorConnection( + { ...ID_PAGINATED_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db + .select(getTableColumns(schema.permissionsUser)) + .from(schema.permissionsUser) + .innerJoin(schema.registry, join) + .where(and(scope, paginateBy(schema.permissionsUser.id, before, after))) + .orderBy(orderPaginationBy(schema.permissionsUser.id, inverted)) + .limit(limit), + ), + }); + }, + }), }), }); @@ -330,6 +403,13 @@ ENSv2DomainRef.implement({ // Inputs ////////////////////// +export const DomainPermissionsWhereInput = builder.inputType("DomainPermissionsWhereInput", { + description: "Filter Permissions over this Domain by a specific User address.", + fields: (t) => ({ + user: t.field({ type: "Address" }), + }), +}); + export const DomainIdInput = builder.inputType("DomainIdInput", { description: "Reference a specific Domain.", isOneOf: true, diff --git a/apps/ensapi/src/graphql-api/schema/event.ts b/apps/ensapi/src/graphql-api/schema/event.ts index f88cf6446..0fa83eff0 100644 --- a/apps/ensapi/src/graphql-api/schema/event.ts +++ b/apps/ensapi/src/graphql-api/schema/event.ts @@ -41,6 +41,16 @@ EventRef.implement({ resolve: (parent) => parent.chainId, }), + ///////////////////// + // Event.blockNumber + ///////////////////// + blockNumber: t.field({ + description: "The block number within which this Event was emitted.", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.blockNumber, + }), + /////////////////// // Event.blockHash /////////////////// @@ -71,6 +81,16 @@ EventRef.implement({ resolve: (parent) => parent.transactionHash, }), + //////////////////////////// + // Event.transactionIndex + //////////////////////////// + transactionIndex: t.field({ + description: "The index of the Transaction within the Block.", + type: "Int", + nullable: false, + resolve: (parent) => parent.transactionIndex, + }), + ////////////// // Event.from ////////////// @@ -81,6 +101,17 @@ EventRef.implement({ resolve: (parent) => parent.from, }), + //////////// + // Event.to + //////////// + to: t.field({ + description: + "Identifies the recipient of the Transaction within which this Event was emitted. Null if the transaction deployed a contract.", + type: "Address", + nullable: true, + resolve: (parent) => parent.to, + }), + /////////////////// // Event.address /////////////////// @@ -100,5 +131,78 @@ EventRef.implement({ nullable: false, resolve: (parent) => parent.logIndex, }), + + //////////////// + // Event.topics + //////////////// + topics: t.field({ + description: "The indexed topics of this Event's log.", + type: ["Hex"], + nullable: false, + resolve: (parent) => parent.topics, + }), + + ////////////// + // Event.data + ////////////// + data: t.field({ + description: "The non-indexed data of this Event's log.", + type: "Hex", + nullable: false, + resolve: (parent) => parent.data, + }), + }), +}); + +////////// +// Inputs +////////// + +/** + * Shared filter for events connections. Used by Domain.events, Resolver.events, Permissions.events, + * and Account.events (which excludes `from` since it's implied). + */ +export const EventsWhereInput = builder.inputType("EventsWhereInput", { + description: "Filter conditions for an events connection.", + fields: (t) => ({ + selector_in: t.field({ + type: ["Hex"], + description: + "Filter to events whose selector (event signature) is one of the provided values.", + }), + timestamp_gte: t.field({ + type: "BigInt", + description: "Filter to events at or after this UnixTimestamp.", + }), + timestamp_lte: t.field({ + type: "BigInt", + description: "Filter to events at or before this UnixTimestamp.", + }), + from: t.field({ + type: "Address", + description: "Filter to events sent by this address.", + }), + }), +}); + +/** + * Like EventsWhereInput but without `from` (used where `from` is implied, e.g. Account.events). + */ +export const AccountEventsWhereInput = builder.inputType("AccountEventsWhereInput", { + description: "Filter conditions for Account.events (where `from` is implied by the Account).", + fields: (t) => ({ + selector_in: t.field({ + type: ["Hex"], + description: + "Filter to events whose selector (event signature) is one of the provided values.", + }), + timestamp_gte: t.field({ + type: "BigInt", + description: "Filter to events at or after this UnixTimestamp.", + }), + timestamp_lte: t.field({ + type: "BigInt", + description: "Filter to events at or before this UnixTimestamp.", + }), }), }); diff --git a/apps/ensapi/src/graphql-api/schema/permissions.integration.test.ts b/apps/ensapi/src/graphql-api/schema/permissions.integration.test.ts new file mode 100644 index 000000000..c11de1ac0 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/permissions.integration.test.ts @@ -0,0 +1,510 @@ +import type { Address } from "viem"; +import { toEventSelector } from "viem"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { DatasourceNames, EnhancedAccessControlABI } from "@ensnode/datasources"; +import { getDatasourceContract } from "@ensnode/ensnode-sdk"; + +import { gql } from "@/test/integration/ensnode-graphql-api-client"; +import { + EventFragment, + type EventResult, + PermissionsEventsPaginated, +} from "@/test/integration/find-events/event-pagination-queries"; +import { testEventPagination } from "@/test/integration/find-events/test-event-pagination"; +import { + flattenConnection, + type GraphQLConnection, + type PaginatedGraphQLConnection, + request, +} from "@/test/integration/graphql-utils"; + +const namespace = "ens-test-env"; + +const V2_ETH_REGISTRY = getDatasourceContract(namespace, DatasourceNames.ENSv2Root, "ETHRegistry"); + +const NAME_WITH_RESOLVER = "example.eth"; + +const EAC_ROLES_CHANGED_SELECTOR = toEventSelector( + EnhancedAccessControlABI.find( + (item) => item.type === "event" && item.name === "EACRolesChanged", + )!, +); + +describe("Permissions", () => { + type PermissionsResult = { + permissions: { + id: string; + contract: { chainId: number; address: Address }; + root: { + id: string; + resource: string; + users: GraphQLConnection<{ + id: string; + resource: string; + user: { address: Address }; + roles: string; + }>; + }; + resources: GraphQLConnection<{ + id: string; + resource: string; + users: GraphQLConnection<{ + id: string; + resource: string; + user: { address: Address }; + roles: string; + }>; + }>; + }; + }; + + const PermissionsQuery = gql` + query Permissions($contract: AccountIdInput!) { + permissions(for: $contract) { + id + contract { chainId address } + root { + id resource + users { edges { node { id resource user { address } roles } } } + } + resources { edges { node { + id resource + users { edges { node { id resource user { address } roles } } } + } } } + } + } + `; + + it("resolves all Permissions fields for the ETHRegistry", async () => { + const result = await request(PermissionsQuery, { + contract: V2_ETH_REGISTRY, + }); + + const { permissions } = result; + + // contract field matches the queried contract + expect(permissions.contract.address).toBe(V2_ETH_REGISTRY.address); + expect(permissions.contract.chainId).toBe(V2_ETH_REGISTRY.chainId); + + // root is a PermissionsResource with resource === 0 (ROOT_RESOURCE) + expect(permissions.root.resource).toBe("0"); + + // root has at least one user + const rootUsers = flattenConnection(permissions.root.users); + expect(rootUsers.length).toBeGreaterThan(0); + for (const user of rootUsers) { + expect(user.resource).toBe("0"); + expect(user.user.address).toBeTruthy(); + expect(user.roles).toBeTruthy(); + } + + // resources includes at least the root resource + const resources = flattenConnection(permissions.resources); + expect(resources.length).toBeGreaterThan(0); + + // every resource has a valid resource id and its users match the resource + for (const resource of resources) { + expect(resource.id).toBeTruthy(); + const users = flattenConnection(resource.users); + for (const user of users) { + expect(user.resource).toBe(resource.resource); + } + } + }); +}); + +describe("Registry.permissions", () => { + const RegistryPermissions = gql` + query RegistryPermissions($contract: AccountIdInput!) { + registry(by: { contract: $contract }) { + permissions { id contract { chainId address } } + } + } + `; + + it("resolves permissions from a registry", async () => { + const result = await request<{ + registry: { + permissions: { id: string; contract: { chainId: number; address: Address } }; + }; + }>(RegistryPermissions, { contract: V2_ETH_REGISTRY }); + + expect(result.registry.permissions.contract.address).toBe(V2_ETH_REGISTRY.address); + expect(result.registry.permissions.contract.chainId).toBe(V2_ETH_REGISTRY.chainId); + }); +}); + +describe("Domain.permissions", () => { + type DomainPermissionsResult = { + domain: { + permissions: GraphQLConnection<{ + id: string; + resource: string; + user: { address: Address }; + roles: string; + }>; + }; + }; + + const DomainPermissions = gql` + query DomainPermissions($name: Name!) { + domain(by: { name: $name }) { + ... on ENSv2Domain { + permissions { edges { node { id resource user { address } roles } } } + } + } + } + `; + + let allUsers: { id: string; resource: string; user: { address: Address }; roles: string }[]; + + beforeAll(async () => { + const result = await request(DomainPermissions, { + name: "test.eth", + }); + allUsers = flattenConnection(result.domain.permissions); + expect(allUsers.length).toBeGreaterThan(0); + }); + + it("resolves permissions for test.eth", () => { + // all users should have the same resource (the domain's tokenId) + const resources = new Set(allUsers.map((u) => u.resource)); + expect(resources.size).toBe(1); + + for (const user of allUsers) { + expect(user.user.address).toBeTruthy(); + expect(user.roles).toBeTruthy(); + } + }); + + it("filters permissions by user address", async () => { + const DomainPermissionsFiltered = gql` + query DomainPermissionsFiltered($name: Name!, $user: Address!) { + domain(by: { name: $name }) { + ... on ENSv2Domain { + permissions(where: { user: $user }) { edges { node { id resource user { address } roles } } } + } + } + } + `; + + const targetUser = allUsers[0].user.address; + + const filtered = await request(DomainPermissionsFiltered, { + name: "test.eth", + user: targetUser, + }); + const filteredUsers = flattenConnection(filtered.domain.permissions); + + expect(filteredUsers.length).toBeGreaterThan(0); + for (const user of filteredUsers) { + expect(user.user.address.toLowerCase()).toBe(targetUser.toLowerCase()); + } + }); +}); + +describe("Account.permissions and Account.registryPermissions", () => { + const PermissionsRootUsers = gql` + query PermissionsRootUsers($contract: AccountIdInput!) { + permissions(for: $contract) { + root { users { edges { node { user { address } } } } } + } + } + `; + + const AccountPermissions = gql` + query AccountPermissions($address: Address!) { + account(address: $address) { + permissions { edges { node { id resource user { address } roles } } } + } + } + `; + + const AccountRegistryPermissions = gql` + query AccountRegistryPermissions($address: Address!) { + account(address: $address) { + registryPermissions { edges { node { id registry { id } resource user { address } roles } } } + } + } + `; + + let targetAddress: Address; + + beforeAll(async () => { + const rootResult = await request<{ + permissions: { + root: { users: GraphQLConnection<{ user: { address: Address } }> }; + }; + }>(PermissionsRootUsers, { contract: V2_ETH_REGISTRY }); + + const rootUsers = flattenConnection(rootResult.permissions.root.users); + expect(rootUsers.length).toBeGreaterThan(0); + targetAddress = rootUsers[0].user.address; + }); + + it("resolves permissions for an account with known roles", async () => { + const result = await request<{ + account: { + permissions: GraphQLConnection<{ + id: string; + resource: string; + user: { address: Address }; + roles: string; + }>; + }; + }>(AccountPermissions, { address: targetAddress }); + + const permissions = flattenConnection(result.account.permissions); + expect(permissions.length).toBeGreaterThan(0); + + for (const p of permissions) { + expect(p.user.address.toLowerCase()).toBe(targetAddress.toLowerCase()); + } + }); + + it("resolves registry-scoped permissions for an account", async () => { + const result = await request<{ + account: { + registryPermissions: GraphQLConnection<{ + id: string; + registry: { id: string }; + resource: string; + user: { address: Address }; + roles: string; + }>; + }; + }>(AccountRegistryPermissions, { address: targetAddress }); + + const permissions = flattenConnection(result.account.registryPermissions); + expect(permissions.length).toBeGreaterThan(0); + + for (const p of permissions) { + expect(p.registry.id).toBeTruthy(); + expect(p.user.address.toLowerCase()).toBe(targetAddress.toLowerCase()); + } + }); +}); + +describe("Resolver.permissions", () => { + const ResolverPermissions = gql` + query ResolverPermissions($name: Name!) { + domain(by: { name: $name }) { + resolver { permissions { id contract { chainId address } } } + } + } + `; + + it("resolves permissions from a resolver", async () => { + const result = await request<{ + domain: { + resolver: { + permissions: { id: string; contract: { chainId: number; address: Address } }; + }; + }; + }>(ResolverPermissions, { name: NAME_WITH_RESOLVER }); + + expect( + result.domain.resolver, + `expected ${NAME_WITH_RESOLVER} to have a resolver`, + ).toBeDefined(); + expect(result.domain.resolver.permissions.id).toBeTruthy(); + expect(result.domain.resolver.permissions.contract.address).toBeTruthy(); + expect(result.domain.resolver.permissions.contract.chainId).toBeTruthy(); + }); +}); + +describe("Permissions.events", () => { + type PermissionsEventsResult = { + permissions: { events: GraphQLConnection }; + }; + + const PermissionsEvents = gql` + query PermissionsEvents($contract: AccountIdInput!, $first: Int) { + permissions(for: $contract) { events(first: $first) { edges { node { ...EventFragment } } } } + } + ${EventFragment} + `; + + let allEvents: EventResult[]; + + beforeAll(async () => { + const result = await request(PermissionsEvents, { + contract: V2_ETH_REGISTRY, + first: 1000, + }); + // events are returned in ascending order, so first/last access yields min/max values + allEvents = flattenConnection(result.permissions.events); + expect(allEvents.length).toBeGreaterThan(0); + }); + + it("returns events scoped to the ETHRegistry contract", () => { + for (const event of allEvents) { + expect(event.address.toLowerCase()).toBe(V2_ETH_REGISTRY.address); + } + }); + + it("includes EACRolesChanged events", () => { + const rolesChangedEvents = allEvents.filter( + (e) => e.topics[0]?.toLowerCase() === EAC_ROLES_CHANGED_SELECTOR, + ); + expect(rolesChangedEvents.length).toBeGreaterThan(0); + }); +}); + +describe("Permissions.events pagination", () => { + testEventPagination(async (variables) => { + const result = await request<{ + permissions: { events: PaginatedGraphQLConnection }; + }>(PermissionsEventsPaginated, { contract: V2_ETH_REGISTRY, ...variables }); + return result.permissions.events; + }); +}); + +describe("Permissions.events filtering (EventsWhereInput)", () => { + type PermissionsEventsResult = { + permissions: { events: GraphQLConnection }; + }; + + const PermissionsEventsFiltered = gql` + query PermissionsEventsFiltered($contract: AccountIdInput!, $where: EventsWhereInput, $first: Int) { + permissions(for: $contract) { events(where: $where, first: $first) { edges { node { ...EventFragment } } } } + } + ${EventFragment} + `; + + let allEvents: EventResult[]; + + beforeAll(async () => { + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + first: 1000, + }); + // events are returned in ascending order, so first/last access yields min/max values + allEvents = flattenConnection(result.permissions.events); + expect(allEvents.length).toBeGreaterThan(0); + }); + + it("filters by selector_in", async () => { + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { selector_in: [EAC_ROLES_CHANGED_SELECTOR] }, + }); + const events = flattenConnection(result.permissions.events); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.topics[0]?.toLowerCase()).toBe(EAC_ROLES_CHANGED_SELECTOR); + } + }); + + it("filters by selector_in with unknown topic returns no results", async () => { + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { + selector_in: ["0x0000000000000000000000000000000000000000000000000000000000000001"], + }, + }); + const events = flattenConnection(result.permissions.events); + expect(events.length).toBe(0); + }); + + it("filters by empty selector_in returns no results", async () => { + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { selector_in: [] }, + }); + const events = flattenConnection(result.permissions.events); + expect(events.length).toBe(0); + }); + + it("filters by timestamp_gte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { timestamp_gte: midTimestamp }, + }); + const events = flattenConnection(result.permissions.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(BigInt(event.timestamp)).toBeGreaterThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("filters by timestamp_lte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { timestamp_lte: midTimestamp }, + }); + const events = flattenConnection(result.permissions.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(BigInt(event.timestamp)).toBeLessThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("filters by timestamp range", async () => { + const minTs = allEvents[0].timestamp; + const maxTs = allEvents[allEvents.length - 1].timestamp; + + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { timestamp_gte: minTs, timestamp_lte: maxTs }, + first: 1000, + }); + const events = flattenConnection(result.permissions.events); + + // should return all events when range covers everything + expect(events.length).toBe(allEvents.length); + }); + + it("filters by from address", async () => { + const targetFrom = allEvents[0].from; + + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { from: targetFrom }, + }); + const events = flattenConnection(result.permissions.events); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.from.toLowerCase()).toBe(targetFrom.toLowerCase()); + } + }); + + it("combines selector_in and timestamp_gte", async () => { + const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; + + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { selector_in: [EAC_ROLES_CHANGED_SELECTOR], timestamp_gte: midTimestamp }, + }); + const events = flattenConnection(result.permissions.events); + + expect(events.length).toBeGreaterThan(0); + expect(events.length).toBeLessThanOrEqual(allEvents.length); + for (const event of events) { + expect(event.topics[0]?.toLowerCase()).toBe(EAC_ROLES_CHANGED_SELECTOR); + expect(BigInt(event.timestamp)).toBeGreaterThanOrEqual(BigInt(midTimestamp)); + } + }); + + it("excludes all events with a future timestamp", async () => { + const maxTimestamp = BigInt(allEvents[allEvents.length - 1].timestamp); + + const result = await request(PermissionsEventsFiltered, { + contract: V2_ETH_REGISTRY, + where: { timestamp_gte: (maxTimestamp + 1n).toString() }, + }); + const events = flattenConnection(result.permissions.events); + expect(events.length).toBe(0); + }); +}); diff --git a/apps/ensapi/src/graphql-api/schema/permissions.ts b/apps/ensapi/src/graphql-api/schema/permissions.ts index 07b4b4355..b8c7273e1 100644 --- a/apps/ensapi/src/graphql-api/schema/permissions.ts +++ b/apps/ensapi/src/graphql-api/schema/permissions.ts @@ -13,11 +13,13 @@ import { import { builder } from "@/graphql-api/builder"; import { orderPaginationBy, paginateBy } from "@/graphql-api/lib/connection-helpers"; +import { resolveFindEvents } from "@/graphql-api/lib/find-events/find-events-resolver"; import { getModelId } from "@/graphql-api/lib/get-model-id"; import { lazyConnection } from "@/graphql-api/lib/lazy-connection"; import { AccountRef } from "@/graphql-api/schema/account"; import { AccountIdRef } from "@/graphql-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { EventRef, EventsWhereInput } from "@/graphql-api/schema/event"; import { db } from "@/lib/db"; export const PermissionsRef = builder.loadableObjectRef("Permissions", { @@ -65,7 +67,7 @@ PermissionsRef.implement({ //////////////////////////// id: t.field({ description: "A unique reference to this Permission.", - type: "ID", + type: "PermissionsId", nullable: false, resolve: (parent) => parent.id, }), @@ -119,6 +121,24 @@ PermissionsRef.implement({ }); }, }), + + ////////////////////// + // Permissions.events + ////////////////////// + events: t.connection({ + description: "All Events associated with these Permissions.", + type: EventRef, + args: { + where: t.arg({ type: EventsWhereInput }), + }, + resolve: (parent, args) => + resolveFindEvents(args, { + through: { + table: schema.permissionsEvent, + scope: eq(schema.permissionsEvent.permissionsId, parent.id), + }, + }), + }), }), }); @@ -133,11 +153,21 @@ PermissionsResourceRef.implement({ //////////////////////////// id: t.field({ description: "A unique reference to this PermissionsResource.", - type: "ID", + type: "PermissionsResourceId", nullable: false, resolve: (parent) => parent.id, }), + //////////////////////// + // Permissions.contract + //////////////////////// + contract: t.field({ + description: "The contract within which these Permissions are granted.", + type: AccountIdRef, + nullable: false, + resolve: ({ chainId, address }) => ({ chainId, address }), + }), + /////////////////////////////////// // PermissionsResource.permissions /////////////////////////////////// @@ -201,11 +231,21 @@ PermissionsUserRef.implement({ //////////////////////////// id: t.field({ description: "A unique reference to this PermissionsUser.", - type: "ID", + type: "PermissionsUserId", nullable: false, resolve: (parent) => parent.id, }), + //////////////////////// + // Permissions.contract + //////////////////////// + contract: t.field({ + description: "The contract within which these Permissions are granted.", + type: AccountIdRef, + nullable: false, + resolve: ({ chainId, address }) => ({ chainId, address }), + }), + //////////////////////////// // PermissionsUser.resource //////////////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/query.integration.test.ts b/apps/ensapi/src/graphql-api/schema/query.integration.test.ts index f185d622b..11300b5cd 100644 --- a/apps/ensapi/src/graphql-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/graphql-api/schema/query.integration.test.ts @@ -14,18 +14,18 @@ import { } from "@ensnode/ensnode-sdk"; import { DEVNET_NAMES } from "@/test/integration/devnet-names"; +import { gql } from "@/test/integration/ensnode-graphql-api-client"; import { type PaginatedDomainResult, QueryDomainsPaginated, -} from "@/test/integration/domain-pagination-queries"; -import { gql } from "@/test/integration/ensnode-graphql-api-client"; +} from "@/test/integration/find-domains/domain-pagination-queries"; +import { testDomainPagination } from "@/test/integration/find-domains/test-domain-pagination"; import { flattenConnection, type GraphQLConnection, type PaginatedGraphQLConnection, request, } from "@/test/integration/graphql-utils"; -import { testDomainPagination } from "@/test/integration/test-domain-pagination"; const namespace = "ens-test-env"; diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index bdc0de27d..7c1082176 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -4,10 +4,10 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@poth import * as schema from "@ensnode/ensnode-schema"; import { - getENSv2RootRegistryId, makePermissionsId, makeRegistryId, makeResolverId, + maybeGetENSv2RootRegistryId, } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; @@ -221,10 +221,11 @@ builder.queryType({ // Get Root Registry ///////////////////// root: t.field({ - description: "The ENSv2 Root Registry", + description: "The ENSv2 Root Registry, if exists.", type: RegistryRef, - nullable: false, - resolve: () => getENSv2RootRegistryId(config.namespace), + // TODO: make this nullable: false after all namespaces define ENSv2Root + nullable: true, + resolve: () => maybeGetENSv2RootRegistryId(config.namespace), }), }), }); diff --git a/apps/ensapi/src/graphql-api/schema/registration.ts b/apps/ensapi/src/graphql-api/schema/registration.ts index 0e678aff1..3e6d8878b 100644 --- a/apps/ensapi/src/graphql-api/schema/registration.ts +++ b/apps/ensapi/src/graphql-api/schema/registration.ts @@ -65,7 +65,7 @@ RegistrationInterfaceRef.implement({ ////////////////////// id: t.field({ description: "A unique reference to this Registration.", - type: "ID", + type: "RegistrationId", nullable: false, resolve: (parent) => parent.id, }), @@ -90,6 +90,16 @@ RegistrationInterfaceRef.implement({ resolve: (parent) => ({ chainId: parent.registrarChainId, address: parent.registrarAddress }), }), + ////////////////////// + // Registration.start + ////////////////////// + start: t.field({ + description: "A UnixTimestamp indicating when this Registration was created.", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.start, + }), + /////////////////////////// // Registration.expiry /////////////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/registry-permissions-user.ts b/apps/ensapi/src/graphql-api/schema/registry-permissions-user.ts new file mode 100644 index 000000000..969b64b64 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/registry-permissions-user.ts @@ -0,0 +1,66 @@ +import type * as schema from "@ensnode/ensnode-schema"; +import { makeRegistryId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { AccountRef } from "@/graphql-api/schema/account"; +import { RegistryRef } from "@/graphql-api/schema/registry"; + +/** + * Represents a PermissionsUser whose contract is a Registry, providing a semantic `registry` field. + */ +export const RegistryPermissionsUserRef = + builder.objectRef("RegistryPermissionsUser"); + +RegistryPermissionsUserRef.implement({ + fields: (t) => ({ + ///////////////////////////////// + // RegistryPermissionsUser.id + ///////////////////////////////// + id: t.field({ + description: "A unique reference to this RegistryPermissionsUser.", + type: "PermissionsUserId", + nullable: false, + resolve: (parent) => parent.id, + }), + + ///////////////////////////////////// + // RegistryPermissionsUser.registry + ///////////////////////////////////// + registry: t.field({ + description: "The Registry in which this Permission is granted.", + type: RegistryRef, + nullable: false, + resolve: ({ chainId, address }) => makeRegistryId({ chainId, address }), + }), + + ///////////////////////////////////// + // RegistryPermissionsUser.resource + ///////////////////////////////////// + resource: t.field({ + description: "The Resource for which this Permission is granted.", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.resource, + }), + + ///////////////////////////////// + // RegistryPermissionsUser.user + ///////////////////////////////// + user: t.field({ + description: "The User for whom these Roles are granted.", + type: AccountRef, + nullable: false, + resolve: (parent) => parent.user, + }), + + ////////////////////////////////// + // RegistryPermissionsUser.roles + ////////////////////////////////// + roles: t.field({ + description: "The Roles that this Permission grants.", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.roles, + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/registry.integration.test.ts b/apps/ensapi/src/graphql-api/schema/registry.integration.test.ts index 5132b02bc..475ecf847 100644 --- a/apps/ensapi/src/graphql-api/schema/registry.integration.test.ts +++ b/apps/ensapi/src/graphql-api/schema/registry.integration.test.ts @@ -4,18 +4,18 @@ import { DatasourceNames } from "@ensnode/datasources"; import { getDatasourceContract, type InterpretedLabel } from "@ensnode/ensnode-sdk"; import { DEVNET_ETH_LABELS } from "@/test/integration/devnet-names"; +import { gql } from "@/test/integration/ensnode-graphql-api-client"; import { type PaginatedDomainResult, RegistryDomainsPaginated, -} from "@/test/integration/domain-pagination-queries"; -import { gql } from "@/test/integration/ensnode-graphql-api-client"; +} from "@/test/integration/find-domains/domain-pagination-queries"; +import { testDomainPagination } from "@/test/integration/find-domains/test-domain-pagination"; import { flattenConnection, type GraphQLConnection, type PaginatedGraphQLConnection, request, } from "@/test/integration/graphql-utils"; -import { testDomainPagination } from "@/test/integration/test-domain-pagination"; const namespace = "ens-test-env"; diff --git a/apps/ensapi/src/graphql-api/schema/renewal.ts b/apps/ensapi/src/graphql-api/schema/renewal.ts index 485f7f92f..77350b709 100644 --- a/apps/ensapi/src/graphql-api/schema/renewal.ts +++ b/apps/ensapi/src/graphql-api/schema/renewal.ts @@ -28,7 +28,7 @@ RenewalRef.implement({ ////////////// id: t.field({ description: "A unique reference to this Renewal.", - type: "ID", + type: "RenewalId", nullable: false, resolve: (parent) => parent.id, }), diff --git a/apps/ensapi/src/graphql-api/schema/resolver-permissions-user.ts b/apps/ensapi/src/graphql-api/schema/resolver-permissions-user.ts new file mode 100644 index 000000000..939ba2cb9 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/resolver-permissions-user.ts @@ -0,0 +1,66 @@ +import type * as schema from "@ensnode/ensnode-schema"; +import { makeResolverId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { AccountRef } from "@/graphql-api/schema/account"; +import { ResolverRef } from "@/graphql-api/schema/resolver"; + +/** + * Represents a PermissionsUser whose contract is a Resolver, providing a semantic `resolver` field. + */ +export const ResolverPermissionsUserRef = + builder.objectRef("ResolverPermissionsUser"); + +ResolverPermissionsUserRef.implement({ + fields: (t) => ({ + ////////////////////////////////// + // ResolverPermissionsUser.id + ////////////////////////////////// + id: t.field({ + description: "A unique reference to this ResolverPermissionsUser.", + type: "PermissionsUserId", + nullable: false, + resolve: (parent) => parent.id, + }), + + /////////////////////////////////////// + // ResolverPermissionsUser.resolver + /////////////////////////////////////// + resolver: t.field({ + description: "The Resolver in which this Permission is granted.", + type: ResolverRef, + nullable: false, + resolve: ({ chainId, address }) => makeResolverId({ chainId, address }), + }), + + /////////////////////////////////////// + // ResolverPermissionsUser.resource + /////////////////////////////////////// + resource: t.field({ + description: "The Resource for which this Permission is granted.", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.resource, + }), + + ////////////////////////////////// + // ResolverPermissionsUser.user + ////////////////////////////////// + user: t.field({ + description: "The User for whom these Roles are granted.", + type: AccountRef, + nullable: false, + resolve: (parent) => parent.user, + }), + + //////////////////////////////////// + // ResolverPermissionsUser.roles + //////////////////////////////////// + roles: t.field({ + description: "The Roles that this Permission grants.", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.roles, + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/resolver-records.ts b/apps/ensapi/src/graphql-api/schema/resolver-records.ts index 3bd001ac8..b6658ee8f 100644 --- a/apps/ensapi/src/graphql-api/schema/resolver-records.ts +++ b/apps/ensapi/src/graphql-api/schema/resolver-records.ts @@ -25,7 +25,7 @@ ResolverRecordsRef.implement({ ////////////////////// id: t.field({ description: "A unique reference to these ResolverRecords.", - type: "ID", + type: "ResolverRecordsId", nullable: false, resolve: (parent) => parent.id, }), diff --git a/apps/ensapi/src/graphql-api/schema/resolver.integration.test.ts b/apps/ensapi/src/graphql-api/schema/resolver.integration.test.ts new file mode 100644 index 000000000..04eaaa6b6 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/resolver.integration.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { gql } from "@/test/integration/ensnode-graphql-api-client"; +import { + EventFragment, + type EventResult, + ResolverEventsPaginated, +} from "@/test/integration/find-events/event-pagination-queries"; +import { testEventPagination } from "@/test/integration/find-events/test-event-pagination"; +import { + flattenConnection, + type GraphQLConnection, + type PaginatedGraphQLConnection, + request, +} from "@/test/integration/graphql-utils"; + +// TODO: once the devnet has deterministic resolver addresses, we can resolver(by: { contract }) +// but until then we'll access by a domain's assigned resolver +const DEVNET_NAME_WITH_OWNED_RESOLVER = "example.eth"; + +describe("Resolver.events", () => { + type ResolverEventsResult = { + domain: { + resolver: { + events: GraphQLConnection; + }; + }; + }; + + const ResolverEvents = gql` + query ResolverEvents($name: Name!) { + domain(by: { name: $name }) { + resolver { + events { + edges { + node { + ...EventFragment + } + } + } + } + } + } + + ${EventFragment} + `; + + it("returns events for a known resolver", async () => { + const result = await request(ResolverEvents, { + name: DEVNET_NAME_WITH_OWNED_RESOLVER, + }); + + const events = flattenConnection(result.domain.resolver.events); + + expect(events.length).toBeGreaterThan(0); + }); +}); + +describe("Resolver.events pagination", () => { + testEventPagination(async (variables) => { + const result = await request<{ + domain: { resolver: { events: PaginatedGraphQLConnection } }; + }>(ResolverEventsPaginated, { name: DEVNET_NAME_WITH_OWNED_RESOLVER, ...variables }); + return result.domain.resolver.events; + }); +}); diff --git a/apps/ensapi/src/graphql-api/schema/resolver.ts b/apps/ensapi/src/graphql-api/schema/resolver.ts index 1762b55a7..ee144a9eb 100644 --- a/apps/ensapi/src/graphql-api/schema/resolver.ts +++ b/apps/ensapi/src/graphql-api/schema/resolver.ts @@ -16,11 +16,13 @@ import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; import { builder } from "@/graphql-api/builder"; import { orderPaginationBy, paginateBy } from "@/graphql-api/lib/connection-helpers"; +import { resolveFindEvents } from "@/graphql-api/lib/find-events/find-events-resolver"; import { getModelId } from "@/graphql-api/lib/get-model-id"; import { lazyConnection } from "@/graphql-api/lib/lazy-connection"; import { AccountRef } from "@/graphql-api/schema/account"; import { AccountIdInput, AccountIdRef } from "@/graphql-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { EventRef, EventsWhereInput } from "@/graphql-api/schema/event"; import { NameOrNodeInput } from "@/graphql-api/schema/name-or-node"; import { PermissionsRef, type PermissionsUserResource } from "@/graphql-api/schema/permissions"; import { ResolverRecordsRef } from "@/graphql-api/schema/resolver-records"; @@ -156,6 +158,24 @@ ResolverRef.implement({ type: PermissionsRef, resolve: ({ chainId, address }) => makePermissionsId({ chainId, address }), }), + + //////////////////// + // Resolver.events + //////////////////// + events: t.connection({ + description: "All Events associated with this Resolver.", + type: EventRef, + args: { + where: t.arg({ type: EventsWhereInput }), + }, + resolve: (parent, args) => + resolveFindEvents(args, { + through: { + table: schema.resolverEvent, + scope: eq(schema.resolverEvent.resolverId, parent.id), + }, + }), + }), }), }); diff --git a/apps/ensapi/src/graphql-api/schema/scalars.ts b/apps/ensapi/src/graphql-api/schema/scalars.ts index c5091ef96..112d08a2b 100644 --- a/apps/ensapi/src/graphql-api/schema/scalars.ts +++ b/apps/ensapi/src/graphql-api/schema/scalars.ts @@ -9,8 +9,14 @@ import { isInterpretedName, type Name, type Node, + type PermissionsId, + type PermissionsResourceId, + type PermissionsUserId, + type RegistrationId, type RegistryId, + type RenewalId, type ResolverId, + type ResolverRecordsId, } from "@ensnode/ensnode-sdk"; import { makeChainIdSchema, @@ -130,3 +136,63 @@ builder.scalarType("ResolverId", { .transform((val) => val as ResolverId) .parse(value), }); + +builder.scalarType("PermissionsId", { + description: "PermissionsId represents a @ensnode/ensnode-sdk#PermissionsId.", + serialize: (value: PermissionsId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as PermissionsId) + .parse(value), +}); + +builder.scalarType("PermissionsResourceId", { + description: "PermissionsResourceId represents a @ensnode/ensnode-sdk#PermissionsResourceId.", + serialize: (value: PermissionsResourceId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as PermissionsResourceId) + .parse(value), +}); + +builder.scalarType("PermissionsUserId", { + description: "PermissionsUserId represents a @ensnode/ensnode-sdk#PermissionsUserId.", + serialize: (value: PermissionsUserId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as PermissionsUserId) + .parse(value), +}); + +builder.scalarType("RegistrationId", { + description: "RegistrationId represents a @ensnode/ensnode-sdk#RegistrationId.", + serialize: (value: RegistrationId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as RegistrationId) + .parse(value), +}); + +builder.scalarType("RenewalId", { + description: "RenewalId represents a @ensnode/ensnode-sdk#RenewalId.", + serialize: (value: RenewalId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as RenewalId) + .parse(value), +}); + +builder.scalarType("ResolverRecordsId", { + description: "ResolverRecordsId represents a @ensnode/ensnode-sdk#ResolverRecordsId.", + serialize: (value: ResolverRecordsId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as ResolverRecordsId) + .parse(value), +}); diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index db88e5e2c..7be431f8c 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -1,14 +1,22 @@ import config from "@/config"; -import * as ensIndexerSchema from "@ensnode/ensnode-schema/ensindexer"; +import { EnsIndexerDbReader } from "@ensnode/ensnode-schema"; -import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; +// TODO: pending rename `config.databaseUrl` to `config.ensDbUrl` +// Will be executed once https://github.com/namehash/ensnode/issues/1763 is resolved. +const ensDbUrl = config.databaseUrl; +// TODO: pending rename `config.databaseSchemaName` to `config.ensIndexerSchemaName` +// Will be executed once https://github.com/namehash/ensnode/issues/1762 is resolved. +const ensIndexerSchemaName = config.databaseSchemaName; + +const ensIndexerDbReader = new EnsIndexerDbReader(ensDbUrl, ensIndexerSchemaName); + +/** + * Read-only Drizzle instance for queries to ENSIndexer Schema in ENSDb. + */ +export const ensIndexerDbReadonly = ensIndexerDbReader.db; /** - * Read-only Drizzle instance for ENSDb queries to ENSIndexer Schema + * Read-only Drizzle instance for queries to ENSIndexer Schema in ENSDb. */ -export const db = makeReadOnlyDrizzle({ - databaseUrl: config.databaseUrl, - databaseSchema: config.databaseSchemaName, - schema: ensIndexerSchema, -}); +export const db = ensIndexerDbReadonly; diff --git a/apps/ensapi/src/lib/fetch-ensindexer-config.ts b/apps/ensapi/src/lib/fetch-ensindexer-config.ts new file mode 100644 index 000000000..0cd11beb8 --- /dev/null +++ b/apps/ensapi/src/lib/fetch-ensindexer-config.ts @@ -0,0 +1,17 @@ +import { + deserializeENSIndexerPublicConfig, + deserializeErrorResponse, + type SerializedENSIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +export async function fetchENSIndexerConfig(url: URL) { + const response = await fetch(new URL(`/api/config`, url)); + const responseData = await response.json(); + + if (!response.ok) { + const errorResponse = deserializeErrorResponse(responseData); + throw new Error(`Fetching ENSNode Config Failed: ${errorResponse.message}`); + } + + return deserializeENSIndexerPublicConfig(responseData as SerializedENSIndexerPublicConfig); +} diff --git a/apps/ensapi/src/test/integration/domain-pagination-queries.ts b/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts similarity index 97% rename from apps/ensapi/src/test/integration/domain-pagination-queries.ts rename to apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts index 22c3956cf..2f9325b8c 100644 --- a/apps/ensapi/src/test/integration/domain-pagination-queries.ts +++ b/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts @@ -13,21 +13,23 @@ const PageInfoFragment = gql` const PaginatedDomainFragment = gql` fragment PaginatedDomainFragment on Domain { + id name label { interpreted } registration { expiry - event { timestamp } + start } } `; export type PaginatedDomainResult = { + id: string; name: Name | null; label: { interpreted: InterpretedLabel }; registration: { expiry: string | null; - event: { timestamp: string }; + start: string; } | null; }; diff --git a/apps/ensapi/src/test/integration/test-domain-pagination.ts b/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts similarity index 65% rename from apps/ensapi/src/test/integration/test-domain-pagination.ts rename to apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts index baf378fb9..53d8b9279 100644 --- a/apps/ensapi/src/test/integration/test-domain-pagination.ts +++ b/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts @@ -2,12 +2,16 @@ import { beforeAll, describe, expect, it } from "vitest"; import type { DomainsOrderByValue, DomainsOrderInput } from "@/graphql-api/schema/domain"; import type { OrderDirectionValue } from "@/graphql-api/schema/order-direction"; -import type { PaginatedDomainResult } from "@/test/integration/domain-pagination-queries"; +import type { PaginatedDomainResult } from "@/test/integration/find-domains/domain-pagination-queries"; import { - flattenConnection, + collectBackward, + collectForward, type PaginatedGraphQLConnection, } from "@/test/integration/graphql-utils"; +// NOTE: using small page size to force multiple pages in devnet result set +const PAGE_SIZE = 2; + type FetchPageVariables = { order: typeof DomainsOrderInput.$inferInput; first?: number; @@ -34,7 +38,7 @@ function getSortValue(domain: PaginatedDomainResult, by: DomainsOrderByValue): s case "NAME": return domain.label.interpreted; case "REGISTRATION_TIMESTAMP": - return domain.registration?.event.timestamp ?? null; + return domain.registration?.start ?? null; case "REGISTRATION_EXPIRY": return domain.registration?.expiry ?? null; } @@ -86,55 +90,6 @@ function assertOrdering( } } -async function collectForward( - fetchPage: FetchPage, - order: typeof DomainsOrderInput.$inferInput, - pageSize: number, -): Promise { - const all: PaginatedDomainResult[] = []; - let after: string | undefined; - - while (true) { - const page = await fetchPage({ order, first: pageSize, after }); - all.push(...flattenConnection(page)); - - if (!page.pageInfo.hasNextPage) break; - - const nextCursor = page.pageInfo.endCursor ?? undefined; - expect(nextCursor, "endCursor must advance when hasNextPage is true").not.toBe(after); - after = nextCursor; - } - - return all; -} - -async function collectBackward( - fetchPage: FetchPage, - order: typeof DomainsOrderInput.$inferInput, - pageSize: number, -): Promise { - const all: PaginatedDomainResult[] = []; - let before: string | undefined; - - while (true) { - const page = await fetchPage({ order, last: pageSize, before }); - // prepend: last pages come in forward order within the page, - // but we're iterating from the end of the full list - all.unshift(...flattenConnection(page)); - - if (!page.pageInfo.hasPreviousPage) break; - - const nextCursor = page.pageInfo.startCursor ?? undefined; - expect(nextCursor, "startCursor must advance when hasPreviousPage is true").not.toBe(before); - before = nextCursor; - } - - return all; -} - -// NOTE: using small page size to force multiple pages in devnet result set -const PAGE_SIZE = 2; - /** * Generic pagination test suite for any find-domains connection field. * @@ -148,19 +103,27 @@ export function testDomainPagination(fetchPage: FetchPage) { let backwardNodes: PaginatedDomainResult[]; beforeAll(async () => { - forwardNodes = await collectForward(fetchPage, order, PAGE_SIZE); - backwardNodes = await collectBackward(fetchPage, order, PAGE_SIZE); + forwardNodes = await collectForward((vars) => fetchPage({ order, ...vars }), PAGE_SIZE); + backwardNodes = await collectBackward((vars) => fetchPage({ order, ...vars }), PAGE_SIZE); + }); + + it("forward pagination collects more nodes than page size", () => { + expect( + forwardNodes.length, + `expected more than ${PAGE_SIZE} nodes to prove pagination was exercised`, + ).toBeGreaterThan(PAGE_SIZE); }); - it("forward pagination collects all nodes", async () => { - expect(forwardNodes.length).toBeGreaterThan(0); + it("no duplicate ids across pages", () => { + const ids = forwardNodes.map((d) => d.id); + expect(ids.length).toBe(new Set(ids).size); }); it("nodes are correctly ordered", () => { assertOrdering(forwardNodes, order.by, order.dir); }); - it("backward pagination yields same nodes in same order", async () => { + it("backward pagination yields same nodes in same order", () => { expect(backwardNodes).toEqual(forwardNodes); }); }); diff --git a/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts b/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts new file mode 100644 index 000000000..b5d9b7e06 --- /dev/null +++ b/apps/ensapi/src/test/integration/find-events/event-pagination-queries.ts @@ -0,0 +1,126 @@ +import { gql } from "@/test/integration/ensnode-graphql-api-client"; + +const PageInfoFragment = gql` + fragment PageInfoFragment on PageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } +`; + +export const EventFragment = gql` + fragment EventFragment on Event { + id + chainId + blockNumber + blockHash + timestamp + transactionHash + transactionIndex + from + to + address + logIndex + topics + data + } +`; + +export type EventResult = { + id: string; + chainId: number; + blockNumber: string; + blockHash: string; + timestamp: string; + transactionHash: string; + transactionIndex: number; + from: string; + to: string | null; + address: string; + logIndex: number; + topics: string[]; + data: string; +}; + +export const DomainEventsPaginated = gql` + query DomainEventsPaginated( + $name: Name! + $first: Int + $after: String + $last: Int + $before: String + ) { + domain(by: { name: $name }) { + events(first: $first, after: $after, last: $last, before: $before) { + edges { cursor node { ...EventFragment } } + pageInfo { ...PageInfoFragment } + } + } + } + + ${PageInfoFragment} + ${EventFragment} +`; + +export const AccountEventsPaginated = gql` + query AccountEventsPaginated( + $address: Address! + $first: Int + $after: String + $last: Int + $before: String + ) { + account(address: $address) { + events(first: $first, after: $after, last: $last, before: $before) { + edges { cursor node { ...EventFragment } } + pageInfo { ...PageInfoFragment } + } + } + } + + ${PageInfoFragment} + ${EventFragment} +`; + +export const ResolverEventsPaginated = gql` + query ResolverEventsPaginated( + $name: Name! + $first: Int + $after: String + $last: Int + $before: String + ) { + domain(by: { name: $name }) { + resolver { + events(first: $first, after: $after, last: $last, before: $before) { + edges { cursor node { ...EventFragment } } + pageInfo { ...PageInfoFragment } + } + } + } + } + + ${PageInfoFragment} + ${EventFragment} +`; + +export const PermissionsEventsPaginated = gql` + query PermissionsEventsPaginated( + $contract: AccountIdInput! + $first: Int + $after: String + $last: Int + $before: String + ) { + permissions(for: $contract) { + events(first: $first, after: $after, last: $last, before: $before) { + edges { cursor node { ...EventFragment } } + pageInfo { ...PageInfoFragment } + } + } + } + + ${PageInfoFragment} + ${EventFragment} +`; diff --git a/apps/ensapi/src/test/integration/find-events/test-event-pagination.ts b/apps/ensapi/src/test/integration/find-events/test-event-pagination.ts new file mode 100644 index 000000000..bb46eef5c --- /dev/null +++ b/apps/ensapi/src/test/integration/find-events/test-event-pagination.ts @@ -0,0 +1,113 @@ +import { beforeAll, expect, it } from "vitest"; + +import type { EventResult } from "@/test/integration/find-events/event-pagination-queries"; +import { + collectBackward, + collectForward, + type PaginatedGraphQLConnection, +} from "@/test/integration/graphql-utils"; + +// NOTE: using small page size to force multiple pages in devnet result set +const PAGE_SIZE = 2; + +type FetchPage = (variables: { + first?: number; + after?: string; + last?: number; + before?: string; +}) => Promise>; + +/** + * Asserts that events are in ascending order by their actual metadata columns, + * matching the composite sort key: [timestamp, chainId, blockNumber, transactionIndex, logIndex]. + * + * This verifies that the lexicographic id sort matches the underlying data. + */ +function assertAscendingOrder(events: EventResult[]) { + for (let i = 0; i < events.length - 1; i++) { + const a = events[i]; + const b = events[i + 1]; + + const cmp = compareSortKey(a, b); + expect( + cmp <= 0, + `expected event at index ${i} to sort before event at index ${i + 1}:\n` + + ` a: timestamp=${a.timestamp} chainId=${a.chainId} block=${a.blockNumber} txIdx=${a.transactionIndex} logIdx=${a.logIndex}\n` + + ` b: timestamp=${b.timestamp} chainId=${b.chainId} block=${b.blockNumber} txIdx=${b.transactionIndex} logIdx=${b.logIndex}`, + ).toBe(true); + } +} + +/** + * Compare two events by their composite sort key columns. + * Returns negative if a < b, 0 if equal, positive if a > b. + */ +function compareSortKey(a: EventResult, b: EventResult): number { + // timestamp (bigint comparison) + const tsA = BigInt(a.timestamp); + const tsB = BigInt(b.timestamp); + if (tsA !== tsB) return tsA < tsB ? -1 : 1; + + // chainId + if (a.chainId !== b.chainId) return a.chainId - b.chainId; + + // blockNumber (bigint comparison) + const bnA = BigInt(a.blockNumber); + const bnB = BigInt(b.blockNumber); + if (bnA !== bnB) return bnA < bnB ? -1 : 1; + + // transactionIndex + if (a.transactionIndex !== b.transactionIndex) return a.transactionIndex - b.transactionIndex; + + // logIndex + if (a.logIndex !== b.logIndex) return a.logIndex - b.logIndex; + + // equal sort key (differentiated only by id) + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * Generic pagination test suite for any find-events connection field. + * + * Events are always sorted in ascending order by their composite sort key + * (timestamp, chainId, blockNumber, transactionIndex, logIndex), which is + * encoded into the event id for lexicographic sorting. + * + * Tests forward pagination, ordering correctness (verifying the lexicographic + * id sort matches the actual metadata), and backward pagination. + */ +export function testEventPagination(fetchPage: FetchPage) { + let forwardNodes: EventResult[]; + let backwardNodes: EventResult[]; + + beforeAll(async () => { + forwardNodes = await collectForward(fetchPage, PAGE_SIZE); + backwardNodes = await collectBackward(fetchPage, PAGE_SIZE); + }); + + it("forward pagination collects more nodes than page size", () => { + expect( + forwardNodes.length, + `expected more than ${PAGE_SIZE} nodes to prove pagination was exercised`, + ).toBeGreaterThan(PAGE_SIZE); + }); + + it("no duplicate ids across pages", () => { + const ids = forwardNodes.map((e) => e.id); + expect(ids.length).toBe(new Set(ids).size); + }); + + it("events are in ascending order by sort key columns", () => { + assertAscendingOrder(forwardNodes); + }); + + it("lexicographic id order matches metadata sort order", () => { + const ids = forwardNodes.map((e) => e.id); + const sorted = [...ids].sort(); + expect(ids).toEqual(sorted); + }); + + it("backward pagination yields same nodes in same order", () => { + expect(backwardNodes).toEqual(forwardNodes); + }); +} diff --git a/apps/ensapi/src/test/integration/graphql-utils.ts b/apps/ensapi/src/test/integration/graphql-utils.ts index 3741c07d0..a4467d81e 100644 --- a/apps/ensapi/src/test/integration/graphql-utils.ts +++ b/apps/ensapi/src/test/integration/graphql-utils.ts @@ -1,5 +1,6 @@ import { type DocumentNode, Kind, parse, print } from "graphql"; import type { RequestDocument, Variables } from "graphql-request"; +import { expect } from "vitest"; import { client } from "./ensnode-graphql-api-client"; import { highlightGraphQL, highlightJSON } from "./highlight"; @@ -24,6 +25,61 @@ export function flattenConnection( return (connection?.edges ?? []).map((edge) => edge.node); } +/** + * A function that fetches a page given cursor pagination variables. + * Extra variables (e.g. order, filters) should be closed over by the caller. + */ +type FetchPage = (variables: { + first?: number; + after?: string; + last?: number; + before?: string; +}) => Promise>; + +/** + * Collects all nodes by paginating forward through a connection. + */ +export async function collectForward(fetchPage: FetchPage, pageSize: number): Promise { + const all: T[] = []; + let after: string | undefined; + + while (true) { + const page = await fetchPage({ first: pageSize, after }); + all.push(...flattenConnection(page)); + + if (!page.pageInfo.hasNextPage) break; + + const nextCursor = page.pageInfo.endCursor ?? undefined; + expect(nextCursor, "endCursor must advance when hasNextPage is true").not.toBe(after); + after = nextCursor; + } + + return all; +} + +/** + * Collects all nodes by paginating backward through a connection. + */ +export async function collectBackward(fetchPage: FetchPage, pageSize: number): Promise { + const all: T[] = []; + let before: string | undefined; + + while (true) { + const page = await fetchPage({ last: pageSize, before }); + // prepend: last pages come in forward order within the page, + // but we're iterating from the end of the full list + all.unshift(...flattenConnection(page)); + + if (!page.pageInfo.hasPreviousPage) break; + + const nextCursor = page.pageInfo.startCursor ?? undefined; + expect(nextCursor, "startCursor must advance when hasPreviousPage is true").not.toBe(before); + before = nextCursor; + } + + return all; +} + function isDocumentNode(obj: any): obj is DocumentNode { return ( typeof obj === "object" && @@ -33,6 +89,9 @@ function isDocumentNode(obj: any): obj is DocumentNode { ); } +/** + * Wrapper over client.request with logging for test debugging. + */ export async function request( document: RequestDocument, variables?: Variables, diff --git a/apps/ensindexer/drizzle-kit/config.ts b/apps/ensindexer/drizzle-kit/config.ts new file mode 100644 index 000000000..12f271e0b --- /dev/null +++ b/apps/ensindexer/drizzle-kit/config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "drizzle-kit"; + +// Resolve the path to the database schema file for Drizzle migrations. +const dbSchemaPath = fileURLToPath(new URL("./schema.ts", import.meta.url)); + +export default defineConfig({ + casing: "snake_case", + dialect: "postgresql", + out: `drizzle-kit/migrations`, + schema: dbSchemaPath, +}); diff --git a/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql b/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql new file mode 100644 index 000000000..c7daf971b --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA IF NOT EXISTS ensnode; + +CREATE TABLE "ensnode"."ensnode_metadata" ( + "ens_indexer_schema_name" text NOT NULL, + "key" text NOT NULL, + "value" jsonb NOT NULL, + CONSTRAINT "ensnode_metadata_pkey" PRIMARY KEY("ens_indexer_schema_name","key") +); diff --git a/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..491d0e496 --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json @@ -0,0 +1,58 @@ +{ + "id": "033e8b27-4739-4da9-b9da-517b0c2700d7", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "ensnode.ensnode_metadata": { + "name": "ensnode_metadata", + "schema": "ensnode", + "columns": { + "ens_indexer_schema_name": { + "name": "ens_indexer_schema_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ensnode_metadata_pkey": { + "name": "ensnode_metadata_pkey", + "columns": [ + "ens_indexer_schema_name", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json b/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json new file mode 100644 index 000000000..2b6baba5c --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1773421837301, + "tag": "0000_certain_slyde", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/ensindexer/drizzle-kit/schema.ts b/apps/ensindexer/drizzle-kit/schema.ts new file mode 100644 index 000000000..94f4f63dd --- /dev/null +++ b/apps/ensindexer/drizzle-kit/schema.ts @@ -0,0 +1,2 @@ +// Re-export the ENSNode schema for Drizzle migrations. +export * from "@ensnode/ensnode-schema/ensnode"; diff --git a/apps/ensindexer/package.json b/apps/ensindexer/package.json index e559cecbe..ca7192c1b 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -20,7 +20,8 @@ "test": "vitest", "lint": "biome check --write .", "lint:ci": "biome ci", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "drizzle-gen": "drizzle-kit generate --config ./drizzle-kit/config.ts" }, "dependencies": { "@ensdomains/ensjs": "^4.0.2", @@ -47,6 +48,7 @@ "@types/dns-packet": "^5.6.5", "@types/node": "catalog:", "@types/pg": "8.16.0", + "drizzle-kit": "0.31.9", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/apps/ensindexer/ponder/ponder.schema.ts b/apps/ensindexer/ponder/ponder.schema.ts index 27bdf0a08..f329f417b 100644 --- a/apps/ensindexer/ponder/ponder.schema.ts +++ b/apps/ensindexer/ponder/ponder.schema.ts @@ -1,2 +1,2 @@ -// export the shared ponder schema -export * from "@ensnode/ensnode-schema"; +// export the ENSIndexer schema for Ponder to use when indexing data +export * from "@ensnode/ensnode-schema/ensindexer"; diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index 994b98704..33f022d85 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -10,13 +10,13 @@ import { serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { ensNodeDbWriter } from "@/lib/ensdb-client/singleton"; const app = new Hono(); // include ENSIndexer Public Config endpoint app.get("/config", async (c) => { - const publicConfig = await ensDbClient.getEnsIndexerPublicConfig(); + const publicConfig = await ensNodeDbWriter.getEnsIndexerPublicConfig(); // Invariant: the public config is guaranteed to be available in ENSDb after // application startup. @@ -30,7 +30,7 @@ app.get("/config", async (c) => { app.get("/indexing-status", async (c) => { try { - const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot(); + const crossChainSnapshot = await ensNodeDbWriter.getIndexingStatusSnapshot(); // Invariant: the Indexing Status Snapshot is expected to be available in // ENSDb shortly after application startup. There is a possibility that diff --git a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts b/apps/ensindexer/src/lib/ensdb-client/drizzle.ts deleted file mode 100644 index 079e18c58..000000000 --- a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file is based on `packages/ponder-subgraph/src/drizzle.ts` file. -// We currently duplicate the makeDrizzle function, as we don't have -// a shared package for backend code yet. When we do, we can move -// this function to the shared package and import it in both places. -import { drizzle } from "drizzle-orm/node-postgres"; - -type Schema = { [name: string]: unknown }; - -/** - * Makes a Drizzle DB object. - */ -export const makeDrizzle = ({ - schema, - databaseUrl, -}: { - schema: SCHEMA; - databaseUrl: string; -}) => { - return drizzle(databaseUrl, { schema, casing: "snake_case" }); -}; diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts deleted file mode 100644 index 91f7cf273..000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - type BlockRef, - ChainIndexingStatusIds, - CrossChainIndexingStrategyIds, - type EnsIndexerPublicConfig, - OmnichainIndexingStatusIds, - PluginName, - RangeTypeIds, - type SerializedCrossChainIndexingStatusSnapshot, -} from "@ensnode/ensnode-sdk"; - -export const earlierBlockRef = { - timestamp: 1672531199, - number: 1024, -} as const satisfies BlockRef; - -export const laterBlockRef = { - timestamp: 1672531200, - number: 1025, -} as const satisfies BlockRef; - -export const databaseUrl = "postgres://user:pass@localhost:5432/ensdb"; - -export const databaseSchemaName = "public"; - -// This is the same as the default value of config.databaseSchemaName, -// which is used as the ensIndexerRef for multi-tenancy in ENSDb. -export const ensIndexerRef = databaseSchemaName; - -export const publicConfig = { - databaseSchemaName, - ensRainbowPublicConfig: { - version: "0.32.0", - labelSet: { - labelSetId: "subgraph", - highestLabelSetVersion: 0, - }, - recordsCount: 100, - }, - labelSet: { - labelSetId: "subgraph", - labelSetVersion: 0, - }, - indexedChainIds: new Set([1]), - isSubgraphCompatible: true, - namespace: "mainnet", - plugins: [PluginName.Subgraph], - versionInfo: { - nodejs: "v22.10.12", - ponder: "0.11.25", - ensDb: "0.32.0", - ensIndexer: "0.32.0", - ensNormalize: "1.11.1", - }, -} satisfies EnsIndexerPublicConfig; - -export const serializedSnapshot = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: earlierBlockRef.timestamp, - snapshotTime: earlierBlockRef.timestamp + 20, - omnichainSnapshot: { - omnichainStatus: OmnichainIndexingStatusIds.Following, - chains: { - "1": { - chainStatus: ChainIndexingStatusIds.Following, - config: { - rangeType: RangeTypeIds.LeftBounded, - startBlock: earlierBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - latestKnownBlock: laterBlockRef, - }, - }, - omnichainIndexingCursor: earlierBlockRef.timestamp, - }, -} satisfies SerializedCrossChainIndexingStatusSnapshot; diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts deleted file mode 100644 index 480bc8a74..000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; -import { - deserializeCrossChainIndexingStatusSnapshot, - EnsNodeMetadataKeys, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; - -import { makeDrizzle } from "./drizzle"; -import { EnsDbClient } from "./ensdb-client"; -import * as ensDbClientMock from "./ensdb-client.mock"; - -// Mock the config module to prevent it from trying to load actual environment variables during tests -vi.mock("@/config", () => ({ default: {} })); - -// Mock the makeDrizzle function to return a mock database instance -vi.mock("./drizzle", () => ({ makeDrizzle: vi.fn() })); - -describe("EnsDbClient", () => { - // Mock database query results and methods - const selectResult = { current: [] as Array<{ value: unknown }> }; - const whereMock = vi.fn(async () => selectResult.current); - const fromMock = vi.fn(() => ({ where: whereMock })); - const selectMock = vi.fn(() => ({ from: fromMock })); - const onConflictDoUpdateMock = vi.fn(async () => undefined); - const valuesMock = vi.fn(() => ({ onConflictDoUpdate: onConflictDoUpdateMock })); - const insertMock = vi.fn(() => ({ values: valuesMock })); - const executeMock = vi.fn(async () => undefined); - const txMock = { insert: insertMock, execute: executeMock }; - const transactionMock = vi.fn(async (callback: (tx: typeof txMock) => Promise) => - callback(txMock), - ); - const dbMock = { select: selectMock, insert: insertMock, transaction: transactionMock }; - - beforeEach(() => { - selectResult.current = []; - whereMock.mockClear(); - fromMock.mockClear(); - selectMock.mockClear(); - onConflictDoUpdateMock.mockClear(); - valuesMock.mockClear(); - insertMock.mockClear(); - executeMock.mockClear(); - transactionMock.mockClear(); - vi.mocked(makeDrizzle).mockReturnValue(dbMock as unknown as ReturnType); - }); - - describe("getEnsDbVersion", () => { - it("returns undefined when no record exists", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsDbVersion()).resolves.toBeUndefined(); - - expect(selectMock).toHaveBeenCalledTimes(1); - expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); - }); - - it("returns value when one record exists", async () => { - // arrange - selectResult.current = [{ value: "0.1.0" }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsDbVersion()).resolves.toBe("0.1.0"); - }); - - // This scenario should be impossible due to the primary key constraint on - // the 'key' column of 'ensnode_metadata' table. - it("throws when multiple records exist", async () => { - // arrange - selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); - }); - }); - - describe("getEnsIndexerPublicConfig", () => { - it("returns undefined when no record exists", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsIndexerPublicConfig()).resolves.toBeUndefined(); - }); - - it("deserializes the stored config", async () => { - // arrange - const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - selectResult.current = [{ value: serializedConfig }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsIndexerPublicConfig()).resolves.toStrictEqual( - ensDbClientMock.publicConfig, - ); - }); - }); - - describe("getIndexingStatusSnapshot", () => { - it("deserializes the stored indexing status snapshot", async () => { - // arrange - selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - const expected = deserializeCrossChainIndexingStatusSnapshot( - ensDbClientMock.serializedSnapshot, - ); - - // act & assert - await expect(client.getIndexingStatusSnapshot()).resolves.toStrictEqual(expected); - }); - }); - - describe("upsertEnsDbVersion", () => { - it("writes the database version metadata", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act - await client.upsertEnsDbVersion("0.2.0"); - - // assert - expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.ensIndexerRef, - key: EnsNodeMetadataKeys.EnsDbVersion, - value: "0.2.0", - }); - expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ - target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], - set: { value: "0.2.0" }, - }); - }); - }); - - describe("upsertEnsIndexerPublicConfig", () => { - it("serializes and writes the public config", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - // act - await client.upsertEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - // assert - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.ensIndexerRef, - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: expectedValue, - }); - }); - }); - - describe("upsertIndexingStatusSnapshot", () => { - it("serializes and writes the indexing status snapshot", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - const snapshot = deserializeCrossChainIndexingStatusSnapshot( - ensDbClientMock.serializedSnapshot, - ); - const expectedValue = serializeCrossChainIndexingStatusSnapshot(snapshot); - - // act - await client.upsertIndexingStatusSnapshot(snapshot); - - // assert - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.ensIndexerRef, - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - value: expectedValue, - }); - }); - }); -}); diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts deleted file mode 100644 index d743f3590..000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { and, eq, sql } from "drizzle-orm/sql"; - -import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; -import { - type CrossChainIndexingStatusSnapshot, - deserializeCrossChainIndexingStatusSnapshot, - deserializeEnsIndexerPublicConfig, - type EnsDbClientMutation, - type EnsDbClientQuery, - type EnsIndexerPublicConfig, - EnsNodeMetadataKeys, - type SerializedEnsNodeMetadata, - type SerializedEnsNodeMetadataEnsDbVersion, - type SerializedEnsNodeMetadataEnsIndexerIndexingStatus, - type SerializedEnsNodeMetadataEnsIndexerPublicConfig, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; - -import { makeDrizzle } from "./drizzle"; - -/** - * Drizzle database - * - * Allows interacting with Postgres database for ENSDb, using Drizzle ORM. - */ -interface DrizzleDb extends NodePgDatabase {} - -/** - * ENSDb Client - * - * This client exists to provide an abstraction layer for interacting with ENSDb. - * It enables ENSIndexer and ENSApi to decouple from each other, and use - * ENSDb as the integration point between the two (via ENSDb Client). - * - * Enables querying and mutating ENSDb data, such as: - * - ENSDb version - * - ENSIndexer Public Config, and Indexing Status Snapshot and CrossChainIndexingStatusSnapshot. - */ -export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { - /** - * Drizzle database instance for ENSDb. - */ - private db: DrizzleDb; - - /** - * ENSIndexer reference string for multi-tenancy in ENSDb. - */ - private ensIndexerRef: string; - - /** - * @param databaseUrl connection string for ENSDb Postgres database - * @param ensIndexerRef reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) - */ - constructor(databaseUrl: string, ensIndexerRef: string) { - this.db = makeDrizzle({ - databaseUrl, - schema: ensNodeSchema, - }); - - this.ensIndexerRef = ensIndexerRef; - } - - /** - * @inheritdoc - */ - async getEnsDbVersion(): Promise { - const record = await this.getEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - }); - - return record; - } - - /** - * @inheritdoc - */ - async getEnsIndexerPublicConfig(): Promise { - const record = await this.getEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - }); - - if (!record) { - return undefined; - } - - return deserializeEnsIndexerPublicConfig(record); - } - - /** - * @inheritdoc - */ - async getIndexingStatusSnapshot(): Promise { - const record = await this.getEnsNodeMetadata( - { - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - }, - ); - - if (!record) { - return undefined; - } - - return deserializeCrossChainIndexingStatusSnapshot(record); - } - - /** - * @inheritdoc - */ - async upsertEnsDbVersion(ensDbVersion: string): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - value: ensDbVersion, - }); - } - - /** - * @inheritdoc - */ - async upsertEnsIndexerPublicConfig( - ensIndexerPublicConfig: EnsIndexerPublicConfig, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), - }); - } - - /** - * @inheritdoc - */ - async upsertIndexingStatusSnapshot( - indexingStatus: CrossChainIndexingStatusSnapshot, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - value: serializeCrossChainIndexingStatusSnapshot(indexingStatus), - }); - } - - /** - * Get ENSNode metadata record - * - * @returns selected record in ENSDb. - * @throws when more than one matching metadata record is found - * (should be impossible given the composite PK constraint on - * 'ensIndexerRef' and 'key'). - */ - private async getEnsNodeMetadata( - metadata: Pick, - ): Promise { - const result = await this.db - .select() - .from(ensNodeSchema.ensNodeMetadata) - .where( - and( - eq(ensNodeSchema.ensNodeMetadata.ensIndexerRef, this.ensIndexerRef), - eq(ensNodeSchema.ensNodeMetadata.key, metadata.key), - ), - ); - - if (result.length === 0) { - return undefined; - } - - if (result.length === 1 && result[0]) { - return result[0].value as EnsNodeMetadataType["value"]; - } - - throw new Error(`There must be exactly one ENSNodeMetadata record for '${metadata.key}' key`); - } - - /** - * Upsert ENSNode metadata - * - * @throws when upsert operation failed. - */ - private async upsertEnsNodeMetadata< - EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, - >(metadata: EnsNodeMetadataType): Promise { - await this.db - .insert(ensNodeSchema.ensNodeMetadata) - .values({ - ensIndexerRef: this.ensIndexerRef, - key: metadata.key, - value: metadata.value, - }) - .onConflictDoUpdate({ - target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], - set: { value: metadata.value }, - }); - } -} diff --git a/apps/ensindexer/src/lib/ensdb-client/singleton.ts b/apps/ensindexer/src/lib/ensdb-client/singleton.ts index a47ef727c..a41975775 100644 --- a/apps/ensindexer/src/lib/ensdb-client/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-client/singleton.ts @@ -1,12 +1,12 @@ import config from "@/config"; -import { EnsDbClient } from "./ensdb-client"; +import { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; -// config.databaseSchemaName is unique per ENSIndexer instance and is used as the ensIndexerRef -// tenant key in the shared ENSNode schema (ensnode.*). -const ensIndexerRef = config.databaseSchemaName; +// TODO: pending rename `config.databaseSchemaName` to `config.ensIndexerSchemaName` +// Will be executed once https://github.com/namehash/ensnode/issues/1762 is resolved. +const ensIndexerSchemaName = config.databaseSchemaName; /** - * Singleton instance of {@link EnsDbClient} for use in ENSIndexer. + * Singleton instance of {@link EnsNodeDbWriter} for use in ENSIndexer. */ -export const ensDbClient = new EnsDbClient(config.databaseUrl, ensIndexerRef); +export const ensNodeDbWriter = new EnsNodeDbWriter(config.databaseUrl, ensIndexerSchemaName); diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts index 0b98b5e51..ba0be91a8 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -1,39 +1,82 @@ import { vi } from "vitest"; +import type { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; import { type CrossChainIndexingStatusSnapshot, CrossChainIndexingStrategyIds, type EnsIndexerPublicConfig, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, + PluginName, } from "@ensnode/ensnode-sdk"; -import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; -import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; +import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder"; +export const publicConfig = { + databaseSchemaName: "ensindexer_0", + ensRainbowPublicConfig: { + version: "0.32.0", + labelSet: { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }, + recordsCount: 100, + }, + labelSet: { + labelSetId: "subgraph", + labelSetVersion: 0, + }, + indexedChainIds: new Set([1]), + isSubgraphCompatible: true, + namespace: "mainnet", + plugins: [PluginName.Subgraph], + versionInfo: { + nodejs: "v22.10.12", + ponder: "0.11.25", + ensDb: "0.32.0", + ensIndexer: "0.32.0", + ensNormalize: "1.11.1", + }, +} satisfies EnsIndexerPublicConfig; + // Helper to create mock objects with consistent typing -export function createMockEnsDbClient( - overrides: Partial> = {}, -): EnsDbClient { +export function createMockEnsNodeDbWriter( + overrides: Partial> = {}, +): EnsNodeDbWriter { return { - ...baseEnsDbClient(), + ...baseEnsNodeDbWriter(), ...overrides, - } as unknown as EnsDbClient; + } as unknown as EnsNodeDbWriter; +} + +export function createMockEnsDbWriterWorker( + ensNodeDbWriter: EnsNodeDbWriter, + publicConfigBuilder: PublicConfigBuilder, + indexingStatusBuilder: IndexingStatusBuilder, + migrationsDirPath: string = "/mock/migrations", +) { + return new EnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + migrationsDirPath, + ); } -export function baseEnsDbClient() { +export function baseEnsNodeDbWriter() { return { getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), + migrate: vi.fn().mockResolvedValue(undefined), }; } export function createMockPublicConfigBuilder( - resolvedConfig: EnsIndexerPublicConfig = ensDbClientMock.publicConfig, + resolvedConfig: EnsIndexerPublicConfig = publicConfig, ): PublicConfigBuilder { return { getPublicConfig: vi.fn().mockResolvedValue(resolvedConfig), diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index f46ad5f93..e1d8b71f0 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -6,14 +6,14 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; -import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; +import * as ensNodeDbWriterMock from "./ensdb-writer-worker.mock"; import { createMockCrossChainSnapshot, - createMockEnsDbClient, + createMockEnsDbWriterWorker, + createMockEnsNodeDbWriter, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, createMockPublicConfigBuilder, @@ -44,34 +44,123 @@ describe("EnsDbWriterWorker", () => { }); describe("run() - worker initialization", () => { + it("executes database migrations on startup", async () => { + // arrange + const migrationsDirPath = "/custom/migrations/path"; + const ensNodeDbWriter = createMockEnsNodeDbWriter(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + migrationsDirPath, + ); + + // act + await worker.run(); + + // assert - verify migrations were executed with correct path + expect(ensNodeDbWriter.migrate).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.migrate).toHaveBeenCalledWith(migrationsDirPath); + + // cleanup + worker.stop(); + }); + + it("throws when database migration fails", async () => { + // arrange + const migrationError = new Error("Migration failed: invalid SQL syntax"); + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + migrate: vi.fn().mockRejectedValue(migrationError), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); + + // act & assert + await expect(worker.run()).rejects.toThrow("Migration failed: invalid SQL syntax"); + expect(ensNodeDbWriter.migrate).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsIndexerPublicConfig).not.toHaveBeenCalled(); + }); + + it("executes migrations before any other operations", async () => { + // arrange + const operationOrder: string[] = []; + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + migrate: vi.fn().mockImplementation(async () => { + operationOrder.push("migrate"); + }), + upsertEnsDbVersion: vi.fn().mockImplementation(async () => { + operationOrder.push("upsertEnsDbVersion"); + }), + upsertEnsIndexerPublicConfig: vi.fn().mockImplementation(async () => { + operationOrder.push("upsertEnsIndexerPublicConfig"); + }), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); + + // act + await worker.run(); + + // assert - verify migrations executed first + expect(operationOrder[0]).toBe("migrate"); + expect(operationOrder).toEqual([ + "migrate", + "upsertEnsDbVersion", + "upsertEnsIndexerPublicConfig", + ]); + + // cleanup + worker.stop(); + }); + it("upserts version, config, and starts interval for indexing status snapshots", async () => { // arrange const omnichainSnapshot = createMockOmnichainSnapshot(); const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); // assert - verify initial upserts happened - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - ensDbClientMock.publicConfig.versionInfo.ensDb, + expect(ensNodeDbWriter.upsertEnsDbVersion).toHaveBeenCalledWith( + ensNodeDbWriterMock.publicConfig.versionInfo.ensDb, ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( - ensDbClientMock.publicConfig, + expect(ensNodeDbWriter.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( + ensNodeDbWriterMock.publicConfig, ); // advance time to trigger interval await vi.advanceTimersByTimeAsync(1000); // assert - snapshot should be upserted - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( omnichainSnapshot, expect.any(Number), @@ -88,26 +177,34 @@ describe("EnsDbWriterWorker", () => { throw incompatibleError; }); - const ensDbClient = createMockEnsDbClient({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensNodeDbWriterMock.publicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const publicConfigBuilder = createMockPublicConfigBuilder(ensNodeDbWriterMock.publicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act & assert await expect(worker.run()).rejects.toThrow("incompatible"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); }); it("throws error when worker is already running", async () => { // arrange - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act - first run await worker.run(); @@ -122,34 +219,42 @@ describe("EnsDbWriterWorker", () => { it("throws error when config fetch fails", async () => { // arrange const networkError = new Error("Network failure"); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = { getPublicConfig: vi.fn().mockRejectedValue(networkError), } as unknown as PublicConfigBuilder; const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act & assert await expect(worker.run()).rejects.toThrow("Network failure"); expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); }); it("throws error when stored config fetch fails", async () => { // arrange const dbError = new Error("Database connection lost"); - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), }); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act & assert await expect(worker.run()).rejects.toThrow("Database connection lost"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); }); it("fetches stored and in-memory configs concurrently", async () => { @@ -158,19 +263,23 @@ describe("EnsDbWriterWorker", () => { // validation passes }); - const ensDbClient = createMockEnsDbClient({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensNodeDbWriterMock.publicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const publicConfigBuilder = createMockPublicConfigBuilder(ensNodeDbWriterMock.publicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); // assert - both should have been called (concurrent execution via Promise.all) - expect(ensDbClient.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); // cleanup @@ -182,19 +291,23 @@ describe("EnsDbWriterWorker", () => { const snapshot = createMockCrossChainSnapshot(); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); // assert - config should be called once (pRetry is mocked) expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( - ensDbClientMock.publicConfig, + expect(ensNodeDbWriter.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( + ensNodeDbWriterMock.publicConfig, ); // cleanup @@ -206,11 +319,15 @@ describe("EnsDbWriterWorker", () => { it("stops the interval when stop() is called", async () => { // arrange const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); - const ensDbClient = createMockEnsDbClient({ upsertIndexingStatusSnapshot }); + const ensNodeDbWriter = createMockEnsNodeDbWriter({ upsertIndexingStatusSnapshot }); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); @@ -231,11 +348,15 @@ describe("EnsDbWriterWorker", () => { describe("isRunning - worker state", () => { it("indicates isRunning status correctly", async () => { // arrange - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // assert - not running initially expect(worker.isRunning).toBe(false); @@ -271,7 +392,7 @@ describe("EnsDbWriterWorker", () => { vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi @@ -280,7 +401,11 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(validSnapshot), } as unknown as IndexingStatusBuilder; - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act - run returns immediately await worker.run(); @@ -293,8 +418,8 @@ describe("EnsDbWriterWorker", () => { // assert expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledTimes(2); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); // cleanup worker.stop(); @@ -321,7 +446,7 @@ describe("EnsDbWriterWorker", () => { .mockReturnValueOnce(crossChainSnapshot2) .mockReturnValueOnce(crossChainSnapshot2); - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ upsertIndexingStatusSnapshot: vi .fn() .mockResolvedValueOnce(undefined) @@ -337,24 +462,30 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(snapshot2), } as unknown as IndexingStatusBuilder; - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); // first tick - succeeds await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot1); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledWith( + crossChainSnapshot1, + ); // second tick - fails with DB error, but continues await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( crossChainSnapshot2, ); // third tick - succeeds again await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); // cleanup worker.stop(); diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 78bcd921a..410a3c537 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -1,6 +1,7 @@ import { getUnixTime, secondsToMilliseconds } from "date-fns"; import pRetry from "p-retry"; +import type { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; import { buildCrossChainIndexingStatusSnapshotOmnichain, type CrossChainIndexingStatusSnapshot, @@ -11,7 +12,6 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; @@ -24,10 +24,12 @@ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; /** * ENSDb Writer Worker * - * A worker responsible for writing ENSIndexer-related metadata into ENSDb, including: - * - ENSDb version - * - ENSIndexer Public Config - * - ENSIndexer Indexing Status Snapshots + * A worker responsible for: + * - executing ENSDb database migrations on startup, and + * - writing ENSNode-related metadata into ENSDb, including: + * - ENSDb version + * - ENSIndexer Public Config + * - Indexing Status Snapshots */ export class EnsDbWriterWorker { /** @@ -36,9 +38,9 @@ export class EnsDbWriterWorker { private indexingStatusInterval: ReturnType | null = null; /** - * ENSDb Client instance used by the worker to interact with ENSDb. + * Client instance used by the worker to write data into ENSNode Schema in ENSDb. */ - private ensDbClient: EnsDbClient; + private ensNodeDbWriter: EnsNodeDbWriter; /** * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. @@ -51,24 +53,33 @@ export class EnsDbWriterWorker { private publicConfigBuilder: PublicConfigBuilder; /** - * @param ensDbClient ENSDb Client instance used by the worker to interact with ENSDb. + * Path to the directory containing ENSDb migrations to be executed by the worker on startup. + */ + private migrationsDirPath: string; + + /** + * @param ensNodeDbWriter ENSNodeDbWriter instance used by the worker to write data into ENSNode Schema in ENSDb. * @param publicConfigBuilder ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. * @param indexingStatusBuilder Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. + * @param migrationsDirPath Path to the directory containing ENSDb migrations to be executed by the worker on startup. */ constructor( - ensDbClient: EnsDbClient, + ensNodeDbWriter: EnsNodeDbWriter, publicConfigBuilder: PublicConfigBuilder, indexingStatusBuilder: IndexingStatusBuilder, + migrationsDirPath: string, ) { - this.ensDbClient = ensDbClient; + this.ensNodeDbWriter = ensNodeDbWriter; this.publicConfigBuilder = publicConfigBuilder; this.indexingStatusBuilder = indexingStatusBuilder; + this.migrationsDirPath = migrationsDirPath; } /** * Run the ENSDb Writer Worker * * The worker performs the following tasks: + * 0) Execute pending ENSDb migrations. * 1) A single attempt to upsert ENSDb version into ENSDb. * 2) A single attempt to upsert serialized representation of * {@link EnsIndexerPublicConfig} into ENSDb. @@ -76,6 +87,7 @@ export class EnsDbWriterWorker { * {@link CrossChainIndexingStatusSnapshot} into ENSDb. * * @throws Error if the worker is already running, or + * if database migrations execution fails, or * if the in-memory ENSIndexer Public Config could not be fetched, or * if the in-memory ENSIndexer Public Config is incompatible with the stored config in ENSDb. */ @@ -85,19 +97,24 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } + // Task 0: execute database migrations + console.log(`[EnsDbWriterWorker]: Executing database migrations...`); + await this.executeMigrations(); + console.log(`[EnsDbWriterWorker]: Database migrations executed successfully`); + // Fetch data required for task 1 and task 2. const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig(); // Task 1: upsert ENSDb version into ENSDb. console.log(`[EnsDbWriterWorker]: Upserting ENSDb version into ENSDb...`); - await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); + await this.ensNodeDbWriter.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); console.log( `[EnsDbWriterWorker]: ENSDb version upserted successfully: ${inMemoryConfig.versionInfo.ensDb}`, ); // Task 2: upsert of EnsIndexerPublicConfig into ENSDb. console.log(`[EnsDbWriterWorker]: Upserting ENSIndexer Public Config into ENSDb...`); - await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig); + await this.ensNodeDbWriter.upsertEnsIndexerPublicConfig(inMemoryConfig); console.log(`[EnsDbWriterWorker]: ENSIndexer Public Config upserted successfully`); // Task 3: recurring upsert of Indexing Status Snapshot into ENSDb. @@ -126,6 +143,17 @@ export class EnsDbWriterWorker { } } + /** + * Execute database migrations for the ENSDb Writer Worker. + * + * Runs all pending migrations in the defined migrations directory. + * + * @throws Error if any migration fails to execute. + */ + private async executeMigrations(): Promise { + await this.ensNodeDbWriter.migrate(this.migrationsDirPath); + } + /** * Get validated ENSIndexer Public Config object for the ENSDb Writer Worker. * @@ -164,7 +192,7 @@ export class EnsDbWriterWorker { try { [storedConfig, inMemoryConfig] = await Promise.all([ - this.ensDbClient.getEnsIndexerPublicConfig(), + this.ensNodeDbWriter.getEnsIndexerPublicConfig(), inMemoryConfigPromise, ]); } catch (error) { @@ -221,7 +249,7 @@ export class EnsDbWriterWorker { snapshotTime, ); - await this.ensDbClient.upsertIndexingStatusSnapshot(crossChainSnapshot); + await this.ensNodeDbWriter.upsertIndexingStatusSnapshot(crossChainSnapshot); } catch (error) { console.error( `[EnsDbWriterWorker]: Error retrieving or validating Indexing Status Snapshot:`, diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index d58ddc9e9..8891c6407 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,4 +1,6 @@ -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { fileURLToPath } from "node:url"; + +import { ensNodeDbWriter } from "@/lib/ensdb-client/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; @@ -20,10 +22,15 @@ export function startEnsDbWriterWorker() { throw new Error("EnsDbWriterWorker has already been initialized"); } + const migrationsDirPath = fileURLToPath( + new URL("../../../drizzle-kit/migrations/", import.meta.url), + ); + ensDbWriterWorker = new EnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, + migrationsDirPath, ); ensDbWriterWorker diff --git a/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts index 6e2e8419b..708689aab 100644 --- a/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts @@ -1,7 +1,23 @@ import type { Context } from "ponder:registry"; import schema from "ponder:schema"; +import type { Hash } from "viem"; -import type { LogEvent } from "@/lib/ponder-helpers"; +import { + type AccountId, + type DomainId, + makePermissionsId, + makeResolverId, +} from "@ensnode/ensnode-sdk"; + +import type { LogEventBase } from "@/lib/ponder-helpers"; + +type Topics = [Hash, ...Hash[]]; + +/** + * Constrains the type of topics from [] | [Hash, ...Hash[]] to just [Hash, ...Hash[]] + */ +const hasTopics = (topics: LogEventBase["log"]["topics"]): topics is Topics => + topics.length !== 0 && topics[0] !== null; /** * Ensures that an Event entity exists for the given `context` and `event`, returning the Event's @@ -9,24 +25,72 @@ import type { LogEvent } from "@/lib/ponder-helpers"; * * @returns event.id */ -export async function ensureEvent(context: Context, event: LogEvent) { - await context.db.insert(schema.event).values({ - id: event.id, - // chain - chainId: context.chain.id, +export async function ensureEvent(context: Context, event: LogEventBase) { + // all relevant ENS events obviously have a topic, so we can safely constrain the type of this data + if (!hasTopics(event.log.topics)) { + throw new Error(`Invariant: All events indexed via ensureEvent must have at least one topic.`); + } + + // TODO: ponder provides nulls in the topics array, so we filter them out + // https://github.com/ponder-sh/ponder/blob/main/packages/core/src/sync-store/encode.ts#L59 + const topics = event.log.topics.filter((topic): topic is Hash => topic !== null) as Topics; + + await context.db + .insert(schema.event) + .values({ + id: event.id, - // block - blockHash: event.block.hash, - timestamp: event.block.timestamp, + // chain + chainId: context.chain.id, - // transaction - transactionHash: event.transaction.hash, - from: event.transaction.from, + // block + blockNumber: event.block.number, + blockHash: event.block.hash, + timestamp: event.block.timestamp, - // log - address: event.log.address, - logIndex: event.log.logIndex, - }); + // transaction + transactionHash: event.transaction.hash, + transactionIndex: event.transaction.transactionIndex, + from: event.transaction.from, + to: event.transaction.to, + + // log + address: event.log.address, + logIndex: event.log.logIndex, + selector: topics[0], + topics, + data: event.log.data, + }) + .onConflictDoNothing(); return event.id; } + +export async function ensureDomainEvent(context: Context, event: LogEventBase, domainId: DomainId) { + const eventId = await ensureEvent(context, event); + await context.db.insert(schema.domainEvent).values({ domainId, eventId }).onConflictDoNothing(); +} + +export async function ensureResolverEvent( + context: Context, + event: LogEventBase, + resolver: AccountId, +) { + const eventId = await ensureEvent(context, event); + await context.db + .insert(schema.resolverEvent) + .values({ resolverId: makeResolverId(resolver), eventId }) + .onConflictDoNothing(); +} + +export async function ensurePermissionsEvent( + context: Context, + event: LogEventBase, + contract: AccountId, +) { + const eventId = await ensureEvent(context, event); + await context.db + .insert(schema.permissionsEvent) + .values({ permissionsId: makePermissionsId(contract), eventId }) + .onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/lib/get-this-account-id.ts b/apps/ensindexer/src/lib/get-this-account-id.ts index 86b023b44..efe625cf0 100644 --- a/apps/ensindexer/src/lib/get-this-account-id.ts +++ b/apps/ensindexer/src/lib/get-this-account-id.ts @@ -2,7 +2,7 @@ import type { Context } from "ponder:registry"; import type { AccountId } from "@ensnode/ensnode-sdk"; -import type { LogEvent } from "@/lib/ponder-helpers"; +import type { LogEventBase } from "@/lib/ponder-helpers"; /** * Retrieves the AccountId representing the contract on this chain under which `event` was emitted. @@ -10,5 +10,5 @@ import type { LogEvent } from "@/lib/ponder-helpers"; * @example * const { chainId, address } = getThisAccountId(context, event); */ -export const getThisAccountId = (context: Context, event: Pick) => +export const getThisAccountId = (context: Context, event: Pick) => ({ chainId: context.chain.id, address: event.log.address }) satisfies AccountId; diff --git a/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts b/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts index d42457a72..6c4bc79cb 100644 --- a/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts +++ b/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts @@ -6,6 +6,7 @@ import type { Address } from "viem"; import { getENSRootChainId } from "@ensnode/datasources"; import type { LabelHash, LiteralLabel } from "@ensnode/ensnode-sdk"; +import { toJson } from "@/lib/json-stringify-with-bigints"; import { maybeHealLabelByAddrReverseSubname } from "@/lib/maybe-heal-label-by-addr-reverse-subname"; import type { EventWithArgs } from "@/lib/ponder-helpers"; import { @@ -30,7 +31,7 @@ export async function healAddrReverseSubnameLabel( ); } - // First, try healing with the transaction sender address. + // Try healing with the transaction sender address. // // NOTE: In most cases, the transaction sender calls `setName` on the ENS Registry, which may // request the ENS Reverse Registry to create a reverse address record assigned to the transaction @@ -58,15 +59,27 @@ export async function healAddrReverseSubnameLabel( const healedFromOwner = maybeHealLabelByAddrReverseSubname(labelHash, event.args.owner); if (healedFromOwner !== null) return healedFromOwner; - // If previous healing methods failed, try addresses from transaction traces. In rare cases, - // neither the transaction sender nor event owner is used for the reverse address record. - // This can happen if the sender calls a factory contract that creates a new contract, which - // then acquires a subdomain under a proxy-managed ENS name. The new contract's address is - // used for the reverse record and is only found in traces, not as sender or owner. + // Try healing based on the deployed contract's address, if exists. // - // For these transactions, search the traces for addresses that could heal the label. All - // caller addresses are included in traces. This brute-force method is a last resort, as it - // requires an extra RPC call and parsing all addresses involved in the transaction. + // This handles contract setting their own Reverse Name in their constructor via ReverseClaimer.sol + try { + const receipt = await context.client.getTransactionReceipt({ hash: event.transaction.hash }); + if (receipt.contractAddress) { + const healedFromContractAddress = maybeHealLabelByAddrReverseSubname( + labelHash, + receipt.contractAddress, + ); + if (healedFromContractAddress) return healedFromContractAddress; + } + } catch { + // NOTE: context.client.getTransactionReceipt can throw, so we swallow the error in order to + // proceed to trace parsing + } + + // If previous healing methods failed, try all addresses from the transaction trace. + // + // This brute-force method is a last resort, as it requires an extra RPC call and parsing all + // addresses involved in the transaction. // // Example transaction: // https://etherscan.io/tx/0x9a6a5156f9f1fc6b1d5551483b97930df32e802f2f9229b35572170f1111134d @@ -89,6 +102,6 @@ export async function healAddrReverseSubnameLabel( // Invariant: by this point, we should have healed all subnames of addr.reverse throw new Error( - `Invariant(healAddrReverseSubnameLabel): Unable to heal the label for subname of addr.reverse with labelHash '${labelHash}'.`, + `Invariant(healAddrReverseSubnameLabel): Unable to heal the label for subname of addr.reverse with labelHash '${labelHash}'. Event:\n${toJson(event)}`, ); } diff --git a/apps/ensindexer/src/lib/ponder-helpers.ts b/apps/ensindexer/src/lib/ponder-helpers.ts index 48732047b..b38d40268 100644 --- a/apps/ensindexer/src/lib/ponder-helpers.ts +++ b/apps/ensindexer/src/lib/ponder-helpers.ts @@ -30,7 +30,7 @@ import type { ENSIndexerConfig } from "@/config/types"; * - `${string}.${string}` (call trace events) * - `${ContractName}:setup` (setup events) */ -export type LogEvent = T extends `${string}:block` +type FilterLogEvents = T extends `${string}:block` ? never : T extends `${string}:transaction:${"from" | "to"}` ? never @@ -44,7 +44,12 @@ export type LogEvent = T extends `${string}:b ? Event : never; -export type EventWithArgs = {}> = Omit & { +export type LogEvent = FilterLogEvents; + +/** LogEvent without args — for functions that only need block/transaction/log metadata. */ +export type LogEventBase = Omit; + +export type EventWithArgs = {}> = LogEventBase & { args: ARGS; }; /** diff --git a/apps/ensindexer/src/lib/subgraph/db-helpers.ts b/apps/ensindexer/src/lib/subgraph/db-helpers.ts index 56a0e48bf..ec2f62559 100644 --- a/apps/ensindexer/src/lib/subgraph/db-helpers.ts +++ b/apps/ensindexer/src/lib/subgraph/db-helpers.ts @@ -2,7 +2,7 @@ import type { Context } from "ponder:registry"; import schema from "ponder:schema"; import type { Address } from "viem"; -import type { LogEvent } from "@/lib/ponder-helpers"; +import type { LogEventBase } from "@/lib/ponder-helpers"; import { makeEventId } from "@/lib/subgraph/ids"; export async function upsertAccount(context: Context, address: Address) { @@ -43,7 +43,7 @@ export async function upsertRegistration( } // simplifies generating the shared event column values from the ponder Event object -export function sharedEventValues(chainId: number, event: Omit) { +export function sharedEventValues(chainId: number, event: LogEventBase) { return { id: makeEventId(chainId, event.block.number, event.log.logIndex), blockNumber: event.block.number, diff --git a/apps/ensindexer/src/plugins/ensv2/event-handlers.ts b/apps/ensindexer/src/plugins/ensv2/event-handlers.ts index 4c2f256a3..8fefc5b73 100644 --- a/apps/ensindexer/src/plugins/ensv2/event-handlers.ts +++ b/apps/ensindexer/src/plugins/ensv2/event-handlers.ts @@ -5,6 +5,7 @@ import attach_RegistrarControllerHandlers from "./handlers/ensv1/RegistrarContro import attach_RegistryHandlers from "./handlers/ensv2/ENSv2Registry"; import attach_EnhancedAccessControlHandlers from "./handlers/ensv2/EnhancedAccessControl"; import attach_ETHRegistrarHandlers from "./handlers/ensv2/ETHRegistrar"; +import attach_ResolverHandlers from "./handlers/shared/Resolver"; export default function () { attach_BaseRegistrarHandlers(); @@ -14,4 +15,5 @@ export default function () { attach_EnhancedAccessControlHandlers(); attach_RegistryHandlers(); attach_ETHRegistrarHandlers(); + attach_ResolverHandlers(); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts index b642c9191..acd6296a1 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts @@ -14,7 +14,7 @@ import { import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; -import { ensureEvent } from "@/lib/ensv2/event-db-helpers"; +import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { getLatestRegistration, insertLatestRegistration, @@ -83,6 +83,9 @@ export default function () { // materialize Domain owner if exists const domain = await context.db.find(schema.v1Domain, { id: domainId }); if (domain) await materializeENSv1DomainEffectiveOwner(context, domainId, to); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -125,6 +128,7 @@ export default function () { registrarChainId: registrar.chainId, registrarAddress: registrar.address, registrantId: interpretAddress(registrant), + start: event.block.timestamp, expiry, // all BaseRegistrar-derived Registrars use the same GRACE_PERIOD gracePeriod: BigInt(GRACE_PERIOD_SECONDS), @@ -134,6 +138,9 @@ export default function () { // materialize Domain owner if exists const domain = await context.db.find(schema.v1Domain, { id: domainId }); if (domain) await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } ponder.on(namespaceContract(pluginName, "BaseRegistrar:NameRegistered"), handleNameRegistered); @@ -223,6 +230,9 @@ export default function () { // NOTE: no pricing information from BaseRegistrar#NameRenewed. in ENSv1, this info is // indexed from the Registrar Controllers, see apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 91183d21b..5b228c1fb 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -17,6 +17,7 @@ import { } from "@ensnode/ensnode-sdk"; import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; +import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers"; import { healAddrReverseSubnameLabel } from "@/lib/heal-addr-reverse-subname-label"; import { namespaceContract } from "@/lib/plugin-helpers"; @@ -91,6 +92,9 @@ export default function () { // owner changes to that of the NameWrapper but then the NameWrapper emits NameWrapped, and this // indexing code re-materializes the Domain.ownerId to the NameWraper-emitted value. await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } async function handleTransfer({ @@ -119,6 +123,46 @@ export default function () { // owner changes to that of the NameWrapper but then the NameWrapper emits NameWrapped, and this // indexing code re-materializes the Domain.ownerId to the NameWraper-emitted value. await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); + } + + async function handleNewTTL({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node }>; + }) { + const { node } = event.args; + const domainId = makeENSv1DomainId(node); + + // ENSv2 model does not include root node, no-op + if (node === ROOT_NODE) return; + + // push event to domain history + await ensureDomainEvent(context, event, domainId); + } + + async function handleNewResolver({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node }>; + }) { + const { node } = event.args; + const domainId = makeENSv1DomainId(node); + + // ENSv2 model does not include root node, no-op + if (node === ROOT_NODE) return; + + // NOTE: Domain-Resolver relations are handled by the protocol-acceleration plugin and are not + // directly indexed here + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } /** @@ -153,6 +197,34 @@ export default function () { }, ); + /** + * Handles Registry#NewTTL for: + * - ENS Root Chain's ENSv1RegistryOld + */ + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:NewTTL"), + async ({ context, event }) => { + const shouldIgnoreEvent = await nodeIsMigrated(context, event.args.node); + if (shouldIgnoreEvent) return; + + return handleNewTTL({ context, event }); + }, + ); + + /** + * Handles Registry#NewResolver for: + * - ENS Root Chain's ENSv1RegistryOld + */ + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:NewResolver"), + async ({ context, event }) => { + const shouldIgnoreEvent = await nodeIsMigrated(context, event.args.node); + if (shouldIgnoreEvent) return; + + return handleNewResolver({ context, event }); + }, + ); + /** * Handles Registry events for: * - ENS Root Chain's (new) Registry @@ -161,4 +233,6 @@ export default function () { */ ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewOwner"), handleNewOwner); ponder.on(namespaceContract(pluginName, "ENSv1Registry:Transfer"), handleTransfer); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewTTL"), handleNewTTL); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewResolver"), handleNewResolver); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts index 791a0f178..50fb804e0 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts @@ -22,7 +22,7 @@ import { import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; -import { ensureEvent } from "@/lib/ensv2/event-db-helpers"; +import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; import { getLatestRegistration, @@ -133,6 +133,9 @@ export default function () { // now guaranteed to be an unexpired transferrable Registration // so materialize domain owner await materializeENSv1DomainEffectiveOwner(context, domainId, to); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } ponder.on( @@ -244,10 +247,14 @@ export default function () { registrarAddress: registrar.address, registrantId: interpretAddress(registrant), fuses, + start: event.block.timestamp, expiry, eventId: await ensureEvent(context, event), }); } + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -270,7 +277,7 @@ export default function () { } if (registration.type === "BaseRegistrar") { - // if this is a wrapped BaseRegisrar Registration, unwrap it + // if this is a wrapped BaseRegistrar Registration, unwrap it await context.db.update(schema.registration, { id: registration.id }).set({ wrapped: false, fuses: null, @@ -283,6 +290,9 @@ export default function () { }); } + // push event to domain history + await ensureDomainEvent(context, event, domainId); + // NOTE: we don't need to adjust Domain.ownerId because NameWrapper always calls ens.setOwner }, ); @@ -316,6 +326,9 @@ export default function () { fuses, // expiry: // TODO: NameWrapper expiry logic ? }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -346,6 +359,9 @@ export default function () { await context.db.update(schema.registration, { id: registration.id }).set({ expiry }); + // push event to domain history + await ensureDomainEvent(context, event, domainId); + // if this is a NameWrapper Registration, this is a Renewal event. otherwise, this is a wrapped // BaseRegistrar Registration, and the Renewal is already being managed diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts index 8ae6a4bf1..cfce030a5 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -13,6 +13,7 @@ import { PluginName, } from "@ensnode/ensnode-sdk"; +import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers"; import { getLatestRegistration, getLatestRenewal } from "@/lib/ensv2/registration-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; @@ -72,6 +73,9 @@ export default function () { await context.db .update(schema.registration, { id: registration.id }) .set({ base, premium, referrer }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } async function handleNameRenewedByController({ @@ -142,6 +146,9 @@ export default function () { // update renewal info // TODO(paymentToken): add payment token tracking here await context.db.update(schema.renewal, { id: renewal.id }).set({ base, premium, referrer }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } ////////////////////////////////////// diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 459a56422..b2a513e91 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -7,6 +7,7 @@ import { getCanonicalId, interpretAddress, isRegistrationFullyExpired, + type LabelHash, type LiteralLabel, makeENSv2DomainId, makeRegistryId, @@ -14,7 +15,7 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; -import { ensureEvent } from "@/lib/ensv2/event-db-helpers"; +import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; import { getLatestRegistration, @@ -37,12 +38,14 @@ export default function () { context: Context; event: EventWithArgs<{ tokenId: bigint; + labelHash: LabelHash; label: string; + owner: Address; expiry: bigint; - registeredBy: Address; + sender: Address; }>; }) => { - const { tokenId, label: _label, expiry, registeredBy: registrant } = event.args; + const { tokenId, label: _label, expiry, sender: registrant } = event.args; const label = _label as LiteralLabel; const labelHash = labelhash(label); @@ -115,9 +118,13 @@ export default function () { registrarChainId: registry.chainId, registrarAddress: registry.address, registrantId: interpretAddress(registrant), + start: event.block.timestamp, expiry, eventId: await ensureEvent(context, event), }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -131,11 +138,11 @@ export default function () { event: EventWithArgs<{ tokenId: bigint; newExpiry: bigint; - changedBy: Address; + sender: Address; }>; }) => { - // biome-ignore lint/correctness/noUnusedVariables: not sure if we care to index changedBy - const { tokenId, newExpiry: expiry, changedBy } = event.args; + // biome-ignore lint/correctness/noUnusedVariables: not sure if we care to index sender + const { tokenId, newExpiry: expiry, sender } = event.args; const registry = getThisAccountId(context, event); const canonicalId = getCanonicalId(tokenId); @@ -157,6 +164,9 @@ export default function () { // update Registration await context.db.update(schema.registration, { id: registration.id }).set({ expiry }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -206,6 +216,9 @@ export default function () { await context.db.update(schema.v2Domain, { id: domainId }).set({ subregistryId }); } + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -219,11 +232,9 @@ export default function () { event: EventWithArgs<{ oldTokenId: bigint; newTokenId: bigint; - resource: bigint; }>; }) => { - // biome-ignore lint/correctness/noUnusedVariables: TODO: use resource - const { oldTokenId, newTokenId, resource } = event.args; + const { oldTokenId, newTokenId } = event.args; // Invariant: CanonicalIds must match if (getCanonicalId(oldTokenId) !== getCanonicalId(newTokenId)) { @@ -234,10 +245,10 @@ export default function () { const registryAccountId = getThisAccountId(context, event); const domainId = makeENSv2DomainId(registryAccountId, canonicalId); - // TODO: likely need to track resource as well, since it depends on eacVersion - // then we can likely provide a Domain.resource -> PermissionsResource resolver in the api - await context.db.update(schema.v2Domain, { id: domainId }).set({ tokenId: newTokenId }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -264,6 +275,9 @@ export default function () { await context.db .update(schema.v2Domain, { id: domainId }) .set({ ownerId: interpretAddress(owner) }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); } ponder.on(namespaceContract(pluginName, "ENSv2Registry:TransferSingle"), handleTransferSingle); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts index 71dc88380..b2ea3cdaf 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts @@ -14,16 +14,16 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; -import { ensureEvent } from "@/lib/ensv2/event-db-helpers"; +import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { getLatestRegistration, insertLatestRenewal } from "@/lib/ensv2/registration-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { toJson } from "@/lib/json-stringify-with-bigints"; import { namespaceContract } from "@/lib/plugin-helpers"; -import type { EventWithArgs, LogEvent } from "@/lib/ponder-helpers"; +import type { EventWithArgs, LogEventBase } from "@/lib/ponder-helpers"; const pluginName = PluginName.ENSv2; -async function getRegistrarAndRegistry(context: Context, event: LogEvent) { +async function getRegistrarAndRegistry(context: Context, event: LogEventBase) { const registrar = getThisAccountId(context, event); const registry: AccountId = { chainId: context.chain.id, @@ -112,6 +112,9 @@ export default function () { base, premium, }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); @@ -166,6 +169,9 @@ export default function () { // TODO(paymentToken) base, }); + + // push event to domain history + await ensureDomainEvent(context, event, domainId); }, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts index 5f4f8fe05..589adeae8 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts @@ -10,6 +10,7 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { ensurePermissionsEvent } from "@/lib/ensv2/event-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; @@ -84,6 +85,9 @@ export default function () { .values({ id: permissionsUserId, ...contract, resource, user, roles }) .onConflictDoUpdate({ roles }); } + + // push event to permissions + await ensurePermissionsEvent(context, event, contract); }, ); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/shared/Resolver.ts b/apps/ensindexer/src/plugins/ensv2/handlers/shared/Resolver.ts new file mode 100644 index 000000000..8f344afcc --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/shared/Resolver.ts @@ -0,0 +1,72 @@ +import { type Context, ponder } from "ponder:registry"; + +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { ensureResolverEvent } from "@/lib/ensv2/event-db-helpers"; +import { getThisAccountId } from "@/lib/get-this-account-id"; +import { namespaceContract } from "@/lib/plugin-helpers"; +import type { LogEventBase } from "@/lib/ponder-helpers"; + +const pluginName = PluginName.ENSv2; + +/** + * Handlers for Resolver contracts in the ENSv2 plugin. Note that the Protocol Acceleration plugin + * handles most indexing behavior, these additional indexing functions: + * + * - ensure that the event for the Resolver is indexed + */ +export default function () { + async function handleResolverEvent({ + context, + event, + }: { + context: Context; + event: LogEventBase; + }) { + const resolver = getThisAccountId(context, event); + await ensureResolverEvent(context, event, resolver); + } + + ponder.on(namespaceContract(pluginName, "Resolver:AddrChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:AddressChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:NameChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:ContenthashChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:ABIChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:PubkeyChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:InterfaceChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:AuthorisationChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:VersionChanged"), handleResolverEvent); + ponder.on(namespaceContract(pluginName, "Resolver:DNSRecordDeleted"), handleResolverEvent); + + ponder.on( + namespaceContract( + pluginName, + "Resolver:TextChanged(bytes32 indexed node, string indexed indexedKey, string key)", + ), + handleResolverEvent, + ); + + ponder.on( + namespaceContract( + pluginName, + "Resolver:TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value)", + ), + handleResolverEvent, + ); + + ponder.on( + namespaceContract( + pluginName, + "Resolver:DNSRecordChanged(bytes32 indexed node, bytes name, uint16 resource, bytes record)", + ), + handleResolverEvent, + ); + + ponder.on( + namespaceContract( + pluginName, + "Resolver:DNSRecordChanged(bytes32 indexed node, bytes name, uint16 resource, uint32 ttl, bytes record)", + ), + handleResolverEvent, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/plugin.ts b/apps/ensindexer/src/plugins/ensv2/plugin.ts index da7162a64..353ca0ffe 100644 --- a/apps/ensindexer/src/plugins/ensv2/plugin.ts +++ b/apps/ensindexer/src/plugins/ensv2/plugin.ts @@ -36,14 +36,19 @@ import { EnhancedAccessControlABI, ETHRegistrarABI, RegistryABI, + ResolverABI, } from "@ensnode/datasources"; -import { PluginName } from "@ensnode/ensnode-sdk"; -import { getDatasourcesWithENSv2Contracts } from "@ensnode/ensnode-sdk/internal"; +import { buildBlockNumberRange, PluginName } from "@ensnode/ensnode-sdk"; +import { + getDatasourcesWithENSv2Contracts, + getDatasourcesWithResolvers, +} from "@ensnode/ensnode-sdk/internal"; import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { chainConfigForContract, chainsConnectionConfigForDatasources, + constrainBlockrange, getRequiredDatasources, maybeGetDatasources, } from "@/lib/ponder-helpers"; @@ -292,6 +297,26 @@ export default createPlugin({ )), }, }, + + ////////////////////// + // Resolver Contracts + ////////////////////// + [namespaceContract(pluginName, "Resolver")]: { + abi: ResolverABI, + chain: getDatasourcesWithResolvers(config.namespace).reduce( + (memo, datasource) => ({ + ...memo, + [datasource.chain.id.toString()]: constrainBlockrange( + config.globalBlockrange, + buildBlockNumberRange( + datasource.contracts.Resolver.startBlock, + datasource.contracts.Resolver.endBlock, + ), + ), + }), + {}, + ), + }, }, }); }, diff --git a/package.json b/package.json index af25e3890..cbb8ba22f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,11 @@ "minimatch@<10.2.3": ">=10.2.3", "rollup@>=4.0.0 <4.59.0": ">=4.59.0", "svgo@>=3.0.0 <3.3.3": "^3.3.3", - "ponder>@hono/node-server@<1.19.10": "^1.19.10" + "ponder>@hono/node-server@<1.19.10": "^1.19.10", + "devalue@<5.6.4": "^5.6.4", + "undici@>=7.0.0 <7.24.0": "^7.24.0", + "undici@>=6.0.0 <6.24.0": "^6.24.0", + "yauzl@<3.2.1": "^3.2.1" }, "ignoredBuiltDependencies": [ "bun" diff --git a/packages/datasources/src/abis/basenames/BaseRegistrar.ts b/packages/datasources/src/abis/basenames/BaseRegistrar.ts index fa18354bb..32fe98911 100644 --- a/packages/datasources/src/abis/basenames/BaseRegistrar.ts +++ b/packages/datasources/src/abis/basenames/BaseRegistrar.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const BaseRegistrar = [ { inputs: [ @@ -553,4 +555,4 @@ export const BaseRegistrar = [ stateMutability: "payable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/basenames/EARegistrarController.ts b/packages/datasources/src/abis/basenames/EARegistrarController.ts index 2923cbc10..165392a0b 100644 --- a/packages/datasources/src/abis/basenames/EARegistrarController.ts +++ b/packages/datasources/src/abis/basenames/EARegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const EarlyAccessRegistrarController = [ { inputs: [ @@ -565,4 +567,4 @@ export const EarlyAccessRegistrarController = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/basenames/L1Resolver.ts b/packages/datasources/src/abis/basenames/L1Resolver.ts index ba2c1b686..38fea1c76 100644 --- a/packages/datasources/src/abis/basenames/L1Resolver.ts +++ b/packages/datasources/src/abis/basenames/L1Resolver.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const L1Resolver = [ { inputs: [ @@ -464,4 +466,4 @@ export const L1Resolver = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/basenames/RegistrarController.ts b/packages/datasources/src/abis/basenames/RegistrarController.ts index eed59ccbb..52907bef9 100644 --- a/packages/datasources/src/abis/basenames/RegistrarController.ts +++ b/packages/datasources/src/abis/basenames/RegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const RegistrarController = [ { inputs: [ @@ -630,4 +632,4 @@ export const RegistrarController = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/basenames/Registry.ts b/packages/datasources/src/abis/basenames/Registry.ts index c02547884..913bbf4ae 100644 --- a/packages/datasources/src/abis/basenames/Registry.ts +++ b/packages/datasources/src/abis/basenames/Registry.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const Registry = [ { inputs: [{ internalType: "address", name: "rootOwner", type: "address" }], @@ -196,4 +198,4 @@ export const Registry = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/basenames/ReverseRegistrar.ts b/packages/datasources/src/abis/basenames/ReverseRegistrar.ts index cc67d88c9..1f315a989 100644 --- a/packages/datasources/src/abis/basenames/ReverseRegistrar.ts +++ b/packages/datasources/src/abis/basenames/ReverseRegistrar.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const ReverseRegistrar = [ { inputs: [ @@ -242,4 +244,4 @@ export const ReverseRegistrar = [ stateMutability: "payable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts b/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts index a0af90336..915fef2a9 100644 --- a/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts +++ b/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const UpgradeableRegistrarController = [ { inputs: [], stateMutability: "nonpayable", type: "constructor" }, { @@ -479,4 +481,4 @@ export const UpgradeableRegistrarController = [ type: "function", }, { inputs: [], name: "withdrawETH", outputs: [], stateMutability: "nonpayable", type: "function" }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/ensv2/ETHRegistrar.ts b/packages/datasources/src/abis/ensv2/ETHRegistrar.ts index d621bfeb1..d3eead1ce 100644 --- a/packages/datasources/src/abis/ensv2/ETHRegistrar.ts +++ b/packages/datasources/src/abis/ensv2/ETHRegistrar.ts @@ -1,16 +1,46 @@ +import type { Abi } from "viem"; + export const ETHRegistrar = [ { - inputs: [], - name: "REGISTRY", - outputs: [ + inputs: [ { internalType: "contract IPermissionedRegistry", - name: "", + name: "registry", + type: "address", + }, + { + internalType: "contract IHCAFactoryBasic", + name: "hcaFactory", + type: "address", + }, + { + internalType: "address", + name: "beneficiary", + type: "address", + }, + { + internalType: "uint64", + name: "minCommitmentAge", + type: "uint64", + }, + { + internalType: "uint64", + name: "maxCommitmentAge", + type: "uint64", + }, + { + internalType: "uint64", + name: "minRegisterDuration", + type: "uint64", + }, + { + internalType: "contract IRentPriceOracle", + name: "rentPriceOracle_", type: "address", }, ], - stateMutability: "view", - type: "function", + stateMutability: "nonpayable", + type: "constructor", }, { inputs: [ @@ -70,6 +100,127 @@ export const ETHRegistrar = [ name: "DurationTooShort", type: "error", }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "EACCannotGrantRoles", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "EACCannotRevokeRoles", + type: "error", + }, + { + inputs: [], + name: "EACInvalidAccount", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + ], + name: "EACInvalidRoleBitmap", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "role", + type: "uint256", + }, + ], + name: "EACMaxAssignees", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "role", + type: "uint256", + }, + ], + name: "EACMinAssignees", + type: "error", + }, + { + inputs: [], + name: "EACRootResourceNotAllowed", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "EACUnauthorizedAccountRoles", + type: "error", + }, + { + inputs: [], + name: "InvalidOwner", + type: "error", + }, { inputs: [], name: "MaxCommitmentAgeTooLow", @@ -83,7 +234,7 @@ export const ETHRegistrar = [ type: "string", }, ], - name: "NameAlreadyRegistered", + name: "NameIsAvailable", type: "error", }, { @@ -94,7 +245,7 @@ export const ETHRegistrar = [ type: "string", }, ], - name: "NameNotRegistered", + name: "NameNotAvailable", type: "error", }, { @@ -119,6 +270,17 @@ export const ETHRegistrar = [ name: "PaymentTokenNotSupported", type: "error", }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + ], + name: "SafeERC20FailedOperation", + type: "error", + }, { inputs: [ { @@ -143,6 +305,37 @@ export const ETHRegistrar = [ name: "CommitmentMade", type: "event", }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "oldRoleBitmap", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "newRoleBitmap", + type: "uint256", + }, + ], + name: "EACRolesChanged", + type: "event", + }, { anonymous: false, inputs: [ @@ -286,89 +479,104 @@ export const ETHRegistrar = [ type: "event", }, { + anonymous: false, inputs: [ { - internalType: "bytes32", - name: "commitment", - type: "bytes32", + indexed: false, + internalType: "contract IRentPriceOracle", + name: "oracle", + type: "address", }, ], - name: "commit", - outputs: [], - stateMutability: "nonpayable", - type: "function", + name: "RentPriceOracleChanged", + type: "event", }, { - inputs: [ - { - internalType: "bytes32", - name: "commitment", - type: "bytes32", - }, - ], - name: "commitmentAt", + inputs: [], + name: "BENEFICIARY", outputs: [ { - internalType: "uint64", + internalType: "address", name: "", - type: "uint64", + type: "address", }, ], stateMutability: "view", type: "function", }, { - inputs: [ + inputs: [], + name: "HCA_FACTORY", + outputs: [ { - internalType: "string", - name: "label", - type: "string", + internalType: "contract IHCAFactoryBasic", + name: "", + type: "address", }, ], - name: "isAvailable", + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "MAX_COMMITMENT_AGE", outputs: [ { - internalType: "bool", + internalType: "uint64", name: "", - type: "bool", + type: "uint64", }, ], stateMutability: "view", type: "function", }, { - inputs: [ + inputs: [], + name: "MIN_COMMITMENT_AGE", + outputs: [ { - internalType: "contract IERC20", - name: "paymentToken", - type: "address", + internalType: "uint64", + name: "", + type: "uint64", }, ], - name: "isPaymentToken", + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "MIN_REGISTER_DURATION", outputs: [ { - internalType: "bool", + internalType: "uint64", name: "", - type: "bool", + type: "uint64", }, ], stateMutability: "view", type: "function", }, { - inputs: [ + inputs: [], + name: "REGISTRY", + outputs: [ { - internalType: "string", - name: "label", - type: "string", + internalType: "contract IPermissionedRegistry", + name: "", + type: "address", }, ], - name: "isValid", + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "ROOT_RESOURCE", outputs: [ { - internalType: "bool", + internalType: "uint256", name: "", - type: "bool", + type: "uint256", }, ], stateMutability: "view", @@ -376,19 +584,267 @@ export const ETHRegistrar = [ }, { inputs: [ - { - internalType: "string", - name: "label", - type: "string", - }, - { - internalType: "address", - name: "owner", - type: "address", - }, { internalType: "bytes32", - name: "secret", + name: "commitment", + type: "bytes32", + }, + ], + name: "commit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + ], + name: "commitmentAt", + outputs: [ + { + internalType: "uint64", + name: "commitTime", + type: "uint64", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + ], + name: "getAssigneeCount", + outputs: [ + { + internalType: "uint256", + name: "counts", + type: "uint256", + }, + { + internalType: "uint256", + name: "mask", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "grantRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "grantRootRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + ], + name: "hasAssignees", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "hasRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "hasRootRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "isAvailable", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + ], + name: "isPaymentToken", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "isValid", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "bytes32", + name: "secret", type: "bytes32", }, { @@ -470,7 +926,7 @@ export const ETHRegistrar = [ outputs: [ { internalType: "uint256", - name: "", + name: "tokenId", type: "uint256", }, ], @@ -544,4 +1000,145 @@ export const ETHRegistrar = [ stateMutability: "view", type: "function", }, -] as const; + { + inputs: [], + name: "rentPriceOracle", + outputs: [ + { + internalType: "contract IRentPriceOracle", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "revokeRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "roleBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "revokeRootRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + ], + name: "roleCount", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "resource", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "roles", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "contract IRentPriceOracle", + name: "oracle", + type: "address", + }, + ], + name: "setRentPriceOracle", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "interfaceId", + type: "bytes4", + }, + ], + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts b/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts index e4753b00c..adc932208 100644 --- a/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts +++ b/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const EnhancedAccessControl = [ { inputs: [ @@ -274,7 +276,7 @@ export const EnhancedAccessControl = [ }, { internalType: "uint256", - name: "rolesBitmap", + name: "roleBitmap", type: "uint256", }, { @@ -298,7 +300,7 @@ export const EnhancedAccessControl = [ inputs: [ { internalType: "uint256", - name: "rolesBitmap", + name: "roleBitmap", type: "uint256", }, { @@ -433,4 +435,4 @@ export const EnhancedAccessControl = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/ensv2/Registry.ts b/packages/datasources/src/abis/ensv2/Registry.ts index 517e43e15..6a70bb746 100644 --- a/packages/datasources/src/abis/ensv2/Registry.ts +++ b/packages/datasources/src/abis/ensv2/Registry.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const Registry = [ { anonymous: false, @@ -40,9 +42,9 @@ export const Registry = [ type: "uint64", }, { - indexed: false, + indexed: true, internalType: "address", - name: "changedBy", + name: "sender", type: "address", }, ], @@ -58,12 +60,24 @@ export const Registry = [ name: "tokenId", type: "uint256", }, + { + indexed: true, + internalType: "bytes32", + name: "labelHash", + type: "bytes32", + }, { indexed: false, internalType: "string", name: "label", type: "string", }, + { + indexed: false, + internalType: "address", + name: "owner", + type: "address", + }, { indexed: false, internalType: "uint64", @@ -71,15 +85,96 @@ export const Registry = [ type: "uint64", }, { - indexed: false, + indexed: true, internalType: "address", - name: "registeredBy", + name: "sender", type: "address", }, ], name: "NameRegistered", type: "event", }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: true, + internalType: "bytes32", + name: "labelHash", + type: "bytes32", + }, + { + indexed: false, + internalType: "string", + name: "label", + type: "string", + }, + { + indexed: false, + internalType: "uint64", + name: "expiry", + type: "uint64", + }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + ], + name: "NameReserved", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + ], + name: "NameUnregistered", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "contract IRegistry", + name: "parent", + type: "address", + }, + { + indexed: false, + internalType: "string", + name: "label", + type: "string", + }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + ], + name: "ParentUpdated", + type: "event", + }, { anonymous: false, inputs: [ @@ -95,6 +190,12 @@ export const Registry = [ name: "resolver", type: "address", }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, ], name: "ResolverUpdated", type: "event", @@ -114,6 +215,12 @@ export const Registry = [ name: "subregistry", type: "address", }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, ], name: "SubregistryUpdated", type: "event", @@ -133,12 +240,6 @@ export const Registry = [ name: "newTokenId", type: "uint256", }, - { - indexed: false, - internalType: "uint256", - name: "resource", - type: "uint256", - }, ], name: "TokenRegenerated", type: "event", @@ -284,6 +385,24 @@ export const Registry = [ stateMutability: "view", type: "function", }, + { + inputs: [], + name: "getParent", + outputs: [ + { + internalType: "contract IRegistry", + name: "parent", + type: "address", + }, + { + internalType: "string", + name: "label", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -468,4 +587,4 @@ export const Registry = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/ensv2/UniversalResolverV2.ts b/packages/datasources/src/abis/ensv2/UniversalResolverV2.ts new file mode 100644 index 000000000..060784e0c --- /dev/null +++ b/packages/datasources/src/abis/ensv2/UniversalResolverV2.ts @@ -0,0 +1,857 @@ +import type { Abi } from "viem"; + +export const UniversalResolverV2 = [ + { + inputs: [ + { + internalType: "contract IRegistry", + name: "root", + type: "address", + }, + { + internalType: "contract IGatewayProvider", + name: "batchGatewayProvider", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [ + { + internalType: "bytes", + name: "dns", + type: "bytes", + }, + ], + name: "DNSDecodingFailed", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "ens", + type: "string", + }, + ], + name: "DNSEncodingFailed", + type: "error", + }, + { + inputs: [], + name: "EmptyAddress", + type: "error", + }, + { + inputs: [ + { + internalType: "uint16", + name: "status", + type: "uint16", + }, + { + internalType: "string", + name: "message", + type: "string", + }, + ], + name: "HttpError", + type: "error", + }, + { + inputs: [], + name: "InvalidBatchGatewayResponse", + type: "error", + }, + { + inputs: [], + name: "LabelIsEmpty", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "LabelIsTooLong", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "sender", + type: "address", + }, + { + internalType: "string[]", + name: "urls", + type: "string[]", + }, + { + internalType: "bytes", + name: "callData", + type: "bytes", + }, + { + internalType: "bytes4", + name: "callbackFunction", + type: "bytes4", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "OffchainLookup", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "offset", + type: "uint256", + }, + { + internalType: "uint256", + name: "length", + type: "uint256", + }, + ], + name: "OffsetOutOfBoundsError", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes", + name: "errorData", + type: "bytes", + }, + ], + name: "ResolverError", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + ], + name: "ResolverNotContract", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + ], + name: "ResolverNotFound", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "primary", + type: "string", + }, + { + internalType: "bytes", + name: "primaryAddress", + type: "bytes", + }, + ], + name: "ReverseAddressMismatch", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "selector", + type: "bytes4", + }, + ], + name: "UnsupportedResolverProfile", + type: "error", + }, + { + inputs: [], + name: "ROOT_REGISTRY", + outputs: [ + { + internalType: "contract IRegistry", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "batchGatewayProvider", + outputs: [ + { + internalType: "contract IGatewayProvider", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: "address", + name: "target", + type: "address", + }, + { + internalType: "bytes", + name: "call", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "flags", + type: "uint256", + }, + ], + internalType: "struct CCIPBatcher.Lookup[]", + name: "lookups", + type: "tuple[]", + }, + { + internalType: "string[]", + name: "gateways", + type: "string[]", + }, + ], + internalType: "struct CCIPBatcher.Batch", + name: "batch", + type: "tuple", + }, + ], + name: "ccipBatch", + outputs: [ + { + components: [ + { + components: [ + { + internalType: "address", + name: "target", + type: "address", + }, + { + internalType: "bytes", + name: "call", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "flags", + type: "uint256", + }, + ], + internalType: "struct CCIPBatcher.Lookup[]", + name: "lookups", + type: "tuple[]", + }, + { + internalType: "string[]", + name: "gateways", + type: "string[]", + }, + ], + internalType: "struct CCIPBatcher.Batch", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "ccipBatchCallback", + outputs: [ + { + components: [ + { + components: [ + { + internalType: "address", + name: "target", + type: "address", + }, + { + internalType: "bytes", + name: "call", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "flags", + type: "uint256", + }, + ], + internalType: "struct CCIPBatcher.Lookup[]", + name: "lookups", + type: "tuple[]", + }, + { + internalType: "string[]", + name: "gateways", + type: "string[]", + }, + ], + internalType: "struct CCIPBatcher.Batch", + name: "batch", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "ccipReadCallback", + outputs: [], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "contract IRegistry", + name: "registry", + type: "address", + }, + ], + name: "findCanonicalName", + outputs: [ + { + internalType: "bytes", + name: "", + type: "bytes", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + ], + name: "findCanonicalRegistry", + outputs: [ + { + internalType: "contract IRegistry", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + ], + name: "findRegistries", + outputs: [ + { + internalType: "contract IRegistry[]", + name: "", + type: "address[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + ], + name: "findResolver", + outputs: [ + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "bytes32", + name: "node", + type: "bytes32", + }, + { + internalType: "uint256", + name: "offset", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + ], + name: "requireResolver", + outputs: [ + { + components: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + { + internalType: "uint256", + name: "offset", + type: "uint256", + }, + { + internalType: "bytes32", + name: "node", + type: "bytes32", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "bool", + name: "extended", + type: "bool", + }, + ], + internalType: "struct AbstractUniversalResolver.ResolverInfo", + name: "info", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "resolve", + outputs: [ + { + internalType: "bytes", + name: "", + type: "bytes", + }, + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "resolveBatchCallback", + outputs: [], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "resolveCallback", + outputs: [ + { + internalType: "bytes", + name: "", + type: "bytes", + }, + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "resolveDirectCallback", + outputs: [], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "", + type: "bytes", + }, + ], + name: "resolveDirectCallbackError", + outputs: [], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "string[]", + name: "gateways", + type: "string[]", + }, + ], + name: "resolveWithGateways", + outputs: [ + { + internalType: "bytes", + name: "result", + type: "bytes", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "string[]", + name: "gateways", + type: "string[]", + }, + ], + name: "resolveWithResolver", + outputs: [ + { + internalType: "bytes", + name: "", + type: "bytes", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "lookupAddress", + type: "bytes", + }, + { + internalType: "uint256", + name: "coinType", + type: "uint256", + }, + ], + name: "reverse", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + { + internalType: "address", + name: "", + type: "address", + }, + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "reverseAddressCallback", + outputs: [ + { + internalType: "string", + name: "primary", + type: "string", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "address", + name: "reverseResolver", + type: "address", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "response", + type: "bytes", + }, + { + internalType: "bytes", + name: "extraData", + type: "bytes", + }, + ], + name: "reverseNameCallback", + outputs: [ + { + internalType: "string", + name: "primary", + type: "string", + }, + { + internalType: "address", + name: "", + type: "address", + }, + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "lookupAddress", + type: "bytes", + }, + { + internalType: "uint256", + name: "coinType", + type: "uint256", + }, + { + internalType: "string[]", + name: "gateways", + type: "string[]", + }, + ], + name: "reverseWithGateways", + outputs: [ + { + internalType: "string", + name: "primary", + type: "string", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "address", + name: "reverseResolver", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "interfaceId", + type: "bytes4", + }, + ], + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/lineanames/BaseRegistrar.ts b/packages/datasources/src/abis/lineanames/BaseRegistrar.ts index 26d7c20fb..72c251576 100644 --- a/packages/datasources/src/abis/lineanames/BaseRegistrar.ts +++ b/packages/datasources/src/abis/lineanames/BaseRegistrar.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const BaseRegistrar = [ { inputs: [ @@ -405,4 +407,4 @@ export const BaseRegistrar = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/lineanames/EthRegistrarController.ts b/packages/datasources/src/abis/lineanames/EthRegistrarController.ts index 49dd80119..ae50b1d17 100644 --- a/packages/datasources/src/abis/lineanames/EthRegistrarController.ts +++ b/packages/datasources/src/abis/lineanames/EthRegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const EthRegistrarController = [ { inputs: [ @@ -534,4 +536,4 @@ export const EthRegistrarController = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/lineanames/NameWrapper.ts b/packages/datasources/src/abis/lineanames/NameWrapper.ts index 4b427c996..92ccf32f5 100644 --- a/packages/datasources/src/abis/lineanames/NameWrapper.ts +++ b/packages/datasources/src/abis/lineanames/NameWrapper.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const NameWrapper = [ { inputs: [ @@ -734,4 +736,4 @@ export const NameWrapper = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/lineanames/Registry.ts b/packages/datasources/src/abis/lineanames/Registry.ts index 978f58a99..85c47beb2 100644 --- a/packages/datasources/src/abis/lineanames/Registry.ts +++ b/packages/datasources/src/abis/lineanames/Registry.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const Registry = [ { inputs: [], stateMutability: "nonpayable", type: "constructor" }, { @@ -191,4 +193,4 @@ export const Registry = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/BaseRegistrar.ts b/packages/datasources/src/abis/root/BaseRegistrar.ts index a5c656e64..7428807aa 100644 --- a/packages/datasources/src/abis/root/BaseRegistrar.ts +++ b/packages/datasources/src/abis/root/BaseRegistrar.ts @@ -1,399 +1,732 @@ +import type { Abi } from "viem"; + export const BaseRegistrar = [ { - constant: true, - inputs: [{ name: "interfaceID", type: "bytes4" }], - name: "supportsInterface", - outputs: [{ name: "", type: "bool" }], - payable: false, - stateMutability: "view", - type: "function", + inputs: [ + { + internalType: "contract ENS", + name: "_ens", + type: "address", + }, + { + internalType: "bytes32", + name: "_baseNode", + type: "bytes32", + }, + ], + stateMutability: "nonpayable", + type: "constructor", }, { - constant: true, - inputs: [{ name: "tokenId", type: "uint256" }], - name: "getApproved", - outputs: [{ name: "", type: "address" }], - payable: false, - stateMutability: "view", - type: "function", + 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", }, { - constant: false, + anonymous: false, inputs: [ - { name: "to", type: "address" }, - { name: "tokenId", type: "uint256" }, + { + 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: "approve", - outputs: [], - payable: false, - stateMutability: "nonpayable", - type: "function", + name: "ApprovalForAll", + type: "event", }, { - constant: false, + anonymous: false, inputs: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "tokenId", type: "uint256" }, + { + indexed: true, + internalType: "address", + name: "controller", + type: "address", + }, ], - name: "transferFrom", - outputs: [], - payable: false, - stateMutability: "nonpayable", - type: "function", + name: "ControllerAdded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "controller", + type: "address", + }, + ], + name: "ControllerRemoved", + type: "event", }, { - constant: false, + anonymous: false, inputs: [ - { name: "id", type: "uint256" }, - { name: "owner", type: "address" }, + { + indexed: true, + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "expires", + type: "uint256", + }, ], - name: "reclaim", - outputs: [], - payable: false, - stateMutability: "nonpayable", - type: "function", + name: "NameMigrated", + type: "event", }, { - constant: true, - inputs: [], - name: "ens", - outputs: [{ name: "", type: "address" }], - payable: false, - stateMutability: "view", - type: "function", + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "expires", + type: "uint256", + }, + ], + name: "NameRegistered", + type: "event", }, { - constant: false, + anonymous: false, inputs: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "tokenId", type: "uint256" }, + { + indexed: true, + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "expires", + type: "uint256", + }, ], - name: "safeTransferFrom", - outputs: [], - payable: false, - stateMutability: "nonpayable", - type: "function", + name: "NameRenewed", + 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: 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", }, { - constant: true, inputs: [], - name: "transferPeriodEnds", - outputs: [{ name: "", type: "uint256" }], - payable: false, + name: "GRACE_PERIOD", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], stateMutability: "view", type: "function", }, { - constant: false, - inputs: [{ name: "resolver", type: "address" }], - name: "setResolver", + inputs: [ + { + internalType: "address", + name: "controller", + type: "address", + }, + ], + name: "addController", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: true, - inputs: [{ name: "tokenId", type: "uint256" }], - name: "ownerOf", - outputs: [{ name: "", type: "address" }], - payable: false, - stateMutability: "view", + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "approve", + outputs: [], + stateMutability: "nonpayable", type: "function", }, { - constant: true, - inputs: [], - name: "MIGRATION_LOCK_PERIOD", - outputs: [{ name: "", type: "uint256" }], - payable: false, + inputs: [ + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + ], + name: "available", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], stateMutability: "view", type: "function", }, { - constant: true, - inputs: [{ name: "owner", type: "address" }], + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + ], name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - payable: false, + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], stateMutability: "view", type: "function", }, { - constant: false, inputs: [], - name: "renounceOwnership", - outputs: [], - payable: false, - stateMutability: "nonpayable", + name: "baseNode", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", type: "function", }, { - constant: true, - inputs: [], - name: "owner", - outputs: [{ name: "", type: "address" }], - payable: false, + inputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + name: "controllers", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], stateMutability: "view", type: "function", }, { - constant: true, inputs: [], - name: "isOwner", - outputs: [{ name: "", type: "bool" }], - payable: false, + name: "ens", + outputs: [ + { + internalType: "contract ENS", + name: "", + type: "address", + }, + ], stateMutability: "view", type: "function", }, { - constant: true, - inputs: [{ name: "id", type: "uint256" }], - name: "available", - outputs: [{ name: "", type: "bool" }], - payable: false, + inputs: [ + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "getApproved", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], stateMutability: "view", type: "function", }, { - constant: false, inputs: [ - { name: "to", type: "address" }, - { name: "approved", type: "bool" }, + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "address", + name: "operator", + type: "address", + }, ], - name: "setApprovalForAll", - outputs: [], - payable: false, - stateMutability: "nonpayable", - type: "function", - }, - { - constant: false, - inputs: [{ name: "controller", type: "address" }], - name: "addController", - outputs: [], - payable: false, - stateMutability: "nonpayable", + name: "isApprovedForAll", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", type: "function", }, { - constant: true, inputs: [], - name: "previousRegistrar", - outputs: [{ name: "", type: "address" }], - payable: false, + name: "name", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], stateMutability: "view", type: "function", }, { - constant: false, inputs: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "tokenId", type: "uint256" }, - { name: "_data", type: "bytes" }, + { + internalType: "uint256", + name: "id", + type: "uint256", + }, ], - name: "safeTransferFrom", - outputs: [], - payable: false, - stateMutability: "nonpayable", + name: "nameExpires", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", type: "function", }, { - constant: true, inputs: [], - name: "GRACE_PERIOD", - outputs: [{ name: "", type: "uint256" }], - payable: false, + name: "owner", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], stateMutability: "view", type: "function", }, { - constant: false, inputs: [ - { name: "id", type: "uint256" }, - { name: "duration", type: "uint256" }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "ownerOf", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, ], - name: "renew", - outputs: [{ name: "", type: "uint256" }], - payable: false, - stateMutability: "nonpayable", - type: "function", - }, - { - constant: true, - inputs: [{ name: "id", type: "uint256" }], - name: "nameExpires", - outputs: [{ name: "", type: "uint256" }], - payable: false, stateMutability: "view", type: "function", }, { - constant: true, - inputs: [{ name: "", type: "address" }], - name: "controllers", - outputs: [{ name: "", type: "bool" }], - payable: false, - stateMutability: "view", + inputs: [ + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + ], + name: "reclaim", + outputs: [], + stateMutability: "nonpayable", type: "function", }, { - constant: true, - inputs: [], - name: "baseNode", - outputs: [{ name: "", type: "bytes32" }], - payable: false, - stateMutability: "view", + inputs: [ + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "uint256", + name: "duration", + type: "uint256", + }, + ], + name: "register", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "nonpayable", type: "function", }, { - constant: true, inputs: [ - { name: "owner", type: "address" }, - { name: "operator", type: "address" }, + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "uint256", + name: "duration", + type: "uint256", + }, ], - name: "isApprovedForAll", - outputs: [{ name: "", type: "bool" }], - payable: false, - stateMutability: "view", + name: "registerOnly", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ - { name: "label", type: "bytes32" }, - { name: "deed", type: "address" }, - { name: "", type: "uint256" }, + { + internalType: "address", + name: "controller", + type: "address", + }, ], - name: "acceptRegistrarTransfer", + name: "removeController", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, - inputs: [{ name: "newOwner", type: "address" }], - name: "transferOwnership", - outputs: [], - payable: false, + inputs: [ + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + internalType: "uint256", + name: "duration", + type: "uint256", + }, + ], + name: "renew", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], stateMutability: "nonpayable", type: "function", }, { - constant: false, - inputs: [{ name: "controller", type: "address" }], - name: "removeController", + inputs: [], + name: "renounceOwnership", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ - { name: "id", type: "uint256" }, - { name: "owner", type: "address" }, - { name: "duration", type: "uint256" }, + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, ], - name: "register", - outputs: [{ name: "", type: "uint256" }], - payable: false, + name: "safeTransferFrom", + outputs: [], stateMutability: "nonpayable", type: "function", }, { inputs: [ - { name: "_ens", type: "address" }, - { name: "_previousRegistrar", type: "address" }, - { name: "_baseNode", type: "bytes32" }, - { name: "_transferPeriodEnds", type: "uint256" }, + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, ], - payable: false, + name: "safeTransferFrom", + outputs: [], stateMutability: "nonpayable", - type: "constructor", - }, - { - anonymous: false, - inputs: [{ indexed: true, name: "controller", type: "address" }], - name: "ControllerAdded", - type: "event", - }, - { - anonymous: false, - inputs: [{ indexed: true, name: "controller", type: "address" }], - name: "ControllerRemoved", - type: "event", + type: "function", }, { - anonymous: false, inputs: [ - { indexed: true, name: "id", type: "uint256" }, - { indexed: true, name: "owner", type: "address" }, - { indexed: false, name: "expires", type: "uint256" }, + { + internalType: "address", + name: "operator", + type: "address", + }, + { + internalType: "bool", + name: "approved", + type: "bool", + }, ], - name: "NameMigrated", - type: "event", + name: "setApprovalForAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", }, { - anonymous: false, inputs: [ - { indexed: true, name: "id", type: "uint256" }, - { indexed: true, name: "owner", type: "address" }, - { indexed: false, name: "expires", type: "uint256" }, + { + internalType: "address", + name: "resolver", + type: "address", + }, ], - name: "NameRegistered", - type: "event", + name: "setResolver", + outputs: [], + stateMutability: "nonpayable", + type: "function", }, { - anonymous: false, inputs: [ - { indexed: true, name: "id", type: "uint256" }, - { indexed: false, name: "expires", type: "uint256" }, + { + internalType: "bytes4", + name: "interfaceID", + type: "bytes4", + }, ], - name: "NameRenewed", - type: "event", + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", }, { - anonymous: false, - inputs: [ - { indexed: true, name: "previousOwner", type: "address" }, - { indexed: true, name: "newOwner", type: "address" }, + inputs: [], + name: "symbol", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, ], - name: "OwnershipTransferred", - type: "event", + stateMutability: "view", + type: "function", }, { - anonymous: false, inputs: [ - { indexed: true, name: "from", type: "address" }, - { indexed: true, name: "to", type: "address" }, - { indexed: true, name: "tokenId", type: "uint256" }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, ], - name: "Transfer", - type: "event", + name: "tokenURI", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", }, { - anonymous: false, inputs: [ - { indexed: true, name: "owner", type: "address" }, - { indexed: true, name: "approved", type: "address" }, - { indexed: true, name: "tokenId", type: "uint256" }, + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, ], - name: "Approval", - type: "event", + name: "transferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", }, { - anonymous: false, inputs: [ - { indexed: true, name: "owner", type: "address" }, - { indexed: true, name: "operator", type: "address" }, - { indexed: false, name: "approved", type: "bool" }, + { + internalType: "address", + name: "newOwner", + type: "address", + }, ], - name: "ApprovalForAll", - type: "event", + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/LegacyEthRegistrarController.ts b/packages/datasources/src/abis/root/LegacyEthRegistrarController.ts index 2a6ad67e5..7083d307d 100644 --- a/packages/datasources/src/abis/root/LegacyEthRegistrarController.ts +++ b/packages/datasources/src/abis/root/LegacyEthRegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const LegacyEthRegistrarController = [ { constant: true, @@ -237,4 +239,4 @@ export const LegacyEthRegistrarController = [ name: "OwnershipTransferred", type: "event", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/NameWrapper.ts b/packages/datasources/src/abis/root/NameWrapper.ts index 28e7f857b..ba5a3c8e8 100644 --- a/packages/datasources/src/abis/root/NameWrapper.ts +++ b/packages/datasources/src/abis/root/NameWrapper.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const NameWrapper = [ { inputs: [ @@ -83,6 +85,22 @@ export const NameWrapper = [ name: "NameIsNotWrapped", type: "error", }, + { + inputs: [ + { + internalType: "uint256", + name: "offset", + type: "uint256", + }, + { + internalType: "uint256", + name: "length", + type: "uint256", + }, + ], + name: "OffsetOutOfBoundsError", + type: "error", + }, { inputs: [ { @@ -110,6 +128,31 @@ export const NameWrapper = [ name: "Unauthorised", 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: [ @@ -403,6 +446,24 @@ export const NameWrapper = [ stateMutability: "view", type: "function", }, + { + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "approve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { @@ -451,6 +512,30 @@ export const NameWrapper = [ stateMutability: "view", type: "function", }, + { + inputs: [ + { + internalType: "bytes32", + name: "node", + type: "bytes32", + }, + { + internalType: "address", + name: "addr", + type: "address", + }, + ], + name: "canExtendSubnames", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -536,6 +621,25 @@ export const NameWrapper = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + ], + name: "getApproved", + outputs: [ + { + internalType: "address", + name: "operator", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -589,6 +693,30 @@ export const NameWrapper = [ stateMutability: "view", type: "function", }, + { + inputs: [ + { + internalType: "bytes32", + name: "parentNode", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "labelhash", + type: "bytes32", + }, + ], + name: "isWrapped", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -1238,24 +1366,14 @@ export const NameWrapper = [ { inputs: [ { - internalType: "bytes32", - name: "parentNode", - type: "bytes32", - }, - { - internalType: "string", - name: "label", - type: "string", - }, - { - internalType: "address", - name: "wrappedOwner", - type: "address", + internalType: "bytes", + name: "name", + type: "bytes", }, { - internalType: "address", - name: "resolver", - type: "address", + internalType: "bytes", + name: "extraData", + type: "bytes", }, ], name: "upgrade", @@ -1276,29 +1394,6 @@ export const NameWrapper = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { - internalType: "string", - name: "label", - type: "string", - }, - { - internalType: "address", - name: "wrappedOwner", - type: "address", - }, - { - internalType: "address", - name: "resolver", - type: "address", - }, - ], - name: "upgradeETH2LD", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [ { @@ -1365,8 +1460,14 @@ export const NameWrapper = [ }, ], name: "wrapETH2LD", - outputs: [], + outputs: [ + { + internalType: "uint64", + name: "expiry", + type: "uint64", + }, + ], stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/Registry.ts b/packages/datasources/src/abis/root/Registry.ts index b357065de..32f0d21a3 100644 --- a/packages/datasources/src/abis/root/Registry.ts +++ b/packages/datasources/src/abis/root/Registry.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const Registry = [ { inputs: [ @@ -7,7 +9,6 @@ export const Registry = [ type: "address", }, ], - payable: false, stateMutability: "nonpayable", type: "constructor", }, @@ -119,7 +120,6 @@ export const Registry = [ type: "event", }, { - constant: true, inputs: [ { internalType: "address", @@ -140,12 +140,10 @@ export const Registry = [ type: "bool", }, ], - payable: false, stateMutability: "view", type: "function", }, { - constant: true, inputs: [], name: "old", outputs: [ @@ -155,12 +153,10 @@ export const Registry = [ type: "address", }, ], - payable: false, stateMutability: "view", type: "function", }, { - constant: true, inputs: [ { internalType: "bytes32", @@ -176,12 +172,10 @@ export const Registry = [ type: "address", }, ], - payable: false, stateMutability: "view", type: "function", }, { - constant: true, inputs: [ { internalType: "bytes32", @@ -197,12 +191,10 @@ export const Registry = [ type: "bool", }, ], - payable: false, stateMutability: "view", type: "function", }, { - constant: true, inputs: [ { internalType: "bytes32", @@ -218,12 +210,10 @@ export const Registry = [ type: "address", }, ], - payable: false, stateMutability: "view", type: "function", }, { - constant: false, inputs: [ { internalType: "address", @@ -238,12 +228,10 @@ export const Registry = [ ], name: "setApprovalForAll", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ { internalType: "bytes32", @@ -258,12 +246,10 @@ export const Registry = [ ], name: "setOwner", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ { internalType: "bytes32", @@ -288,12 +274,10 @@ export const Registry = [ ], name: "setRecord", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ { internalType: "bytes32", @@ -308,12 +292,10 @@ export const Registry = [ ], name: "setResolver", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ { internalType: "bytes32", @@ -339,12 +321,10 @@ export const Registry = [ type: "bytes32", }, ], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ { internalType: "bytes32", @@ -374,12 +354,10 @@ export const Registry = [ ], name: "setSubnodeRecord", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: false, inputs: [ { internalType: "bytes32", @@ -394,12 +372,10 @@ export const Registry = [ ], name: "setTTL", outputs: [], - payable: false, stateMutability: "nonpayable", type: "function", }, { - constant: true, inputs: [ { internalType: "bytes32", @@ -415,8 +391,7 @@ export const Registry = [ type: "uint64", }, ], - payable: false, stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/UniversalRegistrarRenewalWithReferrer.ts b/packages/datasources/src/abis/root/UniversalRegistrarRenewalWithReferrer.ts index 103fc5bc5..331850d33 100644 --- a/packages/datasources/src/abis/root/UniversalRegistrarRenewalWithReferrer.ts +++ b/packages/datasources/src/abis/root/UniversalRegistrarRenewalWithReferrer.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const UniversalRegistrarRenewalWithReferrer = [ { inputs: [ @@ -35,4 +37,4 @@ export const UniversalRegistrarRenewalWithReferrer = [ type: "function", }, { stateMutability: "payable", type: "receive" }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/UniversalResolver.ts b/packages/datasources/src/abis/root/UniversalResolverV1.ts similarity index 96% rename from packages/datasources/src/abis/root/UniversalResolver.ts rename to packages/datasources/src/abis/root/UniversalResolverV1.ts index c89ecac2e..93d7b943c 100644 --- a/packages/datasources/src/abis/root/UniversalResolver.ts +++ b/packages/datasources/src/abis/root/UniversalResolverV1.ts @@ -1,9 +1,16 @@ -export const UniversalResolver = [ +import type { Abi } from "viem"; + +export const UniversalResolverV1 = [ { inputs: [ { - internalType: "contract IRegistry", - name: "root", + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "contract ENS", + name: "ens", type: "address", }, { @@ -175,19 +182,6 @@ export const UniversalResolver = [ name: "UnsupportedResolverProfile", type: "error", }, - { - inputs: [], - name: "ROOT_REGISTRY", - outputs: [ - { - internalType: "contract IRegistry", - name: "", - type: "address", - }, - ], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "batchGatewayProvider", @@ -364,25 +358,6 @@ export const UniversalResolver = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { - internalType: "bytes", - name: "name", - type: "bytes", - }, - ], - name: "findRegistries", - outputs: [ - { - internalType: "contract IRegistry[]", - name: "", - type: "address[]", - }, - ], - stateMutability: "view", - type: "function", - }, { inputs: [ { @@ -395,23 +370,36 @@ export const UniversalResolver = [ outputs: [ { internalType: "address", - name: "resolver", + name: "", type: "address", }, { internalType: "bytes32", - name: "node", + name: "", type: "bytes32", }, { internalType: "uint256", - name: "offset", + name: "", type: "uint256", }, ], stateMutability: "view", type: "function", }, + { + inputs: [], + name: "registry", + outputs: [ + { + internalType: "contract ENS", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -798,4 +786,4 @@ export const UniversalResolver = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/UnwrappedEthRegistrarController.ts b/packages/datasources/src/abis/root/UnwrappedEthRegistrarController.ts index dcc6b73e2..a4cdd5174 100644 --- a/packages/datasources/src/abis/root/UnwrappedEthRegistrarController.ts +++ b/packages/datasources/src/abis/root/UnwrappedEthRegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const UnwrappedEthRegistrarController = [ { inputs: [ @@ -674,4 +676,4 @@ export const UnwrappedEthRegistrarController = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/root/WrappedEthRegistrarController.ts b/packages/datasources/src/abis/root/WrappedEthRegistrarController.ts index aad476333..e3ac59181 100644 --- a/packages/datasources/src/abis/root/WrappedEthRegistrarController.ts +++ b/packages/datasources/src/abis/root/WrappedEthRegistrarController.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const WrappedEthRegistrarController = [ { inputs: [ @@ -589,4 +591,4 @@ export const WrappedEthRegistrarController = [ stateMutability: "nonpayable", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/seaport/Seaport1.5.ts b/packages/datasources/src/abis/seaport/Seaport1.5.ts index 6d4e14858..134a0e473 100644 --- a/packages/datasources/src/abis/seaport/Seaport1.5.ts +++ b/packages/datasources/src/abis/seaport/Seaport1.5.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const Seaport = [ { inputs: [{ internalType: "address", name: "conduitController", type: "address" }], @@ -1999,4 +2001,4 @@ export const Seaport = [ type: "function", }, { stateMutability: "payable", type: "receive" }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/shared/AbstractReverseResolver.ts b/packages/datasources/src/abis/shared/AbstractReverseResolver.ts index d06955fd1..4893d5003 100644 --- a/packages/datasources/src/abis/shared/AbstractReverseResolver.ts +++ b/packages/datasources/src/abis/shared/AbstractReverseResolver.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const AbstractReverseResolver = [ { inputs: [ @@ -45,6 +47,19 @@ export const AbstractReverseResolver = [ stateMutability: "view", type: "function", }, + { + inputs: [], + name: "chainRegistrar", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [], name: "coinType", @@ -120,4 +135,4 @@ export const AbstractReverseResolver = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/shared/LegacyPublicResolver.ts b/packages/datasources/src/abis/shared/LegacyPublicResolver.ts index 71ca032be..d43f57d83 100644 --- a/packages/datasources/src/abis/shared/LegacyPublicResolver.ts +++ b/packages/datasources/src/abis/shared/LegacyPublicResolver.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const LegacyPublicResolver = [ { inputs: [ @@ -860,4 +862,4 @@ export const LegacyPublicResolver = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/shared/Resolver.ts b/packages/datasources/src/abis/shared/Resolver.ts index 0978aaf88..842e42a93 100644 --- a/packages/datasources/src/abis/shared/Resolver.ts +++ b/packages/datasources/src/abis/shared/Resolver.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const Resolver = [ { inputs: [ @@ -1049,4 +1051,4 @@ export const Resolver = [ stateMutability: "view", type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/shared/StandaloneReverseRegistrar.ts b/packages/datasources/src/abis/shared/StandaloneReverseRegistrar.ts index 9aa975207..a2a60fdb3 100644 --- a/packages/datasources/src/abis/shared/StandaloneReverseRegistrar.ts +++ b/packages/datasources/src/abis/shared/StandaloneReverseRegistrar.ts @@ -1,10 +1,61 @@ +import type { Abi } from "viem"; + export const StandaloneReverseRegistrar = [ { - type: "event", + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "addr", + type: "address", + }, + { + indexed: false, + internalType: "string", + name: "name", + type: "string", + }, + ], name: "NameForAddrChanged", + type: "event", + }, + { inputs: [ - { indexed: true, internalType: "address", name: "addr", type: "address" }, - { indexed: false, internalType: "string", name: "name", type: "string" }, + { + internalType: "address", + name: "addr", + type: "address", + }, + ], + name: "nameForAddr", + outputs: [ + { + internalType: "string", + name: "name", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "interfaceID", + type: "bytes4", + }, + ], + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, ], + stateMutability: "view", + type: "function", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/abis/shared/UniversalResolver.ts b/packages/datasources/src/abis/shared/UniversalResolver.ts new file mode 100644 index 000000000..5b9d98fef --- /dev/null +++ b/packages/datasources/src/abis/shared/UniversalResolver.ts @@ -0,0 +1,6 @@ +import { mergeAbis } from "@ponder/utils"; + +import { UniversalResolverV2 } from "../ensv2/UniversalResolverV2"; +import { UniversalResolverV1 } from "../root/UniversalResolverV1"; + +export const UniversalResolverABI = mergeAbis([UniversalResolverV1, UniversalResolverV2]); diff --git a/packages/datasources/src/abis/threedns/ThreeDNSToken.ts b/packages/datasources/src/abis/threedns/ThreeDNSToken.ts index 604cdd26d..bcab70447 100644 --- a/packages/datasources/src/abis/threedns/ThreeDNSToken.ts +++ b/packages/datasources/src/abis/threedns/ThreeDNSToken.ts @@ -1,3 +1,5 @@ +import type { Abi } from "viem"; + export const ThreeDNSToken = [ { type: "event", @@ -153,4 +155,4 @@ export const ThreeDNSToken = [ name: "TransferBatch", type: "event", }, -] as const; +] as const satisfies Abi; diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/ens-test-env.ts index 30ba18567..6d6aac9df 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -3,13 +3,14 @@ import { zeroAddress } from "viem"; import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; import { Registry } from "./abis/ensv2/Registry"; +import { UniversalResolverV2 } from "./abis/ensv2/UniversalResolverV2"; // ABIs for ENSRoot Datasource import { BaseRegistrar as root_BaseRegistrar } from "./abis/root/BaseRegistrar"; import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } from "./abis/root/LegacyEthRegistrarController"; import { NameWrapper as root_NameWrapper } from "./abis/root/NameWrapper"; import { Registry as root_Registry } from "./abis/root/Registry"; import { UniversalRegistrarRenewalWithReferrer as root_UniversalRegistrarRenewalWithReferrer } from "./abis/root/UniversalRegistrarRenewalWithReferrer"; -import { UniversalResolver as root_UniversalResolver } from "./abis/root/UniversalResolver"; +import { UniversalResolverV1 } from "./abis/root/UniversalResolverV1"; import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; import { StandaloneReverseRegistrar } from "./abis/shared/StandaloneReverseRegistrar"; @@ -41,11 +42,13 @@ export default { [DatasourceNames.ENSRoot]: { chain: ensTestEnvChain, contracts: { + // NOTE: named LegacyENSRegistry in devnet ENSv1RegistryOld: { abi: root_Registry, // Registry was redeployed, same abi address: "0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0", startBlock: 0, }, + // NOTE: named ENSRegistry in devnet ENSv1Registry: { abi: root_Registry, // Registry was redeployed, same abi address: "0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9", @@ -55,26 +58,31 @@ export default { abi: ResolverABI, startBlock: 0, }, + // NOTE: named BaseRegistrarImplementation in devnet BaseRegistrar: { abi: root_BaseRegistrar, - address: "0xb7278a61aa25c888815afc32ad3cc52ff24fe575", + address: "0xcd8a1c3ba11cf5ecfa6267617243239504a98d90", startBlock: 0, }, + // NOTE: named LegacyETHRegistrarController in devnet LegacyEthRegistrarController: { abi: root_LegacyEthRegistrarController, - address: "0x2e2ed0cfd3ad2f1d34481277b3204d807ca2f8c2", + address: "0xd8a5a9b31c3c0232e196d518e89fd8bf83acad43", startBlock: 0, }, + // NOTE: named WrappedETHRegistrarController in devnet WrappedEthRegistrarController: { abi: root_WrappedEthRegistrarController, address: "0x253553366da8546fc250f225fe3d25d0c782303b", startBlock: 0, }, + // NOTE: named ETHRegistrarController in devnet UnwrappedEthRegistrarController: { abi: root_UnwrappedEthRegistrarController, - address: "0x51a1ceb83b83f1985a81c295d1ff28afef186e02", + address: "0x36b58f5c1969b7b6591d752ea6f5486d069010ab", startBlock: 0, }, + // NOTE: not in devnet, set to zeroAddress UniversalRegistrarRenewalWithReferrer: { abi: root_UniversalRegistrarRenewalWithReferrer, address: zeroAddress, @@ -82,17 +90,18 @@ export default { }, NameWrapper: { abi: root_NameWrapper, - address: "0xfd471836031dc5108809d173a067e8486b9047a3", + address: "0xcbeaf3bde82155f56486fb5a1072cb8baaf547cc", startBlock: 0, }, UniversalResolver: { - abi: root_UniversalResolver, - address: "0xbec49fa140acaa83533fb00a2bb19bddd0290f25", + abi: UniversalResolverV1, + address: "0xd84379ceae14aa33c123af12424a37803f885889", startBlock: 0, }, + // NOTE: named UniversalResolverV2 in devnet UniversalResolverV2: { - abi: root_UniversalResolver, - address: "0xb0d4afd8879ed9f52b28595d31b441d079b2ca07", + abi: UniversalResolverV2, + address: "0x162a433068f51e18b7d13932f27e66a3f99e6890", startBlock: 0, }, }, @@ -106,17 +115,17 @@ export default { EnhancedAccessControl: { abi: EnhancedAccessControl, startBlock: 0 }, RootRegistry: { abi: Registry, - address: "0x8a791620dd6260079bf849dc5567adc3f2fdc318", + address: "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6", startBlock: 0, }, ETHRegistry: { abi: Registry, - address: "0x84ea74d481ee0a5332c457a4d796187f6ba67feb", + address: "0x9e545e3c0baab3e08cdfd552c960a1050f373042", startBlock: 0, }, ETHRegistrar: { abi: ETHRegistrar, - address: "0x1291be112d480055dafd8a610b7d1e203891c274", + address: "0x5f3f1dbd7b74c6b46e8c44f98792a1daf8d69154", startBlock: 0, }, }, @@ -127,25 +136,28 @@ export default { contracts: { DefaultReverseRegistrar: { abi: StandaloneReverseRegistrar, - address: "0x95401dc811bb5740090279ba06cfa8fcf6113778", + address: "0x998abeb3e57409262ae5b751f60747921b33613e", startBlock: 0, }, + // NOTE: named DefaultReverseResolver in devnet DefaultReverseResolver3: { abi: ResolverABI, - address: "0x70e0ba845a1a0f2da3359c97e0285013525ffc49", + address: "0x4826533b4897376654bb4d4ad88b7fafd0c98528", startBlock: 0, }, + // NOTE: named LegacyPublicResolver in devnet DefaultPublicResolver4: { abi: ResolverABI, - address: "0x172076e0166d1f9cc711c77adf8488051744980c", + address: "0x4ee6ecad1c2dae9f525404de8555724e3c35d07b", startBlock: 0, }, + // NOTE: named PublicResolver in devnet DefaultPublicResolver5: { abi: ResolverABI, - address: "0x4ee6ecad1c2dae9f525404de8555724e3c35d07b", + address: "0xbec49fa140acaa83533fb00a2bb19bddd0290f25", startBlock: 0, }, }, diff --git a/packages/datasources/src/index.ts b/packages/datasources/src/index.ts index 6cf027e1c..dd6d5c167 100644 --- a/packages/datasources/src/index.ts +++ b/packages/datasources/src/index.ts @@ -1,8 +1,8 @@ export { EnhancedAccessControl as EnhancedAccessControlABI } from "./abis/ensv2/EnhancedAccessControl"; export { ETHRegistrar as ETHRegistrarABI } from "./abis/ensv2/ETHRegistrar"; export { Registry as RegistryABI } from "./abis/ensv2/Registry"; -export { UniversalResolver as UniversalResolverABI } from "./abis/root/UniversalResolver"; export { StandaloneReverseRegistrar as StandaloneReverseRegistrarABI } from "./abis/shared/StandaloneReverseRegistrar"; +export { UniversalResolverABI } from "./abis/shared/UniversalResolver"; export { ThreeDNSToken as ThreeDNSTokenABI } from "./abis/threedns/ThreeDNSToken"; export { AnyRegistrarABI } from "./lib/AnyRegistrarABI"; export { AnyRegistrarControllerABI } from "./lib/AnyRegistrarControllerABI"; diff --git a/packages/datasources/src/mainnet.ts b/packages/datasources/src/mainnet.ts index 961a40b24..db9b385a3 100644 --- a/packages/datasources/src/mainnet.ts +++ b/packages/datasources/src/mainnet.ts @@ -17,7 +17,7 @@ import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } fro import { NameWrapper as root_NameWrapper } from "./abis/root/NameWrapper"; import { Registry as root_Registry } from "./abis/root/Registry"; import { UniversalRegistrarRenewalWithReferrer as root_UniversalRegistrarRenewalWithReferrer } from "./abis/root/UniversalRegistrarRenewalWithReferrer"; -import { UniversalResolver as root_UniversalResolver } from "./abis/root/UniversalResolver"; +import { UniversalResolverV1 } from "./abis/root/UniversalResolverV1"; import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; import { Seaport as Seaport1_5 } from "./abis/seaport/Seaport1.5"; @@ -86,7 +86,7 @@ export default { startBlock: 16925608, }, UniversalResolver: { - abi: root_UniversalResolver, + abi: UniversalResolverV1, address: "0xabd80e8a13596feea40fd26fd6a24c3fe76f05fb", startBlock: 22671701, }, diff --git a/packages/datasources/src/sepolia-v2.ts b/packages/datasources/src/sepolia-v2.ts index 043a0c5da..b4b3c6e66 100644 --- a/packages/datasources/src/sepolia-v2.ts +++ b/packages/datasources/src/sepolia-v2.ts @@ -11,7 +11,7 @@ import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } fro import { NameWrapper as root_NameWrapper } from "./abis/root/NameWrapper"; import { Registry as root_Registry } from "./abis/root/Registry"; import { UniversalRegistrarRenewalWithReferrer as root_UniversalRegistrarRenewalWithReferrer } from "./abis/root/UniversalRegistrarRenewalWithReferrer"; -import { UniversalResolver as root_UniversalResolver } from "./abis/root/UniversalResolver"; +import { UniversalResolverV1 } from "./abis/root/UniversalResolverV1"; import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; // Shared ABIs @@ -77,7 +77,7 @@ export default { startBlock: 9374708, }, UniversalResolver: { - abi: root_UniversalResolver, + abi: UniversalResolverV1, address: "0x198827b2316e020c48b500fc3cebdbcaf58787ce", startBlock: 9374708, }, @@ -126,6 +126,11 @@ export default { address: "0xa238d3aca667210d272391a119125d38816af4b1", startBlock: 9374708, }, + DefaultPublicResolver5: { + abi: ResolverABI, + address: "0x0e14ee0592da66bb4c8a8090066bc8a5af15f3e6", + startBlock: 9374708, + }, BaseReverseResolver: { abi: ResolverABI, address: "0xf849bc9d818ac09a629ae981b03bcbcdca750e8f", diff --git a/packages/datasources/src/sepolia.ts b/packages/datasources/src/sepolia.ts index 53b12100e..a7c415657 100644 --- a/packages/datasources/src/sepolia.ts +++ b/packages/datasources/src/sepolia.ts @@ -24,7 +24,7 @@ import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } fro import { NameWrapper as root_NameWrapper } from "./abis/root/NameWrapper"; import { Registry as root_Registry } from "./abis/root/Registry"; import { UniversalRegistrarRenewalWithReferrer as root_UniversalRegistrarRenewalWithReferrer } from "./abis/root/UniversalRegistrarRenewalWithReferrer"; -import { UniversalResolver as root_UniversalResolver } from "./abis/root/UniversalResolver"; +import { UniversalResolverV1 } from "./abis/root/UniversalResolverV1"; import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; import { Seaport as Seaport1_5 } from "./abis/seaport/Seaport1.5"; @@ -94,7 +94,7 @@ export default { startBlock: 3790153, }, UniversalResolver: { - abi: root_UniversalResolver, + abi: UniversalResolverV1, address: "0xb7b7dadf4d42a08b3ec1d3a1079959dfbc8cffcc", startBlock: 8515717, }, diff --git a/packages/ensnode-schema/package.json b/packages/ensnode-schema/package.json index 7e4726696..5e4d7db21 100644 --- a/packages/ensnode-schema/package.json +++ b/packages/ensnode-schema/package.json @@ -60,6 +60,7 @@ }, "peerDependencies": { "drizzle-orm": "catalog:", + "pg-connection-string": "catalog:", "ponder": "catalog:", "viem": "catalog:" }, @@ -67,6 +68,7 @@ "@ensnode/ensnode-sdk": "workspace:", "@ensnode/shared-configs": "workspace:*", "drizzle-orm": "catalog:", + "pg-connection-string": "catalog:", "ponder": "catalog:", "tsup": "catalog:", "typescript": "catalog:", diff --git a/packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts b/packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts new file mode 100644 index 000000000..757e7275a --- /dev/null +++ b/packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts @@ -0,0 +1,66 @@ +import { isTable, Table } from "drizzle-orm"; +import { isPgEnum } from "drizzle-orm/pg-core"; + +import * as ensIndexerSchema from "../ensindexer-schema"; +import { buildDrizzleDbReadonly, type EnsDbDrizzleReadonly } from "../lib/drizzle"; + +/** + * ENSIndexer Database Reader + * + * Provides readonly access to ENSIndexer Schema in ENSDb. + */ +export class EnsIndexerDbReader { + /** + * Readonly Drizzle database instance for ENSIndexer Schema in ENSDb. + */ + private ensIndexerDbReadonly: EnsDbDrizzleReadonly; + + /** + * @param ensDbConnectionString connection string for ENSDb Postgres database + */ + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + const boundSchemaDef = EnsIndexerDbReader.bindDbSchemaDefWithDbSchemaName(ensIndexerSchemaName); + this.ensIndexerDbReadonly = buildDrizzleDbReadonly(ensDbConnectionString, boundSchemaDef); + } + + /** + * Bind a database schema definition with a specific database schema name. + * This is necessary to ensure that all tables and enums in the schema + * definition are associated with the correct database schema in ENSDb. + * + * @param dbSchemaDef - The database schema definition to bind. + * @param dbSchemaName - The schema name to bind to the database schema definition. + * @returns The database schema definition with the bound schema name. + * + * Note: this function is a replacement for `setDatabaseSchema` from `@ponder/client`. + */ + static bindDbSchemaDefWithDbSchemaName(dbSchemaName: string): typeof ensIndexerSchema { + const resultDbSchemaDef = structuredClone(ensIndexerSchema); + + for (const dbObjectDef of Object.values(resultDbSchemaDef)) { + if (isTable(dbObjectDef)) { + // @ts-expect-error - Drizzle's Table type for the schema symbol is + // not typed in a way that allows us to set it directly, + // but we know it exists and can be set. + dbObjectDef[Table.Symbol.Schema] = dbSchemaName; + } else if (isPgEnum(dbObjectDef)) { + // @ts-expect-error - Drizzle's PgEnum type for the schema symbol is + // typed as readonly, but we need to set it here so + // the output schema definition has the correct schema for + // all table and enum objects. + dbObjectDef.schema = dbSchemaName; + } + } + + return resultDbSchemaDef; + } + + /** + * Getter for the readonly Drizzle database instance for ENSIndexer Schema in ENSDb. + * + * Useful while working on complex queries directly with the ENSIndexer schema in ENSDb. + */ + get db(): EnsDbDrizzleReadonly { + return this.ensIndexerDbReadonly; + } +} diff --git a/packages/ensnode-schema/src/ensindexer-db/index.ts b/packages/ensnode-schema/src/ensindexer-db/index.ts new file mode 100644 index 000000000..1af8aca21 --- /dev/null +++ b/packages/ensnode-schema/src/ensindexer-db/index.ts @@ -0,0 +1 @@ +export * from "./ensindexer-db-reader"; diff --git a/packages/ensnode-schema/src/ensindexer-schema/ensv2.subschema.ts b/packages/ensnode-schema/src/ensindexer-schema/ensv2.subschema.ts index 028a8f4d1..aba2dc025 100644 --- a/packages/ensnode-schema/src/ensindexer-schema/ensv2.subschema.ts +++ b/packages/ensnode-schema/src/ensindexer-schema/ensv2.subschema.ts @@ -1,5 +1,5 @@ import { index, onchainEnum, onchainTable, primaryKey, relations, sql, uniqueIndex } from "ponder"; -import type { Address, Hash } from "viem"; +import type { Address, BlockNumber, Hash } from "viem"; import type { ChainId, @@ -15,6 +15,7 @@ import type { RegistrationId, RegistryId, RenewalId, + ResolverId, } from "@ensnode/ensnode-sdk"; /** @@ -79,35 +80,80 @@ import type { * of multiple pieces of information (for example, a Registry is identified by (chainId, address)), * then that information is, as well, included in the entity's columns, not just encoded in the id. * - * Many entities may reference an Event, which represents the metadata associated with the - * on-chain event log responsible for its existence. + * Events are structured as a single "events" table which tracks EVM Event Metadata for any on-chain + * Event. Then, join tables (DomainEvent, ResolverEvent, etc) track the relationship between an + * entity that has many events (Domain, Resolver) to the relevant set of Events. + * + * A Registration references the event that initiated the Registration. A Renewal, too, references + * the Event responsible for its existence. */ -////////////////// -// Event Metadata -////////////////// +////////// +// Events +////////// -export const event = onchainTable("events", (t) => ({ - // Ponder's event.id - id: t.text().primaryKey(), +export const event = onchainTable( + "events", + (t) => ({ + // Ponder's event.id + id: t.text().primaryKey(), - // Event Log Metadata + // Event Log Metadata - // chain - chainId: t.integer().notNull().$type(), + // chain + chainId: t.integer().notNull().$type(), - // block - blockHash: t.hex().notNull().$type(), - timestamp: t.bigint().notNull(), + // block + blockNumber: t.bigint().notNull().$type(), + blockHash: t.hex().notNull().$type(), + timestamp: t.bigint().notNull(), - // transaction - transactionHash: t.hex().notNull().$type(), - from: t.hex().notNull().$type
(), + // transaction + transactionHash: t.hex().notNull().$type(), + transactionIndex: t.integer().notNull(), + from: t.hex().notNull().$type
(), + to: t.hex().$type
(), // NOTE: a null `to` means this was a tx that deployed a contract - // log - address: t.hex().notNull().$type
(), - logIndex: t.integer().notNull().$type(), -})); + // log + address: t.hex().notNull().$type
(), + logIndex: t.integer().notNull().$type(), + selector: t.hex().notNull().$type(), + topics: t.hex().array().notNull().$type<[Hash, ...Hash[]]>(), + data: t.hex().notNull(), + }), + (t) => ({ + bySelector: index().on(t.selector), + byFrom: index().on(t.from), + byTimestamp: index().on(t.timestamp), + }), +); + +export const domainEvent = onchainTable( + "domain_events", + (t) => ({ + domainId: t.text().notNull().$type(), + eventId: t.text().notNull(), + }), + (t) => ({ pk: primaryKey({ columns: [t.domainId, t.eventId] }) }), +); + +export const resolverEvent = onchainTable( + "resolver_events", + (t) => ({ + resolverId: t.text().notNull().$type(), + eventId: t.text().notNull(), + }), + (t) => ({ pk: primaryKey({ columns: [t.resolverId, t.eventId] }) }), +); + +export const permissionsEvent = onchainTable( + "permissions_events", + (t) => ({ + permissionsId: t.text().notNull().$type(), + eventId: t.text().notNull(), + }), + (t) => ({ pk: primaryKey({ columns: [t.permissionsId, t.eventId] }) }), +); /////////// // Account @@ -295,6 +341,8 @@ export const registration = onchainTable( // has a type type: registrationType().notNull(), + // has a start + start: t.bigint().notNull(), // may have an expiry expiry: t.bigint(), // maybe have a grace period (BaseRegistrar) diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts new file mode 100644 index 000000000..b43a14a12 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts @@ -0,0 +1,14 @@ +/** + * Client interface for executing pending database migrations on + * ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbMigrations { + /** + * Execute pending database migrations for ENSNode Schema in ENSDb. + * + * @param migrationsDirPath - The file path to the directory containing + * database migration files for ENSNode Schema. + * @throws error when migration execution fails. + */ + migrate(migrationsDirPath: string): Promise; +} diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts new file mode 100644 index 000000000..f59b7bfc8 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts @@ -0,0 +1,30 @@ +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +/** + * Client interface with mutations for ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbMutations { + /** + * Upsert ENSDb Version + * + * @throws when upsert operation failed. + */ + upsertEnsDbVersion(ensDbVersion: string): Promise; + + /** + * Upsert ENSIndexer Public Config + * + * @throws when upsert operation failed. + */ + upsertEnsIndexerPublicConfig(ensIndexerPublicConfig: EnsIndexerPublicConfig): Promise; + + /** + * Upsert Indexing Status Snapshot + * + * @throws when upsert operation failed. + */ + upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; +} diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts new file mode 100644 index 000000000..30fca2bf1 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts @@ -0,0 +1,30 @@ +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +/** + * Client interface with read-only queries for ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbQueries { + /** + * Get ENSDb Version + * + * @returns the existing record, or `undefined`. + */ + getEnsDbVersion(): Promise; + + /** + * Get ENSIndexer Public Config + * + * @returns the existing record, or `undefined`. + */ + getEnsIndexerPublicConfig(): Promise; + + /** + * Get Indexing Status Snapshot + * + * @returns the existing record, or `undefined`. + */ + getIndexingStatusSnapshot(): Promise; +} diff --git a/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts similarity index 54% rename from apps/ensapi/src/lib/ensdb-client/ensdb-client.ts rename to packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts index 35b33cf34..6c6c3159d 100644 --- a/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts @@ -1,66 +1,50 @@ -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { and, eq } from "drizzle-orm/sql"; +import { and, eq } from "drizzle-orm"; -import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; import { type CrossChainIndexingStatusSnapshot, deserializeCrossChainIndexingStatusSnapshot, deserializeEnsIndexerPublicConfig, - type EnsDbClientQuery, type EnsIndexerPublicConfig, - EnsNodeMetadataKeys, - type SerializedEnsNodeMetadata, - type SerializedEnsNodeMetadataEnsDbVersion, - type SerializedEnsNodeMetadataEnsIndexerIndexingStatus, - type SerializedEnsNodeMetadataEnsIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; -import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; +import * as ensNodeSchema from "../ensnode-schema"; +import { buildDrizzleDbReadonly, type EnsDbDrizzleReadonly } from "../lib/drizzle"; +import type { EnsNodeDbQueries } from "./ensnode-db-queries"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; +import type { + SerializedEnsNodeMetadata, + SerializedEnsNodeMetadataEnsDbVersion, + SerializedEnsNodeMetadataEnsIndexerIndexingStatus, + SerializedEnsNodeMetadataEnsIndexerPublicConfig, +} from "./serialize/ensnode-metadata"; /** - * Drizzle database + * ENSNode Database Reader * - * Allows interacting with Postgres database for ENSDb, using Drizzle ORM. + * Provides readonly access to ENSNode Schema in ENSDb. */ -interface DrizzleDb extends NodePgDatabase {} - -/** - * ENSDb Client - * - * This client exists to provide an abstraction layer for interacting with ENSDb. - * It enables ENSIndexer and ENSApi to decouple from each other, and use - * ENSDb as the integration point between the two (via ENSDb Client). - * - * Enables querying ENSDb data, such as: - * - ENSDb version - * - ENSIndexer Public Config, - * - Indexing Status Snapshot. - */ -export class EnsDbClient implements EnsDbClientQuery { +export class EnsNodeDbReader implements EnsNodeDbQueries { /** - * Drizzle database instance for ENSDb. - * - * This is a read-only Drizzle instance, since ENSApi should not be - * performing any mutations on the database. + * Readonly Drizzle database instance for ENSNode Schema in ENSDb. */ - private db: DrizzleDb; + private ensNodeDbReadonly: EnsDbDrizzleReadonly; /** - * ENSIndexer reference string for multi-tenancy in ENSDb. + * ENSIndexer Schema Name + * + * Used for composite primary key in 'ensNodeMetadata' table to support + * multi-tenancy where records with the same `key` can coexist for different + * ENSIndexer instances without conflict. */ - private ensIndexerRef: string; + protected ensIndexerSchemaName: string; /** - * @param databaseUrl connection string for ENSDb Postgres database - * @param ensIndexerRef reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) + * @param ensDbConnectionString connection string for ENSDb Postgres database + * @param ensIndexerSchemaName reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) */ - constructor(databaseUrl: string, ensIndexerRef: string) { - this.db = makeReadOnlyDrizzle({ - databaseUrl, - schema: ensNodeSchema, - }); - - this.ensIndexerRef = ensIndexerRef; + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + this.ensNodeDbReadonly = buildDrizzleDbReadonly(ensDbConnectionString, ensNodeSchema); + this.ensIndexerSchemaName = ensIndexerSchemaName; } /** @@ -116,12 +100,12 @@ export class EnsDbClient implements EnsDbClientQuery { private async getEnsNodeMetadata( metadata: Pick, ): Promise { - const result = await this.db + const result = await this.ensNodeDbReadonly .select() .from(ensNodeSchema.ensNodeMetadata) .where( and( - eq(ensNodeSchema.ensNodeMetadata.ensIndexerRef, this.ensIndexerRef), + eq(ensNodeSchema.ensNodeMetadata.ensIndexerSchemaName, this.ensIndexerSchemaName), eq(ensNodeSchema.ensNodeMetadata.key, metadata.key), ), ); diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts new file mode 100644 index 000000000..f687935d1 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts @@ -0,0 +1,111 @@ +import { migrate } from "drizzle-orm/node-postgres/migrator"; + +import { + type CrossChainIndexingStatusSnapshot, + type EnsIndexerPublicConfig, + serializeCrossChainIndexingStatusSnapshot, + serializeEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import * as ensNodeSchema from "../ensnode-schema"; +import { buildDrizzleDb, type EnsDbDrizzle } from "../lib/drizzle"; +import type { EnsNodeDbMigrations } from "./ensnode-db-migrations"; +import type { EnsNodeDbMutations } from "./ensnode-db-mutations"; +import { EnsNodeDbReader } from "./ensnode-db-reader"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; +import type { SerializedEnsNodeMetadata } from "./serialize/ensnode-metadata"; + +/** + * ENSNode Database Writer + * + * Provides read and write access to ENSNode Schema in ENSDb. + */ +export class EnsNodeDbWriter + extends EnsNodeDbReader + implements EnsNodeDbMutations, EnsNodeDbMigrations +{ + /** + * Drizzle database instance for ENSNode Schema in ENSDb. + * + * Used for read and write operations. + */ + private ensNodeDb: EnsDbDrizzle; + + /** + * @param ensDbConnectionString connection string for ENSDb Postgres database + * @param ensIndexerSchemaName reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) + */ + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + super(ensDbConnectionString, ensIndexerSchemaName); + + this.ensNodeDb = buildDrizzleDb(ensDbConnectionString, ensNodeSchema); + } + + /** + * @inheritdoc + */ + async migrate(migrationsDirPath: string): Promise { + return migrate(this.ensNodeDb, { + migrationsFolder: migrationsDirPath, + migrationsSchema: ensNodeSchema.ENSNODE_SCHEMA_NAME, + }); + } + + /** + * @inheritdoc + */ + async upsertEnsDbVersion(ensDbVersion: string): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + value: ensDbVersion, + }); + } + + /** + * @inheritdoc + */ + async upsertEnsIndexerPublicConfig( + ensIndexerPublicConfig: EnsIndexerPublicConfig, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), + }); + } + + /** + * @inheritdoc + */ + async upsertIndexingStatusSnapshot( + indexingStatus: CrossChainIndexingStatusSnapshot, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + value: serializeCrossChainIndexingStatusSnapshot(indexingStatus), + }); + } + + /** + * Upsert ENSNode metadata + * + * @throws when upsert operation failed. + */ + private async upsertEnsNodeMetadata< + EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, + >(metadata: EnsNodeMetadataType): Promise { + await this.ensNodeDb + .insert(ensNodeSchema.ensNodeMetadata) + .values({ + ensIndexerSchemaName: this.ensIndexerSchemaName, + key: metadata.key, + value: metadata.value, + }) + .onConflictDoUpdate({ + target: [ + ensNodeSchema.ensNodeMetadata.ensIndexerSchemaName, + ensNodeSchema.ensNodeMetadata.key, + ], + set: { value: metadata.value }, + }); + } +} diff --git a/packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-metadata.ts similarity index 85% rename from packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts rename to packages/ensnode-schema/src/ensnode-db/ensnode-metadata.ts index 15c9bfefc..bdb35c406 100644 --- a/packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-metadata.ts @@ -1,5 +1,7 @@ -import type { EnsIndexerPublicConfig } from "../ensindexer/config"; -import type { CrossChainIndexingStatusSnapshot } from "../indexing-status/cross-chain-indexing-status-snapshot"; +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; /** * Keys used to distinguish records in `ensnode_metadata` table in the ENSDb. diff --git a/packages/ensnode-schema/src/ensnode-db/index.ts b/packages/ensnode-schema/src/ensnode-db/index.ts new file mode 100644 index 000000000..424878f33 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/index.ts @@ -0,0 +1,4 @@ +export * from "./ensnode-db-mutations"; +export * from "./ensnode-db-reader"; +export * from "./ensnode-db-writer"; +export * from "./ensnode-metadata"; diff --git a/packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts b/packages/ensnode-schema/src/ensnode-db/serialize/ensnode-metadata.ts similarity index 84% rename from packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts rename to packages/ensnode-schema/src/ensnode-db/serialize/ensnode-metadata.ts index d74b71abc..cae7fcdd3 100644 --- a/packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts +++ b/packages/ensnode-schema/src/ensnode-db/serialize/ensnode-metadata.ts @@ -1,5 +1,8 @@ -import type { SerializedEnsIndexerPublicConfig } from "../../ensindexer/config"; -import type { SerializedCrossChainIndexingStatusSnapshot } from "../../indexing-status/serialize/cross-chain-indexing-status-snapshot"; +import type { + SerializedCrossChainIndexingStatusSnapshot, + SerializedEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + import type { EnsNodeMetadata, EnsNodeMetadataEnsDbVersion, diff --git a/packages/ensnode-schema/src/ensnode-schema/index.ts b/packages/ensnode-schema/src/ensnode-schema/index.ts index 2f92b82fa..df62dae3f 100644 --- a/packages/ensnode-schema/src/ensnode-schema/index.ts +++ b/packages/ensnode-schema/src/ensnode-schema/index.ts @@ -22,14 +22,13 @@ export const ensNodeMetadata = ENSNODE_SCHEMA.table( "ensnode_metadata", (t) => ({ /** - * ENSIndexer Reference + * ENSIndexer Schema Name * - * References the ENSIndexer instance by a unique ENSIndexer schema name - * that a metadata record is associated with. This allows us to support - * multiple ENSIndexer instances using the same database, while ensuring - * that their metadata records do not conflict with each other. + * References the name of the ENSIndexer Schema that the metadata record + * belongs to. This allows multi-tenancy where multiple ENSIndexer + * instances can write to the same ENSNode Metadata table. */ - ensIndexerRef: t.text().notNull(), + ensIndexerSchemaName: t.text().notNull(), /** * Key @@ -55,12 +54,12 @@ export const ensNodeMetadata = ENSNODE_SCHEMA.table( }), (table) => [ /** - * Primary key constraint on 'ensIndexerRef' and 'key' columns, + * Primary key constraint on 'ensIndexerSchemaName' and 'key' columns, * to ensure that there is only one record for each key per ENSIndexer instance. */ primaryKey({ name: "ensnode_metadata_pkey", - columns: [table.ensIndexerRef, table.key], + columns: [table.ensIndexerSchemaName, table.key], }), ], ); diff --git a/packages/ensnode-schema/src/index.ts b/packages/ensnode-schema/src/index.ts index 49b2d5e31..fb4ec9f95 100644 --- a/packages/ensnode-schema/src/index.ts +++ b/packages/ensnode-schema/src/index.ts @@ -1,2 +1,5 @@ -// Re-export relevant schema definitions for backward compatibility. +export * from "./ensindexer-db"; +export * from "./ensnode-db"; +export * from "./lib/drizzle"; +export * from "./ensnode-schema"; export * from "./ensindexer-schema"; diff --git a/packages/ensnode-schema/src/lib/drizzle.ts b/packages/ensnode-schema/src/lib/drizzle.ts new file mode 100644 index 000000000..7de2cdd4b --- /dev/null +++ b/packages/ensnode-schema/src/lib/drizzle.ts @@ -0,0 +1,105 @@ +/** + * Drizzle database utilities for working with ENSDb. + */ +import type { Logger as DrizzleLogger } from "drizzle-orm/logger"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { parseIntoClientConfig } from "pg-connection-string"; + +/** + * Helper type to extract the connection configuration for Drizzle from + * the provided connection string. + */ +type DrizzleConnectionReadonlyConfig = ReturnType; + +/** + * Build a Drizzle connection configuration with read-only settings, + * based on the provided connection string. + * + * Updated drizzle connection ensures that any database interactions through + * this connection are read-only, which is important for + * the EnsNodeDbReader to prevent accidental mutations to the database. + * + * @param dbConnectionString - The connection string for the Postgres database. + */ +function buildDrizzleConnectionReadonly( + dbConnectionString: string, +): DrizzleConnectionReadonlyConfig { + const drizzleConnection = parseIntoClientConfig(dbConnectionString); + const existingConnectionOptions = drizzleConnection.options || ""; + const readonlyConnectionOption = "-c default_transaction_read_only=on"; + + const drizzleConnectionReadonly = { + ...drizzleConnection, + // Combine existing options from connection string with read-only requirement + options: existingConnectionOptions + ? `${existingConnectionOptions} ${readonlyConnectionOption}` + : readonlyConnectionOption, + }; + + return drizzleConnectionReadonly; +} + +/** + * Base definition for Drizzle database schema + * + * Represents the structure of the database schema required by Drizzle ORM. + */ +type DrizzleDbSchemaDefinition = Record; + +/** + * Drizzle database for `DbSchemaDefinition` in ENSDb. + * + * Allows interacting with `DbSchemaDefinition` in ENSDb, using Drizzle ORM. + */ +export type EnsDbDrizzle = + NodePgDatabase; + +/** + * Readonly Drizzle database for `DbSchemaDefinition` in ENSDb. + * + * Allows readonly interactions with `DbSchemaDefinition` in ENSDb, using Drizzle ORM. + */ +export type EnsDbDrizzleReadonly = Omit< + EnsDbDrizzle, + "insert" | "update" | "delete" | "transaction" +>; + +/** + * Build a Drizzle database instance + * @param connectionString - The connection string for the ENSDb. + * @param dbSchemaDef - The database schema definition for the ENSDb. + * @param logger - Optional Drizzle logger for query logging. + * @returns A Drizzle database instance for the provided database schema definition. + */ +export function buildDrizzleDb( + connectionString: string, + dbSchemaDef: DbSchemaDefinition, + logger?: DrizzleLogger, +): EnsDbDrizzle { + return drizzle({ + connection: connectionString, + schema: dbSchemaDef, + casing: "snake_case", + logger, + }); +} + +/** + * Build a read-only Drizzle database instance + * @param connectionString - The connection string for the ENSDb. + * @param dbSchemaDef - The database schema definition for the ENSDb. + * @param logger - Optional Drizzle logger for query logging. + * @returns A read-only Drizzle database instance for the provided database schema definition. + */ +export function buildDrizzleDbReadonly( + connectionString: string, + dbSchemaDef: DbSchemaDefinition, + logger?: DrizzleLogger, +): EnsDbDrizzleReadonly { + return drizzle({ + connection: buildDrizzleConnectionReadonly(connectionString), + schema: dbSchemaDef, + casing: "snake_case", + logger, + }); +} diff --git a/packages/ensnode-sdk/src/ensdb/client.ts b/packages/ensnode-sdk/src/ensdb/client.ts deleted file mode 100644 index 0cbf9b287..000000000 --- a/packages/ensnode-sdk/src/ensdb/client.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { EnsIndexerPublicConfig } from "../ensindexer/config"; -import type { CrossChainIndexingStatusSnapshot } from "../indexing-status/cross-chain-indexing-status-snapshot"; - -/** - * ENSDb Client Query - * - * Includes methods for reading from ENSDb. - */ -export interface EnsDbClientQuery { - /** - * Get ENSDb Version - * - * @returns the existing record, or `undefined`. - */ - getEnsDbVersion(): Promise; - - /** - * Get ENSIndexer Public Config - * - * @returns the existing record, or `undefined`. - */ - getEnsIndexerPublicConfig(): Promise; - - /** - * Get Indexing Status Snapshot - * - * @returns the existing record, or `undefined`. - */ - getIndexingStatusSnapshot(): Promise; -} - -/** - * ENSDb Client Mutation - * - * Includes methods for writing into ENSDb. - */ -export interface EnsDbClientMutation { - /** - * Upsert ENSDb Version - * - * @throws when upsert operation failed. - */ - upsertEnsDbVersion(ensDbVersion: string): Promise; - - /** - * Upsert ENSIndexer Public Config - * - * @throws when upsert operation failed. - */ - upsertEnsIndexerPublicConfig(ensIndexerPublicConfig: EnsIndexerPublicConfig): Promise; - - /** - * Upsert Indexing Status Snapshot - * - * @throws when upsert operation failed. - */ - upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; -} diff --git a/packages/ensnode-sdk/src/ensdb/index.ts b/packages/ensnode-sdk/src/ensdb/index.ts deleted file mode 100644 index bec975949..000000000 --- a/packages/ensnode-sdk/src/ensdb/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./client"; -export * from "./ensnode-metadata"; -export * from "./serialize/ensnode-metadata"; diff --git a/packages/ensnode-sdk/src/graphql-api/example-queries.ts b/packages/ensnode-sdk/src/graphql-api/example-queries.ts index 7e3a5c345..eb625bd63 100644 --- a/packages/ensnode-sdk/src/graphql-api/example-queries.ts +++ b/packages/ensnode-sdk/src/graphql-api/example-queries.ts @@ -35,6 +35,8 @@ const DEVNET_USER = "0x90F79bf6EB2c4f870365E785982E1f101E93b906"; const VITALIK_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; +const DEVNET_NAME_WITH_OWNED_RESOLVER = "example.eth"; + export const GRAPHQL_API_EXAMPLE_QUERIES: Array<{ query: string; variables: NamespaceSpecificValue>; @@ -52,7 +54,7 @@ export const GRAPHQL_API_EXAMPLE_QUERIES: Array<{ # # There are also example queries in the tabs above ☝️ query HelloWorld { - root { id } + domain(by: { name: "eth" }) { name owner { address } } }`, variables: { default: {} }, }, @@ -98,8 +100,9 @@ query DomainByName($name: Name!) { domain(by: {name: $name}) { __typename id - label { interpreted } + label { interpreted hash } name + owner { address } ... on ENSv1Domain { rootRegistryOwner { address } @@ -135,6 +138,31 @@ query DomainSubdomains($name: Name!) { variables: { default: { name: "eth" } }, }, + ///////////////// + // Domain Events + ///////////////// + { + query: ` +query DomainEvents($name: Name!) { + domain(by: {name: $name}) { + events { + totalCount + edges { + node { + from + to + topics + data + timestamp + transactionHash + } + } + } + } +}`, + variables: { default: { name: "newowner.eth" } }, + }, + //////////////////// // Account Domains //////////////////// @@ -160,6 +188,24 @@ query AccountDomains( }, }, + //////////////////// + // Account Events + //////////////////// + { + query: ` +query AccountEvents( + $address: Address! +) { + account(address: $address) { + events { totalCount edges { node { topics data timestamp } } } + } +}`, + variables: { + default: { address: VITALIK_ADDRESS }, + [ENSNamespaceIds.EnsTestEnv]: { address: DEVNET_DEPLOYER }, + }, + }, + ///////////////////// // Registry Domains ///////////////////// @@ -211,6 +257,7 @@ query PermissionsByContract( } } } + events { totalCount edges { node { topics data timestamp } } } } }`, variables: { @@ -271,6 +318,26 @@ query AccountResolverPermissions($address: Address!) { }, }, + ////////////////////////////// + // Domain's Assigned Resolver + ////////////////////////////// + { + query: ` +query DomainResolver($name: Name!) { + domain(by: { name: $name }) { + resolver { + records { edges { node { node keys coinTypes } } } + permissions { resources { edges { node { resource users { edges { node { user { address } roles } } } } } } } + events { totalCount edges { node { topics data timestamp } } } + } + } +}`, + variables: { + default: { name: "vitalik.eth" }, + [ENSNamespaceIds.EnsTestEnv]: { name: DEVNET_NAME_WITH_OWNED_RESOLVER }, + }, + }, + ////////////// // Namegraph ////////////// diff --git a/packages/ensnode-sdk/src/index.ts b/packages/ensnode-sdk/src/index.ts index 83faed666..64551c589 100644 --- a/packages/ensnode-sdk/src/index.ts +++ b/packages/ensnode-sdk/src/index.ts @@ -1,6 +1,5 @@ export * from "./ens"; export * from "./ensapi"; -export * from "./ensdb"; export * from "./ensindexer"; export * from "./ensrainbow"; export * from "./ensv2"; diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index c120f1a3f..df50dfbdc 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -4,6 +4,7 @@ import { accountIdEqual, getDatasourceContract, makeRegistryId, + maybeGetDatasourceContract, } from "@ensnode/ensnode-sdk"; ////////////// @@ -28,18 +29,45 @@ export const isENSv1Registry = (namespace: ENSNamespaceId, contract: AccountId) /** * Gets the AccountId representing the ENSv2 Root Registry in the selected `namespace`. + * + * @throws if the ENSv2Root Datasource or the RootRegistry contract are not defined */ export const getENSv2RootRegistry = (namespace: ENSNamespaceId) => getDatasourceContract(namespace, DatasourceNames.ENSv2Root, "RootRegistry"); /** * Gets the RegistryId representing the ENSv2 Root Registry in the selected `namespace`. + * + * @throws if the ENSv2Root Datasource or the RootRegistry contract are not defined */ export const getENSv2RootRegistryId = (namespace: ENSNamespaceId) => makeRegistryId(getENSv2RootRegistry(namespace)); /** * Determines whether `contract` is the ENSv2 Root Registry in `namespace`. + * + * @throws if the ENSv2Root Datasource or the RootRegistry contract are not defined */ export const isENSv2RootRegistry = (namespace: ENSNamespaceId, contract: AccountId) => accountIdEqual(getENSv2RootRegistry(namespace), contract); + +/** + * Gets the AccountId representing the ENSv2 Root Registry in the selected `namespace` if defined, + * otherwise `undefined`. + * + * TODO: remove this function and its usage after all namespaces define ENSv2Root + */ +export const maybeGetENSv2RootRegistry = (namespace: ENSNamespaceId) => + maybeGetDatasourceContract(namespace, DatasourceNames.ENSv2Root, "RootRegistry"); + +/** + * Gets the RegistryId representing the ENSv2 Root Registry in the selected `namespace` if defined, + * otherwise `undefined`. + * + * TODO: remove this function and its usage after all namespaces define ENSv2Root + */ +export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { + const root = maybeGetENSv2RootRegistry(namespace); + if (!root) return undefined; + return makeRegistryId(root); +}; diff --git a/packages/integration-test-env/README.md b/packages/integration-test-env/README.md index 6ad225ecc..0677653d8 100644 --- a/packages/integration-test-env/README.md +++ b/packages/integration-test-env/README.md @@ -6,8 +6,9 @@ Integration test environment orchestration for ENSNode. Spins up the full ENSNod The current devnet image is pinned to: + ``` -ghcr.io/ensdomains/contracts-v2:main-f476641 +ghcr.io/ensdomains/contracts-v2:main-cb8e11c ``` Update the `DEVNET_IMAGE` constant in the orchestrator source to change the devnet version. diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index f15e54813..9e8de4c17 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -44,7 +44,7 @@ const ENSAPI_DIR = resolve(MONOREPO_ROOT, "apps/ensapi"); // Docker images const POSTGRES_IMAGE = "postgres:17"; -const DEVNET_IMAGE = "ghcr.io/ensdomains/contracts-v2:main-f476641"; +const DEVNET_IMAGE = "ghcr.io/ensdomains/contracts-v2:main-cb8e11c"; // Ports (devnet ports must be fixed — ensTestEnvChain hardcodes localhost:8545) const DEVNET_RPC_PORT = 8545; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0c9b5384..3b0ad5351 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,10 @@ overrides: rollup@>=4.0.0 <4.59.0: '>=4.59.0' svgo@>=3.0.0 <3.3.3: ^3.3.3 ponder>@hono/node-server@<1.19.10: ^1.19.10 + devalue@<5.6.4: ^5.6.4 + undici@>=7.0.0 <7.24.0: ^7.24.0 + undici@>=6.0.0 <6.24.0: ^6.24.0 + yauzl@<3.2.1: ^3.2.1 patchedDependencies: '@changesets/assemble-release-plan@6.0.9': @@ -521,6 +525,9 @@ importers: '@types/pg': specifier: 8.16.0 version: 8.16.0 + drizzle-kit: + specifier: 0.31.9 + version: 0.31.9 typescript: specifier: 'catalog:' version: 5.9.3 @@ -872,6 +879,9 @@ importers: drizzle-orm: specifier: 'catalog:' version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3) + pg-connection-string: + specifier: 'catalog:' + version: 2.9.1 ponder: specifier: 'catalog:' version: 0.16.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(@types/pg@8.16.0)(hono@4.12.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) @@ -1643,6 +1653,9 @@ packages: resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite@0.2.13': resolution: {integrity: sha512-YRY806NnScVqa21/1L1vaysSQ+0/cAva50z7vlwzaGiBOTS9JhdzIRHN0KfgMhobFAphbznZJ7urMso4RtMBIQ==} @@ -1704,6 +1717,14 @@ packages: resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} engines: {node: '>=18.0.0'} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} engines: {node: '>=18'} @@ -4868,6 +4889,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -5490,8 +5514,8 @@ packages: resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} engines: {node: '>=18'} - devalue@5.6.3: - resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -5569,6 +5593,10 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + drizzle-kit@0.31.9: + resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} + hasBin: true + drizzle-orm@0.41.0: resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==} peerDependencies: @@ -5742,6 +5770,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.25.0' + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -5877,9 +5910,6 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -8048,10 +8078,17 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.6: resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -8502,12 +8539,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.23.0: - resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + undici@6.24.0: + resolution: {integrity: sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==} engines: {node: '>=18.17'} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + undici@7.24.1: + resolution: {integrity: sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==} engines: {node: '>=20.18.1'} unicode-properties@1.4.1: @@ -9133,8 +9170,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.2.1: + resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==} + engines: {node: '>=12'} yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} @@ -10162,6 +10200,8 @@ snapshots: '@ctrl/tinycolor@4.2.0': {} + '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite@0.2.13': {} '@emmetio/abbreviation@2.3.3': @@ -10257,6 +10297,16 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.27.2 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.0 + '@esbuild/aix-ppc64@0.25.11': optional: true @@ -13536,7 +13586,7 @@ snapshots: cssesc: 3.0.0 debug: 4.4.3 deterministic-object-hash: 2.0.2 - devalue: 5.6.3 + devalue: 5.6.4 diff: 5.2.2 dlv: 1.1.3 dset: 3.1.4 @@ -13787,6 +13837,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -13877,7 +13929,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 6.23.0 + undici: 6.24.0 whatwg-mimetype: 4.0.0 chevrotain-allstar@0.3.1(chevrotain@11.1.2): @@ -14408,7 +14460,7 @@ snapshots: dependencies: base-64: 1.0.0 - devalue@5.6.3: {} + devalue@5.6.4: {} devlop@1.1.0: dependencies: @@ -14492,6 +14544,15 @@ snapshots: dotenv@8.6.0: {} + drizzle-kit@0.31.9: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.11 + esbuild-register: 3.6.0(esbuild@0.25.11) + transitivePeerDependencies: + - supports-color + drizzle-orm@0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3): optionalDependencies: '@electric-sql/pglite': 0.2.13 @@ -14585,6 +14646,13 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild-register@3.6.0(esbuild@0.25.11): + dependencies: + debug: 4.4.3 + esbuild: 0.25.11 + transitivePeerDependencies: + - supports-color + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -14748,7 +14816,7 @@ snapshots: dependencies: debug: 4.4.3 get-stream: 5.2.0 - yauzl: 2.10.0 + yauzl: 3.2.1 optionalDependencies: '@types/yauzl': 2.10.3 transitivePeerDependencies: @@ -14787,10 +14855,6 @@ snapshots: dependencies: reusify: 1.1.0 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -17662,8 +17726,15 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.5.6: {} + source-map@0.6.1: {} + source-map@0.7.6: {} source-map@0.8.0-beta.0: @@ -17991,7 +18062,7 @@ snapshots: ssh-remote-port-forward: 1.0.4 tar-fs: 3.1.2 tmp: 0.2.5 - undici: 7.22.0 + undici: 7.24.1 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -18175,9 +18246,9 @@ snapshots: undici-types@7.16.0: {} - undici@6.23.0: {} + undici@6.24.0: {} - undici@7.22.0: {} + undici@7.24.1: {} unicode-properties@1.4.1: dependencies: @@ -18744,10 +18815,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: + yauzl@3.2.1: dependencies: buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 + pend: 1.2.0 yn@3.1.1: optional: true diff --git a/terraform/modules/ensindexer/main.tf b/terraform/modules/ensindexer/main.tf index dcfa5fd62..1c5d81204 100644 --- a/terraform/modules/ensindexer/main.tf +++ b/terraform/modules/ensindexer/main.tf @@ -1,7 +1,6 @@ locals { common_variables = { # Common configuration - "DATABASE_SCHEMA" = { value = var.database_schema }, "DATABASE_URL" = { value = var.ensdb_url }, "ALCHEMY_API_KEY" = { value = var.alchemy_api_key } "QUICKNODE_API_KEY" = { value = var.quicknode_api_key } @@ -29,6 +28,7 @@ resource "render_web_service" "ensindexer" { } env_vars = merge(local.common_variables, { + "DATABASE_SCHEMA" = { value = var.database_schema }, "ENSRAINBOW_URL" = { value = var.ensrainbow_url }, "LABEL_SET_ID" = { value = var.ensindexer_label_set_id }, "LABEL_SET_VERSION" = { value = var.ensindexer_label_set_version },