Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/forty-coins-lose.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/smooth-lines-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

Add QuickNode RPC provider support for auto-generated chain RPC URLs.
18 changes: 0 additions & 18 deletions .github/workflows/deploy_ensnode_blue_green.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy_ensnode_yellow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
84 changes: 66 additions & 18 deletions apps/ensapi/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,79 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database
# Resolution requests you make to ENSApi).
#
# Private RPC service options include:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please see all the refinements I defined for apps/ensindexer/.env.local.example and apply the same refinements here. Thanks

# - 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/<alchemy-api-key>
# - wss://eth-mainnet.g.alchemy.com/v2/<alchemy-api-key>
# - https://base-sepolia.g.alchemy.com/v2/<alchemy-api-key>
# - QuickNode RPC endpoints
# - https://<quicknode-endpoint-name>.quiknode.pro/<quicknode-api-key>
# - wss://<quicknode-endpoint-name>.quiknode.pro/<quicknode-api-key>
# - https://<quicknode-endpoint-name>.base-sepolia.quiknode.pro/<quicknode-api-key>
# - dRPC RPC endpoints
# - https://lb.drpc.live/ethereum/<drpc-api-key>
# - wss://lb.drpc.live/ethereum/<drpc-api-key>
# - https://lb.drpc.live/base-sepolia/<drpc-api-key>
# - Infura RPC endpoints
# - https://mainnet.infura.io/v3/<infura-api-key>
# - wss://mainnet.infura.io/ws/v3/<infura-api-key>
# - https://base-sepolia.infura.io/v3/<infura-api-key>
#
# 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".
Expand Down
67 changes: 66 additions & 1 deletion apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down
9 changes: 3 additions & 6 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
17 changes: 13 additions & 4 deletions apps/ensapi/src/lib/rpc/public-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
),
});
}
Loading