diff --git a/.env.template b/.env.template index ee22dde..bb888de 100644 --- a/.env.template +++ b/.env.template @@ -1,18 +1,21 @@ PORT=string # Optional. The API server port. Defaults to 8080. -NETWORK=string # Required. The network to connect to. See `SupportedChains` in `types.ts` for supported networks. - -# You should provide at least one of the following RPC URLs: -RPC_URL_MAINNET=string # The RPC URL for the Mainnet provider. -RPC_ACCESS_TOKEN_MAINNET=string # Optional. The access token to use for the RPC. If specified, it will be used as a bearer token in the `Authorization` header. -RPC_URL_SEPOLIA=string # The RPC URL for the Sepolia provider. -RPC_ACCESS_TOKEN_SEPOLIA=string # Optional. The access token to use for the RPC. If specified, it will be used as a bearer token in the `Authorization` header. -RPC_URL_OPTIMISM_SEPOLIA=string # The RPC URL for the Optimism Sepolia provider. -RPC_ACCESS_TOKEN_OPTIMISM_SEPOLIA=string # Optional. The access token to use for the RPC. If specified, it will be used as a bearer token in the `Authorization` header. -RPC_URL_POLYGON_AMOY=string # The RPC URL for the Polygon Amoy provider. -RPC_ACCESS_TOKEN_POLYGON_AMOY=string # Optional. The access token to use for the RPC. If specified, it will be used as a bearer token in the `Authorization` header. -RPC_URL_FILECOIN=string # The RPC URL for the Filecoin provider. -RPC_ACCESS_TOKEN_FILECOIN=string # Optional. The access token to use for the RPC. If specified, it will be used as a bearer token in the `Authorization` header. +# Required. The RPC configuration. See `SupportedChain` in `graphql.ts` for supported networks. +RPC_CONFIG='{ + "MAINNET": { + "url": "string", # The RPC URL. + "accessToken": "string", # Optional. The access token for the RPC URL. + "fallbackUrl": "string", # Optional. The fallback RPC URL. + "fallbackAccessToken": "string" # Optional. The access token for the fallback RPC URL. + }, + "SEPOLIA": { + "url": "string", # The RPC URL. + "accessToken": "string", # Optional. The access token for the RPC URL. + "fallbackUrl": "string", # Optional. The fallback RPC URL. + "fallbackAccessToken": "string" # Optional. The access token for the fallback RPC URL. + }, + ... # Add more networks as needed. +}' PUBLIC_API_KEYS=string # Required. Comma-separated list of strings API keys that rate limit is applied to. DRIPS_API_KEY=string # Required. API key withouth rate limit. @@ -29,3 +32,4 @@ MAX_QUERY_DEPTH=number # Optional. defaults to 4. TIMEOUT_IN_SECONDS=number # Optional. defaults to 20. IPFS_GATEWAY_URL=string # Optional. The IPFS gateway URL to use for fetching IPFS data. Defaults to 'https://drips.mypinata.cloud'. + diff --git a/package-lock.json b/package-lock.json index 49b376b..c22cf28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "graphql-tag": "^2.12.6", "pg": "^8.11.3", "sequelize": "^6.32.1", - "zod": "^3.23.4" + "zod": "^3.23.8" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.0", diff --git a/package.json b/package.json index c2dc15b..b1a0594 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:e2e": "docker compose up --attach app --build --exit-code-from app && docker compose down", "build:graphql": "graphql-codegen", "build:contracts": "typechain --target=ethers-v6 --out-dir ./src/generated/contracts ./src/abi/**.json", - "build": "npm run build:contracts && npm run build:graphql && tsc", + "build": "./scripts/check-env-vars.sh && npm run build:contracts && npm run build:graphql && tsc", "dev": "npx nodemon", "start": "node dist/index.js", "check": "tsc --noEmit" @@ -62,6 +62,6 @@ "graphql-tag": "^2.12.6", "pg": "^8.11.3", "sequelize": "^6.32.1", - "zod": "^3.23.4" + "zod": "^3.23.8" } } diff --git a/scripts/check-env-vars.sh b/scripts/check-env-vars.sh new file mode 100755 index 0000000..f63a6ce --- /dev/null +++ b/scripts/check-env-vars.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +echo 🤓 Checking env vars... +echo + +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +required_vars=("RPC_CONFIG" "POSTGRES_CONNECTION_STRING" "DRIPS_API_KEY" "PUBLIC_API_KEYS") + +for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo "❌ Error: $var is not set." + exit 1 + fi +done + +echo "✅ All required environment variables are set." diff --git a/src/common/FailoverProvider.ts b/src/common/FailoverProvider.ts new file mode 100644 index 0000000..7c2c1ab --- /dev/null +++ b/src/common/FailoverProvider.ts @@ -0,0 +1,101 @@ +import type { + JsonRpcApiProviderOptions, + JsonRpcPayload, + JsonRpcResult, + Networkish, +} from 'ethers'; +import { JsonRpcProvider, FetchRequest } from 'ethers'; + +class AggregatedRpcError extends Error { + public readonly errors: { rpcEndpoint: string; error: any }[]; + + constructor(errors: { rpcEndpoint: string; error: any }[]) { + super( + `All RPC endpoints failed:\n${errors.map((e) => `Endpoint '${e.rpcEndpoint}' failed with error: ${e.error.message}.`).join('\n')}`, + ); + + this.name = 'AggregatedRpcError'; + this.errors = errors; + } +} + +/** + * A `JsonRpcProvider` that transparently fails over to a list of backup JSON-RPC endpoints. + */ +export default class FailoverJsonRpcProvider extends JsonRpcProvider { + private readonly _rpcEndpoints: (string | FetchRequest)[]; + + /** + * @param rpcEndpoints An array of JSON-RPC endpoints. The order determines the failover order. + */ + constructor( + rpcEndpoints: (string | FetchRequest)[], + network?: Networkish, + options?: JsonRpcApiProviderOptions, + ) { + super(rpcEndpoints[0], network, options); + + this._rpcEndpoints = rpcEndpoints; + } + + /** + * Overrides the `_send` method to try each endpoint until the request succeeds. + * + * @param payload - The JSON-RPC payload or array of payloads to send. + * @returns A promise that resolves to the result of the first successful JSON-RPC call. + */ + public override async _send( + payload: JsonRpcPayload | Array, + ): Promise> { + // The actual sending of the request is the same as in the base class. + // The only difference is that we're creating a new `FetchRequest` for each endpoint, + // instead of getting the `request` from `_getConnection()`, which will return the *primary* (first) endpoint. + + const errors: { rpcEndpoint: string; error: any }[] = []; + + // Try each endpoint, in order. + for (const rpcEndpoint of this._rpcEndpoints) { + try { + let request: FetchRequest; + + if (typeof rpcEndpoint === 'string') { + request = new FetchRequest(rpcEndpoint); + } else { + request = rpcEndpoint.clone(); + } + + request.body = JSON.stringify(payload); + request.setHeader('content-type', 'application/json'); + const response = await request.send(); + response.assertOk(); + + let resp = response.bodyJson; + if (!Array.isArray(resp)) { + resp = [resp]; + } + + return resp; + } catch (error: any) { + const endpointUrl = this._getRpcEndpointUrl(rpcEndpoint); + errors.push({ rpcEndpoint: endpointUrl, error }); + } + } + + throw new AggregatedRpcError(errors); + } + + /** + * Returns a copy of the endpoint URLs used by the provider. + */ + public getRpcEndpointUrls(): string[] { + return this._rpcEndpoints.map(this._getRpcEndpointUrl); + } + + private _getRpcEndpointUrl(rpcEndpoint: string | FetchRequest): string { + if (typeof rpcEndpoint === 'string') { + return rpcEndpoint; + } + + return rpcEndpoint.url; + } +} diff --git a/src/common/appSettings.ts b/src/common/appSettings.ts index 935946c..13cf778 100644 --- a/src/common/appSettings.ts +++ b/src/common/appSettings.ts @@ -1,37 +1,30 @@ import dotenv from 'dotenv'; -import type { SupportedChain } from '../generated/graphql'; +import { z } from 'zod'; +import { SupportedChain } from '../generated/graphql'; dotenv.config(); -type RpcConfig = { - url: string; - accessToken?: string; -}; +function missingEnvVar(name: string): never { + throw new Error(`Missing ${name} in .env file.`); +} + +const RpcConfigSchema = z.record( + z.nativeEnum(SupportedChain), + z + .object({ + url: z.string().url(), + accessToken: z.string().optional(), + fallbackUrl: z.string().optional(), + fallbackAccessToken: z.string().optional(), + }) + .optional(), +); export default { port: (process.env.PORT || 8080) as number, - rpcConfigs: { - MAINNET: { - url: process.env.RPC_URL_MAINNET, - accessToken: process.env.RPC_ACCESS_TOKEN_MAINNET, - }, - SEPOLIA: { - url: process.env.RPC_URL_SEPOLIA, - accessToken: process.env.RPC_ACCESS_TOKEN_SEPOLIA, - }, - OPTIMISM_SEPOLIA: { - url: process.env.RPC_URL_OPTIMISM_SEPOLIA, - accessToken: process.env.RPC_ACCESS_TOKEN_OPTIMISM_SEPOLIA, - }, - POLYGON_AMOY: { - url: process.env.RPC_URL_POLYGON_AMOY, - accessToken: process.env.RPC_ACCESS_TOKEN_POLYGON_AMOY, - }, - FILECOIN: { - url: process.env.RPC_URL_FILECOIN, - accessToken: process.env.RPC_ACCESS_TOKEN_FILECOIN, - }, - } as Record, + rpcConfig: process.env.RPC_CONFIG + ? RpcConfigSchema.parse(JSON.parse(process.env.RPC_CONFIG)) + : missingEnvVar('RPC_CONFIG'), publicApiKeys: process.env.PUBLIC_API_KEYS?.split(',') || [], dripsApiKey: process.env.DRIPS_API_KEY, postgresConnectionString: process.env.POSTGRES_CONNECTION_STRING, diff --git a/src/common/dripsContracts.ts b/src/common/dripsContracts.ts index 2ed9a54..9280710 100644 --- a/src/common/dripsContracts.ts +++ b/src/common/dripsContracts.ts @@ -1,9 +1,4 @@ -import { - FetchRequest, - JsonRpcProvider, - WebSocketProvider, - ethers, -} from 'ethers'; +import { FetchRequest, ethers } from 'ethers'; import appSettings from './appSettings'; import type { AddressDriver, Drips, RepoDriver } from '../generated/contracts'; import { @@ -21,6 +16,7 @@ import type { ProjectId, } from './types'; import queryableChains from './queryableChains'; +import FailoverJsonRpcProvider from './FailoverProvider'; const chainConfigs: Record< SupportedChain, @@ -62,10 +58,10 @@ const chainConfigs: Record< }, }; -const { rpcConfigs } = appSettings; +const { rpcConfig } = appSettings; const providers: { - [network in SupportedChain]?: JsonRpcProvider | WebSocketProvider; + [network in SupportedChain]?: FailoverJsonRpcProvider; } = {}; function createAuthFetchRequest(rpcUrl: string, token: string): FetchRequest { @@ -77,27 +73,28 @@ function createAuthFetchRequest(rpcUrl: string, token: string): FetchRequest { } Object.values(SupportedChain).forEach((network) => { - const rpcConfig = rpcConfigs[network]; - - if (rpcConfig?.url) { - const rpcUrl = rpcConfig.url; - - let provider: JsonRpcProvider | WebSocketProvider | null = null; - - if (rpcUrl.startsWith('http')) { - provider = rpcConfig?.accessToken - ? new JsonRpcProvider( - createAuthFetchRequest(rpcUrl, rpcConfig.accessToken), - ) - : new JsonRpcProvider(rpcUrl); - } else if (rpcUrl.startsWith('wss')) { - provider = new WebSocketProvider(rpcUrl); - } else { - shouldNeverHappen(`Invalid RPC URL: ${rpcUrl}`); - } - - providers[network] = provider; + const config = rpcConfig[network]; + + if (!config) { + return; + } + + const { url, accessToken, fallbackUrl, fallbackAccessToken } = config; + + const primaryEndpoint = accessToken + ? createAuthFetchRequest(url, accessToken) + : url; + + const rpcEndpoints = [primaryEndpoint]; + + if (fallbackUrl) { + const fallbackEndpoint = fallbackAccessToken + ? createAuthFetchRequest(fallbackUrl, fallbackAccessToken) + : fallbackUrl; + rpcEndpoints.push(fallbackEndpoint); } + + providers[network] = new FailoverJsonRpcProvider(rpcEndpoints); }); const dripsContracts: { diff --git a/src/common/queryableChains.ts b/src/common/queryableChains.ts index ea7efe2..2b6e701 100644 --- a/src/common/queryableChains.ts +++ b/src/common/queryableChains.ts @@ -7,7 +7,7 @@ import appSettings from './appSettings'; const queryableChains: SupportedChain[] = []; Object.keys(SupportedChain).forEach((chain) => { - if (appSettings.rpcConfigs[chain as SupportedChain]?.url) { + if (appSettings.rpcConfig[chain as SupportedChain]?.url) { queryableChains.push(chain as SupportedChain); } }); diff --git a/src/environment.d.ts b/src/environment.d.ts index b98572b..2b7373f 100644 --- a/src/environment.d.ts +++ b/src/environment.d.ts @@ -4,22 +4,7 @@ declare global { namespace NodeJS { interface ProcessEnv { PORT: string; - - RPC_URL_MAINNET: string | undefined; - RPC_ACCESS_TOKEN_MAINNET: string | undefined; - - RPC_URL_SEPOLIA: string | undefined; - RPC_ACCESS_TOKEN_SEPOLIA: string | undefined; - - RPC_URL_OPTIMISM_SEPOLIA: string | undefined; - RPC_ACCESS_TOKEN_OPTIMISM_SEPOLIA: string | undefined; - - RPC_URL_POLYGON_AMOY: string | undefined; - RPC_ACCESS_TOKEN_POLYGON_AMOY: string | undefined; - - RPC_URL_FILECOIN: string | undefined; - RPC_ACCESS_TOKEN_FILECOIN: string | undefined; - + RPC_CONFIG: string; PUBLIC_API_KEYS: string; DRIPS_API_KEY: string; NODE_ENV: 'development' | 'production';