diff --git a/.changeset/tame-socks-cover.md b/.changeset/tame-socks-cover.md new file mode 100644 index 00000000000..10724a6c05e --- /dev/null +++ b/.changeset/tame-socks-cover.md @@ -0,0 +1,9 @@ +--- +"@nomicfoundation/hardhat-ignition": minor +"@nomicfoundation/ignition-core": minor +"@nomicfoundation/hardhat-ignition-ethers": minor +"@nomicfoundation/hardhat-ignition-viem": minor +"@nomicfoundation/ignition-ui": minor +--- + +Added support for verifying on all enabled verification services (e.g. Sourcify) ([#7538](https://github.com/NomicFoundation/hardhat/issues/7538)). diff --git a/v-next/hardhat-ignition/src/index.ts b/v-next/hardhat-ignition/src/index.ts index 8edcb2d5f9a..35c010d7d6a 100644 --- a/v-next/hardhat-ignition/src/index.ts +++ b/v-next/hardhat-ignition/src/index.ts @@ -56,7 +56,7 @@ const hardhatIgnitionPlugin: HardhatPlugin = { }) .addFlag({ name: "verify", - description: "Verify the deployment on Etherscan", + description: "Verify the deployment on all enabled verifiers", }) .addFlag({ name: "writeLocalhostDeployment", diff --git a/v-next/hardhat-ignition/src/internal/tasks/verify.ts b/v-next/hardhat-ignition/src/internal/tasks/verify.ts index b3c90a9775e..2a5b5632a37 100644 --- a/v-next/hardhat-ignition/src/internal/tasks/verify.ts +++ b/v-next/hardhat-ignition/src/internal/tasks/verify.ts @@ -1,10 +1,14 @@ +import type { VerificationProvidersConfig } from "hardhat/types/config"; import type { HardhatRuntimeEnvironment } from "hardhat/types/hre"; import type { NewTaskActionFunction } from "hardhat/types/tasks"; import path from "node:path"; +import { ensureError } from "@nomicfoundation/hardhat-utils/error"; +import { capitalize } from "@nomicfoundation/hardhat-utils/string"; import { verifyContract } from "@nomicfoundation/hardhat-verify/verify"; import { getVerificationInformation } from "@nomicfoundation/ignition-core"; +import chalk from "chalk"; interface TaskVerifyArguments { deploymentId: string; @@ -15,6 +19,37 @@ const verifyTask: NewTaskActionFunction = async ( { deploymentId, force }, hre: HardhatRuntimeEnvironment, ) => { + await verify( + { deploymentId, force }, + hre, + verifyContract, + getVerificationInformation, + ); +}; + +// Exported for testing +export async function verify( + { deploymentId, force }: TaskVerifyArguments, + hre: HardhatRuntimeEnvironment, + verifyContractFn: typeof verifyContract, + getVerificationInformationFn: typeof getVerificationInformation, +): Promise { + const allProviders: Array = [ + "etherscan", + "blockscout", + "sourcify", + ]; + + const enabledProviders = allProviders.filter( + (provider) => hre.config.verify[provider].enabled, + ); + + if (enabledProviders.length === 0) { + console.warn(chalk.yellow("\n⚠️ No verification providers are enabled.")); + + return; + } + const deploymentDir = path.join( hre.config.paths.ignition, "deployments", @@ -23,28 +58,41 @@ const verifyTask: NewTaskActionFunction = async ( const connection = await hre.network.connect(); - for await (const contractInfo of getVerificationInformation(deploymentDir)) { + for await (const contractInfo of getVerificationInformationFn( + deploymentDir, + )) { if (typeof contractInfo === "string") { console.log( - `Could not resolve contract artifacts for contract "${contractInfo}". Skipping verification.`, + `Could not resolve contract artifacts for contract "${contractInfo}". Skipping verification.\n`, ); - console.log(""); + continue; } console.log( - `Verifying contract "${contractInfo.contract}" for network ${connection.networkName}...`, + `\nVerifying contract "${contractInfo.contract}" for network ${connection.networkName}...`, ); - await verifyContract( - { - ...contractInfo, - force, - provider: "etherscan", - }, - hre, - ); + for (const provider of enabledProviders) { + try { + console.log(chalk.cyan.bold(`\n=== ${capitalize(provider)} ===`)); + + await verifyContractFn( + { + ...contractInfo, + force, + provider, + }, + hre, + ); + } catch (error) { + ensureError(error); + + console.error(chalk.red(error.message)); + process.exitCode = 1; + } + } } -}; +} export default verifyTask; diff --git a/v-next/hardhat-ignition/test/tasks/verify.ts b/v-next/hardhat-ignition/test/tasks/verify.ts new file mode 100644 index 00000000000..7948eea26fd --- /dev/null +++ b/v-next/hardhat-ignition/test/tasks/verify.ts @@ -0,0 +1,252 @@ +import type { VerifyContractArgs } from "@nomicfoundation/hardhat-verify/verify"; +import type { VerifyResult } from "@nomicfoundation/ignition-core"; +import type { VerificationProvidersConfig } from "hardhat/types/config"; +import type { HardhatRuntimeEnvironment } from "hardhat/types/hre"; + +import { assert } from "chai"; +import sinon from "sinon"; + +import { verify } from "../../src/internal/tasks/verify.js"; +import { useEphemeralIgnitionProject } from "../test-helpers/use-ignition-project.js"; + +describe("ignition verify task", () => { + useEphemeralIgnitionProject("minimal"); + + // TODO: replace with disableConsole() once converted to `node:test` + let consoleLogStub: sinon.SinonStub; + let consoleWarnStub: sinon.SinonStub; + let consoleErrorStub: sinon.SinonStub; + + beforeEach(() => { + consoleLogStub = sinon.stub(console, "log"); + consoleWarnStub = sinon.stub(console, "warn"); + consoleErrorStub = sinon.stub(console, "error"); + }); + + afterEach(() => { + consoleLogStub.restore(); + consoleWarnStub.restore(); + consoleErrorStub.restore(); + + process.exitCode = undefined; + }); + + const exampleVerifyInfo: VerifyResult = { + address: "0x1234567890123456789012345678901234567890", + constructorArgs: [], + libraries: {}, + contract: "contracts/Foo.sol:Foo", + creationTxHash: + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }; + + it("should verify on all enabled providers", async function () { + const verifyCallsByProvider: Array = []; + + const mockVerifyContract = async ( + args: VerifyContractArgs, + _hre: HardhatRuntimeEnvironment, + ) => { + if (args.provider !== undefined) { + verifyCallsByProvider.push(args.provider); + } + + return true; + }; + + const mockGetVerificationInformation = async function* () { + yield exampleVerifyInfo; + }; + + await verify( + { deploymentId: "test-deployment", force: false }, + this.hre, + mockVerifyContract, + mockGetVerificationInformation, + ); + + assert.equal(verifyCallsByProvider.length, 3); + assert.include(verifyCallsByProvider, "etherscan"); + assert.include(verifyCallsByProvider, "blockscout"); + assert.include(verifyCallsByProvider, "sourcify"); + + assert.equal(process.exitCode, undefined); + }); + + it("should continue verification on other providers when one fails", async function () { + const verifyCallsByProvider: Array = []; + + const mockVerifyContract = async ( + args: VerifyContractArgs, + _hre: HardhatRuntimeEnvironment, + ) => { + if (args.provider !== undefined) { + verifyCallsByProvider.push(args.provider); + + if (args.provider === "blockscout") { + throw new Error("Blockscout verification failed"); + } + } + + return true; + }; + + const mockGetVerificationInformation = async function* () { + yield exampleVerifyInfo; + }; + + await verify( + { deploymentId: "test-deployment", force: false }, + this.hre, + mockVerifyContract, + mockGetVerificationInformation, + ); + + assert.equal(verifyCallsByProvider.length, 3); + assert.include(verifyCallsByProvider, "etherscan"); + assert.include(verifyCallsByProvider, "blockscout"); + assert.include(verifyCallsByProvider, "sourcify"); + + assert.equal(process.exitCode, 1); + }); + + it("should not verify when no providers are enabled", async function () { + let verifyContractCalled = false; + + await verify( + { deploymentId: "test-deployment", force: false }, + { + ...this.hre, + config: { + ...this.hre.config, + verify: { + etherscan: { enabled: false, apiKey: "" as any }, + blockscout: { enabled: false }, + sourcify: { enabled: false }, + }, + }, + }, + async () => { + verifyContractCalled = true; + + return true; + }, + async function* () {}, + ); + + assert.isFalse(verifyContractCalled); + }); + + it("should verify multiple contracts on all enabled providers", async function () { + const verifyCalls: Array<{ provider: string; contract: string }> = []; + + const mockVerifyContract = async ( + args: VerifyContractArgs, + _hre: HardhatRuntimeEnvironment, + ) => { + if (args.contract === undefined) { + assert.fail("Expected contract to be passed"); + } + + if (args.provider !== undefined) { + verifyCalls.push({ provider: args.provider, contract: args.contract }); + } + + return true; + }; + + const mockGetVerificationInformation = async function* () { + yield { + address: "0x1111111111111111111111111111111111111111", + constructorArgs: [], + libraries: {}, + contract: "contracts/Foo.sol:Foo", + creationTxHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }; + yield { + address: "0x2222222222222222222222222222222222222222", + constructorArgs: [123], + libraries: {}, + contract: "contracts/Bar.sol:Bar", + creationTxHash: + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }; + }; + + await verify( + { deploymentId: "test-deployment", force: false }, + this.hre, + mockVerifyContract, + mockGetVerificationInformation, + ); + + // 2 contracts × 3 providers = 6 calls + assert.equal(verifyCalls.length, 6); + + // Each contract should be verified on each provider + assert.deepEqual(verifyCalls, [ + { provider: "etherscan", contract: "contracts/Foo.sol:Foo" }, + { provider: "blockscout", contract: "contracts/Foo.sol:Foo" }, + { provider: "sourcify", contract: "contracts/Foo.sol:Foo" }, + { provider: "etherscan", contract: "contracts/Bar.sol:Bar" }, + { provider: "blockscout", contract: "contracts/Bar.sol:Bar" }, + { provider: "sourcify", contract: "contracts/Bar.sol:Bar" }, + ]); + }); + + it("should pass force flag through to each provider call", async function () { + const forceValues: boolean[] = []; + + const mockVerifyContract = async ( + args: VerifyContractArgs, + _hre: HardhatRuntimeEnvironment, + ) => { + if (args.force === undefined) { + assert.fail("Expected force to be passed"); + } + + forceValues.push(args.force); + + return true; + }; + + const mockGetVerificationInformation = async function* () { + yield exampleVerifyInfo; + }; + + await verify( + { deploymentId: "test-deployment", force: true }, + this.hre, + mockVerifyContract, + mockGetVerificationInformation, + ); + + assert.equal(forceValues.length, 3); + assert.isTrue( + forceValues.every((f) => f === true), + "Expected all verify calls to have force: true", + ); + }); + + it("should not verify if contracts artifacts cannot be resolved", async function () { + let verifyContractCalled = false; + + const mockGetVerificationInformation = async function* () { + yield "contracts/Foo.sol:Foo"; + }; + + await verify( + { deploymentId: "test-deployment", force: false }, + this.hre, + async () => { + verifyContractCalled = true; + + return true; + }, + mockGetVerificationInformation, + ); + + assert.isFalse(verifyContractCalled); + }); +}); diff --git a/v-next/ignition-core/src/internal/execution/types/execution-strategy.ts b/v-next/ignition-core/src/internal/execution/types/execution-strategy.ts index 0f19c8e252f..271e4b8ba34 100644 --- a/v-next/ignition-core/src/internal/execution/types/execution-strategy.ts +++ b/v-next/ignition-core/src/internal/execution/types/execution-strategy.ts @@ -159,6 +159,11 @@ export type StaticCallStrategyGenerator = AsyncGenerator< * process of requestsing `OnchainInteraction`s, which differs whether if the request * has already being resolved or not. See below for more details. * + * A strategy that performs multiple transactions must ensure the last transaction + * encapsulates the semantic changes of the strategy. For example, a multi-sig + * strategy should ensure that the final transaction is the one that performs the + * deployment or create2 invocation. + * * There are two types of request, which follow a different protocol: * * - `OnchainInteractionRequest`: This request is used to perform an onchain diff --git a/v-next/ignition-core/src/types/verify.ts b/v-next/ignition-core/src/types/verify.ts index b4ecc04b59e..9516fb2c521 100644 --- a/v-next/ignition-core/src/types/verify.ts +++ b/v-next/ignition-core/src/types/verify.ts @@ -28,7 +28,7 @@ export interface SourceToLibraryToAddress { } /** - * The information required to verify a contract on Etherscan. + * The information required to verify a contract. * * @beta */ @@ -37,6 +37,7 @@ export interface VerifyInfo { constructorArgs: SolidityParameterType[]; libraries: Record; contract: string; + creationTxHash?: string; } /** diff --git a/v-next/ignition-core/src/verify.ts b/v-next/ignition-core/src/verify.ts index 4cda6ecf8fa..bf27882d59e 100644 --- a/v-next/ignition-core/src/verify.ts +++ b/v-next/ignition-core/src/verify.ts @@ -4,6 +4,7 @@ import type { VerifyInfo, VerifyResult } from "./types/verify.js"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { FileNotFoundError } from "@nomicfoundation/hardhat-utils/fs"; +import { findLastIndex } from "lodash-es"; import { FileDeploymentLoader } from "./internal/deployment-loader/file-deployment-loader.js"; import { loadDeploymentState } from "./internal/execution/deployment-state-helpers.js"; @@ -96,6 +97,8 @@ async function convertExStateToVerifyInfo( `Deployment execution state ${exState.id} should have a successful result to retrieve address`, ); + const creationTxHash = getCreationTxHash(exState); + const verifyInfo: VerifyInfo = { constructorArgs, libraries, @@ -103,7 +106,32 @@ async function convertExStateToVerifyInfo( contract: contractName.includes(":") ? contractName : `${artifact.sourceName}:${contractName}`, + creationTxHash, }; return verifyInfo; } + +function getCreationTxHash( + exState: DeploymentExecutionState, +): string | undefined { + // We know that for both create2 and the basic strategy + // the last confirmed transaction is where the deployment + // happened - and so is the creation transaction. + const networkInteraction = exState.networkInteractions.at(-1); + + if (networkInteraction?.type !== "ONCHAIN_INTERACTION") { + return undefined; + } + + const lastConfirmedIndex = findLastIndex( + networkInteraction.transactions, + (tx) => tx.receipt !== undefined, + ); + + if (lastConfirmedIndex === -1) { + return undefined; + } + + return networkInteraction.transactions[lastConfirmedIndex].hash; +} diff --git a/v-next/ignition-core/test/verify.ts b/v-next/ignition-core/test/verify.ts index 9d67963e433..b2951b13cb7 100644 --- a/v-next/ignition-core/test/verify.ts +++ b/v-next/ignition-core/test/verify.ts @@ -44,6 +44,8 @@ describe("verify", () => { contract: "contracts/Lock.sol:Lock", constructorArgs: [1987909200], libraries: {}, + creationTxHash: + "0x785b3940e12d7876bd3dfc01b906ebd9faf7438a0e35eda5323ac3c6c5ca0b9e", }; const deploymentDir = path.join(__dirname, "mocks", "verify", "success"); @@ -62,6 +64,8 @@ describe("verify", () => { contract: "contracts/Lock.sol:Lock", constructorArgs: [1987909200], libraries: {}, + creationTxHash: + "0x3e7e2d599c58beb63d59e67288b4a9d3d5233cd71b42eea0c9ec6fc92021b3fd", }; const deploymentDir = path.join(