Skip to content

Support Etherscan API v2 #6727

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 23, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nasty-readers-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nomicfoundation/hardhat-verify": patch
---

Support Etherscan API v2 (#6716)
17 changes: 15 additions & 2 deletions packages/hardhat-ignition/src/utils/getApiKeyAndUrls.ts
Original file line number Diff line number Diff line change
@@ -5,18 +5,31 @@ import { NomicLabsHardhatPluginError } from "hardhat/plugins";
export function getApiKeyAndUrls(
etherscanApiKey: string | Record<string, string>,
chainConfig: ChainConfig
): [apiKey: string, apiUrl: string, webUrl: string] {
): [
apiKey: string,
apiUrl: string,
webUrl: string,
chainId: number | undefined
] {
const apiKey: string =
typeof etherscanApiKey === "string"
? etherscanApiKey
: etherscanApiKey[chainConfig.network];

const chainId =
typeof etherscanApiKey === "string" ? chainConfig.chainId : undefined;

if (apiKey === undefined) {
throw new NomicLabsHardhatPluginError(
"@nomicfoundation/hardhat-ignition",
`No etherscan API key configured for network ${chainConfig.network}`
);
}

return [apiKey, chainConfig.urls.apiURL, chainConfig.urls.browserURL];
return [
apiKey,
chainConfig.urls.apiURL,
chainConfig.urls.browserURL,
chainId,
];
}
9 changes: 6 additions & 3 deletions packages/hardhat-ignition/test/verify/getApiKeyAndUrls.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { assert } from "chai";
import { getApiKeyAndUrls } from "../../src/utils/getApiKeyAndUrls";

describe("getApiKeyAndUrls", function () {
it("should return the correct API URLs when given a string", function () {
it("should return the correct API URLs and chain id when given a string", function () {
const apiKeyList = getApiKeyAndUrls("testApiKey", {
network: "mainnet",
chainId: 1,
@@ -17,10 +17,11 @@ describe("getApiKeyAndUrls", function () {
"testApiKey",
"https://api.etherscan.io/api",
"https://etherscan.io",
1,
]);
});

it("should return the correct API URLs when given an apiKey object", function () {
it("should return the correct API URLs without chain id when given an apiKey object", function () {
const apiKeyList = getApiKeyAndUrls(
{
goerli: "goerliApiKey",
@@ -40,10 +41,11 @@ describe("getApiKeyAndUrls", function () {
"goerliApiKey",
"https://api-goerli.etherscan.io/api",
"https://goerli.etherscan.io",
undefined,
]);
});

it("should return the correct API URLs when given a string and the network is not mainnet", function () {
it("should return the correct API URLs and chain id when given a string and the network is not mainnet", function () {
const apiKeyList = getApiKeyAndUrls("goerliApiKey", {
network: "goerli",
chainId: 5,
@@ -57,6 +59,7 @@ describe("getApiKeyAndUrls", function () {
"goerliApiKey",
"https://api-goerli.etherscan.io/api",
"https://goerli.etherscan.io",
5,
]);
});

2 changes: 1 addition & 1 deletion packages/hardhat-verify/src/internal/blockscout.ts
Original file line number Diff line number Diff line change
@@ -24,7 +24,7 @@ export class Blockscout {
* @param browserUrl - The Blockscout browser URL, e.g. https://eth.blockscout.com.
*/
constructor(public apiUrl: string, public browserUrl: string) {
this._etherscan = new Etherscan("api_key", apiUrl, browserUrl);
this._etherscan = new Etherscan("api_key", apiUrl, browserUrl, undefined);
}

public static async getCurrentChainConfig(
45 changes: 42 additions & 3 deletions packages/hardhat-verify/src/internal/etherscan.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import type {

import { HARDHAT_NETWORK_NAME } from "hardhat/plugins";

import picocolors from "picocolors";
import {
ContractStatusPollingInvalidStatusCodeError,
ContractVerificationMissingBytecodeError,
@@ -27,6 +28,8 @@ import { builtinChains } from "./chain-config";
// Used for polling the result of the contract verification.
const VERIFICATION_STATUS_POLLING_TIME = 3000;

export const ETHERSCAN_V2_API_URL = "https://api.etherscan.io/v2/api";

/**
* Etherscan verification provider for verifying smart contracts.
* It should work with other verification providers as long as the interface
@@ -38,12 +41,16 @@ export class Etherscan {
* @param apiKey - The Etherscan API key.
* @param apiUrl - The Etherscan API URL, e.g. https://api.etherscan.io/api.
* @param browserUrl - The Etherscan browser URL, e.g. https://etherscan.io.
* @param chainId - Chain id when willing to use the v2 api, undefined otherwise
*/
constructor(
public apiKey: string,
public apiUrl: string,
public browserUrl: string
) {}
public browserUrl: string,
public chainId: number | undefined
) {
this.apiUrl = chainId === undefined ? apiUrl : ETHERSCAN_V2_API_URL;
}

public static async getCurrentChainConfig(
networkName: string,
@@ -80,7 +87,25 @@ export class Etherscan {
const apiUrl = chainConfig.urls.apiURL;
const browserUrl = chainConfig.urls.browserURL.trim().replace(/\/$/, "");

return new Etherscan(resolvedApiKey, apiUrl, browserUrl);
// If a user sets a single api key, it means it's etherscan.io key, and we can use the api v2 with multiple chain support
// If multiple keys are set, it means that l2/sidechain explorers are being used, and those keys don't work with etherscan.io api v2.
// So we keep using the v1 api of their respective explorers
const isV2 = typeof apiKey === "string";

if (!isV2) {
console.warn(
picocolors.yellow(
"[WARNING] Network and explorer-specific api keys are deprecated in favour of the new Etherscan v2 api. Support for v1 is expected to end by May 31st, 2025. To migrate, please specify a single Etherscan.io api key the apiKey config value."
)
);
}

return new Etherscan(
resolvedApiKey,
apiUrl,
browserUrl,
isV2 ? chainConfig.chainId : undefined
);
}

/**
@@ -99,6 +124,10 @@ export class Etherscan {
address,
});

if (this.chainId !== undefined) {
parameters.set("chainid", String(this.chainId));
}

const url = new URL(this.apiUrl);
url.search = parameters.toString();

@@ -162,6 +191,11 @@ export class Etherscan {
});

const url = new URL(this.apiUrl);

if (this.chainId !== undefined) {
url.searchParams.append("chainid", String(this.chainId));
}

let response: Dispatcher.ResponseData | undefined;
let json: EtherscanVerifyResponse | undefined;
try {
@@ -218,6 +252,11 @@ export class Etherscan {
action: "checkverifystatus",
guid,
});

if (this.chainId !== undefined) {
parameters.set("chainid", String(this.chainId));
}

const url = new URL(this.apiUrl);
url.search = parameters.toString();

3 changes: 3 additions & 0 deletions packages/hardhat-verify/test/integration/index.ts
Original file line number Diff line number Diff line change
@@ -31,13 +31,16 @@ describe("verify task integration tests", () => {

// suppress sourcify info message
let consoleInfoStub: SinonStub;
let consoleWarnStub: SinonStub;
before(() => {
consoleInfoStub = sinon.stub(console, "info");
consoleWarnStub = sinon.stub(console, "warn");
});

// suppress warnings
after(() => {
consoleInfoStub.restore();
consoleWarnStub.restore();
});

it("should return after printing the supported networks", async function () {
199 changes: 199 additions & 0 deletions packages/hardhat-verify/test/unit/etherscan.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,9 @@ import type { EthereumProvider } from "hardhat/types";
import type { ChainConfig } from "../../src/types";

import { assert, expect } from "chai";
import sinon, { SinonStub } from "sinon";
import { Etherscan } from "../../src/internal/etherscan";
import * as undici from "../../src/internal/undici";

describe("Etherscan", () => {
const chainConfig = {
@@ -14,6 +16,22 @@ describe("Etherscan", () => {
},
};

let warnStub: SinonStub;
let sendGetRequestStub: SinonStub;
let sendPostRequestStub: SinonStub;

beforeEach(() => {
warnStub = sinon.stub(console, "warn");
sendGetRequestStub = sinon.stub(undici, "sendGetRequest");
sendPostRequestStub = sinon.stub(undici, "sendPostRequest");
});

afterEach(() => {
warnStub.restore();
sendGetRequestStub.restore();
sendPostRequestStub.restore();
});

describe("constructor", () => {
it("should throw if the apiKey is undefined or empty", () => {
expect(() => Etherscan.fromChainConfig(undefined, chainConfig)).to.throw(
@@ -34,6 +52,49 @@ describe("Etherscan", () => {
/You are trying to verify a contract in 'goerli', but no API token was found for this network./
);
});

it("resolves apiUrl to etherscan v2 if chain id is specified", async () => {
expect(
new Etherscan("api_key", "api_url", "browser_url", 5).apiUrl
).to.equal("https://api.etherscan.io/v2/api");
});

it("uses apiUrl parameter if chain id is not specified", async () => {
expect(
new Etherscan("api_key", "api_url", "browser_url", undefined).apiUrl
).to.equal("api_url");
});
});

describe("fromChainConfig", () => {
it("warns if apiKey config var is an object", async () => {
Etherscan.fromChainConfig({ goerli: "<api-key>" }, chainConfig);

expect(warnStub).to.be.calledOnceWith(
sinon.match(
/Network and explorer-specific api keys are deprecated in favour of the new Etherscan v2 api/
)
);
});

it("doesnt warn if apiKey config var is a string", async () => {
Etherscan.fromChainConfig("<api-key>", chainConfig);

expect(warnStub).to.be.callCount(0);
});

it("passes chain id to Etherscan constructor if apiKey is a string (treated as v2 api)", async () => {
const etherscan = Etherscan.fromChainConfig("<api-key>", chainConfig);
expect(etherscan.chainId).to.equal(5);
});

it("doesnt pass chain id to Etherscan constructor if apiKey is an object (treated as v1 api)", async () => {
const etherscan = Etherscan.fromChainConfig(
{ goerli: "<api-key>" },
chainConfig
);
expect(etherscan.chainId).to.equal(undefined);
});
});

describe("getCurrentChainConfig", () => {
@@ -188,4 +249,142 @@ describe("Etherscan", () => {
assert.equal(contractUrl, expectedContractAddress);
});
});

describe("isVerified", function () {
it("calls the api with a chainid parameter if present", async () => {
const etherscan = new Etherscan(
"api_key",
"https://api.etherscan.io/api",
"https://etherscan.io",
5
);

try {
await etherscan.isVerified("0x123abc");
} catch (error) {}

expect(sendGetRequestStub).to.be.calledOnceWithExactly(
sinon.match.has(
"search",
sinon.match(sinon.match((value) => /chainid=5/.test(value)))
)
);
});

it("doesnt pass chainid if its not present in the instance", async () => {
const etherscan = new Etherscan(
"api_key",
"https://api.etherscan.io/api",
"https://etherscan.io",
undefined
);

try {
await etherscan.isVerified("0x123abc");
} catch (error) {}

expect(sendGetRequestStub).to.be.calledOnceWithExactly(
sinon.match.has(
"search",
sinon.match((value) => !/chainid/.test(value))
)
);
});
});

describe("verify", function () {
it("calls the api with a chainid parameter if present", async () => {
const etherscan = new Etherscan(
"api_key",
"https://api.etherscan.io/api",
"https://etherscan.io",
5
);

try {
await etherscan.verify(
"0x123abc",
"sourceCode",
"contractName",
"v0.8.19",
"constructorArgs"
);
} catch (error) {}

expect(sendPostRequestStub).to.be.calledOnceWith(
sinon.match.has(
"search",
sinon.match(sinon.match((value) => /chainid=5/.test(value)))
)
);
});

it("doesnt pass chainid if its not present in the instance", async () => {
const etherscan = new Etherscan(
"api_key",
"https://api.etherscan.io/api",
"https://etherscan.io",
undefined
);

try {
await etherscan.verify(
"0x123abc",
"sourceCode",
"contractName",
"v0.8.19",
"constructorArgs"
);
} catch (error) {}

expect(sendPostRequestStub).to.be.calledOnceWith(
sinon.match.has(
"search",
sinon.match((value) => !/chainid/.test(value))
)
);
});
});

describe("getVerificationStatus", function () {
it("calls the api with a chainid parameter if present", async () => {
const etherscan = new Etherscan(
"api_key",
"https://api.etherscan.io/api",
"https://etherscan.io",
5
);

try {
await etherscan.getVerificationStatus("0x123abc");
} catch (error) {}

expect(sendGetRequestStub).to.be.calledOnceWithExactly(
sinon.match.has(
"search",
sinon.match(sinon.match((value) => /chainid=5/.test(value)))
)
);
});

it("doesnt pass chainid if its not present in the instance", async () => {
const etherscan = new Etherscan(
"api_key",
"https://api.etherscan.io/api",
"https://etherscan.io",
undefined
);

try {
await etherscan.getVerificationStatus("0x123abc");
} catch (error) {}

expect(sendGetRequestStub).to.be.calledOnceWithExactly(
sinon.match.has(
"search",
sinon.match((value) => !/chainid/.test(value))
)
);
});
});
});