diff --git a/.changeset/forty-coins-lose.md b/.changeset/forty-coins-lose.md new file mode 100644 index 000000000..0fe986d3d --- /dev/null +++ b/.changeset/forty-coins-lose.md @@ -0,0 +1,6 @@ +--- +"ensapi": minor +"ensindexer": minor +--- + +Enable auto-generated QuickNode RPC provider support with `QUICKNODE_API_KEY` and `QUICKNODE_ENDPOINT_NAME` environment variables. diff --git a/.changeset/smooth-lines-vanish.md b/.changeset/smooth-lines-vanish.md new file mode 100644 index 000000000..257067557 --- /dev/null +++ b/.changeset/smooth-lines-vanish.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Add QuickNode RPC provider support for auto-generated chain RPC URLs. diff --git a/.github/workflows/deploy_ensnode_blue_green.yml b/.github/workflows/deploy_ensnode_blue_green.yml index c484bf62d..a16370715 100644 --- a/.github/workflows/deploy_ensnode_blue_green.yml +++ b/.github/workflows/deploy_ensnode_blue_green.yml @@ -33,24 +33,6 @@ jobs: RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }} RAILWAY_ENVIRONMENT_ID: ${{ secrets.RAILWAY_ENVIRONMENT_ID}} RAILWAY_TEAM_TOKEN: ${{ secrets.RAILWAY_TEAM_TOKEN }} - # Terraform related envs - - # AWS_REGION is required for aws-actions/configure-aws-credentials@v4 - # Terraform keeps it's state inside S3 bucket. This bucket needs to be created before running Terraform apply. - # AWS_REGION should be the same as Terraform S3 bucket state region. - AWS_REGION: us-east-1 - TF_VAR_ensnode_version: ${{ inputs.tag }} - TF_VAR_ensindexer_label_set_id: ${{ vars.LABEL_SET_ID }} - TF_VAR_ensindexer_label_set_version: ${{ vars.LABEL_SET_VERSION }} - TF_VAR_ensrainbow_label_set_id: ${{ vars.LABEL_SET_ID }} - TF_VAR_ensrainbow_label_set_version: ${{ vars.LABEL_SET_VERSION }} - TF_VAR_db_schema_version: ${{ vars.DB_SCHEMA_VERSION }} - TF_VAR_anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # For now only one Terraform environment is active. In future this will be calculated based on workflow input. - TF_VAR_render_environment: "yellow" - TF_VAR_render_api_key: ${{ secrets.RENDER_API_KEY }} - TF_VAR_render_owner_id: ${{ secrets.RENDER_OWNER_ID }} - TF_VAR_alchemy_api_key: ${{ secrets.ALCHEMY_API_KEY }} steps: - name: Checkout repository diff --git a/.github/workflows/deploy_ensnode_yellow.yml b/.github/workflows/deploy_ensnode_yellow.yml index 63d3338f5..532aa4145 100644 --- a/.github/workflows/deploy_ensnode_yellow.yml +++ b/.github/workflows/deploy_ensnode_yellow.yml @@ -39,6 +39,8 @@ jobs: TF_VAR_render_api_key: ${{ secrets.RENDER_API_KEY }} TF_VAR_render_owner_id: ${{ secrets.RENDER_OWNER_ID }} TF_VAR_alchemy_api_key: ${{ secrets.ALCHEMY_API_KEY }} + TF_VAR_quicknode_api_key: ${{ secrets.QUICKNODE_API_KEY }} + TF_VAR_quicknode_endpoint_name: ${{ secrets.QUICKNODE_ENDPOINT_NAME}} steps: - name: Checkout repository diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 5124d427c..cb0b82812 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -113,6 +113,8 @@ jobs: ENSRAINBOW_URL: https://api.ensrainbow.io ENSINDEXER_URL: http://localhost:42069 ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + QUICKNODE_API_KEY: ${{ secrets.QUICKNODE_API_KEY }} + QUICKNODE_ENDPOINT_NAME: ${{ secrets.QUICKNODE_ENDPOINT_NAME}} # healthcheck script env variables HEALTH_CHECK_TIMEOUT: 60 run: ./.github/scripts/run_ensindexer_healthcheck.sh diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 085d39b09..8e3f50317 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -28,31 +28,79 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database # Resolution requests you make to ENSApi). # # Private RPC service options include: -# - Alchemy - https://www.alchemy.com -# - QuickNode - https://www.quicknode.com -# - Infura - https://infura.io -# - DRPC - https://drpc.org +# - Alchemy (paid plan) - https://www.alchemy.com/ +# - QuickNode (paid plan) - https://www.quicknode.com +# - drpc.org (paid plan) - https://drpc.org/ +# - Infura (paid plan) - https://infura.io/ # -# The following environment variables are supported: -# - ALCHEMY_API_KEY — if set, an Alchemy RPC URL will be provided for each of the chains it supports -# - DRPC_API_KEY — if set, an DRPC RPC URL will be provided for each of the chains it supports -# - RPC_URL_${chainId} — specific, per-chain RPC settings (see below). +# Example RPC endpoint URL formats: +# - Alchemy RPC endpoints +# - https://eth-mainnet.g.alchemy.com/v2/ +# - wss://eth-mainnet.g.alchemy.com/v2/ +# - https://base-sepolia.g.alchemy.com/v2/ +# - QuickNode RPC endpoints +# - https://.quiknode.pro/ +# - wss://.quiknode.pro/ +# - https://.base-sepolia.quiknode.pro/ +# - dRPC RPC endpoints +# - https://lb.drpc.live/ethereum/ +# - wss://lb.drpc.live/ethereum/ +# - https://lb.drpc.live/base-sepolia/ +# - Infura RPC endpoints +# - https://mainnet.infura.io/v3/ +# - wss://mainnet.infura.io/ws/v3/ +# - https://base-sepolia.infura.io/v3/ # -# If RPC_URL_${chainId} is specified, it will take precedence over the automatic RPC URLs from -# Alchemy or DRPC. It must be a comma-separated list of HTTP/HTTPS RPC endpoints. If multiple -# HTTP RPC endpoints are provided for a single chain, they will be used in order, falling back if -# RPC errors are encountered. If multiple automatic RPC environment variables are specified, they -# will be used in the following order: Alchemy > DRPC. +# Configuring the following environment variables enables auto-generation of +# RPC endpoint URLs for each indexed chain (with limitations as noted below): +# - ALCHEMY_API_KEY — API key for your Alchemy app, if set, Alchemy RPC URLs (HTTP) will be autogenerated for chains Alchemy supports. +# - QUICKNODE_API_KEY - API key for your multi-chain QuickNode endpoint. +# - QUICKNODE_ENDPOINT_NAME — endpoint name of your multi-chain QuickNode endpoint. +# - DRPC_API_KEY — if set, an dRPC RPC URL (HTTP) will be autogenerated for chains dRPC supports. +# - RPC_URL_${chainId} — specific, per-chain RPC settings. # -# Automatic: +# If both, QUICKNODE_API_KEY and QUICKNODE_ENDPOINT_NAME are specified, +# a QuickNode RPC URL will be autogenerated for chains QuickNode supports. +# If only one of QUICKNODE_API_KEY or QUICKNODE_ENDPOINT_NAME is set but not the other, +# the configuration will be rejected with an error. +# Note key constraints of QuickNode RPC endpoints: +# - Only multi-chain QuickNode endpoints can be used for setting +# QUICKNODE_API_KEY and QUICKNODE_ENDPOINT_NAME environment variables. +# A multi-chain endpoint allows sharing the same endpoint name and API key +# across all chains supported by QuickNode platform. Read more in QuickNode docs: +# https://www.quicknode.com/guides/quicknode-products/how-to-use-multichain-endpoint +# - QuickNode platform does not support Linea Sepolia RPC (as of 2025-12-03). +# https://www.quicknode.com/docs/linea +# +# Each RPC_URL_${chainId} environment variable, if specified, will take precedence over +# all auto-generated RPC URLs for the specified chainId from Alchemy, QuickNode, or dRPC. +# It must be a comma-separated list of HTTP/HTTPS RPC endpoints. +# ENSApi provides all the resulting RPC URLs to Viem Client. If multiple HTTP RPC URLs are provided to Viem Client, +# Viem Client automatically balances requests between them (see below). +# +# Auto-generated RPC URLs: # ALCHEMY_API_KEY=xyz +# QUICKNODE_API_KEY=your-api-key +# QUICKNODE_ENDPOINT_NAME=your-endpoint-name # DRPC_API_KEY=xyz # -# Chain-Specific: +# For full control of chain-specific RPC configuration, use the RPC_URL_{chainId} environment variable. +# Its value is a comma-separated list of one or more HTTP RPC URLs. +# +# 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_17000=https://eth-holesky.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 +# +# The RPC_URL_${chainId} value has the following invariants: +# - Must always include at least one HTTP/HTTPS RPC endpoint. RPC endpoints can +# fail or experience downtime. To optimize resiliency of ENSIndexer, +# defining more than one HTTP/HTTPS endpoint (from more than one RPC provider) +# per indexed chain is strongly encouraged. +# +# To optimize performance, Viem Client automatically adapts to latency and stability of each +# provided HTTP/HTTPS RPC. More details at: https://v1.viem.sh/docs/clients/transports/fallback.html#transport-ranking # Log Level # Optional. If this is not set, the default value is "info". diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 40aaec3eb..793a349db 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -4,7 +4,7 @@ import { ENS_HOLIDAY_AWARDS_END_DATE, ENS_HOLIDAY_AWARDS_START_DATE, } from "@namehash/ens-referrals"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ENSIndexerPublicConfig, @@ -16,6 +16,13 @@ import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; +import logger from "@/lib/logger"; + +vi.mock("@/lib/logger", () => ({ + default: { + error: vi.fn(), + }, +})); const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; @@ -79,6 +86,64 @@ describe("buildConfigFromEnvironment", () => { ensHolidayAwardsEnd: ENS_HOLIDAY_AWARDS_END_DATE, }); }); + + describe("Useful error messages", () => { + // Mock process.exit to prevent actual exit + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + mockExit.mockClear(); + }); + + const TEST_ENV: EnsApiEnvironment = { + DATABASE_URL: BASE_ENV.DATABASE_URL, + ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL, + }; + + it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), + }); + + await buildConfigFromEnvironment({ + ...TEST_ENV, + QUICKNODE_API_KEY: "my-api-key", + }); + + expect(logger.error).toHaveBeenCalledWith( + new Error( + "Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well.", + ), + "Failed to build EnsApiConfig", + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), + }); + + await buildConfigFromEnvironment({ + ...TEST_ENV, + QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", + }); + + expect(logger.error).toHaveBeenCalledWith( + new Error( + "Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well.", + ), + "Failed to build EnsApiConfig", + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); }); describe("buildEnsApiPublicConfig", () => { diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 5d749c4bc..6fbbcc750 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -113,15 +113,12 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis } catch (error) { if (error instanceof ZodError) { logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); - process.exit(1); - } - - if (error instanceof Error) { + } else if (error instanceof Error) { logger.error(error, `Failed to build EnsApiConfig`); - process.exit(1); + } else { + logger.error(`Unknown Error`); } - logger.error(`Unknown Error`); process.exit(1); } } diff --git a/apps/ensapi/src/lib/rpc/public-client.ts b/apps/ensapi/src/lib/rpc/public-client.ts index bbf74618c..ee4531683 100644 --- a/apps/ensapi/src/lib/rpc/public-client.ts +++ b/apps/ensapi/src/lib/rpc/public-client.ts @@ -5,14 +5,23 @@ import { createPublicClient, fallback, http, type PublicClient } from "viem"; import type { ChainId } from "@ensnode/ensnode-sdk"; export function getPublicClient(chainId: ChainId): PublicClient { - // Invariant: ENSIndexer must have an rpcConfig for the requested `chainId` + // Invariant: ENSApi must have an rpcConfig for the requested `chainId` const rpcConfig = config.rpcConfigs.get(chainId); if (!rpcConfig) { - throw new Error(`Invariant: ENSIndexer does not have an RPC to chain id '${chainId}'.`); + throw new Error(`Invariant: ENSApi does not have an RPC to chain id '${chainId}'.`); } - // create an un-cached publicClient that uses a fallback() transport with all specified HTTP RPCs + // Create an un-cached publicClient that uses a fallback() transport with all specified HTTP RPCs return createPublicClient({ - transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))), + transport: fallback( + rpcConfig.httpRPCs.map((url) => http(url.toString())), + { + // Transport Ranking enables each of the Transports passed to + // the "fallback transport" to be automatically ranked based on their + // latency & stability via a weighted moving score algorithm. + // https://v1.viem.sh/docs/clients/transports/fallback.html#transport-ranking + rank: true, + }, + ), }); } diff --git a/apps/ensindexer/.env.local.example b/apps/ensindexer/.env.local.example index 4369780ab..73439e54b 100644 --- a/apps/ensindexer/.env.local.example +++ b/apps/ensindexer/.env.local.example @@ -13,29 +13,73 @@ # that is prepared to support millions of requests (ex: 500+ requests / second) # # Each configured RPC endpoint must be prepared to receive and quickly -# process millions of RPC requests. Private RPC service options include: -# - drpc.org (paid plan) - https://drpc.org/ +# process millions of RPC requests. High-volume RPC service options include: # - Alchemy (paid plan) - https://www.alchemy.com/ -# - QuickNode (paid plan) - https://www.quicknode.com/ +# - QuickNode (paid plan) - https://www.quicknode.com +# - drpc.org (paid plan) - https://drpc.org/ # - Infura (paid plan) - https://infura.io/ # -# The following environment variables are supported: -# - ALCHEMY_API_KEY — if set, Alchemy RPC URLs (HTTP & WS) will be provided for each of the chains it supports -# - DRPC_API_KEY — if set, an DRPC RPC URL (HTTP) will be provided for each of the chains it supports -# - RPC_URL_${chainId} — specific, per-chain RPC settings (see below). +# Example RPC endpoint URL formats: +# - Alchemy RPC endpoints +# - https://eth-mainnet.g.alchemy.com/v2/ +# - wss://eth-mainnet.g.alchemy.com/v2/ +# - https://base-sepolia.g.alchemy.com/v2/ +# - QuickNode RPC endpoints +# - https://.quiknode.pro/ +# - wss://.quiknode.pro/ +# - https://.base-sepolia.quiknode.pro/ +# - dRPC RPC endpoints +# - https://lb.drpc.live/ethereum/ +# - wss://lb.drpc.live/ethereum/ +# - https://lb.drpc.live/base-sepolia/ +# - Infura RPC endpoints +# - https://mainnet.infura.io/v3/ +# - wss://mainnet.infura.io/ws/v3/ +# - https://base-sepolia.infura.io/v3/ +# +# Configuring the following environment variables enables auto-generation of +# RPC endpoint URLs for each indexed chain (with limitations as noted below): +# - ALCHEMY_API_KEY — ALCHEMY_API_KEY — API key for your Alchemy app, if set, Alchemy RPC URLs (HTTP & WS) will be autogenerated for chains Alchemy supports. +# - QUICKNODE_API_KEY - API key for your multi-chain QuickNode endpoint. +# - QUICKNODE_ENDPOINT_NAME — endpoint name of your multi-chain QuickNode endpoint. +# - DRPC_API_KEY — if set, an dRPC RPC URL (HTTP) will be autogenerated for chains dRPC supports. +# - RPC_URL_${chainId} — specific, per-chain RPC settings. # -# If RPC_URL_${chainId} is specified, that value will take precedence over the automatic RPC URLs -# from Alchemy or DRPC. If both Alchemy and DRPC API Keys are specified, ENSIndexer will provide -# both to Ponder, which will balance requests between them (see below). +# If both, QUICKNODE_API_KEY and QUICKNODE_ENDPOINT_NAME are specified, +# a QuickNode RPC URL will be autogenerated for chains QuickNode supports. +# If only one of QUICKNODE_API_KEY or QUICKNODE_ENDPOINT_NAME is set but not the other, +# the configuration will be rejected with an error. +# Note key constraints of QuickNode RPC endpoints: +# - Only multi-chain QuickNode endpoints can be used for setting +# QUICKNODE_API_KEY and QUICKNODE_ENDPOINT_NAME environment variables. +# A multi-chain endpoint allows sharing the same endpoint name and API key +# across all chains supported by QuickNode platform. Read more in QuickNode docs: +# https://www.quicknode.com/guides/quicknode-products/how-to-use-multichain-endpoint +# - QuickNode platform does not support Linea Sepolia RPC (as of 2025-12-03). +# https://www.quicknode.com/docs/linea +# +# Each RPC_URL_${chainId} environment variable, if specified, will take precedence over +# all auto-generated RPC URLs for the specified chainId from Alchemy, QuickNode, or dRPC. +# It must be a comma-separated list of HTTP/HTTPS RPC endpoints. +# ENSIndexer provides all the resulting RPC URLs to Ponder. If multiple HTTP RPC URLs are provided to Ponder, +# Ponder automatically balances requests between them (see below). + +# Auto-generated RPC URLs: +# ALCHEMY_API_KEY=xyz +# QUICKNODE_API_KEY=your-api-key +# QUICKNODE_ENDPOINT_NAME=your-endpoint-name +# DRPC_API_KEY=xyz # -# For chain-specific RPC configuration, use the RPC_URL_{chainId} environment variable. +# For full control of chain-specific RPC configuration, use the RPC_URL_{chainId} environment variable. # Its value is a comma-separated list of one or more HTTP RPC URLs and at most one WebSocket RPC URL. # # 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 # # 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,wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_1=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY,https://lb.drpc.org/ethereum/YOUR_API_KEY +# RPC_URL_11155111=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY,https://lb.drpc.org/ethereum-sepolia/YOUR_API_KEY # # The RPC_URL_${chainId} value has the following invariants: # - Must always include at least one HTTP/HTTPS RPC endpoint. RPC endpoints can @@ -56,64 +100,64 @@ # Ethereum Mainnet # - required if the configured namespace is mainnet # - required by plugins: subgraph, protocol-acceleration, registrars, tokenscope -RPC_URL_1= +# RPC_URL_1= # Optimism Mainnet # - required by plugins: threedns, protocol-acceleration, tokenscope -RPC_URL_10= +# RPC_URL_10= # Base Mainnet # - required by plugins: basenames, threedns, protocol-acceleration, registrars, tokenscope -RPC_URL_8453= +# RPC_URL_8453= # Arbitrum Mainnet # - required by plugins: protocol-acceleration -RPC_URL_42161= +# RPC_URL_42161= # Linea Mainnet # - required by plugins: lineanames, protocol-acceleration, registrars, tokenscope -RPC_URL_59144= +# RPC_URL_59144= # Scroll Mainnet # - required by plugins: protocol-acceleration -RPC_URL_534352= +# RPC_URL_534352= # === ENS Namespace: Sepolia === # Ethereum Sepolia (public testnet) # - required if the configured namespace is sepolia # - required by plugins: subgraph, protocol-acceleration, registrars, tokenscope -RPC_URL_11155111= +# RPC_URL_11155111= # Base Sepolia (public testnet) # - required by plugins: basenames, protocol-acceleration, registrars, tokenscope -RPC_URL_84532= +# RPC_URL_84532= # Linea Sepolia (public testnet) # - required by plugins: lineanames, protocol-acceleration, registrars, tokenscope -RPC_URL_59141= +# RPC_URL_59141= # Optimism Sepolia (public testnet) # - required by plugins: protocol-acceleration, tokenscope -RPC_URL_11155420= +# RPC_URL_11155420= # Arbitrum Sepolia (public testnet) # - required by plugins: protocol-acceleration -RPC_URL_421614= +# RPC_URL_421614= # Scroll Sepolia (public testnet) # - required by plugins: protocol-acceleration -RPC_URL_534351= +# 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= +# RPC_URL_17000= # === ENS Namespace: ens-test-env === # ens-test-env (local testnet) # - required if the configured namespace is ens-test-env -RPC_URL_1337= +# RPC_URL_1337= # Database configuration # Required. This is a namespace for the tables that the indexer will create to store indexed data. diff --git a/apps/ensindexer/src/config/config.test.ts b/apps/ensindexer/src/config/config.test.ts index 315e31f92..6aa7c41ca 100644 --- a/apps/ensindexer/src/config/config.test.ts +++ b/apps/ensindexer/src/config/config.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENSNamespaceIds, PluginName } from "@ensnode/ensnode-sdk"; import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; +import { buildConfigFromEnvironment } from "@/config/config.schema"; + import type { ENSIndexerEnvironment } from "./environment"; import { EnvironmentDefaults } from "./environment-defaults"; @@ -368,6 +370,41 @@ describe("config (with base env)", () => { /RPC endpoint configuration for a chain must include at most one websocket \(ws\/wss\) protocol URL./i, ); }); + + describe("Useful error messages", () => { + // Mock process.exit to prevent actual exit + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + mockExit.mockClear(); + }); + + it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { + expect(() => + buildConfigFromEnvironment({ + ...BASE_ENV, + QUICKNODE_API_KEY: "my-api-key", + }), + ).toThrowError( + /Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well/i, + ); + }); + + it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => { + expect(() => + buildConfigFromEnvironment({ + ...BASE_ENV, + QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", + }), + ).toThrowError( + /Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well/i, + ); + }); + }); }); describe(".databaseUrl", () => { @@ -668,7 +705,7 @@ describe("config (minimal base env)", () => { ).toBe(true); }); - it("does not provide drpc if chain id is not supported", async () => { + 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/); }); diff --git a/docs/ensnode.io/src/content/docs/ensindexer/usage/configuration.mdx b/docs/ensnode.io/src/content/docs/ensindexer/usage/configuration.mdx index dbb9b81e6..6b6784902 100644 --- a/docs/ensnode.io/src/content/docs/ensindexer/usage/configuration.mdx +++ b/docs/ensnode.io/src/content/docs/ensindexer/usage/configuration.mdx @@ -12,7 +12,7 @@ import envExample from '@workspace/apps/ensindexer/.env.local.example?raw'; :::danger[Custom RPC Configuration Required] **ENSIndexer requires private RPC endpoints.** Public (rate limited) RPC endpoints will not provide acceptable performance. -You must configure private (paid) RPC services from providers like drpc.org, Alchemy, QuickNode, or Infura or host your own RPC service that can handle high-volume requests. +You must configure private (paid) RPC services from providers like Alchemy, QuickNode, drpc.org, or Infura or host your own RPC service that can handle high-volume requests. ::: :::caution[Performance Tip: ENSRainbow Server] diff --git a/packages/ensnode-sdk/src/shared/config/build-rpc-urls.test.ts b/packages/ensnode-sdk/src/shared/config/build-rpc-urls.test.ts index db8e7e807..5ed22714a 100644 --- a/packages/ensnode-sdk/src/shared/config/build-rpc-urls.test.ts +++ b/packages/ensnode-sdk/src/shared/config/build-rpc-urls.test.ts @@ -1,3 +1,4 @@ +import { lineaSepolia } from "viem/chains"; import { describe, expect, it } from "vitest"; import { @@ -8,7 +9,7 @@ import { getENSNamespace, } from "@ensnode/datasources"; -import { buildAlchemyBaseUrl, buildDRPCUrl } from "./build-rpc-urls"; +import { buildAlchemyBaseUrl, buildDRPCUrl, buildQuickNodeURL } from "./build-rpc-urls"; const KEY = "whatever"; @@ -23,7 +24,16 @@ describe("build-rpc-urls", () => { it("should build rpc urls for each known public chain id", () => { ALL_KNOWN_PUBLIC_CHAIN_IDS.forEach((chainId) => { expect(buildAlchemyBaseUrl(chainId, KEY), `Alchemy ${chainId}`).not.toBeUndefined(); - expect(buildDRPCUrl(chainId, KEY), `DRPC ${chainId}`).not.toBeUndefined(); + + if (chainId !== lineaSepolia.id) { + // QuickNode does not support Linea Sepolia RPC (as of 2025-12-03). + expect( + buildQuickNodeURL(chainId, KEY, "endpoint-name"), + `QuickNode ${chainId}`, + ).not.toBeUndefined(); + } + + expect(buildDRPCUrl(chainId, KEY), `dRPC ${chainId}`).not.toBeUndefined(); }); }); }); 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 adc21b612..1b1dab616 100644 --- a/packages/ensnode-sdk/src/shared/config/build-rpc-urls.ts +++ b/packages/ensnode-sdk/src/shared/config/build-rpc-urls.ts @@ -63,11 +63,11 @@ export function buildAlchemyBaseUrl(chainId: ChainId, key: string): string | und } /** - * Builds a DRPC RPC URL for the specified chain ID. + * Builds a dRPC RPC URL for the specified chain ID. * * @param chainId - The chain ID to build the RPC URL for - * @param key - The DRPC API key - * @returns The complete DRPC RPC URL, or undefined if the chain is not supported + * @param key - The dRPC API key + * @returns The complete dRPC RPC URL, or undefined if the chain is not supported * * @example * ```typescript @@ -108,10 +108,71 @@ export function buildDRPCUrl(chainId: ChainId, key: string): string | undefined } } +/** + * Builds a QuickNode RPC base URL for the specified chain ID. + * + * @param chainId - The chain ID to build the RPC base URL for + * @param apiKey - The QuickNode API key + * @param endpointName - The QuickNode Endpoint name + * @returns The QuickNode RPC base URL, or undefined if the chain is not supported + * + * NOTE: + * - Only multi-chain QuickNode endpoints are supported. + * https://www.quicknode.com/guides/quicknode-products/how-to-use-multichain-endpoint + * - QuickNode platform does not support Linea Sepolia RPC (as of 2025-12-03). + * https://www.quicknode.com/docs/linea + * + * @example + * ```typescript + * const url = buildQuickNodeURL(1, "your-api-key", "your-endpoint-name"); + * // Returns: "your-endpoint-name.quiknode.pro/your-api-key" + * ``` + */ +export function buildQuickNodeURL( + chainId: ChainId, + apiKey: string, + endpointName: string, +): string | undefined { + switch (chainId) { + case mainnet.id: + 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: + return `${endpointName}.arbitrum-sepolia.quiknode.pro/${apiKey}`; + case base.id: + return `${endpointName}.base-mainnet.quiknode.pro/${apiKey}`; + case baseSepolia.id: + return `${endpointName}.base-sepolia.quiknode.pro/${apiKey}`; + case optimism.id: + return `${endpointName}.optimism.quiknode.pro/${apiKey}`; + case optimismSepolia.id: + return `${endpointName}.optimism-sepolia.quiknode.pro/${apiKey}`; + case linea.id: + return `${endpointName}.linea-mainnet.quiknode.pro/${apiKey}`; + case lineaSepolia.id: + return undefined; + case scroll.id: + return `${endpointName}.scroll-mainnet.quiknode.pro/${apiKey}`; + case scrollSepolia.id: + return `${endpointName}.scroll-testnet.quiknode.pro/${apiKey}`; + default: + return undefined; + } +} + export function alchemySupportsChain(chainId: ChainId) { return buildAlchemyBaseUrl(chainId, "") !== undefined; } -export function drpcSupportsChain(chainId: ChainId) { +export function dRPCSupportsChain(chainId: ChainId) { return buildDRPCUrl(chainId, "") !== undefined; } + +export function quickNodeSupportsChain(chainId: ChainId) { + return buildQuickNodeURL(chainId, "", "") !== undefined; +} diff --git a/packages/ensnode-sdk/src/shared/config/environments.ts b/packages/ensnode-sdk/src/shared/config/environments.ts index 2fcad30ae..3344bfc42 100644 --- a/packages/ensnode-sdk/src/shared/config/environments.ts +++ b/packages/ensnode-sdk/src/shared/config/environments.ts @@ -12,6 +12,8 @@ export interface DatabaseEnvironment { export interface RpcEnvironment { [x: `RPC_URL_${number}`]: ChainIdSpecificRpcEnvironmentVariable | undefined; ALCHEMY_API_KEY?: string; + QUICKNODE_API_KEY?: string; + QUICKNODE_ENDPOINT_NAME?: string; DRPC_API_KEY?: string; } diff --git a/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.test.ts b/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.test.ts new file mode 100644 index 000000000..312c99a54 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.test.ts @@ -0,0 +1,155 @@ +import { lineaSepolia } from "viem/chains"; +import { describe, expect, it } from "vitest"; + +import { ENSNamespaceIds } from "@ensnode/datasources"; + +import { deserializeChainId } from "../deserialize"; +import { isHttpProtocol } from "../url"; +import { buildRpcConfigsFromEnv } from "./rpc-configs-from-env"; + +const allPublicEnsNamespaceIds = Object.values(ENSNamespaceIds).filter( + (id) => id !== ENSNamespaceIds.EnsTestEnv, +); + +const rpcConfigHttp = (rpcConfig: string) => + rpcConfig + .split(",") + .map((rpcUrl) => new URL(rpcUrl)) + .filter(isHttpProtocol); + +describe("buildRpcConfigsFromEnv", () => { + const ALCHEMY_API_KEY = "my-alchemy-api-key"; + const QUICKNODE_API_KEY = "my-quicknode-api-key"; + const QUICKNODE_ENDPOINT_NAME = "my-quicknode-endpoint-name"; + const DRPC_API_KEY = "my-drpc-api-key"; + + describe("Auto-generated RPC URLs, Alchemy only", () => { + const env = { + ALCHEMY_API_KEY, + }; + + describe.each(allPublicEnsNamespaceIds)("%s ENS namespace", (ensNamespaceId) => { + const rpcConfigs = buildRpcConfigsFromEnv(env, ensNamespaceId); + + it.each(Object.entries(rpcConfigs))( + "can build RPC URL for chainId %d", + (_chainId, rpcConfig) => { + const [alchemyRpcUrl, drpcRpcUrl, quickNodeRpcUrl] = rpcConfigHttp(rpcConfig); + + expect(alchemyRpcUrl.pathname).toContain(ALCHEMY_API_KEY); + expect(drpcRpcUrl).toBeUndefined(); + expect(quickNodeRpcUrl).toBeUndefined(); + }, + ); + }); + }); + + describe("Auto-generated RPC URLs, Alchemy followed by QuickNode", () => { + const env = { + ALCHEMY_API_KEY, + QUICKNODE_API_KEY, + QUICKNODE_ENDPOINT_NAME, + }; + + describe.each(allPublicEnsNamespaceIds)("%s ENS namespace", (ensNamespaceId) => { + const rpcConfigs = buildRpcConfigsFromEnv(env, ensNamespaceId); + + it.each(Object.entries(rpcConfigs))( + "can build RPC URL for chainId %d", + (chainIdString, rpcConfig) => { + const chainId = deserializeChainId(chainIdString); + const [alchemyRpcUrl, quickNodeRpcUrl] = rpcConfigHttp(rpcConfig); + + expect(alchemyRpcUrl.pathname).toContain(ALCHEMY_API_KEY); + + if (chainId !== lineaSepolia.id) { + expect(quickNodeRpcUrl.pathname).toContain(QUICKNODE_API_KEY); + expect(quickNodeRpcUrl.hostname.startsWith(QUICKNODE_ENDPOINT_NAME)).toBe(true); + } + }, + ); + }); + }); + + describe("Auto-generated RPC URLs, QuickNode followed by dRPC", () => { + const env = { + QUICKNODE_API_KEY, + QUICKNODE_ENDPOINT_NAME, + DRPC_API_KEY, + }; + + describe.each(allPublicEnsNamespaceIds)("%s ENS namespace", (ensNamespaceId) => { + const rpcConfigs = buildRpcConfigsFromEnv(env, ensNamespaceId); + + it.each(Object.entries(rpcConfigs))( + "can build RPC URL for chainId %d", + (chainIdString, rpcConfig) => { + const chainId = deserializeChainId(chainIdString); + + if (chainId !== lineaSepolia.id) { + const [quickNodeRpcUrl, dRPCRpcUrl] = rpcConfigHttp(rpcConfig); + expect(quickNodeRpcUrl.pathname).toContain(QUICKNODE_API_KEY); + expect(quickNodeRpcUrl.hostname.startsWith(QUICKNODE_ENDPOINT_NAME)).toBe(true); + + expect(dRPCRpcUrl.pathname).toContain(DRPC_API_KEY); + } else { + const [dRPCRpcUrl] = rpcConfigHttp(rpcConfig); + expect(dRPCRpcUrl.pathname).toContain(DRPC_API_KEY); + } + }, + ); + }); + }); + + describe("Auto-generated RPC URLs, QuickNode only, both API key and endpoint name provided", () => { + const env = { + QUICKNODE_API_KEY, + QUICKNODE_ENDPOINT_NAME, + }; + + describe.each(allPublicEnsNamespaceIds)("%s ENS namespace", (ensNamespaceId) => { + const rpcConfigs = buildRpcConfigsFromEnv(env, ensNamespaceId); + + it.each(Object.entries(rpcConfigs))( + "can build RPC URL for chainId %d", + (chainIdString, rpcConfig) => { + const chainId = deserializeChainId(chainIdString); + const [quickNodeRpcUrl] = rpcConfigHttp(rpcConfig); + + if (chainId !== lineaSepolia.id) { + expect(quickNodeRpcUrl.pathname).toContain(QUICKNODE_API_KEY); + expect(quickNodeRpcUrl.hostname.startsWith(QUICKNODE_ENDPOINT_NAME)).toBe(true); + } + }, + ); + }); + }); + + describe("Auto-generated RPC URLs, QuickNode only, only API key provided", () => { + const env = { + QUICKNODE_API_KEY, + }; + + describe.each(allPublicEnsNamespaceIds)("%s ENS namespace", (ensNamespaceId) => { + it("should not build RPC URL for chainId %d", () => { + expect(() => buildRpcConfigsFromEnv(env, ensNamespaceId)).toThrowError( + /Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well/i, + ); + }); + }); + }); + + describe("Auto-generated RPC URLs, QuickNode only, only endpoint name provided", () => { + const env = { + QUICKNODE_ENDPOINT_NAME, + }; + + describe.each(allPublicEnsNamespaceIds)("%s ENS namespace", (ensNamespaceId) => { + it("should not build RPC URL for chainId %d", () => { + expect(() => buildRpcConfigsFromEnv(env, ensNamespaceId)).toThrowError( + /Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well/i, + ); + }); + }); + }); +}); 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 a27c04534..fabecebe6 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 @@ -6,7 +6,9 @@ import { alchemySupportsChain, buildAlchemyBaseUrl, buildDRPCUrl, - drpcSupportsChain, + buildQuickNodeURL, + dRPCSupportsChain, + quickNodeSupportsChain, } from "./build-rpc-urls"; import type { ChainIdSpecificRpcEnvironmentVariable, RpcEnvironment } from "./environments"; @@ -14,26 +16,48 @@ import type { ChainIdSpecificRpcEnvironmentVariable, RpcEnvironment } from "./en * Constructs dynamic chain configuration from environment variables, scoped to chain IDs that appear * in the specified `namespace`. * - * This function provides the following RPC URLs in the following order: + * This function auto-generates RPC URLs in the following order: * 1. RPC_URL_*, if available in the env * 2. Alchemy, if ALCHEMY_API_KEY is available in the env - * 3. DRPC, if DRPC_API_KEY is available in the env + * 3. QuickNode, if both, QUICKNODE_API_KEY and QUICKNODE_ENDPOINT_NAME are specified, + * 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 + * TODO: also inject wss:// urls for alchemy, dRPC keys * * NOTE: This function returns raw RpcConfigEnvironment values which are not yet parsed or validated. + * + * @throws when only one but not both of the following environment variables are defined: + * {@link RpcEnvironment.QUICKNODE_API_KEY} or + * {@link RpcEnvironment.QUICKNODE_ENDPOINT_NAME}. */ export function buildRpcConfigsFromEnv( env: RpcEnvironment, namespace: ENSNamespaceId, ): Record { + const alchemyApiKey = env.ALCHEMY_API_KEY; + const quickNodeApiKey = env.QUICKNODE_API_KEY; + const quickNodeEndpointName = env.QUICKNODE_ENDPOINT_NAME; + const dRPCKey = env.DRPC_API_KEY; + + // Invariant: QuickNode: using API key requires using endpoint name as well. + if (quickNodeApiKey && !quickNodeEndpointName) { + throw new Error( + "Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well.", + ); + } + + // Invariant: QuickNode: using endpoint name requires using API key as well. + if (quickNodeEndpointName && !quickNodeApiKey) { + throw new Error( + "Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well.", + ); + } + const chainsInNamespace = Object.entries(getENSNamespace(namespace)).map( ([, datasource]) => (datasource as Datasource).chain, ); - const alchemyApiKey = env.ALCHEMY_API_KEY; - const drpcKey = env.DRPC_API_KEY; - const rpcConfigs: Record = {}; for (const chain of chainsInNamespace) { @@ -47,13 +71,17 @@ export function buildRpcConfigsFromEnv( const httpUrls = [ // alchemy, if specified and available alchemyApiKey && - alchemySupportsChain(chain.id) && // + alchemySupportsChain(chain.id) && `https://${buildAlchemyBaseUrl(chain.id, alchemyApiKey)}`, - // drpc, if specified and available - drpcKey && - drpcSupportsChain(chain.id) && // - buildDRPCUrl(chain.id, drpcKey), + // QuickNode, if specified and available + quickNodeApiKey && + quickNodeEndpointName && + quickNodeSupportsChain(chain.id) && + `https://${buildQuickNodeURL(chain.id, quickNodeApiKey, quickNodeEndpointName)}`, + + // dRPC, if specified and available + dRPCKey && dRPCSupportsChain(chain.id) && buildDRPCUrl(chain.id, dRPCKey), ]; const wsUrl = diff --git a/terraform/main.tf b/terraform/main.tf index 875a14a74..15f781d59 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -134,10 +134,12 @@ module "ensindexer" { ensrainbow_url = module.ensrainbow.ensrainbow_url # Common configuration - render_region = local.render_region - render_environment_id = render_project.ensnode.environments["default"].id - ensdb_url = module.ensdb.internal_connection_string - alchemy_api_key = var.alchemy_api_key + render_region = local.render_region + render_environment_id = render_project.ensnode.environments["default"].id + ensdb_url = module.ensdb.internal_connection_string + alchemy_api_key = var.alchemy_api_key + quicknode_api_key = var.quicknode_api_key + quicknode_endpoint_name = var.quicknode_endpoint_name # The "fully pinned" label set reference that ENSIndexer will request ENSRainbow use for deterministic label healing across time. This label set reference is "fully pinned" as it requires both the labelSetId and labelSetVersion fields to be defined. ensindexer_label_set_id = var.ensindexer_label_set_id diff --git a/terraform/modules/ensindexer/main.tf b/terraform/modules/ensindexer/main.tf index 640613660..9d1a5a4dc 100644 --- a/terraform/modules/ensindexer/main.tf +++ b/terraform/modules/ensindexer/main.tf @@ -1,8 +1,10 @@ locals { common_variables = { # Common configuration - "DATABASE_URL" = { value = var.ensdb_url }, - "ALCHEMY_API_KEY" = { value = var.alchemy_api_key } + "DATABASE_URL" = { value = var.ensdb_url }, + "ALCHEMY_API_KEY" = { value = var.alchemy_api_key } + "QUICKNODE_API_KEY" = { value = var.quicknode_api_key } + "QUICKNODE_ENDPOINT_NAME" = { value = var.quicknode_endpoint_name } } } diff --git a/terraform/modules/ensindexer/variables.tf b/terraform/modules/ensindexer/variables.tf index e557d063d..3c95241ee 100644 --- a/terraform/modules/ensindexer/variables.tf +++ b/terraform/modules/ensindexer/variables.tf @@ -78,3 +78,11 @@ variable "subgraph_compat" { variable "alchemy_api_key" { type = string } + +variable "quicknode_api_key" { + type = string +} + +variable "quicknode_endpoint_name" { + type = string +} diff --git a/terraform/variables.tf b/terraform/variables.tf index fc5d01df8..c3bb1bb6b 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -32,6 +32,14 @@ variable "alchemy_api_key" { type = string } +variable "quicknode_api_key" { + type = string +} + +variable "quicknode_endpoint_name" { + type = string +} + # The "fully pinned" label set reference that ENSIndexer will request ENSRainbow use for deterministic label healing across time. This label set reference is "fully pinned" as it requires both the labelSetId and labelSetVersion fields to be defined. variable "ensindexer_label_set_id" { type = string