diff --git a/.changeset/happy-snails-sleep.md b/.changeset/happy-snails-sleep.md new file mode 100644 index 0000000000..802b24b1f1 --- /dev/null +++ b/.changeset/happy-snails-sleep.md @@ -0,0 +1,8 @@ +--- +"@latticexyz/cli": minor +"@latticexyz/world": major +--- + +`WorldFactory` now expects a user-provided `salt` when calling `deployWorld(...)` (instead of the previous globally incrementing counter). This enables deterministic world addresses across different chains. + +When using `mud deploy`, you can provide a `bytes32` hex-encoded salt using the `--salt` option, otherwise it defaults to a random hex value. diff --git a/packages/cli/src/commands/dev-contracts.ts b/packages/cli/src/commands/dev-contracts.ts index 44e25634c3..9cd69fb3cd 100644 --- a/packages/cli/src/commands/dev-contracts.ts +++ b/packages/cli/src/commands/dev-contracts.ts @@ -89,6 +89,7 @@ const commandModule: CommandModule = { client: Client; config: Config; + salt?: Hex; worldAddress?: Address; }; @@ -31,6 +33,7 @@ type DeployOptions = { export async function deploy({ client, config, + salt, worldAddress: existingWorldAddress, }: DeployOptions): Promise { const tables = Object.values(config.tables) as Table[]; @@ -58,7 +61,7 @@ export async function deploy({ const worldDeploy = existingWorldAddress ? await getWorldDeploy(client, existingWorldAddress) - : await deployWorld(client); + : await deployWorld(client, salt ? salt : `0x${randomBytes(32).toString("hex")}`); if (!supportedStoreVersions.includes(worldDeploy.storeVersion)) { throw new Error(`Unsupported Store version: ${worldDeploy.storeVersion}`); diff --git a/packages/cli/src/deploy/deployWorld.ts b/packages/cli/src/deploy/deployWorld.ts index eb5e448ba0..c038327c84 100644 --- a/packages/cli/src/deploy/deployWorld.ts +++ b/packages/cli/src/deploy/deployWorld.ts @@ -1,4 +1,4 @@ -import { Account, Chain, Client, Log, Transport } from "viem"; +import { Account, Chain, Client, Hex, Log, Transport } from "viem"; import { waitForTransactionReceipt } from "viem/actions"; import { ensureWorldFactory, worldFactory } from "./ensureWorldFactory"; import WorldFactoryAbi from "@latticexyz/world/out/WorldFactory.sol/WorldFactory.abi.json" assert { type: "json" }; @@ -7,7 +7,10 @@ import { debug } from "./debug"; import { logsToWorldDeploy } from "./logsToWorldDeploy"; import { WorldDeploy } from "./common"; -export async function deployWorld(client: Client): Promise { +export async function deployWorld( + client: Client, + salt: Hex +): Promise { await ensureWorldFactory(client); debug("deploying world"); @@ -16,6 +19,7 @@ export async function deployWorld(client: Client; export type DeployOptions = InferredOptionTypes; @@ -38,6 +42,11 @@ export type DeployOptions = InferredOptionTypes; * This is used by the deploy, test, and dev-contracts CLI commands. */ export async function runDeploy(opts: DeployOptions): Promise { + const salt = opts.salt; + if (salt != null && !isHex(salt)) { + throw new MUDError("Expected hex string for salt"); + } + const profile = opts.profile ?? process.env.FOUNDRY_PROFILE; const config = (await loadConfig(opts.configPath)) as StoreConfig & WorldConfig; @@ -79,6 +88,7 @@ in your contracts directory to use the default anvil private key.` const startTime = Date.now(); const worldDeploy = await deploy({ + salt, worldAddress: opts.worldAddress as Hex | undefined, client, config: resolvedConfig, diff --git a/packages/world/gas-report.json b/packages/world/gas-report.json index 55acc5ca9b..38c3e324a1 100644 --- a/packages/world/gas-report.json +++ b/packages/world/gas-report.json @@ -57,13 +57,13 @@ "file": "test/Factories.t.sol", "test": "testCreate2Factory", "name": "deploy contract via Create2", - "gasUsed": 4586875 + "gasUsed": 4609895 }, { "file": "test/Factories.t.sol", "test": "testWorldFactoryGas", "name": "deploy world via WorldFactory", - "gasUsed": 12716027 + "gasUsed": 12694691 }, { "file": "test/World.t.sol", diff --git a/packages/world/src/IWorldFactory.sol b/packages/world/src/IWorldFactory.sol index 625c03a051..08589dda72 100644 --- a/packages/world/src/IWorldFactory.sol +++ b/packages/world/src/IWorldFactory.sol @@ -13,17 +13,10 @@ interface IWorldFactory { */ event WorldDeployed(address indexed newContract); - /** - * @notice Returns the total count of deployed World contracts per account. - * @param account The account. - * @return The total number of World contracts deployed by this factory per account. - */ - function worldCounts(address account) external view returns (uint256); - /** * @notice Deploys a new World contract. * @dev The deployment of the World contract will result in the `WorldDeployed` event being emitted. * @return worldAddress The address of the newly deployed World contract. */ - function deployWorld() external returns (address worldAddress); + function deployWorld(bytes memory salt) external returns (address worldAddress); } diff --git a/packages/world/src/WorldFactory.sol b/packages/world/src/WorldFactory.sol index 0b2e452d30..7e9028109f 100644 --- a/packages/world/src/WorldFactory.sol +++ b/packages/world/src/WorldFactory.sol @@ -17,9 +17,6 @@ contract WorldFactory is IWorldFactory { /// @notice Address of the init module to be set in the World instances. IModule public immutable initModule; - /// @notice Counters to keep track of the number of World instances deployed per address. - mapping(address creator => uint256 worldCount) public worldCounts; - /// @param _initModule The address of the init module. constructor(IModule _initModule) { initModule = _initModule; @@ -28,13 +25,14 @@ contract WorldFactory is IWorldFactory { /** * @notice Deploys a new World instance, installs the InitModule and transfers ownership to the caller. * @dev Uses the Create2 for deterministic deployment. + * @param salt User defined salt for deterministic world addresses across chains * @return worldAddress The address of the newly deployed World contract. */ - function deployWorld() public returns (address worldAddress) { + function deployWorld(bytes memory salt) public returns (address worldAddress) { // Deploy a new World and increase the WorldCount bytes memory bytecode = type(World).creationCode; - uint256 salt = uint256(keccak256(abi.encode(msg.sender, worldCounts[msg.sender]++))); - worldAddress = Create2.deploy(bytecode, salt); + uint256 _salt = uint256(keccak256(abi.encode(msg.sender, salt))); + worldAddress = Create2.deploy(bytecode, _salt); IBaseWorld world = IBaseWorld(worldAddress); // Initialize the World and transfer ownership to the caller diff --git a/packages/world/test/Factories.t.sol b/packages/world/test/Factories.t.sol index f0e3a72e73..33c4cda0e4 100644 --- a/packages/world/test/Factories.t.sol +++ b/packages/world/test/Factories.t.sol @@ -48,13 +48,10 @@ contract FactoriesTest is Test, GasReporter { startGasReport("deploy contract via Create2"); create2Factory.deployContract(combinedBytes, uint256(0)); endGasReport(); - - // Confirm worldFactory was deployed correctly - IWorldFactory worldFactory = IWorldFactory(calculatedAddress); - assertEq(uint256(worldFactory.worldCounts(address(0))), uint256(0)); } - function testWorldFactory(address account) public { + function testWorldFactory(address account, uint256 salt1, uint256 salt2) public { + vm.assume(salt1 != salt2); vm.startPrank(account); // Deploy WorldFactory with current InitModule @@ -62,10 +59,13 @@ contract FactoriesTest is Test, GasReporter { address worldFactoryAddress = address(new WorldFactory(initModule)); IWorldFactory worldFactory = IWorldFactory(worldFactoryAddress); - // Address we expect for World + // User defined bytes for create2 + bytes memory _salt1 = abi.encode(salt1); + + // Address we expect for first World address calculatedAddress = calculateAddress( worldFactoryAddress, - keccak256(abi.encode(account, 0)), + keccak256(abi.encode(account, _salt1)), type(World).creationCode ); @@ -77,27 +77,28 @@ contract FactoriesTest is Test, GasReporter { vm.expectEmit(true, false, false, false); emit WorldDeployed(calculatedAddress); startGasReport("deploy world via WorldFactory"); - worldFactory.deployWorld(); + worldFactory.deployWorld(_salt1); endGasReport(); // Set the store address manually StoreSwitch.setStoreAddress(calculatedAddress); - // Confirm accountCount (which is salt) has incremented - assertEq(uint256(worldFactory.worldCounts(account)), uint256(1)); - // Confirm correct Core is installed assertTrue(InstalledModules.get(address(initModule), keccak256(new bytes(0)))); // Confirm the msg.sender is owner of the root namespace of the new world assertEq(NamespaceOwner.get(ROOT_NAMESPACE_ID), account); - // Deploy another world + // Deploy a second world - // Address we expect for World + // User defined bytes for create2 + // unchecked for the fuzzing test + bytes memory _salt2 = abi.encode(salt2); + + // Address we expect for second World calculatedAddress = calculateAddress( worldFactoryAddress, - keccak256(abi.encode(account, 1)), + keccak256(abi.encode(account, _salt2)), type(World).creationCode ); @@ -108,10 +109,7 @@ contract FactoriesTest is Test, GasReporter { // Check for WorldDeployed event from Factory vm.expectEmit(true, false, false, false); emit WorldDeployed(calculatedAddress); - worldFactory.deployWorld(); - - // Confirm accountCount (which is salt) has incremented - assertEq(uint256(worldFactory.worldCounts(account)), uint256(2)); + worldFactory.deployWorld(_salt2); // Set the store address manually StoreSwitch.setStoreAddress(calculatedAddress); @@ -121,9 +119,13 @@ contract FactoriesTest is Test, GasReporter { // Confirm the msg.sender is owner of the root namespace of the new world assertEq(NamespaceOwner.get(ROOT_NAMESPACE_ID), account); + + // Expect revert when deploying world with same bytes salt as already deployed world + vm.expectRevert(); + worldFactory.deployWorld(_salt1); } function testWorldFactoryGas() public { - testWorldFactory(address(this)); + testWorldFactory(address(this), 0, 1); } }