diff --git a/apps/api/src/api/controllers/moonbeam.controller.ts b/apps/api/src/api/controllers/moonbeam.controller.ts index e8d6ebaff..208180313 100644 --- a/apps/api/src/api/controllers/moonbeam.controller.ts +++ b/apps/api/src/api/controllers/moonbeam.controller.ts @@ -50,7 +50,7 @@ export const executeXcmController = async ( try { const { maxFeePerGas, maxPriorityFeePerGas } = await moonbeamClient.estimateFeesPerGas(); // Safe to send multiple times. Idempotent. - const hash = (await evmClientManager.sendTransactionWithBlindRetry(Networks.Moonbeam, moonbeamExecutorAccount, { + const hash = (await evmClientManager.sendTransaction(Networks.Moonbeam, moonbeamExecutorAccount, { data, maxFeePerGas, maxPriorityFeePerGas, diff --git a/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts index b7145a072..f98f1a533 100644 --- a/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts @@ -112,7 +112,7 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { ], functionName: "permit" }); - permitHash = await this.evmClientManager.sendTransactionWithBlindRetry(Networks.Polygon, account, { + permitHash = await this.evmClientManager.sendTransaction(Networks.Polygon, account, { data: permitData, to: ERC20_EURE_POLYGON_V2 }); @@ -160,7 +160,7 @@ export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { private async executeTransaction(txData: string): Promise { try { const evmClientManager = EvmClientManager.getInstance(); - const txHash = await evmClientManager.sendRawTransactionWithRetry(Networks.Polygon, txData as `0x${string}`); + const txHash = await evmClientManager.sendRawTransaction(Networks.Polygon, txData as `0x${string}`); return txHash; } catch (error) { logger.error("Error sending raw transaction", error); diff --git a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts index 95cc09c78..5caf80e8f 100644 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts +++ b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts @@ -63,7 +63,7 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { const publicClient = evmClientManager.getClient(Networks.Moonbeam); const isHashRegisteredInSplitReceiver = async () => { - const result = await evmClientManager.readContractWithRetry(Networks.Moonbeam, { + const result = await evmClientManager.readContract(Networks.Moonbeam, { abi: splitReceiverABI, address: MOONBEAM_RECEIVER_CONTRACT_ADDRESS, args: [squidRouterReceiverHash], @@ -99,7 +99,7 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { let attempt = 0; while (attempt < 5 && (!receipt || receipt.status !== "success")) { // blind retry for transaction submission - obtainedHash = await evmClientManager.sendTransactionWithBlindRetry(Networks.Moonbeam, moonbeamExecutorAccount, { + obtainedHash = await evmClientManager.sendTransaction(Networks.Moonbeam, moonbeamExecutorAccount, { data, maxFeePerGas, maxPriorityFeePerGas, diff --git a/apps/frontend/App.css b/apps/frontend/App.css index 09b6595c6..393df99d2 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -139,7 +139,7 @@ .btn-vortex-accent:hover { @apply bg-blue-100; @apply text-blue-700; - @apply border-blue-300; + @apply border-blue-700; } .btn-vortex-primary-inverse { diff --git a/apps/frontend/src/machines/actors/register.actor.ts b/apps/frontend/src/machines/actors/register.actor.ts index f005b3128..0173c52d8 100644 --- a/apps/frontend/src/machines/actors/register.actor.ts +++ b/apps/frontend/src/machines/actors/register.actor.ts @@ -1,5 +1,6 @@ import { AccountMeta, + API, ApiManager, EphemeralAccountType, FiatToken, @@ -7,6 +8,7 @@ import { Networks, RampDirection, RegisterRampRequest, + SubstrateApiNetwork, signUnsignedTransactions } from "@vortexfi/shared"; import { config } from "../../config"; @@ -15,7 +17,8 @@ import { RampState } from "../../types/phases"; import { RampContext } from "../types"; export enum RegisterRampErrorType { - InvalidInput = "INVALID_INPUT" + InvalidInput = "INVALID_INPUT", + ConnectionFailed = "CONNECTION_FAILED" } export class RegisterRampError extends Error { @@ -26,6 +29,37 @@ export class RegisterRampError extends Error { } } +const API_CONNECTION_TIMEOUT = 15000; // 15 seconds + +async function getApiWithTimeout( + apiManager: ApiManager, + network: SubstrateApiNetwork, + timeoutMs: number = API_CONNECTION_TIMEOUT +): Promise { + const uuid = crypto.randomUUID(); + + const tryConnect = async (): Promise => { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Connection to ${network} timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + const api = await Promise.race([apiManager.getApiWithShuffling(network, uuid), timeoutPromise]); + return api; + }; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await tryConnect(); + } catch { + if (attempt === 3) { + throw new RegisterRampError(`Failed to connect to ${network} after 3 attempts`, RegisterRampErrorType.ConnectionFailed); + } + } + } + + throw new RegisterRampError(`Failed to connect to ${network}`, RegisterRampErrorType.ConnectionFailed); +} + export const registerRampActor = async ({ input }: { input: RampContext }): Promise => { const { executionInput, chainId, connectedWalletAddress, authToken, paymentData, quote } = input; @@ -39,9 +73,10 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom } const apiManager = ApiManager.getInstance(); - const pendulumApiComponents = await apiManager.getApi(Networks.Pendulum); - const moonbeamApiComponents = await apiManager.getApi(Networks.Moonbeam); - const hydrationApiComponents = await apiManager.getApi(Networks.Hydration); + + const pendulumApiComponents = await getApiWithTimeout(apiManager, Networks.Pendulum); + const moonbeamApiComponents = await getApiWithTimeout(apiManager, Networks.Moonbeam); + const hydrationApiComponents = await getApiWithTimeout(apiManager, Networks.Hydration); if (!chainId) { throw new RegisterRampError("Chain ID is required to register ramp.", RegisterRampErrorType.InvalidInput); diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index d4011171a..bd9ee530b 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -140,13 +140,10 @@ export const kycStateNode = { }), onDone: [ { - actions: assign(({ context, event }: { context: RampContext; event: any }) => { - console.log("Monerium KYC completed with response:", event.output); - return { - ...context, - authToken: event.output.authToken - }; - }), + actions: assign(({ context, event }: { context: RampContext; event: any }) => ({ + ...context, + authToken: event.output.authToken + })), guard: ({ event }: { event: any }) => !!event.output.authToken, target: "VerificationComplete" }, @@ -216,13 +213,6 @@ export const kycStateNode = { VerificationComplete: { always: { target: "#ramp.KycComplete" - }, - entry: { - actions: [ - ({ context }: any) => { - console.log("KYC verification completed successfully:", context.kycResponse); - } - ] } } } diff --git a/apps/frontend/src/machines/moneriumKyc.machine.ts b/apps/frontend/src/machines/moneriumKyc.machine.ts index bea1c7894..974df5fa2 100644 --- a/apps/frontend/src/machines/moneriumKyc.machine.ts +++ b/apps/frontend/src/machines/moneriumKyc.machine.ts @@ -110,15 +110,20 @@ export const moneriumKycMachine = setup({ id: "exchangeMoneriumCode", input: ({ context }) => context, onDone: { - actions: assign({ - authToken: ({ event }) => event.output.authToken - }), + actions: [ + assign({ + authToken: ({ event }) => event.output.authToken + }) + ], target: "Done" }, onError: { - actions: assign({ - error: () => new MoneriumKycMachineError("Error exchanging Monerium code", MoneriumKycMachineErrorType.UnknownError) - }), + actions: [ + assign({ + error: () => + new MoneriumKycMachineError("Error exchanging Monerium code", MoneriumKycMachineErrorType.UnknownError) + }) + ], target: "Failure" }, src: "exchangeMoneriumCode" diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index b5b0b1a0b..ecb0836db 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -471,9 +471,11 @@ export const rampMachine = setup({ invoke: { input: ({ context }) => context, onDone: { - actions: assign({ - rampState: ({ event }) => event.output - }), + actions: [ + assign({ + rampState: ({ event }) => event.output + }) + ], target: "UpdateRamp" }, onError: { diff --git a/apps/frontend/src/services/api/price.service.ts b/apps/frontend/src/services/api/price.service.ts index 13c482841..fcd2273b6 100644 --- a/apps/frontend/src/services/api/price.service.ts +++ b/apps/frontend/src/services/api/price.service.ts @@ -130,39 +130,4 @@ export class PriceService { } }); } - - /** - * @deprecated Use getAllPricesBundled instead for better error handling and performance - * Get price information from all providers - * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) - * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) - * @param amount The amount to convert - * @param direction The direction of the conversion (onramp or offramp) - * @param network Optional network name - * @returns Price information from all providers - */ - static async getAllPrices( - sourceCurrency: Currency, - targetCurrency: Currency, - amount: string, - direction: RampDirection, - network?: string - ): Promise> { - const providers: PriceProvider[] = ["alchemypay", "moonpay", "transak"]; - - const results = await Promise.allSettled( - providers.map(provider => this.getPrice(provider, sourceCurrency, targetCurrency, amount, direction, network)) - ); - - return results.reduce( - (acc, result, index) => { - const provider = providers[index]; - if (result.status === "fulfilled") { - acc[provider] = result.value; - } - return acc; - }, - {} as Record - ); - } } diff --git a/apps/frontend/src/wagmiConfig.ts b/apps/frontend/src/wagmiConfig.ts index ef3a3b79e..f9bbb851a 100644 --- a/apps/frontend/src/wagmiConfig.ts +++ b/apps/frontend/src/wagmiConfig.ts @@ -1,31 +1,39 @@ import { arbitrum, avalanche, base, bsc, mainnet, polygon, polygonAmoy } from "@reown/appkit/networks"; import { createAppKit } from "@reown/appkit/react"; import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; -import { http } from "wagmi"; +import { createSmartFallbackTransports } from "@vortexfi/shared"; import { config } from "./config"; -// If we have an Alchemy API key, we can use it to fetch data from Polygon, otherwise use the default endpoint -const transports = config.alchemyApiKey +const chainRpcConfig = config.alchemyApiKey ? { - [arbitrum.id]: http(`https://arb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`), - [avalanche.id]: http(`https://avax-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`), - [base.id]: http(`https://base-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`), - [bsc.id]: http(`https://bnb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`), - [mainnet.id]: http(`https://eth-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`), - [polygon.id]: http(`https://polygon-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`), - [polygonAmoy.id]: http("") + [arbitrum.id]: [`https://arb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`, "https://arb1.arbitrum.io/rpc"], + [avalanche.id]: [ + `https://avax-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`, + "https://api.avax.network/ext/bc/C/rpc" + ], + [base.id]: [`https://base-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`, "https://mainnet.base.org"], + [bsc.id]: [`https://bnb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`, "https://bsc-dataseed.binance.org"], + [mainnet.id]: [`https://eth-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`, "https://eth.llamarpc.com"], + [polygon.id]: [`https://polygon-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`, "https://polygon-rpc.com"], + [polygonAmoy.id]: ["https://polygon-amoy.api.onfinality.io/public", "https://rpc-amoy.polygon.technology"] } : { - [arbitrum.id]: http(""), - [avalanche.id]: http(""), - [base.id]: http(""), - [bsc.id]: http(""), - [mainnet.id]: http(""), - [polygon.id]: http(""), - [polygonAmoy.id]: http("") + [arbitrum.id]: ["https://arb1.arbitrum.io/rpc"], + [avalanche.id]: ["https://api.avax.network/ext/bc/C/rpc"], + [base.id]: ["https://mainnet.base.org"], + [bsc.id]: ["https://bsc-dataseed.binance.org"], + [mainnet.id]: ["https://eth.llamarpc.com"], + [polygon.id]: ["https://polygon-rpc.com"], + [polygonAmoy.id]: ["https://polygon-amoy.api.onfinality.io/public", "https://rpc-amoy.polygon.technology"] }; +// Create smart fallback transports with automatic retry and RPC switching +const transports = createSmartFallbackTransports(chainRpcConfig, { + initialDelayMs: 500, + timeout: 10_000 +}); + const metadata = { description: "Vortex", icons: [], diff --git a/packages/shared/src/services/evm/balance.ts b/packages/shared/src/services/evm/balance.ts index 875aa1b27..82267100d 100644 --- a/packages/shared/src/services/evm/balance.ts +++ b/packages/shared/src/services/evm/balance.ts @@ -28,7 +28,7 @@ export async function getEvmTokenBalance({ tokenAddress, ownerAddress, chain }: try { const evmClientManager = EvmClientManager.getInstance(); - const balanceResult = await evmClientManager.readContractWithRetry(chain, { + const balanceResult = await evmClientManager.readContract(chain, { abi: erc20ABI, address: tokenAddress, args: [ownerAddress], @@ -56,7 +56,7 @@ export function checkEvmBalancePeriodically( const checkBalance = async () => { try { - const result = await evmClientManager.readContractWithRetry(chain, { + const result = await evmClientManager.readContract(chain, { abi: erc20ABI, address: tokenAddress as EvmAddress, args: [brlaEvmAddress], diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 3843e9ebf..fbe44ebc2 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -2,6 +2,7 @@ import { Account, Chain, createPublicClient, createWalletClient, http, PublicCli import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { ALCHEMY_API_KEY, EvmNetworks, Networks } from "../../index"; import logger from "../../logger"; +import { createSmartFallbackTransport } from "./smartFallbackTransport"; export interface EvmNetworkConfig { name: EvmNetworks; @@ -55,116 +56,39 @@ function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { ]; } -export class EvmClientManager { - private static instance: EvmClientManager; - private clientInstances: Map = new Map(); - private walletClientInstances: Map> = new Map(); - private networks: EvmNetworkConfig[] = []; - - /** - * RPC selector that exhausts all URLs before repeating. - * First cycle uses definition order (preferred RPCs first), subsequent cycles are shuffled. - * Attempt 1-3: A → B → C (definition order) - * Attempt 4-6: C → A → B (shuffled randomly) - * Attempt 7-9: B → C → A (new shuffle) - */ - private createRpcSelector(rpcUrls: string[]) { - let pool = [...rpcUrls]; - const usedInCurrentCycle = new Set(); +/** + * Creates a transport for the given RPC URLs. + * Uses smart fallback for multiple URLs, simple http for single URL. + */ +function createTransportForNetwork(rpcUrls: string[]): Transport { + const validUrls = rpcUrls.filter(url => url !== ""); - const shuffleArray = (array: T[]): T[] => { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; - }; - - return { - getNext: (): string => { - // If we've exhausted all RPCs, shuffle for next cycle - if (usedInCurrentCycle.size === pool.length) { - pool = shuffleArray(rpcUrls); - usedInCurrentCycle.clear(); - } - - // Select the next RPC based on current index - const selectedRpc = pool[usedInCurrentCycle.size]; - usedInCurrentCycle.add(selectedRpc); - - return selectedRpc; - } - }; + if (validUrls.length === 0) { + return http(); } - /** - * Generic retry wrapper with smart RPC selection and exponential backoff. - * Exhausts all available RPCs before repeating selections. - * - * @param networkName - The network to operate on - * @param operation - Async function that receives an RPC URL and returns a result - * @param operationName - Name of the operation for logging - * @param maxRetries - Maximum number of retry attempts - * @param initialDelayMs - Initial delay before first retry - * @returns Result of the operation - */ - private async executeWithRetry( - networkName: EvmNetworks, - operation: (rpcUrl: string) => Promise, - operationName: string, - maxRetries = 3, - initialDelayMs = 1000 - ): Promise { - const network = this.getNetworkConfig(networkName); - const rpcUrls = network.rpcUrls; - - if (rpcUrls.length === 0) { - throw new Error(`No RPC URLs configured for network ${networkName}`); - } - - const rpcSelector = this.createRpcSelector(rpcUrls); - let lastError: Error | undefined; - let attempt = 0; - - while (attempt <= maxRetries) { - const rpcUrl = rpcSelector.getNext(); - - try { - const result = await operation(rpcUrl); - return result; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - logger.current.warn( - `${operationName} attempt ${attempt + 1}/${maxRetries + 1} failed on ${networkName} with RPC ${rpcUrl}: ${lastError.message}` - ); - - if (attempt < maxRetries) { - const delayMs = initialDelayMs * Math.pow(2, attempt); // Exponential backoff - await new Promise(resolve => setTimeout(resolve, delayMs)); - } + if (validUrls.length === 1) { + return http(validUrls[0], { timeout: 10_000 }); + } - attempt++; - } - } + return createSmartFallbackTransport(validUrls, { + initialDelayMs: 1000, + timeout: 10_000 + }); +} - // TODO should we return the raw rpc error here, instead of just the message? - throw new Error( - `Failed to ${operationName} on ${networkName} after ${maxRetries + 1} attempts. Last error: ${lastError?.message}` - ); - } +export class EvmClientManager { + private static instance: EvmClientManager; + private clientInstances: Map = new Map(); + private walletClientInstances: Map> = new Map(); + private networks: EvmNetworkConfig[] = []; private constructor() { this.networks = getEvmNetworks(ALCHEMY_API_KEY); - // Pre-create all public clients for all RPCs this.networks.forEach(network => { - network.rpcUrls.forEach(rpcUrl => { - const client = this.createClient(network.name, rpcUrl); - const key = this.generatePublicClientKey(network.name, rpcUrl); - this.clientInstances.set(key, client); - logger.current.info(`Pre-created EVM client for ${network.name} with RPC: ${rpcUrl}`); - }); + const client = this.createClient(network); + this.clientInstances.set(network.name, client); + logger.current.info(`Pre-created EVM client for ${network.name} with ${network.rpcUrls.length} RPC(s)`); }); } @@ -175,10 +99,6 @@ export class EvmClientManager { return EvmClientManager.instance; } - private generatePublicClientKey(networkName: EvmNetworks, rpcUrl: string): string { - return `${networkName}-${rpcUrl}`; - } - private getNetworkConfig(networkName: EvmNetworks): EvmNetworkConfig { const network = this.networks.find(n => n.name === networkName); if (!network) { @@ -187,65 +107,55 @@ export class EvmClientManager { return network; } - private createClient(networkName: EvmNetworks, rpcUrl?: string): PublicClient { - const network = this.getNetworkConfig(networkName); - - const transport = rpcUrl ? http(rpcUrl) : http(network.rpcUrls[0]); + private createClient(network: EvmNetworkConfig): PublicClient { + const transport = createTransportForNetwork(network.rpcUrls); - const client = createPublicClient({ + return createPublicClient({ chain: network.chain, transport }); - - return client; } - private generateWalletClientKey(networkName: EvmNetworks, accountAddress: string, rpcUrl?: string): string { - const rpcSuffix = rpcUrl ? `-${rpcUrl}` : ""; - return `${networkName}-${accountAddress.toLowerCase()}${rpcSuffix}`; + private generateWalletClientKey(networkName: EvmNetworks, accountAddress: string): string { + return `${networkName}-${accountAddress.toLowerCase()}`; } - private createWalletClient( - networkName: EvmNetworks, - account: Account, - rpcUrl?: string - ): WalletClient { + private createWalletClient(networkName: EvmNetworks, account: Account): WalletClient { const network = this.getNetworkConfig(networkName); + const transport = createTransportForNetwork(network.rpcUrls); - const transport = rpcUrl ? http(rpcUrl) : http(network.rpcUrls[0]); - - const walletClient = createWalletClient({ + return createWalletClient({ account, chain: network.chain, transport }); - - return walletClient; } - public getClient(networkName: EvmNetworks, rpcUrl?: string): PublicClient { - const network = this.getNetworkConfig(networkName); - - const targetRpcUrl = rpcUrl || network.rpcUrls[0]; - const key = this.generatePublicClientKey(networkName, targetRpcUrl); - const client = this.clientInstances.get(key); + /** + * Gets the public client for a network. + * The client uses smart fallback transport with automatic retry and RPC switching. + */ + public getClient(networkName: EvmNetworks): PublicClient { + const client = this.clientInstances.get(networkName); if (!client) { - throw new Error(`Client for ${networkName} with RPC ${targetRpcUrl} not found. This should not happen.`); + throw new Error(`Client for ${networkName} not found. This should not happen.`); } return client; } - public getWalletClient(networkName: EvmNetworks, account: Account, rpcUrl?: string): WalletClient { - const key = this.generateWalletClientKey(networkName, account.address, rpcUrl); + /** + * Gets or creates a wallet client for a network and account. + * The client uses smart fallback transport with automatic retry and RPC switching. + */ + public getWalletClient(networkName: EvmNetworks, account: Account): WalletClient { + const key = this.generateWalletClientKey(networkName, account.address); let walletClient = this.walletClientInstances.get(key); if (!walletClient) { - logger.current.info( - `Creating new EVM wallet client for ${networkName} with account ${account.address}${rpcUrl ? ` using RPC: ${rpcUrl}` : ""}` - ); - walletClient = this.createWalletClient(networkName, account, rpcUrl); + logger.current.info(`Creating new EVM wallet client for ${networkName} with account ${account.address}`); + walletClient = this.createWalletClient(networkName, account); this.walletClientInstances.set(key, walletClient); } @@ -253,54 +163,40 @@ export class EvmClientManager { } /** - * Reads a contract with smart retry logic using exponential backoff and RPC switching. - * Exhausts all available RPCs before repeating selections. + * Reads a contract. Retry and fallback are handled automatically by the smart fallback transport. * * @param networkName - The EVM network to read from * @param contractParams - The contract read parameters (abi, address, functionName, args) - * @param maxRetries - Maximum number of retry attempts (default: 3) - * @param initialDelayMs - Initial delay in milliseconds before first retry (default: 1000) * @returns Contract read result */ - public async readContractWithRetry( + public async readContract( networkName: EvmNetworks, contractParams: { + // biome-ignore lint/suspicious/noExplicitAny: ABI types are complex abi: any; address: `0x${string}`; functionName: string; + // biome-ignore lint/suspicious/noExplicitAny: Contract args can be any type args?: any[]; - }, - maxRetries = 3, - initialDelayMs = 1000 + } ): Promise { - return this.executeWithRetry( - networkName, - async rpcUrl => { - const publicClient = this.getClient(networkName, rpcUrl); - return (await publicClient.readContract({ - ...contractParams, - args: contractParams.args || [] - })) as T; - }, - "read contract", - maxRetries, - initialDelayMs - ); + const publicClient = this.getClient(networkName); + return (await publicClient.readContract({ + ...contractParams, + args: contractParams.args || [] + })) as T; } /** - * Sends a transaction with smart retry logic using exponential backoff and RPC switching. - * Exhausts all available RPCs before repeating selections. + * Sends a transaction. Retry and fallback are handled automatically by the smart fallback transport. * This method should be used for idempotent operations where retrying is safe. * * @param networkName - The EVM network to send the transaction on * @param account - The account to send the transaction from - * @param transactionParams - The transaction parameters (data, to, value, maxFeePerGas, etc.) - * @param maxRetries - Maximum number of retry attempts (default: 3) - * @param initialDelayMs - Initial delay in milliseconds before first retry (default: 1000) + * @param transactionParams - The transaction parameters * @returns Transaction hash */ - public async sendTransactionWithBlindRetry( + public async sendTransaction( networkName: EvmNetworks, account: Account, transactionParams: { @@ -311,50 +207,21 @@ export class EvmClientManager { maxPriorityFeePerGas?: bigint; gas?: bigint; nonce?: number; - }, - maxRetries = 3, - initialDelayMs = 1000 + } ): Promise<`0x${string}`> { - return this.executeWithRetry( - networkName, - async rpcUrl => { - const walletClient = this.getWalletClient(networkName, account, rpcUrl); - return await walletClient.sendTransaction(transactionParams); - }, - "send transaction", - maxRetries, - initialDelayMs - ); + const walletClient = this.getWalletClient(networkName, account); + return await walletClient.sendTransaction(transactionParams); } /** - * Sends a raw transaction with smart retry logic using exponential backoff and RPC switching. - * Exhausts all available RPCs before repeating selections. - * This method should be used for idempotent operations where retrying is safe. + * Sends a raw transaction. Retry and fallback are handled automatically by the smart fallback transport. * * @param networkName - The EVM network to send the transaction on * @param serializedTransaction - The serialized transaction data - * @param maxRetries - Maximum number of retry attempts (default: 3) - * @param initialDelayMs - Initial delay in milliseconds before first retry (default: 1000) * @returns Transaction hash */ - public async sendRawTransactionWithRetry( - networkName: EvmNetworks, - serializedTransaction: `0x${string}`, - maxRetries = 3, - initialDelayMs = 1000 - ): Promise { - return this.executeWithRetry( - networkName, - async rpcUrl => { - const publicClient = this.getClient(networkName, rpcUrl); - return await publicClient.sendRawTransaction({ - serializedTransaction - }); - }, - "send raw transaction", - maxRetries, - initialDelayMs - ); + public async sendRawTransaction(networkName: EvmNetworks, serializedTransaction: `0x${string}`): Promise { + const publicClient = this.getClient(networkName); + return await publicClient.sendRawTransaction({ serializedTransaction }); } } diff --git a/packages/shared/src/services/evm/index.ts b/packages/shared/src/services/evm/index.ts index 58ff96995..ca54c40df 100644 --- a/packages/shared/src/services/evm/index.ts +++ b/packages/shared/src/services/evm/index.ts @@ -1,2 +1,3 @@ export * from "./balance"; export * from "./clientManager"; +export * from "./smartFallbackTransport"; diff --git a/packages/shared/src/services/evm/smartFallbackTransport.ts b/packages/shared/src/services/evm/smartFallbackTransport.ts new file mode 100644 index 000000000..3fdca2a55 --- /dev/null +++ b/packages/shared/src/services/evm/smartFallbackTransport.ts @@ -0,0 +1,123 @@ +import { createTransport, type HttpTransportConfig, http, type Transport } from "viem"; +import logger from "../../logger"; + +export interface SmartFallbackConfig { + /** Maximum number of retry attempts. Defaults to the number of RPC URLs provided. */ + maxRetries?: number; + /** Initial delay in ms before first retry. Uses exponential backoff. Default: 1000 */ + initialDelayMs?: number; + /** Request timeout in ms. Default: 10000 */ + timeout?: number; + httpConfig?: Omit; + onRetry?: (info: { rpcUrl: string; attempt: number; maxRetries: number; error: Error }) => void; +} + +interface TransportInstance { + url: string; + // biome-ignore lint/suspicious/noExplicitAny: viem internal request function type + request: (args: { method: string; params?: any }) => Promise; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function createSmartFallbackTransport(rpcUrls: string[], config: SmartFallbackConfig = {}): Transport { + const { initialDelayMs = 1000, timeout = 10_000, httpConfig = {}, onRetry } = config; + + if (rpcUrls.length === 0) { + throw new Error("createSmartFallbackTransport requires at least one RPC URL"); + } + + // Default maxRetries to number of RPC URLs if not specified + const maxRetries = config.maxRetries ?? rpcUrls.length; + + const key = "smartFallback"; + const name = "Smart Fallback"; + + return ({ chain }) => { + const transports: TransportInstance[] = rpcUrls.map(url => { + const transport = http(url, { + ...httpConfig, + retryCount: 0, + timeout + })({ chain }); + + return { + request: transport.request, + url + }; + }); + + // biome-ignore lint/suspicious/noExplicitAny: viem EIP1193RequestFn has complex generics + const request = async ({ method, params }: { method: string; params?: any }): Promise => { + let lastError: Error | undefined; + + // Cycle through RPCs up to maxRetries times + for (let attempt = 0; attempt < maxRetries; attempt++) { + const transportIndex = attempt % transports.length; + const transport = transports[transportIndex]; + + try { + const result = await transport.request({ method, params }); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + const retryInfo = { + attempt: attempt + 1, + error: lastError, + maxRetries, + rpcUrl: transport.url || `transport-${transportIndex}` + }; + + if (onRetry) { + onRetry(retryInfo); + } else { + logger.current.warn( + `Smart fallback attempt ${retryInfo.attempt}/${retryInfo.maxRetries} failed on ${retryInfo.rpcUrl}: ${lastError.message}` + ); + } + + if (attempt < maxRetries - 1) { + const delayMs = initialDelayMs * Math.pow(2, attempt); + await sleep(delayMs); + } + } + } + + throw lastError ?? new Error("All RPC endpoints failed"); + }; + + return createTransport({ + key, + name, + request, + retryCount: 0, + type: "smartFallback" + }); + }; +} + +export type ChainRpcConfig = Record; + +export function createSmartFallbackTransports( + chainRpcConfig: ChainRpcConfig, + config: SmartFallbackConfig = {} +): Record { + const transports: Record = {}; + + for (const [chainIdStr, rpcUrls] of Object.entries(chainRpcConfig)) { + const chainId = Number(chainIdStr); + + if (rpcUrls.length === 0) { + transports[chainId] = http(); + } else if (rpcUrls.length === 1) { + transports[chainId] = http(rpcUrls[0], { timeout: config.timeout ?? 10_000 }); + } else { + transports[chainId] = createSmartFallbackTransport(rpcUrls, config); + } + } + + return transports; +}