Skip to content

Commit

Permalink
feat: support Etherscan API keys (#4748)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewwalsh0 authored Oct 1, 2024
1 parent aa9f33c commit c2845d1
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 8 deletions.
6 changes: 5 additions & 1 deletion packages/transaction-controller/src/TransactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,10 @@ export type TransactionControllerOptions = {
getNetworkState: () => NetworkState;
getPermittedAccounts?: (origin?: string) => Promise<string[]>;
getSavedGasFees?: (chainId: Hex) => SavedGasFees | undefined;
incomingTransactions?: IncomingTransactionOptions;
incomingTransactions?: IncomingTransactionOptions & {
/** API keys to be used for Etherscan requests to prevent rate limiting. */
etherscanApiKeysByChainId?: Record<Hex, string>;
};
isMultichainEnabled: boolean;
isSimulationEnabled?: () => boolean;
messenger: TransactionControllerMessenger;
Expand Down Expand Up @@ -884,6 +887,7 @@ export class TransactionController extends BaseController<

const etherscanRemoteTransactionSource =
new EtherscanRemoteTransactionSource({
apiKeysByChainId: incomingTransactions.etherscanApiKeysByChainId,
includeTokenTransfers: incomingTransactions.includeTokenTransfers,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,8 @@ jest.mock('../utils/etherscan', () => ({

jest.mock('uuid');

const API_KEY_MOCK = 'TestApiKey';

describe('EtherscanRemoteTransactionSource', () => {
let clock: sinon.SinonFakeTimers;

Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,29 @@ 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<Hex, string>;

#includeTokenTransfers: boolean;

#isTokenRequestPending: boolean;

#mutex = new Mutex();

constructor({
apiKeysByChainId,
includeTokenTransfers,
}: { includeTokenTransfers?: boolean } = {}) {
}: {
apiKeysByChainId?: Record<Hex, string>;
includeTokenTransfers?: boolean;
} = {}) {
this.#apiKeysByChainId = apiKeysByChainId;
this.#includeTokenTransfers = includeTokenTransfers ?? true;
this.#isTokenRequestPending = false;
}
Expand All @@ -57,10 +65,17 @@ export class EtherscanRemoteTransactionSource
): Promise<TransactionMeta[]> {
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 {
Expand Down
28 changes: 28 additions & 0 deletions packages/transaction-controller/src/utils/etherscan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
);
});
});
});
21 changes: 16 additions & 5 deletions packages/transaction-controller/src/utils/etherscan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -76,13 +77,15 @@ 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.
* @returns An Etherscan response object containing the request status and an array of token transaction data.
*/
export async function fetchEtherscanTransactions({
address,
apiKey,
chainId,
fromBlock,
limit,
Expand All @@ -91,6 +94,7 @@ export async function fetchEtherscanTransactions({
> {
return await fetchTransactions('txlist', {
address,
apiKey,
chainId,
fromBlock,
limit,
Expand All @@ -102,13 +106,15 @@ 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.
* @returns An Etherscan response object containing the request status and an array of token transaction data.
*/
export async function fetchEtherscanTokenTransactions({
address,
apiKey,
chainId,
fromBlock,
limit,
Expand All @@ -117,6 +123,7 @@ export async function fetchEtherscanTokenTransactions({
> {
return await fetchTransactions('tokentx', {
address,
apiKey,
chainId,
fromBlock,
limit,
Expand All @@ -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<T extends EtherscanTransactionMetaBase>(
async function fetchTransactions<
ResponseData extends EtherscanTransactionMetaBase,
>(
action: string,
{
address,
apiKey,
chainId,
fromBlock,
limit,
}: {
address: string;
apiKey?: string;
chainId: Hex;
fromBlock?: number;
limit?: number;
},
): Promise<EtherscanTransactionResponse<T>> {
): Promise<EtherscanTransactionResponse<ResponseData>> {
const urlParams = {
module: 'account',
address,
Expand All @@ -161,13 +171,14 @@ async function fetchTransactions<T extends EtherscanTransactionMetaBase>(
const etherscanTxUrl = getEtherscanApiUrl(chainId, {
...urlParams,
action,
apikey: apiKey,
});

log('Sending Etherscan request', etherscanTxUrl);

const response = (await handleFetch(
etherscanTxUrl,
)) as EtherscanTransactionResponse<T>;
)) as EtherscanTransactionResponse<ResponseData>;

return response;
}
Expand Down

0 comments on commit c2845d1

Please sign in to comment.