Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Etherscan API keys #4748

Merged
merged 6 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading