diff --git a/.changeset/frank-beds-taste.md b/.changeset/frank-beds-taste.md new file mode 100644 index 000000000..4245c39e1 --- /dev/null +++ b/.changeset/frank-beds-taste.md @@ -0,0 +1,8 @@ +--- +"@ensnode/ensnode-schema": minor +"@ensnode/datasources": minor +"ensindexer": minor +"ensapi": minor +--- + +Introduces the ENSv2 Plugin ('ensv2') for indexing both ENSv1 and the future ENSv2 protocol. diff --git a/.changeset/legal-mammals-try.md b/.changeset/legal-mammals-try.md new file mode 100644 index 000000000..8e63c997b --- /dev/null +++ b/.changeset/legal-mammals-try.md @@ -0,0 +1,6 @@ +--- +"ensindexer": minor +"@ensnode/datasources": minor +--- + +BREAKING: Removed holesky ENSNamespace. diff --git a/.github/actions/build_docker_image/action.yml b/.github/actions/build_docker_image/action.yml index 64ae0dff7..735548f2a 100644 --- a/.github/actions/build_docker_image/action.yml +++ b/.github/actions/build_docker_image/action.yml @@ -3,23 +3,23 @@ description: builds multi-arch docker image inputs: image: - description: 'Target Docker image name' + description: "Target Docker image name" required: true dockerfile: - description: 'Target Dockerfile path' + description: "Target Dockerfile path" required: true tags: - description: 'Docker Image Tags' + description: "Docker Image Tags" required: false registry_user: - description: 'Username for Docker registry' + description: "Username for Docker registry" required: true registry_token: - description: 'Registry token for Docker registry authentication' + description: "Registry token for Docker registry authentication" required: true build_args: @@ -46,11 +46,11 @@ runs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Setup Docker Builder + uses: useblacksmith/setup-docker-builder@v1 - name: Build & Push - uses: docker/build-push-action@v6 + uses: useblacksmith/build-push-action@v2 with: context: . file: ${{ inputs.dockerfile }} @@ -59,5 +59,3 @@ runs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: ${{ inputs.build_args }} - # cache-from: type=gha - # cache-to: type=gha,mode=max diff --git a/.github/scripts/run_ensindexer_healthcheck.sh b/.github/scripts/run_ensindexer_healthcheck.sh index 42c462b8e..ca7a35860 100755 --- a/.github/scripts/run_ensindexer_healthcheck.sh +++ b/.github/scripts/run_ensindexer_healthcheck.sh @@ -5,7 +5,7 @@ # Set default timeout if not provided by environment # Use env var if set, otherwise default to 60 seconds -: "${HEALTH_CHECK_TIMEOUT:=60}" +: "${HEALTH_CHECK_TIMEOUT:=60}" # Detect if running from CI or local if [ -n "$GITHUB_WORKSPACE" ]; then @@ -38,19 +38,27 @@ PID=$! echo "ENSIndexer started with PID: $PID" +# Require ENSINDEXER_URL to be set +if [ -z "$ENSINDEXER_URL" ]; then + echo "Error: ENSINDEXER_URL environment variable must be set" + kill -9 $PID 2>/dev/null || true + wait $PID 2>/dev/null || true + rm -f "$LOG_FILE" + [ -f "$ENV_FILE" ] && rm -f "$ENV_FILE" + exit 1 +fi + # Wait for health check to pass -echo "Waiting for health check to pass (up to $HEALTH_CHECK_TIMEOUT seconds)..." +echo "Waiting for health check to pass at ${ENSINDEXER_URL}/health (up to $HEALTH_CHECK_TIMEOUT seconds)..." health_check_start=$(date +%s) last_log_check=0 while true; do current_time=$(date +%s) - # Periodically show log progress (every 15 seconds) to prevent CI timeout + # Periodically show progress (every 15 seconds) to prevent CI timeout if [ $((current_time - last_log_check)) -ge 15 ]; then echo "Still waiting for health check at $(date) (elapsed: $((current_time - health_check_start)) seconds)..." - echo "Recent log entries:" - tail -n 10 "$LOG_FILE" last_log_check=$current_time fi @@ -63,30 +71,26 @@ while true; do echo "Last 30 lines of log:" tail -n 30 "$LOG_FILE" rm -f "$LOG_FILE" - # Clean up env file [ -f "$ENV_FILE" ] && rm -f "$ENV_FILE" exit 1 fi - # Check for health ready message - if grep -q "Started returning 200 responses from /health endpoint" "$LOG_FILE"; then + # Check health endpoint + if curl -sf "${ENSINDEXER_URL}/health" >/dev/null 2>&1; then echo "Health check passed! ENSIndexer is up and running." echo "Test successful - terminating ENSIndexer" # Force kill the ENSIndexer process kill -9 $PID 2>/dev/null || true - # Make sure we don't wait for the process to exit since we've force killed it wait $PID 2>/dev/null || true # Clean up the log file and env file rm -f "$LOG_FILE" [ -f "$ENV_FILE" ] && rm -f "$ENV_FILE" - # Explicitly exit with success code echo "Exiting with success code 0" exit 0 fi # Check if we've reached the health check timeout elapsed=$((current_time - health_check_start)) - if [ $elapsed -ge $HEALTH_CHECK_TIMEOUT ]; then echo "Health check timeout reached. ENSIndexer did not become healthy." kill -9 $PID 2>/dev/null || true @@ -94,7 +98,6 @@ while true; do echo "Last 30 lines of log:" tail -n 30 "$LOG_FILE" rm -f "$LOG_FILE" - # Clean up env file [ -f "$ENV_FILE" ] && rm -f "$ENV_FILE" exit 1 fi diff --git a/.github/workflows/deploy_ensnode_blue_green.yml b/.github/workflows/deploy_ensnode_blue_green.yml index a16370715..c8a1b25a5 100644 --- a/.github/workflows/deploy_ensnode_blue_green.yml +++ b/.github/workflows/deploy_ensnode_blue_green.yml @@ -69,9 +69,6 @@ jobs: #SEPOLIA echo "SEPOLIA_API_SVC_ID="${{ secrets.GREEN_SEPOLIA_API_SVC_ID }} >> "$GITHUB_ENV" echo "SEPOLIA_INDEXER_SVC_ID="${{ secrets.GREEN_SEPOLIA_INDEXER_SVC_ID }} >> "$GITHUB_ENV" - #HOLESKY - echo "HOLESKY_API_SVC_ID="${{ secrets.GREEN_HOLESKY_API_SVC_ID }} >> "$GITHUB_ENV" - echo "HOLESKY_INDEXER_SVC_ID="${{ secrets.GREEN_HOLESKY_INDEXER_SVC_ID }} >> "$GITHUB_ENV" #ENSRAINBOW echo "ENSRAINBOW_SVC_ID="${{ secrets.GREEN_ENSRAINBOW_SVC_ID }} >> "$GITHUB_ENV" #ENSADMIN @@ -93,9 +90,6 @@ jobs: #SEPOLIA echo "SEPOLIA_API_SVC_ID="${{ secrets.BLUE_SEPOLIA_API_SVC_ID }} >> "$GITHUB_ENV" echo "SEPOLIA_INDEXER_SVC_ID="${{ secrets.BLUE_SEPOLIA_INDEXER_SVC_ID }} >> "$GITHUB_ENV" - #HOLESKY - echo "HOLESKY_API_SVC_ID="${{ secrets.BLUE_HOLESKY_API_SVC_ID }} >> "$GITHUB_ENV" - echo "HOLESKY_INDEXER_SVC_ID="${{ secrets.BLUE_HOLESKY_INDEXER_SVC_ID }} >> "$GITHUB_ENV" #ENSRAINBOW echo "ENSRAINBOW_SVC_ID="${{ secrets.BLUE_ENSRAINBOW_SVC_ID }} >> "$GITHUB_ENV" #ENSADMIN @@ -144,9 +138,6 @@ jobs: #SEPOLIA update_service_image ${RAILWAY_ENVIRONMENT_ID} ${SEPOLIA_API_SVC_ID} ${{ env.ENSAPI_DOCKER_IMAGE }} update_service_image ${RAILWAY_ENVIRONMENT_ID} ${SEPOLIA_INDEXER_SVC_ID} ${{ env.ENSINDEXER_DOCKER_IMAGE }} - #HOLESKY - update_service_image ${RAILWAY_ENVIRONMENT_ID} ${HOLESKY_API_SVC_ID} ${{ env.ENSAPI_DOCKER_IMAGE }} - update_service_image ${RAILWAY_ENVIRONMENT_ID} ${HOLESKY_INDEXER_SVC_ID} ${{ env.ENSINDEXER_DOCKER_IMAGE }} #ENSRAINBOW update_service_image ${RAILWAY_ENVIRONMENT_ID} ${ENSRAINBOW_SVC_ID} ${{ env.ENSRAINBOW_DOCKER_IMAGE }} #ENSADMIN @@ -174,7 +165,6 @@ jobs: set_shared_variable ${RAILWAY_ENVIRONMENT_ID} "MAINNET_DATABASE_SCHEMA" "mainnetSchema${TAG}" set_shared_variable ${RAILWAY_ENVIRONMENT_ID} "ALPHA-SEPOLIA_DATABASE_SCHEMA" "alphaSepoliaSchema${TAG}" set_shared_variable ${RAILWAY_ENVIRONMENT_ID} "SEPOLIA_DATABASE_SCHEMA" "sepoliaSchema${TAG}" - set_shared_variable ${RAILWAY_ENVIRONMENT_ID} "HOLESKY_DATABASE_SCHEMA" "holeskySchema${TAG}" - name: Redeploy ENSNode instances run: | @@ -203,9 +193,6 @@ jobs: #SEPOLIA redeploy_service ${RAILWAY_ENVIRONMENT_ID} ${SEPOLIA_API_SVC_ID} redeploy_service ${RAILWAY_ENVIRONMENT_ID} ${SEPOLIA_INDEXER_SVC_ID} - #HOLESKY - redeploy_service ${RAILWAY_ENVIRONMENT_ID} ${HOLESKY_API_SVC_ID} - redeploy_service ${RAILWAY_ENVIRONMENT_ID} ${HOLESKY_INDEXER_SVC_ID} #ENSRAINBOW redeploy_service ${RAILWAY_ENVIRONMENT_ID} ${ENSRAINBOW_SVC_ID} #ENSADMIN diff --git a/.github/workflows/deploy_switch_ensnode_environment.yml b/.github/workflows/deploy_switch_ensnode_environment.yml index 231da8095..b34dd58e0 100644 --- a/.github/workflows/deploy_switch_ensnode_environment.yml +++ b/.github/workflows/deploy_switch_ensnode_environment.yml @@ -70,19 +70,9 @@ jobs: redis-cli -u $REDIS_URL SET traefik/http/routers/lb-header-alpha-sepolia-indexer-router/service "${TARGET_ENVIRONMENT}-alpha-sepolia-indexer" # SEPOLIA - redis-cli -u $REDIS_URL SET traefik/http/routers/sepolia-api-router/service "${TARGET_ENVIRONMENT}-sepolia-api" - redis-cli -u $REDIS_URL SET traefik/http/routers/sepolia-indexer-router/service "${TARGET_ENVIRONMENT}-sepolia-indexer" - redis-cli -u $REDIS_URL SET traefik/http/routers/lb-header-sepolia-api-router/service "${TARGET_ENVIRONMENT}-sepolia-api" redis-cli -u $REDIS_URL SET traefik/http/routers/lb-header-sepolia-indexer-router/service "${TARGET_ENVIRONMENT}-sepolia-indexer" - # HOLESKY - redis-cli -u $REDIS_URL SET traefik/http/routers/holesky-api-router/service "${TARGET_ENVIRONMENT}-holesky-api" - redis-cli -u $REDIS_URL SET traefik/http/routers/holesky-indexer-router/service "${TARGET_ENVIRONMENT}-holesky-indexer" - - redis-cli -u $REDIS_URL SET traefik/http/routers/lb-header-holesky-api-router/service "${TARGET_ENVIRONMENT}-holesky-api" - redis-cli -u $REDIS_URL SET traefik/http/routers/lb-header-holesky-indexer-router/service "${TARGET_ENVIRONMENT}-holesky-indexer" - # ENSRAINBOW redis-cli -u $REDIS_URL SET traefik/http/routers/ensrainbow-api-router/service "${TARGET_ENVIRONMENT}-ensrainbow-api" diff --git a/.tool-versions b/.tool-versions index ae9c7b993..c914eff2a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ nodejs 22.14.0 -pnpm 10.20.0 +pnpm 10.26.0 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..e285a5f8a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Script: ENSApi Dev", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "dev"], + "cwd": "${workspaceFolder}/apps/ensapi", + "env": { + "NODE_ENV": "development" + }, + "console": "integratedTerminal", + "outputCapture": "std" + } + ] +} diff --git a/apps/ensadmin/.env.local.example b/apps/ensadmin/.env.local.example index 04584a75a..60ee0b83c 100644 --- a/apps/ensadmin/.env.local.example +++ b/apps/ensadmin/.env.local.example @@ -7,8 +7,8 @@ ENSADMIN_PUBLIC_URL=http://localhost:4173 # Server's library of ENSNode URLs offered as connection options in the connection picker. -# Optional. If not set, defaults to `DEFAULT_SERVER_CONNECTION_LIBRARY` (https://api.alpha.ensnode.io,https://api.alpha-sepolia.ensnode.io,https://api.mainnet.ensnode.io,https://api.sepolia.ensnode.io,https://api.holesky.ensnode.io). +# Optional. If not set, defaults to `DEFAULT_SERVER_CONNECTION_LIBRARY` (https://api.alpha.ensnode.io,https://api.alpha-sepolia.ensnode.io,https://api.mainnet.ensnode.io,https://api.sepolia.ensnode.io). # Note: it must be a comma-separated list of URLs that are accessible from a web browser # (i.e. it cannot be a hostname in a docker network) # Note: if a user doesn't explicitly select an ENSNode connection then, by default, ENSAdmin will automatically select the first URL in this list as the ENSNode instance to connect the user to. -NEXT_PUBLIC_SERVER_CONNECTION_LIBRARY=https://api.alpha.ensnode.io,https://api.alpha-sepolia.ensnode.io,https://api.mainnet.ensnode.io,https://api.sepolia.ensnode.io,https://api.holesky.ensnode.io +NEXT_PUBLIC_SERVER_CONNECTION_LIBRARY=https://api.alpha.ensnode.io,https://api.alpha-sepolia.ensnode.io,https://api.mainnet.ensnode.io,https://api.sepolia.ensnode.io diff --git a/apps/ensadmin/package.json b/apps/ensadmin/package.json index d15ea7cc2..b5ce33565 100644 --- a/apps/ensadmin/package.json +++ b/apps/ensadmin/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "Explore the ENS Protocol like never before", "license": "MIT", - "packageManager": "pnpm@10.20.0", + "packageManager": "pnpm@10.26.0", "repository": { "type": "git", "url": "git+https://github.com/namehash/ensnode.git", diff --git a/apps/ensadmin/src/app/mock/config-info/data.json b/apps/ensadmin/src/app/mock/config-info/data.json index 4fbf81ce6..f8d9f90fb 100644 --- a/apps/ensadmin/src/app/mock/config-info/data.json +++ b/apps/ensadmin/src/app/mock/config-info/data.json @@ -122,33 +122,6 @@ "isSubgraphCompatible": true } }, - "Subgraph Holesky": { - "version": "0.35.0", - "theGraphFallback": { - "canFallback": false, - "reason": "no-api-key" - }, - "ensIndexerPublicConfig": { - "labelSet": { - "labelSetId": "subgraph", - "labelSetVersion": 0 - }, - "versionInfo": { - "nodejs": "22.18.0", - "ponder": "0.11.43", - "ensDb": "0.35.0", - "ensIndexer": "0.35.0", - "ensNormalize": "1.11.1", - "ensRainbow": "0.34.0", - "ensRainbowSchema": 3 - }, - "indexedChainIds": [17000], - "namespace": "holesky", - "plugins": ["subgraph"], - "databaseSchemaName": "holeskySchema0.34.0", - "isSubgraphCompatible": true - } - }, "Serialization Error": { "version": "0.35.0", "theGraphFallback": { @@ -169,8 +142,8 @@ "ensRainbow": "", "ensRainbowSchema": -1 }, - "indexedChainIds": [17000], - "namespace": "holesky", + "indexedChainIds": [11155111], + "namespace": "sepolia", "plugins": ["subgraph"], "databaseSchemaName": "DeserializationSchema0.34.0", "isSubgraphCompatible": true diff --git a/apps/ensadmin/src/components/chains/ChainIcon.tsx b/apps/ensadmin/src/components/chains/ChainIcon.tsx index c1c05938d..831138203 100644 --- a/apps/ensadmin/src/components/chains/ChainIcon.tsx +++ b/apps/ensadmin/src/components/chains/ChainIcon.tsx @@ -3,7 +3,6 @@ import { arbitrumSepolia, base, baseSepolia, - holesky, linea, lineaSepolia, mainnet, @@ -58,9 +57,6 @@ const chainIcons = new Map([ [ensTestEnvL1Chain.id, "Ethereum Local (ens-test-env)"], [mainnet.id, "Ethereum"], [sepolia.id, "Ethereum Sepolia"], - [holesky.id, "Ethereum Holesky"], [base.id, "Base"], [baseSepolia.id, "Base Sepolia"], [linea.id, "Linea"], @@ -84,8 +81,6 @@ export function getEnsManagerAppUrl(namespaceId: ENSNamespaceId): URL | null { return new URL(`https://app.ens.domains/`); case ENSNamespaceIds.Sepolia: return new URL(`https://sepolia.app.ens.domains/`); - case ENSNamespaceIds.Holesky: - return new URL(`https://holesky.app.ens.domains/`); case ENSNamespaceIds.EnsTestEnv: // ens-test-env runs on a local chain and is not supported by app.ens.domains return null; @@ -115,9 +110,6 @@ export function buildEnsMetadataServiceAvatarUrl( return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); case ENSNamespaceIds.Sepolia: return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); - case ENSNamespaceIds.Holesky: - // metadata.ens.domains doesn't currently support holesky - return null; case ENSNamespaceIds.EnsTestEnv: // ens-test-env runs on a local chain and is not supported by metadata.ens.domains // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 7e7d2e4e9..f5a2d230c 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -17,7 +17,7 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # 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, holesky, ens-test-env). This ENS Root Chain RPC +# on ENSIndexer's NAMESPACE (ex: mainnet, sepolia, ens-test-env). This ENS Root Chain RPC # is used to power the Resolution API, in situations where Protocol Acceleration is not possible. # # When ENSApi starts up it connects to the indicated ENSINDEXER_URL verifies that the ENS Root Chain @@ -89,6 +89,8 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # # Example (single HTTP RPC URL): # RPC_URL_1=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_11155111=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_1337=http://localhost:8545 # # Example (multiple HTTP RPC URL, single WebSocket RPC URL): # RPC_URL_1=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY,https://lb.drpc.org/ethereum/YOUR_API_KEY diff --git a/apps/ensapi/biome.jsonc b/apps/ensapi/biome.jsonc new file mode 100644 index 000000000..a9e9629ae --- /dev/null +++ b/apps/ensapi/biome.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", + "extends": "//", + "overrides": [ + // allow unused function parameters in pothos schema files due to resolve() pattern + { + "includes": ["./src/graphql-api/**/*.ts"], + "linter": { + "rules": { + "correctness": { + "noUnusedFunctionParameters": "off" + } + } + } + } + ] +} diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index d421d0f66..95d82bd73 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -40,10 +40,17 @@ "@opentelemetry/sdk-trace-base": "^2.0.1", "@opentelemetry/sdk-trace-node": "^2.0.1", "@opentelemetry/semantic-conventions": "^1.34.0", + "@ponder/client": "^0.14.13", "@ponder/utils": "catalog:", + "@pothos/core": "^4.10.0", + "@pothos/plugin-dataloader": "^4.4.3", + "@pothos/plugin-relay": "^4.6.2", "@standard-schema/utils": "^0.3.0", + "dataloader": "^2.2.3", "date-fns": "catalog:", "drizzle-orm": "catalog:", + "graphql": "^16.11.0", + "graphql-yoga": "^5.16.0", "hono": "catalog:", "hono-openapi": "^1.1.1", "p-memoize": "^8.0.0", diff --git a/apps/ensapi/src/graphql-api/builder.ts b/apps/ensapi/src/graphql-api/builder.ts new file mode 100644 index 000000000..e659cc071 --- /dev/null +++ b/apps/ensapi/src/graphql-api/builder.ts @@ -0,0 +1,42 @@ +import SchemaBuilder from "@pothos/core"; +import DataloaderPlugin from "@pothos/plugin-dataloader"; +import RelayPlugin from "@pothos/plugin-relay"; +import type { Address, Hex } from "viem"; + +import type { + ChainId, + CoinType, + DomainId, + ENSNamespaceId, + InterpretedName, + Node, + RegistryId, + ResolverId, +} from "@ensnode/ensnode-sdk"; + +export const builder = new SchemaBuilder<{ + Context: { + namespace: ENSNamespaceId; + now: bigint; + }; + Scalars: { + BigInt: { Input: bigint; Output: bigint }; + Address: { Input: Address; Output: Address }; + Hex: { Input: Hex; Output: Hex }; + ChainId: { Input: ChainId; Output: ChainId }; + CoinType: { Input: CoinType; Output: CoinType }; + Node: { Input: Node; Output: Node }; + Name: { Input: InterpretedName; Output: InterpretedName }; + DomainId: { Input: DomainId; Output: DomainId }; + RegistryId: { Input: RegistryId; Output: RegistryId }; + ResolverId: { Input: ResolverId; Output: ResolverId }; + // PermissionsId: { Input: PermissionsId; Output: PermissionsId }; + }; +}>({ + plugins: [DataloaderPlugin, RelayPlugin], + relay: { + // disable the Query.node & Query.nodes methods + nodeQueryOptions: false, + nodesQueryOptions: false, + }, +}); diff --git a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts new file mode 100644 index 000000000..87428b37b --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts @@ -0,0 +1,70 @@ +import config from "@/config"; + +import { sql } from "drizzle-orm"; + +import * as schema from "@ensnode/ensnode-schema"; +import { + type CanonicalPath, + type DomainId, + getENSv2RootRegistryId, + type RegistryId, +} from "@ensnode/ensnode-sdk"; + +import { db } from "@/lib/db"; + +const MAX_DEPTH = 16; +const ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace); + +/** + * Provide the canonical parents from the Root Registry to `domainId`. + * i.e. reverse traversal of the namegraph + * + * TODO: this implementation has undefined canonical name behavior, need to updated based on proposed + * reverse mapping + */ +export async function getCanonicalPath(domainId: DomainId): Promise { + const result = await db.execute(sql` + WITH RECURSIVE upward AS ( + -- Base case: start from the target domain + SELECT + d.id AS domain_id, + d.registry_id, + d.label_hash, + 1 AS depth + FROM ${schema.v2Domain} d + WHERE d.id = ${domainId} + + UNION ALL + + -- Step upward: domain -> registry -> parent domain + SELECT + pd.id AS domain_id, + pd.registry_id, + pd.label_hash, + upward.depth + 1 + FROM upward + JOIN ${schema.registry} r + ON r.id = upward.registry_id + JOIN ${schema.v2Domain} pd + ON pd.subregistry_id = r.id + WHERE r.id != ${ROOT_REGISTRY_ID} + AND upward.depth < ${MAX_DEPTH} + ) + SELECT * + FROM upward + ORDER BY depth; + `); + + const rows = result.rows as { domain_id: DomainId; registry_id: RegistryId }[]; + + if (rows.length === 0) { + throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' did not exist.`); + } + + const tld = rows[rows.length - 1]; + const isCanonical = tld.registry_id === ROOT_REGISTRY_ID; + + if (!isCanonical) return null; + + return rows.map((row) => row.domain_id); +} diff --git a/apps/ensapi/src/graphql-api/lib/get-domain-by-fqdn.ts b/apps/ensapi/src/graphql-api/lib/get-domain-by-fqdn.ts new file mode 100644 index 000000000..dc02aab27 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/get-domain-by-fqdn.ts @@ -0,0 +1,194 @@ +import config from "@/config"; + +import { getUnixTime } from "date-fns"; +import { Param, sql } from "drizzle-orm"; +import { labelhash, namehash } from "viem"; + +import { DatasourceNames, getDatasource } from "@ensnode/datasources"; +import * as schema from "@ensnode/ensnode-schema"; +import { + type DomainId, + type ENSv2DomainId, + ETH_NODE, + getENSv2RootRegistryId, + type InterpretedName, + interpretedLabelsToInterpretedName, + interpretedNameToInterpretedLabels, + interpretedNameToLabelHashPath, + isRegistrationFullyExpired, + type LabelHash, + type LiteralLabel, + labelhashLiteralLabel, + makeENSv1DomainId, + makeRegistryId, + makeSubdomainNode, + type RegistryId, +} from "@ensnode/ensnode-sdk"; + +import { getLatestRegistration } from "@/graphql-api/lib/get-latest-registration"; +import { db } from "@/lib/db"; + +const ensroot = getDatasource(config.namespace, DatasourceNames.ENSRoot); +const namechain = getDatasource(config.namespace, DatasourceNames.Namechain); + +const ETH_LABELHASH = labelhashLiteralLabel("eth" as LiteralLabel); + +const ROOT_REGISTRY_ID = getENSv2RootRegistryId(config.namespace); + +const ENS_ROOT_V2_ETH_REGISTRY_ID = makeRegistryId({ + chainId: ensroot.chain.id, + address: ensroot.contracts.ETHRegistry.address, +}); + +const NAMECHAIN_V2_ETH_REGISTRY_ID = makeRegistryId({ + chainId: namechain.chain.id, + address: namechain.contracts.ETHRegistry.address, +}); + +/** + * Gets the DomainId of the Domain addressed by `name`. + */ +export async function getDomainIdByInterpretedName( + name: InterpretedName, +): Promise { + // 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_getDomainIdByFqdn(name), + v2_getDomainIdByFqdn(ROOT_REGISTRY_ID, name), + ]); + + // prefer v2DomainId + return v2DomainId || v1DomainId || null; +} + +/** + * Retrieves the ENSv1DomainId for the provided `name`, if exists. + */ +async function v1_getDomainIdByFqdn(name: InterpretedName): Promise { + const node = namehash(name); + const domainId = makeENSv1DomainId(node); + + const domain = await db.query.v1Domain.findFirst({ where: (t, { eq }) => eq(t.id, domainId) }); + return domain?.id ?? null; +} + +/** + * Forward-traverses the ENSv2 namegraph in order to identify the Domain addressed by `name`. + * + * If the exact Domain was not found, and the path terminates at a bridging resolver, bridge to the + * indicated Registry and continue traversing. + */ +async function v2_getDomainIdByFqdn( + registryId: RegistryId, + name: InterpretedName, + { now } = { now: BigInt(getUnixTime(new Date())) }, +): Promise { + const labelHashPath = interpretedNameToLabelHashPath(name); + + // https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 + const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; + + // TODO: need to join latest registration and confirm that it's not expired, if expired should treat the domain as not existing + + const result = await db.execute(sql` + WITH RECURSIVE path AS ( + SELECT + r.id AS registry_id, + NULL::text AS domain_id, + NULL::text AS label_hash, + 0 AS depth + FROM ${schema.registry} r + WHERE r.id = ${registryId} + + UNION ALL + + SELECT + d.subregistry_id AS registry_id, + d.id AS domain_id, + d.label_hash, + path.depth + 1 + FROM path + JOIN ${schema.v2Domain} d + ON d.registry_id = path.registry_id + WHERE d.label_hash = (${rawLabelHashPathArray})[path.depth + 1] + AND path.depth + 1 <= array_length(${rawLabelHashPathArray}, 1) + ) + SELECT * + FROM path + WHERE domain_id IS NOT NULL + ORDER BY depth; + `); + + // couldn't for the life of me figure out how to type this result this correctly within drizzle... + const rows = result.rows as { + registry_id: RegistryId; + domain_id: ENSv2DomainId; + label_hash: LabelHash; + depth: number; + }[]; + + // this was a query for a TLD and it does not exist in ENS Root Chain ENSv2 + if (rows.length === 0) return null; + + // biome-ignore lint/style/noNonNullAssertion: length check above + const leaf = rows[rows.length - 1]!; + + // we have an exact match within ENSv2 on the ENS Root Chain + const exact = rows.length === labelHashPath.length; + if (exact) { + console.log(`Found '${name}' in ENSv2 from Registry ${registryId}`); + return leaf.domain_id; + } + + console.log(name); + console.log(JSON.stringify(rows, null, 2)); + + // we did not find an exact match for the Domain within ENSv2 on the ENS Root Chain + // if the path terminates at the .eth Registry, we must implement the logic in ETHTLDResolver + // TODO: we could add an additional invariant that the .eth v2 Registry does indeed have the ETHTLDResolver + // set as its resolver, but that is unnecessary at the moment and incurs additional db requests or a join against + // domain_resolver_relationships + // TODO: generalize this into other future bridging resolvers depending on how basenames etc do it + if (leaf.registry_id === ENS_ROOT_V2_ETH_REGISTRY_ID) { + // Invariant: must be >= 2LD + if (labelHashPath.length < 2) { + throw new Error(`Invariant: Not >= 2LD??`); + } + + // Invariant: must be a .eth subname + if (labelHashPath[0] !== ETH_LABELHASH) { + throw new Error(`Invariant: Not .eth subname????`); + } + + // Invariant: must be a .eth subname + if (leaf.label_hash !== labelhash("eth")) { + throw new Error(`Invariant: Not .eth subname??`); + } + + // construct the node of the 2ld + const dotEth2LDNode = makeSubdomainNode(labelHashPath[1], ETH_NODE); + + // 1. if there's an active registration in ENSv1 for the .eth 2LD, then resolve from ENSv1 + const ensv1DomainId = makeENSv1DomainId(dotEth2LDNode); + const registration = await getLatestRegistration(ensv1DomainId); + + if (registration && !isRegistrationFullyExpired(registration, now)) { + console.log( + `ETHTLDResolver deferring to actively registered name ${dotEth2LDNode} in ENSv1...`, + ); + return await v1_getDomainIdByFqdn(name); + } + + // 2. otherwise, direct to Namechain ENSv2 .eth Registry + const nameWithoutTld = interpretedLabelsToInterpretedName( + interpretedNameToInterpretedLabels(name).slice(0, -1), + ); + console.log( + `ETHTLDResolver deferring ${nameWithoutTld} to ENSv2 .eth Registry on Namechain...`, + ); + return v2_getDomainIdByFqdn(NAMECHAIN_V2_ETH_REGISTRY_ID, nameWithoutTld); + } + + // finally, not found + return null; +} diff --git a/apps/ensapi/src/graphql-api/lib/get-domain-resolver.ts b/apps/ensapi/src/graphql-api/lib/get-domain-resolver.ts new file mode 100644 index 000000000..2913d22a7 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/get-domain-resolver.ts @@ -0,0 +1,12 @@ +import type { DomainId } from "@ensnode/ensnode-sdk"; + +import { db } from "@/lib/db"; + +export async function getDomainResolver(domainId: DomainId) { + const drr = await db.query.domainResolverRelation.findFirst({ + where: (t, { eq }) => eq(t.domainId, domainId), + with: { resolver: true }, + }); + + return drr?.resolver; +} diff --git a/apps/ensapi/src/graphql-api/lib/get-latest-registration.ts b/apps/ensapi/src/graphql-api/lib/get-latest-registration.ts new file mode 100644 index 000000000..6706ee575 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/get-latest-registration.ts @@ -0,0 +1,13 @@ +import type { DomainId } from "@ensnode/ensnode-sdk"; + +import { db } from "@/lib/db"; + +/** + * Gets the latest Registration entity for Domain `domainId`. + */ +export async function getLatestRegistration(domainId: DomainId) { + return await db.query.registration.findFirst({ + where: (t, { eq }) => eq(t.domainId, domainId), + orderBy: (t, { desc }) => desc(t.index), + }); +} diff --git a/apps/ensapi/src/graphql-api/lib/get-model-id.ts b/apps/ensapi/src/graphql-api/lib/get-model-id.ts new file mode 100644 index 000000000..e03df509f --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/get-model-id.ts @@ -0,0 +1,4 @@ +/** + * Simple type-safe accessor for *.id for use with Dataloader. + */ +export const getModelId = (model: T): ID => model.id; diff --git a/apps/ensapi/src/graphql-api/lib/reject-any-errors.ts b/apps/ensapi/src/graphql-api/lib/reject-any-errors.ts new file mode 100644 index 000000000..ccf310841 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/reject-any-errors.ts @@ -0,0 +1,20 @@ +/** + * Given a Promise<(Error | T)[]>, throws with the first Error, if any. + * + * @throws The first Error encountered in `promise`, if any. + * @dev This is useful for making manual Dataloaded arrays conform to T[]. + * @example return rejectAnyErrors(SomeLoadableRef.getDataloader(context).load(ids)) + */ +export async function rejectAnyErrors( + promise: Promise, +): Promise { + const values = await promise; + + for (const element of values) { + if (element instanceof Error) { + throw element; + } + } + + return values as readonly T[]; +} diff --git a/apps/ensapi/src/graphql-api/schema.ts b/apps/ensapi/src/graphql-api/schema.ts new file mode 100644 index 000000000..50b689269 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema.ts @@ -0,0 +1,10 @@ +import { builder } from "@/graphql-api/builder"; + +import "./schema/account-id"; +import "./schema/domain"; +import "./schema/permissions"; +import "./schema/query"; +import "./schema/registry"; +import "./schema/scalars"; + +export const schema = builder.toSchema(); diff --git a/apps/ensapi/src/graphql-api/schema/account-id.ts b/apps/ensapi/src/graphql-api/schema/account-id.ts new file mode 100644 index 000000000..1376dd489 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/account-id.ts @@ -0,0 +1,20 @@ +import type { AccountId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; + +export const AccountIdRef = builder.objectRef("AccountId"); +AccountIdRef.implement({ + description: "A CAIP-10 Account ID including chainId and address.", + fields: (t) => ({ + chainId: t.expose("chainId", { type: "ChainId" }), + address: t.expose("address", { type: "Address" }), + }), +}); + +export const AccountIdInput = builder.inputType("AccountIdInput", { + description: "A CAIP-10 Account ID including chainId and address.", + fields: (t) => ({ + chainId: t.field({ type: "ChainId", required: true }), + address: t.field({ type: "Address", required: true }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/account-registries-permissions.ts b/apps/ensapi/src/graphql-api/schema/account-registries-permissions.ts new file mode 100644 index 000000000..1402063af --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/account-registries-permissions.ts @@ -0,0 +1,51 @@ +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: "TODO", + type: RegistryRef, + nullable: false, + resolve: (parent) => parent.registry, + }), + + /////////////////////////////////////// + // AccountRegistryPermissions.resource + /////////////////////////////////////// + resource: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.permissionsUser.resource, + }), + + //////////////////////////////////// + // AccountRegistryPermissions.roles + //////////////////////////////////// + roles: t.field({ + description: "TODO", + 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 new file mode 100644 index 000000000..b0e37b884 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/account-resolver-permissions.ts @@ -0,0 +1,51 @@ +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: "TODO", + type: ResolverRef, + nullable: false, + resolve: (parent) => parent.resolver, + }), + + /////////////////////////////////////// + // AccountResolverPermissions.resource + /////////////////////////////////////// + resource: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.permissionsUser.resource, + }), + + //////////////////////////////////// + // AccountResolverPermissions.roles + //////////////////////////////////// + roles: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.permissionsUser.roles, + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts new file mode 100644 index 000000000..61cf6060c --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -0,0 +1,226 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and, asc, desc, eq, gt, lt } from "drizzle-orm"; +import { unionAll } from "drizzle-orm/pg-core"; +import type { Address } from "viem"; + +import * as schema from "@ensnode/ensnode-schema"; +import type { PermissionsUserId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; +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 { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { DomainInterfaceRef } from "@/graphql-api/schema/domain"; +import { PermissionsUserRef } from "@/graphql-api/schema/permissions"; +import { db } from "@/lib/db"; + +export const AccountRef = builder.loadableObjectRef("Account", { + load: (ids: Address[]) => + db.query.account.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Account = Exclude; + +/////////// +// Account +/////////// +AccountRef.implement({ + description: "TODO", + fields: (t) => ({ + ////////////// + // Account.id + ////////////// + id: t.field({ + description: "TODO", + type: "Address", + nullable: false, + resolve: (parent) => parent.id, + }), + + /////////////////// + // Account.address + /////////////////// + address: t.field({ + description: "TODO", + type: "Address", + nullable: false, + resolve: (parent) => parent.id, + }), + + /////////////////// + // Account.domains + /////////////////// + domains: t.connection({ + description: "TODO", + type: DomainInterfaceRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + const v1Domains = db + .select({ id: schema.v1Domain.id }) + .from(schema.v1Domain) + .where(eq(schema.v1Domain.ownerId, parent.id)) + .leftJoin(schema.label, eq(schema.v1Domain.labelHash, schema.label.labelHash)); + + const v2Domains = db + .select({ id: schema.v2Domain.id }) + .from(schema.v2Domain) + .where(eq(schema.v2Domain.ownerId, parent.id)) + .leftJoin(schema.label, eq(schema.v2Domain.labelHash, schema.label.labelHash)); + + // use any to ignore id column type mismatch (ENSv1DomainId & ENSv2DomainId) + const domains = db.$with("domains").as(unionAll(v1Domains, v2Domains as any)); + + const results = await db + .with(domains) + .select() + .from(domains) + .where( + and( + ...[ + // NOTE: using any because drizzle infers id as ENSv1DomainId + before && lt(domains.id, cursors.decode(before)), + after && gt(domains.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + ) + .orderBy(inverted ? desc(domains.id) : asc(domains.id)) + .limit(limit); + + return rejectAnyErrors( + DomainInterfaceRef.getDataloader(context).loadMany( + results.map((result) => result.id), + ), + ); + }, + ), + }), + + /////////////////////// + // Account.permissions + /////////////////////// + permissions: t.connection({ + description: "TODO", + type: PermissionsUserRef, + args: { + in: t.arg({ type: AccountIdInput }), + }, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.permissionsUser.findMany({ + where: (t, { lt, gt, and, eq }) => + and( + ...[ + // this user's permissions + eq(t.user, parent.id), + // optionally filtered by contract + args.in && and(eq(t.chainId, args.in.chainId), eq(t.address, args.in.address)), + // optionall filtered by cursor + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + }), + ), + }), + + /////////////////////////////// + // Account.registryPermissions + /////////////////////////////// + // TODO: this returns all permissions in a registry, perhaps can provide api for non-token resources... + registryPermissions: t.connection({ + description: "TODO", + type: AccountRegistryPermissionsRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + const results = await db + .select({ + permissionsUser: schema.permissionsUser, + registry: schema.registry, + }) + .from(schema.permissionsUser) + .innerJoin( + schema.registry, + and( + eq(schema.permissionsUser.chainId, schema.registry.chainId), + eq(schema.permissionsUser.address, schema.registry.address), + ), + ) + .where( + and( + ...[ + eq(schema.permissionsUser.user, parent.id), + before !== undefined && + lt(schema.permissionsUser.id, cursors.decode(before)), + after !== undefined && + gt(schema.permissionsUser.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + ) + .orderBy(inverted ? desc(schema.permissionsUser.id) : asc(schema.permissionsUser.id)) + .limit(limit); + + return results.map((result) => ({ id: result.permissionsUser.id, ...result })); + }, + ), + }), + + /////////////////////////////// + // Account.resolverPermissions + /////////////////////////////// + resolverPermissions: t.connection({ + description: "TODO", + type: AccountResolverPermissionsRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + const results = await db + .select({ + permissionsUser: schema.permissionsUser, + resolver: schema.resolver, + }) + .from(schema.permissionsUser) + .innerJoin( + schema.resolver, + and( + eq(schema.permissionsUser.chainId, schema.resolver.chainId), + eq(schema.permissionsUser.address, schema.resolver.address), + ), + ) + .where( + and( + ...[ + eq(schema.permissionsUser.user, parent.id), + before !== undefined && + lt(schema.permissionsUser.id, cursors.decode(before)), + after !== undefined && + gt(schema.permissionsUser.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + ) + .orderBy(inverted ? desc(schema.permissionsUser.id) : asc(schema.permissionsUser.id)) + .limit(limit); + + return results.map((result) => ({ id: result.permissionsUser.id, ...result })); + }, + ), + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/constants.ts b/apps/ensapi/src/graphql-api/schema/constants.ts new file mode 100644 index 000000000..9af5c8cab --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/constants.ts @@ -0,0 +1,11 @@ +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { cursors } from "@/graphql-api/schema/cursors"; + +/** + * Default Connection field arguments for use with the Relay plugin. + */ +export const DEFAULT_CONNECTION_ARGS = { + toCursor: (model: T) => cursors.encode(getModelId(model)), + defaultSize: 100, + maxSize: 1000, +} as const; diff --git a/apps/ensapi/src/graphql-api/schema/cursors.ts b/apps/ensapi/src/graphql-api/schema/cursors.ts new file mode 100644 index 000000000..6545bf53c --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/cursors.ts @@ -0,0 +1,8 @@ +/** + * 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.ts b/apps/ensapi/src/graphql-api/schema/domain.ts new file mode 100644 index 000000000..9fefd7014 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -0,0 +1,328 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; + +import { + type DomainId, + type ENSv1DomainId, + type ENSv2DomainId, + getCanonicalId, + type RegistrationId, +} from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +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 { AccountRef } from "@/graphql-api/schema/account"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { RegistrationInterfaceRef } from "@/graphql-api/schema/registration"; +import { RegistryRef } from "@/graphql-api/schema/registry"; +import { ResolverRef } from "@/graphql-api/schema/resolver"; +import { db } from "@/lib/db"; + +const isENSv1Domain = (domain: Domain): domain is ENSv1Domain => "parentId" in domain; + +///////////////////////////// +// ENSv1Domain & ENSv2Domain +///////////////////////////// + +export const ENSv1DomainRef = builder.loadableObjectRef("ENSv1Domain", { + load: (ids: ENSv1DomainId[]) => + db.query.v1Domain.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + with: { label: true }, + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export const ENSv2DomainRef = builder.loadableObjectRef("ENSv2Domain", { + load: (ids: ENSv2DomainId[]) => + db.query.v2Domain.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + with: { label: true }, + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export const DomainInterfaceRef = builder.loadableInterfaceRef("Domain", { + load: async (ids: DomainId[]): Promise<(ENSv1Domain | ENSv2Domain)[]> => { + const [v1Domains, v2Domains] = await Promise.all([ + db.query.v1Domain.findMany({ + where: (t, { inArray }) => inArray(t.id, ids as any), // ignore downcast to ENSv1DomainId + with: { label: true }, + }), + db.query.v2Domain.findMany({ + where: (t, { inArray }) => inArray(t.id, ids as any), // ignore downcast to ENSv2DomainId + with: { label: true }, + }), + ]); + + return [...v1Domains, ...v2Domains]; + }, + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type ENSv1Domain = Exclude; +export type ENSv2Domain = Exclude; +export type Domain = Exclude; + +////////////////////////////////// +// DomainInterface Implementation +////////////////////////////////// +DomainInterfaceRef.implement({ + description: "a Domain", + fields: (t) => ({ + ////////////////////// + // Domain.id + ////////////////////// + id: t.field({ + description: "TODO", + type: "DomainId", + nullable: false, + resolve: (parent) => parent.id, + }), + + ////////////////////// + // Domain.label + ////////////////////// + label: t.field({ + type: "String", + description: "TODO", + nullable: false, + resolve: async ({ label }) => label.value, + }), + + //////////////////// + // Domain.canonical + //////////////////// + // TODO: pending ENS team canonicalName implementation + // canonical: t.field({ + // description: "TODO", + // type: "Name", + // nullable: true, + // resolve: async ({ id }, args, context) => { + // // TODO: dataloader the getCanonicalPath(domainId) function + // const canonicalPath = await getCanonicalPath(id); + // if (!canonicalPath) return null; + + // const domains = await rejectAnyErrors( + // DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), + // ); + + // return interpretedLabelsToInterpretedName( + // canonicalPath.map((domainId) => { + // const found = domains.find((d) => d.id === domainId); + // if (!found) throw new Error(`Invariant`); + // return found.label.value; + // }), + // ); + // }, + // }), + + ////////////////// + // Domain.parents + ////////////////// + // TODO: pending ENS team canonicalName implementation + // parents: t.field({ + // description: "TODO", + // type: [DomainInterfaceRef], + // nullable: true, + // resolve: async ({ id }, args, context) => { + // // TODO: dataloader the getCanonicalPath(domainId) function + // const canonicalPath = await getCanonicalPath(id); + // if (!canonicalPath) return null; + + // const domains = await rejectErrors( + // DomainInterfaceRef.getDataloader(context).loadMany(canonicalPath), + // ); + + // return domains.slice(1); + // }, + // }), + + ////////////////// + // Domain.aliases + ////////////////// + // TODO: pending ENS team canonicalName implementation, maybe impossible to implement + // aliases: t.field({ + // description: "TODO", + // type: ["Name"], + // nullable: false, + // resolve: async (parent) => { + // // a domain's aliases are all of the paths from root to this domain for which it can be + // // resolved. naively reverse-traverse the namegaph until the root is reached... yikes. + // return []; + // }, + // }), + + ////////////////////// + // Domain.owner + ////////////////////// + owner: t.field({ + type: AccountRef, + description: "TODO", + nullable: true, + resolve: (parent) => parent.ownerId, + }), + + ////////////////////// + // Domain.resolver + ////////////////////// + resolver: t.field({ + description: "TODO", + type: ResolverRef, + nullable: true, + resolve: (parent) => getDomainResolver(parent.id), + }), + + /////////////////////// + // Domain.registration + /////////////////////// + registration: t.field({ + description: "TODO", + type: RegistrationInterfaceRef, + nullable: true, + resolve: (parent) => getLatestRegistration(parent.id), + }), + + //////////////////////// + // Domain.registrations + //////////////////////// + registrations: t.connection({ + description: "TODO", + type: RegistrationInterfaceRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.registration.findMany({ + where: (t, { lt, gt, and, eq }) => + and( + ...[ + eq(t.domainId, parent.id), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? asc(t.index) : desc(t.index)), + limit, + }), + ), + }), + }), +}); + +////////////////////////////// +// ENSv1Domain Implementation +////////////////////////////// +ENSv1DomainRef.implement({ + description: "TODO", + interfaces: [DomainInterfaceRef], + isTypeOf: (domain) => isENSv1Domain(domain as Domain), + fields: (t) => ({ + ////////////////////// + // ENSv1Domain.parent + ////////////////////// + parent: t.field({ + description: "TODO", + type: ENSv1DomainRef, + nullable: true, + resolve: (parent) => parent.parentId, + }), + + //////////////////////// + // ENSv1Domain.children + //////////////////////// + children: t.connection({ + description: "TODO", + type: ENSv1DomainRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.v1Domain.findMany({ + where: (t, { lt, gt, and, eq }) => + and( + ...[ + eq(t.parentId, parent.id), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + with: { label: true }, + }), + ), + }), + }), +}); + +////////////////////////////// +// ENSv2Domain Implementation +////////////////////////////// +ENSv2DomainRef.implement({ + description: "TODO", + interfaces: [DomainInterfaceRef], + isTypeOf: (domain) => !isENSv1Domain(domain as Domain), + fields: (t) => ({ + ////////////////////// + // Domain.canonicalId + ////////////////////// + canonicalId: t.field({ + type: "BigInt", + description: "TODO", + nullable: false, + resolve: (parent) => getCanonicalId(parent.tokenId), + }), + + ////////////////////// + // Domain.tokenId + ////////////////////// + tokenId: t.field({ + type: "BigInt", + description: "TODO", + nullable: false, + resolve: (parent) => parent.tokenId, + }), + + ////////////////////// + // Domain.registry + ////////////////////// + registry: t.field({ + description: "TODO", + type: RegistryRef, + nullable: false, + resolve: (parent) => parent.registryId, + }), + + ////////////////////// + // Domain.subregistry + ////////////////////// + subregistry: t.field({ + type: RegistryRef, + description: "TODO", + nullable: true, + resolve: (parent) => parent.subregistryId, + }), + }), +}); + +////////////////////// +// Inputs +////////////////////// + +export const DomainIdInput = builder.inputType("DomainIdInput", { + description: "TODO", + isOneOf: true, + fields: (t) => ({ + name: t.field({ type: "Name" }), + id: t.field({ type: "DomainId" }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/name-or-node.ts b/apps/ensapi/src/graphql-api/schema/name-or-node.ts new file mode 100644 index 000000000..a52714e16 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/name-or-node.ts @@ -0,0 +1,13 @@ +import { builder } from "@/graphql-api/builder"; + +/** + * Input that requires one of `name` or `node`. + */ +export const NameOrNodeInput = builder.inputType("NameOrNodeInput", { + description: "TODO", + isOneOf: true, + fields: (t) => ({ + name: t.field({ type: "Name" }), + node: t.field({ type: "Node" }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/permissions.ts b/apps/ensapi/src/graphql-api/schema/permissions.ts new file mode 100644 index 000000000..b86f6e5cc --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/permissions.ts @@ -0,0 +1,230 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; + +import { + makePermissionsId, + makePermissionsResourceId, + type PermissionsId, + type PermissionsResourceId, + type PermissionsUserId, + ROOT_RESOURCE, +} from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { AccountRef } from "@/graphql-api/schema/account"; +import { AccountIdRef } from "@/graphql-api/schema/account-id"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { db } from "@/lib/db"; + +export const PermissionsRef = builder.loadableObjectRef("Permissions", { + load: (ids: PermissionsId[]) => + db.query.permissions.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export const PermissionsResourceRef = builder.loadableObjectRef("PermissionsResource", { + load: (ids: PermissionsResourceId[]) => + db.query.permissionsResource.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export const PermissionsUserRef = builder.loadableObjectRef("PermissionsUser", { + load: (ids: PermissionsUserId[]) => + db.query.permissionsUser.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Permissions = Exclude; +export type PermissionsResource = Exclude< + typeof PermissionsResourceRef.$inferType, + PermissionsResourceId +>; +export type PermissionsUserResource = Exclude< + typeof PermissionsUserRef.$inferType, + PermissionsUserId +>; + +/////////////// +// Permissions +/////////////// +PermissionsRef.implement({ + description: "Permissions", + fields: (t) => ({ + //////////////////////////// + // Permissions.id + //////////////////////////// + id: t.field({ + description: "TODO", + type: "ID", + nullable: false, + resolve: (parent) => parent.id, + }), + + //////////////////////// + // Permissions.contract + //////////////////////// + contract: t.field({ + description: "TODO", + type: AccountIdRef, + nullable: false, + resolve: ({ chainId, address }) => ({ chainId, address }), + }), + + //////////////////// + // Permissions.root + //////////////////// + root: t.field({ + description: "TODO", + type: PermissionsResourceRef, + nullable: false, + resolve: ({ chainId, address }) => + makePermissionsResourceId({ chainId, address }, ROOT_RESOURCE), + }), + + ///////////////////////// + // Permissions.resources + ///////////////////////// + resources: t.connection({ + description: "TODO", + type: PermissionsResourceRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.permissionsResource.findMany({ + where: (t, { lt, gt, eq, and }) => + and( + ...[ + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + }), + ), + }), + }), +}); + +/////////////////////// +// PermissionsResource +/////////////////////// +PermissionsResourceRef.implement({ + description: "PermissionsResource", + fields: (t) => ({ + //////////////////////////// + // PermissionsResource.id + //////////////////////////// + id: t.field({ + description: "TODO", + type: "ID", + nullable: false, + resolve: (parent) => parent.id, + }), + + /////////////////////////////////// + // PermissionsResource.permissions + /////////////////////////////////// + permissions: t.field({ + description: "TODO", + type: PermissionsRef, + nullable: false, + resolve: ({ chainId, address }) => makePermissionsId({ chainId, address }), + }), + + //////////////////////////////// + // PermissionsResource.resource + //////////////////////////////// + resource: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.resource, + }), + + ///////////////////////////// + // PermissionsResource.users + ///////////////////////////// + users: t.connection({ + description: "TODO", + type: PermissionsUserRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.permissionsUser.findMany({ + where: (t, { lt, gt, eq, and }) => + and( + ...[ + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + eq(t.resource, parent.resource), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + }), + ), + }), + }), +}); + +/////////////////// +// PermissionsUser +/////////////////// +PermissionsUserRef.implement({ + description: "PermissionsUser", + fields: (t) => ({ + //////////////////////////// + // PermissionsUser.id + //////////////////////////// + id: t.field({ + description: "TODO", + type: "ID", + nullable: false, + resolve: (parent) => parent.id, + }), + + //////////////////////////// + // PermissionsUser.resource + //////////////////////////// + resource: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.resource, + }), + + //////////////////////// + // PermissionsUser.user + //////////////////////// + user: t.field({ + description: "TODO", + type: AccountRef, + nullable: false, + resolve: (parent) => parent.user, + }), + + ///////////////////////// + // PermissionsUser.roles + ///////////////////////// + roles: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.roles, + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts new file mode 100644 index 000000000..95bb8bf0d --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -0,0 +1,207 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; + +import { + type ENSv1DomainId, + type ENSv2DomainId, + getENSv2RootRegistryId, + makePermissionsId, + makeRegistryId, + makeResolverId, + type RegistrationId, + type ResolverId, +} from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getDomainIdByInterpretedName } from "@/graphql-api/lib/get-domain-by-fqdn"; +import { AccountRef } from "@/graphql-api/schema/account"; +import { AccountIdInput } from "@/graphql-api/schema/account-id"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { + DomainIdInput, + DomainInterfaceRef, + ENSv1DomainRef, + ENSv2DomainRef, +} from "@/graphql-api/schema/domain"; +import { PermissionsRef } from "@/graphql-api/schema/permissions"; +import { RegistrationInterfaceRef } from "@/graphql-api/schema/registration"; +import { RegistryIdInput, RegistryRef } from "@/graphql-api/schema/registry"; +import { ResolverIdInput, ResolverRef } from "@/graphql-api/schema/resolver"; +import { db } from "@/lib/db"; + +// don't want them to get familiar/accustom to these methods until their necessity is certain +const INCLUDE_DEV_METHODS = process.env.NODE_ENV !== "production"; + +builder.queryType({ + fields: (t) => ({ + ...(INCLUDE_DEV_METHODS && { + ///////////////////////////// + // Query.v1Domains (Testing) + ///////////////////////////// + v1Domains: t.connection({ + description: "TODO", + type: ENSv1DomainRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.v1Domain.findMany({ + where: (t, { lt, gt, and }) => + and( + ...[ + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + with: { label: true }, + }), + ), + }), + + ///////////////////////////// + // Query.v2Domains (Testing) + ///////////////////////////// + v2Domains: t.connection({ + description: "TODO", + type: ENSv2DomainRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.v2Domain.findMany({ + where: (t, { lt, gt, and }) => + and( + ...[ + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + with: { label: true }, + }), + ), + }), + + ///////////////////////////// + // Query.resolvers (Testing) + ///////////////////////////// + resolvers: t.connection({ + description: "TODO", + type: ResolverRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.resolver.findMany({ + where: (t, { lt, gt, and }) => + and( + ...[ + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + }), + ), + }), + + ///////////////////////////////// + // Query.registrations (Testing) + ///////////////////////////////// + registrations: t.connection({ + description: "TODO", + type: RegistrationInterfaceRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.registration.findMany({ + where: (t, { lt, gt, and }) => + and( + ...[ + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + }), + ), + }), + }), + + ////////////////////////////////// + // Get Domain by Name or DomainId + ////////////////////////////////// + domain: t.field({ + description: "TODO", + type: DomainInterfaceRef, + args: { by: t.arg({ type: DomainIdInput, required: true }) }, + nullable: true, + resolve: async (parent, args, ctx, info) => { + if (args.by.id !== undefined) return args.by.id; + return getDomainIdByInterpretedName(args.by.name); + }, + }), + + ////////////////////////// + // Get Account by address + ////////////////////////// + account: t.field({ + description: "TODO", + type: AccountRef, + args: { address: t.arg({ type: "Address", required: true }) }, + resolve: async (parent, args, context, info) => args.address, + }), + + /////////////////////////////////// + // Get Registry by Id or AccountId + /////////////////////////////////// + registry: t.field({ + description: "TODO", + type: RegistryRef, + args: { by: t.arg({ type: RegistryIdInput, required: true }) }, + resolve: async (parent, args, context, info) => { + if (args.by.id !== undefined) return args.by.id; + return makeRegistryId(args.by.contract); + }, + }), + + /////////////////////////////////// + // Get Resolver by Id or AccountId + /////////////////////////////////// + resolver: t.field({ + description: "TODO", + type: ResolverRef, + args: { by: t.arg({ type: ResolverIdInput, required: true }) }, + resolve: async (parent, args, context, info) => { + if (args.by.id !== undefined) return args.by.id; + return makeResolverId(args.by.contract); + }, + }), + + /////////////////////////////// + // Get Permissions by Contract + /////////////////////////////// + permissions: t.field({ + description: "TODO", + type: PermissionsRef, + args: { for: t.arg({ type: AccountIdInput, required: true }) }, + resolve: (parent, args, context, info) => makePermissionsId(args.for), + }), + + ///////////////////// + // Get Root Registry + ///////////////////// + root: t.field({ + description: "TODO", + type: RegistryRef, + nullable: false, + resolve: (parent, args, context) => getENSv2RootRegistryId(context.namespace), + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/registration.ts b/apps/ensapi/src/graphql-api/schema/registration.ts new file mode 100644 index 000000000..d8333b78c --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/registration.ts @@ -0,0 +1,256 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; + +import { + isRegistrationFullyExpired, + isRegistrationInGracePeriod, + type RegistrationId, + type RenewalId, + type RequiredAndNotNull, +} from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { AccountIdRef } from "@/graphql-api/schema/account-id"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { DomainInterfaceRef } from "@/graphql-api/schema/domain"; +import { RenewalRef } from "@/graphql-api/schema/renewal"; +import { WrappedBaseRegistrarRegistrationRef } from "@/graphql-api/schema/wrapped-baseregistrar-registration"; +import { db } from "@/lib/db"; + +export const RegistrationInterfaceRef = builder.loadableInterfaceRef("Registration", { + load: (ids: RegistrationId[]) => + db.query.registration.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Registration = Exclude; +export type RegistrationInterface = Pick< + Registration, + | "id" + | "type" + | "index" + | "domainId" + | "start" + | "expiry" + | "registrarChainId" + | "registrarAddress" + | "registrantId" + | "referrer" +>; +export type NameWrapperRegistration = RequiredAndNotNull; +export type BaseRegistrarRegistration = RequiredAndNotNull< + Registration, + "gracePeriod" | "wrapped" | "fuses" +> & { + baseCost: bigint | null; + premium: bigint | null; +}; +export type ThreeDNSRegistration = Registration; +export type ENSv2RegistryRegistration = Registration; + +RegistrationInterfaceRef.implement({ + description: "TODO", + fields: (t) => ({ + ////////////////////// + // Registration.id + ////////////////////// + id: t.field({ + description: "TODO", + type: "ID", + nullable: false, + resolve: (parent) => parent.id, + }), + + /////////////////////// + // Registration.domain + /////////////////////// + domain: t.field({ + description: "TODO", + type: DomainInterfaceRef, + nullable: false, + resolve: (parent) => parent.domainId, + }), + + ////////////////////////// + // Registration.registrar + ////////////////////////// + registrar: t.field({ + description: "TODO", + type: AccountIdRef, + nullable: false, + resolve: (parent) => ({ chainId: parent.registrarChainId, address: parent.registrarAddress }), + }), + + ////////////////////// + // Registration.start + ////////////////////// + start: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.start, + }), + + /////////////////////////// + // Registration.expiry + /////////////////////////// + expiry: t.field({ + description: "TODO", + type: "BigInt", + nullable: true, + resolve: (parent) => parent.expiry, + }), + + //////////////////////// + // Registration.expired + //////////////////////// + expired: t.field({ + description: "TODO", + type: "Boolean", + nullable: false, + resolve: (parent, args, context) => isRegistrationFullyExpired(parent, context.now), + }), + + ///////////////////////// + // Registration.referrer + ///////////////////////// + referrer: t.field({ + description: "TODO", + type: "Hex", + nullable: true, + resolve: (parent) => parent.referrer, + }), + + ///////////////////////// + // Registration.renewals + ///////////////////////// + renewals: t.connection({ + description: "TODO", + type: RenewalRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.renewal.findMany({ + where: (t, { lt, gt, and }) => + and( + ...[ + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + }), + ), + }), + }), +}); + +/////////////////////////// +// NameWrapperRegistration +/////////////////////////// +export const NameWrapperRegistrationRef = + builder.objectRef("NameWrapperRegistration"); +NameWrapperRegistrationRef.implement({ + description: "TODO", + interfaces: [RegistrationInterfaceRef], + isTypeOf: (value) => (value as RegistrationInterface).type === "NameWrapper", + fields: (t) => ({ + ///////////////////////////////// + // NameWrapperRegistration.fuses + ///////////////////////////////// + fuses: t.field({ + description: "TODO", + type: "Int", + nullable: false, + // TODO: decode/render Fuses enum + resolve: (parent) => parent.fuses, + }), + }), +}); + +///////////////////////////// +// BaseRegistrarRegistration +///////////////////////////// +export const BaseRegistrarRegistrationRef = builder.objectRef( + "BaseRegistrarRegistration", +); +BaseRegistrarRegistrationRef.implement({ + description: "TODO", + interfaces: [RegistrationInterfaceRef], + isTypeOf: (value) => (value as RegistrationInterface).type === "BaseRegistrar", + fields: (t) => ({ + ////////////////////////////////////// + // BaseRegistrarRegistration.baseCost + ////////////////////////////////////// + baseCost: t.field({ + description: "TODO", + type: "BigInt", + nullable: true, + resolve: (parent) => parent.baseCost, + }), + + ///////////////////////////////////// + // BaseRegistrarRegistration.premium + ///////////////////////////////////// + premium: t.field({ + description: "TODO", + type: "BigInt", + nullable: true, + resolve: (parent) => parent.premium, + }), + + ///////////////////////////////////// + // BaseRegistrarRegistration.wrapped + ///////////////////////////////////// + wrapped: t.field({ + description: "TODO", + type: WrappedBaseRegistrarRegistrationRef, + nullable: true, + resolve: (parent) => (parent.wrapped ? parent : null), + }), + + //////////////////////////////// + // Registration.isInGracePeriod + //////////////////////////////// + isInGracePeriod: t.field({ + description: "TODO", + type: "Boolean", + nullable: false, + resolve: (parent, args, context) => isRegistrationInGracePeriod(parent, context.now), + }), + }), +}); + +//////////////////////// +// ThreeDNSRegistration +//////////////////////// +export const ThreeDNSRegistrationRef = + builder.objectRef("ThreeDNSRegistration"); +ThreeDNSRegistrationRef.implement({ + description: "TODO", + interfaces: [RegistrationInterfaceRef], + isTypeOf: (value) => (value as RegistrationInterface).type === "ThreeDNS", + fields: (t) => ({ + // + }), +}); + +///////////////////////////// +// ENSv2RegistryRegistration +///////////////////////////// +export const ENSv2RegistryRegistrationRef = builder.objectRef( + "ENSv2RegistryRegistration", +); +ENSv2RegistryRegistrationRef.implement({ + description: "TODO", + interfaces: [RegistrationInterfaceRef], + isTypeOf: (value) => (value as RegistrationInterface).type === "ENSv2Registry", + fields: (t) => ({}), +}); diff --git a/apps/ensapi/src/graphql-api/schema/registry.ts b/apps/ensapi/src/graphql-api/schema/registry.ts new file mode 100644 index 000000000..8eb66e6bf --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/registry.ts @@ -0,0 +1,122 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; + +import { type ENSv2DomainId, makePermissionsId, type RegistryId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { AccountIdInput, AccountIdRef } from "@/graphql-api/schema/account-id"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { ENSv2DomainRef } from "@/graphql-api/schema/domain"; +import { PermissionsRef } from "@/graphql-api/schema/permissions"; +import { db } from "@/lib/db"; + +export const RegistryRef = builder.loadableObjectRef("Registry", { + load: (ids: RegistryId[]) => + db.query.registry.findMany({ where: (t, { inArray }) => inArray(t.id, ids) }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Registry = Exclude; + +RegistryRef.implement({ + description: "TODO", + fields: (t) => ({ + ////////////////////// + // Registry.id + ////////////////////// + id: t.field({ + description: "TODO", + type: "RegistryId", + nullable: false, + resolve: (parent) => parent.id, + }), + + //////////////////// + // Registry.parents + //////////////////// + parents: t.connection({ + description: "TODO", + type: ENSv2DomainRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.v2Domain.findMany({ + where: (t, { lt, gt, and, eq }) => + and( + ...[ + eq(t.subregistryId, parent.id), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + with: { label: true }, + }), + ), + }), + + ////////////////////// + // Registry.domains + ////////////////////// + domains: t.connection({ + description: "TODO", + type: ENSv2DomainRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.v2Domain.findMany({ + where: (t, { lt, gt, eq, and }) => + and( + ...[ + eq(t.registryId, parent.id), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + with: { label: true }, + }), + ), + }), + + //////////////////////// + // Registry.permissions + //////////////////////// + permissions: t.field({ + description: "TODO", + type: PermissionsRef, + // TODO: render a RegistryPermissions model that parses the backing permissions into registry-semantic roles + resolve: ({ chainId, address }) => makePermissionsId({ chainId, address }), + }), + + ///////////////////// + // Registry.contract + ///////////////////// + contract: t.field({ + description: "TODO", + type: AccountIdRef, + nullable: false, + resolve: ({ chainId, address }) => ({ chainId, address }), + }), + }), +}); + +////////// +// Inputs +////////// + +export const RegistryIdInput = builder.inputType("RegistryIdInput", { + description: "TODO", + isOneOf: true, + fields: (t) => ({ + id: t.field({ type: "RegistryId" }), + contract: t.field({ type: AccountIdInput }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/renewal.ts b/apps/ensapi/src/graphql-api/schema/renewal.ts new file mode 100644 index 000000000..0c00b99dd --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/renewal.ts @@ -0,0 +1,77 @@ +import type { RenewalId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { db } from "@/lib/db"; + +export const RenewalRef = builder.loadableObjectRef("Renewal", { + load: (ids: RenewalId[]) => + db.query.renewal.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Renewal = Exclude; + +/////////// +// Renewal +/////////// +RenewalRef.implement({ + description: "TODO", + fields: (t) => ({ + ////////////// + // Renewal.id + ////////////// + id: t.field({ + description: "TODO", + type: "ID", + nullable: false, + resolve: (parent) => parent.id, + }), + + //////////////////// + // Renewal.duration + //////////////////// + duration: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.duration, + }), + + //////////////////// + // Renewal.referrer + //////////////////// + referrer: t.field({ + description: "TODO", + type: "Hex", + nullable: true, + resolve: (parent) => parent.referrer, + }), + + //////////////// + // Renewal.base + //////////////// + base: t.field({ + description: "TODO", + type: "BigInt", + nullable: true, + resolve: (parent) => parent.base, + }), + + /////////////////// + // Renewal.premium + /////////////////// + premium: t.field({ + description: "TODO", + type: "BigInt", + nullable: true, + resolve: (parent) => parent.premium, + }), + + // TODO(paymentToken): add payment token tracking here + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/resolver-records.ts b/apps/ensapi/src/graphql-api/schema/resolver-records.ts new file mode 100644 index 000000000..22217e4ee --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/resolver-records.ts @@ -0,0 +1,76 @@ +import { bigintToCoinType, type ResolverRecordsId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { db } from "@/lib/db"; + +export const ResolverRecordsRef = builder.loadableObjectRef("ResolverRecords", { + load: (ids: ResolverRecordsId[]) => + db.query.resolverRecords.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + with: { textRecords: true, addressRecords: true }, + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type ResolverRecords = Exclude; + +ResolverRecordsRef.implement({ + description: "TODO", + fields: (t) => ({ + ////////////////////// + // ResolverRecords.id + ////////////////////// + id: t.field({ + description: "TODO", + type: "ID", + nullable: false, + resolve: (parent) => parent.id, + }), + + //////////////////////// + // ResolverRecords.node + //////////////////////// + node: t.field({ + description: "TODO", + type: "Node", + nullable: false, + resolve: (parent) => parent.node, + }), + + //////////////////////// + // ResolverRecords.name + //////////////////////// + name: t.expose("name", { + description: "TODO", + type: "String", + nullable: true, + }), + + //////////////////////// + // ResolverRecords.keys + //////////////////////// + keys: t.field({ + description: "TODO", + type: ["String"], + nullable: false, + resolve: (parent) => parent.textRecords.map((r) => r.key).toSorted(), + }), + + ///////////////////////////// + // ResolverRecords.coinTypes + ///////////////////////////// + coinTypes: t.field({ + description: "TODO", + type: ["CoinType"], + nullable: false, + resolve: (parent) => + parent.addressRecords + .map((r) => r.coinType) + .map(bigintToCoinType) + .toSorted(), + }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/resolver.ts b/apps/ensapi/src/graphql-api/schema/resolver.ts new file mode 100644 index 000000000..100face5d --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/resolver.ts @@ -0,0 +1,224 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { namehash } from "viem"; + +import { + makePermissionsId, + makeResolverRecordsId, + NODE_ANY, + type ResolverId, + type ResolverRecordsId, + ROOT_RESOURCE, +} from "@ensnode/ensnode-sdk"; +import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; + +import { builder } from "@/graphql-api/builder"; +import { getModelId } from "@/graphql-api/lib/get-model-id"; +import { AccountRef } from "@/graphql-api/schema/account"; +import { AccountIdInput, AccountIdRef } from "@/graphql-api/schema/account-id"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { cursors } from "@/graphql-api/schema/cursors"; +import { NameOrNodeInput } from "@/graphql-api/schema/name-or-node"; +import { PermissionsRef } from "@/graphql-api/schema/permissions"; +import { ResolverRecordsRef } from "@/graphql-api/schema/resolver-records"; +import { db } from "@/lib/db"; + +/** + * Note that this indexed Resolver entity represents not _all_ Resolver contracts that exist onchain, + * but the set of Resolver contracts that have emitted at least one event that we are able to index. + * + * This means that if one were to access a Resolver contract that _does_ exist on-chain, but hasn't + * emitted any events (ex: BasenamesL1Resolver), this API (which retrieves data from the index) + * would say that it doesn't exist. + * + * This limitation has always been the case, including for the legacy ENS Subgraph, and would require + * an RPC call to the chain be performed in the case that a Resolver doesn't exist in the index, which + * is prohibitive in both cost and latency. As such we acknowledge this limitation here, for now. + */ + +export const ResolverRef = builder.loadableObjectRef("Resolver", { + load: (ids: ResolverId[]) => + db.query.resolver.findMany({ + where: (t, { inArray }) => inArray(t.id, ids), + }), + toKey: getModelId, + cacheResolved: true, + sort: true, +}); + +export type Resolver = Exclude; + +//////////// +// Resolver +//////////// +ResolverRef.implement({ + description: "A Resolver Contract", + fields: (t) => ({ + /////////////// + // Resolver.id + /////////////// + id: t.field({ + description: "TODO", + type: "ResolverId", + nullable: false, + resolve: (parent) => parent.id, + }), + + ///////////////////// + // Resolver.contract + ///////////////////// + contract: t.field({ + description: "TODO", + type: AccountIdRef, + nullable: false, + resolve: ({ chainId, address }) => ({ chainId, address }), + }), + + //////////////////// + // Resolver.records + //////////////////// + records: t.connection({ + description: "TODO", + type: ResolverRecordsRef, + resolve: (parent, args, context) => + resolveCursorConnection( + { ...DEFAULT_CONNECTION_ARGS, args }, + ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => + db.query.resolverRecords.findMany({ + where: (t, { lt, gt, and, eq }) => + and( + ...[ + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + before !== undefined && lt(t.id, cursors.decode(before)), + after !== undefined && gt(t.id, cursors.decode(after)), + ].filter((c) => !!c), + ), + orderBy: (t, { asc, desc }) => (inverted ? desc(t.id) : asc(t.id)), + limit, + with: { textRecords: true, addressRecords: true }, + }), + ), + }), + + //////////////////////////////////// + // Resolver.records by Name or Node + //////////////////////////////////// + records_: t.field({ + description: "TODO", + type: ResolverRecordsRef, + args: { for: t.arg({ type: NameOrNodeInput, required: true }) }, + nullable: true, + resolve: async ({ chainId, address }, args) => { + const node = args.for.node ?? namehash(args.for.name); + return makeResolverRecordsId({ chainId, address }, node); + }, + }), + + ///////////////////// + // Resolver.extended + ///////////////////// + extended: t.field({ + description: "TODO", + type: "Boolean", + nullable: false, + resolve: (parent) => parent.isExtended, + }), + + ////////////////////// + // Resolver.dedicated + ////////////////////// + dedicated: t.field({ + description: "TODO", + type: DedicatedResolverMetadataRef, + nullable: true, + resolve: (parent) => (parent.isDedicated ? parent : null), + }), + + //////////////////// + // Resolver.bridged + //////////////////// + bridged: t.field({ + description: "TODO", + type: AccountIdRef, + nullable: true, + resolve: (parent, args, context) => isBridgedResolver(context.namespace, parent), + }), + + //////////////////////// + // Resolver.permissions + //////////////////////// + permissions: t.field({ + description: "TODO", + type: PermissionsRef, + resolve: ({ chainId, address }) => makePermissionsId({ chainId, address }), + }), + }), +}); + +///////////////////////////// +// DedicatedResolverMetadata +///////////////////////////// +export const DedicatedResolverMetadataRef = builder.objectRef( + "DedicatedResolverMetadataRef", +); +DedicatedResolverMetadataRef.implement({ + description: "TODO", + fields: (t) => ({ + /////////////////////////// + // DedicatedResolver.owner + /////////////////////////// + owner: t.field({ + description: "TODO", + type: AccountRef, + nullable: true, + resolve: async (parent) => { + const permissionsUser = await db.query.permissionsUser.findFirst({ + where: (t, { eq, and }) => + and( + eq(t.chainId, parent.chainId), + eq(t.address, parent.address), + eq(t.resource, ROOT_RESOURCE), + ), + }); + + return permissionsUser?.user; + }, + }), + + ///////////////////////////////// + // DedicatedResolver.permissions + ///////////////////////////////// + // TODO(EAC) — support DedicatedResolver.permissions after EAC change + permissions: t.field({ + description: "TODO", + type: PermissionsRef, + nullable: false, + // TODO: render a DedicatedResolverPermissions model that parses the backing permissions into dedicated-resolver-semantic roles? + resolve: ({ chainId, address }) => makePermissionsId({ chainId, address }), + }), + + ///////////////////////////// + // Resolver.dedicatedRecords + ///////////////////////////// + records: t.field({ + description: "TODO", + type: ResolverRecordsRef, + nullable: true, + resolve: ({ chainId, address }, args) => + makeResolverRecordsId({ chainId, address }, NODE_ANY), + }), + }), +}); + +///////////////////// +// Inputs +///////////////////// + +export const ResolverIdInput = builder.inputType("ResolverIdInput", { + description: "TODO", + isOneOf: true, + fields: (t) => ({ + id: t.field({ type: "ResolverId" }), + contract: t.field({ type: AccountIdInput }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/scalars.ts b/apps/ensapi/src/graphql-api/schema/scalars.ts new file mode 100644 index 000000000..c5091ef96 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/scalars.ts @@ -0,0 +1,132 @@ +import { type Address, type Hex, isHex, size } from "viem"; +import { z } from "zod/v4"; + +import { + type ChainId, + type CoinType, + type DomainId, + type InterpretedName, + isInterpretedName, + type Name, + type Node, + type RegistryId, + type ResolverId, +} from "@ensnode/ensnode-sdk"; +import { + makeChainIdSchema, + makeCoinTypeSchema, + makeLowercaseAddressSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { builder } from "@/graphql-api/builder"; + +builder.scalarType("BigInt", { + description: "BigInt represents non-fractional signed whole numeric values.", + serialize: (value: bigint) => value.toString(), + parseValue: (value) => z.coerce.bigint().parse(value), +}); + +builder.scalarType("Address", { + description: "Address represents a lowercase (unchecksummed) viem#Address.", + serialize: (value: Address) => value.toString(), + parseValue: (value) => makeLowercaseAddressSchema("Address").parse(value), +}); + +builder.scalarType("Hex", { + description: "Hex represents viem#Hex.", + serialize: (value: Hex) => value.toString(), + parseValue: (value) => + z.coerce + .string() + .check((ctx) => { + if (!isHex(value)) { + ctx.issues.push({ + code: "custom", + message: "Must be a valid Hex", + input: ctx.value, + }); + } + }) + .transform((val) => val as Hex) + .parse(value), +}); + +builder.scalarType("ChainId", { + description: "ChainId represents a @ensnode/ensnode-sdk#ChainId.", + serialize: (value: ChainId) => value, + parseValue: (value) => makeChainIdSchema("ChainId").parse(value), +}); + +builder.scalarType("CoinType", { + description: "CoinType represents a @ensnode/ensnode-sdk#CoinType.", + serialize: (value: CoinType) => value, + parseValue: (value) => makeCoinTypeSchema("CoinType").parse(value), +}); + +builder.scalarType("Node", { + description: "Node represents a @ensnode/ensnode-sdk#Node.", + serialize: (value: Node) => value, + parseValue: (value) => + z.coerce + .string() + .check((ctx) => { + if (isHex(ctx.value) && size(ctx.value) === 32) return; + + ctx.issues.push({ + code: "custom", + message: `Node must be a valid Node`, + input: ctx.value, + }); + }) + .transform((val) => val as Node) + .parse(value), +}); + +builder.scalarType("Name", { + description: "Name represents a @ensnode/ensnode-sdk#InterpretedName.", + serialize: (value: Name) => value, + parseValue: (value) => + z.coerce + .string() + .check((ctx) => { + if (!isInterpretedName(ctx.value)) { + ctx.issues.push({ + code: "custom", + message: "Name must consist exclusively of Encoded LabelHashes or normalized labels.", + input: ctx.value, + }); + } + }) + .transform((val) => val as InterpretedName) + .parse(value), +}); + +builder.scalarType("DomainId", { + description: "DomainId represents a @ensnode/ensnode-sdk#DomainId.", + serialize: (value: DomainId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as DomainId) + .parse(value), +}); + +builder.scalarType("RegistryId", { + description: "RegistryId represents a @ensnode/ensnode-sdk#RegistryId.", + serialize: (value: RegistryId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as RegistryId) + .parse(value), +}); + +builder.scalarType("ResolverId", { + description: "ResolverId represents a @ensnode/ensnode-sdk#ResolverId.", + serialize: (value: ResolverId) => value, + parseValue: (value) => + z.coerce + .string() + .transform((val) => val as ResolverId) + .parse(value), +}); diff --git a/apps/ensapi/src/graphql-api/schema/wrapped-baseregistrar-registration.ts b/apps/ensapi/src/graphql-api/schema/wrapped-baseregistrar-registration.ts new file mode 100644 index 000000000..eae2a60a5 --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/wrapped-baseregistrar-registration.ts @@ -0,0 +1,37 @@ +import { hexToBigInt } from "viem"; + +import type { ENSv1DomainId } from "@ensnode/ensnode-sdk"; + +import { builder } from "@/graphql-api/builder"; +import type { BaseRegistrarRegistration } from "@/graphql-api/schema/registration"; + +export const WrappedBaseRegistrarRegistrationRef = builder.objectRef( + "WrappedBaseRegistrarRegistration", +); + +WrappedBaseRegistrarRegistrationRef.implement({ + description: "TODO", + fields: (t) => ({ + /////////////////// + // Wrapped.tokenId + /////////////////// + tokenId: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + // NOTE: only ENSv1 Domains can be wrapped, id is guaranteed to be ENSv1DomainId === Node + resolve: (parent) => hexToBigInt(parent.domainId as ENSv1DomainId), + }), + + ///////////////// + // Wrapped.fuses + ///////////////// + fuses: t.field({ + description: "TODO", + type: "Int", + nullable: false, + // TODO: decode/render Fuses enum + resolve: (parent) => parent.fuses, + }), + }), +}); diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/ensnode-api.ts index e3e19d779..4b80e818d 100644 --- a/apps/ensapi/src/handlers/ensnode-api.ts +++ b/apps/ensapi/src/handlers/ensnode-api.ts @@ -17,6 +17,7 @@ import { import { buildEnsApiPublicConfig } from "@/config/config.schema"; import { factory } from "@/lib/hono-factory"; +import ensnodeGraphQLApi from "./ensnode-graphql-api"; import nameTokensApi from "./name-tokens-api"; import registrarActionsApi from "./registrar-actions-api"; import resolutionApi from "./resolution-api"; @@ -103,4 +104,7 @@ app.route("/registrar-actions", registrarActionsApi); // Resolution API app.route("/resolve", resolutionApi); +// ENSNode GraphQL API +app.route("/graphql", ensnodeGraphQLApi); + export default app; diff --git a/apps/ensapi/src/handlers/ensnode-graphql-api.ts b/apps/ensapi/src/handlers/ensnode-graphql-api.ts new file mode 100644 index 000000000..3005c9e5f --- /dev/null +++ b/apps/ensapi/src/handlers/ensnode-graphql-api.ts @@ -0,0 +1,70 @@ +// import { maxAliasesPlugin } from "@escape.tech/graphql-armor-max-aliases"; +// import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth"; +// import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens"; + +import config from "@/config"; + +import { getUnixTime } from "date-fns"; +import { createYoga } from "graphql-yoga"; + +import { schema } from "@/graphql-api/schema"; +import { factory } from "@/lib/hono-factory"; +import { makeLogger } from "@/lib/logger"; +import { requireCorePluginMiddleware } from "@/middleware/require-core-plugin.middleware"; + +const logger = makeLogger("ensnode-graphql"); + +const yoga = createYoga({ + graphqlEndpoint: "*", + schema, + context: () => ({ + // inject config's namespace into context, feel cleaner than accessing from @/config directly + namespace: config.namespace, + + // generate a bigint UnixTimestamp per-request for handlers to use + now: BigInt(getUnixTime(new Date())), + }), + graphiql: { + defaultQuery: `query DomainsByOwner { + account(address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") { + domains { + edges { + node { + id + label + owner { address } + registration { expiry } + ... on ENSv1Domain { + parent { label } + } + ... on ENSv2Domain { + canonicalId + registry { contract {chainId address}} + } + } + } + } + } +}`, + }, + + // integrate logging with pino + logging: logger, + + // TODO: plugins + // plugins: [ + // maxTokensPlugin({ n: maxOperationTokens }), + // maxDepthPlugin({ n: maxOperationDepth, ignoreIntrospection: false }), + // maxAliasesPlugin({ n: maxOperationAliases, allowList: [] }), + // ], +}); + +const app = factory.createApp(); + +app.use(requireCorePluginMiddleware("ensv2")); +app.use(async (c) => { + const response = await yoga.fetch(c.req.raw, c.var); + return response; +}); + +export default app; diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/resolution-api.ts index 42c05558c..c131d17be 100644 --- a/apps/ensapi/src/handlers/resolution-api.ts +++ b/apps/ensapi/src/handlers/resolution-api.ts @@ -69,7 +69,7 @@ app.get( "query", z .object({ - selection: params.selection, + ...params.selectionParams.shape, trace: params.trace, accelerate: params.accelerate, }) diff --git a/apps/ensapi/src/handlers/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph-api.ts index 491d4e6fd..d426d5963 100644 --- a/apps/ensapi/src/handlers/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph-api.ts @@ -4,9 +4,8 @@ import { createDocumentationMiddleware } from "ponder-enrich-gql-docs-middleware import * as schema from "@ensnode/ensnode-schema"; import type { Duration } from "@ensnode/ensnode-sdk"; -import { buildGraphQLSchema, subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; +import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; -import { makeDrizzle } from "@/lib/handlers/drizzle"; import { factory } from "@/lib/hono-factory"; import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation"; import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; @@ -21,13 +20,6 @@ const MAX_REALTIME_DISTANCE_TO_RESOLVE: Duration = 10 * 60; // 10 minutes in sec // generate a subgraph-specific subset of the schema const subgraphSchema = filterSchemaByPrefix("subgraph_", schema); -// make subgraph-specific drizzle db -const drizzle = makeDrizzle({ - schema: subgraphSchema, - databaseUrl: config.databaseUrl, - databaseSchema: config.databaseSchemaName, -}); - const app = factory.createApp(); // 404 if subgraph core plugin not enabled @@ -51,48 +43,47 @@ app.use(subgraphMetaMiddleware); // use subgraph middleware app.use( subgraphGraphQLMiddleware({ - drizzle, - graphqlSchema: buildGraphQLSchema({ - schema: subgraphSchema, - // describes the polymorphic (interface) relationships in the schema - polymorphicConfig: { - types: { - DomainEvent: [ - subgraphSchema.transfer, - subgraphSchema.newOwner, - subgraphSchema.newResolver, - subgraphSchema.newTTL, - subgraphSchema.wrappedTransfer, - subgraphSchema.nameWrapped, - subgraphSchema.nameUnwrapped, - subgraphSchema.fusesSet, - subgraphSchema.expiryExtended, - ], - RegistrationEvent: [ - subgraphSchema.nameRegistered, - subgraphSchema.nameRenewed, - subgraphSchema.nameTransferred, - ], - ResolverEvent: [ - subgraphSchema.addrChanged, - subgraphSchema.multicoinAddrChanged, - subgraphSchema.nameChanged, - subgraphSchema.abiChanged, - subgraphSchema.pubkeyChanged, - subgraphSchema.textChanged, - subgraphSchema.contenthashChanged, - subgraphSchema.interfaceChanged, - subgraphSchema.authorisationChanged, - subgraphSchema.versionChanged, - ], - }, - fields: { - "Domain.events": "DomainEvent", - "Registration.events": "RegistrationEvent", - "Resolver.events": "ResolverEvent", - }, + databaseUrl: config.databaseUrl, + databaseSchema: config.databaseSchemaName, + schema: subgraphSchema, + // describes the polymorphic (interface) relationships in the schema + polymorphicConfig: { + types: { + DomainEvent: [ + subgraphSchema.transfer, + subgraphSchema.newOwner, + subgraphSchema.newResolver, + subgraphSchema.newTTL, + subgraphSchema.wrappedTransfer, + subgraphSchema.nameWrapped, + subgraphSchema.nameUnwrapped, + subgraphSchema.fusesSet, + subgraphSchema.expiryExtended, + ], + RegistrationEvent: [ + subgraphSchema.nameRegistered, + subgraphSchema.nameRenewed, + subgraphSchema.nameTransferred, + ], + ResolverEvent: [ + subgraphSchema.addrChanged, + subgraphSchema.multicoinAddrChanged, + subgraphSchema.nameChanged, + subgraphSchema.abiChanged, + subgraphSchema.pubkeyChanged, + subgraphSchema.textChanged, + subgraphSchema.contenthashChanged, + subgraphSchema.interfaceChanged, + subgraphSchema.authorisationChanged, + subgraphSchema.versionChanged, + ], + }, + fields: { + "Domain.events": "DomainEvent", + "Registration.events": "RegistrationEvent", + "Resolver.events": "ResolverEvent", }, - }), + }, }), ); diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 9c7ca9c53..68736bdc7 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -7,8 +7,6 @@ import { cors } from "hono/cors"; import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; -import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; - import { indexingStatusCache } from "@/cache/indexing-status.cache"; import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache"; import { redactEnsApiConfig } from "@/config/redact"; @@ -107,9 +105,7 @@ const server = serve( port: config.port, }, async (info) => { - logger.info( - `ENSApi listening on port ${info.port} with config:\n${prettyPrintJson(redactEnsApiConfig(config))}`, - ); + logger.info({ config: redactEnsApiConfig(config) }, `ENSApi listening on port ${info.port}`); // self-healthcheck to connect to ENSIndexer & warm Indexing Status cache await app.request("/health"); diff --git a/apps/ensapi/src/lib/handlers/drizzle.ts b/apps/ensapi/src/lib/handlers/drizzle.ts index e397173ef..a5d8f36da 100644 --- a/apps/ensapi/src/lib/handlers/drizzle.ts +++ b/apps/ensapi/src/lib/handlers/drizzle.ts @@ -1,21 +1,11 @@ -import { isTable, Table } from "drizzle-orm"; +import { setDatabaseSchema } from "@ponder/client"; import { drizzle } from "drizzle-orm/node-postgres"; -import { isPgEnum } from "drizzle-orm/pg-core"; + +import { makeLogger } from "@/lib/logger"; type Schema = { [name: string]: unknown }; -// https://github.com/ponder-sh/ponder/blob/f7f6444ab8d1a870fe6492023941091df7b7cddf/packages/client/src/index.ts#L226C1-L239C3 -const setDatabaseSchema = (schema: T, schemaName: string) => { - for (const table of Object.values(schema)) { - if (isTable(table)) { - // @ts-expect-error - table[Table.Symbol.Schema] = schemaName; - } else if (isPgEnum(table)) { - // @ts-expect-error - table.schema = schemaName; - } - } -}; +const logger = makeLogger("drizzle"); /** * Makes a Drizzle DB object. @@ -32,5 +22,11 @@ export const makeDrizzle = ({ // monkeypatch schema onto tables setDatabaseSchema(schema, databaseSchema); - return drizzle(databaseUrl, { schema, casing: "snake_case" }); + return drizzle(databaseUrl, { + schema, + casing: "snake_case", + logger: { + logQuery: (query, params) => logger.trace({ params }, query), + }, + }); }; diff --git a/apps/ensapi/src/lib/protocol-acceleration/ens-root-registry.ts b/apps/ensapi/src/lib/protocol-acceleration/ens-root-registry.ts deleted file mode 100644 index 02113f4b4..000000000 --- a/apps/ensapi/src/lib/protocol-acceleration/ens-root-registry.ts +++ /dev/null @@ -1,18 +0,0 @@ -import config from "@/config"; - -import { DatasourceNames, getDatasource } from "@ensnode/datasources"; -import { type AccountId, accountIdEqual } from "@ensnode/ensnode-sdk"; - -const ensRoot = getDatasource(config.namespace, DatasourceNames.ENSRoot); - -/** - * The AccountId of the ENS Registry on the Root Chain. - */ -export const ENS_ROOT_REGISTRY: AccountId = { - chainId: ensRoot.chain.id, - address: ensRoot.contracts.Registry.address, -}; - -export function isENSRootRegistry(accountId: AccountId) { - return accountIdEqual(accountId, ENS_ROOT_REGISTRY); -} diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index d3ebea638..0e37f6115 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -16,8 +16,10 @@ import { DatasourceNames, getDatasource } from "@ensnode/datasources"; import { type AccountId, accountIdEqual, + type DomainId, getDatasourceContract, getNameHierarchy, + isENSv1Registry, type Name, type Node, type NormalizedName, @@ -25,7 +27,6 @@ import { import { db } from "@/lib/db"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; -import { isENSRootRegistry } from "@/lib/protocol-acceleration/ens-root-registry"; type FindResolverResult = | { @@ -43,13 +44,20 @@ const NULL_RESULT: FindResolverResult = { const tracer = trace.getTracer("find-resolver"); -const RegistryOld = getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "RegistryOld"); +const ENSv1RegistryOld = getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "ENSv1RegistryOld", +); /** * Identifies `name`'s active resolver in `registry`. * - * Note that any `registry` that is not the ENS Root Chain's Registry is a Shadow Registry like - * Basenames' or Lineanames' (shadow)Registry contracts. + * Registry can be: + * - ENSv1 Root Chain Registry + * - ENSv1 Basenames (shadow) Registry + * - ENSv1 Lineanames (shadow) Registry + * - TODO: any ENSv2 Registry */ export async function findResolver({ registry, @@ -69,14 +77,14 @@ export async function findResolver({ // If: // 1) the caller requested acceleration, and // 2) the ProtocolAcceleration plugin is active, - // then we can identify a node's active resolver via the indexed Node-Resolver Relationships. + // then we can identify a node's active resolver via the indexed Domain-Resolver Relationships. ////////////////////////////////////////////////// if (accelerate && canAccelerate) { return findResolverWithIndex(registry, name); } // Invariant: UniversalResolver#findResolver only works for ENS Root Registry - if (!isENSRootRegistry(registry)) { + if (!isENSv1Registry(config.namespace, registry)) { throw new Error( `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers agains the ENs Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, ); @@ -179,6 +187,8 @@ async function findResolverWithIndex( "findResolverWithIndex", { chainId: registry.chainId, registry: registry.address, name }, async () => { + // TODO: all of this logic needs to be updated for ENSv2 Datamodel, need to reference new UR + // 1. construct a hierarchy of names. i.e. sub.example.eth -> [sub.example.eth, example.eth, eth] const names = getNameHierarchy(name); @@ -187,87 +197,86 @@ async function findResolverWithIndex( throw new Error(`Invariant(findResolverWithIndex): received an invalid name: '${name}'`); } - // 2. compute node of each via namehash + // 2. compute domainId of each node + // NOTE: this is currently ENSv1-specific const nodes = names.map((name) => namehash(name) as Node); + const domainIds = nodes as DomainId[]; - // 3. for each node, find its resolver in the selected registry - const nodeResolverRelations = await withSpanAsync( + // 3. for each domain, find its associated resolver in the selected registry + const domainResolverRelations = await withSpanAsync( tracer, - "nodeResolverRelation.findMany", + "domainResolverRelation.findMany", {}, async () => { // the current ENS Root Chain Registry is actually ENSRegistryWithFallback: if a node // doesn't exist in its own storage, it directs the lookup to RegistryOld. We must encode // this logic here, so that the active resolver of unmigrated nodes can be correctly identified. // https://github.com/ensdomains/ens-contracts/blob/be53b9c25be5b2c7326f524bbd34a3939374ab1f/contracts/registry/ENSRegistryWithFallback.sol#L19 - const records = await db.query.nodeResolverRelation.findMany({ - where: (nrr, { inArray, and, or, eq }) => + const records = await db.query.domainResolverRelation.findMany({ + where: (t, { inArray, and, or, eq }) => and( or( ...[ - // filter for Node-Resolver Relationship in the current Registry - and(eq(nrr.chainId, registry.chainId), eq(nrr.registry, registry.address)), + // filter for Domain-Resolver Relationship in the current Registry + and(eq(t.chainId, registry.chainId), eq(t.address, registry.address)), // OR, if the registry is the ENS Root Registry, also include records from RegistryOld - isENSRootRegistry(registry) && + isENSv1Registry(config.namespace, registry) && and( - eq(nrr.chainId, RegistryOld.chainId), - eq(nrr.registry, RegistryOld.address), + eq(t.chainId, ENSv1RegistryOld.chainId), + eq(t.address, ENSv1RegistryOld.address), ), ].filter((c) => !!c), ), - // filter for Node-Resolver Relations for the following Nodes - inArray(nrr.node, nodes), + // filter for Domain-Resolver Relations for the following DomainIds + inArray(t.domainId, domainIds), ), }); - // 3.1 sort into the same order as `nodes`: db results are not guaranteed to match `inArray` order + // 3.1 sort into the same order as `domainIds`: db results are not guaranteed to match `inArray` order // NOTE: we also sort with a preference for `registry` matching the specific Registry we're - // searching within — this provides the "prefer Node-Resolver-Relationships in Registry + // searching within — this provides the "prefer Domain-Resolver-Relationships in Registry // over RegistryOld" necessary to implement fallback. records.sort((a, b) => { - // if the nodes match, prefer exact-registry-match - if (a.node === b.node) { - return accountIdEqual({ chainId: a.chainId, address: a.registry }, registry) ? -1 : 1; - } + // if the DomainIds match, prefer exact-registry-match + if (a.domainId === b.domainId) return accountIdEqual(a, registry) ? -1 : 1; - // otherwise, sort by order in `nodes` - return nodes.indexOf(a.node) > nodes.indexOf(b.node) ? 1 : -1; + // otherwise, sort by order in `domainIds` + return domainIds.indexOf(a.domainId) > domainIds.indexOf(b.domainId) ? 1 : -1; }); - // cast into our semantic types - return records as { node: Node; resolver: Address }[]; + return records; }, ); - // 4. If no Node-Resolver Relations were found, there is no active resolver for the given node - if (nodeResolverRelations.length === 0) return NULL_RESULT; + // 4. If no Domain-Resolver Relations were found, there is no active resolver for the given domain + if (domainResolverRelations.length === 0) return NULL_RESULT; // 5. The first record is the active resolver - const { node, resolver } = nodeResolverRelations[0]; + const { domainId, resolver } = domainResolverRelations[0]; - // Invariant: Node-Resolver Relations encodes the unsetting of a Resolver as null, so `resolver` + // Invariant: Domain-Resolver Relations encodes the unsetting of a Resolver as null, so `resolver` // should never be zeroAddress. if (isAddressEqual(resolver, zeroAddress)) { throw new Error( - `Invariant(findResolverWithIndex): Encountered a zeroAddress resolverAddress for node ${node}, which should be impossible: check ProtocolAcceleration Node-Resolver Relation indexing logic.`, + `Invariant(findResolverWithIndex): Encountered a zeroAddress resolverAddress for ${domainId}, which should be impossible: check ProtocolAcceleration Domain-Resolver Relation indexing logic.`, ); } - // map the relation's `node` back to its name in `names` - const indexInHierarchy = nodes.indexOf(node); + // map the relation's `domainId` back to its name in `names` + const indexInHierarchy = domainIds.indexOf(domainId); const activeName = names[indexInHierarchy]; // will never occur, exlusively for typechecking if (!activeName) { throw new Error( - `Invariant(findResolverWithIndex): activeName could not be determined. names = ${JSON.stringify(names)} nodes = ${JSON.stringify(nodes)} active resolver's node: ${node}.`, + `Invariant(findResolverWithIndex): activeName could not be determined. names = ${JSON.stringify(names)} domains = ${JSON.stringify(domainIds)} active resolver's domainId: ${domainId}.`, ); } return { activeName, activeResolver: resolver, - // this resolver must have wildcard support if it was not for the first node in our hierarchy + // this resolver must have wildcard support if it was not for the first domain in our hierarchy requiresWildcardSupport: indexInHierarchy > 0, }; }, diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts index 69a711461..5eb0e6796 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-primary-name-from-index.ts @@ -32,7 +32,7 @@ export async function getENSIP19ReverseNameRecordFromIndex( and( // address = address eq(t.address, address), - // AND coinType IN [_coinType, DEFAULT_EVM_COIN_TYPE] + // AND coinType IN [coinType, DEFAULT_EVM_COIN_TYPE] inArray(t.coinType, [_coinType, DEFAULT_EVM_COIN_TYPE_BIGINT]), ), columns: { coinType: true, value: true }, @@ -40,7 +40,6 @@ export async function getENSIP19ReverseNameRecordFromIndex( ); const coinTypeName = records.find((pn) => pn.coinType === _coinType)?.value ?? null; - const defaultName = records.find((pn) => pn.coinType === DEFAULT_EVM_COIN_TYPE_BIGINT)?.value ?? null; diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts index e7460bd58..5b3c1969f 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts @@ -1,73 +1,64 @@ -import { trace } from "@opentelemetry/api"; -import type { Address } from "viem"; +import config from "@/config"; import { - type ChainId, + type AccountId, DEFAULT_EVM_COIN_TYPE, + makeResolverId, type Node, type ResolverRecordsSelection, } from "@ensnode/ensnode-sdk"; +import { staticResolverImplementsAddressRecordDefaulting } from "@ensnode/ensnode-sdk/internal"; import { db } from "@/lib/db"; -import { withSpanAsync } from "@/lib/instrumentation/auto-span"; -import { onchainStaticResolverImplementsDefaultAddress } from "@/lib/protocol-acceleration/known-onchain-static-resolver"; import type { IndexedResolverRecords } from "@/lib/resolution/make-records-response"; -const tracer = trace.getTracer("get-records"); - const DEFAULT_EVM_COIN_TYPE_BIGINT = BigInt(DEFAULT_EVM_COIN_TYPE); export async function getRecordsFromIndex({ - chainId, - resolverAddress, + resolver, node, selection, }: { - chainId: ChainId; - resolverAddress: Address; + resolver: AccountId; node: Node; selection: SELECTION; }): Promise { - // fetch the Resolver Records from index - const resolverRecords = await withSpanAsync(tracer, "resolverRecords.findFirst", {}, async () => { - const records = await db.query.resolverRecords.findFirst({ - where: (resolver, { and, eq }) => - and( - eq(resolver.chainId, chainId), - eq(resolver.resolver, resolverAddress), - eq(resolver.node, node), - ), - columns: { name: true }, - with: { addressRecords: true, textRecords: true }, - }); + const records = (await db.query.resolverRecords.findFirst({ + where: (t, { and, eq }) => + and( + // filter by specific resolver + eq(t.chainId, resolver.chainId), + eq(t.address, resolver.address), + // filter by specific node + eq(t.node, node), + ), + columns: { name: true }, + with: { addressRecords: true, textRecords: true }, + })) as IndexedResolverRecords | undefined; - return records as IndexedResolverRecords | undefined; - }); + // no records found + if (!records) return null; - if (!resolverRecords) return null; + // if the resolver doesn't implement address record defaulting, return records as-is + if (!staticResolverImplementsAddressRecordDefaulting(config.namespace, resolver)) return records; - // if the resolver implements address record defaulting, materialize all selected address records - // that do not yet exist - if (onchainStaticResolverImplementsDefaultAddress(chainId, resolverAddress)) { - if (selection.addresses) { - const defaultRecord = resolverRecords.addressRecords.find( - (record) => record.coinType === DEFAULT_EVM_COIN_TYPE_BIGINT, - ); + // otherwise, materialize all selected address records that do not yet exist + if (selection.addresses) { + const defaultRecord = records.addressRecords.find( + (record) => record.coinType === DEFAULT_EVM_COIN_TYPE_BIGINT, + ); - for (const coinType of selection.addresses) { - const _coinType = BigInt(coinType); - const existing = resolverRecords.addressRecords.find( - (record) => record.coinType === _coinType, - ); - if (!existing && defaultRecord) { - resolverRecords.addressRecords.push({ - address: defaultRecord.address, - coinType: _coinType, - }); - } + for (const coinType of selection.addresses) { + const _coinType = BigInt(coinType); + const existing = records.addressRecords.find((record) => record.coinType === _coinType); + if (!existing && defaultRecord) { + records.addressRecords.push({ + value: defaultRecord.value, + coinType: _coinType, + }); } } } - return resolverRecords; + return records; } diff --git a/apps/ensapi/src/lib/protocol-acceleration/known-ccip-read-shadow-registry-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/known-ccip-read-shadow-registry-resolver.ts deleted file mode 100644 index d375953ce..000000000 --- a/apps/ensapi/src/lib/protocol-acceleration/known-ccip-read-shadow-registry-resolver.ts +++ /dev/null @@ -1,86 +0,0 @@ -import config from "@/config"; - -import type { Address } from "viem"; - -import { type ContractConfig, type ENSNamespace, getENSNamespace } from "@ensnode/datasources"; -import { type AccountId, accountIdEqual } from "@ensnode/ensnode-sdk"; - -const namespace = getENSNamespace(config.namespace) as ENSNamespace; - -type ContractConfigWithSingleAddress = ContractConfig & { address: Address }; -const hasSingleAddress = ( - contractConfig: ContractConfig | undefined, -): contractConfig is ContractConfigWithSingleAddress => - !!contractConfig?.address && typeof contractConfig.address === "string"; - -/** - * For a given `resolver`, if it is a known CCIP-Read Shadow Registry Resolver, return the - * AccountId describing the (shadow)Registry it defers resolution to. - * - * These CCIP-Read Shadow Registry Resolvers must abide the following pattern: - * 1. They _always_ emit OffchainLookup for any resolve() call to a well-known CCIP-Read Gateway, - * 2. That CCIP-Read Gateway exclusively consults a specific (shadow)Registry in order to identify - * a name's active resolver and resolve records, and - * 3. Its behavior is unlikely to change (i.e. the contract is not upgradable or is unlikely to be - * upgraded in a way that violates principles 1. or 2.). - * - * The goal is to encode the pattern followed by projects like Basenames and Lineanames where a - * wildcard resolver is used for subnames of base.eth and that L1Resolver always returns OffchainLookup - * instructing the caller to consult a well-known CCIP-Read Gateway. This CCIP-Read Gateway then - * exclusively behaves in the following way: it identifies the name's active resolver via a well-known - * (shadow)Registry (likely on an L2), and resolves records on that active resolver. - * - * In these cases, if the Node-Resolver relationships for the (shadow)Registry in question are indexed, - * then the CCIP-Read can be short-circuited, in favor of performing an _accelerated_ Forward Resolution - * against the (shadow)Registry in question. - * - * TODO: these relationships could/should be encoded in an ENSIP, likely as a mapping from - * resolverAddress to (shadow)Registry on a specified chain. - */ -export function possibleKnownCCIPReadShadowRegistryResolverDefersTo( - resolver: AccountId, -): AccountId | null { - const { ensroot, basenames, lineanames } = namespace; - - if ( - basenames && - hasSingleAddress(basenames.contracts.Registry) && - hasSingleAddress(ensroot.contracts.BasenamesL1Resolver) - ) { - // the ENSRoot's BasenamesL1Resolver defers to the Basenames (shadow)Registry - const isBasenamesL1Resolver = accountIdEqual(resolver, { - chainId: ensroot.chain.id, - address: ensroot.contracts.BasenamesL1Resolver.address, - }); - - if (isBasenamesL1Resolver) { - return { - chainId: basenames.chain.id, - address: basenames.contracts.Registry.address, - }; - } - } - - if ( - lineanames && - hasSingleAddress(lineanames.contracts.Registry) && - hasSingleAddress(ensroot.contracts.LineanamesL1Resolver) - ) { - // the ENSRoot's LineanamesL1Resolver defers to the Lineanames (shadow)Registry - const isLineanamesL1Resolver = accountIdEqual(resolver, { - chainId: ensroot.chain.id, - address: ensroot.contracts.LineanamesL1Resolver.address, - }); - - if (isLineanamesL1Resolver) { - return { - chainId: lineanames.chain.id, - address: lineanames.contracts.Registry.address, - }; - } - } - - // TODO: ThreeDNS - - return null; -} diff --git a/apps/ensapi/src/lib/protocol-acceleration/known-ensip-19-reverse-resolvers.ts b/apps/ensapi/src/lib/protocol-acceleration/known-ensip-19-reverse-resolvers.ts deleted file mode 100644 index 8f423e9b3..000000000 --- a/apps/ensapi/src/lib/protocol-acceleration/known-ensip-19-reverse-resolvers.ts +++ /dev/null @@ -1,33 +0,0 @@ -import config from "@/config"; - -import type { Address } from "viem"; - -import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; -import type { ChainId } from "@ensnode/ensnode-sdk"; - -const rrRoot = maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverRoot); - -/** - * ENSIP-19 Reverse Resolvers (i.e. DefaultReverseResolver or ChainReverseResolver) simply: - * a. read the Name for their specific coinType from their connected StandaloneReverseRegistry, or - * b. return the default coinType's Name. - * - * We encode this behavior here, for the purposes of Protocol Acceleration. - */ -export function isKnownENSIP19ReverseResolver(chainId: ChainId, resolverAddress: Address): boolean { - // NOTE: ENSIP-19 Reverse Resolvers are only valid in the context of the ENS Root chain - if (chainId !== rrRoot?.chain.id) return false; - - return [ - // DefaultReverseResolver (default.reverse) - rrRoot?.contracts.DefaultReverseResolver3?.address, - // the following are each ChainReverseResolver ([coinType].reverse) - rrRoot?.contracts.BaseReverseResolver?.address, - rrRoot?.contracts.LineaReverseResolver?.address, - rrRoot?.contracts.OptimismReverseResolver?.address, - rrRoot?.contracts.ArbitrumReverseResolver?.address, - rrRoot?.contracts.ScrollReverseResolver?.address, - ] - .filter((address): address is Address => !!address) - .includes(resolverAddress); -} diff --git a/apps/ensapi/src/lib/protocol-acceleration/known-onchain-static-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/known-onchain-static-resolver.ts deleted file mode 100644 index 6acbaa561..000000000 --- a/apps/ensapi/src/lib/protocol-acceleration/known-onchain-static-resolver.ts +++ /dev/null @@ -1,87 +0,0 @@ -import config from "@/config"; - -import type { Address } from "viem"; - -import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; -import type { ChainId } from "@ensnode/ensnode-sdk"; - -const rrRoot = maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverRoot); -const basenames = maybeGetDatasource(config.namespace, DatasourceNames.Basenames); - -/** - * Returns whether `resolverAddress` on `chainId` is a Known Onchain Static Resolver. - * - * Onchain Static Resolvers must abide the following pattern: - * 1. Onchain: all information necessary for resolution is stored on-chain, and - * 2. Static: All resolve() calls resolve to the exact value previously emitted by the Resolver in - * its events (i.e. no post-processing or other logic, a simple return of the on-chain data). - * 2.a the Resolver MAY implement address record defaulting and still be considered Static (see below). - * 3. Its behavior is unlikely to change (i.e. the contract is not upgradable or is unlikely to be - * upgraded in a way that violates principles 1. or 2.). - * - * NOTE: ContractConfig['address'] can be Address | Address[] but we know all of these are just Address - * - * TODO: these relationships could be encoded in an ENSIP - */ -export function isKnownOnchainStaticResolver(chainId: ChainId, resolverAddress: Address): boolean { - // on the ENS Deployment Chain - if (chainId === rrRoot?.chain.id) { - return [ - // the Root LegacyDefaultResolver is an Onchain Static Resolver - rrRoot.contracts.DefaultPublicResolver1?.address, - - // NOTE: this is _also_ the ENSIP-11 ReverseResolver (aka DefaultReverseResolver2) - rrRoot.contracts.DefaultPublicResolver2?.address, - - // the ENSIP-19 default PublicResolver is an Onchain Static Resolver - rrRoot.contracts.DefaultPublicResolver3?.address, - ] - .filter((address): address is Address => !!address) - .includes(resolverAddress); - } - - // on Base Chain - if (chainId === basenames?.chain.id) { - return [ - // the Basenames Default Resolvers are Onchain Static Resolvers - basenames.contracts.L2Resolver1?.address, - basenames.contracts.L2Resolver2?.address, - ] - .filter((address): address is Address => !!address) - .includes(resolverAddress); - } - - return false; -} - -/** - * Returns whether `resolverAddress` on `chainId` implements address record defaulting. - * - * @see https://docs.ens.domains/ensip/19/#default-address - */ -export function onchainStaticResolverImplementsDefaultAddress( - chainId: ChainId, - resolverAddress: Address, -): boolean { - // on ENS Root Chain - if (chainId === rrRoot?.chain.id) { - return [ - // the DefaultPublicResolver3 (ENSIP-19 default PublicResolver) implements address defaulting - rrRoot.contracts.DefaultPublicResolver3?.address, - ] - .filter((address): address is Address => !!address) - .includes(resolverAddress); - } - - // on Base Chain - if (chainId === basenames?.chain.id) { - return [ - // the Basenames L2Resolver2 implements address defaulting - basenames.contracts.L2Resolver2?.address, - ] - .filter((address): address is Address => !!address) - .includes(resolverAddress); - } - - return false; -} diff --git a/apps/ensapi/src/lib/rpc/public-client.ts b/apps/ensapi/src/lib/public-client.ts similarity index 52% rename from apps/ensapi/src/lib/rpc/public-client.ts rename to apps/ensapi/src/lib/public-client.ts index b726162e4..7cfa125e9 100644 --- a/apps/ensapi/src/lib/rpc/public-client.ts +++ b/apps/ensapi/src/lib/public-client.ts @@ -1,7 +1,8 @@ import config from "@/config"; -import { createPublicClient, fallback, http, type PublicClient } from "viem"; +import { ccipRequest, createPublicClient, fallback, http, type PublicClient } from "viem"; +import { ensTestEnvL1Chain } from "@ensnode/datasources"; import type { ChainId } from "@ensnode/ensnode-sdk"; const _cache = new Map(); @@ -23,6 +24,21 @@ export function getPublicClient(chainId: ChainId): PublicClient { // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs createPublicClient({ transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))), + ccipRead: { + async request({ data, sender, urls }) { + // When running in Docker, ENSApi's viem should fetch the UniversalResolverGateway at + // http://devnet:8547 rather than the default of http://localhost:8547, which is unreachable + // from within the Docker container. So here, if we're handling a CCIP-Read request on + // the ens-test-env L1 Chain, we add the ens-test-env's docker-compose-specific url as + // a fallback if the default (http://localhost:8547) fails. + if (chainId === ensTestEnvL1Chain.id) { + return ccipRequest({ data, sender, urls: [...urls, "http://devnet:8547"] }); + } + + // otherwise, handle as normal + return ccipRequest({ data, sender, urls }); + }, + }, }), ); } diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 5d65771ed..6e0b1ccdd 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -10,25 +10,30 @@ import { type ForwardResolutionArgs, ForwardResolutionProtocolStep, type ForwardResolutionResult, + getENSv1Registry, isNormalizedName, isSelectionEmpty, type Node, + PluginName, parseReverseName, type ResolverRecordsResponse, type ResolverRecordsSelection, TraceableENSProtocol, } from "@ensnode/ensnode-sdk"; +import { + isBridgedResolver, + isExtendedResolver, + isKnownENSIP19ReverseResolver, + isStaticResolver, +} from "@ensnode/ensnode-sdk/internal"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; -import { ENS_ROOT_REGISTRY } from "@/lib/protocol-acceleration/ens-root-registry"; import { findResolver } from "@/lib/protocol-acceleration/find-resolver"; import { getENSIP19ReverseNameRecordFromIndex } from "@/lib/protocol-acceleration/get-primary-name-from-index"; import { getRecordsFromIndex } from "@/lib/protocol-acceleration/get-records-from-index"; -import { possibleKnownCCIPReadShadowRegistryResolverDefersTo } from "@/lib/protocol-acceleration/known-ccip-read-shadow-registry-resolver"; -import { isKnownENSIP19ReverseResolver } from "@/lib/protocol-acceleration/known-ensip-19-reverse-resolvers"; -import { isKnownOnchainStaticResolver } from "@/lib/protocol-acceleration/known-onchain-static-resolver"; import { areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId } from "@/lib/protocol-acceleration/resolver-records-indexed-on-chain"; +import { getPublicClient } from "@/lib/public-client"; import { makeEmptyResolverRecordsResponse, makeRecordsResponseFromIndexedRecords, @@ -39,8 +44,7 @@ import { interpretRawCallsAndResults, makeResolveCalls, } from "@/lib/resolution/resolve-calls-and-results"; -import { supportsENSIP10Interface } from "@/lib/rpc/ensip-10"; -import { getPublicClient } from "@/lib/rpc/public-client"; +import { executeResolveCallsWithUniversalResolver } from "@/lib/resolution/resolve-with-universal-resolver"; import { addEnsProtocolStepEvent, withEnsProtocolStep, @@ -48,7 +52,6 @@ import { const logger = makeLogger("forward-resolution"); const tracer = trace.getTracer("forward-resolution"); -// const metric = metrics.getMeter("forward-resolution"); // NOTE: normalize generic name to force the normalization lib to lazy-load itself (otherwise the // first trace generated here would be unusually slow) @@ -90,13 +93,15 @@ export async function resolveForward ): Promise> { // NOTE: `resolveForward` is just `_resolveForward` with the enforcement that `registry` must // initially be ENS Root Chain's Registry: see `_resolveForward` for additional context. - return _resolveForward(name, selection, { ...options, registry: ENS_ROOT_REGISTRY }); + return _resolveForward(name, selection, { + ...options, + registry: getENSv1Registry(config.namespace), + }); } /** - * Internal Forward Resolution implementation. - * - * NOTE: uses `chainId` parameter for internal Protocol Acceleration behavior (see recursive call below). + * Internal Forward Resolution implementation for a given `name`, beginning from the specified + * `registry`. */ async function _resolveForward( name: ForwardResolutionArgs["name"], @@ -156,12 +161,40 @@ async function _resolveForward( ); } + const publicClient = getPublicClient(chainId); + + //////////////////////////// + /// Temporary ENSv2 Bailout + //////////////////////////// + // TODO: re-enable protocol acceleration for ENSv2 + if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { + // execute each record's call against the UniversalResolver + const rawResults = await withEnsProtocolStep( + TraceableENSProtocol.ForwardResolution, + ForwardResolutionProtocolStep.ExecuteResolveCalls, + {}, + () => + executeResolveCallsWithUniversalResolver({ + name, + calls, + publicClient, + }), + ); + + span.setAttribute("rawResults", JSON.stringify(replaceBigInts(rawResults, String))); + + // additional semantic interpretation of the raw results from the chain + const results = interpretRawCallsAndResults(rawResults); + span.setAttribute("results", JSON.stringify(replaceBigInts(results, String))); + + // return record values + return makeRecordsResponseFromResolveResults(selection, results); + } + ////////////////////////////////////////////////// // 1. Identify the active resolver for the name on the specified chain. ////////////////////////////////////////////////// - const publicClient = getPublicClient(chainId); - const { activeName, activeResolver, requiresWildcardSupport } = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.FindResolver, @@ -204,20 +237,22 @@ async function _resolveForward( ////////////////////////////////////////////////// ////////////////////////////////////////////////// - // Protocol Acceleration: ENSIP-19 Reverse Resolvers - // If: - // 1) the caller requested acceleration, and - // 2) the ProtocolAcceleration plugin is active, and - // 3) the activeResolver is a Known ENSIP-19 Reverse Resolver, - // then we can just read the name record value directly from the index. + // Protocol Acceleration ////////////////////////////////////////////////// - if (accelerate) { - const activeResolverIsKnownENSIP19ReverseResolver = isKnownENSIP19ReverseResolver( - chainId, - activeResolver, - ); - - if (canAccelerate && activeResolverIsKnownENSIP19ReverseResolver) { + if (accelerate && canAccelerate) { + // NOTE: because Resolvers can exist without emitting events (and therefore may or may + // not actually exist in the index), we have to do runtime validation of the Resolver's + // metadata (i.e. whether it's an ENSIP-19 Reverse Resolver, a Bridged Resolver, etc) + // ex: BasenamesL1Resolver need not emit events to function properly + // https://etherscan.io/address/0xde9049636f4a1dfe0a64d1bfe3155c0a14c54f31#code + const resolver = { chainId, address: activeResolver }; + + ////////////////////////////////////////////////// + // Protocol Acceleration: ENSIP-19 Reverse Resolvers + // If the activeResolver is a Known ENSIP-19 Reverse Resolver, + // then we can just read the name record value directly from the index. + ////////////////////////////////////////////////// + if (isKnownENSIP19ReverseResolver(config.namespace, resolver)) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateENSIP19ReverseResolver, @@ -258,29 +293,19 @@ async function _resolveForward( }, ); } - } - - ////////////////////////////////////////////////// - // Protocol Acceleration: CCIP-Read Shadow Registry Resolvers - // If: - // 1) the caller requested acceleration, and - // 2) the ProtocolAcceleration Plugin is active, and - // 3) the activeResolver is a CCIP-Read Shadow Registry Resolver, - // then we can short-circuit the CCIP-Read and defer resolution to the indicated - // (shadow)Registry. - ////////////////////////////////////////////////// - if (accelerate) { - const defersToRegistry = possibleKnownCCIPReadShadowRegistryResolverDefersTo({ - chainId, - address: activeResolver, - }); - if (canAccelerate && defersToRegistry !== null) { + ////////////////////////////////////////////////// + // Protocol Acceleration: Bridged Resolvers + // If the activeResolver is a Bridged Resolver, + // then we can short-circuit the CCIP-Read and defer resolution to the indicated (shadow)Registry. + ////////////////////////////////////////////////// + const bridgesTo = isBridgedResolver(config.namespace, resolver); + if (bridgesTo) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, {}, - () => _resolveForward(name, selection, { ...options, registry: defersToRegistry }), + () => _resolveForward(name, selection, { ...options, registry: bridgesTo }), ); } @@ -290,54 +315,38 @@ async function _resolveForward( ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, false, ); - } - ////////////////////////////////////////////////// - // Protocol Acceleration: Known On-Chain Static Resolvers - // If: - // 1) the caller requested acceleration, and - // 2) the ProtocolAcceleration Plugin is active, and - // 3) the ProtocolAcceleration Plugin indexes records for all Resolver contracts on - // this chain, and - // 4) the activeResolver is a Known Onchain Static Resolver on this chain, - // then we can retrieve records directly from the database. - ////////////////////////////////////////////////// - if (accelerate) { + ////////////////////////////////////////////////// + // Protocol Acceleration: Known On-Chain Static Resolvers + // If: + // 1) the ProtocolAcceleration Plugin indexes records for all Resolver contracts on + // this chain, and + // 2) the activeResolver is a Static Resolver, + // then we can retrieve records directly from the database. + ////////////////////////////////////////////////// const resolverRecordsAreIndexed = areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId( config.namespace, chainId, ); - const activeResolverIsKnownOnchainStaticResolver = isKnownOnchainStaticResolver( - chainId, - activeResolver, - ); - - if ( - canAccelerate && - resolverRecordsAreIndexed && - activeResolverIsKnownOnchainStaticResolver - ) { + if (resolverRecordsAreIndexed && isStaticResolver(config.namespace, resolver)) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, {}, async () => { - const resolver = await getRecordsFromIndex({ - chainId, - resolverAddress: activeResolver, + const records = await getRecordsFromIndex({ + resolver: { chainId, address: activeResolver }, node, selection, }); // if resolver doesn't exist here, there are no records in the index - if (!resolver) { - return makeEmptyResolverRecordsResponse(selection); - } + if (!records) return makeEmptyResolverRecordsResponse(selection); - // format into RecordsResponse and return - return makeRecordsResponseFromIndexedRecords(selection, resolver); + // otherwise, format into RecordsResponse and return + return makeRecordsResponseFromIndexedRecords(selection, records); }, ); } @@ -357,28 +366,28 @@ async function _resolveForward( ////////////////////////////////////////////////// // 3.1 requireResolver() — verifies that the resolver supports ENSIP-10 if necessary - const isExtendedResolver = await withEnsProtocolStep( + const extended = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.RequireResolver, { chainId, activeResolver, requiresWildcardSupport }, async (span) => { - const isExtendedResolver = await withSpanAsync( + const extended = await withSpanAsync( tracer, - "supportsENSIP10Interface", + "isExtendedResolver", { chainId, address: activeResolver }, - () => supportsENSIP10Interface({ address: activeResolver, publicClient }), + () => isExtendedResolver({ address: activeResolver, publicClient }), ); - span.setAttribute("isExtendedResolver", isExtendedResolver); + span.setAttribute("isExtendedResolver", extended); - return isExtendedResolver; + return extended; }, ); // if we require wildcard support and this is NOT an extended resolver, the resolver is not // valid, i.e. there is no active resolver for the name // https://docs.ens.domains/ensip/10/#specification - if (requiresWildcardSupport && !isExtendedResolver) { + if (requiresWildcardSupport && !extended) { return makeEmptyResolverRecordsResponse(selection); } @@ -393,7 +402,7 @@ async function _resolveForward( resolverAddress: activeResolver, // NOTE: ENSIP-10 specifies that if a resolver supports IExtendedResolver, // the client MUST use the ENSIP-10 resolve() method over the legacy methods. - useENSIP10Resolve: isExtendedResolver, + useENSIP10Resolve: extended, calls, publicClient, }), diff --git a/apps/ensapi/src/lib/resolution/make-records-response.test.ts b/apps/ensapi/src/lib/resolution/make-records-response.test.ts index ebe2e064b..bd060f2a2 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.test.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.test.ts @@ -12,8 +12,8 @@ describe("lib-resolution", () => { const mockRecords: IndexedResolverRecords = { name: "test.eth", addressRecords: [ - { coinType: 60n, address: "0x123" }, - { coinType: 1001n, address: "0x456" }, + { coinType: 60n, value: "0x123" }, + { coinType: 1001n, value: "0x456" }, ], textRecords: [ { key: "com.twitter", value: "@test" }, diff --git a/apps/ensapi/src/lib/resolution/make-records-response.ts b/apps/ensapi/src/lib/resolution/make-records-response.ts index 1f8d62cf4..ff77bf387 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.ts @@ -9,7 +9,7 @@ import type { ResolveCallsAndResults } from "./resolve-calls-and-results"; export interface IndexedResolverRecords { name: string | null; - addressRecords: { coinType: bigint; address: string }[]; + addressRecords: { coinType: bigint; value: string }[]; textRecords: { key: string; value: string }[]; } @@ -34,7 +34,7 @@ export function makeRecordsResponseFromIndexedRecords { memo[coinType] = - records.addressRecords.find((r) => bigintToCoinType(r.coinType) === coinType)?.address || + records.addressRecords.find((r) => bigintToCoinType(r.coinType) === coinType)?.value || null; return memo; }, diff --git a/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts b/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts index 90855771b..f00b21fad 100644 --- a/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts +++ b/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts @@ -117,6 +117,7 @@ export async function executeResolveCalls { const ResolverContract = { abi: ResolverABI, address: resolverAddress } as const; + // NOTE: automatically multicalled by viem return await Promise.all( calls.map(async (call) => { try { diff --git a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts new file mode 100644 index 000000000..6f30011d2 --- /dev/null +++ b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts @@ -0,0 +1,86 @@ +import config from "@/config"; + +import { + bytesToHex, + ContractFunctionExecutionError, + decodeAbiParameters, + encodeFunctionData, + getAbiItem, + type PublicClient, + size, +} from "viem"; +import { packetToBytes } from "viem/ens"; + +import { DatasourceNames, getDatasource, ResolverABI } from "@ensnode/datasources"; +import type { Name, ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import type { + ResolveCalls, + ResolveCallsAndRawResults, +} from "@/lib/resolution/resolve-calls-and-results"; + +const ensroot = getDatasource(config.namespace, DatasourceNames.ENSRoot); + +/** + * Execute a set of ResolveCalls for `name` against the UniversalResolver. + */ +export async function executeResolveCallsWithUniversalResolver< + SELECTION extends ResolverRecordsSelection, +>({ + name, + calls, + publicClient, +}: { + name: Name; + calls: ResolveCalls; + publicClient: PublicClient; +}): Promise> { + // NOTE: automatically multicalled by viem + return await Promise.all( + calls.map(async (call) => { + try { + const encodedName = bytesToHex(packetToBytes(name)); // DNS-encode `name` for resolve() + const encodedMethod = encodeFunctionData({ abi: ResolverABI, ...call }); + + const [value] = await publicClient.readContract({ + abi: ensroot.contracts.UniversalResolver.abi, + address: ensroot.contracts.UniversalResolver.address, + functionName: "resolve", + args: [encodedName, encodedMethod], + }); + + // if resolve() returned empty bytes or reverted, coalece to null + if (size(value) === 0) { + return { call, result: null, reason: "returned empty response" }; + } + + // ENSIP-10 — resolve() always returns bytes that need to be decoded + const results = decodeAbiParameters( + getAbiItem({ abi: ResolverABI, name: call.functionName, args: call.args }).outputs, + value, + ); + + // NOTE: results is type-guaranteed to have at least 1 result (because each abi item's outputs.length >= 1) + const result = results[0]; + + console.log(`.resolve(${call.functionName}, ${call.args}) -> ${result}`); + + return { + call, + result: result, + reason: `.resolve(${call.functionName}, ${call.args})`, + }; + } catch (error) { + console.log(`.resolve(${call.functionName}, ${call.args}) -> ${error}`); + + // in general, reverts are expected behavior + if (error instanceof ContractFunctionExecutionError) { + return { call, result: null, reason: error.shortMessage }; + } + + // otherwise, rethrow + throw error; + } + }), + ); +} diff --git a/apps/ensapi/src/lib/rpc/ensip-10.ts b/apps/ensapi/src/lib/rpc/ensip-10.ts deleted file mode 100644 index 79d8b6a18..000000000 --- a/apps/ensapi/src/lib/rpc/ensip-10.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Address, PublicClient } from "viem"; - -import { supportsInterface } from "./eip-165"; - -/** - * ENSIP-10 Wildcard Resolution Interface Id - * @see https://docs.ens.domains/ensip/10 - */ -const ENSIP10_INTERFACE_ID = "0x9061b923"; - -/** - * Determines whether a Resolver contract supports ENSIP-10. - */ -export async function supportsENSIP10Interface({ - address, - publicClient, -}: { - address: Address; - publicClient: PublicClient; -}) { - return await supportsInterface({ - address, - interfaceId: ENSIP10_INTERFACE_ID, - publicClient, - }); -} diff --git a/apps/ensapi/src/middleware/can-accelerate.middleware.ts b/apps/ensapi/src/middleware/can-accelerate.middleware.ts index ea4e4e2fd..9aee7188b 100644 --- a/apps/ensapi/src/middleware/can-accelerate.middleware.ts +++ b/apps/ensapi/src/middleware/can-accelerate.middleware.ts @@ -11,6 +11,7 @@ export type CanAccelerateMiddlewareVariables = { canAccelerate: boolean }; // TODO: expand this datamodel to include 'reasons' acceleration was disabled to drive ui +let didWarnCannotAccelerateENSv2 = false; let didWarnNoProtocolAccelerationPlugin = false; let didInitialCanAccelerate = false; let prevCanAccelerate = false; @@ -28,9 +29,26 @@ export const canAccelerateMiddleware = factory.createMiddleware(async (c, next) throw new Error(`Invariant(canAccelerateMiddleware): isRealtime middleware required`); } - ///////////////////////////////////////////// + //////////////////////////// + /// Temporary ENSv2 Bailout + //////////////////////////// + // TODO: re-enable acceleration for ensv2 once implemented + if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { + if (!didWarnCannotAccelerateENSv2) { + logger.warn( + `ENSApi is currently unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, + ); + + didWarnCannotAccelerateENSv2 = true; + } + + c.set("canAccelerate", false); + return await next(); + } + + ////////////////////////////////////////////// /// Protocol Acceleration Plugin Availability - ///////////////////////////////////////////// + ////////////////////////////////////////////// const hasProtocolAccelerationPlugin = config.ensIndexerPublicConfig.plugins.includes( PluginName.ProtocolAcceleration, @@ -45,9 +63,9 @@ export const canAccelerateMiddleware = factory.createMiddleware(async (c, next) didWarnNoProtocolAccelerationPlugin = true; } - ///////////////////////////// + ////////////////////////////// /// Can Accelerate Derivation - ///////////////////////////// + ////////////////////////////// // the Resolution API can accelerate requests if // a) ENSIndexer reports that it is within MAX_REALTIME_DISTANCE_TO_ACCELERATE of realtime, and diff --git a/apps/ensapi/src/middleware/require-core-plugin.middleware.ts b/apps/ensapi/src/middleware/require-core-plugin.middleware.ts index 7b951719a..fe38e147a 100644 --- a/apps/ensapi/src/middleware/require-core-plugin.middleware.ts +++ b/apps/ensapi/src/middleware/require-core-plugin.middleware.ts @@ -16,14 +16,16 @@ import { factory } from "@/lib/hono-factory"; export const requireCorePluginMiddleware = (core: "subgraph" | "ensv2") => factory.createMiddleware(async (c, next) => { if ( - core === "subgraph" && + core === "subgraph" && // !config.ensIndexerPublicConfig.plugins.includes(PluginName.Subgraph) ) { return c.notFound(); } - // TODO: enable ensv2 checking - if (core === "ensv2") { + if ( + core === "ensv2" && // + !config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2) + ) { return c.notFound(); } diff --git a/apps/ensindexer/.env.local.example b/apps/ensindexer/.env.local.example index 73439e54b..8820cfcfb 100644 --- a/apps/ensindexer/.env.local.example +++ b/apps/ensindexer/.env.local.example @@ -148,12 +148,6 @@ # - required by plugins: protocol-acceleration # RPC_URL_534351= -# === ENS Namespace: Holesky === -# Ethereum Holesky (public testnet) -# - required if the configured namespace is holesky -# - required by plugins: subgraph, protocol-acceleration, tokenscope -# RPC_URL_17000= - # === ENS Namespace: ens-test-env === # ens-test-env (local testnet) # - required if the configured namespace is ens-test-env @@ -182,8 +176,8 @@ DATABASE_SCHEMA=production DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # ENS Namespace Configuration -# Required. Must be an ENS namespace's Identifier such as mainnet, sepolia, holesky, -# or ens-test-env. (see `@ensnode/datasources` for available options). +# Required. Must be an ENS namespace's Identifier such as mainnet, sepolia, or ens-test-env. +# (see `@ensnode/datasources` for available options). NAMESPACE=mainnet # Plugin Configuration diff --git a/apps/ensindexer/package.json b/apps/ensindexer/package.json index 8d1daaa24..019cd5395 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -12,7 +12,7 @@ }, "homepage": "https://github.com/namehash/ensnode/tree/main/apps/ensindexer", "scripts": { - "dev": "DATABASE_SCHEMA=public ponder --root ./ponder dev", + "dev": "DATABASE_SCHEMA=public ponder --root ./ponder dev --disable-ui", "start": "ponder --root ./ponder start", "serve": "ponder --root ./ponder serve", "db": "ponder --root ./ponder db", diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 05e95d3f7..20a398fdc 100644 --- a/apps/ensindexer/ponder/src/register-handlers.ts +++ b/apps/ensindexer/ponder/src/register-handlers.ts @@ -7,6 +7,7 @@ import config from "@/config"; import { PluginName } from "@ensnode/ensnode-sdk"; +import attach_ENSv2Handlers from "@/plugins/ensv2/event-handlers"; import attach_protocolAccelerationHandlers from "@/plugins/protocol-acceleration/event-handlers"; import attach_RegistrarsHandlers from "@/plugins/registrars/event-handlers"; import attach_BasenamesHandlers from "@/plugins/subgraph/plugins/basenames/event-handlers"; @@ -49,3 +50,12 @@ if (config.plugins.includes(PluginName.Registrars)) { if (config.plugins.includes(PluginName.TokenScope)) { attach_TokenscopeHandlers(); } + +// ENSv2 Plugin +// NOTE: Because the ENSv2 plugin depends on node migration logic in the ProtocolAcceleration plugin, +// it's important that ENSv2 handlers are registered _after_ Protocol Acceleration handlers. This +// ensures that the Protocol Acceleration handlers are executed first and the results of their node +// migration indexing is available for the identical handlers in the ENSv2 plugin. +if (config.plugins.includes(PluginName.ENSv2)) { + attach_ENSv2Handlers(); +} diff --git a/apps/ensindexer/src/config/config.test.ts b/apps/ensindexer/src/config/config.test.ts index 6aa7c41ca..43f6ffb05 100644 --- a/apps/ensindexer/src/config/config.test.ts +++ b/apps/ensindexer/src/config/config.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ensTestEnvL1Chain, ensTestEnvL2Chain } from "@ensnode/datasources"; import { ENSNamespaceIds, PluginName } from "@ensnode/ensnode-sdk"; import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; @@ -655,7 +656,17 @@ describe("config (minimal base env)", () => { await expect(getConfig()).resolves.toMatchObject({ plugins: [PluginName.TokenScope] }); }); - describe("with ALCHEMY_API_KEY", async () => { + describe("ens-test-env rpcs", () => { + it("should provide ens-test-env rpc defaults", async () => { + stubEnv({ NAMESPACE: "ens-test-env", PLUGINS: "subgraph" }); + + const config = await getConfig(); + expect(config.rpcConfigs.has(ensTestEnvL1Chain.id)).toBe(true); + expect(config.rpcConfigs.has(ensTestEnvL2Chain.id)).toBe(true); + }); + }); + + describe("with ALCHEMY_API_KEY", () => { beforeEach(() => { stubEnv({ ALCHEMY_API_KEY: "anything", RPC_URL_1: undefined }); }); @@ -675,11 +686,6 @@ describe("config (minimal base env)", () => { "must have ws rpc url", ).toBe(true); }); - - it("does not provide alchemy if chain id is not supported", async () => { - stubEnv({ NAMESPACE: "ens-test-env", PLUGINS: "subgraph" }); - await expect(getConfig()).rejects.toThrow(/RPC Config/); - }); }); describe("with DRPC_API_KEY", async () => { @@ -698,17 +704,11 @@ describe("config (minimal base env)", () => { "must have http rpc url", ).toBe(true); - // TODO: update this when auto-generated ws:// urls are added, this test will have failed expect( rpcConfigs.every((rpcConfig) => rpcConfig.websocketRPC === undefined), "must not have ws rpc url", ).toBe(true); }); - - it("does not provide dRPC if chain id is not supported", async () => { - stubEnv({ NAMESPACE: "ens-test-env", PLUGINS: "subgraph" }); - await expect(getConfig()).rejects.toThrow(/RPC Config/); - }); }); describe("with ALCHEMY_API_KEY, DRPC_API_KEY, and RPC_URL_1", async () => { diff --git a/apps/ensindexer/src/config/derived-params.ts b/apps/ensindexer/src/config/derived-params.ts index 78d014ae8..c6713e3e6 100644 --- a/apps/ensindexer/src/config/derived-params.ts +++ b/apps/ensindexer/src/config/derived-params.ts @@ -1,7 +1,7 @@ +import { type ENSNamespace, getENSNamespace } from "@ensnode/datasources"; import type { ChainId } from "@ensnode/ensnode-sdk"; import type { ENSIndexerConfig } from "@/config/types"; -import { getENSNamespaceAsFullyDefinedAtCompileTime } from "@/lib/plugin-helpers"; import { getPlugin } from "@/plugins"; /** @@ -18,15 +18,14 @@ export const derive_indexedChainIds = < ): CONFIG & { indexedChainIds: ENSIndexerConfig["indexedChainIds"] } => { const indexedChainIds = new Set(); - const datasources = getENSNamespaceAsFullyDefinedAtCompileTime(config.namespace); + const datasources = getENSNamespace(config.namespace) as ENSNamespace; for (const pluginName of config.plugins) { const datasourceNames = getPlugin(pluginName).requiredDatasourceNames; for (const datasourceName of datasourceNames) { - const { chain } = datasources[datasourceName]; - - indexedChainIds.add(chain.id); + const datasource = datasources[datasourceName]; + if (datasource) indexedChainIds.add(datasource.chain.id); } } diff --git a/apps/ensindexer/src/config/validations.ts b/apps/ensindexer/src/config/validations.ts index 2544782f9..48f6f43b9 100644 --- a/apps/ensindexer/src/config/validations.ts +++ b/apps/ensindexer/src/config/validations.ts @@ -1,24 +1,25 @@ import { type Address, isAddress } from "viem"; -import type { z } from "zod/v4"; -import type { DatasourceName } from "@ensnode/datasources"; -import { asLowerCaseAddress, uniq } from "@ensnode/ensnode-sdk"; +import { + type DatasourceName, + type ENSNamespace, + getENSNamespace, + maybeGetDatasource, +} from "@ensnode/datasources"; +import { asLowerCaseAddress, PluginName, uniq } from "@ensnode/ensnode-sdk"; +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; -import { getENSNamespaceAsFullyDefinedAtCompileTime } from "@/lib/plugin-helpers"; import { getPlugin } from "@/plugins"; import type { ENSIndexerConfig } from "./types"; -// type alias to highlight the input param of Zod's check() method -type ZodCheckFnInput = z.core.ParsePayload; - // Invariant: specified plugins' datasources are available in the specified namespace's Datasources export function invariant_requiredDatasources( ctx: ZodCheckFnInput>, ) { const { value: config } = ctx; - const datasources = getENSNamespaceAsFullyDefinedAtCompileTime(config.namespace); + const datasources = getENSNamespace(config.namespace); const availableDatasourceNames = Object.keys(datasources) as DatasourceName[]; // validate that each active plugin's requiredDatasources are available in availableDatasourceNames @@ -50,19 +51,18 @@ export function invariant_rpcConfigsSpecifiedForIndexedChains( ) { const { value: config } = ctx; - const datasources = getENSNamespaceAsFullyDefinedAtCompileTime(config.namespace); - for (const pluginName of config.plugins) { const datasourceNames = getPlugin(pluginName).requiredDatasourceNames; for (const datasourceName of datasourceNames) { - const { chain } = datasources[datasourceName]; + const datasource = maybeGetDatasource(config.namespace, datasourceName); + if (!datasource) continue; // ignore undefined datasources, caught by requiredDatasources invariant - if (!config.rpcConfigs.has(chain.id)) { + if (!config.rpcConfigs.has(datasource.chain.id)) { ctx.issues.push({ code: "custom", input: config, - message: `Plugin '${pluginName}' indexes chain with id ${chain.id} but RPC_URL_${chain.id} is not specified.`, + message: `Plugin '${pluginName}' indexes chain with id ${datasource.chain.id} but RPC_URL_${datasource.chain.id} is not specified.`, }); } } @@ -77,11 +77,12 @@ export function invariant_globalBlockrange( const { globalBlockrange } = config; if (globalBlockrange.startBlock !== undefined || globalBlockrange.endBlock !== undefined) { - const datasources = getENSNamespaceAsFullyDefinedAtCompileTime(config.namespace); + const datasources = getENSNamespace(config.namespace) as ENSNamespace; const indexedChainIds = uniq( config.plugins .flatMap((pluginName) => getPlugin(pluginName).requiredDatasourceNames) .map((datasourceName) => datasources[datasourceName]) + .filter((ds) => !!ds) // ignore undefined datasources, caught by requiredDatasources invariant .map((datasource) => datasource.chain.id), ); @@ -97,7 +98,7 @@ export function invariant_globalBlockrange( END_BLOCK=${globalBlockrange.endBlock || "n/a"} The usage you're most likely interested in is: - NAMESPACE=(mainnet|sepolia|holesky) PLUGINS=subgraph END_BLOCK=x pnpm run start + NAMESPACE=(mainnet|sepolia) PLUGINS=subgraph END_BLOCK=x pnpm run start which runs just the 'subgraph' plugin with a specific end block, suitable for snapshotting ENSNode and comparing to Subgraph snapshots. In the future, indexing multiple chains with chain-specific blockrange constraints may be possible.`, @@ -112,12 +113,14 @@ export function invariant_validContractConfigs( ) { const { value: config } = ctx; - const datasources = getENSNamespaceAsFullyDefinedAtCompileTime(config.namespace); - for (const datasourceName of Object.keys(datasources) as DatasourceName[]) { - const { contracts } = datasources[datasourceName]; + const datasources = getENSNamespace(config.namespace) as ENSNamespace; + const datasourceNames = Object.keys(datasources) as DatasourceName[]; + for (const datasourceName of datasourceNames) { + const datasource = datasources[datasourceName]; + if (!datasource) continue; // ignore undefined datasources, caught by requiredDatasources invariant // Invariant: `contracts` must provide valid addresses if a filter is not provided - for (const [contractName, contractConfig] of Object.entries(contracts)) { + for (const [contractName, contractConfig] of Object.entries(datasource.contracts)) { if ("address" in contractConfig && typeof contractConfig.address === "string") { // only ContractConfigs with `address` defined const isValidAddress = @@ -133,3 +136,20 @@ export function invariant_validContractConfigs( } } } + +// Invariant: ensv2 core plugin requires protocol acceleration +export function invariant_ensv2RequiresProtocolAcceleration( + ctx: ZodCheckFnInput>, +) { + const { value: config } = ctx; + + // TODO: getCorePlugin(config.plugins) + if ( + config.plugins.includes(PluginName.ENSv2) && + !config.plugins.includes(PluginName.ProtocolAcceleration) + ) { + throw new Error( + `Core Plugin '${PluginName.ENSv2}' requires inclusion of '${PluginName.ProtocolAcceleration}' plugin.`, + ); + } +} diff --git a/apps/ensindexer/src/lib/currencies.ts b/apps/ensindexer/src/lib/currencies.ts index 7a879344e..6b6f3c570 100644 --- a/apps/ensindexer/src/lib/currencies.ts +++ b/apps/ensindexer/src/lib/currencies.ts @@ -1,14 +1,5 @@ import { type Address, zeroAddress } from "viem"; -import { - base, - baseSepolia, - holesky, - linea, - lineaSepolia, - mainnet, - optimism, - sepolia, -} from "viem/chains"; +import { base, baseSepolia, linea, lineaSepolia, mainnet, optimism, sepolia } from "viem/chains"; import { type AccountId, type ChainId, type CurrencyId, CurrencyIds } from "@ensnode/ensnode-sdk"; @@ -53,13 +44,6 @@ const SUPPORTED_CURRENCY_CONTRACTS: Record> "0x176211869ca2b568f2a7d4ee941e073a821ee1ff": CurrencyIds.USDC, "0x4af15ec2a0bd43db75dd04e62faa3b8ef36b00d5": CurrencyIds.DAI, }, - - /** holesky namespace */ - [holesky.id]: { - [zeroAddress]: CurrencyIds.ETH, - "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": CurrencyIds.USDC, - "0x3e622317f8c93f7328350cf0b56d9ed4c620c5d6": CurrencyIds.DAI, - }, }; /** diff --git a/apps/ensindexer/src/lib/dns-helpers.test.ts b/apps/ensindexer/src/lib/dns-helpers.test.ts index 6ac10f464..38eaac0a1 100644 --- a/apps/ensindexer/src/lib/dns-helpers.test.ts +++ b/apps/ensindexer/src/lib/dns-helpers.test.ts @@ -16,7 +16,7 @@ import { // Example TXT `record` representing key: 'com.twitter', value: '0xTko' // via: https://optimistic.etherscan.io/tx/0xf32db67e7bf2118ea2c3dd8f40fc48d18e83a4a2317fbbddce8f741e30a1e8d7#eventlog const { args } = decodeEventLog({ - abi: getDatasource("mainnet", "threedns-base").contracts.Resolver.abi, + abi: getDatasource("mainnet", "threednsBase").contracts.Resolver.abi, topics: [ "0xaaac3b4b3e6807b5b4585562beabaa2de9bd07db514a1eba2c11d1af5b9d9dc7", "0x6470e2677db6a5bb6c69e51fce7271aeeb5f2808ea7dfdf34b703749555b3e10", diff --git a/apps/ensindexer/src/lib/ensraibow-api-client.ts b/apps/ensindexer/src/lib/ensraibow-api-client.ts index edac19015..d41ed44a1 100644 --- a/apps/ensindexer/src/lib/ensraibow-api-client.ts +++ b/apps/ensindexer/src/lib/ensraibow-api-client.ts @@ -16,9 +16,7 @@ export function getENSRainbowApiClient() { EnsRainbowApiClient.defaultOptions().endpointUrl ) { console.warn( - `Using default public ENSRainbow server which may cause increased network latency. -For production, use your own ENSRainbow server that runs on the same network -as the ENSIndexer server.`, + `Using default public ENSRainbow server which may cause increased network latency. For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`, ); } diff --git a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts new file mode 100644 index 000000000..a7c2e40be --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts @@ -0,0 +1,16 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import type { Address } from "viem"; + +import { interpretAddress } from "@ensnode/ensnode-sdk"; + +/** + * Ensures that the account identified by `address` exists. + * If `address` is the zeroAddress, no-op. + */ +export async function ensureAccount(context: Context, address: Address) { + const interpreted = interpretAddress(address); + if (interpreted === null) return; + + await context.db.insert(schema.account).values({ id: interpreted }).onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts new file mode 100644 index 000000000..90097edbe --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/domain-db-helpers.ts @@ -0,0 +1,22 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import type { Address } from "viem"; + +import { type ENSv1DomainId, interpretAddress } from "@ensnode/ensnode-sdk"; + +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; + +/** + * Sets an ENSv1 Domain's effective owner to `owner`. + */ +export async function materializeENSv1DomainEffectiveOwner( + context: Context, + id: ENSv1DomainId, + owner: Address, +) { + // ensure owner + await ensureAccount(context, owner); + + // update v1Domain's effective owner + await context.db.update(schema.v1Domain, { id }).set({ ownerId: interpretAddress(owner) }); +} diff --git a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts new file mode 100644 index 000000000..87c257627 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts @@ -0,0 +1,49 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import { labelhash } from "viem"; + +import { + encodeLabelHash, + type InterpretedLabel, + type LabelHash, + type LiteralLabel, + literalLabelToInterpretedLabel, +} from "@ensnode/ensnode-sdk"; + +import { labelByLabelHash } from "@/lib/graphnode-helpers"; + +/** + * Ensures that the LiteralLabel `label` is interpreted and upserted into the Label rainbow table. + */ +export async function ensureLabel(context: Context, label: LiteralLabel) { + const labelHash = labelhash(label); + const interpretedLabel = literalLabelToInterpretedLabel(label); + + await context.db + .insert(schema.label) + .values({ labelHash, value: interpretedLabel }) + .onConflictDoUpdate({ value: interpretedLabel }); +} + +/** + * Ensures that the LabelHash `labelHash` is available in the Label rainbow table, attempting an + * ENSRainbow heal if this is the first time it has been encountered. + */ +export async function ensureUnknownLabel(context: Context, labelHash: LabelHash) { + // do nothing for existing labels, they're either healed or we don't know them + const exists = await context.db.find(schema.label, { labelHash }); + if (exists) return; + + // attempt ENSRainbow heal + const healedLabel = await labelByLabelHash(labelHash); + + // if healed, ensure (known) label + if (healedLabel) return await ensureLabel(context, healedLabel); + + // otherwise upsert label entity + const interpretedLabel = encodeLabelHash(labelHash) as InterpretedLabel; + await context.db + .insert(schema.label) + .values({ labelHash, value: interpretedLabel }) + .onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/lib/ensv2/registrar-lib.ts b/apps/ensindexer/src/lib/ensv2/registrar-lib.ts new file mode 100644 index 000000000..62e78a075 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/registrar-lib.ts @@ -0,0 +1,130 @@ +import config from "@/config"; + +import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; +import { + type AccountId, + accountIdEqual, + getDatasourceContract, + type InterpretedName, + type LabelHash, + maybeGetDatasourceContract, + type Name, + uint256ToHex32, +} from "@ensnode/ensnode-sdk"; + +const ethnamesNameWrapper = getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "NameWrapper", +); + +const lineanamesNameWrapper = maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Lineanames, + "NameWrapper", +); + +/** + * Mapping of RegistrarManagedName to its related Registrar and Registrar-adjacent contracts. + */ +const REGISTRAR_CONTRACTS_BY_MANAGED_NAME: Record = { + eth: [ + getDatasourceContract( + config.namespace, // + DatasourceNames.ENSRoot, + "BaseRegistrar", + ), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "LegacyEthRegistrarController", + ), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "WrappedEthRegistrarController", + ), + getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "UnwrappedEthRegistrarController", + ), + ethnamesNameWrapper, + ], + "base.eth": [ + maybeGetDatasourceContract( + config.namespace, // + DatasourceNames.Basenames, + "BaseRegistrar", + ), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "EARegistrarController", + ), + maybeGetDatasourceContract( + config.namespace, // + DatasourceNames.Basenames, + "RegistrarController", + ), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "UpgradeableRegistrarController", + ), + ].filter((c) => !!c), + "linea.eth": [ + maybeGetDatasourceContract(config.namespace, DatasourceNames.Lineanames, "BaseRegistrar"), + maybeGetDatasourceContract( + config.namespace, + DatasourceNames.Lineanames, + "EthRegistrarController", + ), + lineanamesNameWrapper, + ].filter((c) => !!c), +}; + +/** + * Certain RegistrarManagedNames are different depending on the ENSNamespace — this encodes that + * relationship. + */ +const RMN_NAMESPACE_OVERRIDE: Partial>> = { + sepolia: { + "base.eth": "basetest.eth", + "linea.eth": "linea-sepolia.eth", + }, +}; + +/** + * Given a `contract`, identify its RegistrarManagedName. + */ +export const getRegistrarManagedName = (contract: AccountId) => { + for (const [managedName, contracts] of Object.entries(REGISTRAR_CONTRACTS_BY_MANAGED_NAME)) { + const isAnyOfTheContracts = contracts.some((_contract) => accountIdEqual(_contract, contract)); + if (isAnyOfTheContracts) { + const namespaceSpecificManagedName = + RMN_NAMESPACE_OVERRIDE[config.namespace]?.[managedName] ?? managedName; + // override the rmn with namespace-specific version if available + return namespaceSpecificManagedName as InterpretedName; + } + } + + throw new Error("never"); +}; + +/** + * Determines whether `contract` is the NameWrapper. + */ +export function isNameWrapper(contract: AccountId) { + if (accountIdEqual(ethnamesNameWrapper, contract)) return true; + if (lineanamesNameWrapper && accountIdEqual(lineanamesNameWrapper, contract)) return true; + return false; +} + +/** + * BaseRegistrar-derived Registrars register direct subnames of a RegistrarManagedName. As such, the + * tokens issued by them are keyed by the direct subname's label's labelHash. + * + * https://github.com/ensdomains/ens-contracts/blob/db613bc/contracts/ethregistrar/ETHRegistrarController.sol#L215 + */ +export const registrarTokenIdToLabelHash = (tokenId: bigint): LabelHash => uint256ToHex32(tokenId); diff --git a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts new file mode 100644 index 000000000..14c760683 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts @@ -0,0 +1,112 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; + +import { + type DomainId, + makeLatestRegistrationId, + makeLatestRenewalId, + makeRegistrationId, + makeRenewalId, +} from "@ensnode/ensnode-sdk"; + +import { toJson } from "@/lib/json-stringify-with-bigints"; + +/** + * Latest Registration & Renewals + * + * We store a one-to-many relationship of Domain -> Registration and a one-to-many relationship of + * Registration -> Renewal, but must efficiently access the latest Registration or Renewal in our + * indexing logic. The concrete reason for this is that information regarding the latest Registration + * is spread across event handlers: in ENSv1, the .eth BaseRegistrar emits an event that omits pricing + * information. This pricing information is only knowable until the RegistrarController emits an event + * (directly afterwards) including said info. If we were to only index the RegistrarController, however, + * we could theoretically miss Registrations or Renewals created by a RegistrarController that we don't + * index for whatever reason. + * + * If we were to access these entities via a custom sql query like "SELECT * from registrations + * WHERE domainId= $domainId ORDER BY index DESC", ponder's in-memory cache would have to be flushed + * to postgres every time we access the latest Registration/Renewal, which is pretty frequent. + * To avoid this, we use the special key path /latest (instead of /:index) to access the + * latest Registration/Renewal, turning the operation into an O(1) lookup compatible with Ponder's + * in-memory cacheable db api. + * + * Then, when a new Registration/Renewal is to be created, the current latest is 'superceded': its id + * that is currently /latest is replaced by /:index and the new /latest is inserted. To make this + * compatible with Ponder's cacheable api, instead of updating the id, we delete the /latest entity + * and insert a new entity (with all of the same columns) under the new id. See `supercedeLatestRegistration` + * for implementation. + * + * This same logic applies to Renewals. Note that the foreign key for a Renewal's Registration is NOT + * the RegistrationId (which changes from /latest to /:index as discussed) but is + * (domainId, registrationIndex, index). The renewals_relationships shows how the composite key + * (domainId, registrationIndex) is used to join Registrations and Renewals. + * + * Finally, RenewalIds must use the 'pinned' RegistrationId (i.e. /:index) at all times, to avoid + * uniqueness collisions when Registrations are superceded. + */ + +/** + * Gets the latest Regsitration for the provided `domainId`. + */ +export async function getLatestRegistration(context: Context, domainId: DomainId) { + return context.db.find(schema.registration, { id: makeLatestRegistrationId(domainId) }); +} + +/** + * Supercedes the latest Registration, pinning its, making room in the set for a new latest Registration. + */ +export async function supercedeLatestRegistration( + context: Context, + registration: typeof schema.registration.$inferSelect, +) { + // Invariant: Must be the latest Registration + if (registration.id !== makeLatestRegistrationId(registration.domainId)) { + throw new Error( + `Invariant(supercedeLatestRegistration): Attempted to supercede non-latest Registration:\n${toJson(registration)}`, + ); + } + + // delete latest + await context.db.delete(schema.registration, { id: registration.id }); + + // insert existing data into new Registration w/ pinned RegistrationId + await context.db.insert(schema.registration).values({ + ...registration, + id: makeRegistrationId(registration.domainId, registration.index), + }); +} + +/** + * Gets the latest Renewal. + */ +export async function getLatestRenewal( + context: Context, + domainId: DomainId, + registrationIndex: number, +) { + return context.db.find(schema.renewal, { id: makeLatestRenewalId(domainId, registrationIndex) }); +} + +/** + * Supercedes the latest Renewal, pinning its id, making room in the set for a new latest Renewal. + */ +export async function supercedeLatestRenewal( + context: Context, + renewal: typeof schema.renewal.$inferSelect, +) { + // Invariant: Must be the latest Renewal + if (renewal.id !== makeLatestRenewalId(renewal.domainId, renewal.registrationIndex)) { + throw new Error( + `Invariant(supercedeLatestRenewal): Attempted to supercede non-latest Renewal:\n${toJson(renewal)}`, + ); + } + + // delete latest + await context.db.delete(schema.renewal, { id: renewal.id }); + + // insert existing data into new Renewal w/ 'pinned' RenewalId + await context.db.insert(schema.renewal).values({ + ...renewal, + id: makeRenewalId(renewal.domainId, renewal.registrationIndex, renewal.index), + }); +} diff --git a/apps/ensindexer/src/lib/get-this-account-id.ts b/apps/ensindexer/src/lib/get-this-account-id.ts new file mode 100644 index 000000000..86b023b44 --- /dev/null +++ b/apps/ensindexer/src/lib/get-this-account-id.ts @@ -0,0 +1,14 @@ +import type { Context } from "ponder:registry"; + +import type { AccountId } from "@ensnode/ensnode-sdk"; + +import type { LogEvent } from "@/lib/ponder-helpers"; + +/** + * Retrieves the AccountId representing the contract on this chain under which `event` was emitted. + * + * @example + * const { chainId, address } = getThisAccountId(context, event); + */ +export const getThisAccountId = (context: Context, event: Pick) => + ({ chainId: context.chain.id, address: event.log.address }) satisfies AccountId; diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index 5c7252524..b3d2e0ce0 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -14,7 +14,16 @@ const ensRainbowApiClient = getENSRainbowApiClient(); * @throws if the labelHash is not correctly formatted, or server error occurs, or connection error occurs. **/ export async function labelByLabelHash(labelHash: LabelHash): Promise { - const response = await ensRainbowApiClient.heal(labelHash); + let response: Awaited>; + try { + response = await ensRainbowApiClient.heal(labelHash); + } catch (error) { + if (error instanceof Error) { + error.message = `ENSRainbow Heal Request Failed: ENSRainbow unavailable at '${ensRainbowApiClient.getOptions().endpointUrl}'.`; + } + + throw error; + } if (isHealError(response)) { // no original label found for the labelHash 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 348995acc..d42457a72 100644 --- a/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts +++ b/apps/ensindexer/src/lib/heal-addr-reverse-subname-label.ts @@ -72,13 +72,13 @@ export async function healAddrReverseSubnameLabel( // https://etherscan.io/tx/0x9a6a5156f9f1fc6b1d5551483b97930df32e802f2f9229b35572170f1111134d // The `debug_traceTransaction` RPC call is cached by Ponder - const traces = await context.client.request({ + const trace = await context.client.request({ method: "debug_traceTransaction", params: [event.transaction.hash, { tracer: "callTracer" }], }); // extract all addresses from the traces - const allAddressesInTransaction = getAddressesFromTrace(traces); + const allAddressesInTransaction = getAddressesFromTrace(trace); // iterate over all addresses in the transaction traces // and try to heal the label with each address diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts index 2e0f0d777..21914cb55 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts +++ b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts @@ -27,9 +27,9 @@ const makeChainNameSchema = (indexedChainNames: string[]) => z.enum(indexedChain const PonderBlockRefSchema = makeBlockRefSchema(); -const PonderCommandSchema = z.enum(["dev", "start"]); +const PonderCommandSchema = z.enum(["dev", "start", "serve"]); -const PonderOrderingSchema = z.literal("omnichain"); +const PonderOrderingSchema = z.literal("omnichain").prefault("omnichain"); export const PonderAppSettingsSchema = z.strictObject({ command: PonderCommandSchema, diff --git a/apps/ensindexer/src/lib/json-stringify-with-bigints.ts b/apps/ensindexer/src/lib/json-stringify-with-bigints.ts new file mode 100644 index 000000000..1cebc88f4 --- /dev/null +++ b/apps/ensindexer/src/lib/json-stringify-with-bigints.ts @@ -0,0 +1,7 @@ +import { replaceBigInts } from "ponder"; + +/** + * JSON.stringify with bigints replaced. + */ +export const toJson = (value: unknown, pretty = true) => + JSON.stringify(replaceBigInts(value, String), null, pretty ? 2 : undefined); diff --git a/apps/ensindexer/src/lib/plugin-helpers.ts b/apps/ensindexer/src/lib/plugin-helpers.ts index f48c6f451..f1f8638a6 100644 --- a/apps/ensindexer/src/lib/plugin-helpers.ts +++ b/apps/ensindexer/src/lib/plugin-helpers.ts @@ -1,6 +1,6 @@ import type { createConfig as createPonderConfig } from "ponder"; -import { type DatasourceName, type ENSNamespaceId, getENSNamespace } from "@ensnode/datasources"; +import type { DatasourceName } from "@ensnode/datasources"; import { PluginName, uniq } from "@ensnode/ensnode-sdk"; import type { ENSIndexerConfig } from "@/config/types"; @@ -83,50 +83,6 @@ type PonderConfigResult< BLOCKS extends object = {}, > = ReturnType>; -/** - * ENSNamespaceFullyDefinedAtCompileTime is a helper type necessary to support runtime-conditional - * Ponder plugins. - * - * 1. ENSNode can be configured to index in the context of different ENS namespaces, - * (currently: mainnet, sepolia, holesky, ens-test-env), using a user-specified set of plugins. - * 2. Ponder's inferred type-checking requires const-typed values, and so those plugins must be able - * to define their Ponder config statically so the types can be inferred at compile-time, regardless - * of whether the plugin's config and handler logic is loaded/executed at runtime. - * 3. To make this work, we provide a ENSNamespaceFullyDefinedAtCompileTime, set to the typeof mainnet's - * ENSNamespace, which fully defines all known Datasources (if this is ever not the case, a merged - * type can be used to ensure that this type has the full set of possible Datasources). Plugins - * can use the runtime value returned from {@link getENSNamespaceAsFullyDefinedAtCompileTime} and - * by casting it to ENSNamespaceFullyDefinedAtCompileTime we ensure that the values expected by - * those plugins pass the typechecker. ENSNode ensures that non-active plugins are not executed, - * so the issue of type/value mismatch does not occur during execution. - */ -type ENSNamespaceFullyDefinedAtCompileTime = ReturnType>; - -/** - * Returns the ENSNamespace for the provided `namespaceId`, cast to ENSNamespaceFullyDefinedAtCompileTime. - * - * See {@link ENSNamespaceFullyDefinedAtCompileTime} for more info. - * - * @param namespaceId - The ENS namespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', 'ens-test-env') - * @returns the ENSNamespace - */ -export const getENSNamespaceAsFullyDefinedAtCompileTime = (namespaceId: ENSNamespaceId) => - getENSNamespace(namespaceId) as ENSNamespaceFullyDefinedAtCompileTime; - -/** - * Returns the `datasourceName` Datasource within the `namespaceId` namespace, cast as ENSNamespaceFullyDefinedAtCompileTime. - * - * NOTE: the typescript typechecker will _not_ enforce validity. i.e. using an invalid `datasourceName` - * within the specified `namespaceId` will have a valid return type but be undefined at runtime. - */ -export const getDatasourceAsFullyDefinedAtCompileTime = < - N extends ENSNamespaceId, - D extends keyof ENSNamespaceFullyDefinedAtCompileTime, ->( - namespaceId: N, - datasourceName: D, -) => getENSNamespaceAsFullyDefinedAtCompileTime(namespaceId)[datasourceName]; - /** * Options type for `buildPlugin` function input. * diff --git a/apps/ensindexer/src/lib/ponder-helpers.ts b/apps/ensindexer/src/lib/ponder-helpers.ts index 21d1dd493..d8e48527b 100644 --- a/apps/ensindexer/src/lib/ponder-helpers.ts +++ b/apps/ensindexer/src/lib/ponder-helpers.ts @@ -4,21 +4,52 @@ * as the config object will not be ready yet. */ -import type { Event } from "ponder:registry"; +import type { Event, EventNames } from "ponder:registry"; import type { ChainConfig } from "ponder"; import type { Address, PublicClient } from "viem"; import * as z from "zod/v4"; -import type { ContractConfig } from "@ensnode/datasources"; -import type { Blockrange, ChainId } from "@ensnode/ensnode-sdk"; +import { + type ContractConfig, + type DatasourceName, + ensTestEnvL1Chain, + ensTestEnvL2Chain, + maybeGetDatasource, +} from "@ensnode/datasources"; +import type { Blockrange, ChainId, ENSNamespaceId } from "@ensnode/ensnode-sdk"; import type { BlockInfo, PonderStatus } from "@ensnode/ponder-metadata"; import type { ENSIndexerConfig } from "@/config/types"; -export type EventWithArgs = {}> = Omit & { +/** + * A type that represents only log events (case 6 in the Event conditional type). + * This filters out block events, transaction events, transfer events, call trace events, and setup events. + * + * Valid event names have the pattern: `${ContractName}:${EventName}` where EventName is not "setup". + * Invalid patterns that are excluded: + * - `${string}:block` (block events) + * - `${string}:transaction:${"from" | "to"}` (transaction events) + * - `${string}:transfer:${"from" | "to"}` (transfer events) + * - `${string}.${string}` (call trace events) + * - `${ContractName}:setup` (setup events) + */ +export type LogEvent = T extends `${string}:block` + ? never + : T extends `${string}:transaction:${"from" | "to"}` + ? never + : T extends `${string}:transfer:${"from" | "to"}` + ? never + : T extends `${string}.${string}` + ? never + : T extends `${string}:setup` + ? never + : T extends `${string}:${string}` + ? Event + : never; + +export type EventWithArgs = {}> = Omit & { args: ARGS; }; - /** * Given a contract's block range, returns a block range describing a start and end block * that maintains validity within the global blockrange. The returned start block will always be @@ -280,7 +311,10 @@ export function chainsConnectionConfig( rpc: rpcConfig.httpRPCs.map((httpRPC) => httpRPC.toString()), ws: rpcConfig.websocketRPC?.toString(), // NOTE: disable cache on local chains (e.g. Anvil, Ganache) - ...((chainId === 31337 || chainId === 1337) && { disableCache: true }), + ...((chainId === 31337 || + chainId === 1337 || + chainId === ensTestEnvL1Chain.id || + chainId === ensTestEnvL2Chain.id) && { disableCache: true }), } satisfies ChainConfig, }; } @@ -317,6 +351,75 @@ export function chainConfigForContract( }; } +/** + * TODO + */ +export function chainsConnectionConfigForDatasources( + namespace: ENSNamespaceId, + rpcConfigs: ENSIndexerConfig["rpcConfigs"], + datasourceNames: DatasourceName[], +) { + return datasourceNames + .map((datasourceName) => maybeGetDatasource(namespace, datasourceName)) + .filter((ds) => !!ds) + .map((datasource) => datasource.chain) + .reduce>( + (memo, chain) => ({ + ...memo, + ...chainsConnectionConfig(rpcConfigs, chain.id), + }), + {}, + ); +} + +type MapOfRequiredDatasources< + N extends ENSNamespaceId, + DATASOURCE_NAMES extends readonly DatasourceName[], +> = { + [K in DATASOURCE_NAMES[number]]: Exclude>, undefined>; +}; + +type MapOfMaybeDatasources< + N extends ENSNamespaceId, + DATASOURCE_NAMES extends readonly DatasourceName[], +> = { + [K in DATASOURCE_NAMES[number]]: ReturnType>; +}; + +/** + * TODO + */ +export function getRequiredDatasources< + N extends ENSNamespaceId, + DATASOURCE_NAMES extends DatasourceName[], +>(namespace: N, datasourceNames: DATASOURCE_NAMES) { + return Object.fromEntries( + datasourceNames.map((datasourceName) => { + const datasource = maybeGetDatasource(namespace, datasourceName); + if (!datasource) { + throw new Error( + `Required datasource "${datasourceName}" not found for namespace "${namespace}"`, + ); + } + return [datasourceName, datasource] as const; + }), + ) as MapOfRequiredDatasources; +} + +/** + * TODO + */ +export function maybeGetDatasources< + N extends ENSNamespaceId, + DATASOURCE_NAMES extends DatasourceName[], +>(namespace: N, datasourceNames: DATASOURCE_NAMES) { + return Object.fromEntries( + datasourceNames.map( + (datasourceName) => [datasourceName, maybeGetDatasource(namespace, datasourceName)] as const, + ), + ) as MapOfMaybeDatasources; +} + /** * Merges a set of ContractConfigs representing contracts at specific addresses on the same chain. * Uses the lowest startBlock to ensure all events are indexed. diff --git a/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts new file mode 100644 index 000000000..28e33a533 --- /dev/null +++ b/apps/ensindexer/src/lib/protocol-acceleration/domain-resolver-relationship-db-helpers.ts @@ -0,0 +1,26 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import { type Address, isAddressEqual, zeroAddress } from "viem"; + +import type { AccountId, DomainId } from "@ensnode/ensnode-sdk"; + +/** + * Ensures that the Domain-Resolver Relationship for the provided `domainId` in `registry` is set + * to `resolver`. If `resolver` is zeroAddress, it is interpreted as a deletion, and the relationship + * is removed. + */ +export async function ensureDomainResolverRelation( + context: Context, + registry: AccountId, + domainId: DomainId, + resolver: Address, +) { + if (isAddressEqual(zeroAddress, resolver)) { + await context.db.delete(schema.domainResolverRelation, { ...registry, domainId }); + } else { + await context.db + .insert(schema.domainResolverRelation) + .values({ ...registry, domainId, resolver }) + .onConflictDoUpdate({ resolver }); + } +} diff --git a/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts deleted file mode 100644 index b36f8f155..000000000 --- a/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Context } from "ponder:registry"; -import schema from "ponder:schema"; -import type { Address } from "viem"; - -import type { Node } from "@ensnode/ensnode-sdk"; - -export async function removeNodeResolverRelation(context: Context, registry: Address, node: Node) { - const chainId = context.chain.id; - - await context.db.delete(schema.nodeResolverRelation, { chainId, registry, node }); -} - -export async function upsertNodeResolverRelation( - context: Context, - registry: Address, - node: Node, - resolver: Address, -) { - const chainId = context.chain.id; - - return context.db - .insert(schema.nodeResolverRelation) - .values({ chainId, registry, node, resolver }) - .onConflictDoUpdate({ resolver }); -} diff --git a/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts new file mode 100644 index 000000000..b48f65c43 --- /dev/null +++ b/apps/ensindexer/src/lib/protocol-acceleration/resolver-db-helpers.ts @@ -0,0 +1,179 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import type { Address } from "viem"; + +import { + type AccountId, + type CoinType, + makeResolverId, + makeResolverRecordsId, + type Node, +} from "@ensnode/ensnode-sdk"; +import { + interpretAddressRecordValue, + interpretNameRecordValue, + interpretTextRecordKey, + interpretTextRecordValue, + isDedicatedResolver, + isExtendedResolver, +} from "@ensnode/ensnode-sdk/internal"; + +import type { EventWithArgs } from "@/lib/ponder-helpers"; + +/** + * Infer the type of the ResolverRecord entity's composite key. + */ +type ResolverRecordsCompositeKey = Pick< + typeof schema.resolverRecords.$inferInsert, + "chainId" | "address" | "node" +>; + +/** + * Constructs a ResolverRecordsCompositeKey from a provided Resolver event. + * + * @returns ResolverRecordsCompositeKey + */ +export function makeResolverRecordsCompositeKey( + resolver: AccountId, + event: EventWithArgs<{ node: Node }>, +): ResolverRecordsCompositeKey { + return { + ...resolver, + node: event.args.node, + }; +} + +/** + * Ensures that the Resolver contract described by `resolver` exists, including behavioral metadata + * on initial insert. + */ +export async function ensureResolver(context: Context, resolver: AccountId) { + const resolverId = makeResolverId(resolver); + const existing = await context.db.find(schema.resolver, { id: resolverId }); + if (existing) return; + + const [isExtended, isDedicated] = await Promise.all([ + isExtendedResolver({ + address: resolver.address, + publicClient: context.client, + }), + isDedicatedResolver({ + address: resolver.address, + publicClient: context.client, + }), + ]); + + // ensure Resolver + await context.db.insert(schema.resolver).values({ + id: resolverId, + ...resolver, + isExtended, + isDedicated, + }); +} + +/** + * Ensures that the ResolverRecords entity described by `resolverRecordsKey` exists. + */ +export async function ensureResolverRecords( + context: Context, + resolverRecordsKey: ResolverRecordsCompositeKey, +) { + const resolver: AccountId = { + chainId: resolverRecordsKey.chainId, + address: resolverRecordsKey.address, + }; + const resolverRecordsId = makeResolverRecordsId(resolver, resolverRecordsKey.node); + + // ensure ResolverRecords + await context.db + .insert(schema.resolverRecords) + .values({ + id: resolverRecordsId, + ...resolverRecordsKey, + }) + .onConflictDoNothing(); +} + +/** + * Updates the `name` record value for the ResolverRecords described by `id`. + */ +export async function handleResolverNameUpdate( + context: Context, + resolverRecordsKey: ResolverRecordsCompositeKey, + name: string, +) { + const resolverRecordsId = makeResolverRecordsId( + { chainId: resolverRecordsKey.chainId, address: resolverRecordsKey.address }, + resolverRecordsKey.node, + ); + + await context.db + .update(schema.resolverRecords, { id: resolverRecordsId }) + .set({ name: interpretNameRecordValue(name) }); +} + +/** + * Updates the `address` record value by `coinType` for the ResolverRecords described by `id`. + */ +export async function handleResolverAddressRecordUpdate( + context: Context, + resolverRecordsKey: ResolverRecordsCompositeKey, + coinType: CoinType, + address: Address, +) { + // construct the ResolverAddressRecord's Composite Key + const id = { ...resolverRecordsKey, coinType: BigInt(coinType) }; + + // interpret the incoming address record value + const interpretedValue = interpretAddressRecordValue(address); + + // consider this a deletion iff the interpreted value is null + const isDeletion = interpretedValue === null; + if (isDeletion) { + // delete + await context.db.delete(schema.resolverAddressRecord, id); + } else { + // upsert + await context.db + .insert(schema.resolverAddressRecord) + .values({ ...id, value: interpretedValue }) + .onConflictDoUpdate({ value: interpretedValue }); + } +} + +/** + * Updates the `text` record value by `key` for the ResolverRecords described by `id`. + * + * If `value` is null, it will be interpreted as a deletion of the associated record. + */ +export async function handleResolverTextRecordUpdate( + context: Context, + resolverRecordsId: ResolverRecordsCompositeKey, + key: string, + value: string | null, +) { + const interpretedKey = interpretTextRecordKey(key); + + // ignore updates involving keys that should be ignored as per `interpretTextRecordKey` + if (interpretedKey === null) return; + + // construct the ResolverTextRecord's Composite Key + const id = { ...resolverRecordsId, key: interpretedKey }; + + // interpret the incoming text record value + const interpretedValue = value == null ? null : interpretTextRecordValue(value); + + // consider this a deletion iff the interpreted value is null + const isDeletion = interpretedValue === null; + if (isDeletion) { + // delete + await context.db.delete(schema.resolverTextRecord, id); + } else { + // upsert + await context.db + .insert(schema.resolverTextRecord) + .values({ ...id, value: interpretedValue }) + .onConflictDoUpdate({ value: interpretedValue }); + } +} diff --git a/apps/ensindexer/src/lib/protocol-acceleration/resolver-records-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/resolver-records-db-helpers.ts deleted file mode 100644 index 982e523b4..000000000 --- a/apps/ensindexer/src/lib/protocol-acceleration/resolver-records-db-helpers.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { Context } from "ponder:registry"; -import schema from "ponder:schema"; -import type { Address } from "viem"; - -import type { Node } from "@ensnode/ensnode-sdk"; -import { - interpretAddressRecordValue, - interpretNameRecordValue, - interpretTextRecordKey, - interpretTextRecordValue, -} from "@ensnode/ensnode-sdk/internal"; - -import type { EventWithArgs } from "@/lib/ponder-helpers"; - -/** - * Infer the type of the ResolverRecord entity's composite primary key. - */ -type ResolverRecordsId = Pick< - typeof schema.resolverRecords.$inferInsert, - "chainId" | "resolver" | "node" ->; - -/** - * Constructs a ResolverRecordsId from a provided Resolver event. - * - * @returns ResolverRecordsId - */ -export function makeResolverRecordsId( - context: Context, - event: EventWithArgs<{ node: Node }>, -): ResolverRecordsId { - return { - chainId: context.chain.id, - resolver: event.log.address, - node: event.args.node, - }; -} - -/** - * Ensures that the ResolverRecords entity described by `id` exists. - */ -export async function ensureResolverRecords(context: Context, id: ResolverRecordsId) { - await context.db.insert(schema.resolverRecords).values(id).onConflictDoNothing(); -} - -/** - * Updates the `name` record value for the ResolverRecords described by `id`. - */ -export async function handleResolverNameUpdate( - context: Context, - id: ResolverRecordsId, - name: string, -) { - await context.db.update(schema.resolverRecords, id).set({ name: interpretNameRecordValue(name) }); -} - -/** - * Updates the `address` record value by `coinType` for the ResolverRecords described by `id`. - */ -export async function handleResolverAddressRecordUpdate( - context: Context, - resolverRecordsId: ResolverRecordsId, - coinType: bigint, - address: Address, -) { - // construct the ResolverAddressRecord's Composite Key - const id = { ...resolverRecordsId, coinType }; - - // interpret the incoming address record value - const interpretedValue = interpretAddressRecordValue(address); - - // consider this a deletion iff the interpreted value is null - const isDeletion = interpretedValue === null; - if (isDeletion) { - // delete - await context.db.delete(schema.resolverAddressRecord, id); - } else { - // upsert - await context.db - .insert(schema.resolverAddressRecord) - .values({ ...id, address: interpretedValue }) - .onConflictDoUpdate({ address: interpretedValue }); - } -} - -/** - * Updates the `text` record value by `key` for the ResolverRecords described by `id`. - * - * If `value` is null, it will be interpreted as a deletion of the associated record. - */ -export async function handleResolverTextRecordUpdate( - context: Context, - resolverRecordsId: ResolverRecordsId, - key: string, - value: string | null, -) { - const interpretedKey = interpretTextRecordKey(key); - - // ignore updates involving keys that should be ignored as per `interpretTextRecordKey` - if (interpretedKey === null) return; - - // construct the ResolverTextRecord's Composite Key - const id = { ...resolverRecordsId, key: interpretedKey }; - - // interpret the incoming text record value - const interpretedValue = value == null ? null : interpretTextRecordValue(value); - - // consider this a deletion iff the interpreted value is null - const isDeletion = interpretedValue === null; - if (isDeletion) { - // delete - await context.db.delete(schema.resolverTextRecord, id); - } else { - // upsert - await context.db - .insert(schema.resolverTextRecord) - .values({ ...id, value: interpretedValue }) - .onConflictDoUpdate({ value: interpretedValue }); - } -} diff --git a/apps/ensindexer/src/lib/subgraph/db-helpers.ts b/apps/ensindexer/src/lib/subgraph/db-helpers.ts index e55be291a..56a0e48bf 100644 --- a/apps/ensindexer/src/lib/subgraph/db-helpers.ts +++ b/apps/ensindexer/src/lib/subgraph/db-helpers.ts @@ -1,7 +1,8 @@ -import type { Context, Event } from "ponder:registry"; +import type { Context } from "ponder:registry"; import schema from "ponder:schema"; import type { Address } from "viem"; +import type { LogEvent } from "@/lib/ponder-helpers"; import { makeEventId } from "@/lib/subgraph/ids"; export async function upsertAccount(context: Context, address: Address) { @@ -42,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: Omit) { return { id: makeEventId(chainId, event.block.number, event.log.logIndex), blockNumber: event.block.number, diff --git a/apps/ensindexer/src/lib/tokenscope/nft-issuers.ts b/apps/ensindexer/src/lib/tokenscope/nft-issuers.ts index e90a420ba..0ac43078c 100644 --- a/apps/ensindexer/src/lib/tokenscope/nft-issuers.ts +++ b/apps/ensindexer/src/lib/tokenscope/nft-issuers.ts @@ -71,7 +71,7 @@ const labelHashGeneratedTokenIdToNode = (tokenId: TokenId, parentNode: Node): No /** * Gets all the SupportedNFTIssuer for the specified namespace. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @returns an array of 0 or more SupportedNFTIssuer for the specified namespace */ const getSupportedNFTIssuers = (namespaceId: ENSNamespaceId): SupportedNFTIssuer[] => { @@ -174,8 +174,7 @@ const getSupportedNFTIssuers = (namespaceId: ENSNamespaceId): SupportedNFTIssuer /** * Gets the SupportedNFTIssuer for the given contract in the specified namespace. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', - * 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param contract - The AccountId of the contract to get the SupportedNFTIssuer for * @returns the SupportedNFTIssuer for the given contract, or null * if the contract is not a SupportedNFTIssuer in the specified namespace diff --git a/apps/ensindexer/src/lib/tokenscope/seaport.ts b/apps/ensindexer/src/lib/tokenscope/seaport.ts index 05a8ac3f1..1053a6402 100644 --- a/apps/ensindexer/src/lib/tokenscope/seaport.ts +++ b/apps/ensindexer/src/lib/tokenscope/seaport.ts @@ -39,8 +39,7 @@ const getAssetNamespace = (itemType: ItemType): AssetNamespace | null => { /** * Gets the supported NFT from a given Seaport item. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', - * 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param chainId - The chain ID of the Seaport item * @param item - The Seaport item to get the supported NFT from * @returns the supported NFT from the given Seaport item, or `null` if the Seaport item is diff --git a/apps/ensindexer/src/plugins/ensv2/event-handlers.ts b/apps/ensindexer/src/plugins/ensv2/event-handlers.ts new file mode 100644 index 000000000..4c2f256a3 --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/event-handlers.ts @@ -0,0 +1,17 @@ +import attach_BaseRegistrarHandlers from "./handlers/ensv1/BaseRegistrar"; +import attach_ENSv1RegistryHandlers from "./handlers/ensv1/ENSv1Registry"; +import attach_NameWrapperHandlers from "./handlers/ensv1/NameWrapper"; +import attach_RegistrarControllerHandlers from "./handlers/ensv1/RegistrarController"; +import attach_RegistryHandlers from "./handlers/ensv2/ENSv2Registry"; +import attach_EnhancedAccessControlHandlers from "./handlers/ensv2/EnhancedAccessControl"; +import attach_ETHRegistrarHandlers from "./handlers/ensv2/ETHRegistrar"; + +export default function () { + attach_BaseRegistrarHandlers(); + attach_ENSv1RegistryHandlers(); + attach_NameWrapperHandlers(); + attach_RegistrarControllerHandlers(); + attach_EnhancedAccessControlHandlers(); + attach_RegistryHandlers(); + attach_ETHRegistrarHandlers(); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts new file mode 100644 index 000000000..6d0e4774f --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/BaseRegistrar.ts @@ -0,0 +1,220 @@ +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { GRACE_PERIOD_SECONDS } from "@ensdomains/ensjs/utils"; +import { type Address, isAddressEqual, namehash, zeroAddress } from "viem"; + +import { + interpretAddress, + isRegistrationFullyExpired, + makeENSv1DomainId, + makeLatestRegistrationId, + makeLatestRenewalId, + makeSubdomainNode, + PluginName, +} from "@ensnode/ensnode-sdk"; + +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; +import { getRegistrarManagedName, registrarTokenIdToLabelHash } from "@/lib/ensv2/registrar-lib"; +import { + getLatestRegistration, + getLatestRenewal, + supercedeLatestRegistration, + supercedeLatestRenewal, +} 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 } from "@/lib/ponder-helpers"; + +const pluginName = PluginName.ENSv2; + +/** + * In ENSv1, all BaseRegistrar-derived Registrar contracts (& their controllers) have the ability to + * `registerOnly`, creating a 'preminted' name (a label with a Registration but no Domain in the + * ENSv1 Registry). The .eth Registrar doesn't do this, but Basenames and Lineanames do. + * + * Because they all technically have this ability, this logic avoids the invariant that an associated + * v1Domain must exist and the v1Domain.owner is conditionally materialized. + * + * Technically each BaseRegistrar Registration also has an associated owner that we could keep track + * of, but because we're materializing the v1Domain's effective owner, we need not explicitly track + * it. When a preminted name is actually registered, the indexing logic will see that the v1Domain + * exists and materialize its effective owner correctly. + */ +export default function () { + ponder.on( + namespaceContract(pluginName, "BaseRegistrar:Transfer"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + from: Address; + to: Address; + tokenId: bigint; + }>; + }) => { + const { from, to, tokenId } = event.args; + + const isMint = isAddressEqual(zeroAddress, from); + + // minting is always followed by Registrar#NameRegistered, safe to ignore + if (isMint) return; + + // this is either: + // a) a user transfering their registration token, or + // b) re-registering a name that has expired, and it will emit NameRegistered directly afterwards, or + // c) user intentionally burning their registration token by transferring to zeroAddress. + // + // in all such cases, a Registration is expected and we can conditionally materialize Domain owner + + const labelHash = registrarTokenIdToLabelHash(tokenId); + const registrar = getThisAccountId(context, event); + const managedNode = namehash(getRegistrarManagedName(registrar)); + const node = makeSubdomainNode(labelHash, managedNode); + const domainId = makeENSv1DomainId(node); + + const registration = await getLatestRegistration(context, domainId); + if (!registration) { + throw new Error(`Invariant(BaseRegistrar:Transfer): expected existing Registration`); + } + + // materialize Domain owner if exists + const domain = await context.db.find(schema.v1Domain, { id: domainId }); + if (domain) await materializeENSv1DomainEffectiveOwner(context, domainId, to); + }, + ); + + async function handleNameRegistered({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + id: bigint; + owner: Address; + expires: bigint; + }>; + }) { + const { id: tokenId, owner, expires: expiry } = event.args; + const registrant = owner; + + const labelHash = registrarTokenIdToLabelHash(tokenId); + const registrar = getThisAccountId(context, event); + const managedNode = namehash(getRegistrarManagedName(registrar)); + const node = makeSubdomainNode(labelHash, managedNode); + + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + const isFullyExpired = + registration && isRegistrationFullyExpired(registration, event.block.timestamp); + + // Invariant: If there is an existing Registration, it must be fully expired. + if (registration && !isFullyExpired) { + throw new Error( + `Invariant(BaseRegistrar:NameRegistered): Existing unexpired registration found in NameRegistered, expected none or expired.\n${toJson(registration)}`, + ); + } + + // supercede the latest Registration if exists + if (registration) await supercedeLatestRegistration(context, registration); + + // insert BaseRegistrar Registration + await ensureAccount(context, registrant); + await context.db.insert(schema.registration).values({ + id: makeLatestRegistrationId(domainId), + index: registration ? registration.index + 1 : 0, + type: "BaseRegistrar", + registrarChainId: registrar.chainId, + registrarAddress: registrar.address, + registrantId: interpretAddress(registrant), + domainId, + start: event.block.timestamp, + expiry, + // all BaseRegistrar-derived Registrars use the same GRACE_PERIOD + gracePeriod: BigInt(GRACE_PERIOD_SECONDS), + }); + + // materialize Domain owner if exists + const domain = await context.db.find(schema.v1Domain, { id: domainId }); + if (domain) await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + } + + ponder.on(namespaceContract(pluginName, "BaseRegistrar:NameRegistered"), handleNameRegistered); + ponder.on( + namespaceContract(pluginName, "BaseRegistrar:NameRegisteredWithRecord"), + handleNameRegistered, + ); + + ponder.on( + namespaceContract(pluginName, "BaseRegistrar:NameRenewed"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ id: bigint; expires: bigint }>; + }) => { + const { id: tokenId, expires: expiry } = event.args; + + const labelHash = registrarTokenIdToLabelHash(tokenId); + const registrar = getThisAccountId(context, event); + const managedNode = namehash(getRegistrarManagedName(registrar)); + const node = makeSubdomainNode(labelHash, managedNode); + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + + // Invariant: There must be a Registration to renew. + if (!registration) { + throw new Error( + `Invariant(BaseRegistrar:NameRenewed): NameRenewed emitted but no Registration to renew.`, + ); + } + + // Invariant: Must be BaseRegistrar Registration + if (registration.type !== "BaseRegistrar") { + throw new Error( + `Invariant(BaseRegistrar:NameRenewed): NameRenewed emitted for a non-BaseRegistrar registration:\n${toJson(registration)}`, + ); + } + + // Invariant: Because it is a BaseRegistrar Registration, it must have an expiry. + if (registration.expiry === null) { + throw new Error( + `Invariant(BaseRegistrar:NameRenewed): NameRenewed emitted for a BaseRegistrar registration that has a null expiry:\n${toJson(registration)}`, + ); + } + + // Invariant: The Registation must not be fully expired. + // https://github.com/ensdomains/ens-contracts/blob/b6cb0e26/contracts/ethregistrar/BaseRegistrarImplementation.sol#L161 + if (isRegistrationFullyExpired(registration, event.block.timestamp)) { + throw new Error( + `Invariant(BaseRegistrar:NameRenewed): NameRenewed emitted but registration is expired:\n${toJson({ registration, timestamp: event.block.timestamp })}`, + ); + } + + // infer duration + const duration = expiry - registration.expiry; + + // update the registration + await context.db.update(schema.registration, { id: registration.id }).set({ expiry }); + + // get latest Renewal and supercede if exists + const renewal = await getLatestRenewal(context, domainId, registration.index); + if (renewal) await supercedeLatestRenewal(context, renewal); + + // insert latest Renewal + await context.db.insert(schema.renewal).values({ + id: makeLatestRenewalId(domainId, registration.index), + domainId, + registrationIndex: registration.index, + index: renewal ? renewal.index + 1 : 0, + duration, + // 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 + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts new file mode 100644 index 000000000..65af6858c --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -0,0 +1,163 @@ +import config from "@/config"; + +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { type Address, isAddressEqual, zeroAddress } from "viem"; + +import { + ADDR_REVERSE_NODE, + getENSRootChainId, + interpretAddress, + type LabelHash, + makeENSv1DomainId, + makeSubdomainNode, + type Node, + PluginName, + ROOT_NODE, +} from "@ensnode/ensnode-sdk"; + +import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-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"; +import type { EventWithArgs } from "@/lib/ponder-helpers"; +import { nodeIsMigrated } from "@/lib/protocol-acceleration/registry-migration-status"; + +const pluginName = PluginName.ENSv2; + +/** + * Handler functions for ENSv1 Regsitry contracts. + * - piggybacks Protocol Resolution plugin's Node Migration status + */ +export default function () { + /** + * Registry#NewOwner is either a new Domain OR the owner of the parent changing the owner of the child. + */ + async function handleNewOwner({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + // NOTE: `node` event arg represents a `Node` that is the _parent_ of the node the NewOwner event is about + node: Node; + // NOTE: `label` event arg represents a `LabelHash` for the sub-node under `node` + label: LabelHash; + owner: Address; + }>; + }) { + const { label: labelHash, node: parentNode, owner } = event.args; + + // if someone mints a node to the zero address, nothing happens in the Registry, so no-op + if (isAddressEqual(zeroAddress, owner)) return; + + const node = makeSubdomainNode(labelHash, parentNode); + const domainId = makeENSv1DomainId(node); + const parentId = makeENSv1DomainId(parentNode); + + // If this is a direct subname of addr.reverse, we have 100% on-chain label discovery. + // + // Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse` + // subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root + // chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19 + // CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in + // the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted + // with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse). + if ( + parentNode === ADDR_REVERSE_NODE && + context.chain.id === getENSRootChainId(config.namespace) + ) { + const label = await healAddrReverseSubnameLabel(context, event, labelHash); + await ensureLabel(context, label); + } else { + await ensureUnknownLabel(context, labelHash); + } + + // upsert domain + await context.db + .insert(schema.v1Domain) + .values({ + id: domainId, + parentId, + labelHash, + }) + .onConflictDoNothing(); + + // materialize domain owner + // NOTE: despite Domain.ownerId being materialized from other sources of truth (i.e. Registrars + // like BaseRegistrars & NameWrapper) it's ok to always set it here because the Registrar-emitted + // events occur _after_ the Registry events. So when a name is registered, for example, the Registry's + // 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); + } + + async function handleTransfer({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node; owner: Address }>; + }) { + const { node, owner: _owner } = event.args; + const owner = interpretAddress(_owner); + + // ENSv2 model does not include root node, no-op + if (node === ROOT_NODE) return; + + const domainId = makeENSv1DomainId(node); + + if (owner === null) { + await context.db.delete(schema.v1Domain, { id: domainId }); + } else { + // materialize domain owner + // NOTE: despite Domain.ownerId being materialized from other sources of truth (i.e. Registrars + // like BaseRegistrars & NameWrapper) it's ok to always set it here because the Registrar-emitted + // events occur _after_ the Registry events. So when a name is wrapped, for example, the Registry's + // 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); + } + } + + /** + * Handles Registry#NewOwner for: + * - ENS Root Chain's ENSv1RegistryOld + */ + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:NewOwner"), + async ({ context, event }) => { + const { label: labelHash, node: parentNode } = event.args; + + // ignore the event on ENSv1RegistryOld if node is migrated to new Registry + const node = makeSubdomainNode(labelHash, parentNode); + const shouldIgnoreEvent = await nodeIsMigrated(context, node); + if (shouldIgnoreEvent) return; + + return handleNewOwner({ context, event }); + }, + ); + + /** + * Handles Registry#Transfer for: + * - ENS Root Chain's ENSv1RegistryOld + */ + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:Transfer"), + async ({ context, event }) => { + const shouldIgnoreEvent = await nodeIsMigrated(context, event.args.node); + if (shouldIgnoreEvent) return; + + return handleTransfer({ context, event }); + }, + ); + + /** + * Handles Registry events for: + * - ENS Root Chain's (new) Registry + * - Basenames Registry + * - Lineanames Registry + */ + ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewOwner"), handleNewOwner); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:Transfer"), handleTransfer); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts new file mode 100644 index 000000000..9e33eae2f --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/NameWrapper.ts @@ -0,0 +1,413 @@ +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { type Address, isAddressEqual, namehash, zeroAddress } from "viem"; + +import { + type DNSEncodedLiteralName, + type DNSEncodedName, + decodeDNSEncodedLiteralName, + interpretAddress, + isPccFuseSet, + isRegistrationExpired, + isRegistrationFullyExpired, + isRegistrationInGracePeriod, + type LiteralLabel, + labelhashLiteralLabel, + makeENSv1DomainId, + makeLatestRegistrationId, + makeLatestRenewalId, + makeSubdomainNode, + type Node, + PluginName, + uint256ToHex32, +} from "@ensnode/ensnode-sdk"; + +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers"; +import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; +import { getRegistrarManagedName } from "@/lib/ensv2/registrar-lib"; +import { + getLatestRegistration, + getLatestRenewal, + supercedeLatestRegistration, + supercedeLatestRenewal, +} 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 } from "@/lib/ponder-helpers"; + +const pluginName = PluginName.ENSv2; + +/** + * When a name is wrapped in the NameWrapper contract, an ERC1155 token is minted that tokenizes + * ownership of the name. The minted token will be assigned a unique tokenId represented as + * uint256(namehash(name)) where name is the fqdn of the name being wrapped. + * https://github.com/ensdomains/ens-contracts/blob/db613bc/contracts/wrapper/ERC1155Fuse.sol#L262 + */ +const tokenIdToNode = (tokenId: bigint): Node => uint256ToHex32(tokenId); + +/** + * NameWrapper emits expiry as 0 to mean 'doesn't expire', so we interpret as null. + */ +const interpretExpiry = (expiry: bigint): bigint | null => (expiry === 0n ? null : expiry); + +// registrar is source of truth for expiry if eth 2LD +// otherwise namewrapper is registrar and source of truth for expiry + +// +// The FusesSet event indicates that fuses were written to storage, but: +// Does not guarantee the name is not expired +// Does not guarantee the fuses are actually active (they could be cleared by _clearOwnerAndFuses on read) +// Simply records the fuse value that was stored, regardless of expiry status +// For indexers, this means you need to track both the FusesSet event AND the expiry to determine the actual active fuses at any point in time. + +// .eth 2LDs always have PARENT_CANNOT_CONTROL set ('burned'), they cannot be transferred during grace period + +const isDirectSubnameOfRegistrarManagedName = ( + managedNode: Node, + name: DNSEncodedLiteralName, + node: Node, +) => { + let labels: LiteralLabel[]; + try { + labels = decodeDNSEncodedLiteralName(name); + + // extra runtime assertion of valid decode + if (labels.length === 0) throw new Error("never"); + } catch { + // must be decodable + throw new Error( + `Invariant(isSubnameOfRegistrarManagedName): NameWrapper emitted DNSEncodedNames for direct-subnames-of-registrar-managed-names MUST be decodable`, + ); + } + + // construct the expected node using emitted name's leaf label and the registrarManagedNode + // biome-ignore lint/style/noNonNullAssertion: length check above + const leaf = labelhashLiteralLabel(labels[0]!); + const expectedNode = makeSubdomainNode(leaf, managedNode); + + // Nodes must exactly match + return node === expectedNode; +}; + +export default function () { + /** + * Transfer* events can occur for both expired and unexpired names. + */ + async function handleTransfer({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + operator: Address; + from: Address; + to: Address; + id: bigint; + }>; + }) { + const { from, to, id: tokenId } = event.args; + + const isMint = isAddressEqual(zeroAddress, from); + const isBurn = isAddressEqual(zeroAddress, to); + + // minting is always followed by NameWrapper#NameWrapped, safe to ignore + if (isMint) return; + + // burning is always followed by NameWrapper#NameUnwrapped, safe to ignore + if (isBurn) return; + + // otherwise is transfer of existing registration + + const domainId = makeENSv1DomainId(tokenIdToNode(tokenId)); + const registration = await getLatestRegistration(context, domainId); + const isExpired = registration && isRegistrationExpired(registration, event.block.timestamp); + + // Invariant: must have Registration + if (!registration) { + throw new Error( + `Invariant(NameWrapper:Transfer): Registration expected:\n${toJson(registration)}`, + ); + } + + // Invariant: Expired Registrations are non-transferrable if PCC is set + const cannotTransferWhileExpired = registration.fuses && isPccFuseSet(registration.fuses); + if (isExpired && cannotTransferWhileExpired) { + throw new Error( + `Invariant(NameWrapper:Transfer): Transfer of expired Registration with PARENT_CANNOT_CONTROL set:\n${toJson(registration)} ${JSON.stringify({ isPccFuseSet: isPccFuseSet(registration.fuses ?? 0) })}`, + ); + } + + // now guaranteed to be an unexpired transferrable Registration + // so materialize domain owner + await materializeENSv1DomainEffectiveOwner(context, domainId, to); + } + + ponder.on( + namespaceContract(pluginName, "NameWrapper:NameWrapped"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + node: Node; + name: DNSEncodedName; + owner: Address; + fuses: number; + expiry: bigint; + }>; + }) => { + const { node, name: _name, owner, fuses, expiry: _expiry } = event.args; + const expiry = interpretExpiry(_expiry); + const name = _name as DNSEncodedLiteralName; + const registrant = owner; + + const registrar = getThisAccountId(context, event); + const domainId = makeENSv1DomainId(node); + + // decode name and discover labels + try { + const labels = decodeDNSEncodedLiteralName(name); + for (const label of labels) { + await ensureLabel(context, label); + } + } catch { + // NameWrapper emitted malformed name? just warn and move on + console.warn(`NameWrapper emitted malformed DNSEncodedName: '${name}'`); + } + + const registration = await getLatestRegistration(context, domainId); + const isFullyExpired = + registration && isRegistrationFullyExpired(registration, event.block.timestamp); + + // materialize domain owner + await materializeENSv1DomainEffectiveOwner(context, domainId, owner); + + // handle wraps of direct-subname-of-registrar-managed-names + if (registration && !isFullyExpired && registration.type === "BaseRegistrar") { + const managedNode = namehash(getRegistrarManagedName(getThisAccountId(context, event))); + + // Invariant: Emitted name is a direct subname of the RegistrarManagedName + if (!isDirectSubnameOfRegistrarManagedName(managedNode, name, node)) { + throw new Error( + `Invariant(NameWrapper:NameWrapped): An unexpired BaseRegistrar Registration was found, but the name in question is NOT a direct subname of this NameWrapper's BaseRegistrar's RegistrarManagedName — wtf?`, + ); + } + + // Invariant: Cannot wrap grace period names + if (isRegistrationInGracePeriod(registration, event.block.timestamp)) { + throw new Error( + `Invariant(NameWrapper:NameWrapped): Cannot wrap direct-subname-of-registrar-managed-names in GRACE_PERIOD \n${toJson(registration)}`, + ); + } + + // Invariant: cannot re-wrap, right? NameWrapped -> NameUnwrapped -> NameWrapped + if (registration.wrapped) { + throw new Error( + `Invariant(NameWrapper:NameWrapped): Re-wrapping already wrapped BaseRegistrar registration\n${toJson(registration)}`, + ); + } + + // Invariant: BaseRegistrar always provides expiry + if (expiry === null) { + throw new Error( + `Invariant(NameWrapper:NameWrapped): Wrap of BaseRegistrar Registration does not include expiry!\n${toJson(registration)}`, + ); + } + + // Invariant: Expiry Alignment + if ( + // If BaseRegistrar Registration has an expiry, + registration.expiry && + // The NameWrapper epiration must be greater than that (+ grace period). + expiry > registration.expiry + (registration.gracePeriod ?? 0n) + ) { + throw new Error("Wrapper expiry exceeds registrar expiry + grace period"); + } + + await context.db.update(schema.registration, { id: registration.id }).set({ + wrapped: true, + fuses, + // expiry, // TODO: NameWrapper expiry logic + }); + } else { + // Invariant: If there's an existing Registration, it should be expired + if (registration && !isFullyExpired) { + throw new Error( + `Invariant(NameWrapper:NameWrapped): NameWrapped but there's an existing unexpired non-BaseRegistrar Registration:\n${toJson({ registration, timestamp: event.block.timestamp })}`, + ); + } + + const isAlreadyExpired = expiry && expiry <= event.block.timestamp; + if (isAlreadyExpired) { + // technically this is allowed... may as well just remove the warning + console.warn(`Creating NameWrapper registration for already-expired name: ${node}`); + } + + // supercede the latest Registration if exists + if (registration) await supercedeLatestRegistration(context, registration); + + // insert NameWrapper Registration + await ensureAccount(context, registrant); + await context.db.insert(schema.registration).values({ + id: makeLatestRegistrationId(domainId), + index: registration ? registration.index + 1 : 0, + type: "NameWrapper", + registrarChainId: registrar.chainId, + registrarAddress: registrar.address, + registrantId: interpretAddress(registrant), + domainId, + start: event.block.timestamp, + fuses, + expiry, + }); + } + }, + ); + + ponder.on( + namespaceContract(pluginName, "NameWrapper:NameUnwrapped"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node; owner: Address }>; + }) => { + const { node } = event.args; + + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + + if (!registration) { + throw new Error(`Invariant(NameWrapper:NameUnwrapped): Registration expected`); + } + + if (registration.type === "BaseRegistrar") { + // if this is a wrapped BaseRegisrar Registration, unwrap it + await context.db.update(schema.registration, { id: registration.id }).set({ + wrapped: false, + fuses: null, + // expiry: null // TODO: NameWrapper expiry logic? maybe nothing to do here + }); + } else { + // otherwise, deactivate the latest registration by setting its expiry to this block + await context.db.update(schema.registration, { id: registration.id }).set({ + expiry: event.block.timestamp, + }); + } + + // NOTE: we don't need to adjust Domain.ownerId because NameWrapper always calls ens.setOwner + }, + ); + + /** + * FusesSet can occur for expired or unexpired Registrations. + */ + ponder.on( + namespaceContract(pluginName, "NameWrapper:FusesSet"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node; fuses: number }>; + }) => { + const { node, fuses } = event.args; + + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + + // Invariant: must have a Registration + if (!registration) { + throw new Error( + `Invariant(NameWrapper:FusesSet): Registration expected:\n${toJson(registration)}`, + ); + } + + // upsert fuses + await context.db.update(schema.registration, { id: registration.id }).set({ + fuses, + // expiry: // TODO: NameWrapper expiry logic ? + }); + }, + ); + + /** + * ExpiryExtended can occur for expired or unexpired Registrations. + */ + ponder.on( + namespaceContract(pluginName, "NameWrapper:ExpiryExtended"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node; expiry: bigint }>; + }) => { + const { node, expiry: _expiry } = event.args; + const expiry = interpretExpiry(_expiry); + + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + + // Invariant: must have Registration + if (!registration) { + throw new Error( + `Invariant(NameWrapper:ExpiryExtended): Registration expected\n${toJson(registration)}`, + ); + } + + await context.db.update(schema.registration, { id: registration.id }).set({ expiry }); + + // 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 + + if (registration.type !== "NameWrapper") return; + + // if the Registration will no longer expire, this isn't really a Renewal, so no-op + if (expiry === null) return; + + // If: + // a) the Registration previously did not expire, and + // b) the new expiry is before the current block timestamp, + // Then it wasn't really renewed, now, was it? And calculating Renewal.duration is more or less + // impossible. + const now = event.block.timestamp; + if (registration.expiry === null && expiry < now) return; + + // if the Registration previously did not expire but now does, we can calculate duration + // as 'time added since now' (which could be 0 seconds) + const duration = expiry - (registration.expiry ?? event.block.timestamp); + + // get latest Renewal and supercede if exists + const renewal = await getLatestRenewal(context, domainId, registration.index); + if (renewal) await supercedeLatestRenewal(context, renewal); + + // insert latest Renewal + await context.db.insert(schema.renewal).values({ + id: makeLatestRenewalId(domainId, registration.index), + domainId, + registrationIndex: registration.index, + index: renewal ? renewal.index + 1 : 0, + duration, + // NOTE: NameWrapper does not include pricing information + }); + }, + ); + + ponder.on(namespaceContract(pluginName, "NameWrapper:TransferSingle"), handleTransfer); + ponder.on( + namespaceContract(pluginName, "NameWrapper:TransferBatch"), + async ({ context, event }) => { + for (const id of event.args.ids) { + await handleTransfer({ + context, + event: { ...event, args: { ...event.args, id } }, + }); + } + }, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts new file mode 100644 index 000000000..9c68c6b2e --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -0,0 +1,209 @@ +/** biome-ignore-all lint/correctness/noUnusedVariables: ignore for now */ +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { labelhash, namehash } from "viem"; + +import { + type EncodedReferrer, + type Label, + type LabelHash, + type LiteralLabel, + labelhashLiteralLabel, + makeENSv1DomainId, + makeSubdomainNode, + PluginName, +} from "@ensnode/ensnode-sdk"; + +import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers"; +import { getRegistrarManagedName } from "@/lib/ensv2/registrar-lib"; +import { getLatestRegistration, getLatestRenewal } 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 } from "@/lib/ponder-helpers"; + +const pluginName = PluginName.ENSv2; + +export default function () { + async function handleNameRegisteredByController({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + label?: Label; + labelHash: LabelHash; + baseCost?: bigint; + premium?: bigint; + referrer?: EncodedReferrer; + }>; + }) { + const { label: _label, labelHash, baseCost: base, premium, referrer } = event.args; + const label = _label as LiteralLabel | undefined; + + // Invariant: If emitted, label must align with labelHash + if (label !== undefined && labelHash !== labelhashLiteralLabel(label)) { + throw new Error( + `Invariant(RegistrarController:NameRegistered): Emitted label '${label}' does not labelhash to emitted labelHash '${labelHash}'.`, + ); + } + + const controller = getThisAccountId(context, event); + const managedNode = namehash(getRegistrarManagedName(controller)); + + const node = makeSubdomainNode(labelHash, managedNode); + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + + if (!registration) { + throw new Error( + `Invariant(RegistrarController:NameRegistered): NameRegistered but no Registration.`, + ); + } + + // ensure label + if (label !== undefined) { + await ensureLabel(context, label); + } else { + await ensureUnknownLabel(context, labelHash); + } + + // update registration's base/premium + // TODO(paymentToken): add payment token tracking here + await context.db + .update(schema.registration, { id: registration.id }) + .set({ base, premium, referrer }); + } + + async function handleNameRenewedByController({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + label: string; + baseCost?: bigint; + premium?: bigint; + referrer?: EncodedReferrer; + }>; + }) { + const { label: _label, baseCost: base, premium, referrer } = event.args; + const label = _label as LiteralLabel; + + const controller = getThisAccountId(context, event); + const managedNode = namehash(getRegistrarManagedName(controller)); + const labelHash = labelhash(label); + const node = makeSubdomainNode(labelHash, managedNode); + const domainId = makeENSv1DomainId(node); + const registration = await getLatestRegistration(context, domainId); + if (!registration) { + throw new Error( + `Invariant(RegistrarController:NameRenewed): NameRenewed but no Registration.`, + ); + } + + const renewal = await getLatestRenewal(context, domainId, registration.index); + if (!renewal) { + throw new Error( + `Invariant(RegistrarController:NameRenewed): NameRenewed but no Renewal for Registration\n${toJson(registration)}`, + ); + } + + // update renewal info + // TODO(paymentToken): add payment token tracking here + await context.db.update(schema.renewal, { id: renewal.id }).set({ base, premium, referrer }); + } + + ////////////////////////////////////// + // RegistrarController:NameRegistered + ////////////////////////////////////// + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRegistered(string label, bytes32 indexed labelhash, address indexed owner, uint256 baseCost, uint256 premium, uint256 expires, bytes32 referrer)", + ), + ({ context, event }) => + handleNameRegisteredByController({ + context, + event: { + ...event, + args: { ...event.args, labelHash: event.args.labelhash }, + }, + }), + ); + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRegistered(string name, bytes32 indexed label, address indexed owner, uint256 baseCost, uint256 premium, uint256 expires)", + ), + ({ context, event }) => + handleNameRegisteredByController({ + context, + event: { + ...event, + args: { ...event.args, label: event.args.name, labelHash: event.args.label }, + }, + }), + ); + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRegistered(string name, bytes32 indexed label, address indexed owner, uint256 cost, uint256 expires)", + ), + ({ context, event }) => + handleNameRegisteredByController({ + context, + event: { + ...event, + args: { ...event.args, label: event.args.name, labelHash: event.args.label }, + }, + }), + ); + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRegistered(string name, bytes32 indexed label, address indexed owner, uint256 expires)", + ), + ({ context, event }) => + handleNameRegisteredByController({ + context, + event: { + ...event, + args: { ...event.args, label: event.args.name, labelHash: event.args.label }, + }, + }), + ); + + /////////////////////////////////// + // RegistrarController:NameRenewed + /////////////////////////////////// + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRenewed(string label, bytes32 indexed labelhash, uint256 cost, uint256 expires, bytes32 referrer)", + ), + ({ context, event }) => + handleNameRenewedByController({ + context, + event: { ...event, args: { ...event.args, baseCost: event.args.cost } }, + }), + ); + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRenewed(string name, bytes32 indexed label, uint256 cost, uint256 expires)", + ), + ({ context, event }) => + handleNameRenewedByController({ + context, + event: { ...event, args: { ...event.args, baseCost: event.args.cost } }, + }), + ); + ponder.on( + namespaceContract( + pluginName, + "RegistrarController:NameRenewed(string name, bytes32 indexed label, uint256 expires)", + ), + handleNameRenewedByController, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts new file mode 100644 index 000000000..75c140503 --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -0,0 +1,273 @@ +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { type Address, hexToBigInt, labelhash } from "viem"; + +import { + type AccountId, + getCanonicalId, + interpretAddress, + isRegistrationFullyExpired, + type LiteralLabel, + makeENSv2DomainId, + makeLatestRegistrationId, + makeRegistryId, + PluginName, +} from "@ensnode/ensnode-sdk"; + +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; +import { + getLatestRegistration, + supercedeLatestRegistration, +} 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 } from "@/lib/ponder-helpers"; + +const pluginName = PluginName.ENSv2; + +export default function () { + ponder.on( + namespaceContract(pluginName, "ENSv2Registry:NameRegistered"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + tokenId: bigint; + label: string; + expiry: bigint; + registeredBy: Address; + }>; + }) => { + const { tokenId, label: _label, expiry, registeredBy: registrant } = event.args; + const label = _label as LiteralLabel; + + const labelHash = labelhash(label); + const registry = getThisAccountId(context, event); + const registryId = makeRegistryId(registry); + const canonicalId = getCanonicalId(tokenId); + const domainId = makeENSv2DomainId(registry, canonicalId); + + // Sanity Check: Canonical Id must match emitted label + if (canonicalId !== getCanonicalId(hexToBigInt(labelhash(label)))) { + throw new Error( + `Sanity Check: Domain's Canonical Id !== getCanonicalId(uint256(labelhash(label)))\n${toJson( + { + tokenId, + canonicalId, + label, + labelHash, + hexToBigInt: hexToBigInt(labelhash(label)), + }, + )}`, + ); + } + + // upsert Registry + // TODO(signals) — move to NewRegistry and add invariant here + await context.db + .insert(schema.registry) + .values({ + id: registryId, + type: "RegistryContract", + ...registry, + }) + .onConflictDoNothing(); + + // ensure discovered Label + await ensureLabel(context, label); + + const registration = await getLatestRegistration(context, domainId); + const isFullyExpired = + registration && isRegistrationFullyExpired(registration, event.block.timestamp); + + // Invariant: If a Registration for this v2Domain exists, it must be fully expired + if (registration && !isFullyExpired) { + throw new Error( + `Invariant(ENSv2Registry:NameRegistered): Existing unexpired ENSv2Registry Registration found in NameRegistered, expected none or expired.\n${toJson(registration)}`, + ); + } + + // insert or update v2Domain + // console.log(`NameRegistered: '${label}'\n ↳ ${domainId}`); + await context.db + .insert(schema.v2Domain) + .values({ + id: domainId, + tokenId, + registryId, + labelHash, + // NOTE: ownerId omitted, Transfer* events are sole source of ownership + }) + // if the v2Domain exists, this is a re-register after expiration and tokenId may have changed + .onConflictDoUpdate({ tokenId }); + + // supercede the latest Registration if exists + if (registration) await supercedeLatestRegistration(context, registration); + + // insert ENSv2Registry Registration + await ensureAccount(context, registrant); + await context.db.insert(schema.registration).values({ + id: makeLatestRegistrationId(domainId), + index: registration ? registration.index + 1 : 0, + type: "ENSv2Registry", + registrarChainId: registry.chainId, + registrarAddress: registry.address, + registrantId: interpretAddress(registrant), + domainId, + start: event.block.timestamp, + expiry, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "ENSv2Registry:ExpiryUpdated"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + tokenId: bigint; + newExpiry: bigint; + changedBy: Address; + }>; + }) => { + // biome-ignore lint/correctness/noUnusedVariables: not sure if we care to index changedBy + const { tokenId, newExpiry: expiry, changedBy } = event.args; + + const registry = getThisAccountId(context, event); + const canonicalId = getCanonicalId(tokenId); + const domainId = makeENSv2DomainId(registry, canonicalId); + + const registration = await getLatestRegistration(context, domainId); + + // Invariant: Registration must exist + if (!registration) { + throw new Error(`Invariant(ENSv2Registry:NameRenewed): Registration expected, none found.`); + } + + // Invariant: Registration must not be expired + if (isRegistrationFullyExpired(registration, event.block.timestamp)) { + throw new Error( + `Invariant(ENSv2Registry:NameRenewed): Registration found but it is expired:\n${toJson(registration)}`, + ); + } + + // update Registration + await context.db.update(schema.registration, { id: registration.id }).set({ expiry }); + + // if newExpiry is 0, this is an `unregister` call, related to ejecting + // https://github.com/ensdomains/namechain/blob/9e31679f4ee6d8abb4d4e840cdf06f2d653a706b/contracts/src/L1/bridge/L1BridgeController.sol#L141 + // TODO(migration): maybe do something special with this state? + // if (expiry === 0n) return; + }, + ); + + ponder.on( + namespaceContract(pluginName, "ENSv2Registry:SubregistryUpdated"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + tokenId: bigint; + subregistry: Address; + }>; + }) => { + const { tokenId, subregistry: _subregistry } = event.args; + const subregistry = interpretAddress(_subregistry); + + const registryAccountId = getThisAccountId(context, event); + const canonicalId = getCanonicalId(tokenId); + const domainId = makeENSv2DomainId(registryAccountId, canonicalId); + + // console.log(`SubregistryUpdated: ${subregistry} \n ↳ ${domainId}`); + + // update domain's subregistry + if (subregistry === null) { + await context.db.update(schema.v2Domain, { id: domainId }).set({ subregistryId: null }); + } else { + const subregistryAccountId: AccountId = { chainId: context.chain.id, address: subregistry }; + const subregistryId = makeRegistryId(subregistryAccountId); + + await context.db.update(schema.v2Domain, { id: domainId }).set({ subregistryId }); + } + }, + ); + + ponder.on( + namespaceContract(pluginName, "ENSv2Registry:TokenRegenerated"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + oldTokenId: bigint; + newTokenId: bigint; + resource: bigint; + }>; + }) => { + // biome-ignore lint/correctness/noUnusedVariables: TODO: use resource + const { oldTokenId, newTokenId, resource } = event.args; + + // Invariant: CanonicalIds must match + if (getCanonicalId(oldTokenId) !== getCanonicalId(newTokenId)) { + throw new Error(`Invariant(ENSv2Registry:TokenRegenerated): Canonical ID Malformed.`); + } + + const canonicalId = getCanonicalId(oldTokenId); + 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 }); + }, + ); + + async function handleTransferSingle({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ id: bigint; to: Address }>; + }) { + const { id: tokenId, to: owner } = event.args; + + const canonicalId = getCanonicalId(tokenId); + const registry = getThisAccountId(context, event); + const domainId = makeENSv2DomainId(registry, canonicalId); + + // TODO(signals): remove this + const registryId = makeRegistryId(registry); + const exists = await context.db.find(schema.registry, { id: registryId }); + if (!exists) return; // no-op non-Registry ERC1155 Transfers + + // just update the owner + // any _burns are always followed by a _mint, which would set the owner correctly + await context.db + .update(schema.v2Domain, { id: domainId }) + .set({ ownerId: interpretAddress(owner) }); + } + + ponder.on(namespaceContract(pluginName, "ENSv2Registry:TransferSingle"), handleTransferSingle); + ponder.on( + namespaceContract(pluginName, "ENSv2Registry:TransferBatch"), + async ({ context, event }) => { + for (const id of event.args.ids) { + await handleTransferSingle({ + context, + event: { ...event, args: { ...event.args, id } }, + }); + } + }, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts new file mode 100644 index 000000000..9c5d43f22 --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts @@ -0,0 +1,181 @@ +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import type { Address } from "viem"; + +import { + type AccountId, + type EncodedReferrer, + getCanonicalId, + interpretAddress, + isRegistrationFullyExpired, + makeENSv2DomainId, + makeLatestRenewalId, + PluginName, + type TokenId, +} from "@ensnode/ensnode-sdk"; + +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { + getLatestRegistration, + getLatestRenewal, + supercedeLatestRenewal, +} 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"; + +const pluginName = PluginName.ENSv2; + +async function getRegistrarAndRegistry(context: Context, event: LogEvent) { + const registrar = getThisAccountId(context, event); + const registry: AccountId = { + chainId: context.chain.id, + // ETHRegistrar (this contract) provides a handle to its backing Registry + address: await context.client.readContract({ + abi: context.contracts[namespaceContract(pluginName, "ETHRegistrar")].abi, + address: event.log.address, + functionName: "REGISTRY", + }), + }; + + return { registrar, registry }; +} + +export default function () { + ponder.on( + namespaceContract(pluginName, "ETHRegistrar:NameRegistered"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + tokenId: TokenId; + label: string; + owner: Address; + subregistry: Address; + resolver: Address; + duration: bigint; + referrer: EncodedReferrer; + paymentToken: Address; + base: bigint; + premium: bigint; + }>; + }) => { + // biome-ignore lint/correctness/noUnusedVariables: TODO(paymentToken) + const { tokenId, owner, referrer, paymentToken, base, premium } = event.args; + + // NOTE: Label and Domain operations are handled by ENSv2Registry:NameRegistered + // (see apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts) which occurs + // _before_ this event. This event upserts the latest Registration with payment info. + + const { registrar, registry } = await getRegistrarAndRegistry(context, event); + const canonicalId = getCanonicalId(tokenId); + const domainId = makeENSv2DomainId(registry, canonicalId); + + const registration = await getLatestRegistration(context, domainId); + + // Invariant: must have latest Registration + if (!registration) { + throw new Error( + `Invariant(ETHRegistrar:NameRegistered): Registration expected, none found.`, + ); + } + + // Invariant: must be ENSv2Registry Registration + if (registration.type !== "ENSv2Registry") { + throw new Error( + `Invariant(ETHRegistrar:NameRegistered): Registration found but not ENSv2Registry Registration:\n${toJson(registration)}`, + ); + } + + // Invariant: must not be expired + const isFullyExpired = isRegistrationFullyExpired(registration, event.block.timestamp); + if (isFullyExpired) { + throw new Error( + `Invariant(ETHRegistrar:NameRegistered): Registration found but expired:\n${toJson(registration)}`, + ); + } + + // upsert registrant + await ensureAccount(context, owner); + + // update latest Registration + await context.db.update(schema.registration, { id: registration.id }).set({ + // TODO: reconsider 'Registration.registrant' if ENSv2 doesn't provide explicit 'registrant' + registrantId: interpretAddress(owner), + + // we now know the correct registrar to attribute to, so overwrite + registrarChainId: registrar.chainId, + registrarAddress: registrar.address, + + referrer, + + // TODO(paymentToken): add payment token tracking here + base, + premium, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "ETHRegistrar:NameRenewed"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + tokenId: TokenId; + label: string; + duration: bigint; + newExpiry: bigint; + referrer: EncodedReferrer; + paymentToken: Address; + base: bigint; + }>; + }) => { + // biome-ignore lint/correctness/noUnusedVariables: TODO(paymentToken) + const { tokenId, duration, referrer, paymentToken, base } = event.args; + + // this event occurs _after_ ENSv2Registry:ExpiryUpdated and therefore does not need to + // update Registration.expiry, it just needs to update the latest Renewal + + const { registry } = await getRegistrarAndRegistry(context, event); + const canonicalId = getCanonicalId(tokenId); + const domainId = makeENSv2DomainId(registry, canonicalId); + + const registration = await getLatestRegistration(context, domainId); + + // Invariant: There must be a Registration to renew. + if (!registration) { + throw new Error(`Invariant(ETHRegistrar:NameRenewed): No Registration to renew.`); + } + + // Invariant: Must be ENSv2Registry Registration + if (registration.type !== "ENSv2Registry") { + throw new Error( + `Invariant(ETHRegistrar:NameRenewed): Registration found but not ENSv2Registry Registration:\n${toJson(registration)}`, + ); + } + + // get latest Renewal and supercede if exists + const renewal = await getLatestRenewal(context, domainId, registration.index); + if (renewal) await supercedeLatestRenewal(context, renewal); + + // insert latest Renewal + await context.db.insert(schema.renewal).values({ + id: makeLatestRenewalId(domainId, registration.index), + domainId, + registrationIndex: registration.index, + index: renewal ? renewal.index + 1 : 0, + duration, + referrer, + + // TODO(paymentToken) + base, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts new file mode 100644 index 000000000..5f4f8fe05 --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts @@ -0,0 +1,89 @@ +import { type Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { type Address, isAddressEqual, zeroAddress } from "viem"; + +import { + makePermissionsId, + makePermissionsResourceId, + makePermissionsUserId, + PluginName, +} from "@ensnode/ensnode-sdk"; + +import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { getThisAccountId } from "@/lib/get-this-account-id"; +import { namespaceContract } from "@/lib/plugin-helpers"; +import type { EventWithArgs } from "@/lib/ponder-helpers"; + +/** + * Infer the type of the Permission entity's composite key. + */ +type PermissionsCompositeKey = Pick; + +const ensurePermissionsResource = async ( + context: Context, + contract: PermissionsCompositeKey, + resource: bigint, +) => { + const permissionsId = makePermissionsId(contract); + const permissionsResourceId = makePermissionsResourceId(contract, resource); + + // ensure permissions + await context.db + .insert(schema.permissions) + .values({ id: permissionsId, ...contract }) + .onConflictDoNothing(); + + // ensure permissions resource + await context.db + .insert(schema.permissionsResource) + .values({ id: permissionsResourceId, ...contract, resource }) + .onConflictDoNothing(); +}; + +const isZeroRoles = (roles: bigint) => roles === 0n; + +export default function () { + ponder.on( + namespaceContract(PluginName.ENSv2, "EnhancedAccessControl:EACRolesChanged"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + resource: bigint; + account: Address; + oldRoleBitmap: bigint; + newRoleBitmap: bigint; + }>; + }) => { + // biome-ignore lint/correctness/noUnusedVariables: TODO: use oldRoleBitmap at all? + const { resource, account: user, oldRoleBitmap, newRoleBitmap } = event.args; + + // Invariant: EAC reverts EACInvalidAccount if account === zeroAddress + if (isAddressEqual(zeroAddress, user)) { + throw new Error( + `Invariant(EnhancedAccessControl:EACRolesChanged): EACRolesChanged emitted for zeroAddress, should have reverted.`, + ); + } + + const contract = getThisAccountId(context, event); + const permissionsUserId = makePermissionsUserId(contract, resource, user); + + await ensureAccount(context, user); + await ensurePermissionsResource(context, contract, resource); + + const roles = newRoleBitmap; + if (isZeroRoles(roles)) { + // ensure deleted + await context.db.delete(schema.permissionsUser, { id: permissionsUserId }); + } else { + // ensure upserted + await context.db + .insert(schema.permissionsUser) + .values({ id: permissionsUserId, ...contract, resource, user, roles }) + .onConflictDoUpdate({ roles }); + } + }, + ); +} diff --git a/apps/ensindexer/src/plugins/ensv2/plugin.ts b/apps/ensindexer/src/plugins/ensv2/plugin.ts new file mode 100644 index 000000000..fe861432e --- /dev/null +++ b/apps/ensindexer/src/plugins/ensv2/plugin.ts @@ -0,0 +1,311 @@ +/** + * TODO + * - root can be inserted on setup or could be discovered naturally — see how that affects traversal/graphql api + * - probably easier to just insert it ahead of time like previously + * - move registration expiration shared logic to sdk/ens + * - update isRegistrationFullyExpired todo in ensapi somewhere + * - RequiredAndNotNull opposite type: RequiredToBeNull for constraining polymorphic entities in graphql schema + * - re-asses NameWrapper expiry logic — compare to subgraph implementation & see if we can simplify + * - indexes based on graphql queries, ask claude to compile recommendations + * - modify Registration schema to more closely match ENSv2, map v1 into it + * - Renewals (v1, v2) + * - include similar /latest / superceding logic, need to be able to reference latest renewal to upsert referrers + * - ThreeDNS + * - Migration + * - need to understand migration pattern better + * - individual names are migrated to v2 and can choose to move to an ENSv2 Registry on L1 or L2 + * - locked names (wrapped and not unwrappable) are 'frozen' by having their fuses burned + * - will need to observe the correct event and then override the existing domain/registratioon info + * - for MigratedWrappedNameRegistries, need to check name expiry during resolution and avoid resolving expired names + * - autocomplete api + * - Query.permissions(by: { contract: { } }) + * - custom wrapper for resolveCursorConnection with typesafety that applies defaults and auto-decodes cursors to the indicated type + * - Pothos envelop plugins (aliases, depth, tokens, whatever) + * - BEFORE MERGE: revert sepolia.ts namespace back to original, including ensv2 stubs + * + * PENDING ENS TEAM + * - DedicatedResolver moving to EAC + * - depends on: namechain --testNames script not crashing in commit >= 803a940 + * - Domain.canonical/Domain.canonicalPath/Domain.fqdn depends on: + * - depends on: Registry.canonicalName implementation + indexing + * - Signal Pattern for Registry contracts + * - depends on: ens team implementing in namechain contracts + * + * MAYBE DO LATER? + * - ? better typechecking for polymorphic entities in drizzle schema + * - could do polymorphic resolver/registration metadata + * - would map well to resolver extensions in graphql + * - ? move all entity ids to opaque base58 encoded IDs? kinda nice since they're just supposed to be opaque, useful for relay purposes, allows the scalar types to all be ID and then casted. but nice to use CAIP identifiers for resolvers and permissions etc. so just for domains and registries? + * + * TODO MUCH LATER + * - after moving protocol-tracing away from otel we can use otel for ourselves + * https://pothos-graphql.dev/docs/plugins/tracing + * + */ + +import { createConfig } from "ponder"; + +import { + AnyRegistrarABI, + AnyRegistrarControllerABI, + DatasourceNames, + EnhancedAccessControlABI, + RegistryABI, +} from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; +import { + chainConfigForContract, + chainsConnectionConfigForDatasources, + getRequiredDatasources, + maybeGetDatasources, +} from "@/lib/ponder-helpers"; + +export const pluginName = PluginName.ENSv2; + +const REQUIRED_DATASOURCE_NAMES = [ + DatasourceNames.ENSRoot, // + DatasourceNames.Namechain, +]; + +const ALL_DATASOURCE_NAMES = [ + ...REQUIRED_DATASOURCE_NAMES, + DatasourceNames.Basenames, + DatasourceNames.Lineanames, +]; + +export default createPlugin({ + name: pluginName, + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, + createPonderConfig(config) { + const { + ensroot, // + namechain, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); + + const { + basenames, // + lineanames, + } = maybeGetDatasources(config.namespace, ALL_DATASOURCE_NAMES); + + return createConfig({ + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + ALL_DATASOURCE_NAMES, + ), + + contracts: { + //////////////////////////// + // ENSv2 Registry Contracts + //////////////////////////// + [namespaceContract(pluginName, "ENSv2Registry")]: { + abi: RegistryABI, + chain: [ensroot, namechain] + .filter((ds) => !!ds) + .reduce( + (memo, datasource) => ({ + ...memo, + ...chainConfigForContract( + config.globalBlockrange, + datasource.chain.id, + datasource.contracts.Registry, + ), + }), + {}, + ), + }, + + /////////////////////////////////// + // EnhancedAccessControl Contracts + /////////////////////////////////// + [namespaceContract(pluginName, "EnhancedAccessControl")]: { + abi: EnhancedAccessControlABI, + chain: [ensroot, namechain] + .filter((ds) => !!ds) + .reduce( + (memo, datasource) => ({ + ...memo, + ...chainConfigForContract( + config.globalBlockrange, + datasource.chain.id, + datasource.contracts.EnhancedAccessControl, + ), + }), + {}, + ), + }, + + ////////////////////////// + // Namechain ETHRegistrar + ////////////////////////// + [namespaceContract(pluginName, "ETHRegistrar")]: { + abi: namechain.contracts.ETHRegistrar.abi, + chain: chainConfigForContract( + config.globalBlockrange, + namechain.chain.id, + namechain.contracts.ETHRegistrar, + ), + }, + + ////////////////////////////////////// + // ENSv1RegistryOld on ENS Root Chain + ////////////////////////////////////// + [namespaceContract(pluginName, "ENSv1RegistryOld")]: { + abi: ensroot.contracts.ENSv1RegistryOld.abi, + chain: { + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.ENSv1RegistryOld, + ), + }, + }, + + ////////////////////////////////////// + // ENSv1Registry on + // - ENS Root Chain + // - Basenames + // - Lineanames + ////////////////////////////////////// + [namespaceContract(pluginName, "ENSv1Registry")]: { + abi: ensroot.contracts.ENSv1Registry.abi, + chain: { + // ENS Root Chain Registry + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.ENSv1Registry, + ), + // Basenames (shadow)Registry if defined + ...(basenames && + chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.Registry, + )), + // Lineanames (shadow)Registry if defined + ...(lineanames && + chainConfigForContract( + config.globalBlockrange, + lineanames.chain.id, + lineanames.contracts.Registry, + )), + }, + }, + + ////////////////////////////////////// + // NameWrapper on + // - ENS Root Chain + // - Lineanames + ////////////////////////////////////// + [namespaceContract(pluginName, "NameWrapper")]: { + abi: ensroot.contracts.NameWrapper.abi, + chain: { + // ENS Root Chain NameWrapper + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.NameWrapper, + ), + // Lineanames NameWrapper if defined + ...(lineanames && + chainConfigForContract( + config.globalBlockrange, + lineanames.chain.id, + lineanames.contracts.NameWrapper, + )), + }, + }, + + /////////////////// + // Base Registrars + /////////////////// + [namespaceContract(pluginName, "BaseRegistrar")]: { + abi: AnyRegistrarABI, + chain: { + // Ethnames BaseRegistrar + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.BaseRegistrar, + ), + // Basenames BaseRegistrar, if defined + ...(basenames && + chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.BaseRegistrar, + )), + // Lineanames BaseRegistrar, if defined + ...(lineanames && + chainConfigForContract( + config.globalBlockrange, + lineanames.chain.id, + lineanames.contracts.BaseRegistrar, + )), + }, + }, + + ///////////////////////// + // Registrar Controllers + ///////////////////////// + [namespaceContract(pluginName, "RegistrarController")]: { + abi: AnyRegistrarControllerABI, + chain: { + /////////////////////////////////// + // Ethnames Registrar Controllers + /////////////////////////////////// + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.LegacyEthRegistrarController, + ), + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.WrappedEthRegistrarController, + ), + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.UnwrappedEthRegistrarController, + ), + + /////////////////////////////////// + // Basenames Registrar Controllers + /////////////////////////////////// + ...(basenames && { + ...chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.EARegistrarController, + ), + ...chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.RegistrarController, + ), + ...chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.UpgradeableRegistrarController, + ), + }), + + //////////////////////////////////// + // Lineanames Registrar Controllers + //////////////////////////////////// + ...(lineanames && + chainConfigForContract( + config.globalBlockrange, + lineanames.chain.id, + lineanames.contracts.EthRegistrarController, + )), + }, + }, + }, + }); + }, +}); diff --git a/apps/ensindexer/src/plugins/index.ts b/apps/ensindexer/src/plugins/index.ts index 7cac1af60..837c98746 100644 --- a/apps/ensindexer/src/plugins/index.ts +++ b/apps/ensindexer/src/plugins/index.ts @@ -2,6 +2,8 @@ import type { PluginName } from "@ensnode/ensnode-sdk"; import type { MergedTypes } from "@/lib/lib-helpers"; +// ENSV2 Core Plugin +import ensv2Plugin from "./ensv2/plugin"; // Core-Schema-Indepdendent Plugins import protocolAccelerationPlugin from "./protocol-acceleration/plugin"; import registrarsPlugin from "./registrars/plugin"; @@ -20,6 +22,7 @@ export const ALL_PLUGINS = [ tokenScopePlugin, protocolAccelerationPlugin, registrarsPlugin, + ensv2Plugin, ] as const; /** diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/event-handlers.ts b/apps/ensindexer/src/plugins/protocol-acceleration/event-handlers.ts index 911c61ce9..8c1b91ffd 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/event-handlers.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/event-handlers.ts @@ -1,10 +1,12 @@ -import attach_RegistryHandlers from "./handlers/Registry"; +import attach_ENSv1RegistryHandlers from "./handlers/ENSv1Registry"; +import attach_ENSv2RegistryHandlers from "./handlers/ENSv2Registry"; import attach_ResolverHandlers from "./handlers/Resolver"; import attach_StandaloneReverseRegistrarHandlers from "./handlers/StandaloneReverseRegistrar"; import attach_ThreeDNSTokenHandlers from "./handlers/ThreeDNSToken"; export default function () { - attach_RegistryHandlers(); + attach_ENSv1RegistryHandlers(); + attach_ENSv2RegistryHandlers(); attach_ResolverHandlers(); attach_StandaloneReverseRegistrarHandlers(); attach_ThreeDNSTokenHandlers(); diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Registry.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts similarity index 65% rename from apps/ensindexer/src/plugins/protocol-acceleration/handlers/Registry.ts rename to apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts index ed584b23a..91b2dd996 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Registry.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts @@ -1,39 +1,25 @@ import config from "@/config"; import { type Context, ponder } from "ponder:registry"; -import { type Address, isAddressEqual, zeroAddress } from "viem"; +import type { Address } from "viem"; import { getENSRootChainId } from "@ensnode/datasources"; -import { type LabelHash, makeSubdomainNode, type Node, PluginName } from "@ensnode/ensnode-sdk"; +import { + type LabelHash, + makeENSv1DomainId, + makeSubdomainNode, + type Node, + PluginName, +} from "@ensnode/ensnode-sdk"; +import { getThisAccountId } from "@/lib/get-this-account-id"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; -import { - removeNodeResolverRelation, - upsertNodeResolverRelation, -} from "@/lib/protocol-acceleration/node-resolver-relationship-db-helpers"; +import { ensureDomainResolverRelation } from "@/lib/protocol-acceleration/domain-resolver-relationship-db-helpers"; import { migrateNode, nodeIsMigrated } from "@/lib/protocol-acceleration/registry-migration-status"; const ensRootChainId = getENSRootChainId(config.namespace); -async function handleNewResolver({ - context, - event, -}: { - context: Context; - event: EventWithArgs<{ node: Node; resolver: Address }>; -}) { - const { node, resolver: resolverAddress } = event.args; - const registry = event.log.address; - const isZeroResolver = isAddressEqual(zeroAddress, resolverAddress); - - if (isZeroResolver) { - await removeNodeResolverRelation(context, registry, node); - } else { - await upsertNodeResolverRelation(context, registry, node, resolverAddress); - } -} - /** * Handler functions for Regsitry contracts in the Protocol Acceleration plugin. * - indexes ENS Root Chain Registry migration status @@ -42,12 +28,27 @@ async function handleNewResolver({ * Note that this registry migration status tracking is isolated to the protocol */ export default function () { + async function handleNewResolver({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ node: Node; resolver: Address }>; + }) { + const { node, resolver } = event.args; + + const registry = getThisAccountId(context, event); + const domainId = makeENSv1DomainId(node); + + await ensureDomainResolverRelation(context, registry, domainId, resolver); + } + /** * Handles Registry#NewOwner for: * - ENS Root Chain's (new) Registry */ ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "Registry:NewOwner"), + namespaceContract(PluginName.ProtocolAcceleration, "ENSv1Registry:NewOwner"), async ({ context, event, @@ -72,10 +73,10 @@ export default function () { /** * Handles Registry#NewResolver for: - * - ENS Root Chain's RegistryOld + * - ENS Root Chain's ENSv1RegistryOld */ ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "RegistryOld:NewResolver"), + namespaceContract(PluginName.ProtocolAcceleration, "ENSv1RegistryOld:NewResolver"), async ({ context, event, @@ -83,7 +84,7 @@ export default function () { context: Context; event: EventWithArgs<{ node: Node; resolver: Address }>; }) => { - // ignore the event on RegistryOld if node is migrated to new Registry + // ignore the event on ENSv1RegistryOld if node is migrated to new Registry const shouldIgnoreEvent = await nodeIsMigrated(context, event.args.node); if (shouldIgnoreEvent) return; @@ -98,7 +99,7 @@ export default function () { * - Lineanames's (shadow) Registry */ ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "Registry:NewResolver"), + namespaceContract(PluginName.ProtocolAcceleration, "ENSv1Registry:NewResolver"), handleNewResolver, ); } diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv2Registry.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv2Registry.ts new file mode 100644 index 000000000..e918bc447 --- /dev/null +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv2Registry.ts @@ -0,0 +1,35 @@ +import { type Context, ponder } from "ponder:registry"; +import type { Address } from "viem"; + +import { getCanonicalId, makeENSv2DomainId, PluginName } from "@ensnode/ensnode-sdk"; + +import { getThisAccountId } from "@/lib/get-this-account-id"; +import { namespaceContract } from "@/lib/plugin-helpers"; +import type { EventWithArgs } from "@/lib/ponder-helpers"; +import { ensureDomainResolverRelation } from "@/lib/protocol-acceleration/domain-resolver-relationship-db-helpers"; + +const pluginName = PluginName.ProtocolAcceleration; + +export default function () { + ponder.on( + namespaceContract(pluginName, "ENSv2Registry:ResolverUpdated"), + async ({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + tokenId: bigint; + resolver: Address; + }>; + }) => { + const { tokenId, resolver } = event.args; + + const registry = getThisAccountId(context, event); + const canonicalId = getCanonicalId(tokenId); + const domainId = makeENSv2DomainId(registry, canonicalId); + + await ensureDomainResolverRelation(context, registry, domainId, resolver); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts index 9d0bc2f3f..dd8d9bea3 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/Resolver.ts @@ -1,57 +1,73 @@ import { ponder } from "ponder:registry"; -import { ETH_COIN_TYPE, PluginName } from "@ensnode/ensnode-sdk"; +import { bigintToCoinType, type CoinType, ETH_COIN_TYPE, PluginName } from "@ensnode/ensnode-sdk"; import { parseDnsTxtRecordArgs } from "@/lib/dns-helpers"; +import { getThisAccountId } from "@/lib/get-this-account-id"; import { namespaceContract } from "@/lib/plugin-helpers"; import { + ensureResolver, ensureResolverRecords, handleResolverAddressRecordUpdate, handleResolverNameUpdate, handleResolverTextRecordUpdate, - makeResolverRecordsId, -} from "@/lib/protocol-acceleration/resolver-records-db-helpers"; + makeResolverRecordsCompositeKey, +} from "@/lib/protocol-acceleration/resolver-db-helpers"; + +const pluginName = PluginName.ProtocolAcceleration; /** * Handlers for Resolver contracts in the Protocol Acceleration plugin. * - indexes all Resolver Records described by protocol-acceleration.schema.ts */ export default function () { - ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "Resolver:AddrChanged"), - async ({ context, event }) => { - const { a: address } = event.args; + ponder.on(namespaceContract(pluginName, "Resolver:AddrChanged"), async ({ context, event }) => { + const { a: address } = event.args; + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); - // the Resolver#AddrChanged event is just Resolver#AddressChanged with implicit coinType of ETH - await handleResolverAddressRecordUpdate(context, id, BigInt(ETH_COIN_TYPE), address); - }, - ); + // the Resolver#AddrChanged event is just Resolver#AddressChanged with implicit coinType of ETH + await handleResolverAddressRecordUpdate(context, resolverRecordsKey, ETH_COIN_TYPE, address); + }); ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "Resolver:AddressChanged"), + namespaceContract(pluginName, "Resolver:AddressChanged"), async ({ context, event }) => { - const { coinType, newAddress } = event.args; + const { coinType: _coinType, newAddress } = event.args; - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverAddressRecordUpdate(context, id, coinType, newAddress); - }, - ); + // all well-known CoinTypes fit into number, so we coerce here + let coinType: CoinType; + try { + coinType = bigintToCoinType(_coinType); + } catch { + return; // ignore if bigint can't be coerced to known CoinType + } - ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "Resolver:NameChanged"), - async ({ context, event }) => { - const { name } = event.args; + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverNameUpdate(context, id, name); + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverAddressRecordUpdate(context, resolverRecordsKey, coinType, newAddress); }, ); + ponder.on(namespaceContract(pluginName, "Resolver:NameChanged"), async ({ context, event }) => { + const { name } = event.args; + + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverNameUpdate(context, resolverRecordsKey, name); + }); + ponder.on( namespaceContract( PluginName.ProtocolAcceleration, @@ -74,9 +90,13 @@ export default function () { }); } catch {} // no-op if readContract throws for whatever reason - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverTextRecordUpdate(context, id, key, value); + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); }, ); @@ -88,9 +108,13 @@ export default function () { async ({ context, event }) => { const { key, value } = event.args; - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverTextRecordUpdate(context, id, key, value); + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); }, ); @@ -105,9 +129,13 @@ export default function () { const { key, value } = parseDnsTxtRecordArgs(event.args); if (key === null) return; // no key to operate over? args were malformed, ignore event - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverTextRecordUpdate(context, id, key, value); + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); }, ); @@ -121,21 +149,29 @@ export default function () { const { key, value } = parseDnsTxtRecordArgs(event.args); if (key === null) return; // no key to operate over? args were malformed, ignore event - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverTextRecordUpdate(context, id, key, value); + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, value); }, ); ponder.on( - namespaceContract(PluginName.ProtocolAcceleration, "Resolver:DNSRecordDeleted"), + namespaceContract(pluginName, "Resolver:DNSRecordDeleted"), async ({ context, event }) => { const { key } = parseDnsTxtRecordArgs(event.args); if (key === null) return; // no key to operate over? args were malformed, ignore event - const id = makeResolverRecordsId(context, event); - await ensureResolverRecords(context, id); - await handleResolverTextRecordUpdate(context, id, key, null); + const resolver = getThisAccountId(context, event); + await ensureResolver(context, resolver); + + const resolverRecordsKey = makeResolverRecordsCompositeKey(resolver, event); + await ensureResolverRecords(context, resolverRecordsKey); + + await handleResolverTextRecordUpdate(context, resolverRecordsKey, key, null); }, ); } diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts index 326ad8215..66236871e 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ThreeDNSToken.ts @@ -7,14 +7,16 @@ import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { type ChainId, type LabelHash, + makeENSv1DomainId, makeSubdomainNode, type Node, PluginName, } from "@ensnode/ensnode-sdk"; +import { getThisAccountId } from "@/lib/get-this-account-id"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; -import { upsertNodeResolverRelation } from "@/lib/protocol-acceleration/node-resolver-relationship-db-helpers"; +import { ensureDomainResolverRelation } from "@/lib/protocol-acceleration/domain-resolver-relationship-db-helpers"; const ThreeDNSResolverByChainId: Record = [ DatasourceNames.ThreeDNSBase, @@ -52,18 +54,19 @@ export default function () { }>; }) => { const { label: labelHash, node: parentNode } = event.args; - const registry = event.log.address; + const registry = getThisAccountId(context, event); const node = makeSubdomainNode(labelHash, parentNode); + const domainId = makeENSv1DomainId(node); - const resolverAddress = ThreeDNSResolverByChainId[context.chain.id]; - if (!resolverAddress) { + // all ThreeDNSToken nodes have a hardcoded resolver + const resolver = ThreeDNSResolverByChainId[context.chain.id]; + if (!resolver) { throw new Error( `Invariant: ThreeDNSToken ${event.log.address} on chain ${context.chain.id} doesn't have an associated Resolver?`, ); } - // all ThreeDNSToken nodes have a hardcoded resolver at that address - await upsertNodeResolverRelation(context, registry, node, resolverAddress); + await ensureDomainResolverRelation(context, registry, domainId, resolver); }, ); } diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/plugin.ts b/apps/ensindexer/src/plugins/protocol-acceleration/plugin.ts index 97e17312f..6ef2c8b29 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/plugin.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/plugin.ts @@ -1,9 +1,8 @@ -import { type ChainConfig, createConfig } from "ponder"; +import { createConfig } from "ponder"; import { - type DatasourceName, DatasourceNames, - getDatasource, + RegistryABI, ResolverABI, StandaloneReverseRegistrarABI, ThreeDNSTokenABI, @@ -14,15 +13,13 @@ import { getDatasourcesWithResolvers, } from "@ensnode/ensnode-sdk/internal"; -import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { chainConfigForContract, - chainsConnectionConfig, + chainsConnectionConfigForDatasources, constrainBlockrange, + getRequiredDatasources, + maybeGetDatasources, } from "@/lib/ponder-helpers"; /** @@ -46,77 +43,44 @@ const DATASOURCE_NAMES_WITH_REVERSE_RESOLVERS = [ DatasourceNames.ReverseResolverOptimism, DatasourceNames.ReverseResolverArbitrum, DatasourceNames.ReverseResolverScroll, -] as const satisfies DatasourceName[]; +]; const ALL_DATASOURCE_NAMES = [ ...DATASOURCE_NAMES_WITH_RESOLVERS, ...DATASOURCE_NAMES_WITH_REVERSE_RESOLVERS, -] as const satisfies DatasourceName[]; +]; + +const REQUIRED_DATASOURCE_NAMES = [DatasourceNames.ENSRoot]; export default createPlugin({ name: pluginName, - requiredDatasourceNames: [DatasourceNames.ENSRoot], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - const allDatasources = ALL_DATASOURCE_NAMES.map((datasourceName) => - getDatasourceAsFullyDefinedAtCompileTime(config.namespace, datasourceName), - ).filter((datasource) => !!datasource); - - const ensroot = getDatasource(config.namespace, DatasourceNames.ENSRoot); - const basenames = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Basenames, - ); - const lineanames = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Lineanames, - ); - const threeDNSOptimism = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ThreeDNSOptimism, - ); - const threeDNSBase = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ThreeDNSBase, - ); - - const rrRoot = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ReverseResolverRoot, - ); - const rrBase = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ReverseResolverBase, - ); - const rrLinea = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ReverseResolverLinea, - ); - const rrOptimism = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ReverseResolverOptimism, - ); - const rrArbitrum = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ReverseResolverArbitrum, - ); - const rrScroll = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ReverseResolverScroll, - ); + const { ensroot } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); + const { + namechain, + basenames, + lineanames, + threednsOptimism, + threednsBase, + rrRoot, + rrBase, + rrLinea, + rrOptimism, + rrArbitrum, + rrScroll, + } = maybeGetDatasources(config.namespace, ALL_DATASOURCE_NAMES); return createConfig({ - chains: allDatasources - .map((datasource) => datasource.chain) - .reduce>( - (memo, chain) => ({ - ...memo, - ...chainsConnectionConfig(config.rpcConfigs, chain.id), - }), - {}, - ), - + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + ALL_DATASOURCE_NAMES, + ), contracts: { - // a multi-chain Resolver ContractConfig + ////////////////////// + // Resolver Contracts + ////////////////////// [namespaceContract(pluginName, "Resolver")]: { abi: ResolverABI, chain: getDatasourcesWithResolvers(config.namespace).reduce( @@ -131,27 +95,31 @@ export default createPlugin({ ), }, - // index the RegistryOld on ENS Root Chain - [namespaceContract(pluginName, "RegistryOld")]: { - abi: ensroot.contracts.RegistryOld.abi, + ///////////////////// + // ENSv1 RegistryOld + ///////////////////// + [namespaceContract(pluginName, "ENSv1RegistryOld")]: { + abi: ensroot.contracts.ENSv1RegistryOld.abi, chain: { ...chainConfigForContract( config.globalBlockrange, ensroot.chain.id, - ensroot.contracts.RegistryOld, + ensroot.contracts.ENSv1RegistryOld, ), }, }, - // a multi-chain Registry ContractConfig - [namespaceContract(pluginName, "Registry")]: { - abi: ensroot.contracts.Registry.abi, + //////////////////////////// + // ENSv1 Registry Contracts + //////////////////////////// + [namespaceContract(pluginName, "ENSv1Registry")]: { + abi: ensroot.contracts.ENSv1Registry.abi, chain: { // ENS Root Chain Registry ...chainConfigForContract( config.globalBlockrange, ensroot.chain.id, - ensroot.contracts.Registry, + ensroot.contracts.ENSv1Registry, ), // Basenames (shadow)Registry ...(basenames && @@ -170,26 +138,50 @@ export default createPlugin({ }, }, - // a multi-chain ThreeDNSToken ContractConfig + //////////////////////////// + // ENSv2 Registry Contracts + //////////////////////////// + [namespaceContract(pluginName, "ENSv2Registry")]: { + abi: RegistryABI, + chain: { + ...chainConfigForContract( + config.globalBlockrange, + ensroot.chain.id, + ensroot.contracts.Registry, + ), + ...(namechain && + chainConfigForContract( + config.globalBlockrange, + namechain.chain.id, + namechain.contracts.Registry, + )), + }, + }, + + ///////////////// + // ThreeDNSToken + ///////////////// [namespaceContract(pluginName, "ThreeDNSToken")]: { abi: ThreeDNSTokenABI, chain: { - ...(threeDNSOptimism && + ...(threednsOptimism && chainConfigForContract( config.globalBlockrange, - threeDNSOptimism.chain.id, - threeDNSOptimism.contracts.ThreeDNSToken, + threednsOptimism.chain.id, + threednsOptimism.contracts.ThreeDNSToken, )), - ...(threeDNSBase && + ...(threednsBase && chainConfigForContract( config.globalBlockrange, - threeDNSBase.chain.id, - threeDNSBase.contracts.ThreeDNSToken, + threednsBase.chain.id, + threednsBase.contracts.ThreeDNSToken, )), }, }, - // a multi-chain StandaloneReverseRegistrar ContractConfig + /////////////////////////////// + // StandaloneReverseRegistrars + /////////////////////////////// [namespaceContract(pluginName, "StandaloneReverseRegistrar")]: { abi: StandaloneReverseRegistrarABI, chain: { diff --git a/apps/ensindexer/src/plugins/registrars/plugin.ts b/apps/ensindexer/src/plugins/registrars/plugin.ts index eeae65667..ac0d9be44 100644 --- a/apps/ensindexer/src/plugins/registrars/plugin.ts +++ b/apps/ensindexer/src/plugins/registrars/plugin.ts @@ -7,165 +7,154 @@ * - Lineanames */ -import * as ponder from "ponder"; +import { createConfig } from "ponder"; import { DatasourceNames } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; -import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + chainConfigForContract, + chainsConnectionConfigForDatasources, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; const pluginName = PluginName.Registrars; +const REQUIRED_DATASOURCE_NAMES = [ + DatasourceNames.ENSRoot, + DatasourceNames.Basenames, + DatasourceNames.Lineanames, +]; + export default createPlugin({ name: pluginName, - requiredDatasourceNames: [ - DatasourceNames.ENSRoot, - DatasourceNames.Basenames, - DatasourceNames.Lineanames, - ], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - // configure Ethnames dependencies - const ethnamesDatasource = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ENSRoot, - ); - - const ethnamesRegistrarContracts = { - [namespaceContract(pluginName, "Ethnames_BaseRegistrar")]: { - chain: chainConfigForContract( - config.globalBlockrange, - ethnamesDatasource.chain.id, - ethnamesDatasource.contracts.BaseRegistrar, - ), - abi: ethnamesDatasource.contracts.BaseRegistrar.abi, - }, - }; - - const ethnamesRegistrarControllerContracts = { - [namespaceContract(pluginName, "Ethnames_LegacyEthRegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - ethnamesDatasource.chain.id, - ethnamesDatasource.contracts.LegacyEthRegistrarController, - ), - abi: ethnamesDatasource.contracts.LegacyEthRegistrarController.abi, - }, - [namespaceContract(pluginName, "Ethnames_WrappedEthRegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - ethnamesDatasource.chain.id, - ethnamesDatasource.contracts.WrappedEthRegistrarController, - ), - abi: ethnamesDatasource.contracts.WrappedEthRegistrarController.abi, - }, - [namespaceContract(pluginName, "Ethnames_UnwrappedEthRegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - ethnamesDatasource.chain.id, - ethnamesDatasource.contracts.UnwrappedEthRegistrarController, - ), - abi: ethnamesDatasource.contracts.UnwrappedEthRegistrarController.abi, - }, - [namespaceContract(pluginName, "Ethnames_UniversalRegistrarRenewalWithReferrer")]: { - chain: chainConfigForContract( - config.globalBlockrange, - ethnamesDatasource.chain.id, - ethnamesDatasource.contracts.UniversalRegistrarRenewalWithReferrer, - ), - abi: ethnamesDatasource.contracts.UniversalRegistrarRenewalWithReferrer.abi, - }, - }; - - // configure Basenames dependencies - const basenamesDatasource = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Basenames, - ); + const { + ensroot: ethnames, + basenames, + lineanames, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); - const basenamesRegistrarContracts = { - [namespaceContract(pluginName, "Basenames_BaseRegistrar")]: { - chain: chainConfigForContract( - config.globalBlockrange, - basenamesDatasource.chain.id, - basenamesDatasource.contracts.BaseRegistrar, - ), - abi: basenamesDatasource.contracts.BaseRegistrar.abi, - }, - }; + return createConfig({ + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + REQUIRED_DATASOURCE_NAMES, + ), + contracts: { + ////////////////////// + // Ethnames Registrar + ////////////////////// + [namespaceContract(pluginName, "Ethnames_BaseRegistrar")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnames.chain.id, + ethnames.contracts.BaseRegistrar, + ), + abi: ethnames.contracts.BaseRegistrar.abi, + }, - const basenamesRegistrarControllerContracts = { - [namespaceContract(pluginName, "Basenames_EARegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - basenamesDatasource.chain.id, - basenamesDatasource.contracts.EARegistrarController, - ), - abi: basenamesDatasource.contracts.EARegistrarController.abi, - }, - [namespaceContract(pluginName, "Basenames_RegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - basenamesDatasource.chain.id, - basenamesDatasource.contracts.RegistrarController, - ), - abi: basenamesDatasource.contracts.RegistrarController.abi, - }, - [namespaceContract(pluginName, "Basenames_UpgradeableRegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - basenamesDatasource.chain.id, - basenamesDatasource.contracts.UpgradeableRegistrarController, - ), - abi: basenamesDatasource.contracts.UpgradeableRegistrarController.abi, - }, - }; + ////////////////////////////////// + // Ethnames Registrar Controllers + ////////////////////////////////// + [namespaceContract(pluginName, "Ethnames_LegacyEthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnames.chain.id, + ethnames.contracts.LegacyEthRegistrarController, + ), + abi: ethnames.contracts.LegacyEthRegistrarController.abi, + }, + [namespaceContract(pluginName, "Ethnames_WrappedEthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnames.chain.id, + ethnames.contracts.WrappedEthRegistrarController, + ), + abi: ethnames.contracts.WrappedEthRegistrarController.abi, + }, + [namespaceContract(pluginName, "Ethnames_UnwrappedEthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnames.chain.id, + ethnames.contracts.UnwrappedEthRegistrarController, + ), + abi: ethnames.contracts.UnwrappedEthRegistrarController.abi, + }, + [namespaceContract(pluginName, "Ethnames_UniversalRegistrarRenewalWithReferrer")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnames.chain.id, + ethnames.contracts.UniversalRegistrarRenewalWithReferrer, + ), + abi: ethnames.contracts.UniversalRegistrarRenewalWithReferrer.abi, + }, - // configure Lineanames dependencies - const linenamesDatasource = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Lineanames, - ); + /////////////////////// + // Basenames Registrar + /////////////////////// + [namespaceContract(pluginName, "Basenames_BaseRegistrar")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.BaseRegistrar, + ), + abi: basenames.contracts.BaseRegistrar.abi, + }, - const lineanamesRegistrarContracts = { - [namespaceContract(pluginName, "Lineanames_BaseRegistrar")]: { - chain: chainConfigForContract( - config.globalBlockrange, - linenamesDatasource.chain.id, - linenamesDatasource.contracts.BaseRegistrar, - ), - abi: linenamesDatasource.contracts.BaseRegistrar.abi, - }, - }; + /////////////////////////////////// + // Basenames Registrar Controllers + /////////////////////////////////// + [namespaceContract(pluginName, "Basenames_EARegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.EARegistrarController, + ), + abi: basenames.contracts.EARegistrarController.abi, + }, + [namespaceContract(pluginName, "Basenames_RegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.RegistrarController, + ), + abi: basenames.contracts.RegistrarController.abi, + }, + [namespaceContract(pluginName, "Basenames_UpgradeableRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenames.chain.id, + basenames.contracts.UpgradeableRegistrarController, + ), + abi: basenames.contracts.UpgradeableRegistrarController.abi, + }, - const lineanamesRegistrarControllerContracts = { - [namespaceContract(pluginName, "Lineanames_EthRegistrarController")]: { - chain: chainConfigForContract( - config.globalBlockrange, - linenamesDatasource.chain.id, - linenamesDatasource.contracts.EthRegistrarController, - ), - abi: linenamesDatasource.contracts.EthRegistrarController.abi, - }, - }; + //////////////////////// + // Lineanames Registrar + //////////////////////// + [namespaceContract(pluginName, "Lineanames_BaseRegistrar")]: { + chain: chainConfigForContract( + config.globalBlockrange, + lineanames.chain.id, + lineanames.contracts.BaseRegistrar, + ), + abi: lineanames.contracts.BaseRegistrar.abi, + }, - return ponder.createConfig({ - chains: { - ...chainsConnectionConfig(config.rpcConfigs, ethnamesDatasource.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, basenamesDatasource.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, linenamesDatasource.chain.id), - }, - contracts: { - ...ethnamesRegistrarContracts, - ...ethnamesRegistrarControllerContracts, - ...basenamesRegistrarContracts, - ...basenamesRegistrarControllerContracts, - ...lineanamesRegistrarContracts, - ...lineanamesRegistrarControllerContracts, + //////////////////////////////////// + // Lineanames Registrar Controllers + //////////////////////////////////// + [namespaceContract(pluginName, "Lineanames_EthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + lineanames.chain.id, + lineanames.contracts.EthRegistrarController, + ), + abi: lineanames.contracts.EthRegistrarController.abi, + }, }, }); }, diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts index 933e6f917..37f647197 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts @@ -71,7 +71,10 @@ export default function () { ponder.on(namespaceContract(pluginName, "BaseRegistrar:Transfer"), async ({ context, event }) => { await handleNameTransferred({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.tokenId) }, + }, }); }); diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/lib/registrar-helpers.ts b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/lib/registrar-helpers.ts index a00a69ab0..74d74988c 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/lib/registrar-helpers.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/lib/registrar-helpers.ts @@ -16,7 +16,6 @@ export function getRegistrarManagedName(namespaceId: ENSNamespaceId): RegistrarM return "base.eth"; case "sepolia": return "basetest.eth"; - case "holesky": case "ens-test-env": throw new Error( `No registrar managed name is known for the Basenames plugin within the "${namespaceId}" namespace.`, diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/plugin.ts b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/plugin.ts index da909a22a..6242058b2 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/plugin.ts @@ -7,26 +7,31 @@ import * as ponder from "ponder"; import { DatasourceNames } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; -import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + chainConfigForContract, + chainsConnectionConfigForDatasources, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; const pluginName = PluginName.Basenames; +const REQUIRED_DATASOURCE_NAMES = [DatasourceNames.Basenames]; + export default createPlugin({ name: pluginName, - requiredDatasourceNames: [DatasourceNames.Basenames], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - const { chain, contracts } = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Basenames, - ); + const { + basenames: { chain, contracts }, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); return ponder.createConfig({ - chains: chainsConnectionConfig(config.rpcConfigs, chain.id), + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + REQUIRED_DATASOURCE_NAMES, + ), contracts: { [namespaceContract(pluginName, "Registry")]: { chain: chainConfigForContract(config.globalBlockrange, chain.id, contracts.Registry), diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/lib/registrar-helpers.ts b/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/lib/registrar-helpers.ts index 49cb6243f..6a99aed2f 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/lib/registrar-helpers.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/lib/registrar-helpers.ts @@ -16,7 +16,6 @@ export function getRegistrarManagedName(namespaceId: ENSNamespaceId): RegistrarM return "linea.eth"; case "sepolia": return "linea-sepolia.eth"; - case "holesky": case "ens-test-env": throw new Error( `No registrar managed name is known for the Linea Names plugin within the "${namespaceId}" namespace.`, diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/plugin.ts b/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/plugin.ts index bbc26139c..4458567e6 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/plugin.ts @@ -8,26 +8,31 @@ import * as ponder from "ponder"; import { DatasourceNames } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; -import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + chainConfigForContract, + chainsConnectionConfigForDatasources, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; const pluginName = PluginName.Lineanames; +const REQUIRED_DATASOURCE_NAMES = [DatasourceNames.Lineanames]; + export default createPlugin({ name: pluginName, - requiredDatasourceNames: [DatasourceNames.Lineanames], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - const { chain, contracts } = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Lineanames, - ); + const { + lineanames: { chain, contracts }, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); return ponder.createConfig({ - chains: chainsConnectionConfig(config.rpcConfigs, chain.id), + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + REQUIRED_DATASOURCE_NAMES, + ), contracts: { [namespaceContract(pluginName, "Registry")]: { chain: chainConfigForContract(config.globalBlockrange, chain.id, contracts.Registry), diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/handlers/Registry.ts b/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/handlers/Registry.ts index dad6df0aa..a3efc376d 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/handlers/Registry.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/handlers/Registry.ts @@ -31,22 +31,25 @@ async function shouldIgnoreRegistryOldEvents(context: Context, node: Node) { export default function () { const pluginName = PluginName.Subgraph; - ponder.on(namespaceContract(pluginName, "RegistryOld:setup"), setupRootNode); + ponder.on(namespaceContract(pluginName, "ENSv1RegistryOld:setup"), setupRootNode); // old registry functions are proxied to the current handlers // iff the domain has not yet been migrated - ponder.on(namespaceContract(pluginName, "RegistryOld:NewOwner"), async ({ context, event }) => { - const { label: labelHash, node: parentNode } = event.args; + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:NewOwner"), + async ({ context, event }) => { + const { label: labelHash, node: parentNode } = event.args; - const node = makeSubdomainNode(labelHash, parentNode); - const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, node); - if (shouldIgnoreEvent) return; + const node = makeSubdomainNode(labelHash, parentNode); + const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, node); + if (shouldIgnoreEvent) return; - return handleNewOwner(false)({ context, event }); - }); + return handleNewOwner(false)({ context, event }); + }, + ); ponder.on( - namespaceContract(pluginName, "RegistryOld:NewResolver"), + namespaceContract(pluginName, "ENSv1RegistryOld:NewResolver"), async ({ context, event }) => { const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, event.args.node); const isRootNode = event.args.node === ROOT_NODE; @@ -60,28 +63,34 @@ export default function () { }, ); - ponder.on(namespaceContract(pluginName, "RegistryOld:NewTTL"), async ({ context, event }) => { - const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, event.args.node); - if (shouldIgnoreEvent) return; + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:NewTTL"), + async ({ context, event }) => { + const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, event.args.node); + if (shouldIgnoreEvent) return; - return handleNewTTL({ context, event }); - }); + return handleNewTTL({ context, event }); + }, + ); - ponder.on(namespaceContract(pluginName, "RegistryOld:Transfer"), async ({ context, event }) => { - // NOTE: this logic derived from the subgraph introduces a bug for queries with a blockheight - // below 9380380, when the new Registry was deployed, as it implicitly ignores Transfer events - // of the ROOT_NODE. as a result, the root node's owner is always zeroAddress until the new - // Registry events are picked up. for backwards compatibility this beahvior is re-implemented - // here. + ponder.on( + namespaceContract(pluginName, "ENSv1RegistryOld:Transfer"), + async ({ context, event }) => { + // NOTE: this logic derived from the subgraph introduces a bug for queries with a blockheight + // below 9380380, when the new Registry was deployed, as it implicitly ignores Transfer events + // of the ROOT_NODE. as a result, the root node's owner is always zeroAddress until the new + // Registry events are picked up. for backwards compatibility this beahvior is re-implemented + // here. - const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, event.args.node); - if (shouldIgnoreEvent) return; + const shouldIgnoreEvent = await shouldIgnoreRegistryOldEvents(context, event.args.node); + if (shouldIgnoreEvent) return; - return handleTransfer({ context, event }); - }); + return handleTransfer({ context, event }); + }, + ); - ponder.on(namespaceContract(pluginName, "Registry:NewOwner"), handleNewOwner(true)); - ponder.on(namespaceContract(pluginName, "Registry:NewResolver"), handleNewResolver); - ponder.on(namespaceContract(pluginName, "Registry:NewTTL"), handleNewTTL); - ponder.on(namespaceContract(pluginName, "Registry:Transfer"), handleTransfer); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewOwner"), handleNewOwner(true)); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewResolver"), handleNewResolver); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:NewTTL"), handleNewTTL); + ponder.on(namespaceContract(pluginName, "ENSv1Registry:Transfer"), handleTransfer); } diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/plugin.ts b/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/plugin.ts index a4e050380..3f576f70d 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/subgraph/plugin.ts @@ -8,34 +8,43 @@ import * as ponder from "ponder"; import { DatasourceNames } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; -import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + chainConfigForContract, + chainsConnectionConfigForDatasources, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; const pluginName = PluginName.Subgraph; +const REQUIRED_DATASOURCE_NAMES = [DatasourceNames.ENSRoot]; + export default createPlugin({ name: pluginName, - requiredDatasourceNames: [DatasourceNames.ENSRoot], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - const { chain, contracts } = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ENSRoot, - ); + const { + ensroot: { chain, contracts }, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); return ponder.createConfig({ - chains: chainsConnectionConfig(config.rpcConfigs, chain.id), + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + REQUIRED_DATASOURCE_NAMES, + ), contracts: { - [namespaceContract(pluginName, "RegistryOld")]: { - chain: chainConfigForContract(config.globalBlockrange, chain.id, contracts.RegistryOld), - abi: contracts.Registry.abi, + [namespaceContract(pluginName, "ENSv1RegistryOld")]: { + chain: chainConfigForContract( + config.globalBlockrange, + chain.id, + contracts.ENSv1RegistryOld, + ), + abi: contracts.ENSv1RegistryOld.abi, }, - [namespaceContract(pluginName, "Registry")]: { - chain: chainConfigForContract(config.globalBlockrange, chain.id, contracts.Registry), - abi: contracts.Registry.abi, + [namespaceContract(pluginName, "ENSv1Registry")]: { + chain: chainConfigForContract(config.globalBlockrange, chain.id, contracts.ENSv1Registry), + abi: contracts.ENSv1Registry.abi, }, [namespaceContract(pluginName, "BaseRegistrar")]: { chain: chainConfigForContract(config.globalBlockrange, chain.id, contracts.BaseRegistrar), diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/threedns/plugin.ts b/apps/ensindexer/src/plugins/subgraph/plugins/threedns/plugin.ts index b16e136ea..c531556af 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/threedns/plugin.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/threedns/plugin.ts @@ -7,50 +7,49 @@ import * as ponder from "ponder"; import { DatasourceNames, ResolverABI } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; -import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + chainConfigForContract, + chainsConnectionConfigForDatasources, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; const pluginName = PluginName.ThreeDNS; +const REQUIRED_DATASOURCE_NAMES = [DatasourceNames.ThreeDNSOptimism, DatasourceNames.ThreeDNSBase]; + export default createPlugin({ name: pluginName, - requiredDatasourceNames: [DatasourceNames.ThreeDNSOptimism, DatasourceNames.ThreeDNSBase], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - const threeDNSOptimism = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ThreeDNSOptimism, - ); - const threeDNSBase = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ThreeDNSBase, - ); + const { + threednsOptimism, // + threednsBase, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); return ponder.createConfig({ - chains: { - ...chainsConnectionConfig(config.rpcConfigs, threeDNSOptimism.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, threeDNSBase.chain.id), - }, + chains: chainsConnectionConfigForDatasources( + config.namespace, + config.rpcConfigs, + REQUIRED_DATASOURCE_NAMES, + ), contracts: { // multi-chain ThreeDNSToken indexing config [namespaceContract(pluginName, "ThreeDNSToken")]: { chain: { ...chainConfigForContract( config.globalBlockrange, - threeDNSOptimism.chain.id, - threeDNSOptimism.contracts.ThreeDNSToken, + threednsOptimism.chain.id, + threednsOptimism.contracts.ThreeDNSToken, ), ...chainConfigForContract( config.globalBlockrange, - threeDNSBase.chain.id, - threeDNSBase.contracts.ThreeDNSToken, + threednsBase.chain.id, + threednsBase.contracts.ThreeDNSToken, ), }, // NOTE: abi is identical in a multi-chain ponder config, just use Optimism's here - abi: threeDNSOptimism.contracts.ThreeDNSToken.abi, + abi: threednsOptimism.contracts.ThreeDNSToken.abi, }, // multi-chain ThreeDNS-specific Resolver indexing config [namespaceContract(pluginName, "Resolver")]: { @@ -58,13 +57,13 @@ export default createPlugin({ chain: { ...chainConfigForContract( config.globalBlockrange, - threeDNSOptimism.chain.id, - threeDNSOptimism.contracts.Resolver, + threednsOptimism.chain.id, + threednsOptimism.contracts.Resolver, ), ...chainConfigForContract( config.globalBlockrange, - threeDNSBase.chain.id, - threeDNSBase.contracts.Resolver, + threednsBase.chain.id, + threednsBase.contracts.Resolver, ), }, }, diff --git a/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts b/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts index 6303d242a..5d1e22caf 100644 --- a/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts +++ b/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts @@ -122,7 +122,7 @@ async function materializeDomainExpiryDate(context: Context, node: Node) { /** * makes a set of shared handlers for the NameWrapper contract * - * @param registrarManagedName the name that the Registrar that NameWrapper interacts with registers subnames of + * @param registrarManagedName the name of the Registrar that NameWrapper interacts with registers subnames of */ export const makeNameWrapperHandlers = ({ registrarManagedName, diff --git a/apps/ensindexer/src/plugins/tokenscope/handlers/BaseRegistrars.ts b/apps/ensindexer/src/plugins/tokenscope/handlers/BaseRegistrars.ts index 8b158750b..f7455539f 100644 --- a/apps/ensindexer/src/plugins/tokenscope/handlers/BaseRegistrars.ts +++ b/apps/ensindexer/src/plugins/tokenscope/handlers/BaseRegistrars.ts @@ -45,7 +45,7 @@ export default function () { config.namespace, DatasourceNames.Basenames, "BaseRegistrar", - event.args.id, + event.args.tokenId, ); const metadata: NFTTransferEventMetadata = { diff --git a/apps/ensindexer/src/plugins/tokenscope/plugin.ts b/apps/ensindexer/src/plugins/tokenscope/plugin.ts index b444de86b..53aed98f6 100644 --- a/apps/ensindexer/src/plugins/tokenscope/plugin.ts +++ b/apps/ensindexer/src/plugins/tokenscope/plugin.ts @@ -9,55 +9,36 @@ import * as ponder from "ponder"; import { DatasourceNames } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import { createPlugin, namespaceContract } from "@/lib/plugin-helpers"; import { - createPlugin, - getDatasourceAsFullyDefinedAtCompileTime, - namespaceContract, -} from "@/lib/plugin-helpers"; -import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + chainConfigForContract, + chainsConnectionConfig, + getRequiredDatasources, +} from "@/lib/ponder-helpers"; const pluginName = PluginName.TokenScope; +const REQUIRED_DATASOURCE_NAMES = [ + DatasourceNames.Seaport, + DatasourceNames.ENSRoot, + DatasourceNames.Basenames, + DatasourceNames.Lineanames, + DatasourceNames.ThreeDNSOptimism, + DatasourceNames.ThreeDNSBase, +]; + export default createPlugin({ name: pluginName, - requiredDatasourceNames: [ - DatasourceNames.Seaport, - DatasourceNames.ENSRoot, - DatasourceNames.Basenames, - DatasourceNames.Lineanames, - DatasourceNames.ThreeDNSOptimism, - DatasourceNames.ThreeDNSBase, - ], + requiredDatasourceNames: REQUIRED_DATASOURCE_NAMES, createPonderConfig(config) { - const seaport = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Seaport, - ); - - const ensroot = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ENSRoot, - ); - - const basenames = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Basenames, - ); - - const lineanames = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.Lineanames, - ); - - const threeDNSOptimism = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ThreeDNSOptimism, - ); - - const threeDNSBase = getDatasourceAsFullyDefinedAtCompileTime( - config.namespace, - DatasourceNames.ThreeDNSBase, - ); + const { + seaport, // + ensroot, + basenames, + lineanames, + threednsOptimism, + threednsBase, + } = getRequiredDatasources(config.namespace, REQUIRED_DATASOURCE_NAMES); // Sanity Check: Seaport and ENSRoot are on the same chain if (seaport.chain.id !== ensroot.chain.id) { @@ -65,7 +46,7 @@ export default createPlugin({ } // Sanity Check: ThreeDNSBase and Basenames are on the same chain - if (threeDNSBase.chain.id !== basenames.chain.id) { + if (threednsBase.chain.id !== basenames.chain.id) { throw new Error( "ThreeDNSBase and Basenames datasources are expected to be on the same chain", ); @@ -77,8 +58,8 @@ export default createPlugin({ ...chainsConnectionConfig(config.rpcConfigs, ensroot.chain.id), ...chainsConnectionConfig(config.rpcConfigs, basenames.chain.id), ...chainsConnectionConfig(config.rpcConfigs, lineanames.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, threeDNSOptimism.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, threeDNSBase.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, threednsOptimism.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, threednsBase.chain.id), }, contracts: { [namespaceContract(pluginName, "Seaport")]: { @@ -148,17 +129,17 @@ export default createPlugin({ chain: { ...chainConfigForContract( config.globalBlockrange, - threeDNSOptimism.chain.id, - threeDNSOptimism.contracts.ThreeDNSToken, + threednsOptimism.chain.id, + threednsOptimism.contracts.ThreeDNSToken, ), ...chainConfigForContract( config.globalBlockrange, - threeDNSBase.chain.id, - threeDNSBase.contracts.ThreeDNSToken, + threednsBase.chain.id, + threednsBase.contracts.ThreeDNSToken, ), }, // NOTE: abi is identical in a multi-chain ponder config, just use Optimism's here - abi: threeDNSOptimism.contracts.ThreeDNSToken.abi, + abi: threednsOptimism.contracts.ThreeDNSToken.abi, }, }, }); diff --git a/biome.jsonc b/biome.jsonc index 32b9d5e8d..2c8e5e688 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -56,7 +56,8 @@ "noSvgWithoutTitle": "off" }, "suspicious": { - "noExplicitAny": "off" + "noExplicitAny": "off", + "useIterableCallbackReturn": "off" } } }, @@ -76,8 +77,7 @@ "includes": ["**/*.test.ts"], "linter": { "rules": { - "style": { "noNonNullAssertion": "off" }, - "suspicious": { "useIterableCallbackReturn": "off" } + "style": { "noNonNullAssertion": "off" } } } } diff --git a/docs/ensnode.io/package.json b/docs/ensnode.io/package.json index 1005cbd0c..f6bf219b9 100644 --- a/docs/ensnode.io/package.json +++ b/docs/ensnode.io/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "version": "1.3.1", - "packageManager": "pnpm@10.20.0", + "packageManager": "pnpm@10.26.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/docs/ensnode.io/src/content/docs/docs/concepts/what-is-ensnode.mdx b/docs/ensnode.io/src/content/docs/docs/concepts/what-is-ensnode.mdx index 1a0b0cfd2..1b7d3512e 100644 --- a/docs/ensnode.io/src/content/docs/docs/concepts/what-is-ensnode.mdx +++ b/docs/ensnode.io/src/content/docs/docs/concepts/what-is-ensnode.mdx @@ -90,7 +90,7 @@ When ENSIndexer is run, the configs for all of the active plugins (those selecte This package provides configurations for each known ENS namespace. An ENS namespace represents a single, unified set of ENS names with a distinct onchain root Registry and the capability to span across multiple chains, subregistries, and offchain resources. -Each namespace is logically independent - for instance, the Sepolia and Holesky testnet namespaces are entirely separate from the canonical mainnet namespace. This package centralizes the contract addresses, start blocks, and other configuration needed to interact with each namespace. +Each namespace is logically independent - for instance, the Sepolia namespace is entirely separate from the canonical mainnet namespace. This package centralizes the contract addresses, start blocks, and other configuration needed to interact with each namespace. ENSIndexer uses `@ensnode/datasources` to configure its plugins and determine which are available for a given target namespace. diff --git a/docs/ensnode.io/src/content/docs/docs/concepts/what-is-the-ens-subgraph.mdx b/docs/ensnode.io/src/content/docs/docs/concepts/what-is-the-ens-subgraph.mdx index 9b293c99f..1f5f90b0d 100644 --- a/docs/ensnode.io/src/content/docs/docs/concepts/what-is-the-ens-subgraph.mdx +++ b/docs/ensnode.io/src/content/docs/docs/concepts/what-is-the-ens-subgraph.mdx @@ -45,7 +45,7 @@ This server is running [Graph Node version 0.39.1](https://github.com/graphproto ## NameHash Labs Hosted ENS Subgraphs -Our Graph Node instance indexes the ENS Subgraph on mainnet, sepolia, and holesky. The live indexing status of each can be monitored with [the indexing status API endpoint](https://graphnode.namehashlabs.org:8030/graphql/playground?query=%7B%0A%20%20indexingStatuses%20%7B%0A%20%20%20%20subgraph%0A%20%20%20%20health%0A%20%20%20%20historyBlocks%0A%20%20%20%20paused%0A%20%20%20%20synced%0A%20%20%20%20chains%20%7B%0A%20%20%20%20%20%20network%0A%20%20%20%20%20%20chainHeadBlock%20%7B%0A%20%20%20%20%20%20%20%20number%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20latestBlock%20%7B%0A%20%20%20%20%20%20%20%20number%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D). +Our Graph Node instance indexes the ENS Subgraph on mainnet and sepolia. The live indexing status of each can be monitored with [the indexing status API endpoint](https://graphnode.namehashlabs.org:8030/graphql/playground?query=%7B%0A%20%20indexingStatuses%20%7B%0A%20%20%20%20subgraph%0A%20%20%20%20health%0A%20%20%20%20historyBlocks%0A%20%20%20%20paused%0A%20%20%20%20synced%0A%20%20%20%20chains%20%7B%0A%20%20%20%20%20%20network%0A%20%20%20%20%20%20chainHeadBlock%20%7B%0A%20%20%20%20%20%20%20%20number%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20latestBlock%20%7B%0A%20%20%20%20%20%20%20%20number%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D). As of the latest update to this page on July 15, 2025: @@ -63,9 +63,3 @@ As of the latest update to this page on July 15, 2025: - Subgraph ID: `QmZkCMqRDzq8tWfJy12KudmRjwzHLSA5F6KnSnHRoC6kQe` - [GraphQL Endpoint](https://graphnode.namehashlabs.org:8000/subgraphs/name/namehash/ens-subgraph-3) - [Interactive GraphiQL Explorer](https://graphnode.namehashlabs.org:8000/subgraphs/name/namehash/ens-subgraph-3/graphql) - -**Holesky** - -- Subgraph ID: `QmQCDNHEuV359KwtyYZBWv4godb8t6kzErcYSFs7YgdqAM` -- [GraphQL Endpoint](https://graphnode.namehashlabs.org:8000/subgraphs/name/namehash/ens-subgraph-holesky) -- [Interactive GraphiQL Explorer](https://graphnode.namehashlabs.org:8000/subgraphs/name/namehash/ens-subgraph-holesky/graphql) diff --git a/docs/ensnode.io/src/content/docs/docs/deploying/terraform.mdx b/docs/ensnode.io/src/content/docs/docs/deploying/terraform.mdx index dff3b9c3c..c2182d596 100644 --- a/docs/ensnode.io/src/content/docs/docs/deploying/terraform.mdx +++ b/docs/ensnode.io/src/content/docs/docs/deploying/terraform.mdx @@ -24,7 +24,7 @@ These Terraform scripts are currently specific to ENSNode instances hosted by Na - [Terraform](https://www.terraform.io/downloads.html) installed - [Render](https://https://render.com/) account - Render API token (generate from https://render.com/docs/api#1-create-an-api-key) -- RPC URLs for the chains you want to support (Mainnet, Sepolia, Holesky, Base, Linea) +- RPC URLs for the chains you want to support (Mainnet, Sepolia, Base, Linea) - AWS account (for DNS management) - AWS S3 bucket defined inside AWS account - `ensnode-terraform` (for Terraform state) @@ -60,9 +60,6 @@ linea_sepolia_rpc_url = "your_linea_sepolia_rpc_url" optimism_sepolia_rpc_url = "your_optimism_sepolia_rpc_url" arbitrum_sepolia_rpc_url = "your_arbitrum_sepolia_rpc_url" scroll_sepolia_rpc_url = "your_scroll_sepolia_rpc_url" - -# Holesky RPC URLs -ethereum_holesky_rpc_url = "your_ethereum_holesky_rpc_url" ``` ## Infrastructure Components diff --git a/docs/ensnode.io/src/content/docs/docs/usage/hosted-ensnode-instances.mdx b/docs/ensnode.io/src/content/docs/docs/usage/hosted-ensnode-instances.mdx index 1c0459161..7f293a256 100644 --- a/docs/ensnode.io/src/content/docs/docs/usage/hosted-ensnode-instances.mdx +++ b/docs/ensnode.io/src/content/docs/docs/usage/hosted-ensnode-instances.mdx @@ -70,16 +70,6 @@ These ENSNode instances focus on maximizing backwards compatibility with the ENS purpose="Demonstration of ENSNode's backwards compatibility with the ENS Subgraph. Provides 1:1 Subgraph compatible data on Sepolia." /> -#### ENSNode 'Holesky' - - - ## Endpoints For more details on how to use these instances, refer to the [ENSNode Quickstart](/docs/). diff --git a/docs/ensrainbow.io/package.json b/docs/ensrainbow.io/package.json index 65bd2adb5..631e51fae 100644 --- a/docs/ensrainbow.io/package.json +++ b/docs/ensrainbow.io/package.json @@ -2,7 +2,7 @@ "name": "@docs/ensrainbow", "type": "module", "version": "1.3.1", - "packageManager": "pnpm@10.20.0", + "packageManager": "pnpm@10.26.0", "private": true, "scripts": { "dev": "astro dev", diff --git a/package.json b/package.json index 1c82ae5e6..3068c9af6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ensnode-monorepo", "version": "0.0.1", "private": true, - "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c", + "packageManager": "pnpm@10.26.0", "scripts": { "lint": "biome check --write .", "lint:ci": "biome ci", diff --git a/packages/datasources/README.md b/packages/datasources/README.md index 6cd02775c..f37567a20 100644 --- a/packages/datasources/README.md +++ b/packages/datasources/README.md @@ -10,7 +10,7 @@ For example, the canonical ENS Namespace on mainnet includes: - The `threedns-optimism` and `threedns-base` Datasources documenting the 3DNS contracts on Optimism and Base, respectively - 🚧 Various offchain Datasources (e.g. `.cb.id`, `.uni.eth`) -Each ENS namespace is logically independent and isolated from the others: for instance, the `sepolia` and `holesky` testnet namespaces manage a set of names that is entirely separate from the canonical `mainnet` namespace, and have distinct `basenames` and `lineanames` **Datasource**s defined. +Each ENS namespace is logically independent and isolated from the others: for instance, the `sepolia` testnet manages a set of names that is entirely separate from the canonical `mainnet` namespace, and have distinct `basenames` and `lineanames` **Datasource**s defined. The `ens-test-env` namespace describes the contracts deployed to an _Anvil_ chain for development and testing with the [ens-test-env](https://github.com/ensdomains/ens-test-env) tool. @@ -48,8 +48,8 @@ import { getDatasource } from '@ensnode/datasources'; // get ensroot datasource relative to mainnet ENS namespace const { chain, contracts } = getDatasource('mainnet', 'ensroot'); -// get ensroot datasource relative to holesky ENS namespace -const { chain, contracts } = getDatasource('holesky', 'ensroot'); +// get ensroot datasource relative to sepolia ENS namespace +const { chain, contracts } = getDatasource('sepolia', 'ensroot'); // get threedns-base datasource relative to mainnet ENS namespace const { chain, contracts } = getDatasource('mainnet', 'threedns-base'); @@ -58,7 +58,6 @@ const { chain, contracts } = getDatasource('mainnet', 'threedns-base'); The available `ENSNamespaceId`s are: - `mainnet` - `sepolia` -- `holesky` - `ens-test-env` — Represents a local testing namespace running on an Anvil chain (chain id 1337) with deterministic configurations that deliberately start at block zero for rapid testing and development. See [ens-test-env](https://github.com/ensdomains/ens-test-env) for additional context. ### DatasourceName diff --git a/packages/datasources/src/abis/basenames/BaseRegistrar.ts b/packages/datasources/src/abis/basenames/BaseRegistrar.ts index 0cf2d6aba..fa18354bb 100644 --- a/packages/datasources/src/abis/basenames/BaseRegistrar.ts +++ b/packages/datasources/src/abis/basenames/BaseRegistrar.ts @@ -254,7 +254,7 @@ export const BaseRegistrar = [ inputs: [ { indexed: true, internalType: "address", name: "from", type: "address" }, { indexed: true, internalType: "address", name: "to", type: "address" }, - { indexed: true, internalType: "uint256", name: "id", type: "uint256" }, + { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, ], name: "Transfer", type: "event", diff --git a/packages/datasources/src/abis/ensv2/ETHRegistrar.ts b/packages/datasources/src/abis/ensv2/ETHRegistrar.ts new file mode 100644 index 000000000..d621bfeb1 --- /dev/null +++ b/packages/datasources/src/abis/ensv2/ETHRegistrar.ts @@ -0,0 +1,547 @@ +export const ETHRegistrar = [ + { + inputs: [], + name: "REGISTRY", + outputs: [ + { + internalType: "contract IPermissionedRegistry", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + { + internalType: "uint64", + name: "validFrom", + type: "uint64", + }, + { + internalType: "uint64", + name: "blockTimestamp", + type: "uint64", + }, + ], + name: "CommitmentTooNew", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + { + internalType: "uint64", + name: "validTo", + type: "uint64", + }, + { + internalType: "uint64", + name: "blockTimestamp", + type: "uint64", + }, + ], + name: "CommitmentTooOld", + type: "error", + }, + { + inputs: [ + { + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + internalType: "uint64", + name: "minDuration", + type: "uint64", + }, + ], + name: "DurationTooShort", + type: "error", + }, + { + inputs: [], + name: "MaxCommitmentAgeTooLow", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "NameAlreadyRegistered", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "NameNotRegistered", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "NotValid", + type: "error", + }, + { + inputs: [ + { + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + ], + name: "PaymentTokenNotSupported", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + ], + name: "UnexpiredCommitmentExists", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + ], + name: "CommitmentMade", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "string", + name: "label", + type: "string", + }, + { + indexed: false, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: false, + internalType: "contract IRegistry", + name: "subregistry", + type: "address", + }, + { + indexed: false, + internalType: "address", + name: "resolver", + type: "address", + }, + { + indexed: false, + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + indexed: false, + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + { + indexed: false, + internalType: "bytes32", + name: "referrer", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint256", + name: "base", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "premium", + type: "uint256", + }, + ], + name: "NameRegistered", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "string", + name: "label", + type: "string", + }, + { + indexed: false, + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + indexed: false, + internalType: "uint64", + name: "newExpiry", + type: "uint64", + }, + { + indexed: false, + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + { + indexed: false, + internalType: "bytes32", + name: "referrer", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint256", + name: "base", + type: "uint256", + }, + ], + name: "NameRenewed", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + ], + name: "PaymentTokenAdded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + ], + name: "PaymentTokenRemoved", + type: "event", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + ], + name: "commit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "commitment", + type: "bytes32", + }, + ], + name: "commitmentAt", + outputs: [ + { + internalType: "uint64", + name: "", + type: "uint64", + }, + ], + 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", + }, + { + internalType: "contract IRegistry", + name: "subregistry", + type: "address", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + internalType: "bytes32", + name: "referrer", + type: "bytes32", + }, + ], + name: "makeCommitment", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "bytes32", + name: "secret", + type: "bytes32", + }, + { + internalType: "contract IRegistry", + name: "subregistry", + type: "address", + }, + { + internalType: "address", + name: "resolver", + type: "address", + }, + { + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + { + internalType: "bytes32", + name: "referrer", + type: "bytes32", + }, + ], + name: "register", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + { + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + { + internalType: "bytes32", + name: "referrer", + type: "bytes32", + }, + ], + name: "renew", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "uint64", + name: "duration", + type: "uint64", + }, + { + internalType: "contract IERC20", + name: "paymentToken", + type: "address", + }, + ], + name: "rentPrice", + outputs: [ + { + internalType: "uint256", + name: "base", + type: "uint256", + }, + { + internalType: "uint256", + name: "premium", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts b/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts new file mode 100644 index 000000000..e4753b00c --- /dev/null +++ b/packages/datasources/src/abis/ensv2/EnhancedAccessControl.ts @@ -0,0 +1,436 @@ +export const EnhancedAccessControl = [ + { + 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", + }, + { + 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", + }, + { + inputs: [], + name: "ROOT_RESOURCE", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + 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: "rolesBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "hasRoles", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "rolesBitmap", + type: "uint256", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "hasRootRoles", + 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: "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: "bytes4", + name: "interfaceId", + type: "bytes4", + }, + ], + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/packages/datasources/src/abis/ensv2/Registry.ts b/packages/datasources/src/abis/ensv2/Registry.ts new file mode 100644 index 000000000..517e43e15 --- /dev/null +++ b/packages/datasources/src/abis/ensv2/Registry.ts @@ -0,0 +1,471 @@ +export const Registry = [ + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "operator", + type: "address", + }, + { + indexed: false, + internalType: "bool", + name: "approved", + type: "bool", + }, + ], + name: "ApprovalForAll", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "uint64", + name: "newExpiry", + type: "uint64", + }, + { + indexed: false, + internalType: "address", + name: "changedBy", + type: "address", + }, + ], + name: "ExpiryUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "string", + name: "label", + type: "string", + }, + { + indexed: false, + internalType: "uint64", + name: "expiry", + type: "uint64", + }, + { + indexed: false, + internalType: "address", + name: "registeredBy", + type: "address", + }, + ], + name: "NameRegistered", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "address", + name: "resolver", + type: "address", + }, + ], + name: "ResolverUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "contract IRegistry", + name: "subregistry", + type: "address", + }, + ], + name: "SubregistryUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "oldTokenId", + type: "uint256", + }, + { + indexed: true, + internalType: "uint256", + name: "newTokenId", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "resource", + type: "uint256", + }, + ], + name: "TokenRegenerated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "operator", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "from", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "to", + type: "address", + }, + { + indexed: false, + internalType: "uint256[]", + name: "ids", + type: "uint256[]", + }, + { + indexed: false, + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + ], + name: "TransferBatch", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "operator", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "from", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "to", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "TransferSingle", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "string", + name: "value", + type: "string", + }, + { + indexed: true, + internalType: "uint256", + name: "id", + type: "uint256", + }, + ], + name: "URI", + type: "event", + }, + { + inputs: [ + { + internalType: "address", + name: "account", + type: "address", + }, + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + ], + name: "balanceOf", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address[]", + name: "accounts", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "ids", + type: "uint256[]", + }, + ], + name: "balanceOfBatch", + outputs: [ + { + internalType: "uint256[]", + name: "", + type: "uint256[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "getResolver", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "label", + type: "string", + }, + ], + name: "getSubregistry", + outputs: [ + { + internalType: "contract IRegistry", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "account", + type: "address", + }, + { + internalType: "address", + name: "operator", + type: "address", + }, + ], + name: "isApprovedForAll", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + ], + name: "ownerOf", + outputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256[]", + name: "ids", + type: "uint256[]", + }, + { + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "safeBatchTransferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "operator", + type: "address", + }, + { + internalType: "bool", + name: "approved", + type: "bool", + }, + ], + name: "setApprovalForAll", + 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; diff --git a/packages/datasources/src/abis/root/UniversalResolver.ts b/packages/datasources/src/abis/root/UniversalResolver.ts index 42cd9e3c8..c89ecac2e 100644 --- a/packages/datasources/src/abis/root/UniversalResolver.ts +++ b/packages/datasources/src/abis/root/UniversalResolver.ts @@ -2,14 +2,14 @@ export const UniversalResolver = [ { inputs: [ { - internalType: "contract ENS", - name: "ens", + internalType: "contract IRegistry", + name: "root", type: "address", }, { - internalType: "string[]", - name: "gateways", - type: "string[]", + internalType: "contract IGatewayProvider", + name: "batchGatewayProvider", + type: "address", }, ], stateMutability: "nonpayable", @@ -94,6 +94,22 @@ export const UniversalResolver = [ name: "OffchainLookup", type: "error", }, + { + inputs: [ + { + internalType: "uint256", + name: "offset", + type: "uint256", + }, + { + internalType: "uint256", + name: "length", + type: "uint256", + }, + ], + name: "OffsetOutOfBoundsError", + type: "error", + }, { inputs: [ { @@ -160,38 +176,26 @@ export const UniversalResolver = [ type: "error", }, { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: "address", - name: "previousOwner", - type: "address", - }, + inputs: [], + name: "ROOT_REGISTRY", + outputs: [ { - indexed: true, - internalType: "address", - name: "newOwner", + internalType: "contract IRegistry", + name: "", type: "address", }, ], - name: "OwnershipTransferred", - type: "event", + stateMutability: "view", + type: "function", }, { - inputs: [ - { - internalType: "uint256", - name: "", - type: "uint256", - }, - ], - name: "batchGateways", + inputs: [], + name: "batchGatewayProvider", outputs: [ { - internalType: "string", + internalType: "contract IGatewayProvider", name: "", - type: "string", + type: "address", }, ], stateMutability: "view", @@ -368,60 +372,46 @@ export const UniversalResolver = [ type: "bytes", }, ], - name: "findResolver", + name: "findRegistries", outputs: [ { - internalType: "address", + internalType: "contract IRegistry[]", name: "", - type: "address", - }, - { - internalType: "bytes32", - name: "", - type: "bytes32", - }, - { - internalType: "uint256", - name: "", - type: "uint256", + type: "address[]", }, ], stateMutability: "view", type: "function", }, { - inputs: [], - name: "owner", - outputs: [ + inputs: [ { - internalType: "address", - name: "", - type: "address", + internalType: "bytes", + name: "name", + type: "bytes", }, ], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "registry", + name: "findResolver", outputs: [ { - internalType: "contract ENS", - name: "", + internalType: "address", + name: "resolver", type: "address", }, + { + internalType: "bytes32", + name: "node", + type: "bytes32", + }, + { + internalType: "uint256", + name: "offset", + type: "uint256", + }, ], stateMutability: "view", type: "function", }, - { - inputs: [], - name: "renounceOwnership", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [ { @@ -518,63 +508,9 @@ export const UniversalResolver = [ { inputs: [ { - 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", - }, - { - 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: "bytes", + name: "response", + type: "bytes", }, { internalType: "bytes", @@ -586,18 +522,54 @@ export const UniversalResolver = [ outputs: [ { internalType: "bytes", - name: "result", + name: "", type: "bytes", }, { internalType: "address", - name: "resolver", + 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: [ { @@ -620,14 +592,48 @@ export const UniversalResolver = [ outputs: [ { internalType: "bytes", - name: "", + name: "result", type: "bytes", }, { internalType: "address", - name: "", + 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", @@ -669,63 +675,9 @@ export const UniversalResolver = [ { inputs: [ { - 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", - }, - { - 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: "bytes", + name: "response", + type: "bytes", }, { internalType: "bytes", @@ -757,63 +709,9 @@ export const UniversalResolver = [ { inputs: [ { - 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: "infoRev", - type: "tuple", - }, - { - 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: "bytes", + name: "response", + type: "bytes", }, { internalType: "bytes", @@ -864,36 +762,23 @@ export const UniversalResolver = [ outputs: [ { internalType: "string", - name: "", + name: "primary", type: "string", }, { internalType: "address", - name: "", + name: "resolver", type: "address", }, { internalType: "address", - name: "", + name: "reverseResolver", type: "address", }, ], stateMutability: "view", type: "function", }, - { - inputs: [ - { - internalType: "string[]", - name: "gateways", - type: "string[]", - }, - ], - name: "setBatchGateways", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [ { @@ -913,17 +798,4 @@ export const UniversalResolver = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { - internalType: "address", - name: "newOwner", - type: "address", - }, - ], - name: "transferOwnership", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, ] as const; diff --git a/packages/datasources/src/abis/shared/AbstractReverseResolver.ts b/packages/datasources/src/abis/shared/AbstractReverseResolver.ts new file mode 100644 index 000000000..d06955fd1 --- /dev/null +++ b/packages/datasources/src/abis/shared/AbstractReverseResolver.ts @@ -0,0 +1,123 @@ +export const AbstractReverseResolver = [ + { + inputs: [ + { + internalType: "bytes", + name: "dns", + type: "bytes", + }, + ], + name: "DNSDecodingFailed", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + ], + name: "UnreachableName", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "selector", + type: "bytes4", + }, + ], + name: "UnsupportedResolverProfile", + type: "error", + }, + { + inputs: [], + name: "chainId", + outputs: [ + { + internalType: "uint32", + name: "", + type: "uint32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "coinType", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "name", + type: "bytes", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "resolve", + outputs: [ + { + internalType: "bytes", + name: "result", + type: "bytes", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address[]", + name: "addrs", + type: "address[]", + }, + ], + name: "resolveNames", + outputs: [ + { + internalType: "string[]", + name: "names", + 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; diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/ens-test-env.ts index f90a0fd41..b020b2e85 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -1,14 +1,21 @@ +import { zeroAddress } from "viem"; + +import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; +import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; +import { Registry } from "./abis/ensv2/Registry"; // 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 { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; -import { ensTestEnvL1Chain } from "./lib/chains"; +import { StandaloneReverseRegistrar } from "./abis/shared/StandaloneReverseRegistrar"; +import { ensTestEnvL1Chain, ensTestEnvL2Chain } from "./lib/chains"; // Shared ABIs -import { ResolverABI, ResolverFilter } from "./lib/resolver"; +import { ResolverABI } from "./lib/ResolverABI"; // Types import { DatasourceNames, type ENSNamespace } from "./lib/types"; @@ -34,29 +41,28 @@ export default { [DatasourceNames.ENSRoot]: { chain: ensTestEnvL1Chain, contracts: { - RegistryOld: { + ENSv1RegistryOld: { abi: root_Registry, // Registry was redeployed, same abi - address: "0x610178da211fef7d417bc0e6fed39f05609ad788", + address: "0x0dcd1bf9a1b36ce34237eeafef220932846bcd82", startBlock: 0, }, - Registry: { + ENSv1Registry: { abi: root_Registry, // Registry was redeployed, same abi - address: "0xb7f8bc63bbcad18155201308c8f3540b07f84f5e", + address: "0x9a676e781a523b5d0c0e43731313a708cb607508", startBlock: 0, }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, startBlock: 0, }, BaseRegistrar: { abi: root_BaseRegistrar, - address: "0xa82ff9afd8f496c3d6ac40e2a0f282e47488cfc9", + address: "0xb7278a61aa25c888815afc32ad3cc52ff24fe575", startBlock: 0, }, LegacyEthRegistrarController: { abi: root_LegacyEthRegistrarController, - address: "0x5081a39b8a5f0e35a8d959395a630b68b74dd30f", + address: "0xbec49fa140acaa83533fb00a2bb19bddd0290f25", startBlock: 0, }, WrappedEthRegistrarController: { @@ -66,17 +72,100 @@ export default { }, UnwrappedEthRegistrarController: { abi: root_UnwrappedEthRegistrarController, - address: "0x36b58f5c1969b7b6591d752ea6f5486d069010ab", + address: "0xfbc22278a96299d91d41c453234d97b4f5eb9b2d", + startBlock: 0, + }, + UniversalRegistrarRenewalWithReferrer: { + abi: root_UniversalRegistrarRenewalWithReferrer, + address: zeroAddress, startBlock: 0, }, NameWrapper: { abi: root_NameWrapper, - address: "0x2e2ed0cfd3ad2f1d34481277b3204d807ca2f8c2", + address: "0xfd471836031dc5108809d173a067e8486b9047a3", startBlock: 0, }, UniversalResolver: { abi: root_UniversalResolver, - address: "0xd84379ceae14aa33c123af12424a37803f885889", + address: "0xdc11f7e700a4c898ae5caddb1082cffa76512add", + startBlock: 0, + }, + + // + + ETHRegistry: { + abi: Registry, + address: "0x0b306bf915c4d645ff596e518faf3f9669b97016", + startBlock: 0, + }, + RootRegistry: { + abi: Registry, + address: "0x9a676e781a523b5d0c0e43731313a708cb607508", + startBlock: 0, + }, + Registry: { + abi: Registry, + startBlock: 0, + }, + EnhancedAccessControl: { + abi: EnhancedAccessControl, + startBlock: 0, + }, + }, + }, + + [DatasourceNames.Namechain]: { + chain: ensTestEnvL2Chain, + contracts: { + Resolver: { + abi: ResolverABI, + startBlock: 0, + }, + Registry: { + abi: Registry, + startBlock: 0, + }, + EnhancedAccessControl: { + abi: EnhancedAccessControl, + startBlock: 0, + }, + ETHRegistry: { + abi: Registry, + address: "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9", + startBlock: 0, + }, + ETHRegistrar: { + abi: ETHRegistrar, + address: "0xa51c1fc2f0d1a1b8494ed1fe312d7c3a78ed91c0", + startBlock: 0, + }, + }, + }, + + [DatasourceNames.ReverseResolverRoot]: { + chain: ensTestEnvL1Chain, + contracts: { + DefaultReverseRegistrar: { + abi: StandaloneReverseRegistrar, + address: "0x8f86403a4de0bb5791fa46b8e795c547942fe4cf", + startBlock: 0, + }, + + DefaultReverseResolver3: { + abi: ResolverABI, + address: "0x5eb3bc0a489c5a8288765d2336659ebca68fcd00", + startBlock: 0, + }, + + DefaultPublicResolver2: { + abi: ResolverABI, + address: "0x367761085bf3c12e5da2df99ac6e1a824612b8fb", + startBlock: 0, + }, + + DefaultPublicResolver3: { + abi: ResolverABI, + address: "0x4c2f7092c2ae51d986befee378e50bd4db99c901", startBlock: 0, }, }, diff --git a/packages/datasources/src/holesky.ts b/packages/datasources/src/holesky.ts deleted file mode 100644 index c121fae8e..000000000 --- a/packages/datasources/src/holesky.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { holesky } from "viem/chains"; - -// 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 { UniversalResolver as root_UniversalResolver } from "./abis/root/UniversalResolver"; -import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; -import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; -// Shared ABIs -import { ResolverABI, ResolverFilter } from "./lib/resolver"; -// Types -import { DatasourceNames, type ENSNamespace } from "./lib/types"; - -/** - * The Holesky ENSNamespace - * - * NOTE: The Holesky ENS namespace has no known Datasource for Basenames, Lineanames, or 3DNS. - * NOTE: The Holesky ENS namespace does not support ENSIP-19. - */ -export default { - /** - * ENSRoot Datasource - * - * Addresses and Start Blocks from ENS Holesky Subgraph Manifest - * https://ipfs.io/ipfs/Qmd94vseLpkUrSFvJ3GuPubJSyHz8ornhNrwEAt6pjcbex - */ - [DatasourceNames.ENSRoot]: { - chain: holesky, - contracts: { - RegistryOld: { - abi: root_Registry, // Registry was redeployed, same abi - address: "0x94f523b8261b815b87effcf4d18e6abef18d6e4b", - startBlock: 801536, - }, - Registry: { - abi: root_Registry, // Registry was redeployed, same abi - address: "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e", - startBlock: 801613, - }, - Resolver: { - abi: ResolverABI, - filter: ResolverFilter, - startBlock: 801536, // ignores any Resolver events prior to `startBlock` of RegistryOld on Holeksy - }, - BaseRegistrar: { - abi: root_BaseRegistrar, - address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85", - startBlock: 801686, - }, - LegacyEthRegistrarController: { - abi: root_LegacyEthRegistrarController, - address: "0xf13fc748601fdc5afa255e9d9166eb43f603a903", - startBlock: 815355, - }, - WrappedEthRegistrarController: { - abi: root_WrappedEthRegistrarController, - address: "0x179be112b24ad4cfc392ef8924dfa08c20ad8583", - startBlock: 815359, - }, - UnwrappedEthRegistrarController: { - abi: root_UnwrappedEthRegistrarController, - address: "0xfce6ce4373cb6e7e470eaa55329638acd9dbd202", - startBlock: 4027261, - }, - NameWrapper: { - abi: root_NameWrapper, - address: "0xab50971078225d365994dc1edcb9b7fd72bb4862", - startBlock: 815127, - }, - UniversalResolver: { - abi: root_UniversalResolver, - address: "0xe3f3174fc2f2b17644cd2dbac3e47bc82ae0cf81", - startBlock: 8515717, - }, - }, - }, -} satisfies ENSNamespace; diff --git a/packages/datasources/src/index.ts b/packages/datasources/src/index.ts index ef0ad331f..9faeee5f6 100644 --- a/packages/datasources/src/index.ts +++ b/packages/datasources/src/index.ts @@ -1,7 +1,10 @@ +export { EnhancedAccessControl as EnhancedAccessControlABI } from "./abis/ensv2/EnhancedAccessControl"; +export { Registry as RegistryABI } from "./abis/ensv2/Registry"; export { StandaloneReverseRegistrar as StandaloneReverseRegistrarABI } from "./abis/shared/StandaloneReverseRegistrar"; export { ThreeDNSToken as ThreeDNSTokenABI } from "./abis/threedns/ThreeDNSToken"; +export { AnyRegistrarABI } from "./lib/AnyRegistrarABI"; +export { AnyRegistrarControllerABI } from "./lib/AnyRegistrarControllerABI"; export * from "./lib/chains"; -// export shared ABIs for consumer convenience -export { ResolverABI } from "./lib/resolver"; +export { ResolverABI } from "./lib/ResolverABI"; export * from "./lib/types"; export * from "./namespaces"; diff --git a/packages/datasources/src/lib/AnyRegistrarABI.ts b/packages/datasources/src/lib/AnyRegistrarABI.ts new file mode 100644 index 000000000..642ed3410 --- /dev/null +++ b/packages/datasources/src/lib/AnyRegistrarABI.ts @@ -0,0 +1,11 @@ +import { mergeAbis } from "@ponder/utils"; + +import { BaseRegistrar as basenames_BaseRegistrar } from "../abis/basenames/BaseRegistrar"; +import { BaseRegistrar as lineanames_BaseRegistrar } from "../abis/lineanames/BaseRegistrar"; +import { BaseRegistrar as ethnames_BaseRegistrar } from "../abis/root/BaseRegistrar"; + +export const AnyRegistrarABI = mergeAbis([ + ethnames_BaseRegistrar, + basenames_BaseRegistrar, + lineanames_BaseRegistrar, +]); diff --git a/packages/datasources/src/lib/AnyRegistrarControllerABI.ts b/packages/datasources/src/lib/AnyRegistrarControllerABI.ts new file mode 100644 index 000000000..3978508e0 --- /dev/null +++ b/packages/datasources/src/lib/AnyRegistrarControllerABI.ts @@ -0,0 +1,22 @@ +import { mergeAbis } from "@ponder/utils"; + +import { EarlyAccessRegistrarController } from "../abis/basenames/EARegistrarController"; +import { RegistrarController } from "../abis/basenames/RegistrarController"; +import { UpgradeableRegistrarController } from "../abis/basenames/UpgradeableRegistrarController"; +import { EthRegistrarController } from "../abis/lineanames/EthRegistrarController"; +import { LegacyEthRegistrarController } from "../abis/root/LegacyEthRegistrarController"; +import { UnwrappedEthRegistrarController } from "../abis/root/UnwrappedEthRegistrarController"; +import { WrappedEthRegistrarController } from "../abis/root/WrappedEthRegistrarController"; + +export const AnyRegistrarControllerABI = mergeAbis([ + // ethnames + LegacyEthRegistrarController, + WrappedEthRegistrarController, + UnwrappedEthRegistrarController, + // basenames + EarlyAccessRegistrarController, + RegistrarController, + UpgradeableRegistrarController, + // lineanames + EthRegistrarController, +]); diff --git a/packages/datasources/src/lib/ResolverABI.ts b/packages/datasources/src/lib/ResolverABI.ts new file mode 100644 index 000000000..0edf07096 --- /dev/null +++ b/packages/datasources/src/lib/ResolverABI.ts @@ -0,0 +1,19 @@ +import { mergeAbis } from "@ponder/utils"; + +import { AbstractReverseResolver } from "../abis/shared/AbstractReverseResolver"; +import { LegacyPublicResolver } from "../abis/shared/LegacyPublicResolver"; +import { Resolver } from "../abis/shared/Resolver"; + +/** + * This Resolver ABI represents the set of all well-known Resolver events/methods, including: + * - LegacyPublicResolver + * - TextChanged event without value + * - IResolver + * - modern Resolver ABI, TextChanged with value + * - ReverseResolvers + * - AbstractReverseResolver + * + * A Resolver contract is a contract that emits _any_ (not _all_) of the events specified here and + * may or may not support any number of the methods available in this ABI. + */ +export const ResolverABI = mergeAbis([LegacyPublicResolver, Resolver, AbstractReverseResolver]); diff --git a/packages/datasources/src/lib/chains.ts b/packages/datasources/src/lib/chains.ts index 4f2e36763..6199a59d0 100644 --- a/packages/datasources/src/lib/chains.ts +++ b/packages/datasources/src/lib/chains.ts @@ -16,10 +16,12 @@ export const ensTestEnvL1Chain = { ...localhost, id: l1ChainId, name: "ens-test-env L1", -} satisfies Chain; + rpcUrls: { default: { http: ["http://localhost:8545"] } }, +} as const satisfies Chain; export const ensTestEnvL2Chain = { ...localhost, id: l2ChainId, name: "ens-test-env L2", -} satisfies Chain; + rpcUrls: { default: { http: ["http://localhost:8546"] } }, +} as const satisfies Chain; diff --git a/packages/datasources/src/lib/resolver.ts b/packages/datasources/src/lib/resolver.ts deleted file mode 100644 index 394566ea0..000000000 --- a/packages/datasources/src/lib/resolver.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mergeAbis } from "@ponder/utils"; - -import { LegacyPublicResolver } from "../abis/shared/LegacyPublicResolver"; -import { Resolver } from "../abis/shared/Resolver"; -import type { ContractConfig } from "./types"; - -/** - * This Resolver ABI represents the set of all well-known Resolver events/methods, including the - * the LegacyPublicResolver's TextChanged event. A Resolver contract is a contract that emits - * _any_ (not _all_) of the events specified here and may or may not support any number of the - * methods available in this ABI. - */ -export const ResolverABI = mergeAbis([LegacyPublicResolver, Resolver]); - -/** - * This is the ContractConfig['filter'] describing the set of events that Resolver contracts emit. - * It is not technically necessary for Ponder to function, but we explicitly document it here. - */ -export const ResolverFilter = [ - { event: "AddrChanged", args: {} }, - { event: "AddressChanged", args: {} }, - { event: "NameChanged", args: {} }, - { event: "ABIChanged", args: {} }, - { event: "PubkeyChanged", args: {} }, - { - event: "TextChanged(bytes32 indexed node, string indexed indexedKey, string key)", - args: {}, - }, - { - event: "TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value)", - args: {}, - }, - { event: "ContenthashChanged", args: {} }, - { event: "InterfaceChanged", args: {} }, - { event: "AuthorisationChanged", args: {} }, - { event: "VersionChanged", args: {} }, - { event: "DNSRecordChanged", args: {} }, - { event: "DNSRecordDeleted", args: {} }, - { event: "DNSZonehashChanged", args: {} }, -] as const satisfies ContractConfig["filter"]; diff --git a/packages/datasources/src/lib/types.ts b/packages/datasources/src/lib/types.ts index a18382782..b87a5f59a 100644 --- a/packages/datasources/src/lib/types.ts +++ b/packages/datasources/src/lib/types.ts @@ -17,8 +17,8 @@ import type { Abi, Address, Chain } from "viem"; * - Etc.. * * Each ENS namespace is logically independent of & isolated from the others, and not exclusively - * correlated with a specific L1 chain. For example, the Sepolia and Holesky testnet ENS namepaces - * are independent of the canonical ENS namespace on mainnet, and there could be an additional + * correlated with a specific L1 chain. For example, the Sepolia testnet ENS namepace + * is independent of the canonical ENS namespace on mainnet, and there could be an additional * deployment of the ENS protocol to mainnet, configured with different Datasources, resulting in a * logically isolated set of ENS names. * @@ -29,7 +29,6 @@ import type { Abi, Address, Chain } from "viem"; export const ENSNamespaceIds = { Mainnet: "mainnet", Sepolia: "sepolia", - Holesky: "holesky", EnsTestEnv: "ens-test-env", } as const; @@ -56,14 +55,15 @@ export const DatasourceNames = { Basenames: "basenames", Lineanames: "lineanames", Seaport: "seaport", - ThreeDNSOptimism: "threedns-optimism", - ThreeDNSBase: "threedns-base", - ReverseResolverRoot: "reverse-resolver-root", - ReverseResolverBase: "reverse-resolver-base", - ReverseResolverLinea: "reverse-resolver-linea", - ReverseResolverOptimism: "reverse-resolver-optimism", - ReverseResolverArbitrum: "reverse-resolver-arbitrum", - ReverseResolverScroll: "reverse-resolver-scroll", + ThreeDNSOptimism: "threednsOptimism", + ThreeDNSBase: "threednsBase", + ReverseResolverRoot: "rrRoot", + ReverseResolverBase: "rrBase", + ReverseResolverLinea: "rrLinea", + ReverseResolverOptimism: "rrOptimism", + ReverseResolverArbitrum: "rrArbitrum", + ReverseResolverScroll: "rrScroll", + Namechain: "namechain", } as const; export type DatasourceName = (typeof DatasourceNames)[keyof typeof DatasourceNames]; @@ -78,42 +78,25 @@ export interface EventFilter { } /** - * Defines the abi, address, filter, and startBlock of a contract relevant to a Datasource. + * Defines the abi, address, and startBlock of a contract relevant to a Datasource. * * A contract is located onchain either by * 1. a single Address in `address`, * 2. a set of Address[] in `address`, - * 3. or a set of event signatures in `filter`. + * 3. or any contract that emits events as defined in `abi`. * * This type is intentionally a subset of Ponder's ContractConfig. * * @param abi - the ABI of the contract * @param address - (optional) Address of the contract or Address[] of each contract to be indexed - * @param filter - (optional) array of event signatures to filter the log by * @param startBlock - block number the contract was deployed in */ -export type ContractConfig = - | { - readonly abi: Abi; - readonly address: Address; - readonly filter?: never; - readonly startBlock: number; - readonly endBlock?: number; - } - | { - readonly abi: Abi; - readonly address: Address[]; - readonly filter?: never; - readonly startBlock: number; - readonly endBlock?: number; - } - | { - readonly abi: Abi; - readonly address?: never; - readonly filter: EventFilter[]; - readonly startBlock: number; - readonly endBlock?: number; - }; +export type ContractConfig = { + readonly abi: Abi; + readonly address?: Address | Address[]; + readonly startBlock: number; + readonly endBlock?: number; +}; /** * ENSNamespace encodes a set of known Datasources associated with the same ENS namespace. @@ -124,3 +107,31 @@ export type ContractConfig = export type ENSNamespace = { [DatasourceNames.ENSRoot]: Datasource; } & Partial, Datasource>>; + +/** + * Helper type to merge multiple types into one. + */ +type MergedTypes = (T extends any ? (x: T) => void : never) extends (x: infer R) => void + ? R + : never; + +/** + * Preserves the chain union while merging contracts from multiple objects + */ +export type MergeNamespaces = T extends ENSNamespace + ? { + chain: T extends { chain: infer C } ? C : never; + contracts: T extends { [DatasourceNames.ENSRoot]: { contracts: infer C } } + ? MergedTypes + : never; + } + : never; + +/** + * Helper type to extract the datasource type for a specific datasource name across all namespaces. + * Returns the union of all possible datasource types for that datasource name, or never if not found. + */ +export type ExtractDatasourceType< + Namespaces extends ENSNamespace, + D extends DatasourceName, +> = Namespaces extends any ? (D extends keyof Namespaces ? Namespaces[D] : never) : never; diff --git a/packages/datasources/src/mainnet.ts b/packages/datasources/src/mainnet.ts index b11be2642..b7ee197eb 100644 --- a/packages/datasources/src/mainnet.ts +++ b/packages/datasources/src/mainnet.ts @@ -6,6 +6,10 @@ import { EarlyAccessRegistrarController as base_EARegistrarController } from "./ import { RegistrarController as base_RegistrarController } from "./abis/basenames/RegistrarController"; import { Registry as base_Registry } from "./abis/basenames/Registry"; import { UpgradeableRegistrarController as base_UpgradeableRegistrarController } from "./abis/basenames/UpgradeableRegistrarController"; +// ABIs for Namechain +import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; +import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; +import { Registry } from "./abis/ensv2/Registry"; // ABIs for Lineanames Datasource import { BaseRegistrar as linea_BaseRegistrar } from "./abis/lineanames/BaseRegistrar"; import { EthRegistrarController as linea_EthRegistrarController } from "./abis/lineanames/EthRegistrarController"; @@ -24,7 +28,7 @@ import { Seaport as Seaport1_5 } from "./abis/seaport/Seaport1.5"; // Shared ABIs import { StandaloneReverseRegistrar } from "./abis/shared/StandaloneReverseRegistrar"; import { ThreeDNSToken } from "./abis/threedns/ThreeDNSToken"; -import { ResolverABI, ResolverFilter } from "./lib/resolver"; +import { ResolverABI } from "./lib/ResolverABI"; // Types import { DatasourceNames, type ENSNamespace } from "./lib/types"; @@ -41,20 +45,19 @@ export default { [DatasourceNames.ENSRoot]: { chain: mainnet, contracts: { - RegistryOld: { + ENSv1RegistryOld: { abi: root_Registry, // Registry was redeployed, same abi address: "0x314159265dd8dbb310642f98f50c066173c1259b", startBlock: 3327417, }, - Registry: { + ENSv1Registry: { abi: root_Registry, // Registry was redeployed, same abi address: "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e", startBlock: 9380380, }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, - startBlock: 3327417, // ignores any Resolver events prior to `startBlock` of RegistryOld on Mainnet + startBlock: 3327417, // ignores any Resolver events prior to `startBlock` of ENSv1RegistryOld on Mainnet }, BaseRegistrar: { abi: root_BaseRegistrar, @@ -101,6 +104,55 @@ export default { address: "0xde16ee87b0c019499cebdde29c9f7686560f679a", startBlock: 20410692, }, + + // + + ETHRegistry: { + abi: Registry, + address: "0x1291be112d480055dafd8a610b7d1e203891c274", + startBlock: 23794084, + }, + RootRegistry: { + abi: Registry, + address: "0x8a791620dd6260079bf849dc5567adc3f2fdc318", + startBlock: 23794084, + }, + Registry: { + abi: Registry, + startBlock: 23794084, + }, + EnhancedAccessControl: { + abi: EnhancedAccessControl, + startBlock: 23794084, + }, + }, + }, + + [DatasourceNames.Namechain]: { + chain: mainnet, + contracts: { + Resolver: { + abi: ResolverABI, + startBlock: 23794084, + }, + Registry: { + abi: Registry, + startBlock: 23794084, + }, + EnhancedAccessControl: { + abi: EnhancedAccessControl, + startBlock: 23794084, + }, + ETHRegistry: { + abi: Registry, + address: "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707", + startBlock: 23794084, + }, + ETHRegistrar: { + abi: ETHRegistrar, + address: "0xa513e6e4b8f2a923d98304ec87f64353c4d5c853", + startBlock: 23794084, + }, }, }, @@ -135,7 +187,6 @@ export default { }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, startBlock: 17571480, // based on startBlock of Registry on Base }, BaseRegistrar: { @@ -215,7 +266,6 @@ export default { }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, startBlock: 6682888, // based on startBlock of Registry on Linea }, BaseRegistrar: { diff --git a/packages/datasources/src/namespaces.ts b/packages/datasources/src/namespaces.ts index 636daf7d4..19a999ba7 100644 --- a/packages/datasources/src/namespaces.ts +++ b/packages/datasources/src/namespaces.ts @@ -1,11 +1,9 @@ import ensTestEnv from "./ens-test-env"; -import holesky from "./holesky"; import { - type Datasource, type DatasourceName, DatasourceNames, - type ENSNamespace, type ENSNamespaceId, + type ExtractDatasourceType, } from "./lib/types"; import mainnet from "./mainnet"; import sepolia from "./sepolia"; @@ -14,19 +12,17 @@ import sepolia from "./sepolia"; const ENSNamespacesById: { readonly mainnet: typeof mainnet; readonly sepolia: typeof sepolia; - readonly holesky: typeof holesky; readonly "ens-test-env": typeof ensTestEnv; } = { mainnet, sepolia, - holesky, "ens-test-env": ensTestEnv, } as const; /** * Returns the ENSNamespace for a specified `namespaceId`. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @returns the ENSNamespace */ export const getENSNamespace = ( @@ -39,7 +35,7 @@ export const getENSNamespace = ( * NOTE: the typescript typechecker _will_ enforce validity. i.e. using an invalid `datasourceName` * within the specified `namespaceId` will be a type error. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param datasourceName - The name of the Datasource to retrieve * @returns The Datasource object for the given name within the specified namespace */ @@ -49,7 +45,7 @@ export const getDatasource = < >( namespaceId: N, datasourceName: D, -): ReturnType>[D] => getENSNamespace(namespaceId)[datasourceName]; +) => getENSNamespace(namespaceId)[datasourceName]; /** * Returns the `datasourceName` Datasource within the specified `namespaceId` namespace, or undefined @@ -59,16 +55,20 @@ export const getDatasource = < * or may not actually be defined. For example, if using {@link getDatasource}, with a * `namespaceId: ENSNamespaceId`, the typechecker will enforce that the only valid `datasourceName` * is ENSRoot (the only Datasource present in all namespaces). This method allows you to receive - * `Datasource | undefined` for a specified `datasourceName`. + * the const Datasource or undefined for a specified `datasourceName`. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param datasourceName - The name of the Datasource to retrieve * @returns The Datasource object for the given name within the specified namespace, or undefined if it does not exist */ -export const maybeGetDatasource = ( - namespaceId: ENSNamespaceId, - datasourceName: DatasourceName, -): Datasource | undefined => (getENSNamespace(namespaceId) as ENSNamespace)[datasourceName]; +export const maybeGetDatasource = < + N extends ENSNamespaceId, + D extends DatasourceName = DatasourceName, +>( + namespaceId: N, + datasourceName: D, +): ExtractDatasourceType>, D> | undefined => + (getENSNamespace(namespaceId) as any)[datasourceName]; /** * Returns the chain for the ENS Root Datasource within the selected namespace. diff --git a/packages/datasources/src/sepolia.ts b/packages/datasources/src/sepolia.ts index 3632c33a0..bbfb1ecfc 100644 --- a/packages/datasources/src/sepolia.ts +++ b/packages/datasources/src/sepolia.ts @@ -13,6 +13,9 @@ import { EarlyAccessRegistrarController as base_EARegistrarController } from "./ import { RegistrarController as base_RegistrarController } from "./abis/basenames/RegistrarController"; import { Registry as base_Registry } from "./abis/basenames/Registry"; import { UpgradeableRegistrarController as base_UpgradeableRegistrarController } from "./abis/basenames/UpgradeableRegistrarController"; +import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; +import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; +import { Registry } from "./abis/ensv2/Registry"; // ABIs for Lineanames Datasource import { BaseRegistrar as linea_BaseRegistrar } from "./abis/lineanames/BaseRegistrar"; import { EthRegistrarController as linea_EthRegistrarController } from "./abis/lineanames/EthRegistrarController"; @@ -30,7 +33,7 @@ import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } f import { Seaport as Seaport1_5 } from "./abis/seaport/Seaport1.5"; // Shared ABIs import { StandaloneReverseRegistrar } from "./abis/shared/StandaloneReverseRegistrar"; -import { ResolverABI, ResolverFilter } from "./lib/resolver"; +import { ResolverABI } from "./lib/ResolverABI"; // Types import { DatasourceNames, type ENSNamespace } from "./lib/types"; @@ -49,19 +52,18 @@ export default { [DatasourceNames.ENSRoot]: { chain: sepolia, contracts: { - RegistryOld: { + ENSv1RegistryOld: { abi: root_Registry, // Registry was redeployed, same abi address: "0x94f523b8261b815b87effcf4d18e6abef18d6e4b", startBlock: 3702721, }, - Registry: { + ENSv1Registry: { abi: root_Registry, // Registry was redeployed, same abi address: "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e", startBlock: 3702728, }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, startBlock: 3702721, // ignores any Resolver events prior to `startBlock` of RegistryOld on Sepolia }, BaseRegistrar: { @@ -99,6 +101,55 @@ export default { address: "0xb7b7dadf4d42a08b3ec1d3a1079959dfbc8cffcc", startBlock: 8515717, }, + + // + + ETHRegistry: { + abi: Registry, + address: "0x1291be112d480055dafd8a610b7d1e203891c274", + startBlock: 23794084, + }, + RootRegistry: { + abi: Registry, + address: "0x8a791620dd6260079bf849dc5567adc3f2fdc318", + startBlock: 23794084, + }, + Registry: { + abi: Registry, + startBlock: 23794084, + }, + EnhancedAccessControl: { + abi: EnhancedAccessControl, + startBlock: 23794084, + }, + }, + }, + + [DatasourceNames.Namechain]: { + chain: sepolia, + contracts: { + Resolver: { + abi: ResolverABI, + startBlock: 23794084, + }, + Registry: { + abi: Registry, + startBlock: 23794084, + }, + EnhancedAccessControl: { + abi: EnhancedAccessControl, + startBlock: 23794084, + }, + ETHRegistry: { + abi: Registry, + address: "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707", + startBlock: 23794084, + }, + ETHRegistrar: { + abi: ETHRegistrar, + address: "0xa513e6e4b8f2a923d98304ec87f64353c4d5c853", + startBlock: 23794084, + }, }, }, @@ -133,7 +184,6 @@ export default { }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, startBlock: 13012458, }, BaseRegistrar: { @@ -195,7 +245,6 @@ export default { }, Resolver: { abi: ResolverABI, - filter: ResolverFilter, startBlock: 2395094, // based on startBlock of Registry on Linea Sepolia }, BaseRegistrar: { diff --git a/packages/ensnode-schema/package.json b/packages/ensnode-schema/package.json index a897a248e..11ab027e8 100644 --- a/packages/ensnode-schema/package.json +++ b/packages/ensnode-schema/package.json @@ -41,6 +41,7 @@ "viem": "catalog:" }, "devDependencies": { + "@ensnode/ensnode-sdk": "workspace:", "@ensnode/shared-configs": "workspace:*", "tsup": "catalog:", "typescript": "catalog:" diff --git a/packages/ensnode-schema/src/ponder.schema.ts b/packages/ensnode-schema/src/ponder.schema.ts index 8137dd0f7..9ec9f5fd9 100644 --- a/packages/ensnode-schema/src/ponder.schema.ts +++ b/packages/ensnode-schema/src/ponder.schema.ts @@ -2,6 +2,7 @@ * Merge the various sub-schemas into a single ponder (drizzle) schema. */ +export * from "./schemas/ensv2.schema"; export * from "./schemas/protocol-acceleration.schema"; export * from "./schemas/registrars.schema"; export * from "./schemas/subgraph.schema"; diff --git a/packages/ensnode-schema/src/schemas/ensv2.schema.ts b/packages/ensnode-schema/src/schemas/ensv2.schema.ts new file mode 100644 index 000000000..490e45b13 --- /dev/null +++ b/packages/ensnode-schema/src/schemas/ensv2.schema.ts @@ -0,0 +1,448 @@ +import { index, onchainEnum, onchainTable, relations, uniqueIndex } from "ponder"; +import type { Address } from "viem"; + +import type { + ChainId, + DomainId, + ENSv1DomainId, + ENSv2DomainId, + EncodedReferrer, + InterpretedLabel, + LabelHash, + PermissionsId, + PermissionsResourceId, + PermissionsUserId, + RegistrationId, + RegistryId, + RenewalId, +} from "@ensnode/ensnode-sdk"; + +/** + * The ENSv2 Schema + * + * While the initial approach was a highly materialized view of the ENS protocol, abstracting away + * as many on-chain details as possible, in practice—due to the sheer complexity of the protocol at + * resolution-time—it becomes more or less impossible to appropriately materialize the canonical + * namegraph. + * + * As a result, this schema takes a balanced approach. It mimics on-chain state as closely as possible, + * with the obvious exception of materializing specific state that must trivially filterable. Then, + * resolution-time logic is applied on _top_ of this index, at query-time, mimicking ENS's own resolution-time + * behavior. This forces our implementation to match the protocol as closely as possible, with the + * obvious note that the performance tradeoffs of evm code and our app are different. For example, + * it's more expensive for us to recursively traverse the namegraph (like evm code does) because our + * individual roundtrips from the db are relatively more expensive. + * + * For the datamodel, this means that instead of a polymorphic Domain entity, representing both v1 + * and v2 Domains, this schema employs separate (but overlapping) v1Domains and v2Domains entities. + * This avoids resolution-time complications and more accurately represents the on-chain state. + * Domain polymorphism is applied at the API later, via GraphQL Interfaces, to simplify queries. + * + * In general: the indexed schema should match on-chain state as closely as possible, and + * resolution-time behavior within the ENS protocol should _also_ be implemented at resolution time + * in ENSApi. The current obvious exception to this is that v1Domain.owner is the _materialized_ + * _effective_ owner of the v1Domain. ENSv1 includes a mind-boggling number of ways to 'own' a v1Domain, + * including the ENSv1 Registry, various Registrars, and the NameWrapper. The ENSv1 indexing logic + * within this ENSv2 plugin materialize the v1Domain's effective owner to simplify this aspect of ENS, + * and enable efficient queries against v1Domain.owner. + * + * Many datamodels are sharable between ENSv1 and ENSv2, including Registrations, Renewals, and Resolvers. + * + * Registrations are polymorphic between the defined RegistrationTypes, depending on the associated + * guarantees (for example, ENSv1 BaseRegistrar Registrations may have a gracePeriod, but ENSv2 + * Registry Registrations do not). + * + * Instead of materializing a Domain's name at any point, we maintain an internal rainbow table of + * labelHash -> InterpretedLabel (the Label entity). This ensures that regardless of how or when a + * new label is encountered onchain, all Domains that use that label are automatically healed at + * resolution-time. + * + * v1Domains exist in a flat namespace and are absolutely addressed by `node`. As such, they inhabit + * a simple tree datamodel of: + * v1Domain -> v1Domain(s) -> v1Domain(s) -> ...etc + * + * v2Domains exist in a set of namegraphs. Each namegraph is a possibly cicular directed graph of + * (Root)Registry -> v2Domain(s) -> (sub)Regsitry -> v2Domain(s) -> ...etc + * with exactly one RootRegistry on the ENS Root Chain establishing the beginning of the _canonical_ + * namegraph. As discussed above, the canonical namegraph is never materialized, only _navigated_ + * at resolution-time, in order to correctly implement the complexities of the ENS protocol. + * + * Note also that the Protocol Acceleration plugin is a hard requirement for the ENSv2 plugin. This + * allows us to rely on the shared logic for indexing: + * a) ENSv1RegistryOld -> ENSv1Registry migration status + * b) Domain-Resolver Relations for both v1Domains and v2Domains + * As such, none of that information is present in this ensv2.schema.ts file. + * + * In general, entities are keyed by a nominally-typed `id` that uniquely references them. This + * allows us to trivially implement cursor-based pagination and allow consumers to reference these + * deeply nested entities by a straightforward string ID. In cases where an entity's `id` is composed + * of multiple pieces of information (for example, a Registry is identified by (chainId, address)), + * then that information is, as well, included in the entity. + */ + +/////////// +// Account +/////////// + +export const account = onchainTable("accounts", (t) => ({ + id: t.hex().primaryKey().$type
(), +})); + +export const account_relations = relations(account, ({ many }) => ({ + registrations: many(registration, { relationName: "registrant" }), + domains: many(v2Domain), + permissions: many(permissionsUser), +})); + +//////////// +// Registry +//////////// + +export const registry = onchainTable( + "registries", + (t) => ({ + // see RegistryId for guarantees + id: t.text().primaryKey().$type(), + + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + }), + (t) => ({ + byId: uniqueIndex().on(t.chainId, t.address), + }), +); + +export const relations_registry = relations(registry, ({ one, many }) => ({ + domain: one(v2Domain, { + relationName: "subregistry", + fields: [registry.id], + references: [v2Domain.registryId], + }), + domains: many(v2Domain, { relationName: "registry" }), + permissions: one(permissions, { + relationName: "permissions", + fields: [registry.chainId, registry.address], + references: [permissions.chainId, permissions.address], + }), +})); + +/////////// +// Domains +/////////// + +export const v1Domain = onchainTable( + "v1_domains", + (t) => ({ + // keyed by node, see ENSv1DomainId for guarantees. + id: t.text().primaryKey().$type(), + + // must have a parent v1Domain (note: root node does not exist in index) + parentId: t.text().notNull().$type(), + + // may have an owner + ownerId: t.hex().$type
(), + + // represents a labelHash + labelHash: t.hex().notNull().$type(), + + // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin + }), + (t) => ({ + byParent: index().on(t.parentId), + byOwner: index().on(t.ownerId), + }), +); + +export const relations_v1Domain = relations(v1Domain, ({ one, many }) => ({ + // v1Domain + parent: one(v1Domain, { + fields: [v1Domain.parentId], + references: [v1Domain.id], + }), + children: many(v1Domain, { relationName: "parent" }), + + // shared + owner: one(account, { + relationName: "owner", + fields: [v1Domain.ownerId], + references: [account.id], + }), + label: one(label, { + relationName: "label", + fields: [v1Domain.labelHash], + references: [label.labelHash], + }), + registrations: many(registration), +})); + +export const v2Domain = onchainTable( + "v2_domains", + (t) => ({ + // see ENSv2DomainId for guarantees + id: t.text().primaryKey().$type(), + + // has a tokenId + tokenId: t.bigint().notNull(), + + // belongs to registry + registryId: t.text().notNull().$type(), + + // may have one subregistry + subregistryId: t.text().$type(), + + // may have an owner + ownerId: t.hex().$type
(), + + // represents a labelHash + labelHash: t.hex().notNull().$type(), + + // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin + }), + (t) => ({ + byRegistry: index().on(t.registryId), + byOwner: index().on(t.ownerId), + }), +); + +export const relations_v2Domain = relations(v2Domain, ({ one, many }) => ({ + // v2Domain + registry: one(registry, { + relationName: "registry", + fields: [v2Domain.registryId], + references: [registry.id], + }), + subregistry: one(registry, { + relationName: "subregistry", + fields: [v2Domain.subregistryId], + references: [registry.id], + }), + + // shared + owner: one(account, { + relationName: "owner", + fields: [v2Domain.ownerId], + references: [account.id], + }), + label: one(label, { + relationName: "label", + fields: [v2Domain.labelHash], + references: [label.labelHash], + }), + registrations: many(registration), +})); + +///////////////// +// Registrations +///////////////// + +export const registrationType = onchainEnum("RegistrationType", [ + // TODO: prefix these with ENSv1, maybe excluding ThreeDNS + "NameWrapper", + "BaseRegistrar", + "ThreeDNS", + "ENSv2Registry", +]); + +export const registration = onchainTable( + "registrations", + (t) => ({ + // keyed by (domainId, index) + id: t.text().primaryKey().$type(), + + domainId: t.text().notNull().$type(), + index: t.integer().notNull().default(0), + + // has a type + type: registrationType().notNull(), + + // must have a start timestamp + start: t.bigint().notNull(), + // may have an expiry + expiry: t.bigint(), + // maybe have a grace period (BaseRegistrar) + gracePeriod: t.bigint(), + + // registrar AccountId + registrarChainId: t.integer().notNull().$type(), + registrarAddress: t.hex().notNull().$type
(), + + // references registrant + registrantId: t.hex().$type
(), + + // may have a referrer + referrer: t.hex().$type(), + + // may have fuses (NameWrapper, Wrapped BaseRegistrar) + fuses: t.integer(), + + // TODO(paymentToken): add payment token tracking here + + // may have base cost (BaseRegistrar, ENSv2Registrar) + base: t.bigint(), + + // may have a premium (BaseRegistrar) + premium: t.bigint(), + + // may be Wrapped (BaseRegistrar) + wrapped: t.boolean().default(false), + }), + (t) => ({ + byId: uniqueIndex().on(t.domainId, t.index), + }), +); + +export const registration_relations = relations(registration, ({ one, many }) => ({ + // belongs to either v1Domain or v2Domain + v1Domain: one(v1Domain, { + fields: [registration.domainId], + references: [v1Domain.id], + }), + v2Domain: one(v2Domain, { + fields: [registration.domainId], + references: [v2Domain.id], + }), + + // has one registrant + registrant: one(account, { + fields: [registration.registrantId], + references: [account.id], + relationName: "registrant", + }), + + // has many renewals + renewals: many(renewal), +})); + +//////////// +// Renewals +//////////// + +export const renewal = onchainTable( + "renewals", + (t) => ({ + // keyed by (registrationId, index) + id: t.text().primaryKey().$type(), + + domainId: t.text().notNull().$type(), + registrationIndex: t.integer().notNull().default(0), + index: t.integer().notNull().default(0), + + // all renewals have a duration + duration: t.bigint().notNull(), + + // may have a referrer + referrer: t.hex().$type(), + + // TODO(paymentToken): add payment token tracking here + + // may have base cost + base: t.bigint(), + + // may have a premium (ENSv1 RegistrarControllers) + premium: t.bigint(), + }), + (t) => ({ + byId: uniqueIndex().on(t.domainId, t.index), + }), +); + +export const renewal_relations = relations(renewal, ({ one }) => ({ + // belongs to registration + registration: one(registration, { + fields: [renewal.domainId, renewal.registrationIndex], + references: [registration.domainId, registration.index], + }), +})); + +/////////////// +// Permissions +/////////////// + +export const permissions = onchainTable( + "permissions", + (t) => ({ + id: t.text().primaryKey().$type(), + + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + }), + (t) => ({ + byId: uniqueIndex().on(t.chainId, t.address), + }), +); + +export const relations_permissions = relations(permissions, ({ many }) => ({ + resources: many(permissionsResource), + users: many(permissionsUser), +})); + +export const permissionsResource = onchainTable( + "permissions_resources", + (t) => ({ + id: t.text().primaryKey().$type(), + + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + resource: t.bigint().notNull(), + }), + (t) => ({ + byId: uniqueIndex().on(t.chainId, t.address, t.resource), + }), +); + +export const relations_permissionsResource = relations(permissionsResource, ({ one }) => ({ + permissions: one(permissions, { + fields: [permissionsResource.chainId, permissionsResource.address], + references: [permissions.chainId, permissions.address], + }), +})); + +export const permissionsUser = onchainTable( + "permissions_users", + (t) => ({ + id: t.text().primaryKey().$type(), + + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + resource: t.bigint().notNull(), + user: t.hex().notNull().$type
(), + + // has one roles bitmap + roles: t.bigint().notNull(), + }), + (t) => ({ + byId: uniqueIndex().on(t.chainId, t.address, t.resource, t.user), + }), +); + +export const relations_permissionsUser = relations(permissionsUser, ({ one }) => ({ + account: one(account, { + fields: [permissionsUser.user], + references: [account.id], + }), + permissions: one(permissions, { + fields: [permissionsUser.chainId, permissionsUser.address], + references: [permissions.chainId, permissions.address], + }), + resource: one(permissionsResource, { + fields: [permissionsUser.chainId, permissionsUser.address, permissionsUser.resource], + references: [ + permissionsResource.chainId, + permissionsResource.address, + permissionsResource.resource, + ], + }), +})); + +////////// +// Labels +////////// + +export const label = onchainTable("labels", (t) => ({ + labelHash: t.hex().primaryKey().$type(), + value: t.text().notNull().$type(), +})); + +export const label_relations = relations(label, ({ many }) => ({ + domains: many(v2Domain), +})); diff --git a/packages/ensnode-schema/src/schemas/protocol-acceleration.schema.ts b/packages/ensnode-schema/src/schemas/protocol-acceleration.schema.ts index 91c29cd60..6a685436c 100644 --- a/packages/ensnode-schema/src/schemas/protocol-acceleration.schema.ts +++ b/packages/ensnode-schema/src/schemas/protocol-acceleration.schema.ts @@ -2,7 +2,10 @@ * Schema Definitions that power Protocol Acceleration in the Resolution API. */ -import { onchainTable, primaryKey, relations } from "ponder"; +import { onchainTable, primaryKey, relations, uniqueIndex } from "ponder"; +import type { Address } from "viem"; + +import type { ChainId, DomainId, Node, ResolverId, ResolverRecordsId } from "@ensnode/ensnode-sdk"; /** * Tracks an Account's ENSIP-19 Reverse Name Records by CoinType. @@ -21,7 +24,7 @@ export const reverseNameRecord = onchainTable( "reverse_name_records", (t) => ({ // keyed by (address, coinType) - address: t.hex().notNull(), + address: t.hex().notNull().$type
(), coinType: t.bigint().notNull(), /** @@ -41,36 +44,76 @@ export const reverseNameRecord = onchainTable( ); /** - * Tracks Node-Resolver relationships to accelerate the identification of a node's active resolver - * in a specific (shadow)Registry. - * - * Note that this model supports the indexing of Node-Resolver relationships across any Registry on - * on any chain, in particular to support the acceleration of ForwardResolution#findResolver for the - * ENS Root Chain's Registry which can have any number of (shadow)Registries (like Basenames' and - * Lineanames') on any chain. + * Tracks Domain-Resolver Relationships. This powers: + * 1. Domain-Resolver Realtionships within the GraphQL API, and + * 2. Accelerated lookups of a Domain's Resolver within the Resolution API. * - * It is keyed by (chainId, registry, node) to match the on-chain datamodel of Registry/(shadow)Registry - * Node-Resolver relationships. + * It is keyed by (chainId, address, domainId) to match the on-chain datamodel of + * Registry/(shadow)Registry Domain-Resolver relationships. */ -export const nodeResolverRelation = onchainTable( - "node_resolver_relations", +export const domainResolverRelation = onchainTable( + "domain_resolver_relations", (t) => ({ // keyed by (chainId, registry, node) - chainId: t.integer().notNull(), - registry: t.hex().notNull(), - node: t.hex().notNull(), + chainId: t.integer().notNull().$type(), + + // The Registry (ENSv1Registry or ENSv2Registry)'s AccountId. + address: t.hex().notNull().$type
(), + domainId: t.hex().notNull().$type(), + + // The Domain's assigned Resolver's address (NOTE: always scoped to chainId) + resolver: t.hex().notNull().$type
(), + }), + (t) => ({ + pk: primaryKey({ columns: [t.chainId, t.address, t.domainId] }), + }), +); + +export const domainResolverRelation_relations = relations(domainResolverRelation, ({ one }) => ({ + resolver: one(resolver, { + fields: [domainResolverRelation.chainId, domainResolverRelation.resolver], + references: [resolver.chainId, resolver.address], + }), +})); + +/** + * Resolver represents an individual IResolver contract that has emitted at least 1 event. + * Note that Resolver contracts can exist on-chain but not emit any events and still function + * properly, so checks against a Resolver's existence and metadata must be done at runtime. + * + * We index whether a Resolver is an IExtendedResolver or an IDedicatedResolver, to minimize RPC + * requests at the API layer, when resolving resources, but note that runtime operations like + * Forward Resolution _must_ query a Resolver's existence against the chain, because the indexed set + * of Resolvers is a subset of the theoretical set of functional Resolvers deployed to a given chain. + */ +export const resolver = onchainTable( + "resolvers", + (t) => ({ + // keyed by (chainId, address) + id: t.text().primaryKey().$type(), + + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + + /** + * Whether the Resolver implements IExtendedResolver. + */ + isExtended: t.boolean().notNull().default(false), /** - * The Address of the Resolver contract this `node` has set (via Registry#NewResolver) within - * the Registry on `chainId`. + * Whether the Resolver implements IDedicatedResolver. */ - resolver: t.hex().notNull(), + isDedicated: t.boolean().notNull().default(false), }), (t) => ({ - pk: primaryKey({ columns: [t.chainId, t.registry, t.node] }), + byId: uniqueIndex().on(t.chainId, t.address), }), ); +export const resolver_relations = relations(resolver, ({ many }) => ({ + records: many(resolverRecords), +})); + /** * Tracks a set of records for a specified `node` within a `resolver` contract on `chainId`. * @@ -89,9 +132,11 @@ export const resolverRecords = onchainTable( "resolver_records", (t) => ({ // keyed by (chainId, resolver, node) - chainId: t.integer().notNull(), - resolver: t.hex().notNull(), - node: t.hex().notNull(), + id: t.text().primaryKey().$type(), + + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + node: t.hex().notNull().$type(), /** * Represents the value of the reverse-resolution (ENSIP-3) name() record, used for Reverse Resolution. @@ -106,11 +151,17 @@ export const resolverRecords = onchainTable( name: t.text(), }), (t) => ({ - pk: primaryKey({ columns: [t.chainId, t.resolver, t.node] }), + byId: uniqueIndex().on(t.chainId, t.address, t.node), }), ); -export const resolverRecords_relations = relations(resolverRecords, ({ many }) => ({ +export const resolverRecords_relations = relations(resolverRecords, ({ one, many }) => ({ + // belongs to resolver + resolver: one(resolver, { + fields: [resolverRecords.chainId, resolverRecords.address], + references: [resolver.chainId, resolver.address], + }), + // resolverRecord has many address records addressRecords: many(resolverAddressRecord), @@ -129,9 +180,11 @@ export const resolverAddressRecord = onchainTable( "resolver_address_records", (t) => ({ // keyed by ((chainId, resolver, node), coinType) - chainId: t.integer().notNull(), - resolver: t.hex().notNull(), - node: t.hex().notNull(), + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + node: t.hex().notNull().$type(), + // NOTE: all well-known CoinTypes fit into javascript number but NOT postgres .integer, must be + // stored as BigInt coinType: t.bigint().notNull(), /** @@ -140,10 +193,10 @@ export const resolverAddressRecord = onchainTable( * The value of this field is interpreted by `interpretAddressRecordValue` — see its implementation * for additional context and specific guarantees. */ - address: t.text().notNull(), + value: t.text().notNull(), }), (t) => ({ - pk: primaryKey({ columns: [t.chainId, t.resolver, t.node, t.coinType] }), + pk: primaryKey({ columns: [t.chainId, t.address, t.node, t.coinType] }), }), ); @@ -152,10 +205,10 @@ export const resolverAddressRecordRelations = relations(resolverAddressRecord, ( resolver: one(resolverRecords, { fields: [ resolverAddressRecord.chainId, - resolverAddressRecord.resolver, + resolverAddressRecord.address, resolverAddressRecord.node, ], - references: [resolverRecords.chainId, resolverRecords.resolver, resolverRecords.node], + references: [resolverRecords.chainId, resolverRecords.address, resolverRecords.node], }), })); @@ -167,12 +220,12 @@ export const resolverAddressRecordRelations = relations(resolverAddressRecord, ( * then additionally keyed by (key). */ export const resolverTextRecord = onchainTable( - "resolver_trecords", + "resolver_text_records", (t) => ({ // keyed by ((chainId, resolver, node), key) - chainId: t.integer().notNull(), - resolver: t.hex().notNull(), - node: t.hex().notNull(), + chainId: t.integer().notNull().$type(), + address: t.hex().notNull().$type
(), + node: t.hex().notNull().$type(), key: t.text().notNull(), /** @@ -184,15 +237,15 @@ export const resolverTextRecord = onchainTable( value: t.text().notNull(), }), (t) => ({ - pk: primaryKey({ columns: [t.chainId, t.resolver, t.node, t.key] }), + pk: primaryKey({ columns: [t.chainId, t.address, t.node, t.key] }), }), ); export const resolverTextRecordRelations = relations(resolverTextRecord, ({ one }) => ({ // belongs to resolverRecord resolver: one(resolverRecords, { - fields: [resolverTextRecord.chainId, resolverTextRecord.resolver, resolverTextRecord.node], - references: [resolverRecords.chainId, resolverRecords.resolver, resolverRecords.node], + fields: [resolverTextRecord.chainId, resolverTextRecord.address, resolverTextRecord.node], + references: [resolverRecords.chainId, resolverRecords.address, resolverRecords.node], }), })); diff --git a/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.test.ts b/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.test.ts index e3d0d89e3..781eb2e9f 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.test.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import type { InterpretedName } from "../../ens"; -import { AssetNamespaces, NameTokenOwnershipTypes, NFTMintStatuses } from "../../tokenscope"; +import { AssetNamespaces } from "../../shared/types"; +import { NameTokenOwnershipTypes, NFTMintStatuses } from "../../tokenscope"; import { NameTokensResponseCodes, type NameTokensResponseOk } from "./response"; import type { SerializedNameTokensResponseOk } from "./serialized-response"; import { makeNameTokensResponseSchema } from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/types.ts b/packages/ensnode-sdk/src/api/types.ts new file mode 100644 index 000000000..0e14b4e95 --- /dev/null +++ b/packages/ensnode-sdk/src/api/types.ts @@ -0,0 +1,255 @@ +import type { z } from "zod/v4"; + +import type { InterpretedName, Node } from "../ens"; +import type { ENSApiPublicConfig } from "../ensapi"; +import type { RealtimeIndexingStatusProjection } from "../ensindexer"; +import type { RegistrarAction } from "../registrars"; +import type { + ForwardResolutionArgs, + MultichainPrimaryNameResolutionArgs, + MultichainPrimaryNameResolutionResult, + ResolverRecordsResponse, + ResolverRecordsSelection, + ReverseResolutionArgs, + ReverseResolutionResult, +} from "../resolution"; +import type { TracingTrace } from "../tracing"; +import type { ErrorResponseSchema } from "./shared/errors/zod-schemas"; + +/** + * API Error Response Type + */ +export type ErrorResponse = z.infer; + +export interface TraceableRequest { + trace?: boolean; +} + +export interface TraceableResponse { + trace?: TracingTrace; +} + +export interface AcceleratableRequest { + accelerate?: boolean; +} + +export interface AcceleratableResponse { + accelerationRequested: boolean; + accelerationAttempted: boolean; +} + +/** + * Resolve Records Request Type + */ +export interface ResolveRecordsRequest + extends ForwardResolutionArgs, + AcceleratableRequest, + TraceableRequest {} + +/** + * Resolve Records Response Type + */ +export interface ResolveRecordsResponse + extends AcceleratableResponse, + TraceableResponse { + records: ResolverRecordsResponse; +} + +/** + * Resolve Primary Name Request Type + */ +export interface ResolvePrimaryNameRequest + extends ReverseResolutionArgs, + AcceleratableRequest, + TraceableRequest {} + +/** + * Resolve Primary Name Response Type + */ +export interface ResolvePrimaryNameResponse extends AcceleratableResponse, TraceableResponse { + name: ReverseResolutionResult; +} + +export interface ResolvePrimaryNamesRequest + extends MultichainPrimaryNameResolutionArgs, + AcceleratableRequest, + TraceableRequest {} + +export interface ResolvePrimaryNamesResponse extends AcceleratableResponse, TraceableResponse { + names: MultichainPrimaryNameResolutionResult; +} + +/** + * ENSIndexer Public Config Response + */ +export type ConfigResponse = ENSApiPublicConfig; + +/** + * Represents a request to Indexing Status API. + */ +export type IndexingStatusRequest = {}; + +/** + * A status code for indexing status responses. + */ +export const IndexingStatusResponseCodes = { + /** + * Represents that the indexing status is available. + */ + Ok: "ok", + + /** + * Represents that the indexing status is unavailable. + */ + Error: "error", +} as const; + +/** + * The derived string union of possible {@link IndexingStatusResponseCodes}. + */ +export type IndexingStatusResponseCode = + (typeof IndexingStatusResponseCodes)[keyof typeof IndexingStatusResponseCodes]; + +/** + * An indexing status response when the indexing status is available. + */ +export type IndexingStatusResponseOk = { + responseCode: typeof IndexingStatusResponseCodes.Ok; + realtimeProjection: RealtimeIndexingStatusProjection; +}; + +/** + * An indexing status response when the indexing status is unavailable. + */ +export type IndexingStatusResponseError = { + responseCode: typeof IndexingStatusResponseCodes.Error; +}; + +/** + * Indexing status response. + * + * Use the `responseCode` field to determine the specific type interpretation + * at runtime. + */ +export type IndexingStatusResponse = IndexingStatusResponseOk | IndexingStatusResponseError; + +/** + * Registrar Actions response + */ + +/** + * Records Filters: Comparators + */ +export const RegistrarActionsFilterComparators = { + EqualsTo: "eq", +} as const; + +export type RegistrarActionsFilterComparator = + (typeof RegistrarActionsFilterComparators)[keyof typeof RegistrarActionsFilterComparators]; + +/** + * Records Filters: Fields + */ +export const RegistrarActionsFilterFields = { + SubregistryNode: "registrationLifecycle.subregistry.node", +} as const; + +export type RegistrarActionsFilterField = + (typeof RegistrarActionsFilterFields)[keyof typeof RegistrarActionsFilterFields]; + +export type RegistrarActionsFilter = { + field: typeof RegistrarActionsFilterFields.SubregistryNode; + comparator: typeof RegistrarActionsFilterComparators.EqualsTo; + value: Node; +}; + +/** + * Records Orders + */ +export const RegistrarActionsOrders = { + LatestRegistrarActions: "orderBy[timestamp]=desc", +} as const; + +export type RegistrarActionsOrder = + (typeof RegistrarActionsOrders)[keyof typeof RegistrarActionsOrders]; + +/** + * Represents a request to Registrar Actions API. + */ +export type RegistrarActionsRequest = { + filter?: RegistrarActionsFilter; + + /** + * Order applied while generating results. + */ + order?: RegistrarActionsOrder; + + /** + * Limit the count of items per page to selected count of records. + * + * Guaranteed to be a positive integer (if defined). + */ + itemsPerPage?: number; +}; + +/** + * A status code for Registrar Actions API responses. + */ +export const RegistrarActionsResponseCodes = { + /** + * Represents that Registrar Actions are available. + */ + Ok: "ok", + + /** + * Represents that Registrar Actions are unavailable. + */ + Error: "error", +} as const; + +/** + * The derived string union of possible {@link RegistrarActionsResponseCodes}. + */ +export type RegistrarActionsResponseCode = + (typeof RegistrarActionsResponseCodes)[keyof typeof RegistrarActionsResponseCodes]; + +/** + * "Logical registrar action" with its associated name. + */ +export interface NamedRegistrarAction { + action: RegistrarAction; + + /** + * Name + * + * FQDN of the name associated with `action`. + * + * Guarantees: + * - `namehash(name)` is always `action.registrationLifecycle.node`. + */ + name: InterpretedName; +} + +/** + * A response when Registrar Actions are available. + */ +export type RegistrarActionsResponseOk = { + responseCode: typeof RegistrarActionsResponseCodes.Ok; + registrarActions: NamedRegistrarAction[]; +}; + +/** + * A response when Registrar Actions are unavailable. + */ +export interface RegistrarActionsResponseError { + responseCode: typeof IndexingStatusResponseCodes.Error; + error: ErrorResponse; +} + +/** + * Registrar Actions response. + * + * Use the `responseCode` field to determine the specific type interpretation + * at runtime. + */ +export type RegistrarActionsResponse = RegistrarActionsResponseOk | RegistrarActionsResponseError; diff --git a/packages/ensnode-sdk/src/ens/constants.ts b/packages/ensnode-sdk/src/ens/constants.ts index 4a23d0b27..491fd2ea7 100644 --- a/packages/ensnode-sdk/src/ens/constants.ts +++ b/packages/ensnode-sdk/src/ens/constants.ts @@ -1,4 +1,4 @@ -import { namehash } from "viem"; +import { namehash, zeroHash } from "viem"; import type { Node } from "./types"; @@ -7,3 +7,15 @@ export const ETH_NODE: Node = namehash("eth"); export const BASENAMES_NODE: Node = namehash("base.eth"); export const LINEANAMES_NODE: Node = namehash("linea.eth"); export const ADDR_REVERSE_NODE: Node = namehash("addr.reverse"); + +/** + * NODE_ANY is a placeholder Node used in the context of DedicatedResolvers — IResolver events are + * emitted with NODE_ANY as the `node` for which the records are issued, but the DedicatedResolver + * returns those records regardless of the name used for record resolution. + */ +export const NODE_ANY: Node = zeroHash; + +/** + * ROOT_RESOURCE represents the 'root' resource in an EnhancedAccessControl contract. + */ +export const ROOT_RESOURCE = 0n; diff --git a/packages/ensnode-sdk/src/ens/fuses.ts b/packages/ensnode-sdk/src/ens/fuses.ts new file mode 100644 index 000000000..bacfa2464 --- /dev/null +++ b/packages/ensnode-sdk/src/ens/fuses.ts @@ -0,0 +1,10 @@ +/** + * The NameWrapper's PARENT_CANNOT_CONTROL fuse. + */ +const PARENT_CANNOT_CONTROL = 0x10000; + +/** + * Determines whether `fuses` has set ('burnt') the PARENT_CANNOT_CONTROL fuse. + */ +export const isPccFuseSet = (fuses: number) => + (fuses & PARENT_CANNOT_CONTROL) === PARENT_CANNOT_CONTROL; diff --git a/packages/ensnode-sdk/src/ens/index.ts b/packages/ensnode-sdk/src/ens/index.ts index 88ff7e923..ac9a315be 100644 --- a/packages/ensnode-sdk/src/ens/index.ts +++ b/packages/ensnode-sdk/src/ens/index.ts @@ -4,6 +4,7 @@ export * from "./coin-type"; export * from "./constants"; export * from "./dns-encoded-name"; export * from "./encode-labelhash"; +export * from "./fuses"; export * from "./is-normalized"; export * from "./labelhash"; export * from "./names"; diff --git a/packages/ensnode-sdk/src/ens/types.ts b/packages/ensnode-sdk/src/ens/types.ts index 6321f2bc5..32350a30b 100644 --- a/packages/ensnode-sdk/src/ens/types.ts +++ b/packages/ensnode-sdk/src/ens/types.ts @@ -1,5 +1,7 @@ import type { Hex } from "viem"; +import type { DomainId } from "../ensv2"; + export type { ENSNamespaceId } from "@ensnode/datasources"; // re-export ENSNamespaceIds and ENSNamespaceId from @ensnode/datasources // so consumers don't need it as a dependency @@ -50,6 +52,24 @@ export type NormalizedName = Name & { __brand: "NormalizedName" }; */ export type LabelHash = Hex; +/** + * A LabelHashPath is an ordered list of LabelHashes that uniquely identify an ENS Name. + * It is ordered in namegraph TRAVERSAL order (i.e. the opposite order of an ENS Name's labels). + * + * ex: example.eth's LabelHashPath is + * [ + * '0x4f5b812789fc606be1b3b16908db13fc7a9adf7ca72641f84d75b47069d3d7f0', // 'eth' + * '0x6fd43e7cffc31bb581d7421c8698e29aa2bd8e7186a394b85299908b4eb9b175', // 'example' + * ] + */ +export type LabelHashPath = LabelHash[]; + +/** + * CanonicalPath is an ordered list of DomainIds describing the canonical path to a Domain. + * It is ordered in namegraph TRAVERSAL order (i.e. the opposite order of an ENS Name's labels). + */ +export type CanonicalPath = DomainId[]; + /** * A Label is a single part of an ENS Name. * diff --git a/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts b/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts index c394b339e..affd46cd7 100644 --- a/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts +++ b/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; -import { PluginName } from "../../ensindexer"; +import { PluginName } from "../../ensindexer/config/types"; import { deserializeENSApiPublicConfig, serializeENSApiPublicConfig } from "."; import type { ENSApiPublicConfig } from "./types"; diff --git a/packages/ensnode-sdk/src/ensindexer/config/types.ts b/packages/ensnode-sdk/src/ensindexer/config/types.ts index 14417376a..9923e00a4 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/types.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/types.ts @@ -15,6 +15,7 @@ export enum PluginName { ProtocolAcceleration = "protocol-acceleration", Registrars = "registrars", TokenScope = "tokenscope", + ENSv2 = "ensv2", } /** diff --git a/packages/ensnode-sdk/src/ensindexer/config/validations.ts b/packages/ensnode-sdk/src/ensindexer/config/validations.ts index 5c3809f3d..cc7567529 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/validations.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/validations.ts @@ -1,4 +1,4 @@ -import type { ZodCheckFnInput } from "../../shared/zod-schemas"; +import type { ZodCheckFnInput } from "../../shared/zod-types"; import type { ENSIndexerVersionInfo } from "./types"; /** diff --git a/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts b/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts index 4383d8c24..21625b91f 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts @@ -6,7 +6,7 @@ * The only way to share Zod schemas is to re-export them from * `./src/internal.ts` file. */ -import z from "zod/v4"; +import { z } from "zod/v4"; import { uniq } from "../../shared"; import { @@ -14,8 +14,8 @@ import { makeENSNamespaceIdSchema, makeNonNegativeIntegerSchema, makePositiveIntegerSchema, - type ZodCheckFnInput, } from "../../shared/zod-schemas"; +import type { ZodCheckFnInput } from "../../shared/zod-types"; import { isSubgraphCompatible } from "./is-subgraph-compatible"; import type { ENSIndexerPublicConfig } from "./types"; import { PluginName } from "./types"; diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts index 4c8fd6454..9378f5c88 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts @@ -437,7 +437,7 @@ export function invariant_snapshotTimeIsTheHighestKnownBlockTimestamp( ctx.issues.push({ code: "custom", input: ctx.value, - message: `'snapshotTime' must be greater than or equal to the "highest known block timestamp" (${highestKnownBlockTimestamp})`, + message: `'snapshotTime' (${snapshotTime}) must be greater than or equal to the "highest known block timestamp" (${highestKnownBlockTimestamp})`, }); } } diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts index 9285f00b2..46df65f11 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts @@ -6,7 +6,7 @@ * The only way to share Zod schemas is to re-export them from * `./src/internal.ts` file. */ -import z from "zod/v4"; +import { z } from "zod/v4"; import { type ChainId, deserializeChainId } from "../../shared"; import { diff --git a/packages/ensnode-sdk/src/ensv2/ids-lib.ts b/packages/ensnode-sdk/src/ensv2/ids-lib.ts new file mode 100644 index 000000000..dab81ef9a --- /dev/null +++ b/packages/ensnode-sdk/src/ensv2/ids-lib.ts @@ -0,0 +1,121 @@ +import { type Address, hexToBigInt } from "viem"; + +import { + type AccountId, + AssetNamespaces, + formatAccountId, + formatAssetId, + type LabelHash, + type Node, +} from "@ensnode/ensnode-sdk"; + +import type { + CanonicalId, + DomainId, + ENSv1DomainId, + ENSv2DomainId, + PermissionsId, + PermissionsResourceId, + PermissionsUserId, + RegistrationId, + RegistryId, + RenewalId, + ResolverId, + ResolverRecordsId, +} from "./ids"; + +/** + * Formats and brands an AccountId as a RegistryId. + */ +export const makeRegistryId = (accountId: AccountId) => formatAccountId(accountId) as RegistryId; + +/** + * Makes an ENSv1 Domain Id given the ENSv1 Domain's `node` + */ +export const makeENSv1DomainId = (node: Node) => node as ENSv1DomainId; + +/** + * Makes an ENSv2 Domain Id given the parent `registry` and the domain's `canonicalId`. + */ +export const makeENSv2DomainId = (registry: AccountId, canonicalId: CanonicalId) => + formatAssetId({ + assetNamespace: AssetNamespaces.ERC1155, + contract: registry, + tokenId: canonicalId, + }) as ENSv2DomainId; + +/** + * Masks the lower 32 bits of `num`. + */ +const maskLower32Bits = (num: bigint) => num ^ (num & 0xffffffffn); + +/** + * Computes a Domain's {@link CanonicalId} given its tokenId or LabelHash as `input`. + */ +export const getCanonicalId = (input: bigint | LabelHash): CanonicalId => { + if (typeof input === "bigint") return maskLower32Bits(input); + return getCanonicalId(hexToBigInt(input)); +}; + +/** + * Formats and brands an AccountId as a PermissionsId. + */ +export const makePermissionsId = (contract: AccountId) => + formatAccountId(contract) as PermissionsId; + +/** + * Constructs a PermissionsResourceId for a given `contract`'s `resource`. + */ +export const makePermissionsResourceId = (contract: AccountId, resource: bigint) => + `${makePermissionsId(contract)}/${resource}` as PermissionsResourceId; + +/** + * Constructs a PermissionsUserId for a given `contract`'s `resource`'s `user`. + */ +export const makePermissionsUserId = (contract: AccountId, resource: bigint, user: Address) => + `${makePermissionsId(contract)}/${resource}/${user}` as PermissionsUserId; + +/** + * Formats and brands an AccountId as a ResolverId. + */ +export const makeResolverId = (contract: AccountId) => formatAccountId(contract) as ResolverId; + +/** + * Constructs a ResolverRecordsId for a given `node` under `resolver`. + */ +export const makeResolverRecordsId = (resolver: AccountId, node: Node) => + `${makeResolverId(resolver)}/${node}` as ResolverRecordsId; + +/** + * Constructs a RegistrationId for a `domainId`'s latest Registration. + * + * @dev See apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts for more info. + */ +export const makeLatestRegistrationId = (domainId: DomainId) => + `${domainId}/latest` as RegistrationId; + +/** + * Constructs a RegistrationId for a `domainId`'s `index`'thd Registration. + * + * @dev See apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts for more info. + */ +export const makeRegistrationId = (domainId: DomainId, index: number = 0) => + `${domainId}/${index}` as RegistrationId; + +/** + * Constructs a RenewalId for a `domainId`'s `registrationIndex`thd Registration's latest Renewal. + * + * @dev Forces usage of the 'pinned' RegistrationId to avoid collisions, see + * apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts for more info. + */ +export const makeLatestRenewalId = (domainId: DomainId, registrationIndex: number) => + `${makeRegistrationId(domainId, registrationIndex)}/latest` as RenewalId; + +/** + * Constructs a RenewalId for a `domainId`'s `registrationIndex`thd Registration's `index`'thd Renewal. + * + * @dev Forces usage of the 'pinned' RegistrationId to avoid collisions, see + * apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts for more info. + */ +export const makeRenewalId = (domainId: DomainId, registrationIndex: number, index: number = 0) => + `${makeRegistrationId(domainId, registrationIndex)}/${index}` as RenewalId; diff --git a/packages/ensnode-sdk/src/ensv2/ids.ts b/packages/ensnode-sdk/src/ensv2/ids.ts new file mode 100644 index 000000000..102f05237 --- /dev/null +++ b/packages/ensnode-sdk/src/ensv2/ids.ts @@ -0,0 +1,61 @@ +import type { AccountIdString, Node } from "@ensnode/ensnode-sdk"; + +/** + * Serialized CAIP-10 Asset ID that uniquely identifies a Registry contract. + */ +export type RegistryId = string & { __brand: "RegistryContractId" }; + +/** + * A Domain's Canonical Id is uint256(labelHash) with lower (right-most) 32 bits zero'd. + */ +export type CanonicalId = bigint; + +/** + * The node that uniquely identifies an ENSv1 name. + */ +export type ENSv1DomainId = Node & { __brand: "ENSv1DomainId" }; + +/** + * The Serialized CAIP-19 Asset ID that uniquely identifies an ENSv2 name. + */ +export type ENSv2DomainId = string & { __brand: "ENSv2DomainId" }; + +/** + * A DomainId is one of ENSv1DomainId or ENSv2DomainId. + */ +export type DomainId = ENSv1DomainId | ENSv2DomainId; + +/** + * Uniquely identifies a Permissions entity. + */ +export type PermissionsId = AccountIdString & { __brand: "PermissionsId" }; + +/** + * Uniquely identifies a PermissionsResource entity. + */ +export type PermissionsResourceId = string & { __brand: "PermissionsResourceId" }; + +/** + * Uniquely identifies a PermissionsUser entity. + */ +export type PermissionsUserId = string & { __brand: "PermissionsUserId" }; + +/** + * Uniquely identifies a Resolver entity. + */ +export type ResolverId = AccountIdString & { __brand: "ResolverId" }; + +/** + * Uniquely identifies a ResolverRecords entity. + */ +export type ResolverRecordsId = string & { __brand: "ResolverRecordsId" }; + +/** + * Uniquely identifies a Registration entity. + */ +export type RegistrationId = string & { __brand: "RegistrationId" }; + +/** + * Uniquely identifies a Renewal entity. + */ +export type RenewalId = string & { __brand: "RenewalId" }; diff --git a/packages/ensnode-sdk/src/ensv2/index.ts b/packages/ensnode-sdk/src/ensv2/index.ts new file mode 100644 index 000000000..2e08fd50b --- /dev/null +++ b/packages/ensnode-sdk/src/ensv2/index.ts @@ -0,0 +1,2 @@ +export * from "./ids"; +export * from "./ids-lib"; diff --git a/packages/ensnode-sdk/src/index.ts b/packages/ensnode-sdk/src/index.ts index f5bb5ab8a..2146d2147 100644 --- a/packages/ensnode-sdk/src/index.ts +++ b/packages/ensnode-sdk/src/index.ts @@ -6,6 +6,7 @@ export * from "./ensanalytics"; export * from "./ensapi"; export * from "./ensindexer"; export * from "./ensrainbow"; +export * from "./ensv2"; export * from "./identity"; export * from "./registrars"; export * from "./resolution"; diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 8ed2b332a..95eec93ca 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -22,6 +22,7 @@ export * from "./ensapi/config/zod-schemas"; export * from "./ensindexer/config/zod-schemas"; export * from "./ensindexer/indexing-status/zod-schemas"; export * from "./registrars/zod-schemas"; +export * from "./rpc"; export * from "./shared/config/build-rpc-urls"; export * from "./shared/config/environments"; export * from "./shared/config/pretty-printing"; @@ -33,8 +34,12 @@ export * from "./shared/config/validatons"; export * from "./shared/config/zod-schemas"; export * from "./shared/config-templates"; export * from "./shared/datasources-with-resolvers"; +export * from "./shared/interpretation/interpret-record-values"; export * from "./shared/log-level"; -export * from "./shared/protocol-acceleration/interpret-record-values"; +export * from "./shared/protocol-acceleration/is-bridged-resolver"; +export * from "./shared/protocol-acceleration/is-ensip-19-reverse-resolver"; +export * from "./shared/protocol-acceleration/is-static-resolver"; export * from "./shared/thegraph"; export * from "./shared/zod-schemas"; +export * from "./shared/zod-types"; export * from "./tokenscope/zod-schemas"; diff --git a/packages/ensnode-sdk/src/registrars/basenames-subregistry.ts b/packages/ensnode-sdk/src/registrars/basenames-subregistry.ts index 2d1e87abf..53522de22 100644 --- a/packages/ensnode-sdk/src/registrars/basenames-subregistry.ts +++ b/packages/ensnode-sdk/src/registrars/basenames-subregistry.ts @@ -46,7 +46,6 @@ export function getBasenamesSubregistryManagedName(namespaceId: ENSNamespaceId): return "base.eth"; case ENSNamespaceIds.Sepolia: return "basetest.eth"; - case ENSNamespaceIds.Holesky: case ENSNamespaceIds.EnsTestEnv: throw new Error( `No registrar managed name is known for the 'basenames' subregistry within the "${namespaceId}" namespace.`, diff --git a/packages/ensnode-sdk/src/registrars/ethnames-subregistry.ts b/packages/ensnode-sdk/src/registrars/ethnames-subregistry.ts index 6952b6c00..c084e4acf 100644 --- a/packages/ensnode-sdk/src/registrars/ethnames-subregistry.ts +++ b/packages/ensnode-sdk/src/registrars/ethnames-subregistry.ts @@ -43,7 +43,6 @@ export function getEthnamesSubregistryManagedName(namespaceId: ENSNamespaceId): switch (namespaceId) { case ENSNamespaceIds.Mainnet: case ENSNamespaceIds.Sepolia: - case ENSNamespaceIds.Holesky: case ENSNamespaceIds.EnsTestEnv: return "eth"; } diff --git a/packages/ensnode-sdk/src/registrars/index.ts b/packages/ensnode-sdk/src/registrars/index.ts index 98bc78759..b9500096c 100644 --- a/packages/ensnode-sdk/src/registrars/index.ts +++ b/packages/ensnode-sdk/src/registrars/index.ts @@ -2,5 +2,6 @@ export * from "./basenames-subregistry"; export * from "./ethnames-subregistry"; export * from "./lineanames-subregistry"; export * from "./registrar-action"; +export * from "./registration-expiration"; export * from "./registration-lifecycle"; export * from "./subregistry"; diff --git a/packages/ensnode-sdk/src/registrars/lineanames-subregistry.ts b/packages/ensnode-sdk/src/registrars/lineanames-subregistry.ts index f50c982ef..520ada4a1 100644 --- a/packages/ensnode-sdk/src/registrars/lineanames-subregistry.ts +++ b/packages/ensnode-sdk/src/registrars/lineanames-subregistry.ts @@ -46,7 +46,6 @@ export function getLineanamesSubregistryManagedName(namespaceId: ENSNamespaceId) return "linea.eth"; case ENSNamespaceIds.Sepolia: return "linea-sepolia.eth"; - case ENSNamespaceIds.Holesky: case ENSNamespaceIds.EnsTestEnv: throw new Error( `No registrar managed name is known for the 'Lineanames' subregistry within the "${namespaceId}" namespace.`, diff --git a/packages/ensnode-sdk/src/registrars/registration-expiration.ts b/packages/ensnode-sdk/src/registrars/registration-expiration.ts new file mode 100644 index 000000000..b81e7d696 --- /dev/null +++ b/packages/ensnode-sdk/src/registrars/registration-expiration.ts @@ -0,0 +1,38 @@ +export interface RegistrationExpiryInfo { + expiry: bigint | null; + gracePeriod: bigint | null; +} + +/** + * Returns whether Registration is expired. If the Registration includes a Grace Period, the + * Grace Period window is considered expired. + */ +export function isRegistrationExpired(info: RegistrationExpiryInfo, now: bigint): boolean { + // no expiry, never expired + if (info.expiry == null) return false; + + // otherwise check against now + return info.expiry <= now; +} + +/** + * Returns whether Registration is fully expired. If the Registration includes a Grace Period, the + * Grace Period window is considered NOT fully-expired. + */ +export function isRegistrationFullyExpired(info: RegistrationExpiryInfo, now: bigint): boolean { + // no expiry, never expired + if (info.expiry == null) return false; + + // otherwise it is expired if now >= expiry + grace + return now >= info.expiry + (info.gracePeriod ?? 0n); +} + +/** + * Returns whether Registration is in grace period. + */ +export function isRegistrationInGracePeriod(info: RegistrationExpiryInfo, now: bigint): boolean { + if (info.expiry == null) return false; + if (info.gracePeriod == null) return false; + + return info.expiry <= now && info.expiry + info.gracePeriod > now; +} diff --git a/packages/ensnode-sdk/src/registrars/zod-schemas.ts b/packages/ensnode-sdk/src/registrars/zod-schemas.ts index 8b9b2e959..b67615404 100644 --- a/packages/ensnode-sdk/src/registrars/zod-schemas.ts +++ b/packages/ensnode-sdk/src/registrars/zod-schemas.ts @@ -1,6 +1,6 @@ import { decodeEncodedReferrer, ENCODED_REFERRER_BYTE_LENGTH } from "@namehash/ens-referrals"; import type { Address } from "viem"; -import z from "zod/v4"; +import { z } from "zod/v4"; import type { ParsePayload } from "zod/v4/core"; import { addPrices, isPriceEqual } from "../shared"; diff --git a/apps/ensapi/src/lib/rpc/eip-165.ts b/packages/ensnode-sdk/src/rpc/eip-165.ts similarity index 61% rename from apps/ensapi/src/lib/rpc/eip-165.ts rename to packages/ensnode-sdk/src/rpc/eip-165.ts index 7d4a01482..1b9142c87 100644 --- a/apps/ensapi/src/lib/rpc/eip-165.ts +++ b/packages/ensnode-sdk/src/rpc/eip-165.ts @@ -1,4 +1,4 @@ -import type { Address, Hex, PublicClient } from "viem"; +import type { Address, Hex } from "viem"; /** * EIP-165 ABI @@ -28,15 +28,26 @@ const EIP_165_ABI = [ /** * Determines whether a Contract at `address` supports a specific EIP-165 `interfaceId`. + * + * Accepts both viem PublicClient and Ponder Context client types. */ -export async function supportsInterface({ +async function supportsInterface< + TClient extends { + readContract: (params: { + abi: typeof EIP_165_ABI; + functionName: "supportsInterface"; + address: Address; + args: readonly [Hex]; + }) => Promise; + }, +>({ publicClient, interfaceId: selector, address, }: { address: Address; interfaceId: Hex; - publicClient: PublicClient; + publicClient: TClient; }) { try { return await publicClient.readContract({ @@ -50,3 +61,10 @@ export async function supportsInterface({ return false; } } + +export const makeSupportsInterfaceReader = + (interfaceId: Hex) => (args: Omit[0], "interfaceId">) => + supportsInterface({ + ...args, + interfaceId, + }); diff --git a/packages/ensnode-sdk/src/rpc/index.ts b/packages/ensnode-sdk/src/rpc/index.ts new file mode 100644 index 000000000..157a5f395 --- /dev/null +++ b/packages/ensnode-sdk/src/rpc/index.ts @@ -0,0 +1,3 @@ +export * from "./eip-165"; +export * from "./is-dedicated-resolver"; +export * from "./is-extended-resolver"; diff --git a/packages/ensnode-sdk/src/rpc/is-dedicated-resolver.ts b/packages/ensnode-sdk/src/rpc/is-dedicated-resolver.ts new file mode 100644 index 000000000..fe52179c5 --- /dev/null +++ b/packages/ensnode-sdk/src/rpc/is-dedicated-resolver.ts @@ -0,0 +1,12 @@ +import { makeSupportsInterfaceReader } from "./eip-165"; + +/** + * DedicatedResolver InterfaceId + * @see https://github.com/ensdomains/namechain/blob/main/contracts/src/common/resolver/interfaces/IDedicatedResolverSetters.sol#L9 + */ +const IDedicatedResolverInterfaceId = "0x92349baa"; + +/** + * Determines whether a Resolver contract supports ENSIP-10. + */ +export const isDedicatedResolver = makeSupportsInterfaceReader(IDedicatedResolverInterfaceId); diff --git a/packages/ensnode-sdk/src/rpc/is-extended-resolver.ts b/packages/ensnode-sdk/src/rpc/is-extended-resolver.ts new file mode 100644 index 000000000..40e54f54a --- /dev/null +++ b/packages/ensnode-sdk/src/rpc/is-extended-resolver.ts @@ -0,0 +1,12 @@ +import { makeSupportsInterfaceReader } from "./eip-165"; + +/** + * ENSIP-10 Wildcard Resolution Interface Id + * @see https://docs.ens.domains/ensip/10 + */ +const IExtendedResolverInterfaceId = "0x9061b923"; + +/** + * Determines whether a Resolver contract supports ENSIP-10. + */ +export const isExtendedResolver = makeSupportsInterfaceReader(IExtendedResolverInterfaceId); diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts index f3d2040e8..9fedb710a 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SWRCache } from "./swr-cache"; -describe("staleWhileRevalidate", () => { +describe("SWRCache", () => { beforeEach(() => { - vi.useFakeTimers(); + vi.useFakeTimers({ shouldAdvanceTime: true, now: new Date(2024, 0, 1) }); }); afterEach(() => { diff --git a/packages/ensnode-sdk/src/shared/cache/ttl-cache.test.ts b/packages/ensnode-sdk/src/shared/cache/ttl-cache.test.ts index 4e717a2f7..1cc3a0bf7 100644 --- a/packages/ensnode-sdk/src/shared/cache/ttl-cache.test.ts +++ b/packages/ensnode-sdk/src/shared/cache/ttl-cache.test.ts @@ -4,7 +4,7 @@ import { TtlCache } from "./ttl-cache"; describe("TtlCache", () => { beforeEach(() => { - vi.useFakeTimers(); + vi.useFakeTimers({ shouldAdvanceTime: true, now: new Date(2024, 0, 1) }); }); afterEach(() => { diff --git a/packages/ensnode-sdk/src/shared/config-templates.ts b/packages/ensnode-sdk/src/shared/config-templates.ts index 7ef62bbd4..ce91126ed 100644 --- a/packages/ensnode-sdk/src/shared/config-templates.ts +++ b/packages/ensnode-sdk/src/shared/config-templates.ts @@ -9,7 +9,6 @@ import { ENSNamespaceIds } from "@ensnode/datasources"; export const ConfigTemplateIds = { Mainnet: "mainnet", Sepolia: "sepolia", - Holesky: "holesky", Alpha: "alpha", AlphaSepolia: "alpha-sepolia", }; @@ -28,7 +27,6 @@ export function isConfigTemplateSubgraphCompatible(configTemplateId: ConfigTempl // these ConfigTemplates are run with SUBGRAPH_COMPAT, meaning they are Subgraph Compatible case ConfigTemplateIds.Mainnet: case ConfigTemplateIds.Sepolia: - case ConfigTemplateIds.Holesky: return true; // these instances are NOT run with SUBGRAPH_COMPAT, meaning they are NOT Subgraph Compatible @@ -53,8 +51,6 @@ export function namespaceForConfigTemplateId(configTemplateId: ConfigTemplateId) case ConfigTemplateIds.AlphaSepolia: case ConfigTemplateIds.Sepolia: return ENSNamespaceIds.Sepolia; - case ConfigTemplateIds.Holesky: - return ENSNamespaceIds.Holesky; default: throw new Error("never"); } diff --git a/packages/ensnode-sdk/src/shared/config/build-rpc-urls.ts b/packages/ensnode-sdk/src/shared/config/build-rpc-urls.ts index 1b1dab616..0bf9bec70 100644 --- a/packages/ensnode-sdk/src/shared/config/build-rpc-urls.ts +++ b/packages/ensnode-sdk/src/shared/config/build-rpc-urls.ts @@ -3,7 +3,6 @@ import { arbitrumSepolia, base, baseSepolia, - holesky, linea, lineaSepolia, mainnet, @@ -35,8 +34,6 @@ export function buildAlchemyBaseUrl(chainId: ChainId, key: string): string | und return `eth-mainnet.g.alchemy.com/v2/${key}`; case sepolia.id: return `eth-sepolia.g.alchemy.com/v2/${key}`; - case holesky.id: - return `eth-holesky.g.alchemy.com/v2/${key}`; case arbitrum.id: return `arb-mainnet.g.alchemy.com/v2/${key}`; case arbitrumSepolia.id: @@ -81,8 +78,6 @@ export function buildDRPCUrl(chainId: ChainId, key: string): string | undefined return `https://lb.drpc.live/ethereum/${key}`; case sepolia.id: return `https://lb.drpc.live/ethereum-sepolia/${key}`; - case holesky.id: - return `https://lb.drpc.live/holesky/${key}`; case arbitrum.id: return `https://lb.drpc.live/arbitrum/${key}`; case arbitrumSepolia.id: @@ -138,8 +133,6 @@ export function buildQuickNodeURL( return `${endpointName}.quiknode.pro/${apiKey}`; case sepolia.id: return `${endpointName}.ethereum-sepolia.quiknode.pro/${apiKey}`; - case holesky.id: - return `${endpointName}.ethereum-holesky.quiknode.pro/${apiKey}`; case arbitrum.id: return `${endpointName}.arbitrum-mainnet.quiknode.pro/${apiKey}`; case arbitrumSepolia.id: diff --git a/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts b/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts index fabecebe6..8314d0ab3 100644 --- a/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts +++ b/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts @@ -1,4 +1,10 @@ -import { type Datasource, type ENSNamespaceId, getENSNamespace } from "@ensnode/datasources"; +import { + type Datasource, + type ENSNamespaceId, + ensTestEnvL1Chain, + ensTestEnvL2Chain, + getENSNamespace, +} from "@ensnode/datasources"; import { serializeChainId } from "../serialize"; import type { ChainIdString } from "../serialized-types"; @@ -23,7 +29,7 @@ import type { ChainIdSpecificRpcEnvironmentVariable, RpcEnvironment } from "./en * a QuickNode RPC URL will be provided for each of the chains it supports. * 4. DRPC, if DRPC_API_KEY is available in the env * - * TODO: also inject wss:// urls for alchemy, dRPC keys + * It also provides a single Alchemy wss:// url if ALCHEMY_API_KEY is available in the env. * * NOTE: This function returns raw RpcConfigEnvironment values which are not yet parsed or validated. * @@ -68,6 +74,20 @@ export function buildRpcConfigsFromEnv( continue; } + // ens-test-env L1 Chain + if (chain.id === ensTestEnvL1Chain.id) { + rpcConfigs[serializeChainId(ensTestEnvL1Chain.id)] = + ensTestEnvL1Chain.rpcUrls.default.http[0]; + continue; + } + + // ens-test-env L2 Chain + if (chain.id === ensTestEnvL2Chain.id) { + rpcConfigs[serializeChainId(ensTestEnvL2Chain.id)] = + ensTestEnvL2Chain.rpcUrls.default.http[0]; + continue; + } + const httpUrls = [ // alchemy, if specified and available alchemyApiKey && diff --git a/packages/ensnode-sdk/src/shared/config/validatons.ts b/packages/ensnode-sdk/src/shared/config/validatons.ts index 25e798236..c12805617 100644 --- a/packages/ensnode-sdk/src/shared/config/validatons.ts +++ b/packages/ensnode-sdk/src/shared/config/validatons.ts @@ -3,7 +3,7 @@ import type { z } from "zod/v4"; import { getENSRootChainId } from "@ensnode/datasources"; import { isHttpProtocol, isWebSocketProtocol } from "../url"; -import type { ZodCheckFnInput } from "../zod-schemas"; +import type { ZodCheckFnInput } from "../zod-types"; import type { ENSNamespaceSchema, RpcConfigsSchema } from "./zod-schemas"; /** diff --git a/packages/ensnode-sdk/src/shared/datasource-contract.ts b/packages/ensnode-sdk/src/shared/datasource-contract.ts index 1902ebeda..9f2355db1 100644 --- a/packages/ensnode-sdk/src/shared/datasource-contract.ts +++ b/packages/ensnode-sdk/src/shared/datasource-contract.ts @@ -1,5 +1,10 @@ -import { type DatasourceName, type ENSNamespaceId, maybeGetDatasource } from "@ensnode/datasources"; -import type { AccountId } from "@ensnode/ensnode-sdk"; +import { + type Datasource, + type DatasourceName, + type ENSNamespaceId, + maybeGetDatasource, +} from "@ensnode/datasources"; +import { type AccountId, accountIdEqual } from "@ensnode/ensnode-sdk"; /** * Gets the AccountId for the contract in the specified namespace, datasource, and @@ -8,19 +13,22 @@ import type { AccountId } from "@ensnode/ensnode-sdk"; * This is useful when you want to retrieve the AccountId for a contract by its name * where it may or may not actually be defined for the given namespace and datasource. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', - * 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param datasourceName - The name of the Datasource to search for contractName in * @param contractName - The name of the contract to retrieve * @returns The AccountId of the contract with the given namespace, datasource, * and contract name, or undefined if it is not found or is not a single AccountId */ -export const maybeGetDatasourceContract = ( - namespaceId: ENSNamespaceId, - datasourceName: DatasourceName, - contractName: string, +export const maybeGetDatasourceContract = < + N extends ENSNamespaceId, + D extends DatasourceName, + C extends string, +>( + namespaceId: N, + datasourceName: D, + contractName: C, ): AccountId | undefined => { - const datasource = maybeGetDatasource(namespaceId, datasourceName); + const datasource = maybeGetDatasource(namespaceId, datasourceName) as Datasource | undefined; if (!datasource) return undefined; const address = datasource.contracts[contractName]?.address; @@ -36,8 +44,7 @@ export const maybeGetDatasourceContract = ( * Gets the AccountId for the contract in the specified namespace, datasource, and * contract name, or throws an error if it is not defined or is not a single AccountId. * - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', - * 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param datasourceName - The name of the Datasource to search for contractName in * @param contractName - The name of the contract to retrieve * @returns The AccountId of the contract with the given namespace, datasource, @@ -57,3 +64,13 @@ export const getDatasourceContract = ( } return contract; }; + +/** + * Makes a comparator fn for `b` against the contract described by `namespace`, `datasourceName`, and `contractName`. + */ +export const makeContractMatcher = + (namespace: ENSNamespaceId, b: AccountId) => + (datasourceName: DatasourceName, contractName: string) => { + const a = maybeGetDatasourceContract(namespace, datasourceName, contractName); + return a && accountIdEqual(a, b); + }; diff --git a/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts b/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts index d152964bb..1b316289a 100644 --- a/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts +++ b/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts @@ -15,6 +15,7 @@ export const DATASOURCE_NAMES_WITH_RESOLVERS = [ DatasourceNames.Lineanames, DatasourceNames.ThreeDNSOptimism, DatasourceNames.ThreeDNSBase, + DatasourceNames.Namechain, ] as const satisfies DatasourceName[]; /** @@ -28,7 +29,7 @@ export const getDatasourcesWithResolvers = ( maybeGetDatasource(namespace, datasourceName), ) .filter((datasource) => !!datasource) - .filter((datasource): datasource is DatasourceWithResolverContract => { + .filter((datasource) => { // all of the relevant datasources provide a Resolver ContractConfig with a `startBlock` if (!datasource.contracts.Resolver) { console.warn( diff --git a/packages/ensnode-sdk/src/shared/index.ts b/packages/ensnode-sdk/src/shared/index.ts index 1391f21d0..e628311f5 100644 --- a/packages/ensnode-sdk/src/shared/index.ts +++ b/packages/ensnode-sdk/src/shared/index.ts @@ -10,6 +10,7 @@ export * from "./interpretation"; export * from "./labelhash"; export * from "./null-bytes"; export * from "./numbers"; +export * from "./root-registry"; export * from "./serialize"; export * from "./serialized-types"; export * from "./types"; diff --git a/packages/ensnode-sdk/src/shared/interpretation/index.ts b/packages/ensnode-sdk/src/shared/interpretation/index.ts new file mode 100644 index 000000000..1741768c1 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/interpretation/index.ts @@ -0,0 +1,3 @@ +export * from "./interpret-address"; +export * from "./interpret-record-values"; +export * from "./interpreted-names-and-labels"; diff --git a/packages/ensnode-sdk/src/shared/interpretation/interpret-address.ts b/packages/ensnode-sdk/src/shared/interpretation/interpret-address.ts new file mode 100644 index 000000000..f9d8a17bf --- /dev/null +++ b/packages/ensnode-sdk/src/shared/interpretation/interpret-address.ts @@ -0,0 +1,7 @@ +import { type Address, isAddressEqual, zeroAddress } from "viem"; + +/** + * Interprets a viem#Address. zeroAddress is interpreted as null, otherwise Address. + */ +export const interpretAddress = (owner: Address) => + isAddressEqual(zeroAddress, owner) ? null : owner; diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/interpret-record-values.test.ts b/packages/ensnode-sdk/src/shared/interpretation/interpret-record-values.test.ts similarity index 100% rename from packages/ensnode-sdk/src/shared/protocol-acceleration/interpret-record-values.test.ts rename to packages/ensnode-sdk/src/shared/interpretation/interpret-record-values.test.ts diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/interpret-record-values.ts b/packages/ensnode-sdk/src/shared/interpretation/interpret-record-values.ts similarity index 100% rename from packages/ensnode-sdk/src/shared/protocol-acceleration/interpret-record-values.ts rename to packages/ensnode-sdk/src/shared/interpretation/interpret-record-values.ts diff --git a/packages/ensnode-sdk/src/shared/interpretation.test.ts b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts similarity index 96% rename from packages/ensnode-sdk/src/shared/interpretation.test.ts rename to packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts index 429335041..3c4e2bfda 100644 --- a/packages/ensnode-sdk/src/shared/interpretation.test.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { encodeLabelHash, type InterpretedLabel, type LiteralLabel } from "../ens"; +import { encodeLabelHash, type InterpretedLabel, type LiteralLabel } from "../../ens"; +import { labelhashLiteralLabel } from "../labelhash"; import { interpretedLabelsToInterpretedName, literalLabelsToInterpretedName, literalLabelToInterpretedLabel, -} from "./interpretation"; -import { labelhashLiteralLabel } from "./labelhash"; +} from "./interpreted-names-and-labels"; const ENCODED_LABELHASH_LABEL = /^\[[\da-f]{64}\]$/; diff --git a/packages/ensnode-sdk/src/shared/interpretation.ts b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts similarity index 57% rename from packages/ensnode-sdk/src/shared/interpretation.ts rename to packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts index 1f3d95269..dada395d9 100644 --- a/packages/ensnode-sdk/src/shared/interpretation.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.ts @@ -1,13 +1,19 @@ +import { isHex } from "viem"; +import { labelhash } from "viem/ens"; + import { encodeLabelHash, type InterpretedLabel, type InterpretedName, isNormalizedLabel, type Label, + type LabelHash, + type LabelHashPath, type LiteralLabel, type LiteralName, -} from "../ens"; -import { labelhashLiteralLabel } from "./labelhash"; + type Name, +} from "../../ens"; +import { labelhashLiteralLabel } from "../labelhash"; /** * Interprets a Literal Label, producing an Interpreted Label. @@ -66,3 +72,57 @@ export function interpretedLabelsToInterpretedName(labels: InterpretedLabel[]): export function literalLabelsToLiteralName(labels: LiteralLabel[]): LiteralName { return labels.join(".") as LiteralName; } + +/** + * Converts an Interpreted Name into a list of Interpreted Labels. + */ +export function interpretedNameToInterpretedLabels(name: InterpretedName): InterpretedLabel[] { + return name.split(".") as InterpretedLabel[]; +} + +// https://github.com/wevm/viem/blob/main/src/utils/ens/encodedLabelToLabelhash.ts +export function encodedLabelToLabelhash(label: string): LabelHash | null { + if (label.length !== 66) return null; + if (label.indexOf("[") !== 0) return null; + if (label.indexOf("]") !== 65) return null; + const hash = `0x${label.slice(1, 65)}`; + if (!isHex(hash)) return null; + return hash; +} + +export function isInterpetedLabel(label: Label): label is InterpretedLabel { + // if it looks like an encoded labelhash, it must be one + if (label.startsWith("[")) { + const labelHash = encodedLabelToLabelhash(label); + if (labelHash === null) return false; + } + + // otherwise label must be normalized + return isNormalizedLabel(label); +} + +export function isInterpretedName(name: Name): name is InterpretedName { + return name.split(".").every(isInterpetedLabel); +} + +/** + * Converts an InterpretedName into a LabelHashPath. + */ +export function interpretedNameToLabelHashPath(name: InterpretedName): LabelHashPath { + return interpretedNameToInterpretedLabels(name) + .map((label) => { + if (!isInterpetedLabel(label)) { + throw new Error( + `Invariant(interpretedNameToLabelHashPath): Expected InterpretedLabel, received '${label}'.`, + ); + } + + // if it looks like an encoded labelhash, return it + const maybeLabelHash = encodedLabelToLabelhash(label); + if (maybeLabelHash !== null) return maybeLabelHash; + + // otherwise, labelhash it + return labelhash(label); + }) + .toReversed(); +} diff --git a/packages/ensnode-sdk/src/shared/reinterpretation.test.ts b/packages/ensnode-sdk/src/shared/interpretation/reinterpretation.test.ts similarity index 95% rename from packages/ensnode-sdk/src/shared/reinterpretation.test.ts rename to packages/ensnode-sdk/src/shared/interpretation/reinterpretation.test.ts index 93121a079..f2964a577 100644 --- a/packages/ensnode-sdk/src/shared/reinterpretation.test.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/reinterpretation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { InterpretedLabel } from "../ens"; +import type { InterpretedLabel } from "../../ens"; import { reinterpretLabel } from "./reinterpretation"; describe("Reinterpretation", () => { diff --git a/packages/ensnode-sdk/src/shared/reinterpretation.ts b/packages/ensnode-sdk/src/shared/interpretation/reinterpretation.ts similarity index 99% rename from packages/ensnode-sdk/src/shared/reinterpretation.ts rename to packages/ensnode-sdk/src/shared/interpretation/reinterpretation.ts index cc7011b28..7da091f84 100644 --- a/packages/ensnode-sdk/src/shared/reinterpretation.ts +++ b/packages/ensnode-sdk/src/shared/interpretation/reinterpretation.ts @@ -6,7 +6,7 @@ import { type InterpretedName, isEncodedLabelHash, isNormalizedLabel, -} from "../ens"; +} from "../../ens"; /** * Reinterpret Label diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts new file mode 100644 index 000000000..99377cfe5 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts @@ -0,0 +1,51 @@ +import { DatasourceNames } from "@ensnode/datasources"; +import { + type AccountId, + type ENSNamespaceId, + getDatasourceContract, + makeContractMatcher, +} from "@ensnode/ensnode-sdk"; + +/** + * For a given `resolver`, if it is a known Bridged Resolver, return the + * AccountId describing the (shadow)Registry it defers resolution to. + * + * These Bridged Resolvers must abide the following pattern: + * 1. They _always_ emit OffchainLookup for any resolve() call to a well-known CCIP-Read Gateway, + * 2. That CCIP-Read Gateway exclusively consults a specific (shadow)Registry in order to identify + * a name's active resolver and resolve records, and + * 3. Its behavior is unlikely to change (i.e. the contract is not upgradable or is unlikely to be + * upgraded in a way that violates principles 1. or 2.). + * + * The goal is to encode the pattern followed by projects like Basenames and Lineanames where a + * wildcard resolver is used for subnames of base.eth and that L1Resolver always returns OffchainLookup + * instructing the caller to consult a well-known CCIP-Read Gateway. This CCIP-Read Gateway then + * exclusively behaves in the following way: it identifies the name's active resolver via a well-known + * (shadow)Registry (likely on an L2), and resolves records on that active resolver. + * + * In these cases, if the Node-Resolver relationships for the (shadow)Registry in question are indexed, + * then the CCIP-Read can be short-circuited, in favor of performing an _accelerated_ Forward Resolution + * against the (shadow)Registry in question. + * + * TODO: these relationships could/should be encoded in an ENSIP + */ +export function isBridgedResolver( + namespace: ENSNamespaceId, + resolver: AccountId, +): AccountId | null { + const resolverEq = makeContractMatcher(namespace, resolver); + + // the ENSRoot's BasenamesL1Resolver bridges to the Basenames (shadow)Registry + if (resolverEq(DatasourceNames.ENSRoot, "BasenamesL1Resolver")) { + return getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"); + } + + // the ENSRoot's LineanamesL1Resolver bridges to the Lineanames (shadow)Registry + if (resolverEq(DatasourceNames.ENSRoot, "LineanamesL1Resolver")) { + return getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"); + } + + // TODO: ThreeDNS + + return null; +} diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-ensip-19-reverse-resolver.ts b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-ensip-19-reverse-resolver.ts new file mode 100644 index 000000000..57b271884 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-ensip-19-reverse-resolver.ts @@ -0,0 +1,28 @@ +import { DatasourceNames } from "@ensnode/datasources"; +import { type AccountId, type ENSNamespaceId, makeContractMatcher } from "@ensnode/ensnode-sdk"; + +/** + * ENSIP-19 Reverse Resolvers (i.e. DefaultReverseResolver or ChainReverseResolver) simply: + * a. read the Name for their specific coinType from their connected StandaloneReverseRegistry, or + * b. return the default coinType's Name. + * + * We encode this behavior here, for the purposes of Protocol Acceleration. + */ +export function isKnownENSIP19ReverseResolver( + namespace: ENSNamespaceId, + resolver: AccountId, +): boolean { + const resolverEq = makeContractMatcher(namespace, resolver); + + return [ + // DefaultReverseResolver (default.reverse) + resolverEq(DatasourceNames.ReverseResolverRoot, "DefaultReverseResolver3"), + + // the following are each ChainReverseResolver ([coinType].reverse) + resolverEq(DatasourceNames.ReverseResolverRoot, "BaseReverseResolver"), + resolverEq(DatasourceNames.ReverseResolverRoot, "LineaReverseResolver"), + resolverEq(DatasourceNames.ReverseResolverRoot, "OptimismReverseResolver"), + resolverEq(DatasourceNames.ReverseResolverRoot, "ArbitrumReverseResolver"), + resolverEq(DatasourceNames.ReverseResolverRoot, "ScrollReverseResolver"), + ].some(Boolean); +} diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-static-resolver.ts b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-static-resolver.ts new file mode 100644 index 000000000..c1b519b77 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-static-resolver.ts @@ -0,0 +1,50 @@ +import { DatasourceNames } from "@ensnode/datasources"; +import { type AccountId, type ENSNamespaceId, makeContractMatcher } from "@ensnode/ensnode-sdk"; + +/** + * Returns whether `resolver` is an Static Resolver. + * + * Static Resolvers must abide the following pattern: + * 1. All information necessary for resolution is stored on-chain, and + * 2. All resolve() calls resolve to the exact value previously emitted by the Resolver in + * its events (i.e. no post-processing or other logic, a simple return of the on-chain data). + * 2.a the Resolver MAY implement address record defaulting and still be considered Static (see below). + * 3. Its behavior is unlikely to change (i.e. the contract is not upgradable or is unlikely to be + * upgraded in a way that violates principles 1. or 2.). + * + * TODO: these relationships could be encoded in an ENSIP + */ +export function isStaticResolver(namespace: ENSNamespaceId, resolver: AccountId): boolean { + const resolverEq = makeContractMatcher(namespace, resolver); + + return [ + // ENS Root Chain + resolverEq(DatasourceNames.ReverseResolverRoot, "DefaultPublicResolver1"), + resolverEq(DatasourceNames.ReverseResolverRoot, "DefaultPublicResolver2"), + resolverEq(DatasourceNames.ReverseResolverRoot, "DefaultPublicResolver3"), + + // Base Chain + resolverEq(DatasourceNames.Basenames, "L2Resolver1"), + resolverEq(DatasourceNames.Basenames, "L2Resolver2"), + ].some(Boolean); +} + +/** + * Returns whether `resolver` implements address record defaulting. + * + * @see https://docs.ens.domains/ensip/19/#default-address + */ +export function staticResolverImplementsAddressRecordDefaulting( + namespace: ENSNamespaceId, + resolver: AccountId, +): boolean { + const resolverEq = makeContractMatcher(namespace, resolver); + + return [ + // ENS Root Chain + resolverEq(DatasourceNames.ReverseResolverRoot, "DefaultPublicResolver3"), + + // Base Chain + resolverEq(DatasourceNames.Basenames, "L2Resolver2"), + ].some(Boolean); +} diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts new file mode 100644 index 000000000..02cc02434 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -0,0 +1,45 @@ +import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; +import { + type AccountId, + accountIdEqual, + getDatasourceContract, + makeRegistryId, +} from "@ensnode/ensnode-sdk"; + +////////////// +// ENSv1 +////////////// + +/** + * Gets the AccountId representing the ENSv1 Registry in the selected `namespace`. + */ +export const getENSv1Registry = (namespace: ENSNamespaceId) => + getDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); + +/** + * Determines whether `contract` is the ENSv1 Registry in `namespace`. + */ +export const isENSv1Registry = (namespace: ENSNamespaceId, contract: AccountId) => + accountIdEqual(getENSv1Registry(namespace), contract); + +////////////// +// ENSv2 +////////////// + +/** + * Gets the AccountId representing the ENSv2 Root Registry in the selected `namespace`. + */ +export const getENSv2RootRegistry = (namespace: ENSNamespaceId) => + getDatasourceContract(namespace, DatasourceNames.ENSRoot, "RootRegistry"); + +/** + * Gets the RegistryId representing the ENSv2 Root Registry in the selected `namespace`. + */ +export const getENSv2RootRegistryId = (namespace: ENSNamespaceId) => + makeRegistryId(getENSv2RootRegistry(namespace)); + +/** + * Determines whether `contract` is the ENSv2 Root Registry in `namespace`. + */ +export const isENSv2RootRegistry = (namespace: ENSNamespaceId, contract: AccountId) => + accountIdEqual(getENSv2RootRegistry(namespace), contract); diff --git a/packages/ensnode-sdk/src/shared/serialize.ts b/packages/ensnode-sdk/src/shared/serialize.ts index 60b2b8bc5..ae68235f2 100644 --- a/packages/ensnode-sdk/src/shared/serialize.ts +++ b/packages/ensnode-sdk/src/shared/serialize.ts @@ -1,13 +1,15 @@ -import { AccountId as CaipAccountId } from "caip"; +import { AccountId as CaipAccountId, AssetId as CaipAssetId } from "caip"; +import { uint256ToHex32 } from "../ens"; import type { Price, PriceEth, SerializedPrice, SerializedPriceEth } from "./currencies"; import type { AccountIdString, + AssetIdString, ChainIdString, DatetimeISO8601, UrlString, } from "./serialized-types"; -import type { AccountId, ChainId, Datetime } from "./types"; +import type { AccountId, AssetId, ChainId, Datetime } from "./types"; /** * Serializes a {@link ChainId} value into its string representation. @@ -60,3 +62,22 @@ export function formatAccountId(accountId: AccountId): AccountIdString { address: accountId.address, }).toLowerCase(); } + +/** + * Format {@link AssetId} object as a string. + * + * Formatted as a fully lowercase CAIP-19 AssetId. + * + * @see https://chainagnostic.org/CAIPs/caip-19 + */ +export function formatAssetId({ + assetNamespace, + contract: { chainId, address }, + tokenId, +}: AssetId): AssetIdString { + return CaipAssetId.format({ + chainId: { namespace: "eip155", reference: chainId.toString() }, + assetName: { namespace: assetNamespace, reference: address }, + tokenId: uint256ToHex32(tokenId), + }).toLowerCase(); +} diff --git a/packages/ensnode-sdk/src/shared/serialized-types.ts b/packages/ensnode-sdk/src/shared/serialized-types.ts index 5f285a4c4..5128d4778 100644 --- a/packages/ensnode-sdk/src/shared/serialized-types.ts +++ b/packages/ensnode-sdk/src/shared/serialized-types.ts @@ -25,3 +25,14 @@ export type UrlString = string; * @see https://chainagnostic.org/CAIPs/caip-10 */ export type AccountIdString = string; + +/** + * String representation of {@link AssetId}. + * + * Formatted as a fully lowercase CAIP-19 AssetId. + * + * @see https://chainagnostic.org/CAIPs/caip-19 + * @example "eip155:1/erc721:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc" + * for vitalik.eth in the eth base registrar on mainnet. + */ +export type AssetIdString = string; diff --git a/packages/ensnode-sdk/src/shared/thegraph.ts b/packages/ensnode-sdk/src/shared/thegraph.ts index 4671692d8..d9a485bd3 100644 --- a/packages/ensnode-sdk/src/shared/thegraph.ts +++ b/packages/ensnode-sdk/src/shared/thegraph.ts @@ -44,8 +44,6 @@ const makeTheGraphSubgraphUrl = (namespace: ENSNamespaceId, apiKey: string) => { return `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/5XqPmWe6gjyrJtFn9cLy237i4cWw2j9HcUJEXsP5qGtH`; case ENSNamespaceIds.Sepolia: return `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/G1SxZs317YUb9nQX3CC98hDyvxfMJNZH5pPRGpNrtvwN`; - case ENSNamespaceIds.Holesky: - return `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/i5EXyL9MzTXWKCmpJ2LG6sbzBfXneUPVuTXaSjYhDDF`; case ENSNamespaceIds.EnsTestEnv: return null; default: diff --git a/packages/ensnode-sdk/src/shared/types.ts b/packages/ensnode-sdk/src/shared/types.ts index d3b41c7a3..6d3252f63 100644 --- a/packages/ensnode-sdk/src/shared/types.ts +++ b/packages/ensnode-sdk/src/shared/types.ts @@ -32,6 +32,34 @@ export interface AccountId { address: Address; } +/** + * An enum representing the possible CAIP-19 Asset Namespace values. + * + * @see https://chainagnostic.org/CAIPs/caip-19 + */ +export const AssetNamespaces = { + ERC721: "erc721", + ERC1155: "erc1155", +} as const; + +export type AssetNamespace = (typeof AssetNamespaces)[keyof typeof AssetNamespaces]; + +/** + * A uint256 value that identifies a specific NFT within a NFT contract. + */ +export type TokenId = bigint; + +/** + * Represents an Asset in `assetNamespace` by `tokenId` in `contract`. + * + * @see https://chainagnostic.org/CAIPs/caip-19 + */ +export interface AssetId { + assetNamespace: AssetNamespace; + contract: AccountId; + tokenId: TokenId; +} + /** * Block Number * @@ -135,3 +163,10 @@ export type DeepPartial = { ? DeepPartial : T[P]; }; + +/** + * Marks keys in K as required (not undefined) and not null. + */ +export type RequiredAndNotNull = T & { + [P in K]-?: NonNullable; +}; diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index 6a2cfd8ab..ab07a4018 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -9,12 +9,12 @@ import { type Address, type Hex, isAddress, isHex, size } from "viem"; * The only way to share Zod schemas is to re-export them from * `./src/internal.ts` file. */ -import z from "zod/v4"; +import { z } from "zod/v4"; import { ENSNamespaceIds, type InterpretedName, Node } from "../ens"; import { asLowerCaseAddress } from "./address"; import { type CurrencyId, CurrencyIds, Price, type PriceEth } from "./currencies"; -import { reinterpretName } from "./reinterpretation"; +import { reinterpretName } from "./interpretation/reinterpretation"; import type { AccountIdString } from "./serialized-types"; import type { AccountId, @@ -26,11 +26,6 @@ import type { UnixTimestamp, } from "./types"; -/** - * Zod `.check()` function input. - */ -export type ZodCheckFnInput = z.core.ParsePayload; - /** * Parses a string value as a boolean. */ diff --git a/packages/ensnode-sdk/src/shared/zod-types.ts b/packages/ensnode-sdk/src/shared/zod-types.ts new file mode 100644 index 000000000..166c95a89 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/zod-types.ts @@ -0,0 +1,6 @@ +import type { z } from "zod/v4"; + +/** + * Zod `.check()` function input. + */ +export type ZodCheckFnInput = z.core.ParsePayload; diff --git a/packages/ensnode-sdk/src/tokenscope/assets.ts b/packages/ensnode-sdk/src/tokenscope/assets.ts index 8d7bdb62b..47786c91c 100644 --- a/packages/ensnode-sdk/src/tokenscope/assets.ts +++ b/packages/ensnode-sdk/src/tokenscope/assets.ts @@ -1,40 +1,23 @@ -import { AssetId as CaipAssetId } from "caip"; import { type Address, type Hex, isAddressEqual, zeroAddress } from "viem"; import { prettifyError } from "zod/v4"; import { type Node, uint256ToHex32 } from "../ens"; -import type { AccountId, ChainId } from "../shared"; +import { + type AccountId, + type AssetId, + type AssetNamespace, + type ChainId, + formatAssetId, + type TokenId, +} from "../shared"; +import type { AssetIdString } from "../shared/serialized-types"; import { makeAssetIdSchema, makeAssetIdStringSchema } from "./zod-schemas"; -/** - * An enum representing the possible CAIP-19 Asset Namespace values. - */ -export const AssetNamespaces = { - ERC721: "erc721", - ERC1155: "erc1155", -} as const; - -export type AssetNamespace = (typeof AssetNamespaces)[keyof typeof AssetNamespaces]; - -/** - * A uint256 value that identifies a specific NFT within a NFT contract. - */ -export type TokenId = bigint; - /** * Serialized representation of {@link TokenId}. */ export type SerializedTokenId = string; -/** - * A globally unique reference to an NFT. - */ -export interface AssetId { - assetNamespace: AssetNamespace; - contract: AccountId; - tokenId: TokenId; -} - /** * Serialized representation of {@link AssetId}. */ @@ -43,18 +26,7 @@ export interface SerializedAssetId extends Omit { } /** - * String representation of an {@link AssetId}. - * - * Formatted as a fully lowercase CAIP-19 AssetId. - * - * @see https://chainagnostic.org/CAIPs/caip-19 - * @example "eip155:1/erc721:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85/0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc" - * for vitalik.eth in the eth base registrar on mainnet. - */ -export type AssetIdString = string; - -/** - * Serializes {@link AssetId} object. + * Serializes {@link AssetId} object to a structured form. */ export function serializeAssetId(assetId: AssetId): SerializedAssetId { return { @@ -78,19 +50,6 @@ export function deserializeAssetId(maybeAssetId: unknown, valueLabel?: string): return parsed.data; } -/** - * Format {@link AssetId} object as a string. - */ -export function formatAssetId(assetId: AssetId): AssetIdString { - const { assetNamespace, contract, tokenId } = serializeAssetId(assetId); - - return CaipAssetId.format({ - chainId: { namespace: "eip155", reference: contract.chainId.toString() }, - assetName: { namespace: assetNamespace, reference: contract.address }, - tokenId, - }).toLowerCase(); -} - /** * Parse a stringified representation of {@link AssetId} object. */ @@ -182,14 +141,14 @@ export interface NFTTransferEventMetadata { } export const formatNFTTransferEventMetadata = (metadata: NFTTransferEventMetadata): string => { - const serializedAssetId = serializeAssetId(metadata.nft); + const assetIdString = formatAssetId(metadata.nft); return [ `Event: ${metadata.eventHandlerName}`, `Chain ID: ${metadata.chainId}`, `Block Number: ${metadata.blockNumber}`, `Transaction Hash: ${metadata.transactionHash}`, - `NFT: ${serializedAssetId}`, + `NFT: ${assetIdString}`, ] .map((line) => ` - ${line}`) .join("\n"); diff --git a/packages/ensnode-sdk/src/tokenscope/name-token.ts b/packages/ensnode-sdk/src/tokenscope/name-token.ts index 9cd5011fd..937e93e33 100644 --- a/packages/ensnode-sdk/src/tokenscope/name-token.ts +++ b/packages/ensnode-sdk/src/tokenscope/name-token.ts @@ -5,12 +5,8 @@ import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; import { getParentNameFQDN, type InterpretedName } from "../ens"; import { type AccountId, accountIdEqual } from "../shared"; import { getDatasourceContract, maybeGetDatasourceContract } from "../shared/datasource-contract"; -import { - type AssetId, - type NFTMintStatus, - type SerializedAssetId, - serializeAssetId, -} from "./assets"; +import type { AssetId } from "../shared/types"; +import { type NFTMintStatus, type SerializedAssetId, serializeAssetId } from "./assets"; /** * An enum representing the possible Name Token Ownership types. diff --git a/packages/ensnode-sdk/src/tokenscope/zod-schemas.test.ts b/packages/ensnode-sdk/src/tokenscope/zod-schemas.test.ts index 56eeb7032..409c779e3 100644 --- a/packages/ensnode-sdk/src/tokenscope/zod-schemas.test.ts +++ b/packages/ensnode-sdk/src/tokenscope/zod-schemas.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; -import { type AssetId, type AssetIdString, serializeAssetId } from "./assets"; +import type { AssetIdString } from "../shared/serialized-types"; +import type { AssetId } from "../shared/types"; +import { serializeAssetId } from "./assets"; import { makeAssetIdSchema, makeAssetIdStringSchema } from "./zod-schemas"; describe("Tokenscope: Zod Schemas", () => { diff --git a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts index 8d866e6ab..f17903841 100644 --- a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts +++ b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts @@ -3,14 +3,9 @@ import { zeroAddress } from "viem"; import z from "zod/v4"; import type { ParsePayload } from "zod/v4/core"; +import { type AssetId, AssetNamespaces } from "../shared/types"; import { makeAccountIdSchema, makeNodeSchema } from "../shared/zod-schemas"; -import { - type AssetId, - AssetNamespaces, - type DomainAssetId, - NFTMintStatuses, - type SerializedAssetId, -} from "./assets"; +import { type DomainAssetId, NFTMintStatuses, type SerializedAssetId } from "./assets"; import { type NameToken, type NameTokenOwnershipBurned, diff --git a/packages/ponder-subgraph/package.json b/packages/ponder-subgraph/package.json index 22cdae851..c9787cfc2 100644 --- a/packages/ponder-subgraph/package.json +++ b/packages/ponder-subgraph/package.json @@ -42,8 +42,9 @@ "@escape.tech/graphql-armor-max-aliases": "^2.6.2", "@escape.tech/graphql-armor-max-depth": "^2.4.2", "@escape.tech/graphql-armor-max-tokens": "^2.5.1", + "@ponder/client": "^0.14.13", "dataloader": "^2.2.3", - "drizzle-orm": "catalog:", + "drizzle-orm": "0.41.0", "graphql": "^16.10.0", "graphql-scalars": "^1.24.0", "graphql-yoga": "^5.10.9" diff --git a/packages/ponder-subgraph/src/drizzle.ts b/packages/ponder-subgraph/src/drizzle.ts new file mode 100644 index 000000000..60146b73e --- /dev/null +++ b/packages/ponder-subgraph/src/drizzle.ts @@ -0,0 +1,22 @@ +import { setDatabaseSchema } from "@ponder/client"; +import { drizzle } from "drizzle-orm/node-postgres"; + +import type { Schema } from "./types"; + +/** + * Makes a Drizzle DB object. + */ +export const makeDrizzle = ({ + schema, + databaseUrl, + databaseSchema, +}: { + schema: SCHEMA; + databaseUrl: string; + databaseSchema: string; +}) => { + // monkeypatch schema onto tables + setDatabaseSchema(schema, databaseSchema); + + return drizzle(databaseUrl, { schema, casing: "snake_case" }); +}; diff --git a/packages/ponder-subgraph/src/graphql.ts b/packages/ponder-subgraph/src/graphql.ts index 539956a68..6d3ed1c77 100644 --- a/packages/ponder-subgraph/src/graphql.ts +++ b/packages/ponder-subgraph/src/graphql.ts @@ -153,7 +153,7 @@ export interface PolymorphicConfig { fields: Record; } -interface BuildGraphQLSchemaOptions { +export interface BuildGraphQLSchemaOptions { schema: Schema; polymorphicConfig?: PolymorphicConfig; } diff --git a/packages/ponder-subgraph/src/index.ts b/packages/ponder-subgraph/src/index.ts index 75fd0f39d..4d253c99c 100644 --- a/packages/ponder-subgraph/src/index.ts +++ b/packages/ponder-subgraph/src/index.ts @@ -1,3 +1,2 @@ -export { buildGraphQLSchema } from "./graphql"; export * from "./middleware"; export * from "./types"; diff --git a/packages/ponder-subgraph/src/middleware.ts b/packages/ponder-subgraph/src/middleware.ts index 66d14928c..6e1122abe 100644 --- a/packages/ponder-subgraph/src/middleware.ts +++ b/packages/ponder-subgraph/src/middleware.ts @@ -10,21 +10,23 @@ import { maxAliasesPlugin } from "@escape.tech/graphql-armor-max-aliases"; import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth"; import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens"; -import type { GraphQLSchema } from "graphql"; import { createYoga } from "graphql-yoga"; import { createMiddleware } from "hono/factory"; -import { buildDataLoaderCache } from "./graphql"; -import type { Drizzle } from "./types"; +import { makeDrizzle } from "./drizzle"; +import { + type BuildGraphQLSchemaOptions, + buildDataLoaderCache, + buildGraphQLSchema, +} from "./graphql"; export function subgraphGraphQLMiddleware( { - drizzle, - graphqlSchema, - }: { - drizzle: Drizzle; - graphqlSchema: GraphQLSchema; - }, + databaseUrl, + databaseSchema, + schema, + polymorphicConfig, + }: BuildGraphQLSchemaOptions & { databaseUrl: string; databaseSchema: string }, { maxOperationTokens = 1000, maxOperationDepth = 100, @@ -41,6 +43,11 @@ export function subgraphGraphQLMiddleware( maxOperationAliases: 30, }, ) { + // make subgraph-specific drizzle db + const drizzle = makeDrizzle({ schema, databaseUrl, databaseSchema }); + + const graphqlSchema = buildGraphQLSchema({ schema, polymorphicConfig }); + const yoga = createYoga({ graphqlEndpoint: "*", // Disable built-in route validation, use Hono routing instead schema: graphqlSchema, diff --git a/packages/shared-configs/tsconfig.ponder.json b/packages/shared-configs/tsconfig.ponder.json index af74c0f2a..5a554f22e 100644 --- a/packages/shared-configs/tsconfig.ponder.json +++ b/packages/shared-configs/tsconfig.ponder.json @@ -10,8 +10,8 @@ "moduleResolution": "bundler", "module": "ESNext", "noEmit": true, - "lib": ["ES2022"], - "target": "ES2022", + "lib": ["ESNext"], + "target": "ESNext", "skipLibCheck": true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a91677811..32c1737de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ catalogs: specifier: 0.12.0 version: 0.12.0 '@ponder/utils': - specifier: 0.2.14 - version: 0.2.14 + specifier: 0.2.16 + version: 0.2.16 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0 @@ -40,7 +40,7 @@ catalogs: specifier: 4.1.0 version: 4.1.0 drizzle-orm: - specifier: '=0.41.0' + specifier: 0.41.0 version: 0.41.0 hono: specifier: ^4.10.2 @@ -52,8 +52,8 @@ catalogs: specifier: 10.1.0 version: 10.1.0 ponder: - specifier: 0.13.16 - version: 0.13.16 + specifier: 0.15.16 + version: 0.15.16 tsup: specifier: ^8.3.6 version: 8.5.0 @@ -149,7 +149,7 @@ importers: version: 13.8.0(react@19.2.1) '@ponder/utils': specifier: 'catalog:' - version: 0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) + version: 0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -334,18 +334,39 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.34.0 version: 1.37.0 + '@ponder/client': + specifier: ^0.14.13 + version: 0.14.13(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3)(typescript@5.9.3) '@ponder/utils': specifier: 'catalog:' - version: 0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) + version: 0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) + '@pothos/core': + specifier: ^4.10.0 + version: 4.10.0(graphql@16.11.0) + '@pothos/plugin-dataloader': + specifier: ^4.4.3 + version: 4.4.3(@pothos/core@4.10.0(graphql@16.11.0))(dataloader@2.2.3)(graphql@16.11.0) + '@pothos/plugin-relay': + specifier: ^4.6.2 + version: 4.6.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) '@standard-schema/utils': specifier: ^0.3.0 version: 0.3.0 + dataloader: + specifier: ^2.2.3 + version: 2.2.3 date-fns: specifier: 'catalog:' version: 4.1.0 drizzle-orm: specifier: 'catalog:' version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3) + graphql: + specifier: ^16.11.0 + version: 16.11.0 + graphql-yoga: + specifier: ^5.16.0 + version: 5.16.0(graphql@16.11.0) hono: specifier: 'catalog:' version: 4.10.3 @@ -433,7 +454,7 @@ importers: version: 2.9.1 ponder: specifier: 'catalog:' - version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) + version: 0.15.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@3.25.76) @@ -699,7 +720,7 @@ importers: dependencies: '@ponder/utils': specifier: 'catalog:' - version: 0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) + version: 0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) devDependencies: '@ensnode/shared-configs': specifier: workspace:* @@ -779,11 +800,14 @@ importers: dependencies: ponder: specifier: 'catalog:' - version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.4)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) + version: 0.15.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.4)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@3.25.76) devDependencies: + '@ensnode/ensnode-sdk': + specifier: 'workspace:' + version: link:../ensnode-sdk '@ensnode/shared-configs': specifier: workspace:* version: link:../shared-configs @@ -885,7 +909,7 @@ importers: version: 4.10.3 ponder: specifier: 'catalog:' - version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) + version: 0.15.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) tsup: specifier: 'catalog:' version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -907,11 +931,14 @@ importers: '@escape.tech/graphql-armor-max-tokens': specifier: ^2.5.1 version: 2.5.1 + '@ponder/client': + specifier: ^0.14.13 + version: 0.14.13(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3)(typescript@5.9.3) dataloader: specifier: ^2.2.3 version: 2.2.3 drizzle-orm: - specifier: 'catalog:' + specifier: 0.41.0 version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3) graphql: specifier: ^16.10.0 @@ -1928,6 +1955,12 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@graphql-tools/executor@1.5.0': + resolution: {integrity: sha512-3HzAxfexmynEWwRB56t/BT+xYKEYLGPvJudR1jfs+XZX8bpfqujEhqVFoxmkpEE8BbFcKuBNoQyGkTi1eFJ+hA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@graphql-tools/merge@9.1.1': resolution: {integrity: sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==} engines: {node: '>=16.0.0'} @@ -1940,6 +1973,12 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@graphql-tools/utils@10.11.0': + resolution: {integrity: sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@graphql-tools/utils@10.9.1': resolution: {integrity: sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw==} engines: {node: '>=16.0.0'} @@ -2005,12 +2044,6 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' - '@hono/node-server@1.13.3': - resolution: {integrity: sha512-tEo3hcyQ6chvSnJ3tKzfX4z2sd7Q+ZkBwwBdW1Ya8Mz29dukxC2xcWiB/lAMwGJrYMW8QTgknIsLu1AsnMBe7A==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@hono/node-server@1.19.5': resolution: {integrity: sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ==} engines: {node: '>=18.14.1'} @@ -2747,8 +2780,16 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@ponder/utils@0.2.14': - resolution: {integrity: sha512-O4t14Hb6/tVcD0WoS13ghFnDntP6x33/DDvA+sd0tRjemzS+Cne4YTkXl9TKW3AawBIEwMjGrGbAn82C8gXQWQ==} + '@ponder/client@0.14.13': + resolution: {integrity: sha512-MNmuppswNiL6TIdMuwmz6fhz/PPK4OSTXiBq62UFMLXIK55g8j8bECOPxOEZ2JKeSmhaOZ/s0fNMJGh/TQf1Nw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + '@ponder/utils@0.2.16': + resolution: {integrity: sha512-Q2TpJ1BywdZhGDjtWe6mDQXn/jF2YDfMwdblDytwGjQ6zuFyuujP+o/liVbuHfqSYmeUOPHugxk5yKAhHwWfJw==} peerDependencies: typescript: '>=5.0.4' viem: '>=2' @@ -2756,6 +2797,24 @@ packages: typescript: optional: true + '@pothos/core@4.10.0': + resolution: {integrity: sha512-spC7v6N80GfDKqt6ZSGELLu7EFDZsuQBb/oCqAtDwAe+HIL5T0STjv220IumLeC8lIAMgTaZMa/WnMNKkawbFg==} + peerDependencies: + graphql: ^16.10.0 + + '@pothos/plugin-dataloader@4.4.3': + resolution: {integrity: sha512-W80Cne0+IkoO/splip7ndrg9FRdHfaDoTPfwoZjJT7rGXb57kTGuQWDnm66O8363Z58jqdcVVfwwO2+EmkdEFw==} + peerDependencies: + '@pothos/core': '*' + dataloader: '2' + graphql: ^16.10.0 + + '@pothos/plugin-relay@4.6.2': + resolution: {integrity: sha512-9aweCv9T53z4+CmE+JF8QoXeAEd+wT/rZflZNzrvH6ln2Lj6qy/EVEcL5BMr6en3/IYHH+ROyHAAsy12t4uIUQ==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.10.0 + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3396,39 +3455,21 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} - '@shikijs/core@3.14.0': - resolution: {integrity: sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw==} - '@shikijs/core@3.15.0': resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==} - '@shikijs/engine-javascript@3.14.0': - resolution: {integrity: sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ==} - '@shikijs/engine-javascript@3.15.0': resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==} - '@shikijs/engine-oniguruma@3.14.0': - resolution: {integrity: sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==} - '@shikijs/engine-oniguruma@3.15.0': resolution: {integrity: sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==} - '@shikijs/langs@3.14.0': - resolution: {integrity: sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg==} - '@shikijs/langs@3.15.0': resolution: {integrity: sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==} - '@shikijs/themes@3.14.0': - resolution: {integrity: sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA==} - '@shikijs/themes@3.15.0': resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==} - '@shikijs/types@3.14.0': - resolution: {integrity: sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ==} - '@shikijs/types@3.15.0': resolution: {integrity: sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==} @@ -4118,8 +4159,12 @@ packages: resolution: {integrity: sha512-eR8SYtf9Nem1Tnl0IWrY33qJ5wCtIWlt3Fs3c6V4aAaTFLtkEQErXu3SSZg/XCHrj9hXSJ8/8t+CdMk5Qec/ZA==} engines: {node: '>=18.0.0'} - '@whatwg-node/node-fetch@0.8.1': - resolution: {integrity: sha512-cQmQEo7IsI0EPX9VrwygXVzrVlX43Jb7/DBZSmpnC7xH4xkyOnn/HykHpTaQk7TUs7zh59A5uTGqx3p2Ouzffw==} + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.4': + resolution: {integrity: sha512-AlKLc57loGoyYlrzDbejB9EeR+pfdJdGzbYnkEuZaGekFboBwzfVYVMsy88PMriqPI1ORpiGYGgSSWpx7a2sDA==} engines: {node: '>=18.0.0'} '@whatwg-node/promise-helpers@1.3.2': @@ -4130,6 +4175,10 @@ packages: resolution: {integrity: sha512-Otmxo+0mp8az3B48pLI1I4msNOXPIoP7TLm6h5wOEQmynqHt8oP9nR6NJUeJk6iI5OtFpQtkbJFwfGkmplvc3Q==} engines: {node: '>=18.0.0'} + '@whatwg-node/server@0.10.17': + resolution: {integrity: sha512-QxI+HQfJeI/UscFNCTcSri6nrHP25mtyAMbhEri7W2ctdb3EsorPuJz7IovSgNjvKVs73dg9Fmayewx1O2xOxA==} + engines: {node: '>=18.0.0'} + '@xyflow/react@12.9.1': resolution: {integrity: sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==} peerDependencies: @@ -5325,6 +5374,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -5582,10 +5639,20 @@ packages: peerDependencies: graphql: ^15.2.0 || ^16.0.0 + graphql-yoga@5.17.1: + resolution: {integrity: sha512-Izb2uVWfdoWm+tF4bi39KE6F4uml3r700/EwULPZYOciY8inmy4hw+98c6agy3C+xceXvTkP7Li6mY/EI8XliA==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^15.2.0 || ^16.0.0 + graphql@16.11.0: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + graphql@16.8.2: + resolution: {integrity: sha512-cvVIBILwuoSyD54U4cF/UXDh5yAobhNV/tPygI4lZhgOIJQE/WLWC4waBRb4I6bDVYb3OVx3lfHbaQOEoUD5sg==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + h3@1.15.4: resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} @@ -6867,8 +6934,8 @@ packages: graphql: ^16.10.0 hono: ^4.6.19 - ponder@0.13.16: - resolution: {integrity: sha512-UfSNm+1kEu6Z7JuYmk8KPRyodi3GxbpwcXzYInaPrhArE3CNwYt+LmtQ2zEJxZdpTq3gRL94grApiSFtHb9LAA==} + ponder@0.15.16: + resolution: {integrity: sha512-UWJgcnLm9JnvQNA1ehIDmVcX+0KcbborYAl3lYwAHx0pQsc9zN0IbvWU8DR95a02caQrVX9jNq07vCtt75Dzbw==} engines: {node: '>=18.14'} hasBin: true peerDependencies: @@ -7366,9 +7433,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@3.14.0: - resolution: {integrity: sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g==} - shiki@3.15.0: resolution: {integrity: sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==} @@ -7397,10 +7461,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - smol-toml@1.4.2: - resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==} - engines: {node: '>= 18'} - smol-toml@1.5.2: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} @@ -8597,8 +8657,8 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 - shiki: 3.14.0 - smol-toml: 1.4.2 + shiki: 3.15.0 + smol-toml: 1.5.2 unified: 11.0.5 unist-util-remove-position: 5.0.0 unist-util-visit: 5.0.0 @@ -9788,7 +9848,7 @@ snapshots: '@expressive-code/plugin-shiki@0.41.3': dependencies: '@expressive-code/core': 0.41.3 - shiki: 3.14.0 + shiki: 3.15.0 '@expressive-code/plugin-text-markers@0.41.3': dependencies: @@ -9932,7 +9992,7 @@ snapshots: '@graphql-tools/executor@1.4.9(graphql@16.11.0)': dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 @@ -9940,19 +10000,58 @@ snapshots: graphql: 16.11.0 tslib: 2.8.1 + '@graphql-tools/executor@1.5.0(graphql@16.8.2)': + dependencies: + '@graphql-tools/utils': 10.11.0(graphql@16.8.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.2) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.8.2 + tslib: 2.8.1 + '@graphql-tools/merge@9.1.1(graphql@16.11.0)': dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) graphql: 16.11.0 tslib: 2.8.1 + '@graphql-tools/merge@9.1.1(graphql@16.8.2)': + dependencies: + '@graphql-tools/utils': 10.11.0(graphql@16.8.2) + graphql: 16.8.2 + tslib: 2.8.1 + '@graphql-tools/schema@10.0.25(graphql@16.11.0)': dependencies: '@graphql-tools/merge': 9.1.1(graphql@16.11.0) - '@graphql-tools/utils': 10.9.1(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) graphql: 16.11.0 tslib: 2.8.1 + '@graphql-tools/schema@10.0.25(graphql@16.8.2)': + dependencies: + '@graphql-tools/merge': 9.1.1(graphql@16.8.2) + '@graphql-tools/utils': 10.11.0(graphql@16.8.2) + graphql: 16.8.2 + tslib: 2.8.1 + + '@graphql-tools/utils@10.11.0(graphql@16.11.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/utils@10.11.0(graphql@16.8.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.2) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.8.2 + tslib: 2.8.1 + '@graphql-tools/utils@10.9.1(graphql@16.11.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) @@ -9966,6 +10065,10 @@ snapshots: dependencies: graphql: 16.11.0 + '@graphql-typed-document-node/core@3.2.0(graphql@16.8.2)': + dependencies: + graphql: 16.8.2 + '@graphql-yoga/logger@2.0.1': dependencies: tslib: 2.8.1 @@ -10042,10 +10145,6 @@ snapshots: dependencies: react: 18.3.1 - '@hono/node-server@1.13.3(hono@4.10.3)': - dependencies: - hono: 4.10.3 - '@hono/node-server@1.19.5(hono@4.10.3)': dependencies: hono: 4.10.3 @@ -10842,12 +10941,64 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@ponder/utils@0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))': + '@ponder/client@0.14.13(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3)(typescript@5.9.3)': + dependencies: + drizzle-orm: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3) + eventsource: 3.0.7 + superjson: 2.2.5 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - sql.js + - sqlite3 + + '@ponder/utils@0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))': dependencies: viem: 2.38.5(typescript@5.9.3)(zod@3.25.76) optionalDependencies: typescript: 5.9.3 + '@pothos/core@4.10.0(graphql@16.11.0)': + dependencies: + graphql: 16.11.0 + + '@pothos/plugin-dataloader@4.4.3(@pothos/core@4.10.0(graphql@16.11.0))(dataloader@2.2.3)(graphql@16.11.0)': + dependencies: + '@pothos/core': 4.10.0(graphql@16.11.0) + dataloader: 2.2.3 + graphql: 16.11.0 + + '@pothos/plugin-relay@4.6.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)': + dependencies: + '@pothos/core': 4.10.0(graphql@16.11.0) + graphql: 16.11.0 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -11500,13 +11651,6 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@shikijs/core@3.14.0': - dependencies: - '@shikijs/types': 3.14.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - '@shikijs/core@3.15.0': dependencies: '@shikijs/types': 3.15.0 @@ -11514,49 +11658,25 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@3.14.0': - dependencies: - '@shikijs/types': 3.14.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.3 - '@shikijs/engine-javascript@3.15.0': dependencies: '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.3 - '@shikijs/engine-oniguruma@3.14.0': - dependencies: - '@shikijs/types': 3.14.0 - '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@3.15.0': dependencies: '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.14.0': - dependencies: - '@shikijs/types': 3.14.0 - '@shikijs/langs@3.15.0': dependencies: '@shikijs/types': 3.15.0 - '@shikijs/themes@3.14.0': - dependencies: - '@shikijs/types': 3.14.0 - '@shikijs/themes@3.15.0': dependencies: '@shikijs/types': 3.15.0 - '@shikijs/types@3.14.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - '@shikijs/types@3.15.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -12365,10 +12485,15 @@ snapshots: '@whatwg-node/fetch@0.10.11': dependencies: - '@whatwg-node/node-fetch': 0.8.1 + '@whatwg-node/node-fetch': 0.8.4 urlpattern-polyfill: 10.1.0 - '@whatwg-node/node-fetch@0.8.1': + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.4 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.4': dependencies: '@fastify/busboy': 3.2.0 '@whatwg-node/disposablestack': 0.0.6 @@ -12383,7 +12508,15 @@ snapshots: dependencies: '@envelop/instrumentation': 1.0.0 '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.11 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/server@0.10.17': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 @@ -13631,6 +13764,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -13924,8 +14063,26 @@ snapshots: lru-cache: 10.4.3 tslib: 2.8.1 + graphql-yoga@5.17.1(graphql@16.8.2): + dependencies: + '@envelop/core': 5.3.2 + '@envelop/instrumentation': 1.0.0 + '@graphql-tools/executor': 1.5.0(graphql@16.8.2) + '@graphql-tools/schema': 10.0.25(graphql@16.8.2) + '@graphql-tools/utils': 10.11.0(graphql@16.8.2) + '@graphql-yoga/logger': 2.0.1 + '@graphql-yoga/subscription': 5.0.5 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + '@whatwg-node/server': 0.10.17 + graphql: 16.8.2 + lru-cache: 10.4.3 + tslib: 2.8.1 + graphql@16.11.0: {} + graphql@16.8.2: {} + h3@1.15.4: dependencies: cookie-es: 1.2.2 @@ -15561,7 +15718,7 @@ snapshots: graphql: 16.11.0 hono: 4.10.3 - ponder@0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76): + ponder@0.15.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76): dependencies: '@babel/code-frame': 7.27.1 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -15569,8 +15726,8 @@ snapshots: '@escape.tech/graphql-armor-max-aliases': 2.6.2 '@escape.tech/graphql-armor-max-depth': 2.4.2 '@escape.tech/graphql-armor-max-tokens': 2.5.1 - '@hono/node-server': 1.13.3(hono@4.10.3) - '@ponder/utils': 0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) + '@hono/node-server': 1.19.5(hono@4.10.3) + '@ponder/utils': 0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) abitype: 0.10.3(typescript@5.9.3)(zod@3.25.76) ansi-escapes: 7.1.1 commander: 12.1.0 @@ -15580,8 +15737,8 @@ snapshots: dotenv: 16.6.1 drizzle-orm: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3) glob: 12.0.0 - graphql: 16.11.0 - graphql-yoga: 5.16.0(graphql@16.11.0) + graphql: 16.8.2 + graphql-yoga: 5.17.1(graphql@16.8.2) hono: 4.10.3 http-terminator: 3.2.0 kysely: 0.26.3 @@ -15646,7 +15803,7 @@ snapshots: - yaml - zod - ponder@0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.4)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76): + ponder@0.15.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.4)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76): dependencies: '@babel/code-frame': 7.27.1 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -15654,8 +15811,8 @@ snapshots: '@escape.tech/graphql-armor-max-aliases': 2.6.2 '@escape.tech/graphql-armor-max-depth': 2.4.2 '@escape.tech/graphql-armor-max-tokens': 2.5.1 - '@hono/node-server': 1.13.3(hono@4.10.3) - '@ponder/utils': 0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) + '@hono/node-server': 1.19.5(hono@4.10.3) + '@ponder/utils': 0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76)) abitype: 0.10.3(typescript@5.9.3)(zod@3.25.76) ansi-escapes: 7.1.1 commander: 12.1.0 @@ -15665,8 +15822,8 @@ snapshots: dotenv: 16.6.1 drizzle-orm: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3) glob: 12.0.0 - graphql: 16.11.0 - graphql-yoga: 5.16.0(graphql@16.11.0) + graphql: 16.8.2 + graphql-yoga: 5.17.1(graphql@16.8.2) hono: 4.10.3 http-terminator: 3.2.0 kysely: 0.26.3 @@ -16365,17 +16522,6 @@ snapshots: shebang-regex@3.0.0: {} - shiki@3.14.0: - dependencies: - '@shikijs/core': 3.14.0 - '@shikijs/engine-javascript': 3.14.0 - '@shikijs/engine-oniguruma': 3.14.0 - '@shikijs/langs': 3.14.0 - '@shikijs/themes': 3.14.0 - '@shikijs/types': 3.14.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - shiki@3.15.0: dependencies: '@shikijs/core': 3.15.0 @@ -16408,8 +16554,6 @@ snapshots: slash@3.0.0: {} - smol-toml@1.4.2: {} - smol-toml@1.5.2: {} sonic-boom@3.8.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1f6fab8c..822ff5f45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,7 +8,7 @@ catalog: "@astrojs/react": ^4.4.1 "@astrojs/tailwind": ^6.0.2 "@namehash/namekit-react": 0.12.0 - "@ponder/utils": 0.2.14 + "@ponder/utils": 0.2.16 "@testing-library/react": ^16.3.0 "@types/node": 22.18.13 astro: ^5.15.9 @@ -16,11 +16,11 @@ catalog: astro-seo: ^0.8.4 caip: 1.1.1 date-fns: 4.1.0 - drizzle-orm: "=0.41.0" + drizzle-orm: 0.41.0 hono: ^4.10.2 pg-connection-string: ^2.9.1 pino: 10.1.0 - ponder: 0.13.16 + ponder: 0.15.16 tsup: ^8.3.6 typescript: ^5.7.3 viem: ^2.22.13 diff --git a/terraform/.env.sample b/terraform/.env.sample index ce8c1c7cf..dfbc808db 100644 --- a/terraform/.env.sample +++ b/terraform/.env.sample @@ -13,14 +13,12 @@ TF_VAR_ethereum_mainnet_rpc_url= TF_VAR_base_mainnet_rpc_url= TF_VAR_linea_mainnet_rpc_url= TF_VAR_optimism_mainnet_rpc_url= -TF_VAR_arbitrum_mainnet_rpc_url= -TF_VAR_scroll_mainnet_rpc_url= +TF_VAR_arbitrum_mainnet_rpc_url= +TF_VAR_scroll_mainnet_rpc_url= # Sepolia TF_VAR_ethereum_sepolia_rpc_url= TF_VAR_base_sepolia_rpc_url= TF_VAR_linea_sepolia_rpc_url= TF_VAR_optimism_sepolia_rpc_url= -TF_VAR_arbitrum_sepolia_rpc_url= +TF_VAR_arbitrum_sepolia_rpc_url= TF_VAR_scroll_sepolia_rpc_url= -# Holesky -TF_VAR_ethereum_holesky_rpc_url= diff --git a/terraform/main.tf b/terraform/main.tf index 15f781d59..c7ac71c2a 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -19,15 +19,6 @@ locals { # See https://render.com/docs/blueprint-spec#region render_region = "ohio" ensindexer_instances = { - holesky = { - ensnode_indexer_type = "holesky" - ensnode_environment_name = var.render_environment - database_schema = "holeskySchema-${var.ensnode_version}" - plugins = "subgraph" - namespace = "holesky" - render_instance_plan = "starter" - subgraph_compat = true - } sepolia = { ensnode_indexer_type = "sepolia" ensnode_environment_name = var.render_environment