From 88749835c82627f40e692ce7b05a381f36f54cd5 Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Wed, 1 Feb 2023 21:28:16 -0500 Subject: [PATCH] Merged BaseEtherscanProvider and EtherscanProvider since Contract is safer to include for AbstractProvider anyways. --- src.ts/providers/provider-etherscan-base.ts | 554 ------------------- src.ts/providers/provider-etherscan.ts | 584 ++++++++++++++++++-- 2 files changed, 541 insertions(+), 597 deletions(-) delete mode 100644 src.ts/providers/provider-etherscan-base.ts diff --git a/src.ts/providers/provider-etherscan-base.ts b/src.ts/providers/provider-etherscan-base.ts deleted file mode 100644 index d7ba1b1f7..000000000 --- a/src.ts/providers/provider-etherscan-base.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { AbiCoder } from "../abi/index.js"; -import { accessListify, Transaction } from "../transaction/index.js"; -import { - defineProperties, - hexlify, toQuantity, - FetchRequest, - assert, assertArgument, isError, - toUtf8String - } from "../utils/index.js"; - -import { AbstractProvider } from "./abstract-provider.js"; -import { Network } from "./network.js"; -import { NetworkPlugin } from "./plugins-network.js"; -import { showThrottleMessage } from "./community.js"; - -import { PerformActionRequest } from "./abstract-provider.js"; -import type { Networkish } from "./network.js"; -//import type { } from "./pagination"; -import type { TransactionRequest } from "./provider.js"; - -const THROTTLE = 2000; - -/** - * When subscribing to the ``"debug"`` event on an Etherscan-based - * provider, the events receive a **DebugEventEtherscanProvider** - * payload. - * - * @_docloc: api/providers/thirdparty:Etherscan - */ -export type DebugEventEtherscanProvider = { - action: "sendRequest", - id: number, - url: string, - payload: Record -} | { - action: "receiveRequest", - id: number, - result: any -} | { - action: "receiveError", - id: number, - error: any -}; - -const EtherscanPluginId = "org.ethers.plugins.Etherscan"; - -/** - * A Network can include an **EtherscanPlugin** to provide - * a custom base URL. - * - * @_docloc: api/providers/thirdparty:Etherscan - */ -export class EtherscanPlugin extends NetworkPlugin { - /** - * The Etherscan API base URL. - */ - readonly baseUrl!: string; - - /** - * Creates a new **EtherscanProvider** which will use - * %%baseUrl%%. - */ - constructor(baseUrl: string) { - super(EtherscanPluginId); - defineProperties(this, { baseUrl }); - } - - clone(): EtherscanPlugin { - return new EtherscanPlugin(this.baseUrl); - } -} - -let nextId = 1; - -/** - * The **EtherscanBaseProvider** is the super-class of - * [[EtherscanProvider]], which should generally be used instead. - * - * Since the **EtherscanProvider** includes additional code for - * [[Contract]] access, in //rare cases// that contracts are not - * used, this class can reduce code size. - * - * @_docloc: api/providers/thirdparty:Etherscan - */ -export class BaseEtherscanProvider extends AbstractProvider { - - /** - * The connected network. - */ - readonly network!: Network; - - /** - * The API key or null if using the community provided bandwidth. - */ - readonly apiKey!: null | string; - - readonly #plugin: null | EtherscanPlugin; - - /** - * Creates a new **EtherscanBaseProvider**. - */ - constructor(_network?: Networkish, _apiKey?: string) { - const apiKey = (_apiKey != null) ? _apiKey: null; - - super(); - - const network = Network.from(_network); - - this.#plugin = network.getPlugin(EtherscanPluginId); - - defineProperties(this, { apiKey, network }); - - // Test that the network is supported by Etherscan - this.getBaseUrl(); - } - - /** - * Returns the base URL. - * - * If an [[EtherscanPlugin]] is configured on the - * [[EtherscanBaseProvider_network]], returns the plugin's - * baseUrl. - */ - getBaseUrl(): string { - if (this.#plugin) { return this.#plugin.baseUrl; } - - switch(this.network.name) { - case "mainnet": - return "https:/\/api.etherscan.io"; - case "goerli": - return "https:/\/api-goerli.etherscan.io"; - case "sepolia": - return "https:/\/api-sepolia.etherscan.io"; - - case "arbitrum": - return "https:/\/api.arbiscan.io"; - case "arbitrum-goerli": - return "https:/\/api-goerli.arbiscan.io"; - case "matic": - return "https:/\/api.polygonscan.com"; - case "maticmum": - return "https:/\/api-testnet.polygonscan.com"; - case "optimism": - return "https:/\/api-optimistic.etherscan.io"; - case "optimism-goerli": - return "https:/\/api-goerli-optimistic.etherscan.io"; - default: - } - - assertArgument(false, "unsupported network", "network", this.network); - } - - /** - * Returns the URL for the %%module%% and %%params%%. - */ - getUrl(module: string, params: Record): string { - const query = Object.keys(params).reduce((accum, key) => { - const value = params[key]; - if (value != null) { - accum += `&${ key }=${ value }` - } - return accum - }, ""); - const apiKey = ((this.apiKey) ? `&apikey=${ this.apiKey }`: ""); - return `${ this.getBaseUrl() }/api?module=${ module }${ query }${ apiKey }`; - } - - /** - * Returns the URL for using POST requests. - */ - getPostUrl(): string { - return `${ this.getBaseUrl() }/api`; - } - - /** - * Returns the parameters for using POST requests. - */ - getPostData(module: string, params: Record): Record { - params.module = module; - params.apikey = this.apiKey; - return params; - } - - async detectNetwork(): Promise { - return this.network; - } - - /** - * Resolves to the result of calling %%module%% with %%params%%. - * - * If %%post%%, the request is made as a POST request. - */ - async fetch(module: string, params: Record, post?: boolean): Promise { - const id = nextId++; - - const url = (post ? this.getPostUrl(): this.getUrl(module, params)); - const payload = (post ? this.getPostData(module, params): null); - - this.emit("debug", { action: "sendRequest", id, url, payload: payload }); - - const request = new FetchRequest(url); - request.setThrottleParams({ slotInterval: 1000 }); - request.retryFunc = (req, resp, attempt: number) => { - if (this.isCommunityResource()) { - showThrottleMessage("Etherscan"); - } - return Promise.resolve(true); - }; - request.processFunc = async (request, response) => { - const result = response.hasBody() ? JSON.parse(toUtf8String(response.body)): { }; - const throttle = ((typeof(result.result) === "string") ? result.result: "").toLowerCase().indexOf("rate limit") >= 0; - if (module === "proxy") { - // This JSON response indicates we are being throttled - if (result && result.status == 0 && result.message == "NOTOK" && throttle) { - this.emit("debug", { action: "receiveError", id, reason: "proxy-NOTOK", error: result }); - response.throwThrottleError(result.result, THROTTLE); - } - } else { - if (throttle) { - this.emit("debug", { action: "receiveError", id, reason: "null result", error: result.result }); - response.throwThrottleError(result.result, THROTTLE); - } - } - return response; - }; - - if (payload) { - request.setHeader("content-type", "application/x-www-form-urlencoded; charset=UTF-8"); - request.body = Object.keys(payload).map((k) => `${ k }=${ payload[k] }`).join("&"); - } - - const response = await request.send(); - try { - response.assertOk(); - } catch (error) { - this.emit("debug", { action: "receiveError", id, error, reason: "assertOk" }); - assert(false, "response error", "SERVER_ERROR", { request, response }); - } - - if (!response.hasBody()) { - this.emit("debug", { action: "receiveError", id, error: "missing body", reason: "null body" }); - assert(false, "missing response", "SERVER_ERROR", { request, response }); - } - - const result = JSON.parse(toUtf8String(response.body)); - if (module === "proxy") { - if (result.jsonrpc != "2.0") { - this.emit("debug", { action: "receiveError", id, result, reason: "invalid JSON-RPC" }); - assert(false, "invalid JSON-RPC response (missing jsonrpc='2.0')", "SERVER_ERROR", { request, response, info: { result } }); - } - - if (result.error) { - this.emit("debug", { action: "receiveError", id, result, reason: "JSON-RPC error" }); - assert(false, "error response", "SERVER_ERROR", { request, response, info: { result } }); - } - - this.emit("debug", { action: "receiveRequest", id, result }); - - return result.result; - - } else { - // getLogs, getHistory have weird success responses - if (result.status == 0 && (result.message === "No records found" || result.message === "No transactions found")) { - this.emit("debug", { action: "receiveRequest", id, result }); - return result.result; - } - - if (result.status != 1 || (typeof(result.message) === "string" && !result.message.match(/^OK/))) { - this.emit("debug", { action: "receiveError", id, result }); - assert(false, "error response", "SERVER_ERROR", { request, response, info: { result } }); - } - - this.emit("debug", { action: "receiveRequest", id, result }); - - return result.result; - } - } - - /** - * Returns %%transaction%% normalized for the Etherscan API. - */ - _getTransactionPostData(transaction: TransactionRequest): Record { - const result: Record = { }; - for (let key in transaction) { - if ((transaction)[key] == null) { continue; } - let value = (transaction)[key]; - if (key === "type" && value === 0) { continue; } - - // Quantity-types require no leading zero, unless 0 - if (({ type: true, gasLimit: true, gasPrice: true, maxFeePerGs: true, maxPriorityFeePerGas: true, nonce: true, value: true })[key]) { - value = toQuantity(value); - } else if (key === "accessList") { - value = "[" + accessListify(value).map((set) => { - return `{address:"${ set.address }",storageKeys:["${ set.storageKeys.join('","') }"]}`; - }).join(",") + "]"; - } else { - value = hexlify(value); - } - result[key] = value; - } - return result; - } - - /** - * Throws the normalized Etherscan error. - */ - _checkError(req: PerformActionRequest, error: Error, transaction: any): never { - - // Pull any message out if, possible - let message = ""; - if (isError(error, "SERVER_ERROR")) { - // Check for an error emitted by a proxy call - try { - message = (error).info.result.error.message; - } catch (e) { } - - if (!message) { - try { - message = (error).info.message; - } catch (e) { } - } - } - - if (req.method === "estimateGas") { - if (!message.match(/revert/i) && message.match(/insufficient funds/i)) { - assert(false, "insufficient funds", "INSUFFICIENT_FUNDS", { - transaction: req.transaction - }); - } - } - - if (req.method === "call" || req.method === "estimateGas") { - if (message.match(/execution reverted/i)) { - let data = ""; - try { - data = (error).info.result.error.data; - } catch (error) { } - - const e = AbiCoder.getBuiltinCallException(req.method, req.transaction, data); - e.info = { request: req, error } - throw e; - } - } - - if (message) { - if (req.method === "broadcastTransaction") { - const transaction = Transaction.from(req.signedTransaction); - if (message.match(/replacement/i) && message.match(/underpriced/i)) { - assert(false, "replacement fee too low", "REPLACEMENT_UNDERPRICED", { - transaction - }); - } - - if (message.match(/insufficient funds/)) { - assert(false, "insufficient funds for intrinsic transaction cost", "INSUFFICIENT_FUNDS", { - transaction - }); - } - - if (message.match(/same hash was already imported|transaction nonce is too low|nonce too low/)) { - assert(false, "nonce has already been used", "NONCE_EXPIRED", { - transaction - }); - } - } - } - - // Something we could not process - throw error; - } - - async _detectNetwork(): Promise { - return this.network; - } - - async _perform(req: PerformActionRequest): Promise { - switch (req.method) { - case "chainId": - return this.network.chainId; - - case "getBlockNumber": - return this.fetch("proxy", { action: "eth_blockNumber" }); - - case "getGasPrice": - return this.fetch("proxy", { action: "eth_gasPrice" }); - - case "getBalance": - // Returns base-10 result - return this.fetch("account", { - action: "balance", - address: req.address, - tag: req.blockTag - }); - - case "getTransactionCount": - return this.fetch("proxy", { - action: "eth_getTransactionCount", - address: req.address, - tag: req.blockTag - }); - - case "getCode": - return this.fetch("proxy", { - action: "eth_getCode", - address: req.address, - tag: req.blockTag - }); - - case "getStorage": - return this.fetch("proxy", { - action: "eth_getStorageAt", - address: req.address, - position: req.position, - tag: req.blockTag - }); - - case "broadcastTransaction": - return this.fetch("proxy", { - action: "eth_sendRawTransaction", - hex: req.signedTransaction - }, true).catch((error) => { - return this._checkError(req, error, req.signedTransaction); - }); - - case "getBlock": - if ("blockTag" in req) { - return this.fetch("proxy", { - action: "eth_getBlockByNumber", - tag: req.blockTag, - boolean: (req.includeTransactions ? "true": "false") - }); - } - - assert(false, "getBlock by blockHash not supported by Etherscan", "UNSUPPORTED_OPERATION", { - operation: "getBlock(blockHash)" - }); - - case "getTransaction": - return this.fetch("proxy", { - action: "eth_getTransactionByHash", - txhash: req.hash - }); - - case "getTransactionReceipt": - return this.fetch("proxy", { - action: "eth_getTransactionReceipt", - txhash: req.hash - }); - - case "call": { - if (req.blockTag !== "latest") { - throw new Error("EtherscanProvider does not support blockTag for call"); - } - - const postData = this._getTransactionPostData(req.transaction); - postData.module = "proxy"; - postData.action = "eth_call"; - - try { - return await this.fetch("proxy", postData, true); - } catch (error) { - return this._checkError(req, error, req.transaction); - } - } - - case "estimateGas": { - const postData = this._getTransactionPostData(req.transaction); - postData.module = "proxy"; - postData.action = "eth_estimateGas"; - - try { - return await this.fetch("proxy", postData, true); - } catch (error) { - return this._checkError(req, error, req.transaction); - } - } -/* - case "getLogs": { - // Needs to complain if more than one address is passed in - const args: Record = { action: "getLogs" } - - if (params.filter.fromBlock) { - args.fromBlock = checkLogTag(params.filter.fromBlock); - } - - if (params.filter.toBlock) { - args.toBlock = checkLogTag(params.filter.toBlock); - } - - if (params.filter.address) { - args.address = params.filter.address; - } - - // @TODO: We can handle slightly more complicated logs using the logs API - if (params.filter.topics && params.filter.topics.length > 0) { - if (params.filter.topics.length > 1) { - logger.throwError("unsupported topic count", Logger.Errors.UNSUPPORTED_OPERATION, { topics: params.filter.topics }); - } - if (params.filter.topics.length === 1) { - const topic0 = params.filter.topics[0]; - if (typeof(topic0) !== "string" || topic0.length !== 66) { - logger.throwError("unsupported topic format", Logger.Errors.UNSUPPORTED_OPERATION, { topic0: topic0 }); - } - args.topic0 = topic0; - } - } - - const logs: Array = await this.fetch("logs", args); - - // Cache txHash => blockHash - let blocks: { [tag: string]: string } = {}; - - // Add any missing blockHash to the logs - for (let i = 0; i < logs.length; i++) { - const log = logs[i]; - if (log.blockHash != null) { continue; } - if (blocks[log.blockNumber] == null) { - const block = await this.getBlock(log.blockNumber); - if (block) { - blocks[log.blockNumber] = block.hash; - } - } - - log.blockHash = blocks[log.blockNumber]; - } - - return logs; - } -*/ - default: - break; - } - - return super._perform(req); - } - - async getNetwork(): Promise { - return this.network; - } - - /** - * Resolves to the current price of ether. - * - * This returns ``0`` on any network other than ``mainnet``. - */ - async getEtherPrice(): Promise { - if (this.network.name !== "mainnet") { return 0.0; } - return parseFloat((await this.fetch("stats", { action: "ethprice" })).ethusd); - } - - isCommunityResource(): boolean { - return (this.apiKey == null); - } -} diff --git a/src.ts/providers/provider-etherscan.ts b/src.ts/providers/provider-etherscan.ts index e71d228f2..d7ba1b1f7 100644 --- a/src.ts/providers/provider-etherscan.ts +++ b/src.ts/providers/provider-etherscan.ts @@ -1,56 +1,554 @@ -/** - * [[link-etherscan]] provides a third-party service for connecting to - * various blockchains over a combination of JSON-RPC and custom API - * endpoints. - * - * **Supported Networks** - * - * - Ethereum Mainnet (``mainnet``) - * - Goerli Testnet (``goerli``) - * - Sepolia Testnet (``sepolia``) - * - Arbitrum (``arbitrum``) - * - Arbitrum Goerli Testnet (``arbitrum-goerli``) - * - Optimism (``optimism``) - * - Optimism Goerli Testnet (``optimism-goerli``) - * - Polygon (``matic``) - * - Polygon Mumbai Testnet (``maticmum``) - * - * @_subsection api/providers/thirdparty:Etherscan [providers-etherscan] - */ +import { AbiCoder } from "../abi/index.js"; +import { accessListify, Transaction } from "../transaction/index.js"; +import { + defineProperties, + hexlify, toQuantity, + FetchRequest, + assert, assertArgument, isError, + toUtf8String + } from "../utils/index.js"; -import { BaseEtherscanProvider } from "./provider-etherscan-base.js"; -import { Contract } from "../contract/index.js"; +import { AbstractProvider } from "./abstract-provider.js"; +import { Network } from "./network.js"; +import { NetworkPlugin } from "./plugins-network.js"; +import { showThrottleMessage } from "./community.js"; -function isPromise(value: any): value is Promise { - return (value && typeof(value.then) === "function"); -} +import { PerformActionRequest } from "./abstract-provider.js"; +import type { Networkish } from "./network.js"; +//import type { } from "./pagination"; +import type { TransactionRequest } from "./provider.js"; + +const THROTTLE = 2000; /** - * The **EtherscanProvider** connects to the [[link-etherscan]] - * JSON-RPC end-points. + * When subscribing to the ``"debug"`` event on an Etherscan-based + * provider, the events receive a **DebugEventEtherscanProvider** + * payload. * - * By default, requests are highly-throttled, which is - * appropriate for quick prototypes and simple scripts. To - * gain access to an increased rate-limit, it is highly - * recommended to [sign up here](link-etherscan-signup). + * @_docloc: api/providers/thirdparty:Etherscan */ -export class EtherscanProvider extends BaseEtherscanProvider { +export type DebugEventEtherscanProvider = { + action: "sendRequest", + id: number, + url: string, + payload: Record +} | { + action: "receiveRequest", + id: number, + result: any +} | { + action: "receiveError", + id: number, + error: any +}; + +const EtherscanPluginId = "org.ethers.plugins.Etherscan"; + +/** + * A Network can include an **EtherscanPlugin** to provide + * a custom base URL. + * + * @_docloc: api/providers/thirdparty:Etherscan + */ +export class EtherscanPlugin extends NetworkPlugin { + /** + * The Etherscan API base URL. + */ + readonly baseUrl!: string; /** - * Resolves to a [Contract]] for %%address%%, using the - * Etherscan API to retreive the Contract ABI. + * Creates a new **EtherscanProvider** which will use + * %%baseUrl%%. */ - async getContract(_address: string): Promise { - let address = this._getAddress(_address); - if (isPromise(address)) { address = await address; } + constructor(baseUrl: string) { + super(EtherscanPluginId); + defineProperties(this, { baseUrl }); + } - try { - const resp = await this.fetch("contract", { action: "getabi", address }); - const abi = JSON.parse(resp); - return new Contract(address, abi, this); - } catch (error) { - return null; - } + clone(): EtherscanPlugin { + return new EtherscanPlugin(this.baseUrl); } } +let nextId = 1; + +/** + * The **EtherscanBaseProvider** is the super-class of + * [[EtherscanProvider]], which should generally be used instead. + * + * Since the **EtherscanProvider** includes additional code for + * [[Contract]] access, in //rare cases// that contracts are not + * used, this class can reduce code size. + * + * @_docloc: api/providers/thirdparty:Etherscan + */ +export class BaseEtherscanProvider extends AbstractProvider { + + /** + * The connected network. + */ + readonly network!: Network; + + /** + * The API key or null if using the community provided bandwidth. + */ + readonly apiKey!: null | string; + + readonly #plugin: null | EtherscanPlugin; + + /** + * Creates a new **EtherscanBaseProvider**. + */ + constructor(_network?: Networkish, _apiKey?: string) { + const apiKey = (_apiKey != null) ? _apiKey: null; + + super(); + + const network = Network.from(_network); + + this.#plugin = network.getPlugin(EtherscanPluginId); + + defineProperties(this, { apiKey, network }); + + // Test that the network is supported by Etherscan + this.getBaseUrl(); + } + + /** + * Returns the base URL. + * + * If an [[EtherscanPlugin]] is configured on the + * [[EtherscanBaseProvider_network]], returns the plugin's + * baseUrl. + */ + getBaseUrl(): string { + if (this.#plugin) { return this.#plugin.baseUrl; } + + switch(this.network.name) { + case "mainnet": + return "https:/\/api.etherscan.io"; + case "goerli": + return "https:/\/api-goerli.etherscan.io"; + case "sepolia": + return "https:/\/api-sepolia.etherscan.io"; + + case "arbitrum": + return "https:/\/api.arbiscan.io"; + case "arbitrum-goerli": + return "https:/\/api-goerli.arbiscan.io"; + case "matic": + return "https:/\/api.polygonscan.com"; + case "maticmum": + return "https:/\/api-testnet.polygonscan.com"; + case "optimism": + return "https:/\/api-optimistic.etherscan.io"; + case "optimism-goerli": + return "https:/\/api-goerli-optimistic.etherscan.io"; + default: + } + + assertArgument(false, "unsupported network", "network", this.network); + } + + /** + * Returns the URL for the %%module%% and %%params%%. + */ + getUrl(module: string, params: Record): string { + const query = Object.keys(params).reduce((accum, key) => { + const value = params[key]; + if (value != null) { + accum += `&${ key }=${ value }` + } + return accum + }, ""); + const apiKey = ((this.apiKey) ? `&apikey=${ this.apiKey }`: ""); + return `${ this.getBaseUrl() }/api?module=${ module }${ query }${ apiKey }`; + } + + /** + * Returns the URL for using POST requests. + */ + getPostUrl(): string { + return `${ this.getBaseUrl() }/api`; + } + + /** + * Returns the parameters for using POST requests. + */ + getPostData(module: string, params: Record): Record { + params.module = module; + params.apikey = this.apiKey; + return params; + } + + async detectNetwork(): Promise { + return this.network; + } + + /** + * Resolves to the result of calling %%module%% with %%params%%. + * + * If %%post%%, the request is made as a POST request. + */ + async fetch(module: string, params: Record, post?: boolean): Promise { + const id = nextId++; + + const url = (post ? this.getPostUrl(): this.getUrl(module, params)); + const payload = (post ? this.getPostData(module, params): null); + + this.emit("debug", { action: "sendRequest", id, url, payload: payload }); + + const request = new FetchRequest(url); + request.setThrottleParams({ slotInterval: 1000 }); + request.retryFunc = (req, resp, attempt: number) => { + if (this.isCommunityResource()) { + showThrottleMessage("Etherscan"); + } + return Promise.resolve(true); + }; + request.processFunc = async (request, response) => { + const result = response.hasBody() ? JSON.parse(toUtf8String(response.body)): { }; + const throttle = ((typeof(result.result) === "string") ? result.result: "").toLowerCase().indexOf("rate limit") >= 0; + if (module === "proxy") { + // This JSON response indicates we are being throttled + if (result && result.status == 0 && result.message == "NOTOK" && throttle) { + this.emit("debug", { action: "receiveError", id, reason: "proxy-NOTOK", error: result }); + response.throwThrottleError(result.result, THROTTLE); + } + } else { + if (throttle) { + this.emit("debug", { action: "receiveError", id, reason: "null result", error: result.result }); + response.throwThrottleError(result.result, THROTTLE); + } + } + return response; + }; + + if (payload) { + request.setHeader("content-type", "application/x-www-form-urlencoded; charset=UTF-8"); + request.body = Object.keys(payload).map((k) => `${ k }=${ payload[k] }`).join("&"); + } + + const response = await request.send(); + try { + response.assertOk(); + } catch (error) { + this.emit("debug", { action: "receiveError", id, error, reason: "assertOk" }); + assert(false, "response error", "SERVER_ERROR", { request, response }); + } + + if (!response.hasBody()) { + this.emit("debug", { action: "receiveError", id, error: "missing body", reason: "null body" }); + assert(false, "missing response", "SERVER_ERROR", { request, response }); + } + + const result = JSON.parse(toUtf8String(response.body)); + if (module === "proxy") { + if (result.jsonrpc != "2.0") { + this.emit("debug", { action: "receiveError", id, result, reason: "invalid JSON-RPC" }); + assert(false, "invalid JSON-RPC response (missing jsonrpc='2.0')", "SERVER_ERROR", { request, response, info: { result } }); + } + + if (result.error) { + this.emit("debug", { action: "receiveError", id, result, reason: "JSON-RPC error" }); + assert(false, "error response", "SERVER_ERROR", { request, response, info: { result } }); + } + + this.emit("debug", { action: "receiveRequest", id, result }); + + return result.result; + + } else { + // getLogs, getHistory have weird success responses + if (result.status == 0 && (result.message === "No records found" || result.message === "No transactions found")) { + this.emit("debug", { action: "receiveRequest", id, result }); + return result.result; + } + + if (result.status != 1 || (typeof(result.message) === "string" && !result.message.match(/^OK/))) { + this.emit("debug", { action: "receiveError", id, result }); + assert(false, "error response", "SERVER_ERROR", { request, response, info: { result } }); + } + + this.emit("debug", { action: "receiveRequest", id, result }); + + return result.result; + } + } + + /** + * Returns %%transaction%% normalized for the Etherscan API. + */ + _getTransactionPostData(transaction: TransactionRequest): Record { + const result: Record = { }; + for (let key in transaction) { + if ((transaction)[key] == null) { continue; } + let value = (transaction)[key]; + if (key === "type" && value === 0) { continue; } + + // Quantity-types require no leading zero, unless 0 + if (({ type: true, gasLimit: true, gasPrice: true, maxFeePerGs: true, maxPriorityFeePerGas: true, nonce: true, value: true })[key]) { + value = toQuantity(value); + } else if (key === "accessList") { + value = "[" + accessListify(value).map((set) => { + return `{address:"${ set.address }",storageKeys:["${ set.storageKeys.join('","') }"]}`; + }).join(",") + "]"; + } else { + value = hexlify(value); + } + result[key] = value; + } + return result; + } + + /** + * Throws the normalized Etherscan error. + */ + _checkError(req: PerformActionRequest, error: Error, transaction: any): never { + + // Pull any message out if, possible + let message = ""; + if (isError(error, "SERVER_ERROR")) { + // Check for an error emitted by a proxy call + try { + message = (error).info.result.error.message; + } catch (e) { } + + if (!message) { + try { + message = (error).info.message; + } catch (e) { } + } + } + + if (req.method === "estimateGas") { + if (!message.match(/revert/i) && message.match(/insufficient funds/i)) { + assert(false, "insufficient funds", "INSUFFICIENT_FUNDS", { + transaction: req.transaction + }); + } + } + + if (req.method === "call" || req.method === "estimateGas") { + if (message.match(/execution reverted/i)) { + let data = ""; + try { + data = (error).info.result.error.data; + } catch (error) { } + + const e = AbiCoder.getBuiltinCallException(req.method, req.transaction, data); + e.info = { request: req, error } + throw e; + } + } + + if (message) { + if (req.method === "broadcastTransaction") { + const transaction = Transaction.from(req.signedTransaction); + if (message.match(/replacement/i) && message.match(/underpriced/i)) { + assert(false, "replacement fee too low", "REPLACEMENT_UNDERPRICED", { + transaction + }); + } + + if (message.match(/insufficient funds/)) { + assert(false, "insufficient funds for intrinsic transaction cost", "INSUFFICIENT_FUNDS", { + transaction + }); + } + + if (message.match(/same hash was already imported|transaction nonce is too low|nonce too low/)) { + assert(false, "nonce has already been used", "NONCE_EXPIRED", { + transaction + }); + } + } + } + + // Something we could not process + throw error; + } + + async _detectNetwork(): Promise { + return this.network; + } + + async _perform(req: PerformActionRequest): Promise { + switch (req.method) { + case "chainId": + return this.network.chainId; + + case "getBlockNumber": + return this.fetch("proxy", { action: "eth_blockNumber" }); + + case "getGasPrice": + return this.fetch("proxy", { action: "eth_gasPrice" }); + + case "getBalance": + // Returns base-10 result + return this.fetch("account", { + action: "balance", + address: req.address, + tag: req.blockTag + }); + + case "getTransactionCount": + return this.fetch("proxy", { + action: "eth_getTransactionCount", + address: req.address, + tag: req.blockTag + }); + + case "getCode": + return this.fetch("proxy", { + action: "eth_getCode", + address: req.address, + tag: req.blockTag + }); + + case "getStorage": + return this.fetch("proxy", { + action: "eth_getStorageAt", + address: req.address, + position: req.position, + tag: req.blockTag + }); + + case "broadcastTransaction": + return this.fetch("proxy", { + action: "eth_sendRawTransaction", + hex: req.signedTransaction + }, true).catch((error) => { + return this._checkError(req, error, req.signedTransaction); + }); + + case "getBlock": + if ("blockTag" in req) { + return this.fetch("proxy", { + action: "eth_getBlockByNumber", + tag: req.blockTag, + boolean: (req.includeTransactions ? "true": "false") + }); + } + + assert(false, "getBlock by blockHash not supported by Etherscan", "UNSUPPORTED_OPERATION", { + operation: "getBlock(blockHash)" + }); + + case "getTransaction": + return this.fetch("proxy", { + action: "eth_getTransactionByHash", + txhash: req.hash + }); + + case "getTransactionReceipt": + return this.fetch("proxy", { + action: "eth_getTransactionReceipt", + txhash: req.hash + }); + + case "call": { + if (req.blockTag !== "latest") { + throw new Error("EtherscanProvider does not support blockTag for call"); + } + + const postData = this._getTransactionPostData(req.transaction); + postData.module = "proxy"; + postData.action = "eth_call"; + + try { + return await this.fetch("proxy", postData, true); + } catch (error) { + return this._checkError(req, error, req.transaction); + } + } + + case "estimateGas": { + const postData = this._getTransactionPostData(req.transaction); + postData.module = "proxy"; + postData.action = "eth_estimateGas"; + + try { + return await this.fetch("proxy", postData, true); + } catch (error) { + return this._checkError(req, error, req.transaction); + } + } +/* + case "getLogs": { + // Needs to complain if more than one address is passed in + const args: Record = { action: "getLogs" } + + if (params.filter.fromBlock) { + args.fromBlock = checkLogTag(params.filter.fromBlock); + } + + if (params.filter.toBlock) { + args.toBlock = checkLogTag(params.filter.toBlock); + } + + if (params.filter.address) { + args.address = params.filter.address; + } + + // @TODO: We can handle slightly more complicated logs using the logs API + if (params.filter.topics && params.filter.topics.length > 0) { + if (params.filter.topics.length > 1) { + logger.throwError("unsupported topic count", Logger.Errors.UNSUPPORTED_OPERATION, { topics: params.filter.topics }); + } + if (params.filter.topics.length === 1) { + const topic0 = params.filter.topics[0]; + if (typeof(topic0) !== "string" || topic0.length !== 66) { + logger.throwError("unsupported topic format", Logger.Errors.UNSUPPORTED_OPERATION, { topic0: topic0 }); + } + args.topic0 = topic0; + } + } + + const logs: Array = await this.fetch("logs", args); + + // Cache txHash => blockHash + let blocks: { [tag: string]: string } = {}; + + // Add any missing blockHash to the logs + for (let i = 0; i < logs.length; i++) { + const log = logs[i]; + if (log.blockHash != null) { continue; } + if (blocks[log.blockNumber] == null) { + const block = await this.getBlock(log.blockNumber); + if (block) { + blocks[log.blockNumber] = block.hash; + } + } + + log.blockHash = blocks[log.blockNumber]; + } + + return logs; + } +*/ + default: + break; + } + + return super._perform(req); + } + + async getNetwork(): Promise { + return this.network; + } + + /** + * Resolves to the current price of ether. + * + * This returns ``0`` on any network other than ``mainnet``. + */ + async getEtherPrice(): Promise { + if (this.network.name !== "mainnet") { return 0.0; } + return parseFloat((await this.fetch("stats", { action: "ethprice" })).ethusd); + } + + isCommunityResource(): boolean { + return (this.apiKey == null); + } +}