/**
* V2 Request Builder Module
*
* This module provides utilities for building Etherscan v2 API requests.
* The v2 API requires a `chainid` parameter on all requests and uses a different base URL.
*
* @module v2-request-builder
*/
/**
* Parameters for building an API request
*/
export interface RequestParams {
/** The API module (e.g., 'account', 'contract', 'block') */
module: string;
/** The API action (e.g., 'balance', 'getabi', 'getblocknobytime') */
action: string;
/** Additional query parameters specific to the action */
params?: Record<string, string | number | boolean>;
/** The Etherscan API key */
apiKey: string;
}
/**
* Standard Etherscan API response structure
*
* @template T The type of the result data
*/
export interface ApiResponse<T> {
/** Status code: '1' for success, '0' for error */
status: '0' | '1';
/** Status message */
message: string;
/** Response data or error message */
result: T;
}
/**
* V2 Request Builder class
*
* Provides a convenient interface for building v2 API URLs with automatic
* chainid injection and parameter management.
*
* @example
* ```typescript
* const builder = new V2RequestBuilder(1); // Mainnet
*
* const url = builder.buildUrl({
* module: 'account',
* action: 'balance',
* params: { address: '0x123...', tag: 'latest' },
* apiKey: 'YourApiKey'
* });
* ```
*/
export class V2RequestBuilder {
private baseUrl = 'https://api.etherscan.io/v2/api';
private chainId: number;
/**
* Creates a new V2RequestBuilder instance
*
* @param chainId - The blockchain chain ID (e.g., 1 for Ethereum mainnet, 137 for Polygon)
* @throws {Error} If chainId is invalid (zero or negative)
*/
constructor(chainId: number) {
this.validateChainId(chainId);
this.chainId = chainId;
}
/**
* Validates that a chain ID is valid
*
* @param chainId - The chain ID to validate
* @throws {Error} If chain ID is zero or negative
*/
private validateChainId(chainId: number): void {
if (chainId <= 0) {
throw new Error(`Invalid chain ID: ${chainId}. Chain ID must be a positive number.`);
}
}
/**
* Build a complete URL for a v2 API request
* Automatically injects the chainid parameter
*
* @param request - The request parameters
* @returns The complete URL ready for HTTP request
*
* @example
* ```typescript
* const url = builder.buildUrl({
* module: 'account',
* action: 'balance',
* params: { address: '0x123...', tag: 'latest' },
* apiKey: 'YourApiKey'
* });
* // Returns: https://api.etherscan.io/v2/api?chainid=1&action=balance&address=0x123...&apikey=YourApiKey&module=account&tag=latest
* ```
*/
buildUrl(request: RequestParams): string {
return this.buildUrlInternal(this.chainId, request);
}
/**
* Build URL with a different chain ID (for one-off requests)
* Does not modify the instance's chain ID
*
* @param chainId - The chain ID to use for this request
* @param request - The request parameters
* @returns The complete URL ready for HTTP request
* @throws {Error} If chainId is invalid
*
* @example
* ```typescript
* const builder = new V2RequestBuilder(1); // Mainnet instance
*
* // Make a one-off request to Polygon without changing the instance
* const url = builder.buildUrlForChain(137, {
* module: 'account',
* action: 'balance',
* params: { address: '0x123...' },
* apiKey: 'YourApiKey'
* });
* ```
*/
buildUrlForChain(chainId: number, request: RequestParams): string {
this.validateChainId(chainId);
return this.buildUrlInternal(chainId, request);
}
/**
* Internal method to build URLs
*
* @param chainId - The chain ID to use
* @param request - The request parameters
* @returns The complete URL
*/
private buildUrlInternal(chainId: number, request: RequestParams): string {
// Start with chainid as the first parameter
const params = new Map<string, string>();
params.set('chainid', String(chainId));
// Add module and action
params.set('module', request.module);
params.set('action', request.action);
// Add additional params if provided
if (request.params) {
for (const [key, value] of Object.entries(request.params)) {
params.set(key, String(value));
}
}
// Add API key
params.set('apikey', request.apiKey);
// Sort all parameters alphabetically (except chainid which stays first)
const chainidParam = `chainid=${encodeURIComponent(String(chainId))}`;
const sortedParams = Array.from(params.entries())
.filter(([key]) => key !== 'chainid')
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
return `${this.baseUrl}?${chainidParam}&${sortedParams}`;
}
/**
* Get the current chain ID
*
* @returns The current chain ID
*/
getChainId(): number {
return this.chainId;
}
/**
* Set a new chain ID
*
* @param chainId - The new chain ID to use
* @throws {Error} If chainId is invalid
*
* @example
* ```typescript
* const builder = new V2RequestBuilder(1);
* builder.setChainId(137); // Switch to Polygon
* ```
*/
setChainId(chainId: number): void {
this.validateChainId(chainId);
this.chainId = chainId;
}
}
/**
* Build a v2 API URL for a specific chain
* Standalone function for cases where the class isn't needed
*
* @param chainId - The blockchain chain ID
* @param module - The API module
* @param action - The API action
* @param params - Additional query parameters
* @param apiKey - The Etherscan API key
* @returns The complete URL ready for HTTP request
* @throws {Error} If chainId is invalid
*
* @example
* ```typescript
* const url = buildV2Url(
* 1,
* 'account',
* 'balance',
* { address: '0x123...', tag: 'latest' },
* 'YourApiKey'
* );
* ```
*/
export function buildV2Url(
chainId: number,
module: string,
action: string,
params: Record<string, string | number | boolean>,
apiKey: string
): string {
const builder = new V2RequestBuilder(chainId);
return builder.buildUrl({ module, action, params, apiKey });
}
/**
* Parse API response and handle common error cases
*
* @template T The expected type of the result data
* @param response - The raw response to parse (typically from JSON.parse)
* @returns The parsed and validated API response
* @throws {Error} If response structure is invalid
*
* @example
* ```typescript
* const rawResponse = await fetch(url).then(r => r.json());
* const parsed = parseApiResponse<string>(rawResponse);
*
* if (isErrorResponse(parsed)) {
* console.error('API error:', parsed.message);
* } else {
* console.log('Result:', parsed.result);
* }
* ```
*/
export function parseApiResponse<T>(response: unknown): ApiResponse<T> {
// Validate that response is an object
if (typeof response !== 'object' || response === null) {
throw new Error('Invalid API response: expected an object');
}
const obj = response as Record<string, unknown>;
// Validate required fields exist
if (!('status' in obj) || !('message' in obj) || !('result' in obj)) {
throw new Error('Invalid API response: missing required fields (status, message, or result)');
}
// Validate status is either '0' or '1'
if (obj.status !== '0' && obj.status !== '1') {
throw new Error(`Invalid API response: status must be '0' or '1', got '${obj.status}'`);
}
// Validate message is a string
if (typeof obj.message !== 'string') {
throw new Error('Invalid API response: message must be a string');
}
return {
status: obj.status as '0' | '1',
message: obj.message,
result: obj.result as T
};
}
/**
* Check if a response indicates an error
*
* @param response - The parsed API response
* @returns True if the response indicates an error (status === '0')
*
* @example
* ```typescript
* const response = parseApiResponse(rawData);
*
* if (isErrorResponse(response)) {
* throw new Error(`API error: ${response.message} - ${response.result}`);
* }
* ```
*/
export function isErrorResponse(response: ApiResponse<unknown>): boolean {
return response.status === '0';
}