diff --git a/packages/cli/package.json b/packages/cli/package.json index 7d667b42ef..6da0bbd994 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,6 +38,7 @@ "@latticexyz/solecs": "workspace:*", "@latticexyz/std-contracts": "workspace:*", "@latticexyz/store": "workspace:*", + "@latticexyz/utils": "workspace:*", "@latticexyz/world": "workspace:*", "@typechain/ethers-v5": "^10.2.0", "chalk": "^5.0.1", diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ba044ff721..69177e2863 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -10,6 +10,7 @@ import deploy from "./deploy"; import worldgen from "./worldgen"; import setVersion from "./set-version"; import test from "./test"; +import trace from "./trace"; import devContracts from "./dev-contracts"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each command has different options @@ -24,5 +25,6 @@ export const commands: CommandModule<any, any>[] = [ worldgen, setVersion, test, + trace, devContracts, ]; diff --git a/packages/cli/src/commands/trace.ts b/packages/cli/src/commands/trace.ts new file mode 100644 index 0000000000..2a95d1b4ba --- /dev/null +++ b/packages/cli/src/commands/trace.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from "fs"; +import type { CommandModule } from "yargs"; +import { ethers } from "ethers"; + +import { loadConfig } from "@latticexyz/config/node"; +import { MUDError } from "@latticexyz/common/errors"; +import { cast, getRpcUrl, getSrcDirectory } from "@latticexyz/common/foundry"; +import { TableId } from "@latticexyz/utils"; +import { StoreConfig } from "@latticexyz/store"; +import { resolveWorldConfig, WorldConfig } from "@latticexyz/world"; +import { IBaseWorld } from "@latticexyz/world/types/ethers-contracts/IBaseWorld"; +import IBaseWorldData from "@latticexyz/world/abi/IBaseWorld.sol/IBaseWorld.json" assert { type: "json" }; +import { getChainId, getExistingContracts } from "../utils"; + +const systemsTableId = new TableId("", "Systems"); + +type Options = { + tx: string; + worldAddress?: string; + configPath?: string; + profile?: string; + srcDir?: string; + rpc?: string; +}; + +const commandModule: CommandModule<Options, Options> = { + command: "trace", + + describe: "Display the trace of a transaction", + + builder(yargs) { + return yargs.options({ + tx: { type: "string", required: true, description: "Transaction hash to replay" }, + worldAddress: { + type: "string", + description: "World contract address. Defaults to the value from worlds.json, based on rpc's chainId", + }, + configPath: { type: "string", description: "Path to the config file" }, + profile: { type: "string", description: "The foundry profile to use" }, + srcDir: { type: "string", description: "Source directory. Defaults to foundry src directory." }, + rpc: { type: "string", description: "json rpc endpoint. Defaults to foundry's configured eth_rpc_url" }, + }); + }, + + async handler(args) { + args.profile ??= process.env.FOUNDRY_PROFILE; + const { profile } = args; + args.srcDir ??= await getSrcDirectory(profile); + args.rpc ??= await getRpcUrl(profile); + const { tx, configPath, srcDir, rpc } = args; + + const existingContracts = getExistingContracts(srcDir); + + // Load the config + const mudConfig = (await loadConfig(configPath)) as StoreConfig & WorldConfig; + + const resolvedConfig = resolveWorldConfig( + mudConfig, + existingContracts.map(({ basename }) => basename) + ); + + // Get worldAddress either from args or from worldsFile + const worldAddress = args.worldAddress ?? (await getWorldAddress(mudConfig.worldsFile, rpc)); + + // Create World contract instance from deployed address + const provider = new ethers.providers.StaticJsonRpcProvider(rpc); + const WorldContract = new ethers.Contract(worldAddress, IBaseWorldData.abi, provider) as IBaseWorld; + + // TODO account for multiple namespaces (https://github.com/latticexyz/mud/issues/994) + const namespace = mudConfig.namespace; + const names = Object.values(resolvedConfig.systems).map(({ name }) => name); + + const labels: { name: string; address: string }[] = []; + for (const name of names) { + const systemSelector = new TableId(namespace, name); + // Get the first field of `Systems` table (the table maps system name to its address and other data) + const address = await WorldContract.getField(systemsTableId.toHexString(), [systemSelector.toHexString()], 0); + labels.push({ name, address }); + } + + const result = await cast([ + "run", + "--label", + `${worldAddress}:World`, + ...labels.map(({ name, address }) => ["--label", `${address}:${name}`]).flat(), + `${tx}`, + ]); + console.log(result); + + process.exit(0); + }, +}; + +export default commandModule; + +async function getWorldAddress(worldsFile: string, rpc: string) { + if (existsSync(worldsFile)) { + const chainId = await getChainId(rpc); + const deploys = JSON.parse(readFileSync(worldsFile, "utf-8")); + + if (!deploys[chainId]) { + throw new MUDError(`chainId ${chainId} is missing in worldsFile "${worldsFile}"`); + } + return deploys[chainId].address as string; + } else { + throw new MUDError("worldAddress is not specified and worldsFile is missing"); + } +} diff --git a/packages/cli/src/commands/worldgen.ts b/packages/cli/src/commands/worldgen.ts index 49ac781054..0cee74c92d 100644 --- a/packages/cli/src/commands/worldgen.ts +++ b/packages/cli/src/commands/worldgen.ts @@ -4,9 +4,9 @@ import { StoreConfig } from "@latticexyz/store"; import { WorldConfig } from "@latticexyz/world"; import { worldgen } from "@latticexyz/world/node"; import { getSrcDirectory } from "@latticexyz/common/foundry"; -import glob from "glob"; -import path, { basename } from "path"; +import path from "path"; import { rmSync } from "fs"; +import { getExistingContracts } from "../utils"; type Options = { configPath?: string; @@ -36,11 +36,7 @@ const commandModule: CommandModule<Options, Options> = { export async function worldgenHandler(args: Options) { const srcDir = args.srcDir ?? (await getSrcDirectory()); - // Get a list of all contract names - const existingContracts = glob.sync(`${srcDir}/**/*.sol`).map((path) => ({ - path, - basename: basename(path, ".sol"), - })); + const existingContracts = getExistingContracts(srcDir); // Load the config const mudConfig = args.config ?? ((await loadConfig(args.configPath)) as StoreConfig & WorldConfig); diff --git a/packages/cli/src/utils/deploy.ts b/packages/cli/src/utils/deploy.ts index 0da123788e..018f38effe 100644 --- a/packages/cli/src/utils/deploy.ts +++ b/packages/cli/src/utils/deploy.ts @@ -12,7 +12,6 @@ import { StoreConfig } from "@latticexyz/store"; import { resolveAbiOrUserType } from "@latticexyz/store/codegen"; import { WorldConfig, resolveWorldConfig } from "@latticexyz/world"; import { IBaseWorld } from "@latticexyz/world/types/ethers-contracts/IBaseWorld"; - import WorldData from "@latticexyz/world/abi/World.sol/World.json" assert { type: "json" }; import IBaseWorldData from "@latticexyz/world/abi/IBaseWorld.sol/IBaseWorld.json" assert { type: "json" }; import CoreModuleData from "@latticexyz/world/abi/CoreModule.sol/CoreModule.json" assert { type: "json" }; @@ -39,10 +38,10 @@ export interface DeploymentInfo { export async function deploy( mudConfig: StoreConfig & WorldConfig, - existingContracts: string[], + existingContractNames: string[], deployConfig: DeployConfig ): Promise<DeploymentInfo> { - const resolvedConfig = resolveWorldConfig(mudConfig, existingContracts); + const resolvedConfig = resolveWorldConfig(mudConfig, existingContractNames); const startTime = Date.now(); const { worldContractName, namespace, postDeployScript } = mudConfig; diff --git a/packages/cli/src/utils/deployHandler.ts b/packages/cli/src/utils/deployHandler.ts index b61c56d3e7..b3a57cf7fb 100644 --- a/packages/cli/src/utils/deployHandler.ts +++ b/packages/cli/src/utils/deployHandler.ts @@ -1,6 +1,5 @@ import chalk from "chalk"; -import glob from "glob"; -import path, { basename } from "path"; +import path from "path"; import { MUDError } from "@latticexyz/common/errors"; import { loadConfig } from "@latticexyz/config/node"; import { StoreConfig } from "@latticexyz/store"; @@ -9,6 +8,7 @@ import { deploy } from "../utils/deploy"; import { cast, forge, getRpcUrl, getSrcDirectory } from "@latticexyz/common/foundry"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { getChainId } from "../utils/getChainId"; +import { getExistingContracts } from "./getExistingContracts"; export type DeployOptions = { configPath?: string; @@ -27,7 +27,7 @@ export type DeployOptions = { }; export async function deployHandler(args: DeployOptions) { - args.profile = args.profile ?? process.env.FOUNDRY_PROFILE; + args.profile ??= process.env.FOUNDRY_PROFILE; const { configPath, printConfig, profile, clean, skipBuild } = args; const rpc = args.rpc ?? (await getRpcUrl(profile)); @@ -44,10 +44,7 @@ export async function deployHandler(args: DeployOptions) { // Get a list of all contract names const srcDir = args?.srcDir ?? (await getSrcDirectory()); - const existingContracts = glob - .sync(`${srcDir}/**/*.sol`) - // Get the basename of the file - .map((path) => basename(path, ".sol")); + const existingContractNames = getExistingContracts(srcDir).map(({ basename }) => basename); // Load the config const mudConfig = (await loadConfig(configPath)) as StoreConfig & WorldConfig; @@ -61,7 +58,7 @@ export async function deployHandler(args: DeployOptions) { Run 'echo "PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" > .env' in your contracts directory to use the default anvil private key.` ); - const deploymentInfo = await deploy(mudConfig, existingContracts, { ...args, rpc, privateKey }); + const deploymentInfo = await deploy(mudConfig, existingContractNames, { ...args, rpc, privateKey }); if (args.saveDeployment) { // Write deployment result to file (latest and timestamp) diff --git a/packages/cli/src/utils/getExistingContracts.ts b/packages/cli/src/utils/getExistingContracts.ts new file mode 100644 index 0000000000..ac3f67d982 --- /dev/null +++ b/packages/cli/src/utils/getExistingContracts.ts @@ -0,0 +1,12 @@ +import glob from "glob"; +import { basename } from "path"; + +/** + * Get a list of all contract paths/names within the provided src directory + */ +export function getExistingContracts(srcDir: string) { + return glob.sync(`${srcDir}/**/*.sol`).map((path) => ({ + path, + basename: basename(path, ".sol"), + })); +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index b1824ac827..3ac405bcf3 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -1,5 +1,7 @@ -export * from "./errors"; export * from "./deploy"; export * from "./deployHandler"; -export * from "./worldtypes"; +export * from "./errors"; +export * from "./getChainId"; +export * from "./getExistingContracts"; export * from "./printMUD"; +export * from "./worldtypes"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ec5832cc1..0ad6976f05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@latticexyz/store': specifier: workspace:* version: link:../store + '@latticexyz/utils': + specifier: workspace:* + version: link:../utils '@latticexyz/world': specifier: workspace:* version: link:../world