From 623dc7412df086e0230f47d771a21a646b10d23e Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Fri, 2 Jan 2026 18:26:33 +0100 Subject: [PATCH 1/7] implement smartFallbackTransport --- .../services/evm/smartFallbackTransport.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/shared/src/services/evm/smartFallbackTransport.ts diff --git a/packages/shared/src/services/evm/smartFallbackTransport.ts b/packages/shared/src/services/evm/smartFallbackTransport.ts new file mode 100644 index 000000000..5ef6edb14 --- /dev/null +++ b/packages/shared/src/services/evm/smartFallbackTransport.ts @@ -0,0 +1,114 @@ +import { createTransport, type HttpTransportConfig, http, type Transport } from "viem"; +import logger from "../../logger"; + +export interface SmartFallbackConfig { + initialDelayMs?: number; + 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"); + } + + 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; + + for (let i = 0; i < transports.length; i++) { + const transport = transports[i]; + + try { + const result = await transport.request({ method, params }); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + const retryInfo = { + attempt: i + 1, + error: lastError, + maxRetries: transports.length, + rpcUrl: transport.url || `transport-${i}` + }; + + if (onRetry) { + onRetry(retryInfo); + } else { + logger.current.warn( + `Smart fallback attempt ${retryInfo.attempt}/${retryInfo.maxRetries} failed on ${retryInfo.rpcUrl}: ${lastError.message}` + ); + } + + if (i < transports.length - 1) { + const delayMs = initialDelayMs * Math.pow(2, i); + 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; +} From 9f09596176dd6f05526a19a5cc1163edf992bbd8 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Fri, 2 Jan 2026 18:41:51 +0100 Subject: [PATCH 2/7] implement getApiWithTimeout --- .../src/machines/actors/register.actor.ts | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/machines/actors/register.actor.ts b/apps/frontend/src/machines/actors/register.actor.ts index f005b3128..0892933a3 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,41 @@ 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); + }); + + try { + const api = await Promise.race([apiManager.getApiWithShuffling(network, uuid), timeoutPromise]); + return api; + } catch (error) { + throw error; + } + }; + + 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 +77,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); From 657ca7265c9e985f96c3e218aabd1f1cc11feddf Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Fri, 2 Jan 2026 18:56:53 +0100 Subject: [PATCH 3/7] connect new rpc retry mechanism to wagmi config --- apps/frontend/App.css | 2 +- apps/frontend/src/machines/kyc.states.ts | 18 +- .../src/machines/moneriumKyc.machine.ts | 17 +- apps/frontend/src/machines/ramp.machine.ts | 8 +- apps/frontend/src/wagmiConfig.ts | 43 ++- .../shared/src/services/evm/clientManager.ts | 305 +++++++----------- packages/shared/src/services/evm/index.ts | 1 + 7 files changed, 160 insertions(+), 234 deletions(-) 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/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/wagmiConfig.ts b/apps/frontend/src/wagmiConfig.ts index ef3a3b79e..d5ac3f6e6 100644 --- a/apps/frontend/src/wagmiConfig.ts +++ b/apps/frontend/src/wagmiConfig.ts @@ -1,31 +1,40 @@ 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: 1000, + maxRetries: 3, + timeout: 10_000 +}); + const metadata = { description: "Vortex", icons: [], diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 3843e9ebf..f2e04b38e 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[] = []; +/** + * 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 !== ""); - /** - * 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(); - - 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); + private createClient(network: EvmNetworkConfig): PublicClient { + const transport = createTransportForNetwork(network.rpcUrls); - const transport = rpcUrl ? http(rpcUrl) : http(network.rpcUrls[0]); - - 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,53 +163,78 @@ 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 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[]; + } + ): Promise { + const publicClient = this.getClient(networkName); + return (await publicClient.readContract({ + ...contractParams, + args: contractParams.args || [] + })) as T; + } + + /** + * @deprecated Use readContract instead. Retry logic is now handled at transport level. + */ public async readContractWithRetry( 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 + _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 - ); + return this.readContract(networkName, contractParams); } /** - * 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 sendTransaction( + networkName: EvmNetworks, + account: Account, + transactionParams: { + data?: `0x${string}`; + to: `0x${string}`; + value?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gas?: bigint; + nonce?: number; + } + ): Promise<`0x${string}`> { + const walletClient = this.getWalletClient(networkName, account); + return await walletClient.sendTransaction(transactionParams); + } + + /** + * @deprecated Use sendTransaction instead. Retry logic is now handled at transport level. + */ public async sendTransactionWithBlindRetry( networkName: EvmNetworks, account: Account, @@ -312,49 +247,33 @@ export class EvmClientManager { gas?: bigint; nonce?: number; }, - maxRetries = 3, - initialDelayMs = 1000 + _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 - ); + return this.sendTransaction(networkName, account, 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 sendRawTransaction(networkName: EvmNetworks, serializedTransaction: `0x${string}`): Promise { + const publicClient = this.getClient(networkName); + return await publicClient.sendRawTransaction({ serializedTransaction }); + } + + /** + * @deprecated Use sendRawTransaction instead. Retry logic is now handled at transport level. + */ public async sendRawTransactionWithRetry( networkName: EvmNetworks, serializedTransaction: `0x${string}`, - maxRetries = 3, - initialDelayMs = 1000 + _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 - ); + return this.sendRawTransaction(networkName, 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"; From cd91f29db01a59f4f5418f05947d38a60f48929b Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 14 Jan 2026 15:19:55 +0100 Subject: [PATCH 4/7] remove redundant try catch --- apps/frontend/src/machines/actors/register.actor.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/machines/actors/register.actor.ts b/apps/frontend/src/machines/actors/register.actor.ts index 0892933a3..0173c52d8 100644 --- a/apps/frontend/src/machines/actors/register.actor.ts +++ b/apps/frontend/src/machines/actors/register.actor.ts @@ -43,12 +43,8 @@ async function getApiWithTimeout( setTimeout(() => reject(new Error(`Connection to ${network} timed out after ${timeoutMs}ms`)), timeoutMs); }); - try { - const api = await Promise.race([apiManager.getApiWithShuffling(network, uuid), timeoutPromise]); - return api; - } catch (error) { - throw error; - } + const api = await Promise.race([apiManager.getApiWithShuffling(network, uuid), timeoutPromise]); + return api; }; for (let attempt = 1; attempt <= 3; attempt++) { From fcafa0248427e63b2855c1a3f200363f5bd34f57 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 14 Jan 2026 17:56:48 +0100 Subject: [PATCH 5/7] implement max retries --- .../services/evm/smartFallbackTransport.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/services/evm/smartFallbackTransport.ts b/packages/shared/src/services/evm/smartFallbackTransport.ts index 5ef6edb14..3fdca2a55 100644 --- a/packages/shared/src/services/evm/smartFallbackTransport.ts +++ b/packages/shared/src/services/evm/smartFallbackTransport.ts @@ -2,7 +2,11 @@ import { createTransport, type HttpTransportConfig, http, type Transport } from 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; @@ -25,6 +29,9 @@ export function createSmartFallbackTransport(rpcUrls: string[], config: SmartFal 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"; @@ -46,8 +53,10 @@ export function createSmartFallbackTransport(rpcUrls: string[], config: SmartFal const request = async ({ method, params }: { method: string; params?: any }): Promise => { let lastError: Error | undefined; - for (let i = 0; i < transports.length; i++) { - const transport = transports[i]; + // 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 }); @@ -56,10 +65,10 @@ export function createSmartFallbackTransport(rpcUrls: string[], config: SmartFal lastError = error instanceof Error ? error : new Error(String(error)); const retryInfo = { - attempt: i + 1, + attempt: attempt + 1, error: lastError, - maxRetries: transports.length, - rpcUrl: transport.url || `transport-${i}` + maxRetries, + rpcUrl: transport.url || `transport-${transportIndex}` }; if (onRetry) { @@ -70,8 +79,8 @@ export function createSmartFallbackTransport(rpcUrls: string[], config: SmartFal ); } - if (i < transports.length - 1) { - const delayMs = initialDelayMs * Math.pow(2, i); + if (attempt < maxRetries - 1) { + const delayMs = initialDelayMs * Math.pow(2, attempt); await sleep(delayMs); } } From 905e721a23eea5043aac95745f3f69592d85aa38 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 22 Jan 2026 16:30:34 +0100 Subject: [PATCH 6/7] update outdated functions to use retry on the transport layer --- .../api/controllers/moonbeam.controller.ts | 2 +- .../monerium-onramp-self-transfer-handler.ts | 4 +- .../handlers/moonbeam-to-pendulum-handler.ts | 4 +- .../src/services/api/price.service.ts | 35 ------------- packages/shared/src/services/evm/balance.ts | 4 +- .../shared/src/services/evm/clientManager.ts | 52 ------------------- 6 files changed, 7 insertions(+), 94 deletions(-) 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/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/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 f2e04b38e..fbe44ebc2 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -187,25 +187,6 @@ export class EvmClientManager { })) as T; } - /** - * @deprecated Use readContract instead. Retry logic is now handled at transport level. - */ - public async readContractWithRetry( - 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.readContract(networkName, contractParams); - } - /** * 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. @@ -232,27 +213,6 @@ export class EvmClientManager { return await walletClient.sendTransaction(transactionParams); } - /** - * @deprecated Use sendTransaction instead. Retry logic is now handled at transport level. - */ - public async sendTransactionWithBlindRetry( - networkName: EvmNetworks, - account: Account, - transactionParams: { - data?: `0x${string}`; - to: `0x${string}`; - value?: bigint; - maxFeePerGas?: bigint; - maxPriorityFeePerGas?: bigint; - gas?: bigint; - nonce?: number; - }, - _maxRetries = 3, - _initialDelayMs = 1000 - ): Promise<`0x${string}`> { - return this.sendTransaction(networkName, account, transactionParams); - } - /** * Sends a raw transaction. Retry and fallback are handled automatically by the smart fallback transport. * @@ -264,16 +224,4 @@ export class EvmClientManager { const publicClient = this.getClient(networkName); return await publicClient.sendRawTransaction({ serializedTransaction }); } - - /** - * @deprecated Use sendRawTransaction instead. Retry logic is now handled at transport level. - */ - public async sendRawTransactionWithRetry( - networkName: EvmNetworks, - serializedTransaction: `0x${string}`, - _maxRetries = 3, - _initialDelayMs = 1000 - ): Promise { - return this.sendRawTransaction(networkName, serializedTransaction); - } } From 5de191d5c414e7f460508714b162b9649c54991d Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Fri, 23 Jan 2026 11:56:16 +0100 Subject: [PATCH 7/7] update wagmiConfig --- apps/frontend/src/wagmiConfig.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/frontend/src/wagmiConfig.ts b/apps/frontend/src/wagmiConfig.ts index d5ac3f6e6..f9bbb851a 100644 --- a/apps/frontend/src/wagmiConfig.ts +++ b/apps/frontend/src/wagmiConfig.ts @@ -30,8 +30,7 @@ const chainRpcConfig = config.alchemyApiKey // Create smart fallback transports with automatic retry and RPC switching const transports = createSmartFallbackTransports(chainRpcConfig, { - initialDelayMs: 1000, - maxRetries: 3, + initialDelayMs: 500, timeout: 10_000 });