Skip to content

Commit

Permalink
feat: implement RPC fallback mechanism
Browse files Browse the repository at this point in the history
- add fallback to public RPC when Infura fails
- add test coverage for RPC fallback
- mock necessary dependencies for testing
  • Loading branch information
kushagrasarathe committed Jan 15, 2025
1 parent c030da2 commit 57c4093
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 13 deletions.
3 changes: 3 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ global.console = {
// warn: jest.fn(),
// error: jest.fn(),
}

// mock console.warn
global.console.warn = jest.fn()
93 changes: 93 additions & 0 deletions src/utils/__tests__/chains.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { chains } from '@/constants/chains.consts'
import peanut from '@squirrel-labs/peanut-sdk'
import { getChainProvider } from '../chains.utils'

// mock the chains array
jest.mock('@/constants/chains.consts', () => ({
chains: [
{
id: 1,
name: 'Ethereum',
rpcUrls: {
default: { http: ['https://eth-mainnet.infura.io/v3/your-key'] },
public: { http: ['https://ethereum-rpc.publicnode.com'] },
},
},
],
}))

// mock the peanut SDK
jest.mock('@squirrel-labs/peanut-sdk', () => ({
getDefaultProvider: jest.fn(),
}))

describe('Chain Utilities', () => {
// mock provider with required methods
const mockProvider = {
getNetwork: jest.fn(),
}

beforeEach(() => {
// clear all mocks before each test
jest.clearAllMocks()
// reset the mock implementation
mockProvider.getNetwork.mockReset()
})

it('should successfully return provider when Infura RPC works', async () => {
// setup successful infura response
mockProvider.getNetwork.mockResolvedValueOnce({ chainId: 1 })
;(peanut.getDefaultProvider as jest.Mock).mockResolvedValueOnce(mockProvider)

const provider = await getChainProvider('1')

expect(provider).toBe(mockProvider)
expect(peanut.getDefaultProvider).toHaveBeenCalledWith('1')
expect(mockProvider.getNetwork).toHaveBeenCalled()
})

it('should fall back to public RPC when Infura fails', async () => {
// setup failed Infura response but successful public RPC
const failingProvider = {
getNetwork: jest.fn().mockRejectedValueOnce(new Error('Infura failed')),
}
const successfulPublicProvider = {
getNetwork: jest.fn().mockResolvedValueOnce({ chainId: 1 }),
}

// first call fails (Infura), second call succeeds (public RPC)
;(peanut.getDefaultProvider as jest.Mock)
.mockResolvedValueOnce(failingProvider)
.mockResolvedValueOnce(successfulPublicProvider)

const provider = await getChainProvider('1')

expect(provider).toBe(successfulPublicProvider)
expect(peanut.getDefaultProvider).toHaveBeenCalledTimes(2)
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Default RPC failed for chain 1'))
})

it('should throw error when chain is not found', async () => {
await expect(getChainProvider('999999')).rejects.toThrow('Chain 999999 not found')
})

it('should throw error when no public RPC is available', async () => {
// mock a chain without public RPC
const chainWithoutPublicRpc = {
...chains[0],
rpcUrls: {
public: { http: [] },
default: { http: ['some-url'] },
},
}
jest.spyOn(chains, 'find').mockReturnValueOnce(chainWithoutPublicRpc)

// setup failed Infura response
const failingProvider = {
getNetwork: jest.fn().mockRejectedValueOnce(new Error('Infura failed')),
}
;(peanut.getDefaultProvider as jest.Mock).mockResolvedValueOnce(failingProvider)

await expect(getChainProvider('1')).rejects.toThrow('No public RPC URL found for chain 1')
})
})
32 changes: 19 additions & 13 deletions src/utils/chains.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { chains } from '@/constants/chains.consts'
import { ethers } from 'ethers'
import peanut from '@squirrel-labs/peanut-sdk'

/**
* Generates an Infura API URL for supported networks
Expand All @@ -11,20 +11,26 @@ export const getInfuraApiUrl = (network: string): string => {
}

/**
* Retrieves the JSON-RPC provider for a given blockchain network.
*
* @param chainId - The unique identifier of the blockchain network as a string.
* @returns An instance of `ethers.providers.JsonRpcProvider` configured with the network's RPC URL and chain ID.
* @throws Will throw an error if the chain with the specified `chainId` is not found.
*
* @remarks
* This function uses the `ethers` library to create a JSON-RPC provider, which is required by the Peanut SDK.
* Retrieves the JSON-RPC provider for a given blockchain network with fallback to public RPC.
*/
export const getChainProvider = (chainId: string) => {
export const getChainProvider = async (chainId: string) => {
const chain = chains.find((chain) => chain.id.toString() === chainId)

if (!chain) throw new Error(`Chain ${chainId} not found`)

// using ethers cuz peanut sdk accepts provider type to be from ethers atm 🫠
return new ethers.providers.JsonRpcProvider(chain.rpcUrls.default.http[0], Number(chainId))
// Try default (Infura) provider first
try {
const defaultProvider = await peanut.getDefaultProvider(chainId)
await defaultProvider.getNetwork() // Test the connection
return defaultProvider
} catch (error) {
console.warn(`Default RPC failed for chain ${chainId}, falling back to public RPC`)

// Fallback to public RPC
const publicRpcUrl = chain.rpcUrls.public.http[0]
if (!publicRpcUrl) {
throw new Error(`No public RPC URL found for chain ${chainId}`)
}

return peanut.getDefaultProvider(publicRpcUrl)
}
}

0 comments on commit 57c4093

Please sign in to comment.