diff --git a/.changeset/many-bulldogs-pay.md b/.changeset/many-bulldogs-pay.md new file mode 100644 index 0000000000..8a3bf24eb2 --- /dev/null +++ b/.changeset/many-bulldogs-pay.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/cli": patch +--- + +Added a new `mud verify` command which verifies all contracts in a project. This includes systems, modules, the WorldFactory and World. diff --git a/packages/cli/package.json b/packages/cli/package.json index 80cedb8da2..b98fcfafd8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,7 @@ "glob": "^8.0.3", "nice-grpc-web": "^2.0.1", "openurl": "^1.1.1", + "p-queue": "^7.4.1", "p-retry": "^5.1.2", "path": "^0.12.7", "rxjs": "7.5.5", diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index b00bf65dc0..7db9e57ed6 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -14,6 +14,7 @@ import setVersion from "./set-version"; import test from "./test"; import trace from "./trace"; import devContracts from "./dev-contracts"; +import verify from "./verify"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each command has different options export const commands: CommandModule[] = [ @@ -30,4 +31,5 @@ export const commands: CommandModule[] = [ trace, devContracts, abiTs, + verify, ]; diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts new file mode 100644 index 0000000000..d0a6b24294 --- /dev/null +++ b/packages/cli/src/commands/verify.ts @@ -0,0 +1,111 @@ +import type { CommandModule, InferredOptionTypes } from "yargs"; +import { verify } from "../verify"; +import { loadConfig } from "@latticexyz/config/node"; +import { World as WorldConfig } from "@latticexyz/world"; +import { resolveWorldConfig } from "@latticexyz/world/internal"; +import { worldToV1 } from "@latticexyz/world/config/v2"; +import { getOutDirectory, getRpcUrl, getSrcDirectory } from "@latticexyz/common/foundry"; +import { getExistingContracts } from "../utils/getExistingContracts"; +import { getContractData } from "../utils/getContractData"; +import { defaultModuleContracts } from "../utils/defaultModuleContracts"; +import { Hex, createWalletClient, http } from "viem"; +import chalk from "chalk"; + +const verifyOptions = { + deployerAddress: { + type: "string", + desc: "Deploy using an existing deterministic deployer (https://github.com/Arachnid/deterministic-deployment-proxy)", + }, + worldAddress: { type: "string", required: true, desc: "Verify an existing World at the given address" }, + configPath: { type: "string", desc: "Path to the MUD config file" }, + profile: { type: "string", desc: "The foundry profile to use" }, + rpc: { type: "string", desc: "The RPC URL to use. Defaults to the RPC url from the local foundry.toml" }, + rpcBatch: { + type: "boolean", + desc: "Enable batch processing of RPC requests in viem client (defaults to batch size of 100 and wait of 1s)", + }, + srcDir: { type: "string", desc: "Source directory. Defaults to foundry src directory." }, + verifier: { type: "string", desc: "The verifier to use" }, + verifierUrl: { + type: "string", + desc: "The verification provider.", + }, +} as const; + +type Options = InferredOptionTypes; + +const commandModule: CommandModule = { + command: "verify", + + describe: "Verify contracts", + + builder(yargs) { + return yargs.options(verifyOptions); + }, + + async handler(opts) { + const profile = opts.profile ?? process.env.FOUNDRY_PROFILE; + + const configV2 = (await loadConfig(opts.configPath)) as WorldConfig; + const config = worldToV1(configV2); + + const srcDir = opts.srcDir ?? (await getSrcDirectory(profile)); + const outDir = await getOutDirectory(profile); + + const rpc = opts.rpc ?? (await getRpcUrl(profile)); + console.log( + chalk.bgBlue( + chalk.whiteBright(`\n Verifying MUD contracts${profile ? " with profile " + profile : ""} to RPC ${rpc} \n`), + ), + ); + + const client = createWalletClient({ + transport: http(rpc, { + batch: opts.rpcBatch + ? { + batchSize: 100, + wait: 1000, + } + : undefined, + }), + }); + + const contractNames = getExistingContracts(srcDir).map(({ basename }) => basename); + const resolvedWorldConfig = resolveWorldConfig(config, contractNames); + + const systems = Object.keys(resolvedWorldConfig.systems).map((name) => { + const contractData = getContractData(`${name}.sol`, name, outDir); + + return { + name, + bytecode: contractData.bytecode, + }; + }); + + // Get modules + const modules = config.modules.map((mod) => { + const contractData = + defaultModuleContracts.find((defaultMod) => defaultMod.name === mod.name) ?? + getContractData(`${mod.name}.sol`, mod.name, outDir); + + return { + name: mod.name, + bytecode: contractData.bytecode, + }; + }); + + await verify({ + client, + rpc, + foundryProfile: profile, + systems, + modules, + deployerAddress: opts.deployerAddress as Hex | undefined, + worldAddress: opts.worldAddress as Hex, + verifier: opts.verifier, + verifierUrl: opts.verifierUrl, + }); + }, +}; + +export default commandModule; diff --git a/packages/cli/src/verify.ts b/packages/cli/src/verify.ts new file mode 100644 index 0000000000..bc4e796f35 --- /dev/null +++ b/packages/cli/src/verify.ts @@ -0,0 +1,166 @@ +import { Chain, Client, Hex, Transport, getCreate2Address, sliceHex, zeroHash } from "viem"; +import { getWorldFactoryContracts } from "./deploy/getWorldFactoryContracts"; +import { verifyContract } from "./verify/verifyContract"; +import PQueue from "p-queue"; +import { getWorldProxyFactoryContracts } from "./deploy/getWorldProxyFactoryContracts"; +import { getDeployer } from "./deploy/getDeployer"; +import { MUDError } from "@latticexyz/common/errors"; +import { salt } from "./deploy/common"; +import { getStorageAt } from "viem/actions"; + +type VerifyOptions = { + client: Client; + rpc: string; + foundryProfile?: string; + verifier?: string; + verifierUrl?: string; + systems: { name: string; bytecode: Hex }[]; + modules: { name: string; bytecode: Hex }[]; + worldAddress: Hex; + /** + * Address of determinstic deployment proxy: https://github.com/Arachnid/deterministic-deployment-proxy + * By default, we look for a deployment at 0x4e59b44847b379578588920ca78fbf26c0b4956c. + * If it is not deployed or the target chain does not support legacy transactions, the user must set the deployer manually. + */ + deployerAddress?: Hex; +}; + +const ERC1967_IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +export async function verify({ + client, + rpc, + foundryProfile = process.env.FOUNDRY_PROFILE, + systems, + modules, + worldAddress, + deployerAddress: initialDeployerAddress, + verifier, + verifierUrl, +}: VerifyOptions): Promise { + const deployerAddress = initialDeployerAddress ?? (await getDeployer(client)); + if (!deployerAddress) { + throw new MUDError(`No deployer`); + } + + // If the proxy implementation storage slot is set on the World, the World was deployed as a proxy. + const implementationStorage = await getStorageAt(client, { + address: worldAddress, + slot: ERC1967_IMPLEMENTATION_SLOT, + }); + const usesProxy = implementationStorage && implementationStorage !== zeroHash; + + const verifyQueue = new PQueue({ concurrency: 1 }); + + systems.map(({ name, bytecode }) => + verifyQueue.add(() => + verifyContract( + { + name, + rpc, + verifier, + verifierUrl, + address: getCreate2Address({ + from: deployerAddress, + bytecode: bytecode, + salt, + }), + }, + { profile: foundryProfile }, + ).catch((error) => { + console.error(`Error verifying system contract ${name}:`, error); + }), + ), + ); + + Object.entries( + usesProxy ? getWorldProxyFactoryContracts(deployerAddress) : getWorldFactoryContracts(deployerAddress), + ).map(([name, { bytecode }]) => + verifyQueue.add(() => + verifyContract( + { + name, + rpc, + verifier, + verifierUrl, + address: getCreate2Address({ + from: deployerAddress, + bytecode: bytecode, + salt, + }), + }, + { + profile: foundryProfile, + cwd: "node_modules/@latticexyz/world", + }, + ).catch((error) => { + console.error(`Error verifying world factory contract ${name}:`, error); + }), + ), + ); + + modules.map(({ name, bytecode }) => + verifyQueue.add(() => + verifyContract( + { + name: name, + rpc, + verifier, + verifierUrl, + address: getCreate2Address({ + from: deployerAddress, + bytecode: bytecode, + salt, + }), + }, + { + profile: foundryProfile, + cwd: "node_modules/@latticexyz/world-modules", + }, + ).catch((error) => { + console.error(`Error verifying module contract ${name}:`, error); + }), + ), + ); + + // If the world was deployed as a Proxy, verify the proxy and implementation. + if (usesProxy) { + const implementationAddress = sliceHex(implementationStorage, -20); + + verifyQueue.add(() => + verifyContract( + { name: "WorldProxy", rpc, verifier, verifierUrl, address: worldAddress }, + { + profile: foundryProfile, + cwd: "node_modules/@latticexyz/world", + }, + ).catch((error) => { + console.error(`Error verifying WorldProxy contract:`, error); + }), + ); + + verifyQueue.add(() => + verifyContract( + { name: "World", rpc, verifier, verifierUrl, address: implementationAddress }, + { + profile: foundryProfile, + cwd: "node_modules/@latticexyz/world", + }, + ).catch((error) => { + console.error(`Error verifying World contract:`, error); + }), + ); + } else { + verifyQueue.add(() => + verifyContract( + { name: "World", rpc, verifier, verifierUrl, address: worldAddress }, + { + profile: foundryProfile, + cwd: "node_modules/@latticexyz/world", + }, + ).catch((error) => { + console.error(`Error verifying World contract:`, error); + }), + ); + } +} diff --git a/packages/cli/src/verify/verifyContract.ts b/packages/cli/src/verify/verifyContract.ts new file mode 100644 index 0000000000..6f558f240d --- /dev/null +++ b/packages/cli/src/verify/verifyContract.ts @@ -0,0 +1,24 @@ +import { forge } from "@latticexyz/common/foundry"; +import { Address } from "viem"; + +type VerifyContractOptions = { + name: string; + rpc: string; + verifier?: string; + verifierUrl?: string; + address: Address; +}; + +type ForgeOptions = { profile?: string; silent?: boolean; env?: NodeJS.ProcessEnv; cwd?: string }; + +export async function verifyContract(options: VerifyContractOptions, forgeOptions?: ForgeOptions) { + const args = ["verify-contract", options.address, options.name, "--rpc-url", options.rpc]; + + if (options.verifier) { + args.push("--verifier", options.verifier); + } + if (options.verifierUrl) { + args.push("--verifier-url", options.verifierUrl); + } + await forge(args, forgeOptions); +} diff --git a/packages/common/src/foundry/index.ts b/packages/common/src/foundry/index.ts index 83f20bff42..628a98deea 100644 --- a/packages/common/src/foundry/index.ts +++ b/packages/common/src/foundry/index.ts @@ -87,12 +87,13 @@ export async function getRemappings(profile?: string): Promise<[string, string][ */ export async function forge( args: string[], - options?: { profile?: string; silent?: boolean; env?: NodeJS.ProcessEnv }, + options?: { profile?: string; silent?: boolean; env?: NodeJS.ProcessEnv; cwd?: string }, ): Promise { const execOptions: Options = { env: { FOUNDRY_PROFILE: options?.profile, ...options?.env }, stdout: "inherit", stderr: "pipe", + cwd: options?.cwd, }; await (options?.silent ? execa("forge", args, execOptions) : execLog("forge", args, execOptions)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e903f70e5..1718e499b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: openurl: specifier: ^1.1.1 version: 1.1.1 + p-queue: + specifier: ^7.4.1 + version: 7.4.1 p-retry: specifier: ^5.1.2 version: 5.1.2