diff --git a/.changeset/spicy-bags-cough.md b/.changeset/spicy-bags-cough.md new file mode 100644 index 0000000000..d24979ce5e --- /dev/null +++ b/.changeset/spicy-bags-cough.md @@ -0,0 +1,6 @@ +--- +"@latticexyz/world": patch +"@latticexyz/cli": patch +--- + +Added a `deploy.useProxy` option to the MUD config that deploys the World as an upgradable proxy contract. The proxy behaves like a regular World contract, but the underlying implementation can be upgraded by calling `setImplementation`. diff --git a/packages/cli/src/deploy/deploy.ts b/packages/cli/src/deploy/deploy.ts index 27858fc968..c7c95f322f 100644 --- a/packages/cli/src/deploy/deploy.ts +++ b/packages/cli/src/deploy/deploy.ts @@ -28,6 +28,7 @@ type DeployOptions = { * not have a deterministic address. */ deployerAddress?: Hex; + withWorldProxy?: boolean; }; /** @@ -42,12 +43,13 @@ export async function deploy({ salt, worldAddress: existingWorldAddress, deployerAddress: initialDeployerAddress, + withWorldProxy, }: DeployOptions): Promise { const tables = Object.values(config.tables) as Table[]; const deployerAddress = initialDeployerAddress ?? (await ensureDeployer(client)); - await ensureWorldFactory(client, deployerAddress); + await ensureWorldFactory(client, deployerAddress, withWorldProxy); // deploy all dependent contracts, because system registration, module install, etc. all expect these contracts to be callable. await ensureContractsDeployed({ @@ -74,7 +76,7 @@ export async function deploy({ const worldDeploy = existingWorldAddress ? await getWorldDeploy(client, existingWorldAddress) - : await deployWorld(client, deployerAddress, salt ?? `0x${randomBytes(32).toString("hex")}`); + : await deployWorld(client, deployerAddress, salt ?? `0x${randomBytes(32).toString("hex")}`, withWorldProxy); 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 de05ada31f..054a859a22 100644 --- a/packages/cli/src/deploy/deployWorld.ts +++ b/packages/cli/src/deploy/deployWorld.ts @@ -11,8 +11,9 @@ export async function deployWorld( client: Client, deployerAddress: Hex, salt: Hex, + withWorldProxy?: boolean, ): Promise { - const worldFactory = await ensureWorldFactory(client, deployerAddress); + const worldFactory = await ensureWorldFactory(client, deployerAddress, withWorldProxy); debug("deploying world"); const tx = await writeContract(client, { diff --git a/packages/cli/src/deploy/ensureWorldFactory.ts b/packages/cli/src/deploy/ensureWorldFactory.ts index 0c241f7669..9eb684bd62 100644 --- a/packages/cli/src/deploy/ensureWorldFactory.ts +++ b/packages/cli/src/deploy/ensureWorldFactory.ts @@ -6,6 +6,8 @@ import initModuleBuild from "@latticexyz/world/out/InitModule.sol/InitModule.jso import initModuleAbi from "@latticexyz/world/out/InitModule.sol/InitModule.abi.json" assert { type: "json" }; import worldFactoryBuild from "@latticexyz/world/out/WorldFactory.sol/WorldFactory.json" assert { type: "json" }; import worldFactoryAbi from "@latticexyz/world/out/WorldFactory.sol/WorldFactory.abi.json" assert { type: "json" }; +import worldProxyFactoryBuild from "@latticexyz/world/out/WorldProxyFactory.sol/WorldProxyFactory.json" assert { type: "json" }; +import worldProxyFactoryAbi from "@latticexyz/world/out/WorldProxyFactory.sol/WorldProxyFactory.abi.json" assert { type: "json" }; import { Client, Transport, Chain, Account, Hex, getCreate2Address, encodeDeployData, size, Address } from "viem"; import { salt } from "./common"; import { ensureContractsDeployed } from "./ensureContractsDeployed"; @@ -14,6 +16,7 @@ import { Contract } from "./ensureContract"; export async function ensureWorldFactory( client: Client, deployerAddress: Hex, + withWorldProxy?: boolean, ): Promise
{ const accessManagementSystemDeployedBytecodeSize = size(accessManagementSystemBuild.deployedBytecode.object as Hex); const accessManagementSystemBytecode = accessManagementSystemBuild.bytecode.object as Hex; @@ -52,10 +55,13 @@ export async function ensureWorldFactory( const initModule = getCreate2Address({ from: deployerAddress, bytecode: initModuleBytecode, salt }); - const worldFactoryDeployedBytecodeSize = size(worldFactoryBuild.deployedBytecode.object as Hex); + const build = withWorldProxy ? worldProxyFactoryBuild : worldFactoryBuild; + const abi = withWorldProxy ? worldProxyFactoryAbi : worldFactoryAbi; + + const worldFactoryDeployedBytecodeSize = size(build.deployedBytecode.object as Hex); const worldFactoryBytecode = encodeDeployData({ - bytecode: worldFactoryBuild.bytecode.object as Hex, - abi: worldFactoryAbi, + bytecode: build.bytecode.object as Hex, + abi: abi, args: [initModule], }); diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index 0d9cf1ca88..c72e068365 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -110,6 +110,7 @@ in your contracts directory to use the default anvil private key.`, worldAddress: opts.worldAddress as Hex | undefined, client, config: resolvedConfig, + withWorldProxy: configV2.deploy.useProxy, }); if (opts.worldAddress == null || opts.alwaysRunPostDeploy) { await postDeploy(config.postDeployScript, worldDeploy.address, rpc, profile); diff --git a/packages/world/gas-report.json b/packages/world/gas-report.json index e22ab896de..00ceae5dad 100644 --- a/packages/world/gas-report.json +++ b/packages/world/gas-report.json @@ -185,6 +185,114 @@ "name": "update in field 1 item (warm)", "gasUsed": 44023 }, + { + "file": "test/WorldProxy.t.sol", + "test": "testCall", + "name": "call a system via the World", + "gasUsed": 45004 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testCallFromNamespaceDelegation", + "name": "call a system via a namespace fallback delegation", + "gasUsed": 64745 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testCallFromUnlimitedDelegation", + "name": "register an unlimited delegation", + "gasUsed": 79685 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testCallFromUnlimitedDelegation", + "name": "call a system via an unlimited delegation", + "gasUsed": 45592 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testDeleteRecord", + "name": "Delete record", + "gasUsed": 41626 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testPushToDynamicField", + "name": "Push data to the table", + "gasUsed": 115719 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testRegisterFunctionSelector", + "name": "Register a function selector", + "gasUsed": 121522 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testRegisterNamespace", + "name": "Register a new namespace", + "gasUsed": 148961 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testRegisterRootFunctionSelector", + "name": "Register a root function selector", + "gasUsed": 118628 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testRegisterSystem", + "name": "register a system", + "gasUsed": 190257 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testRegisterTable", + "name": "Register a new table in the namespace", + "gasUsed": 574686 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testRenounceNamespace", + "name": "Renounce namespace ownership", + "gasUsed": 67089 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testSetField", + "name": "Write data to a table field", + "gasUsed": 65822 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testSetRecord", + "name": "Write data to the table", + "gasUsed": 69687 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testUnregisterNamespaceDelegation", + "name": "unregister a namespace delegation", + "gasUsed": 65227 + }, + { + "file": "test/WorldProxy.t.sol", + "test": "testUnregisterUnlimitedDelegation", + "name": "unregister an unlimited delegation", + "gasUsed": 59425 + }, + { + "file": "test/WorldProxyFactory.t.sol", + "test": "testWorldProxyFactoryGas", + "name": "deploy world via WorldProxyFactory", + "gasUsed": 9032337 + }, + { + "file": "test/WorldProxyFactory.t.sol", + "test": "testWorldProxyFactoryGas", + "name": "set WorldProxy implementation", + "gasUsed": 31501 + }, { "file": "test/WorldResourceId.t.sol", "test": "testGetNamespace", diff --git a/packages/world/src/ERC165Checker.sol b/packages/world/src/ERC165Checker.sol index bdf0910152..74be23628a 100644 --- a/packages/world/src/ERC165Checker.sol +++ b/packages/world/src/ERC165Checker.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Contracts (last updated v5.0.0) (utils/introspection/ERC165Checker.sol) -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import { IERC165 } from "./IERC165.sol"; diff --git a/packages/world/src/IERC1967.sol b/packages/world/src/IERC1967.sol new file mode 100644 index 0000000000..902bd93a1a --- /dev/null +++ b/packages/world/src/IERC1967.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (interfaces/IERC1967.sol) + +pragma solidity ^0.8.24; + +/** + * @dev ERC-1967: Proxy Storage Slots. This interface contains the events defined in the ERC. + */ +interface IERC1967 { + /** + * @dev Emitted when the implementation is upgraded. + */ + event Upgraded(address indexed implementation); + + /** + * @dev Emitted when the admin account has changed. + */ + event AdminChanged(address previousAdmin, address newAdmin); + + /** + * @dev Emitted when the beacon is changed. + */ + event BeaconUpgraded(address indexed beacon); +} diff --git a/packages/world/src/Proxy.sol b/packages/world/src/Proxy.sol new file mode 100644 index 0000000000..bc57e8b1bd --- /dev/null +++ b/packages/world/src/Proxy.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (proxy/Proxy.sol) + +pragma solidity ^0.8.24; + +/** + * @dev This abstract contract provides a fallback function that delegates all calls to another contract using the EVM + * instruction `delegatecall`. We refer to the second contract as the _implementation_ behind the proxy, and it has to + * be specified by overriding the virtual {_implementation} function. + * + * Additionally, delegation to the implementation can be triggered manually through the {_fallback} function, or to a + * different contract through the {_delegate} function. + * + * The success and return data of the delegated call will be returned back to the caller of the proxy. + */ +abstract contract Proxy { + /** + * @dev Delegates the current call to `implementation`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _delegate(address implementation) internal virtual { + assembly { + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0, 0, calldatasize()) + + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + + // Copy the returned data. + returndatacopy(0, 0, returndatasize()) + + switch result + // delegatecall returns 0 on error. + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + /** + * @dev This is a virtual function that should be overridden so it returns the address to which the fallback + * function and {_fallback} should delegate. + */ + function _implementation() internal view virtual returns (address); + + /** + * @dev Delegates the current call to the address returned by `_implementation()`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _fallback() internal virtual { + _delegate(_implementation()); + } + + /** + * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other + * function in the contract matches the call data. + */ + fallback() external payable virtual { + _fallback(); + } +} diff --git a/packages/world/src/StorageSlot.sol b/packages/world/src/StorageSlot.sol new file mode 100644 index 0000000000..17cf69296f --- /dev/null +++ b/packages/world/src/StorageSlot.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (utils/StorageSlot.sol) +// This file was procedurally generated from scripts/generate/templates/StorageSlot.js. + +pragma solidity ^0.8.24; + +/** + * @dev Library for reading and writing primitive types to specific storage slots. + * + * Storage slots are often used to avoid storage conflict when dealing with upgradeable contracts. + * This library helps with reading and writing to such slots without the need for inline assembly. + * + * The functions in this library return Slot structs that contain a `value` member that can be used to read or write. + * + * Example usage to set ERC1967 implementation slot: + * ```solidity + * contract ERC1967 { + * bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + * + * function _getImplementation() internal view returns (address) { + * return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value; + * } + * + * function _setImplementation(address newImplementation) internal { + * require(newImplementation.code.length > 0); + * StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation; + * } + * } + * ``` + */ +library StorageSlot { + struct AddressSlot { + address value; + } + + struct BooleanSlot { + bool value; + } + + struct Bytes32Slot { + bytes32 value; + } + + struct Uint256Slot { + uint256 value; + } + + struct StringSlot { + string value; + } + + struct BytesSlot { + bytes value; + } + + /** + * @dev Returns an `AddressSlot` with member `value` located at `slot`. + */ + function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `BooleanSlot` with member `value` located at `slot`. + */ + function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `Bytes32Slot` with member `value` located at `slot`. + */ + function getBytes32Slot(bytes32 slot) internal pure returns (Bytes32Slot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `Uint256Slot` with member `value` located at `slot`. + */ + function getUint256Slot(bytes32 slot) internal pure returns (Uint256Slot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `StringSlot` with member `value` located at `slot`. + */ + function getStringSlot(bytes32 slot) internal pure returns (StringSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `StringSlot` representation of the string storage pointer `store`. + */ + function getStringSlot(string storage store) internal pure returns (StringSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := store.slot + } + } + + /** + * @dev Returns an `BytesSlot` with member `value` located at `slot`. + */ + function getBytesSlot(bytes32 slot) internal pure returns (BytesSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `BytesSlot` representation of the bytes storage pointer `store`. + */ + function getBytesSlot(bytes storage store) internal pure returns (BytesSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := store.slot + } + } +} diff --git a/packages/world/src/WorldProxy.sol b/packages/world/src/WorldProxy.sol new file mode 100644 index 0000000000..0796219ad7 --- /dev/null +++ b/packages/world/src/WorldProxy.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { StoreCore } from "@latticexyz/store/src/StoreCore.sol"; +import { STORE_VERSION } from "@latticexyz/store/src/version.sol"; +import { IStoreEvents } from "@latticexyz/store/src/IStoreEvents.sol"; +import { WORLD_VERSION } from "./version.sol"; +import { IWorldEvents } from "./IWorldEvents.sol"; +import { AccessControl } from "./AccessControl.sol"; +import { ROOT_NAMESPACE_ID } from "./constants.sol"; +import { Proxy } from "./Proxy.sol"; +import { IERC1967 } from "./IERC1967.sol"; +import { StorageSlot } from "./StorageSlot.sol"; + +/** + * @dev Storage slot with the address of the current implementation. + * This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1. + */ +bytes32 constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + +/** + * @title World Proxy Contract + * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz) + * @dev This contract is a proxy that uses a World contract as an implementation. + */ +contract WorldProxy is Proxy { + /** + * @notice Constructs the World Proxy. + * @dev Mimics the behaviour of the StoreKernel and World constructors. + */ + constructor(address implementation) { + _setImplementation(implementation); + + StoreCore.initialize(); + emit IStoreEvents.HelloStore(STORE_VERSION); + + emit IWorldEvents.HelloWorld(WORLD_VERSION); + } + + /** + * @dev Stores a new address in the EIP1967 implementation slot. + */ + function _setImplementation(address newImplementation) internal { + StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = newImplementation; + + emit IERC1967.Upgraded(newImplementation); + } + + /** + * @dev Stores a new address in the EIP1967 implementation slot with access control checks. + */ + function setImplementation(address newImplementation) public { + AccessControl.requireOwner(ROOT_NAMESPACE_ID, msg.sender); + + _setImplementation(newImplementation); + } + + /** + * @dev Returns the current implementation address. + * + * TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using + * the https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call. + * `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc` + */ + function _implementation() internal view virtual override returns (address) { + return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value; + } +} diff --git a/packages/world/src/WorldProxyFactory.sol b/packages/world/src/WorldProxyFactory.sol new file mode 100644 index 0000000000..3f5041054c --- /dev/null +++ b/packages/world/src/WorldProxyFactory.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { Create2 } from "./Create2.sol"; +import { World } from "./World.sol"; +import { IWorldFactory } from "./IWorldFactory.sol"; +import { IBaseWorld } from "./codegen/interfaces/IBaseWorld.sol"; +import { IModule } from "./IModule.sol"; +import { ROOT_NAMESPACE_ID } from "./constants.sol"; +import { WorldProxy } from "./WorldProxy.sol"; + +/** + * @title WorldProxyFactory + * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz) + * @notice A factory contract to deploy new WorldProxy instances. + * @dev This contract allows users to deploy a new World and WorldProxy, install the InitModule, and transfer the ownership. + */ +contract WorldProxyFactory is IWorldFactory { + /// @notice Address of the init module to be set in the World instances. + IModule public immutable initModule; + address public immutable worldImplementation; + + /// @param _initModule The address of the init module. + constructor(IModule _initModule) { + initModule = _initModule; + + // Deploy a world implementation + worldImplementation = address(new World()); + } + + /** + * @notice Deploys a new World and WorldProxy 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 WorldProxy contract. + */ + function deployWorld(bytes memory salt) public returns (address worldAddress) { + // Deploy a new World and increase the WorldCount + uint256 _salt = uint256(keccak256(abi.encode(msg.sender, salt))); + + // Deploy the world proxy + bytes memory worldProxyBytecode = abi.encodePacked(type(WorldProxy).creationCode, abi.encode(worldImplementation)); + worldAddress = Create2.deploy(worldProxyBytecode, _salt); + + IBaseWorld world = IBaseWorld(worldAddress); + + // Initialize the World and transfer ownership to the caller + world.initialize(initModule); + world.transferOwnership(ROOT_NAMESPACE_ID, msg.sender); + + emit WorldDeployed(worldAddress, _salt); + } +} diff --git a/packages/world/test/World.t.sol b/packages/world/test/World.t.sol index 2fc33150bb..9d74439f56 100644 --- a/packages/world/test/World.t.sol +++ b/packages/world/test/World.t.sol @@ -174,7 +174,7 @@ contract WorldTest is Test, GasReporter { bytes32[] keyTuple; bytes32[] singletonKey; - function setUp() public { + function setUp() public virtual { world = createWorld(); StoreSwitch.setStoreAddress(address(world)); diff --git a/packages/world/test/WorldProxy.t.sol b/packages/world/test/WorldProxy.t.sol new file mode 100644 index 0000000000..2f75247ef7 --- /dev/null +++ b/packages/world/test/WorldProxy.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; + +import { WorldTest } from "./World.t.sol"; +import { createWorldProxy } from "./createWorldProxy.sol"; + +contract WorldProxyTest is WorldTest { + function setUp() public override { + world = createWorldProxy(); + StoreSwitch.setStoreAddress(address(world)); + + key = "testKey"; + keyTuple = new bytes32[](1); + keyTuple[0] = key; + singletonKey = new bytes32[](0); + } +} diff --git a/packages/world/test/WorldProxyFactory.t.sol b/packages/world/test/WorldProxyFactory.t.sol new file mode 100644 index 0000000000..9992aaef05 --- /dev/null +++ b/packages/world/test/WorldProxyFactory.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { Test, console } from "forge-std/Test.sol"; + +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; +import { GasReporter } from "@latticexyz/gas-report/src/GasReporter.sol"; +import { WORLD_VERSION } from "../src/version.sol"; +import { World } from "../src/World.sol"; +import { IBaseWorld } from "../src/codegen/interfaces/IBaseWorld.sol"; +import { ResourceId } from "../src/WorldResourceId.sol"; +import { InitModule } from "../src/modules/init/InitModule.sol"; +import { Create2Factory } from "../src/Create2Factory.sol"; +import { WorldProxy, IMPLEMENTATION_SLOT } from "../src/WorldProxy.sol"; +import { WorldProxyFactory } from "../src/WorldProxyFactory.sol"; +import { IWorldFactory } from "../src/IWorldFactory.sol"; +import { IWorldEvents } from "../src/IWorldEvents.sol"; +import { IWorldErrors } from "../src/IWorldErrors.sol"; +import { InstalledModules } from "../src/codegen/tables/InstalledModules.sol"; +import { NamespaceOwner } from "../src/codegen/tables/NamespaceOwner.sol"; +import { UserDelegationControl } from "../src/codegen/tables/UserDelegationControl.sol"; +import { ResourceId, WorldResourceIdInstance } from "../src/WorldResourceId.sol"; +import { ROOT_NAMESPACE_ID } from "../src/constants.sol"; +import { createInitModule } from "./createInitModule.sol"; +import { UNLIMITED_DELEGATION } from "../src/constants.sol"; + +contract WorldProxyFactoryTest is Test, GasReporter { + using WorldResourceIdInstance for ResourceId; + + event ContractDeployed(address addr, uint256 salt); + event WorldDeployed(address indexed newContract, uint256 salt); + + function calculateAddress( + address deployingAddress, + bytes32 salt, + bytes memory bytecode + ) internal pure returns (address) { + bytes32 bytecodeHash = keccak256(bytecode); + bytes32 data = keccak256(abi.encodePacked(bytes1(0xff), deployingAddress, salt, bytecodeHash)); + return address(uint160(uint256(data))); + } + + function testWorldProxyFactory(address account1, address account2, uint256 salt1, uint256 salt2) public { + vm.assume(salt1 != salt2); + vm.assume(account1 != account2); + + // Deploy WorldFactory with current InitModule + InitModule initModule = createInitModule(); + address worldFactoryAddress = address(new WorldProxyFactory(initModule)); + IWorldFactory worldFactory = IWorldFactory(worldFactoryAddress); + + // User defined bytes for create2 + bytes memory _salt1 = abi.encode(salt1); + + // Address we expect for first World + address calculatedAddress = calculateAddress( + worldFactoryAddress, + keccak256(abi.encode(account1, 0)), + type(World).creationCode + ); + + // Check for HelloWorld event from World + vm.expectEmit(true, true, true, true); + emit IWorldEvents.HelloWorld(WORLD_VERSION); + + // Deploy world proxy + vm.prank(account1); + startGasReport("deploy world via WorldProxyFactory"); + address worldAddress = worldFactory.deployWorld(_salt1); + endGasReport(); + + address worldImplementationAddress = address(uint160(uint256(vm.load(worldAddress, IMPLEMENTATION_SLOT)))); + + // Set the store address manually + StoreSwitch.setStoreAddress(worldAddress); + + // 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), account1); + + // Deploy a second 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(account1, 0)), + type(World).creationCode + ); + + // Check for HelloWorld event from World + vm.prank(account1); + vm.expectEmit(true, true, true, true); + emit IWorldEvents.HelloWorld(WORLD_VERSION); + worldAddress = worldFactory.deployWorld(_salt2); + + worldImplementationAddress = address(uint160(uint256(vm.load(worldAddress, IMPLEMENTATION_SLOT)))); + + // Set the store address manually + StoreSwitch.setStoreAddress(worldAddress); + + // 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), account1); + + // Expect revert when deploying world with same bytes salt as already deployed world + vm.prank(account1); + vm.expectRevert(); + worldFactory.deployWorld(_salt1); + + // Expect revert when initializing world as not the creator + vm.prank(account1); + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, ROOT_NAMESPACE_ID.toString(), account1) + ); + IBaseWorld(address(worldAddress)).initialize(initModule); + + // Deploy a new world + address newWorldImplementationAddress = address(new World()); + + // Expect revert when changing implementation as not root namespace owner + vm.prank(account2); + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, ROOT_NAMESPACE_ID.toString(), account2) + ); + WorldProxy(payable(worldAddress)).setImplementation(newWorldImplementationAddress); + + // Set proxy implementation to new world + vm.prank(account1); + startGasReport("set WorldProxy implementation"); + WorldProxy(payable(worldAddress)).setImplementation(newWorldImplementationAddress); + endGasReport(); + + worldImplementationAddress = address(uint160(uint256(vm.load(worldAddress, IMPLEMENTATION_SLOT)))); + assertEq(worldImplementationAddress, newWorldImplementationAddress); + + // 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), account1); + + // Test we can execute a world function + vm.prank(account1); + IBaseWorld(address(worldAddress)).registerDelegation(account2, UNLIMITED_DELEGATION, new bytes(0)); + assertEq(ResourceId.unwrap(UserDelegationControl.get(account1, account2)), ResourceId.unwrap(UNLIMITED_DELEGATION)); + } + + function testWorldProxyFactoryGas() public { + testWorldProxyFactory(address(this), address(1), 0, 1); + } +} diff --git a/packages/world/test/createWorldProxy.sol b/packages/world/test/createWorldProxy.sol new file mode 100644 index 0000000000..3ccd2d0f35 --- /dev/null +++ b/packages/world/test/createWorldProxy.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { World } from "../src/World.sol"; +import { WorldProxy } from "../src/WorldProxy.sol"; +import { IBaseWorld } from "../src/codegen/interfaces/IBaseWorld.sol"; +import { createInitModule } from "./createInitModule.sol"; + +function createWorldProxy() returns (IBaseWorld world) { + address worldImplementationAddress = address(new World()); + world = IBaseWorld(address(new WorldProxy(worldImplementationAddress))); + + world.initialize(createInitModule()); +} diff --git a/packages/world/ts/config/v2/defaults.ts b/packages/world/ts/config/v2/defaults.ts index 2b8f0b5a70..034a031e25 100644 --- a/packages/world/ts/config/v2/defaults.ts +++ b/packages/world/ts/config/v2/defaults.ts @@ -19,6 +19,7 @@ export const DEPLOY_DEFAULTS = { postDeployScript: "PostDeploy", deploysDirectory: "./deploys", worldsFile: "./worlds.json", + useProxy: false, } as const; export type DEPLOY_DEFAULTS = typeof DEPLOY_DEFAULTS; diff --git a/packages/world/ts/config/v2/input.ts b/packages/world/ts/config/v2/input.ts index 6c58dcc0ab..3f6bf09baa 100644 --- a/packages/world/ts/config/v2/input.ts +++ b/packages/world/ts/config/v2/input.ts @@ -40,6 +40,8 @@ export type CodegenInput = { worldgenDirectory?: string; /** Path for world package imports. Default is "@latticexyz/world/src/" */ worldImportPath?: string; + /** Deploy the World as an upgradable proxy */ + useProxy?: boolean; }; export type WorldInput = evaluate< diff --git a/packages/world/ts/config/v2/output.ts b/packages/world/ts/config/v2/output.ts index 3e84bbd5d4..980fb76b66 100644 --- a/packages/world/ts/config/v2/output.ts +++ b/packages/world/ts/config/v2/output.ts @@ -41,6 +41,8 @@ export type Deploy = { readonly deploysDirectory: string; /** JSON file to write to with chain -> latest world deploy address (Default "./worlds.json") */ readonly worldsFile: string; + /** Deploy the World as an upgradable proxy */ + readonly useProxy: boolean; }; export type Codegen = {