From 4d435cd56e6dfeff4914e27fff0e9688d41c88b1 Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Tue, 27 Sep 2022 03:35:23 -0400 Subject: [PATCH] Fixed Fragment bugs and added getContract to EtherscanProvider.. --- src.ts/abi/fragments.ts | 30 +- src.ts/abi/interface.ts | 10 +- src.ts/providers/common-networks.ts | 2 +- src.ts/providers/index.ts | 1 + src.ts/providers/provider-etherscan-base.ts | 499 +++++++++++++++++++ src.ts/providers/provider-etherscan.ts | 516 +------------------- src.ts/utils/fetch.ts | 28 +- 7 files changed, 575 insertions(+), 511 deletions(-) create mode 100644 src.ts/providers/provider-etherscan-base.ts diff --git a/src.ts/abi/fragments.ts b/src.ts/abi/fragments.ts index 79f368509..54a146463 100644 --- a/src.ts/abi/fragments.ts +++ b/src.ts/abi/fragments.ts @@ -761,7 +761,7 @@ export abstract class Fragment { case "function": return FunctionFragment.fromObject(obj); case "struct": return StructFragment.fromObject(obj); } - throw new Error("not implemented yet"); + throw new Error(`not implemented yet: ${ obj.type }`); } static fromString(text: string): Fragment { @@ -864,6 +864,11 @@ export class ErrorFragment extends NamedFragment { return result.join(" "); } + static fromObject(obj: any): ErrorFragment { + return new ErrorFragment(_guard, obj.name, + obj.inputs ? obj.inputs.map(ParamType.fromObject): [ ]); + } + static fromString(text: string): ErrorFragment { return ErrorFragment.fromTokens(lex(text)); } @@ -907,6 +912,11 @@ export class EventFragment extends NamedFragment { return result.join(" "); } + static fromObject(obj: any): EventFragment { + return new EventFragment(_guard, obj.name, + obj.inputs ? obj.inputs.map(ParamType.fromObject): [ ], !!obj.anonymous); + } + static fromString(text: string): EventFragment { return EventFragment.fromTokens(lex(text)); } @@ -954,12 +964,14 @@ export class ConstructorFragment extends Fragment { return result.join(" "); } - static fromString(text: string): ConstructorFragment { - return ConstructorFragment.fromTokens(lex(text)); + static fromObject(obj: any): ConstructorFragment { + return new ConstructorFragment(_guard, "constructor", + obj.inputs ? obj.inputs.map(ParamType.fromObject): [ ], + !!obj.payable, (obj.gas != null) ? obj.gas: null); } - static fromObject(obj: any): ConstructorFragment { - throw new Error("TODO"); + static fromString(text: string): ConstructorFragment { + return ConstructorFragment.fromTokens(lex(text)); } static fromTokens(tokens: TokenString): ConstructorFragment { @@ -1028,6 +1040,14 @@ export class FunctionFragment extends NamedFragment { return result.join(" "); } + static fromObject(obj: any): FunctionFragment { + // @TODO: verifyState for stateMutability + return new FunctionFragment(_guard, obj.name, obj.stateMutability, + obj.inputs ? obj.inputs.map(ParamType.fromObject): [ ], + obj.outputs ? obj.outputs.map(ParamType.fromObject): [ ], + (obj.gas != null) ? obj.gas: null); + } + static fromString(text: string): FunctionFragment { return FunctionFragment.fromTokens(lex(text)); } diff --git a/src.ts/abi/interface.ts b/src.ts/abi/interface.ts index 1258b00da..5761827ff 100644 --- a/src.ts/abi/interface.ts +++ b/src.ts/abi/interface.ts @@ -177,8 +177,16 @@ export class Interface { this.#events = new Map(); // this.#structs = new Map(); + + const frags: Array = [ ]; + for (const a of abi) { + try { + frags.push(Fragment.from(a)); + } catch (error) { } + } + defineProperties(this, { - fragments: Object.freeze(abi.map((f) => Fragment.from(f)).filter((f) => (f != null))), + fragments: Object.freeze(frags) }); this.#abiCoder = this.getAbiCoder(); diff --git a/src.ts/providers/common-networks.ts b/src.ts/providers/common-networks.ts index 99082fe66..e891ab630 100644 --- a/src.ts/providers/common-networks.ts +++ b/src.ts/providers/common-networks.ts @@ -5,7 +5,7 @@ */ import { EnsPlugin, GasCostPlugin } from "./plugins-network.js"; -import { EtherscanPlugin } from "./provider-etherscan.js"; +import { EtherscanPlugin } from "./provider-etherscan-base.js"; import { Network } from "./network.js"; diff --git a/src.ts/providers/index.ts b/src.ts/providers/index.ts index ac297726c..9f7fea645 100644 --- a/src.ts/providers/index.ts +++ b/src.ts/providers/index.ts @@ -50,6 +50,7 @@ export { JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner } from "./provider-j export { AlchemyProvider } from "./provider-alchemy.js"; export { AnkrProvider } from "./provider-ankr.js"; export { CloudflareProvider } from "./provider-cloudflare.js"; +export { BaseEtherscanProvider, EtherscanPlugin } from "./provider-etherscan-base.js"; export { EtherscanProvider } from "./provider-etherscan.js"; export { InfuraProvider } from "./provider-infura.js"; //export { PocketProvider } from "./provider-pocket.js"; diff --git a/src.ts/providers/provider-etherscan-base.ts b/src.ts/providers/provider-etherscan-base.ts new file mode 100644 index 000000000..1ebeeef3d --- /dev/null +++ b/src.ts/providers/provider-etherscan-base.ts @@ -0,0 +1,499 @@ +import { accessListify } from "../transaction/index.js"; +import { + defineProperties, + hexlify, toQuantity, + FetchRequest, + throwArgumentError, throwError, + 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; + +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"; + +export class EtherscanPlugin extends NetworkPlugin { + readonly baseUrl!: string; + readonly communityApiKey!: string; + + constructor(baseUrl: string, communityApiKey: string) { + super(EtherscanPluginId); + //if (communityApiKey == null) { communityApiKey = null; } + defineProperties(this, { baseUrl, communityApiKey }); + } + + clone(): EtherscanPlugin { + return new EtherscanPlugin(this.baseUrl, this.communityApiKey); + } +} + +let nextId = 1; + +export class BaseEtherscanProvider extends AbstractProvider { + readonly network!: Network; + readonly apiKey!: string; + + readonly #plugin: null | EtherscanPlugin; + + constructor(_network?: Networkish, apiKey?: string) { + super(); + + const network = Network.from(_network); + + this.#plugin = network.getPlugin(EtherscanPluginId); + + if (apiKey == null && this.#plugin) { + apiKey = this.#plugin.communityApiKey; + } + + defineProperties(this, { apiKey, network }); + + // Test that the network is supported by Etherscan + this.getBaseUrl(); + } + + getBaseUrl(): string { + if (this.#plugin) { return this.#plugin.baseUrl; } + + switch(this.network.name) { + case "homestead": + return "https:/\/api.etherscan.io"; + case "ropsten": + return "https:/\/api-ropsten.etherscan.io"; + case "rinkeby": + return "https:/\/api-rinkeby.etherscan.io"; + case "kovan": + return "https:/\/api-kovan.etherscan.io"; + case "goerli": + return "https:/\/api-goerli.etherscan.io"; + default: + } + + return throwArgumentError("unsupported network", "network", this.network); + } + + 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 }`; + } + + getPostUrl(): string { + return `${ this.getBaseUrl() }/api`; + } + + getPostData(module: string, params: Record): Record { + params.module = module; + params.apikey = this.apiKey; + return params; + } + + async detectNetwork(): Promise { + return this.network; + } + + 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" }); + } + + if (!response.hasBody()) { + this.emit("debug", { action: "receiveError", id, error: "missing body", reason: "null body" }); + throw new Error(); + } + + 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" }); + + const error: any = new Error("invalid response"); + error.result = JSON.stringify(result); + throw error; + } + + if (result.error) { + this.emit("debug", { action: "receiveError", id, result, reason: "JSON-RPC error" }); + + const error: any = new Error(result.error.message || "unknown error"); + if (result.error.code) { error.code = result.error.code; } + if (result.error.data) { error.data = result.error.data; } + throw error; + } + + 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 }); + + const error: any = new Error("invalid response"); + error.result = JSON.stringify(result); + // if ((result.result || "").toLowerCase().indexOf("rate limit") >= 0) { + // error.throttleRetry = true; + // } + throw error; + } + + this.emit("debug", { action: "receiveRequest", id, result }); + + return result.result; + } + } + + // The transaction has already been sanitized by the calls in Provider + _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(hexlify(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; + } + + + _checkError(req: PerformActionRequest, error: Error, transaction: any): never { + /* + let body = ""; + if (isError(error, Logger.Errors.SERVER_ERROR) && error.response && error.response.hasBody()) { + body = toUtf8String(error.response.body); + } + console.log(body); + + // Undo the "convenience" some nodes are attempting to prevent backwards + // incompatibility; maybe for v6 consider forwarding reverts as errors + if (method === "call" && body) { + + // Etherscan keeps changing their string + if (body.match(/reverted/i) || body.match(/VM execution error/i)) { + + // Etherscan prefixes the data like "Reverted 0x1234" + let data = e.data; + if (data) { data = "0x" + data.replace(/^.*0x/i, ""); } + if (!isHexString(data)) { data = "0x"; } + + logger.throwError("call exception", Logger.Errors.CALL_EXCEPTION, { + error, data + }); + } + } + + // Get the message from any nested error structure + let message = error.message; + if (isError(error, Logger.Errors.SERVER_ERROR)) { + if (error.error && typeof(error.error.message) === "string") { + message = error.error.message; + } else if (typeof(error.body) === "string") { + message = error.body; + } else if (typeof(error.responseText) === "string") { + message = error.responseText; + } + } + message = (message || "").toLowerCase(); + + // "Insufficient funds. The account you tried to send transaction from + // does not have enough funds. Required 21464000000000 and got: 0" + if (message.match(/insufficient funds/)) { + logger.throwError("insufficient funds for intrinsic transaction cost", Logger.Errors.INSUFFICIENT_FUNDS, { + error, transaction, info: { method } + }); + } + + // "Transaction with the same hash was already imported." + if (message.match(/same hash was already imported|transaction nonce is too low|nonce too low/)) { + logger.throwError("nonce has already been used", Logger.Errors.NONCE_EXPIRED, { + error, transaction, info: { method } + }); + } + + // "Transaction gas price is too low. There is another transaction with + // same nonce in the queue. Try increasing the gas price or incrementing the nonce." + if (message.match(/another transaction with same nonce/)) { + logger.throwError("replacement fee too low", Logger.Errors.REPLACEMENT_UNDERPRICED, { + error, transaction, info: { method } + }); + } + + if (message.match(/execution failed due to an exception|execution reverted/)) { + logger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.Errors.UNPREDICTABLE_GAS_LIMIT, { + error, transaction, info: { method } + }); + } +*/ + 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 "getStorageAt": + 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") + }); + } + + return throwError("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; + } + + async getEtherPrice(): Promise { + if (this.network.name !== "homestead") { return 0.0; } + return parseFloat((await this.fetch("stats", { action: "ethprice" })).ethusd); + } + + isCommunityResource(): boolean { + const plugin = this.network.getPlugin(EtherscanPluginId); + if (plugin) { return (plugin.communityApiKey === this.apiKey); } + + return (this.apiKey == null); + } +} diff --git a/src.ts/providers/provider-etherscan.ts b/src.ts/providers/provider-etherscan.ts index 976d7011c..7946be040 100644 --- a/src.ts/providers/provider-etherscan.ts +++ b/src.ts/providers/provider-etherscan.ts @@ -1,506 +1,22 @@ -import { accessListify } from "../transaction/index.js"; -import { - defineProperties, - hexlify, toQuantity, - FetchRequest, - throwArgumentError, throwError, - 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"; +function isPromise(value: any): value is Promise { + return (value && typeof(value.then) === "function"); +} +export class EtherscanProvider extends BaseEtherscanProvider { + async getContract(_address: string): Promise { + let address = this._getAddress(_address); + if (isPromise(address)) { address = await address; } -import { PerformActionRequest } from "./abstract-provider.js"; -import type { Networkish } from "./network.js"; -//import type { } from "./pagination"; -import type { TransactionRequest } from "./provider.js"; - - -const defaultApiKey = "9D13ZE7XSBTJ94N9BNJ2MA33VMAY2YPIRB"; - - - -const EtherscanPluginId = "org.ethers.plugins.etherscan"; -export class EtherscanPlugin extends NetworkPlugin { - readonly baseUrl!: string; - readonly communityApiKey!: string; - - constructor(baseUrl: string, communityApiKey: string) { - super(EtherscanPluginId); - //if (communityApiKey == null) { communityApiKey = null; } - defineProperties(this, { baseUrl, communityApiKey }); - } - - clone(): EtherscanPlugin { - return new EtherscanPlugin(this.baseUrl, this.communityApiKey); + 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; + } } } - -export class EtherscanProvider extends AbstractProvider { - readonly network!: Network; - readonly apiKey!: string; - - constructor(_network?: Networkish, apiKey?: string) { - super(); - - const network = Network.from(_network); - if (apiKey == null) { - const plugin = network.getPlugin(EtherscanPluginId); - if (plugin) { - apiKey = plugin.communityApiKey; - } else { - apiKey = defaultApiKey; - } - } - - defineProperties(this, { apiKey, network }); - - // Test that the network is supported by Etherscan - this.getBaseUrl(); - } - - getBaseUrl(): string { - const plugin = this.network.getPlugin(EtherscanPluginId); - if (plugin) { return plugin.baseUrl; } - - switch(this.network.name) { - case "homestead": - return "https:/\/api.etherscan.io"; - case "ropsten": - return "https:/\/api-ropsten.etherscan.io"; - case "rinkeby": - return "https:/\/api-rinkeby.etherscan.io"; - case "kovan": - return "https:/\/api-kovan.etherscan.io"; - case "goerli": - return "https:/\/api-goerli.etherscan.io"; - default: - } - - return throwArgumentError("unsupported network", "network", this.network); - } - - 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 }`; - } - - getPostUrl(): string { - return `${ this.getBaseUrl() }/api`; - } - - getPostData(module: string, params: Record): Record { - params.module = module; - params.apikey = this.apiKey; - return params; - } - - async detectNetwork(): Promise { - return this.network; - } - - async fetch(module: string, params: Record, post?: boolean): Promise { - const url = (post ? this.getPostUrl(): this.getUrl(module, params)); - const payload = (post ? this.getPostData(module, params): null); - - /* - this.emit("debug", { - action: "request", - request: url, - provider: this - }); - */ - const request = new FetchRequest(url); - 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) { - response.throwThrottleError(result.result); - } - } else { - if (throttle) { - response.throwThrottleError(result.result); - } - } - return response; - }; - // @TODO: - //throttleSlotInterval: 1000, - - 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(); - response.assertOk(); - - if (!response.hasBody()) { - throw new Error(); - } - - /* - this.emit("debug", { - action: "response", - request: url, - response: deepCopy(result), - provider: this - }); - */ - - const result = JSON.parse(toUtf8String(response.body)); - - if (module === "proxy") { - if (result.jsonrpc != "2.0") { - // @TODO: not any - const error: any = new Error("invalid response"); - error.result = JSON.stringify(result); - throw error; - } - - if (result.error) { - // @TODO: not any - const error: any = new Error(result.error.message || "unknown error"); - if (result.error.code) { error.code = result.error.code; } - if (result.error.data) { error.data = result.error.data; } - throw error; - } - - return result.result; - - } else { - // getLogs, getHistory have weird success responses - if (result.status == 0 && (result.message === "No records found" || result.message === "No transactions found")) { - return result.result; - } - - if (result.status != 1 || result.message != "OK") { - const error: any = new Error("invalid response"); - error.result = JSON.stringify(result); - // if ((result.result || "").toLowerCase().indexOf("rate limit") >= 0) { - // error.throttleRetry = true; - // } - throw error; - } - - return result.result; - } - } - - // The transaction has already been sanitized by the calls in Provider - _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(hexlify(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; - } - - - _checkError(req: PerformActionRequest, error: Error, transaction: any): never { - /* - let body = ""; - if (isError(error, Logger.Errors.SERVER_ERROR) && error.response && error.response.hasBody()) { - body = toUtf8String(error.response.body); - } - console.log(body); - - // Undo the "convenience" some nodes are attempting to prevent backwards - // incompatibility; maybe for v6 consider forwarding reverts as errors - if (method === "call" && body) { - - // Etherscan keeps changing their string - if (body.match(/reverted/i) || body.match(/VM execution error/i)) { - - // Etherscan prefixes the data like "Reverted 0x1234" - let data = e.data; - if (data) { data = "0x" + data.replace(/^.*0x/i, ""); } - if (!isHexString(data)) { data = "0x"; } - - logger.throwError("call exception", Logger.Errors.CALL_EXCEPTION, { - error, data - }); - } - } - - // Get the message from any nested error structure - let message = error.message; - if (isError(error, Logger.Errors.SERVER_ERROR)) { - if (error.error && typeof(error.error.message) === "string") { - message = error.error.message; - } else if (typeof(error.body) === "string") { - message = error.body; - } else if (typeof(error.responseText) === "string") { - message = error.responseText; - } - } - message = (message || "").toLowerCase(); - - // "Insufficient funds. The account you tried to send transaction from - // does not have enough funds. Required 21464000000000 and got: 0" - if (message.match(/insufficient funds/)) { - logger.throwError("insufficient funds for intrinsic transaction cost", Logger.Errors.INSUFFICIENT_FUNDS, { - error, transaction, info: { method } - }); - } - - // "Transaction with the same hash was already imported." - if (message.match(/same hash was already imported|transaction nonce is too low|nonce too low/)) { - logger.throwError("nonce has already been used", Logger.Errors.NONCE_EXPIRED, { - error, transaction, info: { method } - }); - } - - // "Transaction gas price is too low. There is another transaction with - // same nonce in the queue. Try increasing the gas price or incrementing the nonce." - if (message.match(/another transaction with same nonce/)) { - logger.throwError("replacement fee too low", Logger.Errors.REPLACEMENT_UNDERPRICED, { - error, transaction, info: { method } - }); - } - - if (message.match(/execution failed due to an exception|execution reverted/)) { - logger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.Errors.UNPREDICTABLE_GAS_LIMIT, { - error, transaction, info: { method } - }); - } -*/ - 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 "getStorageAt": - 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") - }); - } - - return throwError("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; - } - - async getEtherPrice(): Promise { - if (this.network.name !== "homestead") { return 0.0; } - return parseFloat((await this.fetch("stats", { action: "ethprice" })).ethusd); - } - - isCommunityResource(): boolean { - const plugin = this.network.getPlugin(EtherscanPluginId); - if (plugin) { return (plugin.communityApiKey === this.apiKey); } - - return (defaultApiKey === this.apiKey); - } -} -/* -(async function() { - const provider = new EtherscanProvider(); - console.log(provider); - console.log(await provider.getBlockNumber()); - / * - provider.on("block", (b) => { - console.log("BB", b); - }); - console.log(await provider.getTransactionReceipt("0xa5ded92f548e9f362192f9ab7e5b3fbc9b5a919a868e29247f177d49ce38de6e")); - - provider.once("0xa5ded92f548e9f362192f9ab7e5b3fbc9b5a919a868e29247f177d49ce38de6e", (tx) => { - console.log("TT", tx); - }); - * / - try { - console.log(await provider.getBlock(100)); - } catch (error) { - console.log(error); - } - - try { - console.log(await provider.getBlock(13821768)); - } catch (error) { - console.log(error); - } - -})(); -*/ diff --git a/src.ts/utils/fetch.ts b/src.ts/utils/fetch.ts index 92084564e..dc2d50879 100644 --- a/src.ts/utils/fetch.ts +++ b/src.ts/utils/fetch.ts @@ -14,6 +14,10 @@ export type GetUrlResponse = { body: null | Uint8Array }; +export type FetchThrottleParams = { + maxAttempts?: number; + slotInterval?: number; +}; /** * Called before any network request, allowing updated headers (e.g. Bearer tokens), etc. */ @@ -161,6 +165,8 @@ export class FetchRequest implements Iterable<[ key: string, value: string ]> { #signal?: FetchCancelSignal; + #throttle: Required; + /** * The fetch URI to requrest. */ @@ -388,11 +394,25 @@ export class FetchRequest implements Iterable<[ key: string, value: string ]> { this.#gzip = false; this.#headers = { }; this.#method = ""; - this.#timeout = 300; + this.#timeout = 300000; + + this.#throttle = { + slotInterval: SLOT_INTERVAL, + maxAttempts: MAX_ATTEMPTS + }; + } + + setThrottleParams(params: FetchThrottleParams): void { + if (params.slotInterval != null) { + this.#throttle.slotInterval = params.slotInterval; + } + if (params.maxAttempts != null) { + this.#throttle.maxAttempts = params.maxAttempts; + } } async #send(attempt: number, expires: number, delay: number, _request: FetchRequest, _response: FetchResponse): Promise { - if (attempt >= MAX_ATTEMPTS) { + if (attempt >= this.#throttle.maxAttempts) { return _response.makeServerError("exceeded maximum retry limit"); } @@ -455,7 +475,7 @@ export class FetchRequest implements Iterable<[ key: string, value: string ]> { // Throttle if (this.retryFunc == null || (await this.retryFunc(req, response, attempt))) { const retryAfter = response.headers["retry-after"]; - let delay = SLOT_INTERVAL * Math.trunc(Math.random() * Math.pow(2, attempt)); + let delay = this.#throttle.slotInterval * Math.trunc(Math.random() * Math.pow(2, attempt)); if (typeof(retryAfter) === "string" && retryAfter.match(/^[1-9][0-9]*$/)) { delay = parseInt(retryAfter); } @@ -475,7 +495,7 @@ export class FetchRequest implements Iterable<[ key: string, value: string ]> { } // Throttle - let delay = SLOT_INTERVAL * Math.trunc(Math.random() * Math.pow(2, attempt));; + let delay = this.#throttle.slotInterval * Math.trunc(Math.random() * Math.pow(2, attempt));; if (error.stall >= 0) { delay = error.stall; } return req.clone().#send(attempt + 1, expires, delay, _request, response);