diff --git a/.changeset/rare-trainers-fry.md b/.changeset/rare-trainers-fry.md new file mode 100644 index 0000000000..4394fcd616 --- /dev/null +++ b/.changeset/rare-trainers-fry.md @@ -0,0 +1,15 @@ +--- +"@latticexyz/world": minor +--- + +It is now possible to transfer ownership of namespaces! + +```solidity +// Register a new namespace +world.registerNamespace("namespace"); +// It's owned by the caller of the function (address(this)) + +// Transfer ownership of the namespace to address(42) +world.transferOwnership("namespace", address(42)); +// It's now owned by address(42) +``` diff --git a/e2e/packages/contracts/types/ethers-contracts/IWorld.ts b/e2e/packages/contracts/types/ethers-contracts/IWorld.ts index ff02756749..34adbd46c8 100644 --- a/e2e/packages/contracts/types/ethers-contracts/IWorld.ts +++ b/e2e/packages/contracts/types/ethers-contracts/IWorld.ts @@ -59,6 +59,7 @@ export interface IWorldInterface extends utils.Interface { "setField(bytes32,bytes32[],uint8,bytes,bytes32)": FunctionFragment; "setRecord(bytes32,bytes32[],bytes,bytes32)": FunctionFragment; "stub(uint256)": FunctionFragment; + "transferOwnership(bytes16,address)": FunctionFragment; "updateInField(bytes32,bytes32[],uint8,uint256,bytes,bytes32)": FunctionFragment; }; @@ -93,6 +94,7 @@ export interface IWorldInterface extends utils.Interface { | "setField" | "setRecord" | "stub" + | "transferOwnership" | "updateInField" ): FunctionFragment; @@ -280,6 +282,10 @@ export interface IWorldInterface extends utils.Interface { functionFragment: "stub", values: [PromiseOrValue] ): string; + encodeFunctionData( + functionFragment: "transferOwnership", + values: [PromiseOrValue, PromiseOrValue] + ): string; encodeFunctionData( functionFragment: "updateInField", values: [ @@ -378,6 +384,10 @@ export interface IWorldInterface extends utils.Interface { decodeFunctionResult(functionFragment: "setField", data: BytesLike): Result; decodeFunctionResult(functionFragment: "setRecord", data: BytesLike): Result; decodeFunctionResult(functionFragment: "stub", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferOwnership", + data: BytesLike + ): Result; decodeFunctionResult( functionFragment: "updateInField", data: BytesLike @@ -675,6 +685,12 @@ export interface IWorld extends BaseContract { overrides?: CallOverrides ): Promise<[BigNumber]>; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -881,6 +897,12 @@ export interface IWorld extends BaseContract { overrides?: CallOverrides ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -1085,6 +1107,12 @@ export interface IWorld extends BaseContract { overrides?: CallOverrides ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: CallOverrides + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -1338,6 +1366,12 @@ export interface IWorld extends BaseContract { overrides?: CallOverrides ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -1545,6 +1579,12 @@ export interface IWorld extends BaseContract { overrides?: CallOverrides ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], diff --git a/e2e/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts b/e2e/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts index 1b3f766848..ab6dab8147 100644 --- a/e2e/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts +++ b/e2e/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts @@ -1041,6 +1041,24 @@ const _abi = [ stateMutability: "pure", type: "function", }, + { + inputs: [ + { + internalType: "bytes16", + name: "namespace", + type: "bytes16", + }, + { + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { diff --git a/examples/minimal/packages/contracts/types/ethers-contracts/IWorld.ts b/examples/minimal/packages/contracts/types/ethers-contracts/IWorld.ts index f717810764..bca3079737 100644 --- a/examples/minimal/packages/contracts/types/ethers-contracts/IWorld.ts +++ b/examples/minimal/packages/contracts/types/ethers-contracts/IWorld.ts @@ -69,6 +69,7 @@ export interface IWorldInterface extends utils.Interface { "setRecord(bytes32,bytes32[],bytes,bytes32)": FunctionFragment; "staticArrayBytesStruct(tuple[1])": FunctionFragment; "staticArrayStringStruct(tuple[1])": FunctionFragment; + "transferOwnership(bytes16,address)": FunctionFragment; "updateInField(bytes32,bytes32[],uint8,uint256,bytes,bytes32)": FunctionFragment; "willRevert()": FunctionFragment; }; @@ -106,6 +107,7 @@ export interface IWorldInterface extends utils.Interface { | "setRecord" | "staticArrayBytesStruct" | "staticArrayStringStruct" + | "transferOwnership" | "updateInField" | "willRevert" ): FunctionFragment; @@ -302,6 +304,10 @@ export interface IWorldInterface extends utils.Interface { functionFragment: "staticArrayStringStruct", values: [[StringStructStruct]] ): string; + encodeFunctionData( + functionFragment: "transferOwnership", + values: [PromiseOrValue, PromiseOrValue] + ): string; encodeFunctionData( functionFragment: "updateInField", values: [ @@ -421,6 +427,10 @@ export interface IWorldInterface extends utils.Interface { functionFragment: "staticArrayStringStruct", data: BytesLike ): Result; + decodeFunctionResult( + functionFragment: "transferOwnership", + data: BytesLike + ): Result; decodeFunctionResult( functionFragment: "updateInField", data: BytesLike @@ -729,6 +739,12 @@ export interface IWorld extends BaseContract { overrides?: Overrides & { from?: PromiseOrValue } ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -949,6 +965,12 @@ export interface IWorld extends BaseContract { overrides?: Overrides & { from?: PromiseOrValue } ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -1167,6 +1189,12 @@ export interface IWorld extends BaseContract { overrides?: CallOverrides ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: CallOverrides + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -1432,6 +1460,12 @@ export interface IWorld extends BaseContract { overrides?: Overrides & { from?: PromiseOrValue } ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], @@ -1653,6 +1687,12 @@ export interface IWorld extends BaseContract { overrides?: Overrides & { from?: PromiseOrValue } ): Promise; + transferOwnership( + namespace: PromiseOrValue, + newOwner: PromiseOrValue, + overrides?: Overrides & { from?: PromiseOrValue } + ): Promise; + updateInField( table: PromiseOrValue, key: PromiseOrValue[], diff --git a/examples/minimal/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts b/examples/minimal/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts index 860136eab7..64672701f8 100644 --- a/examples/minimal/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts +++ b/examples/minimal/packages/contracts/types/ethers-contracts/factories/IWorld__factory.ts @@ -1057,6 +1057,24 @@ const _abi = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { + internalType: "bytes16", + name: "namespace", + type: "bytes16", + }, + { + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { diff --git a/packages/world/abi/AccessManagementSystem.sol/AccessManagementSystem.abi.json b/packages/world/abi/AccessManagementSystem.sol/AccessManagementSystem.abi.json index e9c1bdf054..18b98f95fe 100644 --- a/packages/world/abi/AccessManagementSystem.sol/AccessManagementSystem.abi.json +++ b/packages/world/abi/AccessManagementSystem.sol/AccessManagementSystem.abi.json @@ -114,5 +114,23 @@ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes16", + "name": "namespace", + "type": "bytes16" + }, + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ] \ No newline at end of file diff --git a/packages/world/abi/CoreSystem.sol/CoreSystem.abi.json b/packages/world/abi/CoreSystem.sol/CoreSystem.abi.json index 353b346bdb..8026424550 100644 --- a/packages/world/abi/CoreSystem.sol/CoreSystem.abi.json +++ b/packages/world/abi/CoreSystem.sol/CoreSystem.abi.json @@ -478,5 +478,23 @@ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes16", + "name": "namespace", + "type": "bytes16" + }, + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ] \ No newline at end of file diff --git a/packages/world/abi/IAccessManagementSystem.sol/IAccessManagementSystem.abi.json b/packages/world/abi/IAccessManagementSystem.sol/IAccessManagementSystem.abi.json index 6f82058a6b..47ed7c0c90 100644 --- a/packages/world/abi/IAccessManagementSystem.sol/IAccessManagementSystem.abi.json +++ b/packages/world/abi/IAccessManagementSystem.sol/IAccessManagementSystem.abi.json @@ -34,5 +34,23 @@ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes16", + "name": "namespace", + "type": "bytes16" + }, + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ] \ No newline at end of file diff --git a/packages/world/abi/IBaseWorld.sol/IBaseWorld.abi.json b/packages/world/abi/IBaseWorld.sol/IBaseWorld.abi.json index 59c2ddcb7c..93f7460458 100644 --- a/packages/world/abi/IBaseWorld.sol/IBaseWorld.abi.json +++ b/packages/world/abi/IBaseWorld.sol/IBaseWorld.abi.json @@ -920,6 +920,24 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "bytes16", + "name": "namespace", + "type": "bytes16" + }, + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/packages/world/gas-report.json b/packages/world/gas-report.json index 8e46e1c304..816a3f54d2 100644 --- a/packages/world/gas-report.json +++ b/packages/world/gas-report.json @@ -231,7 +231,7 @@ "file": "test/UniqueEntityModule.t.sol", "test": "testInstall", "name": "install unique entity module", - "gasUsed": 722077 + "gasUsed": 722121 }, { "file": "test/UniqueEntityModule.t.sol", @@ -243,7 +243,7 @@ "file": "test/UniqueEntityModule.t.sol", "test": "testInstallRoot", "name": "installRoot unique entity module", - "gasUsed": 700972 + "gasUsed": 701016 }, { "file": "test/UniqueEntityModule.t.sol", @@ -273,7 +273,7 @@ "file": "test/World.t.sol", "test": "testRegisterFallbackSystem", "name": "Register a fallback system", - "gasUsed": 70183 + "gasUsed": 70205 }, { "file": "test/World.t.sol", @@ -285,7 +285,7 @@ "file": "test/World.t.sol", "test": "testRegisterFunctionSelector", "name": "Register a function selector", - "gasUsed": 90777 + "gasUsed": 90799 }, { "file": "test/World.t.sol", diff --git a/packages/world/src/interfaces/IAccessManagementSystem.sol b/packages/world/src/interfaces/IAccessManagementSystem.sol index ea754bd2f5..8f443689d5 100644 --- a/packages/world/src/interfaces/IAccessManagementSystem.sol +++ b/packages/world/src/interfaces/IAccessManagementSystem.sol @@ -7,4 +7,6 @@ interface IAccessManagementSystem { function grantAccess(bytes32 resourceSelector, address grantee) external; function revokeAccess(bytes32 resourceSelector, address grantee) external; + + function transferOwnership(bytes16 namespace, address newOwner) external; } diff --git a/packages/world/src/modules/core/CoreModule.sol b/packages/world/src/modules/core/CoreModule.sol index e1b820aef2..820bb893a6 100644 --- a/packages/world/src/modules/core/CoreModule.sol +++ b/packages/world/src/modules/core/CoreModule.sol @@ -90,7 +90,7 @@ contract CoreModule is IModule, WorldContextConsumer { * Register function selectors for all CoreSystem functions in the World */ function _registerFunctionSelectors() internal { - bytes4[11] memory functionSelectors = [ + bytes4[12] memory functionSelectors = [ // --- WorldRegistrationSystem --- WorldRegistrationSystem.registerNamespace.selector, WorldRegistrationSystem.registerSystemHook.selector, @@ -105,6 +105,7 @@ contract CoreModule is IModule, WorldContextConsumer { // --- AccessManagementSystem --- AccessManagementSystem.grantAccess.selector, AccessManagementSystem.revokeAccess.selector, + AccessManagementSystem.transferOwnership.selector, // --- EphemeralRecordSystem --- IStoreEphemeral.emitEphemeralRecord.selector ]; diff --git a/packages/world/src/modules/core/implementations/AccessManagementSystem.sol b/packages/world/src/modules/core/implementations/AccessManagementSystem.sol index 3fd2c695d8..d85a759f84 100644 --- a/packages/world/src/modules/core/implementations/AccessManagementSystem.sol +++ b/packages/world/src/modules/core/implementations/AccessManagementSystem.sol @@ -7,6 +7,7 @@ import { AccessControl } from "../../../AccessControl.sol"; import { ResourceSelector } from "../../../ResourceSelector.sol"; import { ResourceAccess } from "../../../tables/ResourceAccess.sol"; import { InstalledModules } from "../../../tables/InstalledModules.sol"; +import { NamespaceOwner } from "../../../tables/NamespaceOwner.sol"; /** * Granting and revoking access from/to resources. @@ -35,4 +36,23 @@ contract AccessManagementSystem is System { // Revoke access from the given resource ResourceAccess.deleteRecord(resourceSelector, grantee); } + + /** + * Transfer ownership of the given namespace to newOwner. + * Revoke ResourceAccess for previous owner and grant to newOwner. + * Requires the caller to own the namespace. + */ + function transferOwnership(bytes16 namespace, address newOwner) public virtual { + // Require the caller to own the namespace + AccessControl.requireOwnerOrSelf(namespace, _msgSender()); + + // Set namespace new owner + NamespaceOwner.set(namespace, newOwner); + + // Revoke access from old owner + ResourceAccess.deleteRecord(namespace, _msgSender()); + + // Grant access to new owner + ResourceAccess.set(namespace, newOwner, true); + } } diff --git a/packages/world/test/World.t.sol b/packages/world/test/World.t.sol index 8141b093b9..f6d97b9850 100644 --- a/packages/world/test/World.t.sol +++ b/packages/world/test/World.t.sol @@ -246,6 +246,38 @@ contract WorldTest is Test, GasReporter { world.registerNamespace("test"); } + function testTransferNamespace() public { + world.registerNamespace("testTransfer"); + + // Expect the new owner to not be namespace owner before transfer + assertFalse( + (NamespaceOwner.get(world, "testTransfer") == address(1)), + "new owner should not be namespace owner before transfer" + ); + // Expect the new owner to not have access before transfer + assertEq( + ResourceAccess.get(world, "testTransfer", address(1)), + false, + "new owner should not have access before transfer" + ); + + world.transferOwnership("testTransfer", address(1)); + // Expect the new owner to be namespace owner + assertEq(NamespaceOwner.get(world, "testTransfer"), address(1), "new owner should be namespace owner"); + // Expect the new owner to have access + assertEq(ResourceAccess.get(world, "testTransfer", address(1)), true, "new owner should have access"); + // Expect previous owner to no longer be owner + assertFalse( + (NamespaceOwner.get(world, "testTransfer") == address(this)), + "caller should no longer be namespace owner" + ); + // Expect previous owner to no longer have access + assertEq(ResourceAccess.get(world, "testTransfer", address(this)), false, "caller should no longer have access"); + // Expect revert if caller is not the owner + _expectAccessDenied(address(this), "testTransfer", 0); + world.transferOwnership("testTransfer", address(1)); + } + function testRegisterTable() public { Schema valueSchema = SchemaEncodeHelper.encode(SchemaType.BOOL, SchemaType.UINT256, SchemaType.STRING); bytes16 namespace = "testNamespace";