diff --git a/src/controllers/BlueprintController.ts b/src/controllers/BlueprintController.ts index 8d5a6613..98dfe0e6 100644 --- a/src/controllers/BlueprintController.ts +++ b/src/controllers/BlueprintController.ts @@ -21,8 +21,8 @@ import { SupabaseDataService } from "../services/SupabaseDataService.js"; import { verifyAuthSignedData } from "../utils/verifyAuthSignedData.js"; import { isAddress } from "viem"; import { Json } from "../types/supabaseData.js"; -import { getEvmClient } from "../utils/getRpcUrl.js"; import { waitForTxThenMintBlueprint } from "../utils/waitForTxThenMintBlueprint.js"; +import { EvmClientFactory } from "../utils/evmClient.js"; @Route("v1/blueprints") @Tags("Blueprints") @@ -367,7 +367,7 @@ export class BlueprintController extends Controller { }; } - const client = getEvmClient(chain_id); + const client = EvmClientFactory.createClient(chain_id); const transaction = await client.getTransaction({ hash: tx_hash as `0x${string}`, }); diff --git a/src/controllers/MarketplaceController.ts b/src/controllers/MarketplaceController.ts index e719ef61..bf615eb9 100644 --- a/src/controllers/MarketplaceController.ts +++ b/src/controllers/MarketplaceController.ts @@ -19,10 +19,10 @@ import { z } from "zod"; import { isAddress, verifyMessage } from "viem"; import { SupabaseDataService } from "../services/SupabaseDataService.js"; import { getFractionsById } from "../utils/getFractionsById.js"; -import { getRpcUrl } from "../utils/getRpcUrl.js"; import { isParsableToBigInt } from "../utils/isParsableToBigInt.js"; import { getHypercertTokenId } from "../utils/tokenIds.js"; import { BaseResponse } from "../types/api.js"; +import { EvmClientFactory } from "../utils/evmClient.js"; export interface CreateOrderRequest { signature: string; @@ -148,7 +148,9 @@ export class MarketplaceController extends Controller { const hec = new HypercertExchangeClient( chainId, // @ts-expect-error Typing issue with provider - new ethers.JsonRpcProvider(getRpcUrl(chainId)), + new ethers.JsonRpcProvider( + EvmClientFactory.getFirstAvailableUrl(chainId), + ), ); const typedData = hec.getTypedDataDomain(); diff --git a/src/lib/safe-signature-verification/SafeSignatureVerifier.ts b/src/lib/safe-signature-verification/SafeSignatureVerifier.ts index 4b4bff8e..a3f75ab4 100644 --- a/src/lib/safe-signature-verification/SafeSignatureVerifier.ts +++ b/src/lib/safe-signature-verification/SafeSignatureVerifier.ts @@ -1,7 +1,6 @@ import { getAddress, hashTypedData, type HashTypedDataParameters } from "viem"; import Safe from "@safe-global/protocol-kit"; - -import { getRpcUrl } from "../../utils/getRpcUrl.js"; +import { EvmClientFactory } from "../../utils/evmClient.js"; export default abstract class SafeSignatureVerifier { protected chainId: number; @@ -9,7 +8,7 @@ export default abstract class SafeSignatureVerifier { protected rpcUrl: string; constructor(chainId: number, safeAddress: `0x${string}`) { - const rpcUrl = getRpcUrl(chainId); + const rpcUrl = EvmClientFactory.getFirstAvailableUrl(chainId); if (!rpcUrl) { throw new Error(`Unsupported chain ID: ${chainId}`); diff --git a/src/services/SupabaseDataService.ts b/src/services/SupabaseDataService.ts index 799e87e3..f9cc85f3 100644 --- a/src/services/SupabaseDataService.ts +++ b/src/services/SupabaseDataService.ts @@ -11,7 +11,6 @@ import { OrderValidatorCode, } from "@hypercerts-org/marketplace-sdk"; import { ethers } from "ethers"; -import { getRpcUrl } from "../utils/getRpcUrl.js"; import { singleton } from "tsyringe"; import { GetUserArgs } from "../graphql/schemas/args/userArgs.js"; import type { DataDatabase as KyselyDataDatabase } from "../types/kyselySupabaseData.js"; @@ -23,6 +22,7 @@ import { GetBlueprintArgs } from "../graphql/schemas/args/blueprintArgs.js"; import { sql } from "kysely"; import { GetSignatureRequestArgs } from "../graphql/schemas/args/signatureRequestArgs.js"; import { GetCollectionsArgs } from "../graphql/schemas/args/collectionArgs.js"; +import { EvmClientFactory } from "../utils/evmClient.js"; @singleton() export class SupabaseDataService extends BaseSupabaseService { @@ -278,7 +278,9 @@ export class SupabaseDataService extends BaseSupabaseService const hec = new HypercertExchangeClient( chainId, // @ts-expect-error Typing issue with provider - new ethers.JsonRpcProvider(getRpcUrl(chainId)), + new ethers.JsonRpcProvider( + EvmClientFactory.getFirstAvailableUrl(chainId), + ), ); const validationResults = await hec.checkOrdersValidity(matchingOrders); diff --git a/src/utils/chainFactory.ts b/src/utils/chainFactory.ts new file mode 100644 index 00000000..e24a809d --- /dev/null +++ b/src/utils/chainFactory.ts @@ -0,0 +1,47 @@ +import { Chain } from "viem"; +import { + arbitrum, + arbitrumSepolia, + base, + baseSepolia, + celo, + filecoin, + filecoinCalibration, + mainnet, + optimism, + sepolia, +} from "viem/chains"; + +const SUPPORTED_CHAINS = [ + mainnet, + optimism, + base, + arbitrum, + celo, + sepolia, + arbitrumSepolia, + baseSepolia, + filecoin, + filecoinCalibration, +]; + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class ChainFactory { + static getChain(chainId: number): Chain { + const chains = SUPPORTED_CHAINS.reduce( + (acc, chain) => { + acc[chain.id] = chain; + return acc; + }, + {} as Record, + ); + + const chain = chains[chainId]; + if (!chain) throw new Error(`Unsupported chain ID: ${chainId}`); + return chain; + } + + static getSupportedChains(): number[] { + return SUPPORTED_CHAINS.map((chain) => chain.id); + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 69f78c32..2606ba24 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -16,3 +16,4 @@ export const infuraApiKey = getRequiredEnvVar("INFURA_API_KEY"); export const drpcApiPkey = getRequiredEnvVar("DRPC_API_KEY"); export const cachingDatabaseUrl = getRequiredEnvVar("CACHING_DATABASE_URL"); export const dataDatabaseUrl = getRequiredEnvVar("DATA_DATABASE_URL"); +export const filecoinApiKey = getRequiredEnvVar("FILECOIN_API_KEY"); diff --git a/src/utils/evmClient.ts b/src/utils/evmClient.ts new file mode 100644 index 00000000..795f102f --- /dev/null +++ b/src/utils/evmClient.ts @@ -0,0 +1,105 @@ +import { PublicClient, createPublicClient, fallback } from "viem"; +import { ChainFactory } from "./chainFactory.js"; +import { UnifiedRpcClientFactory } from "./rpcClientFactory.js"; +import { alchemyApiKey, drpcApiPkey, infuraApiKey } from "./constants.js"; + +interface RpcProvider { + getUrl(chainId: number): string | undefined; +} + +class AlchemyProvider implements RpcProvider { + getUrl(chainId: number): string | undefined { + if (!alchemyApiKey) return undefined; + const urls: Record = { + 10: `https://opt-mainnet.g.alchemy.com/v2/${alchemyApiKey}`, + 8453: `https://base-mainnet.g.alchemy.com/v2/${alchemyApiKey}`, + 42161: `https://arb-mainnet.g.alchemy.com/v2/${alchemyApiKey}`, + 421614: `https://arb-sepolia.g.alchemy.com/v2/${alchemyApiKey}`, + 84532: `https://base-sepolia.g.alchemy.com/v2/${alchemyApiKey}`, + 11155111: `https://eth-sepolia.g.alchemy.com/v2/${alchemyApiKey}`, + }; + return urls[chainId]; + } +} + +class InfuraProvider implements RpcProvider { + getUrl(chainId: number): string | undefined { + if (!infuraApiKey) return undefined; + const urls: Record = { + 10: `https://optimism-mainnet.infura.io/v3/${infuraApiKey}`, + 42220: `https://celo-mainnet.infura.io/v3/${infuraApiKey}`, + 42161: `https://arbitrum-mainnet.infura.io/v3/${infuraApiKey}`, + 421614: `https://arbitrum-sepolia.infura.io/v3/${infuraApiKey}`, + }; + return urls[chainId]; + } +} + +class DrpcProvider implements RpcProvider { + getUrl(chainId: number): string | undefined { + if (!drpcApiPkey) return undefined; + const networks: Record = { + 10: "optimism", + 8453: "base", + 42220: "celo", + 42161: "arbitrum", + 421614: "arbitrum-sepolia", + }; + const network = networks[chainId]; + return network + ? `https://lb.drpc.org/ogrpc?network=${network}&dkey=${drpcApiPkey}` + : undefined; + } +} + +class GlifProvider implements RpcProvider { + getUrl(chainId: number): string | undefined { + const urls: Record = { + 314: `https://node.glif.io/space07/lotus/rpc/v1`, + 314159: `https://calibration.node.glif.io/archive/lotus/rpc/v1`, + }; + return urls[chainId]; + } +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class EvmClientFactory { + private static readonly providers: RpcProvider[] = [ + new AlchemyProvider(), + new InfuraProvider(), + new DrpcProvider(), + new GlifProvider(), + ]; + + static createClient(chainId: number): PublicClient { + const urls = EvmClientFactory.getAllAvailableUrls(chainId); + if (urls.length === 0) + throw new Error(`No RPC URL available for chain ${chainId}`); + + const transports = urls.map((url) => + UnifiedRpcClientFactory.createViemTransport(chainId, url), + ); + + return createPublicClient({ + chain: ChainFactory.getChain(chainId), + transport: fallback(transports), + }); + } + + static getAllAvailableUrls(chainId: number): string[] { + return EvmClientFactory.providers + .map((provider) => provider.getUrl(chainId)) + .filter((url): url is string => url !== undefined); + } + + // Keep this for backward compatibility + static getFirstAvailableUrl(chainId: number): string | undefined { + return EvmClientFactory.getAllAvailableUrls(chainId)[0]; + } +} + +export const getRpcUrl = (chainId: number): string => { + const url = EvmClientFactory.getFirstAvailableUrl(chainId); + if (!url) throw new Error(`No RPC URL available for chain ${chainId}`); + return url; +}; diff --git a/src/utils/getRpcUrl.ts b/src/utils/getRpcUrl.ts deleted file mode 100644 index f36980cf..00000000 --- a/src/utils/getRpcUrl.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { alchemyApiKey, drpcApiPkey, infuraApiKey } from "./constants.js"; -import { createPublicClient, fallback, http } from "viem"; -import { - arbitrum, - arbitrumSepolia, - base, - baseSepolia, - celo, - optimism, - sepolia, -} from "viem/chains"; - -const selectedNetwork = (chainId: number) => { - switch (chainId) { - case 10: - return optimism; - case 8453: - return base; - case 42220: - return celo; - case 42161: - return arbitrum; - case 421614: - return arbitrumSepolia; - case 84532: - return baseSepolia; - case 11155111: - return sepolia; - default: - throw new Error(`Unsupported chain ID: ${chainId}`); - } -}; - -export const alchemyUrl = (chainId: number) => { - switch (chainId) { - case 10: - return `https://opt-mainnet.g.alchemy.com/v2/${alchemyApiKey}`; - case 8453: - return `https://base-mainnet.g.alchemy.com/v2/${alchemyApiKey}`; - case 42220: - return; - case 42161: - return `https://arb-mainnet.g.alchemy.com/v2/${alchemyApiKey}`; - case 421614: - return `https://arb-sepolia.g.alchemy.com/v2/${alchemyApiKey}`; - case 84532: - return `https://base-sepolia.g.alchemy.com/v2/${alchemyApiKey}`; - case 11155111: - return `https://eth-sepolia.g.alchemy.com/v2/${alchemyApiKey}`; - default: - throw new Error(`Unsupported chain ID: ${chainId}`); - } -}; - -const infuraUrl = (chainId: number) => { - switch (chainId) { - case 10: - return `https://optimism-mainnet.infura.io/v3/${infuraApiKey}`; - case 8453: - return; - case 42220: - return `https://celo-mainnet.infura.io/v3/${infuraApiKey}`; - case 42161: - return `https://arbitrum-mainnet.infura.io/v3/${infuraApiKey}`; - case 421614: - return `https://arbitrum-sepolia.infura.io/v3/${infuraApiKey}`; - case 84532: - return; - case 11155111: - return `https://sepolia.infura.io/v3/${infuraApiKey}`; - default: - throw new Error(`Unsupported chain ID: ${chainId}`); - } -}; - -const drpcUrl = (chainId: number) => { - switch (chainId) { - case 10: - return `https://lb.drpc.org/ogrpc?network=optimism&dkey=${drpcApiPkey}`; - case 8453: - return `https://lb.drpc.org/ogrpc?network=base&dkey=${drpcApiPkey}`; - case 42220: - return `https://lb.drpc.org/ogrpc?network=celo&dkey=${drpcApiPkey}`; - case 42161: - return `https://lb.drpc.org/ogrpc?network=arbitrum&dkey=${drpcApiPkey}`; - case 421614: - return `https://lb.drpc.org/ogrpc?network=arbitrum-sepolia&dkey=${drpcApiPkey}`; - case 84532: - return; - case 11155111: - return; - default: - throw new Error(`Unsupported chain ID: ${chainId}`); - } -}; - -const rpc_timeout = 20_000; - -export const getRpcUrl = (chainId: number) => { - const alchemy = alchemyUrl(chainId); - const infura = infuraUrl(chainId); - const drpc = drpcUrl(chainId); - return [alchemy, infura, drpc].filter((url) => url)[0]; -}; - -const fallBackProvider = (chainId: number) => { - const alchemy = alchemyUrl(chainId) - ? [http(alchemyUrl(chainId), { timeout: rpc_timeout })] - : []; - const infura = infuraUrl(chainId) - ? [http(infuraUrl(chainId), { timeout: rpc_timeout })] - : []; - const drpc = drpcUrl(chainId) - ? [http(drpcUrl(chainId), { timeout: rpc_timeout })] - : []; - return fallback([...alchemy, ...drpc, ...infura], { - retryCount: 5, - }); -}; - -/* Returns a PublicClient instance for the configured network. */ -export const getEvmClient = (chainId: number) => - createPublicClient({ - chain: selectedNetwork(chainId), - transport: fallBackProvider(chainId), - }); diff --git a/src/utils/getTokenPriceInUSD.ts b/src/utils/getTokenPriceInUSD.ts index 813dd1f5..97ee2654 100644 --- a/src/utils/getTokenPriceInUSD.ts +++ b/src/utils/getTokenPriceInUSD.ts @@ -4,15 +4,15 @@ import { Currency, } from "@hypercerts-org/marketplace-sdk"; import { formatUnits, getAddress } from "viem"; -import { getEvmClient } from "./getRpcUrl.js"; import { AggregatorV3Abi } from "../abis/AggregatorV3Abi.js"; import { LRUCache } from "lru-cache"; +import { EvmClientFactory } from "./evmClient.js"; export const getTokenPriceInUSD = async ( chainId: ChainId, tokenAddress: string, ) => { - const client = getEvmClient(chainId); + const client = EvmClientFactory.createClient(chainId); // The address of the contract which will provide the price of ETH const feedAddress = tokenAddressToFeedAddress(chainId, tokenAddress); diff --git a/src/utils/rpcClientFactory.ts b/src/utils/rpcClientFactory.ts new file mode 100644 index 00000000..39d6c55e --- /dev/null +++ b/src/utils/rpcClientFactory.ts @@ -0,0 +1,70 @@ +import { createPublicClient, http, Transport } from "viem"; +import { ChainFactory } from "./chainFactory.js"; +import { filecoinApiKey } from "./constants.js"; + +interface RpcConfig { + url: string; + headers?: Record; + timeout?: number; +} + +// Chain-specific RPC configuration factory +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +class RpcConfigFactory { + private static readonly DEFAULT_TIMEOUT = 20_000; + + static getConfig(chainId: number, url: string): RpcConfig { + const baseConfig: RpcConfig = { + url, + timeout: this.DEFAULT_TIMEOUT, + }; + + // Chain-specific configurations + switch (chainId) { + case 314: + case 314159: + return { + ...baseConfig, + headers: { + Authorization: `Bearer ${filecoinApiKey}`, + }, + }; + default: + return baseConfig; + } + } +} + +// Unified client factory for both Viem and Chainsauce clients +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class UnifiedRpcClientFactory { + // Creates a Viem transport + static createViemTransport(chainId: number, url: string): Transport { + const config = RpcConfigFactory.getConfig(chainId, url); + + const httpConfig: Parameters[1] = { + timeout: config.timeout, + }; + + if (config.headers) { + httpConfig.fetchOptions = { + headers: config.headers, + }; + } + + return http(config.url, httpConfig); + } + + // Creates a Viem public client + static createViemClient(chainId: number, url: string) { + return createPublicClient({ + chain: ChainFactory.getChain(chainId), + transport: this.createViemTransport(chainId, url), + }); + } +} + +// Helper function to create appropriate client based on context +export const createRpcClient = (chainId: number, url: string) => { + return UnifiedRpcClientFactory.createViemClient(chainId, url); +}; diff --git a/src/utils/verifyAuthSignedData.ts b/src/utils/verifyAuthSignedData.ts index b40276e0..db3eb447 100644 --- a/src/utils/verifyAuthSignedData.ts +++ b/src/utils/verifyAuthSignedData.ts @@ -1,5 +1,5 @@ import { VerifyTypedDataParameters } from "viem"; -import { getEvmClient } from "./getRpcUrl.js"; +import { EvmClientFactory } from "./evmClient.js"; export const verifyAuthSignedData = async ({ requiredChainId, @@ -8,7 +8,7 @@ export const verifyAuthSignedData = async ({ VerifyTypedDataParameters, "address" | "message" | "types" | "signature" | "primaryType" > & { requiredChainId: number }) => { - const client = getEvmClient(requiredChainId); + const client = EvmClientFactory.createClient(requiredChainId); try { return await client.verifyTypedData({ ...args, diff --git a/src/utils/waitForTxThenMintBlueprint.ts b/src/utils/waitForTxThenMintBlueprint.ts index 9c4e9e71..9933492d 100644 --- a/src/utils/waitForTxThenMintBlueprint.ts +++ b/src/utils/waitForTxThenMintBlueprint.ts @@ -1,13 +1,13 @@ -import { getEvmClient } from "./getRpcUrl.js"; import { generateHypercertIdFromReceipt } from "./generateHypercertIdFromReceipt.js"; import { SupabaseDataService } from "../services/SupabaseDataService.js"; +import { EvmClientFactory } from "./evmClient.js"; export const waitForTxThenMintBlueprint = async ( tx_hash: string, chain_id: number, blueprintId: number, ) => { - const client = getEvmClient(chain_id); + const client = EvmClientFactory.createClient(chain_id); const receipt = await client.waitForTransactionReceipt({ hash: tx_hash as `0x${string}`, diff --git a/test/utils/evmClient.test.ts b/test/utils/evmClient.test.ts new file mode 100644 index 00000000..0047bfdf --- /dev/null +++ b/test/utils/evmClient.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { EvmClientFactory, getRpcUrl } from "../../src/utils/evmClient.js"; + +vi.mock("@/utils/constants", () => ({ + environment: "test", + alchemyApiKey: "mock-alchemy-key", + infuraApiKey: "mock-infura-key", + drpcApiPkey: "mock-drpc-key", + filecoinApiKey: "mock-filecoin-key", + Environment: { TEST: "test", PROD: "prod" }, +})); + +vi.mock("@/clients/rpcClientFactory", () => ({ + UnifiedRpcClientFactory: { + createViemTransport: vi.fn().mockReturnValue({ + request: vi.fn(), + retryCount: 3, + timeout: 20_000, + }), + }, +})); + +vi.mock("./chainFactory", () => ({ + ChainFactory: { + getChain: vi.fn(), + }, +})); + +vi.mock("viem", () => ({ + createPublicClient: vi.fn(), + fallback: vi.fn((transports) => transports), + http: vi.fn((url) => ({ url })), +})); + +describe("EvmClient", () => { + describe("EvmClientFactory", () => { + describe("getAllAvailableUrls", () => { + it("returns all available URLs for supported chain", () => { + const sepoliaUrls = EvmClientFactory.getAllAvailableUrls(11155111); + expect(sepoliaUrls).toHaveLength(1); // Alchemy for Optimism + expect(sepoliaUrls[0]).toContain("alchemy.com"); + + const opUrls = EvmClientFactory.getAllAvailableUrls(10); + expect(opUrls).toHaveLength(3); // Alchemy, Infura, DRPC for Optimism + expect(opUrls[0]).toContain("alchemy.com"); + expect(opUrls[1]).toContain("infura.io"); + expect(opUrls[2]).toContain("drpc.org"); + }); + + it("returns empty array for unsupported chain", () => { + const urls = EvmClientFactory.getAllAvailableUrls(999999); + expect(urls).toHaveLength(0); + }); + }); + + describe("getFirstAvailableUrl", () => { + it("returns first available URL for supported chain", () => { + const url = EvmClientFactory.getFirstAvailableUrl(11155111); + expect(url).toContain("alchemy.com"); + }); + + it("returns undefined for unsupported chain", () => { + const url = EvmClientFactory.getFirstAvailableUrl(999999); + expect(url).toBeUndefined(); + }); + }); + }); + + describe("getRpcUrl", () => { + it("should return URL for supported chain", () => { + const url = getRpcUrl(11155111); + expect(url).toContain("alchemy.com"); + expect(url).toContain("mock-alchemy-key"); + }); + + it("should throw error for unsupported chain", () => { + expect(() => getRpcUrl(999999)).toThrow( + "No RPC URL available for chain 999999", + ); + }); + }); +}); + +describe("RPC Providers", () => { + describe("getRpcUrl", () => { + it("should return Alchemy URL for supported chains", () => { + const url = getRpcUrl(11155111); // Sepolia + expect(url).toContain("alchemy.com"); + expect(url).toContain("alchemy-key"); + }); + + it("should return Infura URL when Alchemy is not available", () => { + const url = getRpcUrl(42220); // Celo + expect(url).toContain("infura.io"); + expect(url).toContain("infura-key"); + }); + + it("should return Glif URL for Filecoin", () => { + const url = getRpcUrl(314159); + expect(url).toContain("glif.io"); + }); + + it("should throw error for unsupported chain", () => { + expect(() => getRpcUrl(999999)).toThrow( + "No RPC URL available for chain 999999", + ); + }); + }); +});