From 76055f45c60b47b7579b3f977e6152c8aecd31d1 Mon Sep 17 00:00:00 2001 From: John Grant Date: Fri, 15 Sep 2023 15:35:53 +0100 Subject: [PATCH] refactor(cli): split up deploy into modules (#1384) Co-authored-by: Kevin Ingersoll Co-authored-by: alvrs --- packages/cli/src/commands/deploy.ts | 2 +- packages/cli/src/commands/dev-contracts.ts | 4 +- packages/cli/src/commands/test.ts | 2 +- packages/cli/src/commands/trace.ts | 3 +- packages/cli/src/commands/worldgen.ts | 2 +- packages/cli/src/utils/deploy.ts | 738 +++++------------- packages/cli/src/utils/deployHandler.ts | 2 +- packages/cli/src/utils/index.ts | 6 - packages/cli/src/utils/modules/constants.ts | 23 + .../utils/modules/getInstallModuleCallData.ts | 27 + .../cli/src/utils/modules/getUserModules.ts | 5 + packages/cli/src/utils/modules/types.ts | 14 + .../utils/systems/getGrantAccessCallData.ts | 29 + .../getRegisterFunctionSelectorsCallData.ts | 68 ++ .../systems/getRegisterSystemCallData.ts | 17 + packages/cli/src/utils/systems/types.ts | 14 + packages/cli/src/utils/systems/utils.ts | 50 ++ .../utils/tables/getRegisterTableCallData.ts | 40 + packages/cli/src/utils/tables/getTableIds.ts | 21 + packages/cli/src/utils/tables/types.ts | 12 + packages/cli/src/utils/utils/confirmNonce.ts | 24 + .../cli/src/utils/utils/deployContract.ts | 33 + packages/cli/src/utils/utils/fastTxExecute.ts | 56 ++ .../cli/src/utils/{ => utils}/getChainId.ts | 0 .../cli/src/utils/utils/getContractData.ts | 29 + packages/cli/src/utils/utils/postDeploy.ts | 25 + .../src/utils/utils/setInternalFeePerGas.ts | 49 ++ packages/cli/src/utils/utils/toBytes16.ts | 16 + packages/cli/src/utils/utils/types.ts | 21 + packages/cli/src/utils/world.ts | 28 + .../config/src/library/dynamicResolution.ts | 4 +- 31 files changed, 795 insertions(+), 569 deletions(-) delete mode 100644 packages/cli/src/utils/index.ts create mode 100644 packages/cli/src/utils/modules/constants.ts create mode 100644 packages/cli/src/utils/modules/getInstallModuleCallData.ts create mode 100644 packages/cli/src/utils/modules/getUserModules.ts create mode 100644 packages/cli/src/utils/modules/types.ts create mode 100644 packages/cli/src/utils/systems/getGrantAccessCallData.ts create mode 100644 packages/cli/src/utils/systems/getRegisterFunctionSelectorsCallData.ts create mode 100644 packages/cli/src/utils/systems/getRegisterSystemCallData.ts create mode 100644 packages/cli/src/utils/systems/types.ts create mode 100644 packages/cli/src/utils/systems/utils.ts create mode 100644 packages/cli/src/utils/tables/getRegisterTableCallData.ts create mode 100644 packages/cli/src/utils/tables/getTableIds.ts create mode 100644 packages/cli/src/utils/tables/types.ts create mode 100644 packages/cli/src/utils/utils/confirmNonce.ts create mode 100644 packages/cli/src/utils/utils/deployContract.ts create mode 100644 packages/cli/src/utils/utils/fastTxExecute.ts rename packages/cli/src/utils/{ => utils}/getChainId.ts (100%) create mode 100644 packages/cli/src/utils/utils/getContractData.ts create mode 100644 packages/cli/src/utils/utils/postDeploy.ts create mode 100644 packages/cli/src/utils/utils/setInternalFeePerGas.ts create mode 100644 packages/cli/src/utils/utils/toBytes16.ts create mode 100644 packages/cli/src/utils/utils/types.ts create mode 100644 packages/cli/src/utils/world.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index f32f900694..1794517e2a 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -1,6 +1,6 @@ import type { CommandModule, Options } from "yargs"; import { logError } from "../utils/errors"; -import { deployHandler, DeployOptions } from "../utils"; +import { DeployOptions, deployHandler } from "../utils/deployHandler"; export const yDeployOptions = { configPath: { type: "string", desc: "Path to the config file" }, diff --git a/packages/cli/src/commands/dev-contracts.ts b/packages/cli/src/commands/dev-contracts.ts index 036cf2f820..145e78fd2e 100644 --- a/packages/cli/src/commands/dev-contracts.ts +++ b/packages/cli/src/commands/dev-contracts.ts @@ -9,10 +9,12 @@ import path from "path"; import { debounce } from "throttle-debounce"; import { worldgenHandler } from "./worldgen"; import { WorldConfig } from "@latticexyz/world"; -import { deployHandler, logError, printMUD } from "../utils"; import { homedir } from "os"; import { rmSync } from "fs"; import { execa } from "execa"; +import { logError } from "../utils/errors"; +import { deployHandler } from "../utils/deployHandler"; +import { printMUD } from "../utils/printMUD"; type Options = { rpc?: string; diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index 938c685f8a..ab0465cb29 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -3,7 +3,7 @@ import { anvil, forge, getRpcUrl } from "@latticexyz/common/foundry"; import chalk from "chalk"; import { rmSync, writeFileSync } from "fs"; import { yDeployOptions } from "./deploy"; -import { deployHandler, DeployOptions } from "../utils"; +import { DeployOptions, deployHandler } from "../utils/deployHandler"; type Options = DeployOptions & { port?: number; worldAddress?: string; forgeOptions?: string }; diff --git a/packages/cli/src/commands/trace.ts b/packages/cli/src/commands/trace.ts index e21ff7ba90..32ea227735 100644 --- a/packages/cli/src/commands/trace.ts +++ b/packages/cli/src/commands/trace.ts @@ -10,7 +10,8 @@ import { resolveWorldConfig, WorldConfig } from "@latticexyz/world"; import IBaseWorldAbi from "@latticexyz/world/out/IBaseWorld.sol/IBaseWorld.abi.json" assert { type: "json" }; import worldConfig from "@latticexyz/world/mud.config.js"; import { tableIdToHex } from "@latticexyz/common"; -import { getChainId, getExistingContracts } from "../utils"; +import { getExistingContracts } from "../utils/getExistingContracts"; +import { getChainId } from "../utils/utils/getChainId"; // TODO account for multiple namespaces (https://github.com/latticexyz/mud/issues/994) const systemsTableId = tableIdToHex(worldConfig.namespace, worldConfig.tables.Systems.name); diff --git a/packages/cli/src/commands/worldgen.ts b/packages/cli/src/commands/worldgen.ts index e43728ced3..63cb98291f 100644 --- a/packages/cli/src/commands/worldgen.ts +++ b/packages/cli/src/commands/worldgen.ts @@ -6,7 +6,7 @@ import { worldgen } from "@latticexyz/world/node"; import { getSrcDirectory } from "@latticexyz/common/foundry"; import path from "path"; import { rmSync } from "fs"; -import { getExistingContracts } from "../utils"; +import { getExistingContracts } from "../utils/getExistingContracts"; type Options = { configPath?: string; diff --git a/packages/cli/src/utils/deploy.ts b/packages/cli/src/utils/deploy.ts index 5ac0bc5bf3..86cc394925 100644 --- a/packages/cli/src/utils/deploy.ts +++ b/packages/cli/src/utils/deploy.ts @@ -1,24 +1,27 @@ -import { existsSync, readFileSync } from "fs"; -import path from "path"; import chalk from "chalk"; -import { BigNumber, ContractInterface, ethers } from "ethers"; -import { defaultAbiCoder as abi, Fragment, ParamType } from "ethers/lib/utils.js"; - -import { getOutDirectory, getScriptDirectory, cast, forge } from "@latticexyz/common/foundry"; -import { resolveWithContext } from "@latticexyz/config"; -import { MUDError } from "@latticexyz/common/errors"; -import { encodeSchema, getStaticByteLength } from "@latticexyz/schema-type/deprecated"; +import { ethers } from "ethers"; +import { getOutDirectory, cast } from "@latticexyz/common/foundry"; import { StoreConfig } from "@latticexyz/store"; -import { resolveAbiOrUserType } from "@latticexyz/store/codegen"; import { WorldConfig, resolveWorldConfig } from "@latticexyz/world"; +import { deployWorldContract } from "./world"; import IBaseWorldAbi from "@latticexyz/world/out/IBaseWorld.sol/IBaseWorld.abi.json" assert { type: "json" }; -import WorldData from "@latticexyz/world/out/World.sol/World.json" assert { type: "json" }; import CoreModuleData from "@latticexyz/world/out/CoreModule.sol/CoreModule.json" assert { type: "json" }; -import KeysWithValueModuleData from "@latticexyz/world/out/KeysWithValueModule.sol/KeysWithValueModule.json" assert { type: "json" }; -import KeysInTableModuleData from "@latticexyz/world/out/KeysInTableModule.sol/KeysInTableModule.json" assert { type: "json" }; -import UniqueEntityModuleData from "@latticexyz/world/out/UniqueEntityModule.sol/UniqueEntityModule.json" assert { type: "json" }; -import { tableIdToHex } from "@latticexyz/common"; -import { fieldLayoutToHex } from "@latticexyz/protocol-parser"; +import { defaultModuleContracts } from "./modules/constants"; +import { getInstallModuleCallData } from "./modules/getInstallModuleCallData"; +import { getUserModules } from "./modules/getUserModules"; +import { getGrantAccessCallData } from "./systems/getGrantAccessCallData"; +import { getRegisterFunctionSelectorsCallData } from "./systems/getRegisterFunctionSelectorsCallData"; +import { getRegisterSystemCallData } from "./systems/getRegisterSystemCallData"; +import { getRegisterTableCallData } from "./tables/getRegisterTableCallData"; +import { getTableIds } from "./tables/getTableIds"; +import { confirmNonce } from "./utils/confirmNonce"; +import { deployContract } from "./utils/deployContract"; +import { fastTxExecute } from "./utils/fastTxExecute"; +import { getContractData } from "./utils/getContractData"; +import { postDeploy } from "./utils/postDeploy"; +import { setInternalFeePerGas } from "./utils/setInternalFeePerGas"; +import { toBytes16 } from "./utils/toBytes16"; +import { ContractCode } from "./utils/types"; export interface DeployConfig { profile?: string; @@ -41,580 +44,205 @@ export async function deploy( existingContractNames: string[], deployConfig: DeployConfig ): Promise { - const resolvedConfig = resolveWorldConfig(mudConfig, existingContractNames); - const startTime = Date.now(); - const { worldContractName, namespace, postDeployScript } = mudConfig; const { profile, rpc, privateKey, priorityFeeMultiplier, debug, worldAddress, disableTxWait, pollInterval } = deployConfig; + const resolvedConfig = resolveWorldConfig(mudConfig, existingContractNames); const forgeOutDirectory = await getOutDirectory(profile); - const baseSystemFunctionNames = (await loadFunctionSignatures("System")).map((item) => item.functionName); - // Set up signer for deployment const provider = new ethers.providers.StaticJsonRpcProvider(rpc); provider.pollingInterval = pollInterval; const signer = new ethers.Wallet(privateKey, provider); console.log("Deploying from", signer.address); - // Manual nonce handling to allow for faster sending of transactions without waiting for previous transactions let nonce = await signer.getTransactionCount(); console.log("Initial nonce", nonce); - // Compute maxFeePerGas and maxPriorityFeePerGas like ethers, but allow for a multiplier to allow replacing pending transactions - let maxPriorityFeePerGas: number | undefined; - let maxFeePerGas: BigNumber | undefined; - let gasPrice: BigNumber | undefined; - - await setInternalFeePerGas(priorityFeeMultiplier); + const txParams = await setInternalFeePerGas(signer, priorityFeeMultiplier); - // Catch all to await any promises before exiting the script - let promises: Promise[] = []; + const txConfig = { + ...txParams, + signer, + debug: Boolean(debug), + disableTxWait, + confirmations: disableTxWait ? 0 : 1, + }; // Get block number before deploying const blockNumber = Number(await cast(["block-number", "--rpc-url", rpc], { profile })); console.log("Start deployment at block", blockNumber); - // Deploy World - const worldPromise = { - World: worldAddress - ? Promise.resolve(worldAddress) - : worldContractName - ? deployContractByName(worldContractName, disableTxWait) - : deployContract(IBaseWorldAbi, WorldData.bytecode, disableTxWait, "World"), - }; - - // Deploy Systems - const systemPromises = Object.keys(resolvedConfig.systems).reduce>>( - (acc, systemName) => { - acc[systemName] = deployContractByName(systemName, disableTxWait); - return acc; - }, - {} - ); - - // Deploy default World modules - const defaultModules: Record> = { - // TODO: these only need to be deployed once per chain, add a check if they exist already - CoreModule: deployContract(CoreModuleData.abi, CoreModuleData.bytecode, disableTxWait, "CoreModule"), - KeysWithValueModule: deployContract( - KeysWithValueModuleData.abi, - KeysWithValueModuleData.bytecode, - disableTxWait, - "KeysWithValueModule" - ), - KeysInTableModule: deployContract( - KeysInTableModuleData.abi, - KeysInTableModuleData.bytecode, - disableTxWait, - "KeysInTableModule" - ), - UniqueEntityModule: deployContract( - UniqueEntityModuleData.abi, - UniqueEntityModuleData.bytecode, - disableTxWait, - "UniqueEntityModule" - ), - }; - - // Deploy user Modules - const modulePromises = mudConfig.modules - .filter((module) => !defaultModules[module.name]) // Only deploy user modules here, not default modules - .reduce>>((acc, module) => { - acc[module.name] = deployContractByName(module.name, disableTxWait); - return acc; - }, defaultModules); - - // Combine all contracts into one object - const contractPromises: Record> = { ...worldPromise, ...systemPromises, ...modulePromises }; - - // Create World contract instance from deployed address - const WorldContract = new ethers.Contract(await contractPromises.World, IBaseWorldAbi, signer); - - const confirmations = disableTxWait ? 0 : 1; - - // Install core Modules - if (!worldAddress) { - console.log(chalk.blue("Installing core World modules")); - await fastTxExecute(WorldContract, "initialize", [await modulePromises.CoreModule], confirmations); - console.log(chalk.green("Installed core World modules")); - } - - // Register namespace - if (namespace) await fastTxExecute(WorldContract, "registerNamespace", [toBytes16(namespace)], confirmations); - - // Register tables - const tableIds: { [tableName: string]: Uint8Array } = {}; - promises = [ - ...promises, - ...Object.entries(mudConfig.tables).map(async ([tableName, { name, valueSchema, keySchema }]) => { - console.log(chalk.blue(`Registering table ${tableName} at ${namespace}/${name}`)); - - // Store the tableId for later use - tableIds[tableName] = toResourceSelector(namespace, name); - - // Register table - const schemaTypes = Object.values(valueSchema).map((abiOrUserType) => { - const { schemaType } = resolveAbiOrUserType(abiOrUserType, mudConfig); - return schemaType; + // Deploy the World contract. Non-blocking. + const worldPromise: Promise = worldAddress + ? Promise.resolve(worldAddress) + : deployWorldContract({ + ...txConfig, + nonce: nonce++, + worldContractName: mudConfig.worldContractName, + forgeOutDirectory, }); - const schemaTypeLengths = schemaTypes.map((schemaType) => getStaticByteLength(schemaType)); - const fieldLayout = { - staticFieldLengths: schemaTypeLengths.filter((schemaTypeLength) => schemaTypeLength > 0), - numDynamicFields: schemaTypeLengths.filter((schemaTypeLength) => schemaTypeLength === 0).length, - }; - - const keyTypes = Object.values(keySchema).map((abiOrUserType) => { - const { schemaType } = resolveAbiOrUserType(abiOrUserType, mudConfig); - return schemaType; - }); - - await fastTxExecute( - WorldContract, - "registerTable", - [ - tableIdToHex(namespace, name), - fieldLayoutToHex(fieldLayout), - encodeSchema(keyTypes), - encodeSchema(schemaTypes), - Object.keys(keySchema), - Object.keys(valueSchema), - ], - confirmations - ); - - console.log(chalk.green(`Registered table ${tableName} at ${name}`)); - }), - ]; - - // Register systems (using forEach instead of for..of to avoid blocking on async calls) - promises = [ - ...promises, - ...Object.entries(resolvedConfig.systems).map( - async ([systemName, { name, openAccess, registerFunctionSelectors }]) => { - // Register system at route - console.log(chalk.blue(`Registering system ${systemName} at ${namespace}/${name}`)); - await fastTxExecute( - WorldContract, - "registerSystem", - [tableIdToHex(namespace, name), await contractPromises[systemName], openAccess], - confirmations - ); - console.log(chalk.green(`Registered system ${systemName} at ${namespace}/${name}`)); - - // Register function selectors for the system - if (registerFunctionSelectors) { - const functionSignatures: FunctionSignature[] = await loadFunctionSignatures(systemName); - const isRoot = namespace === ""; - // Using Promise.all to avoid blocking on async calls - await Promise.all( - functionSignatures.map(async ({ functionName, functionArgs }) => { - const functionSignature = isRoot - ? functionName + functionArgs - : `${namespace}_${name}_${functionName}${functionArgs}`; - - console.log(chalk.blue(`Registering function "${functionSignature}"`)); - if (isRoot) { - const worldFunctionSelector = toFunctionSelector( - functionSignature === "" - ? { functionName: systemName, functionArgs } // Register the system's fallback function as `()` - : { functionName, functionArgs } - ); - const systemFunctionSelector = toFunctionSelector({ functionName, functionArgs }); - await fastTxExecute( - WorldContract, - "registerRootFunctionSelector", - [tableIdToHex(namespace, name), worldFunctionSelector, systemFunctionSelector], - confirmations - ); - } else { - await fastTxExecute( - WorldContract, - "registerFunctionSelector", - [tableIdToHex(namespace, name), functionName, functionArgs], - confirmations - ); - } - console.log(chalk.green(`Registered function "${functionSignature}"`)); - }) - ); - } - } - ), - ]; - - // Wait for resources to be registered before granting access to them - await Promise.all(promises); // ---------------------------------------------------------------------------------------------- - promises = []; - - // Grant access to systems - for (const [systemName, { name, accessListAddresses, accessListSystems }] of Object.entries(resolvedConfig.systems)) { - const resourceSelector = `${namespace}/${name}`; - - // Grant access to addresses - promises = [ - ...promises, - ...accessListAddresses.map(async (address) => { - console.log(chalk.blue(`Grant ${address} access to ${systemName} (${resourceSelector})`)); - await fastTxExecute(WorldContract, "grantAccess", [tableIdToHex(namespace, name), address], confirmations); - console.log(chalk.green(`Granted ${address} access to ${systemName} (${namespace}/${name})`)); - }), - ]; - - // Grant access to other systems - promises = [ - ...promises, - ...accessListSystems.map(async (granteeSystem) => { - console.log(chalk.blue(`Grant ${granteeSystem} access to ${systemName} (${resourceSelector})`)); - await fastTxExecute( - WorldContract, - "grantAccess", - [tableIdToHex(namespace, name), await contractPromises[granteeSystem]], - confirmations - ); - console.log(chalk.green(`Granted ${granteeSystem} access to ${systemName} (${resourceSelector})`)); - }), - ]; - } - - // Wait for access to be granted before installing modules - await Promise.all(promises); // ---------------------------------------------------------------------------------------------- - promises = []; - - // Install modules - promises = [ - ...promises, - ...mudConfig.modules.map(async (module) => { - console.log(chalk.blue(`Installing${module.root ? " root " : " "}module ${module.name}`)); - // Resolve arguments - const resolvedArgs = await Promise.all( - module.args.map((arg) => resolveWithContext(arg, { tableIds, systemAddresses: contractPromises })) - ); - const values = resolvedArgs.map((arg) => arg.value); - const types = resolvedArgs.map((arg) => arg.type); - const moduleAddress = await contractPromises[module.name]; - if (!moduleAddress) throw new Error(`Module ${module.name} not found`); - - // Send transaction to install module - await fastTxExecute( - WorldContract, - module.root ? "installRootModule" : "installModule", - [moduleAddress, abi.encode(types, values)], - confirmations - ); - - console.log(chalk.green(`Installed${module.root ? " root " : " "}module ${module.name}`)); - }), + // Filters any default modules from config + const userModules = getUserModules(defaultModuleContracts, mudConfig.modules); + const userModuleContracts = Object.keys(userModules).map((name) => { + const { abi, bytecode } = getContractData(name, forgeOutDirectory); + return { + name, + abi, + bytecode, + } as ContractCode; + }); + + const systemContracts = Object.keys(resolvedConfig.systems).map((name) => { + const { abi, bytecode } = getContractData(name, forgeOutDirectory); + return { + name, + abi, + bytecode, + } as ContractCode; + }); + + const contracts: ContractCode[] = [ + { + name: "CoreModule", + abi: CoreModuleData.abi, + bytecode: CoreModuleData.bytecode, + }, + ...defaultModuleContracts, + ...userModuleContracts, + ...systemContracts, ]; - // Await all promises before executing PostDeploy script - await Promise.all(promises); // ---------------------------------------------------------------------------------------------- - - // Confirm the current nonce is the expected nonce to make sure all transactions have been included - let remoteNonce = await signer.getTransactionCount(); - let retryCount = 0; - const maxRetries = 100; - while (remoteNonce !== nonce && retryCount < maxRetries) { - console.log( - chalk.gray( - `Waiting for transactions to be included before executing ${postDeployScript} (local nonce: ${nonce}, remote nonce: ${remoteNonce}, retry number ${retryCount}/${maxRetries})` - ) - ); - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - retryCount++; - remoteNonce = await signer.getTransactionCount(); - } - if (remoteNonce !== nonce) { - throw new MUDError( - "Remote nonce doesn't match local nonce, indicating that not all deploy transactions were included." - ); - } - - promises = []; - - // Execute postDeploy forge script - const postDeployPath = path.join(await getScriptDirectory(), postDeployScript + ".s.sol"); - if (existsSync(postDeployPath)) { - console.log(chalk.blue(`Executing post deploy script at ${postDeployPath}`)); - await forge( - [ - "script", - postDeployScript, - "--sig", - "run(address)", - await contractPromises.World, - "--broadcast", - "--rpc-url", - rpc, - "-vvv", - ], - { - profile, - } - ); - } else { - console.log(`No script at ${postDeployPath}, skipping post deploy hook`); - } - - console.log(chalk.green("Deployment completed in", (Date.now() - startTime) / 1000, "seconds")); - - return { worldAddress: await contractPromises.World, blockNumber }; - - // ------------------- INTERNAL FUNCTIONS ------------------- - // (Inlined to avoid having to pass around nonce, signer and forgeOutDir) - - /** - * Deploy a contract and return the address - * @param contractName Name of the contract to deploy (must exist in the file system) - * @param disableTxWait Disable waiting for contract deployment - * @returns Address of the deployed contract - */ - async function deployContractByName(contractName: string, disableTxWait: boolean): Promise { - console.log(chalk.blue("Deploying", contractName)); - - const { abi, bytecode } = await getContractData(contractName); - return deployContract(abi, bytecode, disableTxWait, contractName); - } - - /** - * Deploy a contract and return the address - * @param abi The contract interface - * @param bytecode The contract bytecode - * @param disableTxWait Disable waiting for contract deployment - * @param contractName The contract name (optional, used for logs) - * @param retryCount - * @returns Address of the deployed contract - */ - async function deployContract( - abi: ContractInterface, - bytecode: string | { object: string }, - disableTxWait: boolean, - contractName?: string, - retryCount = 0 - ): Promise { - try { - const factory = new ethers.ContractFactory(abi, bytecode, signer); - console.log(chalk.gray(`executing deployment of ${contractName} with nonce ${nonce}`)); - const deployPromise = factory - .deploy({ - nonce: nonce++, - maxPriorityFeePerGas, - maxFeePerGas, - gasPrice, - }) - .then((c) => (disableTxWait ? c : c.deployed())); - - promises.push(deployPromise); - const { address } = await deployPromise; - - console.log(chalk.green("Deployed", contractName, "to", address)); - return address; - } catch (error: any) { - if (debug) console.error(error); - if (retryCount === 0 && error?.message.includes("transaction already imported")) { - // If the deployment failed because the transaction was already imported, - // retry with a higher priority fee - setInternalFeePerGas(priorityFeeMultiplier * 1.1); - return deployContract(abi, bytecode, disableTxWait, contractName, retryCount++); - } else if (error?.message.includes("invalid bytecode")) { - throw new MUDError( - `Error deploying ${contractName}: invalid bytecode. Note that linking of public libraries is not supported yet, make sure none of your libraries use "external" functions.` - ); - } else if (error?.message.includes("CreateContractLimit")) { - throw new MUDError(`Error deploying ${contractName}: CreateContractLimit exceeded.`); - } else throw error; - } - } - - /** - * Deploy a contract and return the address - * @param contractName Name of the contract to deploy (must exist in the file system) - * @returns Address of the deployed contract - * - * NOTE: Forge deploy seems to be slightly slower than ethers - * (probably due to the extra overhead spawning a child process to run forge), - * so we mostly use ethersDeployContract here. - * However, for contracts not in the user directory (eg. the vanilla World contract), - * using forge is more convenient because it automatically finds the contract in the @latticexyz/world package. - */ - // async function forgeDeployContract(contractName: string): Promise { - // console.log(chalk.blue("Deploying", contractName)); - - // const { deployedTo } = JSON.parse( - // await forge( - // ["create", contractName, "--rpc-url", rpc, "--private-key", privateKey, "--json", "--nonce", String(nonce++)], - // { profile, silent: true } - // ) - // ); - // return deployedTo; - // } - - async function loadFunctionSignatures(contractName: string): Promise { - const { abi } = await getContractData(contractName); - - const functionSelectors = abi - .filter((item) => ["fallback", "function"].includes(item.type)) - .map((item) => { - if (item.type === "fallback") return { functionName: "", functionArgs: "" }; - - return { - functionName: item.name, - functionArgs: parseComponents(item.inputs), - }; - }); + // Deploy the System and Module contracts + const deployedContracts = contracts.reduce>>((acc, contract) => { + acc[contract.name] = deployContract({ + ...txConfig, + nonce: nonce++, + contract, + }); + return acc; + }, {}); - return contractName === "System" - ? functionSelectors - : // Filter out functions that are defined in the base System contract for all non-base systems - functionSelectors.filter((item) => !baseSystemFunctionNames.includes(item.functionName)); - } + // Wait for world to be deployed + const deployedWorldAddress = await worldPromise; + const worldContract = new ethers.Contract(deployedWorldAddress, IBaseWorldAbi); - /** - * Recursively turn (nested) structs in signatures into tuples - */ - function parseComponents(params: ParamType[]): string { - const components = params.map((param) => { - const tupleMatch = param.type.match(/tuple(.*)/); - if (tupleMatch) { - // there can be arrays of tuples, - // `tupleMatch[1]` preserves the array brackets (or is empty string for non-arrays) - return parseComponents(param.components) + tupleMatch[1]; - } else { - return param.type; - } + // If an existing World is passed assume its coreModule is already installed - blocking to install if not + if (!worldAddress) { + console.log(chalk.blue("Installing CoreModule")); + await fastTxExecute({ + ...txConfig, + nonce: nonce++, + contract: worldContract, + func: "initialize", + args: [await deployedContracts["CoreModule"]], }); - return `(${components})`; - } - - /** - * Only await gas estimation (for speed), only execute if gas estimation succeeds (for safety) - */ - async function fastTxExecute( - contract: C, - func: F, - args: Parameters, - confirmations = 1, - retryCount = 0 - ): Promise>["wait"]>>> { - const functionName = `${func as string}(${args.map((arg) => `'${arg}'`).join(",")})`; - try { - const gasLimit = await contract.estimateGas[func].apply(null, args); - console.log(chalk.gray(`executing transaction: ${functionName} with nonce ${nonce}`)); - const txPromise = contract[func] - .apply(null, [...args, { gasLimit, nonce: nonce++, maxPriorityFeePerGas, maxFeePerGas, gasPrice }]) - .then((tx: any) => (confirmations === 0 ? tx : tx.wait(confirmations))); - promises.push(txPromise); - return txPromise; - } catch (error: any) { - if (debug) console.error(error); - if (retryCount === 0 && error?.message.includes("transaction already imported")) { - // If the deployment failed because the transaction was already imported, - // retry with a higher priority fee - setInternalFeePerGas(priorityFeeMultiplier * 1.1); - return fastTxExecute(contract, func, args, confirmations, retryCount++); - } else throw new MUDError(`Gas estimation error for ${functionName}: ${error?.reason}`); - } + console.log(chalk.green("Installed CoreModule")); } - /** - * Load the contract's abi and bytecode from the file system - * @param contractName: Name of the contract to load - */ - async function getContractData(contractName: string): Promise<{ bytecode: string; abi: Fragment[] }> { - let data: any; - const contractDataPath = path.join(forgeOutDirectory, contractName + ".sol", contractName + ".json"); - try { - data = JSON.parse(readFileSync(contractDataPath, "utf8")); - } catch (error: any) { - throw new MUDError(`Error reading file at ${contractDataPath}`); - } - - const bytecode = data?.bytecode?.object; - if (!bytecode) throw new MUDError(`No bytecode found in ${contractDataPath}`); - - const abi = data?.abi; - if (!abi) throw new MUDError(`No ABI found in ${contractDataPath}`); - - return { abi, bytecode }; + if (mudConfig.namespace) { + console.log(chalk.blue("Registering Namespace")); + await fastTxExecute({ + ...txConfig, + nonce: nonce++, + contract: worldContract, + func: "registerNamespace", + args: [toBytes16(mudConfig.namespace)], + }); + console.log(chalk.green("Namespace registered")); } - /** - * Set the maxFeePerGas and maxPriorityFeePerGas based on the current base fee and the given multiplier. - * The multiplier is used to allow replacing pending transactions. - * @param multiplier Multiplier to apply to the base fee - */ - async function setInternalFeePerGas(multiplier: number) { - // Compute maxFeePerGas and maxPriorityFeePerGas like ethers, but allow for a multiplier to allow replacing pending transactions - const feeData = await provider.getFeeData(); - - if (feeData.lastBaseFeePerGas) { - if (!feeData.lastBaseFeePerGas.eq(0) && (await signer.getBalance()).eq(0)) { - throw new MUDError(`Attempting to deploy to a chain with non-zero base fee with an account that has no balance. - If you're deploying to the Lattice testnet, you can fund your account by running 'pnpm mud faucet --address ${await signer.getAddress()}'`); - } - - // Set the priority fee to 0 for development chains with no base fee, to allow transactions from unfunded wallets - maxPriorityFeePerGas = feeData.lastBaseFeePerGas.eq(0) ? 0 : Math.floor(1_500_000_000 * multiplier); - maxFeePerGas = feeData.lastBaseFeePerGas.mul(2).add(maxPriorityFeePerGas); - } else if (feeData.gasPrice) { - // Legacy chains with gasPrice instead of maxFeePerGas - if (!feeData.gasPrice.eq(0) && (await signer.getBalance()).eq(0)) { - throw new MUDError( - `Attempting to deploy to a chain with non-zero gas price with an account that has no balance.` - ); - } - - gasPrice = feeData.gasPrice; - } else { - throw new MUDError("Can not fetch fee data from RPC"); - } - } -} + const tableIds = getTableIds(mudConfig); + const registerTableCalls = Object.values(mudConfig.tables).map((table) => getRegisterTableCallData(table, mudConfig)); + + console.log(chalk.blue("Registering tables")); + await Promise.all( + registerTableCalls.map((call) => + fastTxExecute({ + ...txConfig, + nonce: nonce++, + contract: worldContract, + ...call, + }) + ) + ); + console.log(chalk.green(`Tables registered`)); + + console.log(chalk.blue("Registering Systems and Functions")); + const systemCalls = await Promise.all( + Object.entries(resolvedConfig.systems).map(([systemName, system]) => + getRegisterSystemCallData({ + systemContracts: deployedContracts, + systemName, + system, + namespace: mudConfig.namespace, + }) + ) + ); + const functionCalls = Object.entries(resolvedConfig.systems).flatMap(([systemName, system]) => + getRegisterFunctionSelectorsCallData({ + systemName, + system, + namespace: mudConfig.namespace, + forgeOutDirectory, + }) + ); + await Promise.all( + [...systemCalls, ...functionCalls].map((call) => + fastTxExecute({ + ...txConfig, + nonce: nonce++, + contract: worldContract, + ...call, + }) + ) + ); + console.log(chalk.green(`Systems and Functions registered`)); + + // Wait for System access to be granted before installing modules + const grantCalls = await getGrantAccessCallData({ + systems: Object.values(resolvedConfig.systems), + systemContracts: deployedContracts, + namespace: mudConfig.namespace, + }); + + console.log(chalk.blue("Granting Access")); + await Promise.all( + grantCalls.map((call) => + fastTxExecute({ + ...txConfig, + nonce: nonce++, + contract: worldContract, + ...call, + }) + ) + ); + console.log(chalk.green(`Access granted`)); -// TODO: use stringToBytes16 from utils as soon as utils are usable inside cli -// (see https://github.com/latticexyz/mud/issues/499) -function toBytes16(input: string) { - if (input.length > 16) throw new Error("String does not fit into 16 bytes"); + const moduleCalls = await Promise.all( + mudConfig.modules.map((m) => getInstallModuleCallData(deployedContracts, m, tableIds)) + ); - const result = new Uint8Array(16); - // Set ascii bytes - for (let i = 0; i < input.length; i++) { - result[i] = input.charCodeAt(i); - } - // Set the remaining bytes to 0 - for (let i = input.length; i < 16; i++) { - result[i] = 0; - } - return result; -} + console.log(chalk.blue("Installing User Modules")); + await Promise.all( + moduleCalls.map((call) => + fastTxExecute({ + ...txConfig, + nonce: nonce++, + contract: worldContract, + ...call, + }) + ) + ); + console.log(chalk.green(`User Modules Installed`)); -// TODO: use TableId from utils as soon as utils are usable inside cli -// (see https://github.com/latticexyz/mud/issues/499) -function toResourceSelector(namespace: string, file: string): Uint8Array { - const namespaceBytes = toBytes16(namespace); - const fileBytes = toBytes16(file); - const result = new Uint8Array(32); - result.set(namespaceBytes); - result.set(fileBytes, 16); - return result; -} + // Double check that all transactions have been included by confirming the current nonce is the expected nonce + await confirmNonce(signer, nonce, pollInterval); -interface FunctionSignature { - functionName: string; - functionArgs: string; -} + await postDeploy(mudConfig.postDeployScript, deployedWorldAddress, rpc, profile); -// TODO: move this to utils as soon as utils are usable inside cli -// (see https://github.com/latticexyz/mud/issues/499) -function toFunctionSelector({ functionName, functionArgs }: FunctionSignature): string { - const functionSignature = functionName + functionArgs; - if (functionSignature === "") return "0x"; - return sigHash(functionSignature); -} + console.log(chalk.green("Deployment completed in", (Date.now() - startTime) / 1000, "seconds")); -// TODO: move this to utils as soon as utils are usable inside cli -// (see https://github.com/latticexyz/mud/issues/499) -function sigHash(signature: string) { - return ethers.utils.hexDataSlice(ethers.utils.keccak256(ethers.utils.toUtf8Bytes(signature)), 0, 4); + return { worldAddress: deployedWorldAddress, blockNumber }; } diff --git a/packages/cli/src/utils/deployHandler.ts b/packages/cli/src/utils/deployHandler.ts index c3f10f9a07..213c6e5527 100644 --- a/packages/cli/src/utils/deployHandler.ts +++ b/packages/cli/src/utils/deployHandler.ts @@ -7,9 +7,9 @@ import { WorldConfig } from "@latticexyz/world"; import { deploy } from "../utils/deploy"; import { forge, getRpcUrl, getSrcDirectory } from "@latticexyz/common/foundry"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { getChainId } from "../utils/getChainId"; import { getExistingContracts } from "./getExistingContracts"; import { execa } from "execa"; +import { getChainId } from "./utils/getChainId"; export type DeployOptions = { configPath?: string; diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts deleted file mode 100644 index 679d60af95..0000000000 --- a/packages/cli/src/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./deploy"; -export * from "./deployHandler"; -export * from "./errors"; -export * from "./getChainId"; -export * from "./getExistingContracts"; -export * from "./printMUD"; diff --git a/packages/cli/src/utils/modules/constants.ts b/packages/cli/src/utils/modules/constants.ts new file mode 100644 index 0000000000..ac84680fa2 --- /dev/null +++ b/packages/cli/src/utils/modules/constants.ts @@ -0,0 +1,23 @@ +import KeysWithValueModuleData from "@latticexyz/world/out/KeysWithValueModule.sol/KeysWithValueModule.json" assert { type: "json" }; +import KeysInTableModuleData from "@latticexyz/world/out/KeysInTableModule.sol/KeysInTableModule.json" assert { type: "json" }; +import UniqueEntityModuleData from "@latticexyz/world/out/UniqueEntityModule.sol/UniqueEntityModule.json" assert { type: "json" }; +import { ContractCode } from "../utils/types"; + +// These modules are always deployed +export const defaultModuleContracts: ContractCode[] = [ + { + name: "KeysWithValueModule", + abi: KeysWithValueModuleData.abi, + bytecode: KeysWithValueModuleData.bytecode, + }, + { + name: "KeysInTableModule", + abi: KeysInTableModuleData.abi, + bytecode: KeysInTableModuleData.bytecode, + }, + { + name: "UniqueEntityModule", + abi: UniqueEntityModuleData.abi, + bytecode: UniqueEntityModuleData.bytecode, + }, +]; diff --git a/packages/cli/src/utils/modules/getInstallModuleCallData.ts b/packages/cli/src/utils/modules/getInstallModuleCallData.ts new file mode 100644 index 0000000000..ca9b592ca3 --- /dev/null +++ b/packages/cli/src/utils/modules/getInstallModuleCallData.ts @@ -0,0 +1,27 @@ +import { defaultAbiCoder } from "ethers/lib/utils.js"; +import { resolveWithContext } from "@latticexyz/config"; +import { Module } from "./types"; +import { CallData } from "../utils/types"; +import { TableIds } from "../tables/types"; + +export async function getInstallModuleCallData( + moduleContracts: Record>, + module: Module, + tableIds: TableIds +): Promise { + const moduleAddress = await moduleContracts[module.name]; + if (!moduleAddress) throw new Error(`Module ${module.name} not found`); + // Resolve arguments + const resolvedArgs = module.args.map((arg) => + resolveWithContext(arg, { + tableIds, + }) + ); + const values = resolvedArgs.map((arg) => arg.value); + const types = resolvedArgs.map((arg) => arg.type); + + return { + func: module.root ? "installRootModule" : "installModule", + args: [moduleAddress, defaultAbiCoder.encode(types, values)], + }; +} diff --git a/packages/cli/src/utils/modules/getUserModules.ts b/packages/cli/src/utils/modules/getUserModules.ts new file mode 100644 index 0000000000..f7c1aa1182 --- /dev/null +++ b/packages/cli/src/utils/modules/getUserModules.ts @@ -0,0 +1,5 @@ +import { Module } from "./types"; + +export function getUserModules(defaultModules: { name: string }[], configModules: Module[]): Omit[] { + return configModules.filter((module) => !defaultModules.some((m) => m.name === module.name)); +} diff --git a/packages/cli/src/utils/modules/types.ts b/packages/cli/src/utils/modules/types.ts new file mode 100644 index 0000000000..5647fc3c99 --- /dev/null +++ b/packages/cli/src/utils/modules/types.ts @@ -0,0 +1,14 @@ +export type Module = { + name: string; + root: boolean; + args: ( + | { + value: (string | number | Uint8Array) & (string | number | Uint8Array | undefined); + type: string; + } + | { + type: any; + input: string; + } + )[]; +}; diff --git a/packages/cli/src/utils/systems/getGrantAccessCallData.ts b/packages/cli/src/utils/systems/getGrantAccessCallData.ts new file mode 100644 index 0000000000..f9cd39a1b5 --- /dev/null +++ b/packages/cli/src/utils/systems/getGrantAccessCallData.ts @@ -0,0 +1,29 @@ +import { System } from "./types"; +import { CallData } from "../utils/types"; +import { tableIdToHex } from "@latticexyz/common"; + +export async function getGrantAccessCallData(input: { + systems: System[]; + systemContracts: Record>; + namespace: string; +}): Promise { + const { systems, namespace, systemContracts } = input; + const calls: CallData[] = []; + for (const { name, accessListAddresses, accessListSystems } of systems) { + // Grant access to addresses + accessListAddresses.map(async (address) => calls.push(getGrantSystemAccessCallData(name, namespace, address))); + + // Grant access to other systems + accessListSystems.map(async (granteeSystem) => + calls.push(getGrantSystemAccessCallData(name, namespace, await systemContracts[granteeSystem])) + ); + } + return calls; +} + +function getGrantSystemAccessCallData(name: string, namespace: string, address: string): CallData { + return { + func: "grantAccess", + args: [tableIdToHex(namespace, name), address], + }; +} diff --git a/packages/cli/src/utils/systems/getRegisterFunctionSelectorsCallData.ts b/packages/cli/src/utils/systems/getRegisterFunctionSelectorsCallData.ts new file mode 100644 index 0000000000..23545868fd --- /dev/null +++ b/packages/cli/src/utils/systems/getRegisterFunctionSelectorsCallData.ts @@ -0,0 +1,68 @@ +import { tableIdToHex } from "@latticexyz/common"; +import { System } from "./types"; +import { loadFunctionSignatures, toFunctionSelector } from "./utils"; +import { CallData } from "../utils/types"; + +export function getRegisterFunctionSelectorsCallData(input: { + systemName: string; + system: System; + namespace: string; + forgeOutDirectory: string; +}): CallData[] { + // Register system at route + const callData: CallData[] = []; + const { systemName, namespace, forgeOutDirectory, system } = input; + + if (system.registerFunctionSelectors) { + const baseSystemFunctionNames = loadFunctionSignatures("System", forgeOutDirectory).map((sig) => sig.functionName); + const functionSignatures = loadFunctionSignatures(systemName, forgeOutDirectory).filter( + (sig) => systemName === "System" || !baseSystemFunctionNames.includes(sig.functionName) + ); + const isRoot = namespace === ""; + for (const { functionName, functionArgs } of functionSignatures) { + callData.push( + getRegisterFunctionSelectorCallData({ + namespace, + name: system.name, + systemName, + functionName, + functionArgs, + isRoot, + }) + ); + } + } + return callData; +} + +function getRegisterFunctionSelectorCallData(input: { + namespace: string; + name: string; + systemName: string; + functionName: string; + functionArgs: string; + isRoot: boolean; +}): CallData { + const { namespace, name, systemName, functionName, functionArgs, isRoot } = input; + const functionSignature = isRoot + ? functionName + functionArgs + : `${namespace}_${name}_${functionName}${functionArgs}`; + + if (isRoot) { + const worldFunctionSelector = toFunctionSelector( + functionSignature === "" + ? { functionName: systemName, functionArgs } // Register the system's fallback function as `()` + : { functionName, functionArgs } + ); + const systemFunctionSelector = toFunctionSelector({ functionName, functionArgs }); + return { + func: "registerRootFunctionSelector", + args: [tableIdToHex(namespace, name), worldFunctionSelector, systemFunctionSelector], + }; + } else { + return { + func: "registerRootFunctionSelector", + args: [tableIdToHex(namespace, name), functionName, functionArgs], + }; + } +} diff --git a/packages/cli/src/utils/systems/getRegisterSystemCallData.ts b/packages/cli/src/utils/systems/getRegisterSystemCallData.ts new file mode 100644 index 0000000000..fa2d1c0e20 --- /dev/null +++ b/packages/cli/src/utils/systems/getRegisterSystemCallData.ts @@ -0,0 +1,17 @@ +import { tableIdToHex } from "@latticexyz/common"; +import { System } from "./types"; +import { CallData } from "../utils/types"; + +export async function getRegisterSystemCallData(input: { + systemContracts: Record>; + systemName: string; + system: System; + namespace: string; +}): Promise { + const { namespace, systemName, systemContracts, system } = input; + const systemAddress = await systemContracts[systemName]; + return { + func: "registerSystem", + args: [tableIdToHex(namespace, system.name), systemAddress, system.openAccess], + }; +} diff --git a/packages/cli/src/utils/systems/types.ts b/packages/cli/src/utils/systems/types.ts new file mode 100644 index 0000000000..6cdfa27a24 --- /dev/null +++ b/packages/cli/src/utils/systems/types.ts @@ -0,0 +1,14 @@ +export type System = { + name: string; + registerFunctionSelectors: boolean; + openAccess: boolean; + accessListAddresses: string[]; + accessListSystems: string[]; +}; + +export type SystemsConfig = Record; + +export interface FunctionSignature { + functionName: string; + functionArgs: string; +} diff --git a/packages/cli/src/utils/systems/utils.ts b/packages/cli/src/utils/systems/utils.ts new file mode 100644 index 0000000000..b5a9eb7bee --- /dev/null +++ b/packages/cli/src/utils/systems/utils.ts @@ -0,0 +1,50 @@ +import { ethers } from "ethers"; +import { ParamType } from "ethers/lib/utils.js"; +import { FunctionSignature } from "./types"; +import { getContractData } from "../utils/getContractData"; + +export function loadFunctionSignatures(contractName: string, forgeOutDirectory: string): FunctionSignature[] { + const { abi } = getContractData(contractName, forgeOutDirectory); + + return abi + .filter((item) => ["fallback", "function"].includes(item.type)) + .map((item) => { + if (item.type === "fallback") return { functionName: "", functionArgs: "" }; + + return { + functionName: item.name, + functionArgs: parseComponents(item.inputs), + }; + }); +} + +// TODO: move this to utils as soon as utils are usable inside cli +// (see https://github.com/latticexyz/mud/issues/499) +export function toFunctionSelector({ functionName, functionArgs }: FunctionSignature): string { + const functionSignature = functionName + functionArgs; + if (functionSignature === "") return "0x"; + return sigHash(functionSignature); +} + +/** + * Recursively turn (nested) structs in signatures into tuples + */ +function parseComponents(params: ParamType[]): string { + const components = params.map((param) => { + const tupleMatch = param.type.match(/tuple(.*)/); + if (tupleMatch) { + // there can be arrays of tuples, + // `tupleMatch[1]` preserves the array brackets (or is empty string for non-arrays) + return parseComponents(param.components) + tupleMatch[1]; + } else { + return param.type; + } + }); + return `(${components})`; +} + +// TODO: move this to utils as soon as utils are usable inside cli +// (see https://github.com/latticexyz/mud/issues/499) +function sigHash(signature: string) { + return ethers.utils.hexDataSlice(ethers.utils.keccak256(ethers.utils.toUtf8Bytes(signature)), 0, 4); +} diff --git a/packages/cli/src/utils/tables/getRegisterTableCallData.ts b/packages/cli/src/utils/tables/getRegisterTableCallData.ts new file mode 100644 index 0000000000..7b2f80fda6 --- /dev/null +++ b/packages/cli/src/utils/tables/getRegisterTableCallData.ts @@ -0,0 +1,40 @@ +import { encodeSchema, getStaticByteLength } from "@latticexyz/schema-type/deprecated"; +import { StoreConfig } from "@latticexyz/store"; +import { resolveAbiOrUserType } from "@latticexyz/store/codegen"; +import { tableIdToHex } from "@latticexyz/common"; +import { Table } from "./types"; +import { fieldLayoutToHex } from "@latticexyz/protocol-parser"; +import { CallData } from "../utils/types"; + +export function getRegisterTableCallData(table: Table, storeConfig: StoreConfig): CallData { + const { name, valueSchema, keySchema } = table; + if (!name) throw Error("Table missing name"); + + const schemaTypes = Object.values(valueSchema).map((abiOrUserType) => { + const { schemaType } = resolveAbiOrUserType(abiOrUserType, storeConfig); + return schemaType; + }); + + const schemaTypeLengths = schemaTypes.map((schemaType) => getStaticByteLength(schemaType)); + const fieldLayout = { + staticFieldLengths: schemaTypeLengths.filter((schemaTypeLength) => schemaTypeLength > 0), + numDynamicFields: schemaTypeLengths.filter((schemaTypeLength) => schemaTypeLength === 0).length, + }; + + const keyTypes = Object.values(keySchema).map((abiOrUserType) => { + const { schemaType } = resolveAbiOrUserType(abiOrUserType, storeConfig); + return schemaType; + }); + + return { + func: "registerTable", + args: [ + tableIdToHex(storeConfig.namespace, name), + fieldLayoutToHex(fieldLayout), + encodeSchema(keyTypes), + encodeSchema(schemaTypes), + Object.keys(keySchema), + Object.keys(valueSchema), + ], + }; +} diff --git a/packages/cli/src/utils/tables/getTableIds.ts b/packages/cli/src/utils/tables/getTableIds.ts new file mode 100644 index 0000000000..798fb8d381 --- /dev/null +++ b/packages/cli/src/utils/tables/getTableIds.ts @@ -0,0 +1,21 @@ +import { StoreConfig } from "@latticexyz/store"; +import { TableIds } from "./types"; +import { toBytes16 } from "../utils/toBytes16"; + +export function getTableIds(storeConfig: StoreConfig): TableIds { + const tableIds: TableIds = {}; + for (const [tableName, { name }] of Object.entries(storeConfig.tables)) { + tableIds[tableName] = toResourceSelector(storeConfig.namespace, name); + } + return tableIds; +} + +// (see https://github.com/latticexyz/mud/issues/499) +function toResourceSelector(namespace: string, file: string): Uint8Array { + const namespaceBytes = toBytes16(namespace); + const fileBytes = toBytes16(file); + const result = new Uint8Array(32); + result.set(namespaceBytes); + result.set(fileBytes, 16); + return result; +} diff --git a/packages/cli/src/utils/tables/types.ts b/packages/cli/src/utils/tables/types.ts new file mode 100644 index 0000000000..2fbe66339e --- /dev/null +++ b/packages/cli/src/utils/tables/types.ts @@ -0,0 +1,12 @@ +export type Table = { + valueSchema: Record; + keySchema: Record; + directory: string; + tableIdArgument: boolean; + storeArgument: boolean; + ephemeral: boolean; + name?: string | undefined; + dataStruct?: boolean | undefined; +}; + +export type TableIds = { [tableName: string]: Uint8Array }; diff --git a/packages/cli/src/utils/utils/confirmNonce.ts b/packages/cli/src/utils/utils/confirmNonce.ts new file mode 100644 index 0000000000..ce8aa40122 --- /dev/null +++ b/packages/cli/src/utils/utils/confirmNonce.ts @@ -0,0 +1,24 @@ +import chalk from "chalk"; +import { Wallet } from "ethers"; +import { MUDError } from "@latticexyz/common/errors"; + +export async function confirmNonce(signer: Wallet, nonce: number, pollInterval: number): Promise { + let remoteNonce = await signer.getTransactionCount(); + let retryCount = 0; + const maxRetries = 100; + while (remoteNonce !== nonce && retryCount < maxRetries) { + console.log( + chalk.gray( + `Waiting for transactions to be included before executing postDeployScript (local nonce: ${nonce}, remote nonce: ${remoteNonce}, retry number ${retryCount}/${maxRetries})` + ) + ); + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + retryCount++; + remoteNonce = await signer.getTransactionCount(); + } + if (remoteNonce !== nonce) { + throw new MUDError( + "Remote nonce doesn't match local nonce, indicating that not all deploy transactions were included." + ); + } +} diff --git a/packages/cli/src/utils/utils/deployContract.ts b/packages/cli/src/utils/utils/deployContract.ts new file mode 100644 index 0000000000..219032127f --- /dev/null +++ b/packages/cli/src/utils/utils/deployContract.ts @@ -0,0 +1,33 @@ +import chalk from "chalk"; +import { ethers } from "ethers"; +import { MUDError } from "@latticexyz/common/errors"; +import { TxConfig, ContractCode } from "./types"; + +export async function deployContract(input: TxConfig & { nonce: number; contract: ContractCode }): Promise { + const { signer, nonce, maxPriorityFeePerGas, maxFeePerGas, debug, gasPrice, confirmations, contract } = input; + + try { + const factory = new ethers.ContractFactory(contract.abi, contract.bytecode, signer); + console.log(chalk.gray(`executing deployment of ${contract.name} with nonce ${nonce}`)); + const deployPromise = factory + .deploy({ + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasPrice, + }) + .then((c) => (confirmations ? c : c.deployed())); + const { address } = await deployPromise; + console.log(chalk.green("Deployed", contract.name, "to", address)); + return address; + } catch (error: any) { + if (debug) console.error(error); + if (error?.message.includes("invalid bytecode")) { + throw new MUDError( + `Error deploying ${contract.name}: invalid bytecode. Note that linking of public libraries is not supported yet, make sure none of your libraries use "external" functions.` + ); + } else if (error?.message.includes("CreateContractLimit")) { + throw new MUDError(`Error deploying ${contract.name}: CreateContractLimit exceeded.`); + } else throw error; + } +} diff --git a/packages/cli/src/utils/utils/fastTxExecute.ts b/packages/cli/src/utils/utils/fastTxExecute.ts new file mode 100644 index 0000000000..a0bbf5e3ac --- /dev/null +++ b/packages/cli/src/utils/utils/fastTxExecute.ts @@ -0,0 +1,56 @@ +import chalk from "chalk"; +import { TransactionReceipt, TransactionResponse } from "@ethersproject/providers"; +import { MUDError } from "@latticexyz/common/errors"; +import { TxConfig } from "./types"; + +/** + * Only await gas estimation (for speed), only execute if gas estimation succeeds (for safety) + */ +export async function fastTxExecute< + C extends { connect: any; estimateGas: any; [key: string]: any }, + F extends keyof C +>( + input: TxConfig & { + nonce: number; + contract: C; + func: F; + args: Parameters; + confirmations: number; + } +): Promise { + const { + func, + args, + contract, + signer, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasPrice, + confirmations = 1, + debug, + } = input; + const functionName = `${func as string}(${args.map((arg) => `'${arg}'`).join(",")})`; + try { + const contractWithSigner = contract.connect(signer); + const gasLimit = await contractWithSigner.estimateGas[func].apply(null, args); + console.log(chalk.gray(`executing transaction: ${functionName} with nonce ${nonce}`)); + return contractWithSigner[func] + .apply(null, [ + ...args, + { + gasLimit, + nonce: nonce, + maxPriorityFeePerGas: maxPriorityFeePerGas, + maxFeePerGas: maxFeePerGas, + gasPrice: gasPrice, + }, + ]) + .then((tx: TransactionResponse) => { + return confirmations === 0 ? tx : tx.wait(confirmations); + }); + } catch (error: any) { + if (debug) console.error(error); + throw new MUDError(`Gas estimation error for ${functionName}: ${error?.reason}`); + } +} diff --git a/packages/cli/src/utils/getChainId.ts b/packages/cli/src/utils/utils/getChainId.ts similarity index 100% rename from packages/cli/src/utils/getChainId.ts rename to packages/cli/src/utils/utils/getChainId.ts diff --git a/packages/cli/src/utils/utils/getContractData.ts b/packages/cli/src/utils/utils/getContractData.ts new file mode 100644 index 0000000000..cb70d18c04 --- /dev/null +++ b/packages/cli/src/utils/utils/getContractData.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "fs"; +import path from "path"; +import { Fragment } from "ethers/lib/utils.js"; +import { MUDError } from "@latticexyz/common/errors"; + +/** + * Load the contract's abi and bytecode from the file system + * @param contractName: Name of the contract to load + */ +export function getContractData( + contractName: string, + forgeOutDirectory: string +): { bytecode: string; abi: Fragment[] } { + let data: any; + const contractDataPath = path.join(forgeOutDirectory, contractName + ".sol", contractName + ".json"); + try { + data = JSON.parse(readFileSync(contractDataPath, "utf8")); + } catch (error: any) { + throw new MUDError(`Error reading file at ${contractDataPath}`); + } + + const bytecode = data?.bytecode?.object; + if (!bytecode) throw new MUDError(`No bytecode found in ${contractDataPath}`); + + const abi = data?.abi; + if (!abi) throw new MUDError(`No ABI found in ${contractDataPath}`); + + return { abi, bytecode }; +} diff --git a/packages/cli/src/utils/utils/postDeploy.ts b/packages/cli/src/utils/utils/postDeploy.ts new file mode 100644 index 0000000000..e30d63eaa1 --- /dev/null +++ b/packages/cli/src/utils/utils/postDeploy.ts @@ -0,0 +1,25 @@ +import { existsSync } from "fs"; +import path from "path"; +import chalk from "chalk"; +import { getScriptDirectory, forge } from "@latticexyz/common/foundry"; + +export async function postDeploy( + postDeployScript: string, + worldAddress: string, + rpc: string, + profile: string | undefined +): Promise { + // Execute postDeploy forge script + const postDeployPath = path.join(await getScriptDirectory(), postDeployScript + ".s.sol"); + if (existsSync(postDeployPath)) { + console.log(chalk.blue(`Executing post deploy script at ${postDeployPath}`)); + await forge( + ["script", postDeployScript, "--sig", "run(address)", worldAddress, "--broadcast", "--rpc-url", rpc, "-vvv"], + { + profile: profile, + } + ); + } else { + console.log(`No script at ${postDeployPath}, skipping post deploy hook`); + } +} diff --git a/packages/cli/src/utils/utils/setInternalFeePerGas.ts b/packages/cli/src/utils/utils/setInternalFeePerGas.ts new file mode 100644 index 0000000000..fa926ff66e --- /dev/null +++ b/packages/cli/src/utils/utils/setInternalFeePerGas.ts @@ -0,0 +1,49 @@ +import { BigNumber, Wallet } from "ethers"; +import { MUDError } from "@latticexyz/common/errors"; + +/** + * Set the maxFeePerGas and maxPriorityFeePerGas based on the current base fee and the given multiplier. + * The multiplier is used to allow replacing pending transactions. + * @param multiplier Multiplier to apply to the base fee + */ +export async function setInternalFeePerGas( + signer: Wallet, + multiplier: number +): Promise<{ + maxPriorityFeePerGas: number | undefined; + maxFeePerGas: BigNumber | undefined; + gasPrice: BigNumber | undefined; +}> { + // Compute maxFeePerGas and maxPriorityFeePerGas like ethers, but allow for a multiplier to allow replacing pending transactions + const feeData = await signer.provider.getFeeData(); + let maxPriorityFeePerGas: number | undefined; + let maxFeePerGas: BigNumber | undefined; + let gasPrice: BigNumber | undefined; + + if (feeData.lastBaseFeePerGas) { + if (!feeData.lastBaseFeePerGas.eq(0) && (await signer.getBalance()).eq(0)) { + throw new MUDError(`Attempting to deploy to a chain with non-zero base fee with an account that has no balance. + If you're deploying to the Lattice testnet, you can fund your account by running 'pnpm mud faucet --address ${await signer.getAddress()}'`); + } + + // Set the priority fee to 0 for development chains with no base fee, to allow transactions from unfunded wallets + maxPriorityFeePerGas = feeData.lastBaseFeePerGas.eq(0) ? 0 : Math.floor(1_500_000_000 * multiplier); + maxFeePerGas = feeData.lastBaseFeePerGas.mul(2).add(maxPriorityFeePerGas); + } else if (feeData.gasPrice) { + // Legacy chains with gasPrice instead of maxFeePerGas + if (!feeData.gasPrice.eq(0) && (await signer.getBalance()).eq(0)) { + throw new MUDError( + `Attempting to deploy to a chain with non-zero gas price with an account that has no balance.` + ); + } + + gasPrice = feeData.gasPrice; + } else { + throw new MUDError("Can not fetch fee data from RPC"); + } + return { + maxPriorityFeePerGas, + maxFeePerGas, + gasPrice, + }; +} diff --git a/packages/cli/src/utils/utils/toBytes16.ts b/packages/cli/src/utils/utils/toBytes16.ts new file mode 100644 index 0000000000..414fb351ca --- /dev/null +++ b/packages/cli/src/utils/utils/toBytes16.ts @@ -0,0 +1,16 @@ +// TODO: use stringToBytes16 from utils as soon as utils are usable inside cli +// (see https://github.com/latticexyz/mud/issues/499) +export function toBytes16(input: string) { + if (input.length > 16) throw new Error("String does not fit into 16 bytes"); + + const result = new Uint8Array(16); + // Set ascii bytes + for (let i = 0; i < input.length; i++) { + result[i] = input.charCodeAt(i); + } + // Set the remaining bytes to 0 + for (let i = input.length; i < 16; i++) { + result[i] = 0; + } + return result; +} diff --git a/packages/cli/src/utils/utils/types.ts b/packages/cli/src/utils/utils/types.ts new file mode 100644 index 0000000000..06ca62d8c1 --- /dev/null +++ b/packages/cli/src/utils/utils/types.ts @@ -0,0 +1,21 @@ +import { BigNumber, ContractInterface, ethers } from "ethers"; + +export type CallData = { + func: string; + args: unknown[]; +}; + +export type ContractCode = { + name: string; + abi: ContractInterface; + bytecode: string | { object: string }; +}; + +export type TxConfig = { + signer: ethers.Wallet; + maxPriorityFeePerGas: number | undefined; + maxFeePerGas: BigNumber | undefined; + gasPrice: BigNumber | undefined; + debug: boolean; + confirmations: number; +}; diff --git a/packages/cli/src/utils/world.ts b/packages/cli/src/utils/world.ts new file mode 100644 index 0000000000..e8c9245126 --- /dev/null +++ b/packages/cli/src/utils/world.ts @@ -0,0 +1,28 @@ +import chalk from "chalk"; + +import WorldData from "@latticexyz/world/out/World.sol/World.json" assert { type: "json" }; +import IBaseWorldAbi from "@latticexyz/world/out/IBaseWorld.sol/IBaseWorld.abi.json" assert { type: "json" }; +import { deployContract } from "./utils/deployContract"; +import { getContractData } from "./utils/getContractData"; +import { TxConfig } from "./utils/types"; + +export async function deployWorldContract( + ip: TxConfig & { + nonce: number; + worldContractName: string | undefined; + forgeOutDirectory: string; + } +): Promise { + console.log(chalk.blue(`Deploying World`)); + const contractData = ip.worldContractName + ? { + name: "World", + ...getContractData(ip.worldContractName, ip.forgeOutDirectory), + } + : { abi: IBaseWorldAbi, bytecode: WorldData.bytecode, name: "World" }; + return deployContract({ + ...ip, + nonce: ip.nonce, + contract: contractData, + }); +} diff --git a/packages/config/src/library/dynamicResolution.ts b/packages/config/src/library/dynamicResolution.ts index 00615b55a8..4fc1dbdbcc 100644 --- a/packages/config/src/library/dynamicResolution.ts +++ b/packages/config/src/library/dynamicResolution.ts @@ -33,10 +33,10 @@ export function isDynamicResolution(value: unknown): value is DynamicResolution /** * Turn a DynamicResolution object into a ValueWithType based on the provided context */ -export async function resolveWithContext( +export function resolveWithContext( unresolved: any, context: { systemAddresses?: Record>; tableIds?: Record } -): Promise { +): ValueWithType { if (!isDynamicResolution(unresolved)) return unresolved; let resolved: ValueWithType | undefined = undefined;