diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index b44e2e03f3..0d25caa74d 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -290,7 +290,10 @@ export type TransactionControllerOptions = { getNetworkState: () => NetworkState; getPermittedAccounts?: (origin?: string) => Promise; getSavedGasFees?: (chainId: Hex) => SavedGasFees | undefined; - incomingTransactions?: IncomingTransactionOptions; + incomingTransactions?: IncomingTransactionOptions & { + /** API keys to be used for Etherscan requests to prevent rate limiting. */ + etherscanApiKeysByChainId?: Record; + }; isMultichainEnabled: boolean; isSimulationEnabled?: () => boolean; messenger: TransactionControllerMessenger; @@ -884,6 +887,7 @@ export class TransactionController extends BaseController< const etherscanRemoteTransactionSource = new EtherscanRemoteTransactionSource({ + apiKeysByChainId: incomingTransactions.etherscanApiKeysByChainId, includeTokenTransfers: incomingTransactions.includeTokenTransfers, }); diff --git a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts index 1c9bc290fd..8519fbdb2b 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts @@ -15,6 +15,7 @@ import { ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK, } from '../../tests/EtherscanMocks'; import { CHAIN_IDS } from '../constants'; +import type { RemoteTransactionSourceRequest } from '../types'; import { fetchEtherscanTokenTransactions, fetchEtherscanTransactions, @@ -28,6 +29,8 @@ jest.mock('../utils/etherscan', () => ({ jest.mock('uuid'); +const API_KEY_MOCK = 'TestApiKey'; + describe('EtherscanRemoteTransactionSource', () => { let clock: sinon.SinonFakeTimers; @@ -241,6 +244,48 @@ describe('EtherscanRemoteTransactionSource', () => { expect(fetchEtherscanTransactionsMock).toHaveBeenCalledTimes(3); }); + it('includes API key if chain matches', async () => { + fetchEtherscanTransactionsMock.mockResolvedValueOnce( + ETHERSCAN_TRANSACTION_RESPONSE_MOCK, + ); + + await new EtherscanRemoteTransactionSource({ + apiKeysByChainId: { + [CHAIN_IDS.MAINNET]: API_KEY_MOCK, + }, + }).fetchTransactions({ + currentChainId: CHAIN_IDS.MAINNET, + } as unknown as RemoteTransactionSourceRequest); + + expect(fetchEtherscanTransactionsMock).toHaveBeenCalledTimes(1); + expect(fetchEtherscanTransactionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: API_KEY_MOCK, + }), + ); + }); + + it('does not include API key if chain does not match', async () => { + fetchEtherscanTransactionsMock.mockResolvedValueOnce( + ETHERSCAN_TRANSACTION_RESPONSE_MOCK, + ); + + await new EtherscanRemoteTransactionSource({ + apiKeysByChainId: { + [CHAIN_IDS.MAINNET]: API_KEY_MOCK, + }, + }).fetchTransactions({ + currentChainId: CHAIN_IDS.SEPOLIA, + } as unknown as RemoteTransactionSourceRequest); + + expect(fetchEtherscanTransactionsMock).toHaveBeenCalledTimes(1); + expect(fetchEtherscanTransactionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: undefined, + }), + ); + }); + it.each([ ['no transactions found', ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK], ['error', ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK], diff --git a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts index fd4a6f76b1..9a4c891370 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts @@ -25,12 +25,15 @@ import type { } from '../utils/etherscan'; const ETHERSCAN_RATE_LIMIT_INTERVAL = 5000; + /** * A RemoteTransactionSource that fetches transaction data from Etherscan. */ export class EtherscanRemoteTransactionSource implements RemoteTransactionSource { + #apiKeysByChainId?: Record; + #includeTokenTransfers: boolean; #isTokenRequestPending: boolean; @@ -38,8 +41,13 @@ export class EtherscanRemoteTransactionSource #mutex = new Mutex(); constructor({ + apiKeysByChainId, includeTokenTransfers, - }: { includeTokenTransfers?: boolean } = {}) { + }: { + apiKeysByChainId?: Record; + includeTokenTransfers?: boolean; + } = {}) { + this.#apiKeysByChainId = apiKeysByChainId; this.#includeTokenTransfers = includeTokenTransfers ?? true; this.#isTokenRequestPending = false; } @@ -57,10 +65,17 @@ export class EtherscanRemoteTransactionSource ): Promise { const releaseLock = await this.#mutex.acquire(); const acquiredTime = Date.now(); + const { currentChainId: chainId } = request; + const apiKey = this.#apiKeysByChainId?.[chainId]; + + if (apiKey) { + log('Etherscan API key found for chain', chainId); + } const etherscanRequest: EtherscanTransactionRequest = { ...request, - chainId: request.currentChainId, + apiKey, + chainId, }; try { diff --git a/packages/transaction-controller/src/utils/etherscan.test.ts b/packages/transaction-controller/src/utils/etherscan.test.ts index 6fd0d7e0ac..d4d5dbbe47 100644 --- a/packages/transaction-controller/src/utils/etherscan.test.ts +++ b/packages/transaction-controller/src/utils/etherscan.test.ts @@ -15,6 +15,7 @@ jest.mock('@metamask/controller-utils', () => ({ })); const ADDERSS_MOCK = '0x2A2D72308838A6A46a0B5FDA3055FE915b5D99eD'; +const API_KEY_MOCK = 'TestApiKey'; const REQUEST_MOCK: EtherscanTransactionRequest = { address: ADDERSS_MOCK, @@ -159,5 +160,32 @@ describe('Etherscan', () => { `&page=1`, ); }); + + it('includes API key if provided', async () => { + handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK); + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (Etherscan as any)[method]({ + ...REQUEST_MOCK, + apiKey: API_KEY_MOCK, + fromBlock: undefined, + limit: undefined, + }); + + expect(handleFetchMock).toHaveBeenCalledTimes(1); + expect(handleFetchMock).toHaveBeenCalledWith( + `https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${ + ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain + }/api?` + + `module=account` + + `&address=${REQUEST_MOCK.address}` + + `&sort=desc` + + `&action=${action}` + + `&apikey=${API_KEY_MOCK}` + + `&tag=latest` + + `&page=1`, + ); + }); }); }); diff --git a/packages/transaction-controller/src/utils/etherscan.ts b/packages/transaction-controller/src/utils/etherscan.ts index ff46ccff8e..703871f499 100644 --- a/packages/transaction-controller/src/utils/etherscan.ts +++ b/packages/transaction-controller/src/utils/etherscan.ts @@ -66,6 +66,7 @@ export interface EtherscanTransactionResponse< // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export interface EtherscanTransactionRequest { address: string; + apiKey?: string; chainId: Hex; fromBlock?: number; limit?: number; @@ -76,6 +77,7 @@ export interface EtherscanTransactionRequest { * * @param request - Configuration required to fetch transactions. * @param request.address - Address to retrieve transactions for. + * @param request.apiKey - Etherscan API key to prevent rate limiting. * @param request.chainId - Current chain ID used to determine subdomain and domain. * @param request.fromBlock - Block number to start fetching transactions from. * @param request.limit - Number of transactions to retrieve. @@ -83,6 +85,7 @@ export interface EtherscanTransactionRequest { */ export async function fetchEtherscanTransactions({ address, + apiKey, chainId, fromBlock, limit, @@ -91,6 +94,7 @@ export async function fetchEtherscanTransactions({ > { return await fetchTransactions('txlist', { address, + apiKey, chainId, fromBlock, limit, @@ -102,6 +106,7 @@ export async function fetchEtherscanTransactions({ * * @param request - Configuration required to fetch token transactions. * @param request.address - Address to retrieve token transactions for. + * @param request.apiKey - Etherscan API key to prevent rate limiting. * @param request.chainId - Current chain ID used to determine subdomain and domain. * @param request.fromBlock - Block number to start fetching token transactions from. * @param request.limit - Number of token transactions to retrieve. @@ -109,6 +114,7 @@ export async function fetchEtherscanTransactions({ */ export async function fetchEtherscanTokenTransactions({ address, + apiKey, chainId, fromBlock, limit, @@ -117,6 +123,7 @@ export async function fetchEtherscanTokenTransactions({ > { return await fetchTransactions('tokentx', { address, + apiKey, chainId, fromBlock, limit, @@ -129,27 +136,30 @@ export async function fetchEtherscanTokenTransactions({ * @param action - The Etherscan endpoint to use. * @param options - Options bag. * @param options.address - Address to retrieve transactions for. + * @param options.apiKey - Etherscan API key to prevent rate limiting. * @param options.chainId - Current chain ID used to determine subdomain and domain. * @param options.fromBlock - Block number to start fetching transactions from. * @param options.limit - Number of transactions to retrieve. * @returns An object containing the request status and an array of transaction data. */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention -async function fetchTransactions( +async function fetchTransactions< + ResponseData extends EtherscanTransactionMetaBase, +>( action: string, { address, + apiKey, chainId, fromBlock, limit, }: { address: string; + apiKey?: string; chainId: Hex; fromBlock?: number; limit?: number; }, -): Promise> { +): Promise> { const urlParams = { module: 'account', address, @@ -161,13 +171,14 @@ async function fetchTransactions( const etherscanTxUrl = getEtherscanApiUrl(chainId, { ...urlParams, action, + apikey: apiKey, }); log('Sending Etherscan request', etherscanTxUrl); const response = (await handleFetch( etherscanTxUrl, - )) as EtherscanTransactionResponse; + )) as EtherscanTransactionResponse; return response; }