Skip to content

Commit

Permalink
perf: cache transaction receipts (#2181)
Browse files Browse the repository at this point in the history
  • Loading branch information
dewanshparashar authored Jan 22, 2025
1 parent ec72890 commit 930bbe1
Show file tree
Hide file tree
Showing 3 changed files with 298 additions and 1 deletion.
163 changes: 163 additions & 0 deletions packages/arb-token-bridge-ui/src/token-bridge-sdk/EnhancedProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import {
Networkish,
StaticJsonRpcProvider,
TransactionReceipt
} from '@ethersproject/providers'
import { BigNumber } from 'ethers'
import { ChainId } from '../types/ChainId'
import { ConnectionInfo } from 'ethers/lib/utils.js'
import { isNetwork } from '../util/networks'

interface Storage {
getItem(key: string): string | null
setItem(key: string, value: string): void
}

class WebStorage implements Storage {
getItem(key: string): string | null {
return localStorage.getItem(key)
}

setItem(key: string, value: string): void {
localStorage.setItem(key, value)
}
}

const localStorageKey = `arbitrum:bridge:tx-receipts-cache`
const enableCaching = true

const getCacheKey = (chainId: number | string, txHash: string) =>
`${chainId}:${txHash}`.toLowerCase()

/**
* Converts a TransactionReceipt to a string by encoding BigNumber properties.
* @param txReceipt - The receipt to encode.
* @returns The encoded receipt as a string.
*/
const txReceiptToString = (txReceipt: TransactionReceipt): string => {
const encodedReceipt: Record<string, any> = { ...txReceipt }

Object.keys(encodedReceipt).forEach(field => {
if (encodedReceipt[field]?._isBigNumber) {
encodedReceipt[field] = {
_type: 'BigNumber',
_data: encodedReceipt[field].toString()
}
}
})

return JSON.stringify(encodedReceipt)
}

/**
* Converts a stringified receipt back to a TransactionReceipt by decoding BigNumber properties.
* @param stringified - The stringified receipt to decode.
* @returns The decoded TransactionReceipt.
*/
const txReceiptFromString = (stringified: string): TransactionReceipt => {
const receipt = JSON.parse(stringified)
const decodedReceipt = { ...receipt }

Object.keys(decodedReceipt).forEach(field => {
if (decodedReceipt[field]?._type === 'BigNumber') {
decodedReceipt[field] = BigNumber.from(decodedReceipt[field]._data)
}
})

return decodedReceipt as TransactionReceipt
}

/**
* Checks if a transaction receipt can be cached based on its chain and confirmations.
* @param chainId - The ID of the chain.
* @param txReceipt - The transaction receipt to check.
* @returns True if the receipt can be cached, false otherwise.
*/
export const shouldCacheTxReceipt = (
chainId: number,
txReceipt: TransactionReceipt
): boolean => {
if (!enableCaching) return false

// for now, only enable caching for testnets,
if (!isNetwork(chainId).isTestnet) {
return false
}

// Finality checks to avoid caching re-org'ed transactions
if (
(chainId === ChainId.Ethereum && txReceipt.confirmations < 65) ||
(chainId === ChainId.Sepolia && txReceipt.confirmations < 5) ||
(chainId === ChainId.Holesky && txReceipt.confirmations < 5)
) {
return false
}

return true
}

function getTxReceiptFromCache(
storage: Storage,
chainId: number,
txHash: string
) {
if (!enableCaching) return undefined

const cachedReceipts = JSON.parse(storage.getItem(localStorageKey) || '{}')
const receipt = cachedReceipts[getCacheKey(chainId, txHash)]

if (!receipt) return undefined

return txReceiptFromString(JSON.stringify(receipt))
}

function addTxReceiptToCache(
storage: Storage,
chainId: number,
txReceipt: TransactionReceipt
) {
const cachedReceipts = JSON.parse(storage.getItem(localStorageKey) || '{}')

const key = getCacheKey(chainId, txReceipt.transactionHash)
storage.setItem(
localStorageKey,
JSON.stringify({
...cachedReceipts,
[key]: JSON.parse(txReceiptToString(txReceipt))
})
)
}

export class EnhancedProvider extends StaticJsonRpcProvider {
private storage: Storage

constructor(
url?: ConnectionInfo | string,
network?: Networkish,
storage: Storage = new WebStorage()
) {
super(url, network)
this.storage = storage
}

async getTransactionReceipt(
transactionHash: string | Promise<string>
): Promise<TransactionReceipt> {
const hash = await transactionHash
const chainId = this.network.chainId

// Retrieve the cached receipt for the hash, if it exists
const cachedReceipt = getTxReceiptFromCache(this.storage, chainId, hash)
if (cachedReceipt) return cachedReceipt

// Else, fetch the receipt using the original method
const receipt = await super.getTransactionReceipt(hash)

// Cache the receipt if it meets the criteria
if (receipt && shouldCacheTxReceipt(chainId, receipt)) {
addTxReceiptToCache(this.storage, chainId, receipt)
}

return receipt
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
StaticJsonRpcProvider,
TransactionReceipt
} from '@ethersproject/providers'
import { BigNumber } from 'ethers'
import { ChainId } from '../../types/ChainId'
import { rpcURLs } from '../../util/networks'
import { EnhancedProvider, shouldCacheTxReceipt } from '../EnhancedProvider'

class TestStorage {
private store: Record<string, string> = {}
getItem(key: string): string | null {
return this.store[key] || null
}
setItem(key: string, value: string): void {
this.store[key] = value
}
clear(): void {
this.store = {}
}
}

// a type-safe test transaction receipt which can be extended as per our test cases
const testTxReceipt: TransactionReceipt = {
to: '0x99E2d366BA678522F3793d2c2E758Ac29a59678E',
from: '0x5Be7Babe02224e944582493613b00A4Caf4d56df',
contractAddress: '',
transactionIndex: 27,
gasUsed: BigNumber.from(1000000000),
logsBloom: '',
blockHash: '',
transactionHash:
'0x40c993848fc927ced60283b2188734b50e736d305d462289cbe231876c6570a8',
logs: [],
blockNumber: 7483636,
confirmations: 19088,
cumulativeGasUsed: BigNumber.from(1000000000),
effectiveGasPrice: BigNumber.from(1000000000),
status: 1,
type: 2,
byzantium: true
}

describe('EnhancedProvider', () => {
let storage: TestStorage

beforeEach(() => {
storage = new TestStorage()
jest.restoreAllMocks()
})

it('should fetch real transaction and use cache for subsequent requests', async () => {
const rpcUrl = rpcURLs[ChainId.Sepolia]!

const provider = new EnhancedProvider(rpcUrl, ChainId.Sepolia, storage)
// Wait for network to be initialized
await provider.ready

const txHash =
'0x019ae29f37f27399fc2ac3f640b05e7e3700c75759026a4b4d51d7e572480e4c'

// Mock super.getTransactionReceipt to return a successful receipt
const mockReceipt = { ...testTxReceipt, transactionHash: txHash }

// Spy on the parent class's getTransactionReceipt that fires the RPC call
const superGetReceipt = jest
.spyOn(StaticJsonRpcProvider.prototype, 'getTransactionReceipt')
.mockResolvedValue(mockReceipt)

// First request - should hit the network
const firstReceipt = await provider.getTransactionReceipt(txHash)
expect(firstReceipt).toBeTruthy()
expect(superGetReceipt).toHaveBeenCalledTimes(1)

// Check if cache was populated
const cache = storage.getItem('arbitrum:bridge:tx-receipts-cache')
expect(cache).toBeTruthy()

// Second request - should use cache
const secondReceipt = await provider.getTransactionReceipt(txHash)
expect(secondReceipt).toEqual(firstReceipt)
expect(superGetReceipt).toHaveBeenCalledTimes(1) // No additional RPC calls
})

describe('Caching condition tests', () => {
let provider: EnhancedProvider

beforeEach(async () => {
provider = new EnhancedProvider(
'https://mock.url',
ChainId.Sepolia,
storage
)
// Mock the network property
Object.defineProperty(provider, 'network', {
value: { chainId: ChainId.Sepolia, name: 'sepolia' },
writable: true
})
// Wait for provider to be ready
await provider.ready
})

it('should not cache receipt when confirmations are below threshold', async () => {
const mockReceipt = {
...testTxReceipt,
confirmations: 4 // Below Sepolia threshold of 5
}

expect(shouldCacheTxReceipt(ChainId.Sepolia, mockReceipt)).toBeFalsy()
})

it('should cache receipt when confirmations are above threshold', async () => {
const mockReceipt = {
...testTxReceipt,
confirmations: 10 // Above Sepolia threshold of 5
}

// Mock the parent class's getTransactionReceipt
jest
.spyOn(StaticJsonRpcProvider.prototype, 'getTransactionReceipt')
.mockResolvedValue(mockReceipt)

const receipt = await provider.getTransactionReceipt(
mockReceipt.transactionHash
)
expect(receipt).toBeTruthy()

const cache = storage.getItem('arbitrum:bridge:tx-receipts-cache')
expect(cache).toBeTruthy()
})
})
})
4 changes: 3 additions & 1 deletion packages/arb-token-bridge-ui/src/token-bridge-sdk/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getArbitrumNetwork
} from '@arbitrum/sdk'
import { isDepositMode } from '../util/isDepositMode'
import { EnhancedProvider } from './EnhancedProvider'

export const getAddressFromSigner = async (signer: Signer) => {
const address = await signer.getAddress()
Expand Down Expand Up @@ -107,7 +108,8 @@ const getProviderForChainCache: {

function createProviderWithCache(chainId: ChainId) {
const rpcUrl = rpcURLs[chainId]
const provider = new StaticJsonRpcProvider(rpcUrl, chainId)

const provider = new EnhancedProvider(rpcUrl, chainId)
getProviderForChainCache[chainId] = provider
return provider
}
Expand Down

0 comments on commit 930bbe1

Please sign in to comment.