diff --git a/.changeset/flat-melons-bathe.md b/.changeset/flat-melons-bathe.md new file mode 100644 index 000000000..4c98bb6b2 --- /dev/null +++ b/.changeset/flat-melons-bathe.md @@ -0,0 +1,5 @@ +--- +'@xchainjs/xchain-evm': minor +--- + +Added caching for better performance diff --git a/packages/xchain-evm/__tests__/cache.test.ts b/packages/xchain-evm/__tests__/cache.test.ts new file mode 100644 index 000000000..f558599f2 --- /dev/null +++ b/packages/xchain-evm/__tests__/cache.test.ts @@ -0,0 +1,74 @@ +import { JsonRpcProvider } from 'ethers' +import { getCachedContract } from '../src/cache' +import erc20ABI from '../src/data/erc20.json' + +describe('Contract Cache', () => { + it('should cache contracts separately for different providers', () => { + // Create two different providers + const provider1 = new JsonRpcProvider('https://eth.llamarpc.com') + const provider2 = new JsonRpcProvider('https://goerli.infura.io/v3/test') + + const contractAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // USDC address + + // Get contracts from both providers + const contract1 = getCachedContract(contractAddress, erc20ABI, provider1) + const contract2 = getCachedContract(contractAddress, erc20ABI, provider2) + + // Contracts should have different providers + expect(contract1.runner).toBe(provider1) + expect(contract2.runner).toBe(provider2) + expect(contract1).not.toBe(contract2) // Different contract instances + + // Getting the same contract again should return the cached instance + const contract1Again = getCachedContract(contractAddress, erc20ABI, provider1) + expect(contract1).toBe(contract1Again) // Same instance from cache + }) + + it('should cache contracts separately for different addresses on same provider', () => { + const provider = new JsonRpcProvider('https://eth.llamarpc.com') + + const address1 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // USDC + const address2 = '0xdAC17F958D2ee523a2206206994597C13D831ec7' // USDT + + // Get contracts for different addresses + const contract1 = getCachedContract(address1, erc20ABI, provider) + const contract2 = getCachedContract(address2, erc20ABI, provider) + + // Should be different contract instances + expect(contract1).not.toBe(contract2) + expect(contract1.target).toBe(address1) + expect(contract2.target).toBe(address2) + + // Getting the same contract again should return the cached instance + const contract1Again = getCachedContract(address1, erc20ABI, provider) + expect(contract1).toBe(contract1Again) + }) + + it('should cache contracts separately for different provider instances with same URL', () => { + // Create two distinct provider instances using the same URL + const sameUrl = 'https://eth.llamarpc.com' + const provider1 = new JsonRpcProvider(sameUrl) + const provider2 = new JsonRpcProvider(sameUrl) + + const contractAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // USDC address + + // Get contracts from both provider instances + const contract1 = getCachedContract(contractAddress, erc20ABI, provider1) + const contract2 = getCachedContract(contractAddress, erc20ABI, provider2) + + // Contracts should be different instances (cache keys by provider instance, not just URL) + expect(contract1).not.toBe(contract2) + + // Each contract should be bound to its respective provider + expect(contract1.runner).toBe(provider1) + expect(contract2.runner).toBe(provider2) + expect(contract1.runner).not.toBe(contract2.runner) + + // Calling getCachedContract again with the same provider should return the cached instance + const contract1Again = getCachedContract(contractAddress, erc20ABI, provider1) + const contract2Again = getCachedContract(contractAddress, erc20ABI, provider2) + + expect(contract1).toBe(contract1Again) // Same instance from cache for provider1 + expect(contract2).toBe(contract2Again) // Same instance from cache for provider2 + }) +}) diff --git a/packages/xchain-evm/src/cache.ts b/packages/xchain-evm/src/cache.ts new file mode 100644 index 000000000..6267a72b5 --- /dev/null +++ b/packages/xchain-evm/src/cache.ts @@ -0,0 +1,54 @@ +import { Contract } from 'ethers' +import type { Provider, InterfaceAbi } from 'ethers' +import BigNumber from 'bignumber.js' + +// Provider-scoped contract cache using WeakMap for automatic cleanup +const contractCacheByProvider: WeakMap> = new WeakMap() +const bigNumberCache = new Map() + +/** + * Get a cached Contract instance or create a new one + * Uses provider-scoped caching for proper isolation + */ +export function getCachedContract(address: string, abi: InterfaceAbi, provider: Provider): Contract { + // Get or create the contract cache for this provider + let providerCache = contractCacheByProvider.get(provider) + if (!providerCache) { + providerCache = new Map() + contractCacheByProvider.set(provider, providerCache) + } + + // Use normalized address as key + const normalizedAddress = address.toLowerCase() + + // Get or create the contract for this address + let contract = providerCache.get(normalizedAddress) + if (!contract) { + contract = new Contract(address, abi, provider) + providerCache.set(normalizedAddress, contract) + } + + return contract +} + +/** + * Get a cached BigNumber instance or create a new one + * Only accepts string or bigint to preserve precision + */ +export function getCachedBigNumber(value: string | bigint): BigNumber { + const stringValue = typeof value === 'bigint' ? value.toString() : value + if (!bigNumberCache.has(stringValue)) { + bigNumberCache.set(stringValue, new BigNumber(stringValue)) + } + return bigNumberCache.get(stringValue)! +} + +/** + * Clear all caches (useful for testing or memory management) + * Note: WeakMap-based contract cache will be automatically cleaned up by GC + */ +export function clearCaches(): void { + // Note: WeakMap doesn't have a clear() method, and that's by design + // The contract cache will be automatically cleaned up when providers are GC'd + bigNumberCache.clear() +} diff --git a/packages/xchain-evm/src/clients/client.ts b/packages/xchain-evm/src/clients/client.ts index 15b5cd013..0d8fd1868 100644 --- a/packages/xchain-evm/src/clients/client.ts +++ b/packages/xchain-evm/src/clients/client.ts @@ -26,10 +26,11 @@ import { baseAmount, eqAsset, } from '@xchainjs/xchain-util' -import { Provider, Contract, Transaction, toUtf8Bytes, hexlify } from 'ethers' +import { Provider, Transaction, toUtf8Bytes, hexlify } from 'ethers' import BigNumber from 'bignumber.js' import erc20ABI from '../data/erc20.json' +import { getCachedContract, getCachedBigNumber } from '../cache' import { ApproveParams, Balance, @@ -77,6 +78,33 @@ export type EVMClientParams = XChainClientParams & { signer?: ISigner } +/** + * Helper function to race promises and return the first successful result + * Uses Promise.race with proper error handling as fallback for Promise.any + */ +async function promiseAny(promises: Promise[]): Promise { + // Use Promise.race to get the first resolved promise + const errors: Error[] = [] + + return new Promise((resolve, reject) => { + let completedCount = 0 + + promises.forEach((promise) => { + promise.then(resolve).catch((error) => { + errors.push(error) + completedCount += 1 + if (completedCount === promises.length) { + reject(new Error('All promises failed: ' + errors.map((e) => e.message).join(', '))) + } + }) + }) + + if (promises.length === 0) { + reject(new Error('No promises provided')) + } + }) +} + /** * Custom EVM client class. */ @@ -347,7 +375,7 @@ export class Client extends BaseXChainClient implements EVMClient { throw new Error('Gas price is null') } - const gasPrice = new BigNumber(feeData.gasPrice.toString()) + const gasPrice = getCachedBigNumber(feeData.gasPrice.toString()) // Adjust gas prices for different fee options return { @@ -425,7 +453,7 @@ export class Client extends BaseXChainClient implements EVMClient { // ERC20 gas estimate const assetAddress = getTokenAddress(theAsset) if (!assetAddress) throw Error(`Can't get address from asset ${assetToString(theAsset)}`) - const contract = new Contract(assetAddress, erc20ABI, this.getProvider()) + const contract = getCachedContract(assetAddress, erc20ABI, this.getProvider()) const address = from || (await this.getAddressAsync()) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -433,7 +461,7 @@ export class Client extends BaseXChainClient implements EVMClient { from: address, }) - gasEstimate = new BigNumber(gasEstimateResponse.toString()) + gasEstimate = getCachedBigNumber(gasEstimateResponse.toString()) } else { // ETH gas estimate let stringEncodedMemo @@ -448,7 +476,7 @@ export class Client extends BaseXChainClient implements EVMClient { data: parsedMemo, } const gasEstimation = await this.getProvider().estimateGas(transactionRequest) - gasEstimate = new BigNumber(gasEstimation.toString()) + gasEstimate = getCachedBigNumber(gasEstimation.toString()) } return gasEstimate @@ -470,18 +498,20 @@ export class Client extends BaseXChainClient implements EVMClient { * @returns {FeesWithGasPricesAndLimits} The estimated gas prices/limits and fees. */ async estimateFeesWithGasPricesAndLimits(params: TxParams): Promise { - // Gas prices estimation - const gasPrices = await this.estimateGasPrices() + // Parallel gas prices and limits estimation + const [gasPrices, gasLimit] = await Promise.all([ + this.estimateGasPrices(), + this.estimateGasLimit({ + asset: params.asset, + amount: params.amount, + recipient: params.recipient, + memo: params.memo, + }), + ]) + const decimals = this.config.gasAssetDecimals const { fast: fastGP, fastest: fastestGP, average: averageGP } = gasPrices - // Gas limits estimation - const gasLimit = await this.estimateGasLimit({ - asset: params.asset, - amount: params.amount, - recipient: params.recipient, - memo: params.memo, - }) // Calculate fees return { gasPrices, @@ -527,15 +557,23 @@ export class Client extends BaseXChainClient implements EVMClient { * @throws Error Thrown if no provider is able to retrieve the balance. */ protected async roundRobinGetBalance(address: Address, assets?: TokenAsset[]) { - for (const provider of this.config.dataProviders) { - try { + const promises = this.config.dataProviders + .map(async (provider) => { const prov = provider[this.network] - if (prov) return await prov.getBalance(address, assets) - } catch (error) { - console.warn(error) - } + if (!prov) throw new Error('Provider not available for network') + return await prov.getBalance(address, assets) + }) + .filter(Boolean) + + if (promises.length === 0) { + throw Error('No providers available for network') + } + + try { + return await promiseAny(promises) + } catch (_error) { + throw Error('No provider able to get balance: all providers failed') } - throw Error('no provider able to get balance') } /** * Retrieves transaction data by round-robin querying multiple data providers. @@ -546,15 +584,23 @@ export class Client extends BaseXChainClient implements EVMClient { * @throws Error Thrown if no provider is able to retrieve the transaction data. */ protected async roundRobinGetTransactionData(txId: string, assetAddress?: string) { - for (const provider of this.config.dataProviders) { - try { + const promises = this.config.dataProviders + .map(async (provider) => { const prov = provider[this.network] - if (prov) return await prov.getTransactionData(txId, assetAddress) - } catch (error) { - console.warn(error) - } + if (!prov) throw new Error('Provider not available for network') + return await prov.getTransactionData(txId, assetAddress) + }) + .filter(Boolean) + + if (promises.length === 0) { + throw Error('No providers available for network') + } + + try { + return await promiseAny(promises) + } catch (_error) { + throw Error('No provider able to GetTransactionData: all providers failed') } - throw Error('no provider able to GetTransactionData') } /** * Retrieves transaction history by round-robin querying multiple data providers. @@ -564,15 +610,23 @@ export class Client extends BaseXChainClient implements EVMClient { * @throws Error Thrown if no provider is able to retrieve the transaction history. */ protected async roundRobinGetTransactions(params: TxHistoryParams) { - for (const provider of this.config.dataProviders) { - try { + const promises = this.config.dataProviders + .map(async (provider) => { const prov = provider[this.network] - if (prov) return await prov.getTransactions(params) - } catch (error) { - console.warn(error) - } + if (!prov) throw new Error('Provider not available for network') + return await prov.getTransactions(params) + }) + .filter(Boolean) + + if (promises.length === 0) { + throw Error('No providers available for network') + } + + try { + return await promiseAny(promises) + } catch (_error) { + throw Error('No provider able to GetTransactions: all providers failed') } - throw Error('no provider able to GetTransactions') } /** * Retrieves fee rates by round-robin querying multiple data providers. @@ -581,15 +635,23 @@ export class Client extends BaseXChainClient implements EVMClient { * @throws Error Thrown if no provider is able to retrieve the fee rates. */ protected async roundRobinGetFeeRates(): Promise { - for (const provider of this.config.dataProviders) { - try { + const promises = this.config.dataProviders + .map(async (provider) => { const prov = provider[this.network] - if (prov) return await prov.getFeeRates() - } catch (error) { - console.warn(error) - } + if (!prov) throw new Error('Provider not available for network') + return await prov.getFeeRates() + }) + .filter(Boolean) + + if (promises.length === 0) { + throw Error('No providers available for network') + } + + try { + return await promiseAny(promises) + } catch (_error) { + throw Error('No provider available to getFeeRates: all providers failed') } - throw Error('No provider available to getFeeRates') } /** @@ -641,7 +703,7 @@ export class Client extends BaseXChainClient implements EVMClient { const assetAddress = getTokenAddress(asset) if (!assetAddress) throw Error(`Can't parse address from asset ${assetToString(asset)}`) - const contract = new Contract(assetAddress, erc20ABI, this.getProvider()) + const contract = getCachedContract(assetAddress, erc20ABI, this.getProvider()) const amountToTransfer = BigInt(amount.amount().toFixed()) const unsignedTx = await contract.getFunction('transfer').populateTransaction(recipient, amountToTransfer) @@ -674,7 +736,7 @@ export class Client extends BaseXChainClient implements EVMClient { if (!this.validateAddress(spenderAddress)) throw Error('Invalid spenderAddress address') if (!this.validateAddress(sender)) throw Error('Invalid sender address') - const contract = new Contract(contractAddress, erc20ABI, this.getProvider()) + const contract = getCachedContract(contractAddress, erc20ABI, this.getProvider()) const valueToApprove = getApprovalAmount(amount) const unsignedTx = await contract @@ -852,7 +914,7 @@ export class Client extends BaseXChainClient implements EVMClient { }: ApproveParams): Promise { const sender = await this.getAddressAsync(walletIndex || 0) - const gasPrice: BigNumber = new BigNumber( + const gasPrice: BigNumber = getCachedBigNumber( (await this.estimateGasPrices().then((prices) => prices[feeOption])).amount().toFixed(), ) @@ -864,7 +926,7 @@ export class Client extends BaseXChainClient implements EVMClient { fromAddress: sender, amount, }).catch(() => { - return new BigNumber(this.config.defaults[this.network].approveGasLimit) + return getCachedBigNumber(this.config.defaults[this.network].approveGasLimit.toString()) }) const { rawUnsignedTx } = await this.prepareApprove({ diff --git a/packages/xchain-evm/src/utils.ts b/packages/xchain-evm/src/utils.ts index c136053f5..18da51e8f 100644 --- a/packages/xchain-evm/src/utils.ts +++ b/packages/xchain-evm/src/utils.ts @@ -3,6 +3,7 @@ import { Signer, Contract, Provider, getAddress, InterfaceAbi, BaseContract } fr import BigNumber from 'bignumber.js' import erc20ABI from './data/erc20.json' +import { getCachedContract, getCachedBigNumber } from './cache' /** * Maximum approval amount possible, set to 2^256 - 1. */ @@ -93,7 +94,7 @@ export const filterSelfTxs = - amount && amount.gt(baseAmount(0, amount.decimal)) ? new BigNumber(amount.amount().toFixed()) : MAX_APPROVAL + amount && amount.gt(baseAmount(0, amount.decimal)) ? getCachedBigNumber(amount.amount().toFixed()) : MAX_APPROVAL /** * Estimate gas required for calling a contract function. @@ -119,9 +120,9 @@ export const estimateCall = async ({ funcName: string funcParams?: unknown[] }): Promise => { - const contract = new Contract(contractAddress, abi, provider) + const contract = getCachedContract(contractAddress, abi, provider) const estiamtion = await contract.getFunction(funcName).estimateGas(...funcParams) - return new BigNumber(estiamtion.toString()) + return getCachedBigNumber(estiamtion.toString()) } /** * Calls a contract function. @@ -149,7 +150,7 @@ export const call = async ({ funcName: string funcParams?: unknown[] }): Promise => { - let contract: BaseContract = new Contract(contractAddress, abi, provider) + let contract: BaseContract = getCachedContract(contractAddress, abi, provider) if (signer) { // For sending transactions, a signer is needed contract = contract.connect(signer) @@ -175,7 +176,7 @@ export const getContract = async ({ contractAddress: Address abi: InterfaceAbi }): Promise => { - return new Contract(contractAddress, abi, provider) + return getCachedContract(contractAddress, abi, provider) } /** @@ -238,10 +239,10 @@ export async function isApproved({ fromAddress: Address amount?: BaseAmount }): Promise { - const txAmount = new BigNumber(amount?.amount().toFixed() ?? 1) - const contract: Contract = new Contract(contractAddress, erc20ABI, provider) + const txAmount = getCachedBigNumber(amount?.amount().toFixed() ?? '1') + const contract = getCachedContract(contractAddress, erc20ABI, provider) const allowanceResponse = await contract.allowance(fromAddress, spenderAddress) - const allowance: BigNumber = new BigNumber(allowanceResponse.toString()) + const allowance = getCachedBigNumber(allowanceResponse.toString()) return txAmount.lte(allowance) }