-
Notifications
You must be signed in to change notification settings - Fork 210
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
perf: cache transaction receipts (#2181)
- Loading branch information
1 parent
ec72890
commit 930bbe1
Showing
3 changed files
with
298 additions
and
1 deletion.
There are no files selected for viewing
163 changes: 163 additions & 0 deletions
163
packages/arb-token-bridge-ui/src/token-bridge-sdk/EnhancedProvider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
132 changes: 132 additions & 0 deletions
132
packages/arb-token-bridge-ui/src/token-bridge-sdk/__tests__/EnhancedProvider.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters