Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: use the Failover Provider #28

Merged
merged 5 commits into from
Oct 15, 2024
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
30 changes: 17 additions & 13 deletions .env.template
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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'.

2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
19 changes: 19 additions & 0 deletions scripts/check-env-vars.sh
Original file line number Diff line number Diff line change
@@ -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."
101 changes: 101 additions & 0 deletions src/common/FailoverProvider.ts
Original file line number Diff line number Diff line change
@@ -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<JsonRpcPayload>,
): Promise<Array<JsonRpcResult>> {
// 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;
}
}
47 changes: 20 additions & 27 deletions src/common/appSettings.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedChain, RpcConfig | undefined>,
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,
Expand Down
53 changes: 25 additions & 28 deletions src/common/dripsContracts.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,6 +16,7 @@ import type {
ProjectId,
} from './types';
import queryableChains from './queryableChains';
import FailoverJsonRpcProvider from './FailoverProvider';

const chainConfigs: Record<
SupportedChain,
Expand Down Expand Up @@ -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 {
Expand All @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/common/queryableChains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down
17 changes: 1 addition & 16 deletions src/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading