diff --git a/.changeset/wild-nails-wonder.md b/.changeset/wild-nails-wonder.md new file mode 100644 index 0000000000..3543079a4f --- /dev/null +++ b/.changeset/wild-nails-wonder.md @@ -0,0 +1,13 @@ +--- +"@latticexyz/world": minor +--- + +The `World` now has a `callBatch` method which allows multiple system calls to be batched into a single transaction. + +```solidity +import { SystemCallData } from "@latticexyz/world/modules/core/types.sol"; + +interface IBaseWorld { + function callBatch(SystemCallData[] calldata systemCalls) external returns (bytes[] memory returnDatas); +} +``` diff --git a/packages/world/gas-report.json b/packages/world/gas-report.json index df720c3d4d..621b6463d0 100644 --- a/packages/world/gas-report.json +++ b/packages/world/gas-report.json @@ -35,17 +35,23 @@ "name": "AccessControl: requireAccess (this address)", "gasUsed": 153 }, + { + "file": "test/CallBatch.t.sol", + "test": "testCallBatchReturnData", + "name": "call systems with callBatch", + "gasUsed": 45546 + }, { "file": "test/KeysInTableModule.t.sol", "test": "testInstallComposite", "name": "install keys in table module", - "gasUsed": 1414653 + "gasUsed": 1415069 }, { "file": "test/KeysInTableModule.t.sol", "test": "testInstallGas", "name": "install keys in table module", - "gasUsed": 1414653 + "gasUsed": 1415069 }, { "file": "test/KeysInTableModule.t.sol", @@ -57,13 +63,13 @@ "file": "test/KeysInTableModule.t.sol", "test": "testInstallSingleton", "name": "install keys in table module", - "gasUsed": 1414653 + "gasUsed": 1415069 }, { "file": "test/KeysInTableModule.t.sol", "test": "testSetAndDeleteRecordHookCompositeGas", "name": "install keys in table module", - "gasUsed": 1414653 + "gasUsed": 1415069 }, { "file": "test/KeysInTableModule.t.sol", @@ -81,7 +87,7 @@ "file": "test/KeysInTableModule.t.sol", "test": "testSetAndDeleteRecordHookGas", "name": "install keys in table module", - "gasUsed": 1414653 + "gasUsed": 1415069 }, { "file": "test/KeysInTableModule.t.sol", @@ -99,7 +105,7 @@ "file": "test/KeysWithValueModule.t.sol", "test": "testGetKeysWithValueGas", "name": "install keys with value module", - "gasUsed": 665378 + "gasUsed": 665448 }, { "file": "test/KeysWithValueModule.t.sol", @@ -117,7 +123,7 @@ "file": "test/KeysWithValueModule.t.sol", "test": "testInstall", "name": "install keys with value module", - "gasUsed": 665378 + "gasUsed": 665448 }, { "file": "test/KeysWithValueModule.t.sol", @@ -129,7 +135,7 @@ "file": "test/KeysWithValueModule.t.sol", "test": "testSetAndDeleteRecordHook", "name": "install keys with value module", - "gasUsed": 665378 + "gasUsed": 665448 }, { "file": "test/KeysWithValueModule.t.sol", @@ -147,7 +153,7 @@ "file": "test/KeysWithValueModule.t.sol", "test": "testSetField", "name": "install keys with value module", - "gasUsed": 665378 + "gasUsed": 665448 }, { "file": "test/KeysWithValueModule.t.sol", @@ -231,7 +237,7 @@ "file": "test/StandardDelegationsModule.t.sol", "test": "testCallFromCallboundDelegation", "name": "register a callbound delegation", - "gasUsed": 114589 + "gasUsed": 114729 }, { "file": "test/StandardDelegationsModule.t.sol", @@ -243,7 +249,7 @@ "file": "test/StandardDelegationsModule.t.sol", "test": "testCallFromTimeboundDelegation", "name": "register a timebound delegation", - "gasUsed": 109084 + "gasUsed": 109224 }, { "file": "test/StandardDelegationsModule.t.sol", @@ -255,7 +261,7 @@ "file": "test/UniqueEntityModule.t.sol", "test": "testInstall", "name": "install unique entity module", - "gasUsed": 690431 + "gasUsed": 690539 }, { "file": "test/UniqueEntityModule.t.sol", @@ -267,7 +273,7 @@ "file": "test/UniqueEntityModule.t.sol", "test": "testInstallRoot", "name": "installRoot unique entity module", - "gasUsed": 680681 + "gasUsed": 680735 }, { "file": "test/UniqueEntityModule.t.sol", @@ -285,7 +291,7 @@ "file": "test/World.t.sol", "test": "testCallFromUnlimitedDelegation", "name": "register an unlimited delegation", - "gasUsed": 50629 + "gasUsed": 50617 }, { "file": "test/World.t.sol", @@ -309,43 +315,43 @@ "file": "test/World.t.sol", "test": "testRegisterFallbackSystem", "name": "Register a fallback system", - "gasUsed": 59306 + "gasUsed": 59347 }, { "file": "test/World.t.sol", "test": "testRegisterFallbackSystem", "name": "Register a root fallback system", - "gasUsed": 52950 + "gasUsed": 52972 }, { "file": "test/World.t.sol", "test": "testRegisterFunctionSelector", "name": "Register a function selector", - "gasUsed": 79872 + "gasUsed": 79913 }, { "file": "test/World.t.sol", "test": "testRegisterNamespace", "name": "Register a new namespace", - "gasUsed": 123269 + "gasUsed": 123225 }, { "file": "test/World.t.sol", "test": "testRegisterRootFunctionSelector", "name": "Register a root function selector", - "gasUsed": 74863 + "gasUsed": 74885 }, { "file": "test/World.t.sol", "test": "testRegisterSystem", "name": "register a system", - "gasUsed": 165750 + "gasUsed": 165772 }, { "file": "test/World.t.sol", "test": "testRegisterTable", "name": "Register a new table in the namespace", - "gasUsed": 651802 + "gasUsed": 651898 }, { "file": "test/World.t.sol", diff --git a/packages/world/src/interfaces/IBaseWorld.sol b/packages/world/src/interfaces/IBaseWorld.sol index da2939064d..53e6102d14 100644 --- a/packages/world/src/interfaces/IBaseWorld.sol +++ b/packages/world/src/interfaces/IBaseWorld.sol @@ -9,6 +9,7 @@ import { IWorldKernel } from "../interfaces/IWorldKernel.sol"; import { ICoreSystem } from "./ICoreSystem.sol"; import { IAccessManagementSystem } from "./IAccessManagementSystem.sol"; import { IBalanceTransferSystem } from "./IBalanceTransferSystem.sol"; +import { ICallBatchSystem } from "./ICallBatchSystem.sol"; import { IModuleInstallationSystem } from "./IModuleInstallationSystem.sol"; import { IWorldRegistrationSystem } from "./IWorldRegistrationSystem.sol"; @@ -22,6 +23,7 @@ interface IBaseWorld is ICoreSystem, IAccessManagementSystem, IBalanceTransferSystem, + ICallBatchSystem, IModuleInstallationSystem, IWorldRegistrationSystem { diff --git a/packages/world/src/interfaces/ICallBatchSystem.sol b/packages/world/src/interfaces/ICallBatchSystem.sol new file mode 100644 index 0000000000..54cdf3f64c --- /dev/null +++ b/packages/world/src/interfaces/ICallBatchSystem.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +/* Autogenerated file. Do not edit manually. */ + +import { SystemCallData } from "./../modules/core/types.sol"; + +interface ICallBatchSystem { + function callBatch(SystemCallData[] calldata systemCalls) external returns (bytes[] memory returnDatas); +} diff --git a/packages/world/src/modules/core/CoreModule.sol b/packages/world/src/modules/core/CoreModule.sol index cbc9f6901b..ca67a4e16a 100644 --- a/packages/world/src/modules/core/CoreModule.sol +++ b/packages/world/src/modules/core/CoreModule.sol @@ -28,6 +28,7 @@ import { Balances } from "./tables/Balances.sol"; import { AccessManagementSystem } from "./implementations/AccessManagementSystem.sol"; import { BalanceTransferSystem } from "./implementations/BalanceTransferSystem.sol"; +import { CallBatchSystem } from "./implementations/CallBatchSystem.sol"; import { ModuleInstallationSystem } from "./implementations/ModuleInstallationSystem.sol"; import { StoreRegistrationSystem } from "./implementations/StoreRegistrationSystem.sol"; import { WorldRegistrationSystem } from "./implementations/WorldRegistrationSystem.sol"; @@ -102,7 +103,7 @@ contract CoreModule is Module { * Register function selectors for all CoreSystem functions in the World */ function _registerFunctionSelectors() internal { - bytes4[16] memory functionSelectors = [ + bytes4[17] memory functionSelectors = [ // --- AccessManagementSystem --- AccessManagementSystem.grantAccess.selector, AccessManagementSystem.revokeAccess.selector, @@ -110,6 +111,8 @@ contract CoreModule is Module { // --- BalanceTransferSystem --- BalanceTransferSystem.transferBalanceToNamespace.selector, BalanceTransferSystem.transferBalanceToAddress.selector, + // --- CallBatchSystem --- + CallBatchSystem.callBatch.selector, // --- ModuleInstallationSystem --- ModuleInstallationSystem.installModule.selector, // --- StoreRegistrationSystem --- diff --git a/packages/world/src/modules/core/CoreSystem.sol b/packages/world/src/modules/core/CoreSystem.sol index 6fd79acbd7..61f56f26d7 100644 --- a/packages/world/src/modules/core/CoreSystem.sol +++ b/packages/world/src/modules/core/CoreSystem.sol @@ -5,6 +5,7 @@ import { IWorldErrors } from "../../interfaces/IWorldErrors.sol"; import { AccessManagementSystem } from "./implementations/AccessManagementSystem.sol"; import { BalanceTransferSystem } from "./implementations/BalanceTransferSystem.sol"; +import { CallBatchSystem } from "./implementations/CallBatchSystem.sol"; import { ModuleInstallationSystem } from "./implementations/ModuleInstallationSystem.sol"; import { StoreRegistrationSystem } from "./implementations/StoreRegistrationSystem.sol"; import { WorldRegistrationSystem } from "./implementations/WorldRegistrationSystem.sol"; @@ -17,6 +18,7 @@ contract CoreSystem is IWorldErrors, AccessManagementSystem, BalanceTransferSystem, + CallBatchSystem, ModuleInstallationSystem, StoreRegistrationSystem, WorldRegistrationSystem diff --git a/packages/world/src/modules/core/implementations/CallBatchSystem.sol b/packages/world/src/modules/core/implementations/CallBatchSystem.sol new file mode 100644 index 0000000000..2f8d63b003 --- /dev/null +++ b/packages/world/src/modules/core/implementations/CallBatchSystem.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import { System } from "../../../System.sol"; +import { IBaseWorld } from "../../../interfaces/IBaseWorld.sol"; +import { revertWithBytes } from "../../../revertWithBytes.sol"; + +import { SystemCallData } from "../types.sol"; + +contract CallBatchSystem is System { + /** + * Batch calls to multiple systems into a single transaction, return the array of return data. + */ + function callBatch(SystemCallData[] calldata systemCalls) public returns (bytes[] memory returnDatas) { + IBaseWorld world = IBaseWorld(_world()); + returnDatas = new bytes[](systemCalls.length); + + for (uint256 i; i < systemCalls.length; i++) { + (bool success, bytes memory returnData) = address(world).delegatecall( + abi.encodeCall(world.call, (systemCalls[i].systemId, systemCalls[i].callData)) + ); + if (!success) revertWithBytes(returnData); + + returnDatas[i] = abi.decode(returnData, (bytes)); + } + } +} diff --git a/packages/world/src/modules/core/types.sol b/packages/world/src/modules/core/types.sol new file mode 100644 index 0000000000..d7061059c7 --- /dev/null +++ b/packages/world/src/modules/core/types.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +import { ResourceId } from "@latticexyz/store/src/ResourceId.sol"; + +struct SystemCallData { + ResourceId systemId; + bytes callData; +} diff --git a/packages/world/test/CallBatch.t.sol b/packages/world/test/CallBatch.t.sol new file mode 100644 index 0000000000..2aa3fc81ed --- /dev/null +++ b/packages/world/test/CallBatch.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { GasReporter } from "@latticexyz/gas-report/src/GasReporter.sol"; +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; + +import { World } from "../src/World.sol"; +import { System } from "../src/System.sol"; +import { ResourceId, WorldResourceIdLib } from "../src/WorldResourceId.sol"; +import { RESOURCE_SYSTEM } from "../src/worldResourceTypes.sol"; + +import { IBaseWorld } from "../src/interfaces/IBaseWorld.sol"; +import { CoreModule } from "../src/modules/core/CoreModule.sol"; +import { SystemCallData } from "../src/modules/core/types.sol"; + +address constant caller = address(1); + +contract TestSystem is System { + address public admin; + uint256 public counter; + + function getStoreAddress() public view returns (address) { + return StoreSwitch.getStoreAddress(); + } + + function msgSender() public view returns (address) { + return _msgSender(); + } + + function setAdmin(address newAdmin) public { + admin = newAdmin; + } + + function increment() public { + require(_msgSender() == admin, "sender is not admin"); + + counter++; + } +} + +contract CallBatchTest is Test, GasReporter { + IBaseWorld world; + bytes14 namespace = "namespace"; + bytes16 name = "testSystem"; + + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: namespace, name: name }); + + function setUp() public { + world = IBaseWorld(address(new World())); + world.initialize(new CoreModule()); + } + + function testCallBatch() public { + // Register a new system + TestSystem system = new TestSystem(); + world.registerSystem(systemId, system, true); + + // Try to increment the counter without setting the admin + SystemCallData[] memory systemCalls = new SystemCallData[](1); + systemCalls[0] = SystemCallData(systemId, abi.encodeCall(TestSystem.increment, ())); + + vm.expectRevert("sender is not admin"); + world.callBatch(systemCalls); + + // Set the admin and increment the counter twice + systemCalls = new SystemCallData[](3); + systemCalls[0] = SystemCallData(systemId, abi.encodeCall(TestSystem.setAdmin, (caller))); + systemCalls[1] = SystemCallData(systemId, abi.encodeCall(TestSystem.increment, ())); + systemCalls[2] = SystemCallData(systemId, abi.encodeCall(TestSystem.increment, ())); + + vm.expectRevert("sender is not admin"); + world.callBatch(systemCalls); + + vm.prank(caller); + world.callBatch(systemCalls); + + assertEq(system.counter(), 2, "wrong counter value"); + + // Increment the counter again + systemCalls = new SystemCallData[](1); + systemCalls[0] = SystemCallData(systemId, abi.encodeCall(TestSystem.increment, ())); + + vm.prank(caller); + world.callBatch(systemCalls); + + assertEq(system.counter(), 3, "wrong counter value"); + } + + function testCallBatchReturnData() public { + // Register a new system + TestSystem system = new TestSystem(); + world.registerSystem(systemId, system, true); + + // Batch call functions on the system + SystemCallData[] memory systemCalls = new SystemCallData[](2); + + systemCalls[0] = SystemCallData(systemId, abi.encodeCall(TestSystem.msgSender, ())); + systemCalls[1] = SystemCallData(systemId, abi.encodeCall(TestSystem.getStoreAddress, ())); + + vm.prank(caller); + startGasReport("call systems with callBatch"); + bytes[] memory returnDatas = world.callBatch(systemCalls); + endGasReport(); + + assertEq(abi.decode(returnDatas[0], (address)), caller, "wrong address returned"); + assertEq(abi.decode(returnDatas[1], (address)), address(world), "wrong store returned"); + } +}