provider-etherscan.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. /**
  2. * [[link-etherscan]] provides a third-party service for connecting to
  3. * various blockchains over a combination of JSON-RPC and custom API
  4. * endpoints.
  5. *
  6. * **Supported Networks**
  7. *
  8. * - Ethereum Mainnet (``mainnet``)
  9. * - Goerli Testnet (``goerli``)
  10. * - Sepolia Testnet (``sepolia``)
  11. * - Holesky Testnet (``holesky``)
  12. * - Arbitrum (``arbitrum``)
  13. * - Arbitrum Goerli Testnet (``arbitrum-goerli``)
  14. * - Base (``base``)
  15. * - Base Sepolia Testnet (``base-sepolia``)
  16. * - BNB Smart Chain Mainnet (``bnb``)
  17. * - BNB Smart Chain Testnet (``bnbt``)
  18. * - Optimism (``optimism``)
  19. * - Optimism Goerli Testnet (``optimism-goerli``)
  20. * - Polygon (``matic``)
  21. * - Polygon Mumbai Testnet (``matic-mumbai``)
  22. * - Polygon Amoy Testnet (``matic-amoy``)
  23. *
  24. * @_subsection api/providers/thirdparty:Etherscan [providers-etherscan]
  25. */
  26. import { AbiCoder } from "../abi/index.js";
  27. import { Contract } from "../contract/index.js";
  28. import { accessListify, Transaction } from "../transaction/index.js";
  29. import {
  30. defineProperties,
  31. hexlify, toQuantity,
  32. FetchRequest,
  33. assert, assertArgument, isError,
  34. // parseUnits,
  35. toUtf8String
  36. } from "../utils/index.js";
  37. import { AbstractProvider } from "./abstract-provider.js";
  38. import { Network } from "./network.js";
  39. import { NetworkPlugin } from "./plugins-network.js";
  40. import { showThrottleMessage } from "./community.js";
  41. import { PerformActionRequest } from "./abstract-provider.js";
  42. import type { Networkish } from "./network.js";
  43. //import type { } from "./pagination";
  44. import type { TransactionRequest } from "./provider.js";
  45. const THROTTLE = 2000;
  46. function isPromise<T = any>(value: any): value is Promise<T> {
  47. return (value && typeof(value.then) === "function");
  48. }
  49. /**
  50. * When subscribing to the ``"debug"`` event on an Etherscan-based
  51. * provider, the events receive a **DebugEventEtherscanProvider**
  52. * payload.
  53. *
  54. * @_docloc: api/providers/thirdparty:Etherscan
  55. */
  56. export type DebugEventEtherscanProvider = {
  57. action: "sendRequest",
  58. id: number,
  59. url: string,
  60. payload: Record<string, any>
  61. } | {
  62. action: "receiveRequest",
  63. id: number,
  64. result: any
  65. } | {
  66. action: "receiveError",
  67. id: number,
  68. error: any
  69. };
  70. const EtherscanPluginId = "org.ethers.plugins.provider.Etherscan";
  71. /**
  72. * A Network can include an **EtherscanPlugin** to provide
  73. * a custom base URL.
  74. *
  75. * @_docloc: api/providers/thirdparty:Etherscan
  76. */
  77. export class EtherscanPlugin extends NetworkPlugin {
  78. /**
  79. * The Etherscan API base URL.
  80. */
  81. readonly baseUrl!: string;
  82. /**
  83. * Creates a new **EtherscanProvider** which will use
  84. * %%baseUrl%%.
  85. */
  86. constructor(baseUrl: string) {
  87. super(EtherscanPluginId);
  88. defineProperties<EtherscanPlugin>(this, { baseUrl });
  89. }
  90. clone(): EtherscanPlugin {
  91. return new EtherscanPlugin(this.baseUrl);
  92. }
  93. }
  94. const skipKeys = [ "enableCcipRead" ];
  95. let nextId = 1;
  96. /**
  97. * The **EtherscanBaseProvider** is the super-class of
  98. * [[EtherscanProvider]], which should generally be used instead.
  99. *
  100. * Since the **EtherscanProvider** includes additional code for
  101. * [[Contract]] access, in //rare cases// that contracts are not
  102. * used, this class can reduce code size.
  103. *
  104. * @_docloc: api/providers/thirdparty:Etherscan
  105. */
  106. export class EtherscanProvider extends AbstractProvider {
  107. /**
  108. * The connected network.
  109. */
  110. readonly network!: Network;
  111. /**
  112. * The API key or null if using the community provided bandwidth.
  113. */
  114. readonly apiKey!: null | string;
  115. readonly #plugin: null | EtherscanPlugin;
  116. /**
  117. * Creates a new **EtherscanBaseProvider**.
  118. */
  119. constructor(_network?: Networkish, _apiKey?: string) {
  120. const apiKey = (_apiKey != null) ? _apiKey: null;
  121. super();
  122. const network = Network.from(_network);
  123. this.#plugin = network.getPlugin<EtherscanPlugin>(EtherscanPluginId);
  124. defineProperties<EtherscanProvider>(this, { apiKey, network });
  125. // Test that the network is supported by Etherscan
  126. this.getBaseUrl();
  127. }
  128. /**
  129. * Returns the base URL.
  130. *
  131. * If an [[EtherscanPlugin]] is configured on the
  132. * [[EtherscanBaseProvider_network]], returns the plugin's
  133. * baseUrl.
  134. */
  135. getBaseUrl(): string {
  136. if (this.#plugin) { return this.#plugin.baseUrl; }
  137. switch(this.network.name) {
  138. case "mainnet":
  139. return "https:/\/api.etherscan.io";
  140. case "goerli":
  141. return "https:/\/api-goerli.etherscan.io";
  142. case "sepolia":
  143. return "https:/\/api-sepolia.etherscan.io";
  144. case "holesky":
  145. return "https:/\/api-holesky.etherscan.io";
  146. case "arbitrum":
  147. return "https:/\/api.arbiscan.io";
  148. case "arbitrum-goerli":
  149. return "https:/\/api-goerli.arbiscan.io";
  150. case "base":
  151. return "https:/\/api.basescan.org";
  152. case "base-sepolia":
  153. return "https:/\/api-sepolia.basescan.org";
  154. case "bnb":
  155. return "https:/\/api.bscscan.com";
  156. case "bnbt":
  157. return "https:/\/api-testnet.bscscan.com";
  158. case "matic":
  159. return "https:/\/api.polygonscan.com";
  160. case "matic-amoy":
  161. return "https:/\/api-amoy.polygonscan.com";
  162. case "matic-mumbai":
  163. return "https:/\/api-testnet.polygonscan.com";
  164. case "optimism":
  165. return "https:/\/api-optimistic.etherscan.io";
  166. case "optimism-goerli":
  167. return "https:/\/api-goerli-optimistic.etherscan.io";
  168. default:
  169. }
  170. assertArgument(false, "unsupported network", "network", this.network);
  171. }
  172. /**
  173. * Returns the URL for the %%module%% and %%params%%.
  174. */
  175. getUrl(module: string, params: Record<string, string>): string {
  176. const query = Object.keys(params).reduce((accum, key) => {
  177. const value = params[key];
  178. if (value != null) {
  179. accum += `&${ key }=${ value }`
  180. }
  181. return accum
  182. }, "");
  183. const apiKey = ((this.apiKey) ? `&apikey=${ this.apiKey }`: "");
  184. return `${ this.getBaseUrl() }/api?module=${ module }${ query }${ apiKey }`;
  185. }
  186. /**
  187. * Returns the URL for using POST requests.
  188. */
  189. getPostUrl(): string {
  190. return `${ this.getBaseUrl() }/api`;
  191. }
  192. /**
  193. * Returns the parameters for using POST requests.
  194. */
  195. getPostData(module: string, params: Record<string, any>): Record<string, any> {
  196. params.module = module;
  197. params.apikey = this.apiKey;
  198. return params;
  199. }
  200. async detectNetwork(): Promise<Network> {
  201. return this.network;
  202. }
  203. /**
  204. * Resolves to the result of calling %%module%% with %%params%%.
  205. *
  206. * If %%post%%, the request is made as a POST request.
  207. */
  208. async fetch(module: string, params: Record<string, any>, post?: boolean): Promise<any> {
  209. const id = nextId++;
  210. const url = (post ? this.getPostUrl(): this.getUrl(module, params));
  211. const payload = (post ? this.getPostData(module, params): null);
  212. this.emit("debug", { action: "sendRequest", id, url, payload: payload });
  213. const request = new FetchRequest(url);
  214. request.setThrottleParams({ slotInterval: 1000 });
  215. request.retryFunc = (req, resp, attempt: number) => {
  216. if (this.isCommunityResource()) {
  217. showThrottleMessage("Etherscan");
  218. }
  219. return Promise.resolve(true);
  220. };
  221. request.processFunc = async (request, response) => {
  222. const result = response.hasBody() ? JSON.parse(toUtf8String(response.body)): { };
  223. const throttle = ((typeof(result.result) === "string") ? result.result: "").toLowerCase().indexOf("rate limit") >= 0;
  224. if (module === "proxy") {
  225. // This JSON response indicates we are being throttled
  226. if (result && result.status == 0 && result.message == "NOTOK" && throttle) {
  227. this.emit("debug", { action: "receiveError", id, reason: "proxy-NOTOK", error: result });
  228. response.throwThrottleError(result.result, THROTTLE);
  229. }
  230. } else {
  231. if (throttle) {
  232. this.emit("debug", { action: "receiveError", id, reason: "null result", error: result.result });
  233. response.throwThrottleError(result.result, THROTTLE);
  234. }
  235. }
  236. return response;
  237. };
  238. if (payload) {
  239. request.setHeader("content-type", "application/x-www-form-urlencoded; charset=UTF-8");
  240. request.body = Object.keys(payload).map((k) => `${ k }=${ payload[k] }`).join("&");
  241. }
  242. const response = await request.send();
  243. try {
  244. response.assertOk();
  245. } catch (error) {
  246. this.emit("debug", { action: "receiveError", id, error, reason: "assertOk" });
  247. assert(false, "response error", "SERVER_ERROR", { request, response });
  248. }
  249. if (!response.hasBody()) {
  250. this.emit("debug", { action: "receiveError", id, error: "missing body", reason: "null body" });
  251. assert(false, "missing response", "SERVER_ERROR", { request, response });
  252. }
  253. const result = JSON.parse(toUtf8String(response.body));
  254. if (module === "proxy") {
  255. if (result.jsonrpc != "2.0") {
  256. this.emit("debug", { action: "receiveError", id, result, reason: "invalid JSON-RPC" });
  257. assert(false, "invalid JSON-RPC response (missing jsonrpc='2.0')", "SERVER_ERROR", { request, response, info: { result } });
  258. }
  259. if (result.error) {
  260. this.emit("debug", { action: "receiveError", id, result, reason: "JSON-RPC error" });
  261. assert(false, "error response", "SERVER_ERROR", { request, response, info: { result } });
  262. }
  263. this.emit("debug", { action: "receiveRequest", id, result });
  264. return result.result;
  265. } else {
  266. // getLogs, getHistory have weird success responses
  267. if (result.status == 0 && (result.message === "No records found" || result.message === "No transactions found")) {
  268. this.emit("debug", { action: "receiveRequest", id, result });
  269. return result.result;
  270. }
  271. if (result.status != 1 || (typeof(result.message) === "string" && !result.message.match(/^OK/))) {
  272. this.emit("debug", { action: "receiveError", id, result });
  273. assert(false, "error response", "SERVER_ERROR", { request, response, info: { result } });
  274. }
  275. this.emit("debug", { action: "receiveRequest", id, result });
  276. return result.result;
  277. }
  278. }
  279. /**
  280. * Returns %%transaction%% normalized for the Etherscan API.
  281. */
  282. _getTransactionPostData(transaction: TransactionRequest): Record<string, string> {
  283. const result: Record<string, string> = { };
  284. for (let key in transaction) {
  285. if (skipKeys.indexOf(key) >= 0) { continue; }
  286. if ((<any>transaction)[key] == null) { continue; }
  287. let value = (<any>transaction)[key];
  288. if (key === "type" && value === 0) { continue; }
  289. if (key === "blockTag" && value === "latest") { continue; }
  290. // Quantity-types require no leading zero, unless 0
  291. if ((<any>{ type: true, gasLimit: true, gasPrice: true, maxFeePerGs: true, maxPriorityFeePerGas: true, nonce: true, value: true })[key]) {
  292. value = toQuantity(value);
  293. } else if (key === "accessList") {
  294. value = "[" + accessListify(value).map((set) => {
  295. return `{address:"${ set.address }",storageKeys:["${ set.storageKeys.join('","') }"]}`;
  296. }).join(",") + "]";
  297. } else if (key === "blobVersionedHashes") {
  298. if (value.length === 0) { continue; }
  299. // @TODO: update this once the API supports blobs
  300. assert(false, "Etherscan API does not support blobVersionedHashes", "UNSUPPORTED_OPERATION", {
  301. operation: "_getTransactionPostData",
  302. info: { transaction }
  303. });
  304. } else {
  305. value = hexlify(value);
  306. }
  307. result[key] = value;
  308. }
  309. return result;
  310. }
  311. /**
  312. * Throws the normalized Etherscan error.
  313. */
  314. _checkError(req: PerformActionRequest, error: Error, transaction: any): never {
  315. // Pull any message out if, possible
  316. let message = "";
  317. if (isError(error, "SERVER_ERROR")) {
  318. // Check for an error emitted by a proxy call
  319. try {
  320. message = (<any>error).info.result.error.message;
  321. } catch (e) { }
  322. if (!message) {
  323. try {
  324. message = (<any>error).info.message;
  325. } catch (e) { }
  326. }
  327. }
  328. if (req.method === "estimateGas") {
  329. if (!message.match(/revert/i) && message.match(/insufficient funds/i)) {
  330. assert(false, "insufficient funds", "INSUFFICIENT_FUNDS", {
  331. transaction: req.transaction
  332. });
  333. }
  334. }
  335. if (req.method === "call" || req.method === "estimateGas") {
  336. if (message.match(/execution reverted/i)) {
  337. let data = "";
  338. try {
  339. data = (<any>error).info.result.error.data;
  340. } catch (error) { }
  341. const e = AbiCoder.getBuiltinCallException(req.method, <any>req.transaction, data);
  342. e.info = { request: req, error }
  343. throw e;
  344. }
  345. }
  346. if (message) {
  347. if (req.method === "broadcastTransaction") {
  348. const transaction = Transaction.from(req.signedTransaction);
  349. if (message.match(/replacement/i) && message.match(/underpriced/i)) {
  350. assert(false, "replacement fee too low", "REPLACEMENT_UNDERPRICED", {
  351. transaction
  352. });
  353. }
  354. if (message.match(/insufficient funds/)) {
  355. assert(false, "insufficient funds for intrinsic transaction cost", "INSUFFICIENT_FUNDS", {
  356. transaction
  357. });
  358. }
  359. if (message.match(/same hash was already imported|transaction nonce is too low|nonce too low/)) {
  360. assert(false, "nonce has already been used", "NONCE_EXPIRED", {
  361. transaction
  362. });
  363. }
  364. }
  365. }
  366. // Something we could not process
  367. throw error;
  368. }
  369. async _detectNetwork(): Promise<Network> {
  370. return this.network;
  371. }
  372. async _perform(req: PerformActionRequest): Promise<any> {
  373. switch (req.method) {
  374. case "chainId":
  375. return this.network.chainId;
  376. case "getBlockNumber":
  377. return this.fetch("proxy", { action: "eth_blockNumber" });
  378. case "getGasPrice":
  379. return this.fetch("proxy", { action: "eth_gasPrice" });
  380. case "getPriorityFee":
  381. // This is temporary until Etherscan completes support
  382. if (this.network.name === "mainnet") {
  383. return "1000000000";
  384. } else if (this.network.name === "optimism") {
  385. return "1000000";
  386. } else {
  387. throw new Error("fallback onto the AbstractProvider default");
  388. }
  389. /* Working with Etherscan to get this added:
  390. try {
  391. const test = await this.fetch("proxy", {
  392. action: "eth_maxPriorityFeePerGas"
  393. });
  394. console.log(test);
  395. return test;
  396. } catch (e) {
  397. console.log("DEBUG", e);
  398. throw e;
  399. }
  400. */
  401. /* This might be safe; but due to rounding neither myself
  402. or Etherscan are necessarily comfortable with this. :)
  403. try {
  404. const result = await this.fetch("gastracker", { action: "gasoracle" });
  405. console.log(result);
  406. const gasPrice = parseUnits(result.SafeGasPrice, "gwei");
  407. const baseFee = parseUnits(result.suggestBaseFee, "gwei");
  408. const priorityFee = gasPrice - baseFee;
  409. if (priorityFee < 0) { throw new Error("negative priority fee; defer to abstract provider default"); }
  410. return priorityFee;
  411. } catch (error) {
  412. console.log("DEBUG", error);
  413. throw error;
  414. }
  415. */
  416. case "getBalance":
  417. // Returns base-10 result
  418. return this.fetch("account", {
  419. action: "balance",
  420. address: req.address,
  421. tag: req.blockTag
  422. });
  423. case "getTransactionCount":
  424. return this.fetch("proxy", {
  425. action: "eth_getTransactionCount",
  426. address: req.address,
  427. tag: req.blockTag
  428. });
  429. case "getCode":
  430. return this.fetch("proxy", {
  431. action: "eth_getCode",
  432. address: req.address,
  433. tag: req.blockTag
  434. });
  435. case "getStorage":
  436. return this.fetch("proxy", {
  437. action: "eth_getStorageAt",
  438. address: req.address,
  439. position: req.position,
  440. tag: req.blockTag
  441. });
  442. case "broadcastTransaction":
  443. return this.fetch("proxy", {
  444. action: "eth_sendRawTransaction",
  445. hex: req.signedTransaction
  446. }, true).catch((error) => {
  447. return this._checkError(req, <Error>error, req.signedTransaction);
  448. });
  449. case "getBlock":
  450. if ("blockTag" in req) {
  451. return this.fetch("proxy", {
  452. action: "eth_getBlockByNumber",
  453. tag: req.blockTag,
  454. boolean: (req.includeTransactions ? "true": "false")
  455. });
  456. }
  457. assert(false, "getBlock by blockHash not supported by Etherscan", "UNSUPPORTED_OPERATION", {
  458. operation: "getBlock(blockHash)"
  459. });
  460. case "getTransaction":
  461. return this.fetch("proxy", {
  462. action: "eth_getTransactionByHash",
  463. txhash: req.hash
  464. });
  465. case "getTransactionReceipt":
  466. return this.fetch("proxy", {
  467. action: "eth_getTransactionReceipt",
  468. txhash: req.hash
  469. });
  470. case "call": {
  471. if (req.blockTag !== "latest") {
  472. throw new Error("EtherscanProvider does not support blockTag for call");
  473. }
  474. const postData = this._getTransactionPostData(req.transaction);
  475. postData.module = "proxy";
  476. postData.action = "eth_call";
  477. try {
  478. return await this.fetch("proxy", postData, true);
  479. } catch (error) {
  480. return this._checkError(req, <Error>error, req.transaction);
  481. }
  482. }
  483. case "estimateGas": {
  484. const postData = this._getTransactionPostData(req.transaction);
  485. postData.module = "proxy";
  486. postData.action = "eth_estimateGas";
  487. try {
  488. return await this.fetch("proxy", postData, true);
  489. } catch (error) {
  490. return this._checkError(req, <Error>error, req.transaction);
  491. }
  492. }
  493. /*
  494. case "getLogs": {
  495. // Needs to complain if more than one address is passed in
  496. const args: Record<string, any> = { action: "getLogs" }
  497. if (params.filter.fromBlock) {
  498. args.fromBlock = checkLogTag(params.filter.fromBlock);
  499. }
  500. if (params.filter.toBlock) {
  501. args.toBlock = checkLogTag(params.filter.toBlock);
  502. }
  503. if (params.filter.address) {
  504. args.address = params.filter.address;
  505. }
  506. // @TODO: We can handle slightly more complicated logs using the logs API
  507. if (params.filter.topics && params.filter.topics.length > 0) {
  508. if (params.filter.topics.length > 1) {
  509. logger.throwError("unsupported topic count", Logger.Errors.UNSUPPORTED_OPERATION, { topics: params.filter.topics });
  510. }
  511. if (params.filter.topics.length === 1) {
  512. const topic0 = params.filter.topics[0];
  513. if (typeof(topic0) !== "string" || topic0.length !== 66) {
  514. logger.throwError("unsupported topic format", Logger.Errors.UNSUPPORTED_OPERATION, { topic0: topic0 });
  515. }
  516. args.topic0 = topic0;
  517. }
  518. }
  519. const logs: Array<any> = await this.fetch("logs", args);
  520. // Cache txHash => blockHash
  521. let blocks: { [tag: string]: string } = {};
  522. // Add any missing blockHash to the logs
  523. for (let i = 0; i < logs.length; i++) {
  524. const log = logs[i];
  525. if (log.blockHash != null) { continue; }
  526. if (blocks[log.blockNumber] == null) {
  527. const block = await this.getBlock(log.blockNumber);
  528. if (block) {
  529. blocks[log.blockNumber] = block.hash;
  530. }
  531. }
  532. log.blockHash = blocks[log.blockNumber];
  533. }
  534. return logs;
  535. }
  536. */
  537. default:
  538. break;
  539. }
  540. return super._perform(req);
  541. }
  542. async getNetwork(): Promise<Network> {
  543. return this.network;
  544. }
  545. /**
  546. * Resolves to the current price of ether.
  547. *
  548. * This returns ``0`` on any network other than ``mainnet``.
  549. */
  550. async getEtherPrice(): Promise<number> {
  551. if (this.network.name !== "mainnet") { return 0.0; }
  552. return parseFloat((await this.fetch("stats", { action: "ethprice" })).ethusd);
  553. }
  554. /**
  555. * Resolves to a [Contract]] for %%address%%, using the
  556. * Etherscan API to retreive the Contract ABI.
  557. */
  558. async getContract(_address: string): Promise<null | Contract> {
  559. let address = this._getAddress(_address);
  560. if (isPromise(address)) { address = await address; }
  561. try {
  562. const resp = await this.fetch("contract", {
  563. action: "getabi", address });
  564. const abi = JSON.parse(resp);
  565. return new Contract(address, abi, this);
  566. } catch (error) {
  567. return null;
  568. }
  569. }
  570. isCommunityResource(): boolean {
  571. return (this.apiKey == null);
  572. }
  573. }