Skip to content

Commit 5d6fb1b

Browse files
authored
refactor(store-consumer): adapt WithWorld to be a System (#3546)
1 parent 16710f1 commit 5d6fb1b

18 files changed

Lines changed: 217 additions & 67 deletions

File tree

.changeset/loud-moons-tell.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@latticexyz/store-consumer": patch
3+
"@latticexyz/store": patch
4+
"@latticexyz/world-module-erc20": patch
5+
"@latticexyz/world": patch
6+
---
7+
8+
Updates `WithWorld` to be a `System`, so that functions in child contracts that use the `onlyWorld` or `onlyNamespace` modifiers must be called through the world in order to safely support calls from systems.

docs/pages/world/modules/erc20.mdx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ This module is unaudited and may change in the future.
1212
The [`erc20` module](https://github.com/latticexyz/mud/tree/main/packages/world-module-erc20/) lets you create [ERC-20](https://ethereum.org/en/developers/docs/standards/tokens/erc-20/) tokens as part of a MUD `World`.
1313
The advantage of doing this, rather than creating a separate [ERC-20 contract](https://github.com/OpenZeppelin/openzeppelin-contracts/tree/master/contracts/token/ERC20) and merely controlling it from MUD, is that all the information is in MUD tables and is immediately available in the client.
1414

15+
The token contract can be seen as a hybrid system contract which contains functions directly callable from outside the world (ERC20 functions like `transfer`, `balanceOf`, etc), and restricted functions that must be called as a system function through the World (`mint`, `pause`, etc).
16+
1517
The ERC20Module receives the namespace, name and symbol of the token as parameters, and deploys the new token. Currently it installs a [default ERC20](https://github.com/latticexyz/mud/tree/main/packages/world-module-erc20/src/examples/ERC20WithWorld.sol) with the following features:
1618

1719
- ERC20Burnable: Allows users to burn their tokens (or the ones approved to them) using the `burn` and `burnFrom` function.
18-
- ERC20Pausable: Supports pausing and unpausing token operations. This is combined with the `pause` and `unpause` public functions that can be called by addresses with access to the token's namespace.
19-
- Minting: Addresses with namespace access can call the `mint` function to mint tokens to any address.
20+
- ERC20Pausable: Supports pausing and unpausing token operations. This is combined with the `pause` and `unpause` public functions that can be called by addresses and systems with access to the token's namespace. Must be done through a World call.
21+
- Minting: Addresses and Systems with namespace access can call the `mint` function to mint tokens to any address. This must be done through a World call.
2022

2123
## Installation
2224

@@ -58,13 +60,15 @@ For example, run this script.
5860

5961
<CollapseCode>
6062

61-
```solidity filename="ManageERC20.s.sol" copy showLineNumbers {16,34-38,43,48-56}
63+
```solidity filename="ManageERC20.s.sol" copy showLineNumbers {18,36-41,46,51-60}
6264
import { Script } from "forge-std/Script.sol";
6365
import { console } from "forge-std/console.sol";
6466
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
6567
import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
6668
import { RESOURCE_TABLE } from "@latticexyz/store/src/storeResourceTypes.sol";
69+
import { IWorldCall } from "@latticexyz/world/src/IWorldKernel.sol";
6770
import { WorldResourceIdLib } from "@latticexyz/world/src/WorldResourceId.sol";
71+
import { SystemRegistry } from "@latticexyz/world/src/codegen/tables/SystemRegistry.sol";
6872
import { ERC20Registry } from "@latticexyz/world-module-erc20/src/codegen/index.sol";
6973
import { ERC20WithWorld as ERC20 } from "@latticexyz/world-module-erc20/src/examples/ERC20WithWorld.sol";
7074
@@ -96,6 +100,7 @@ contract ManageERC20 is Script {
96100
ResourceId namespaceResource = WorldResourceIdLib.encodeNamespace(bytes14("erc20Namespace"));
97101
ResourceId erc20RegistryResource = WorldResourceIdLib.encode(RESOURCE_TABLE, "erc20-module", "ERC20_REGISTRY");
98102
address tokenAddress = ERC20Registry.getTokenAddress(erc20RegistryResource, namespaceResource);
103+
ResourceId tokenSystem = SystemRegistry.get(tokenAddress);
99104
console.log("Token address", tokenAddress);
100105
101106
address alice = address(0x600D);
@@ -107,8 +112,9 @@ contract ManageERC20 is Script {
107112
reportBalances(erc20, myAddress);
108113
109114
// Mint some tokens
115+
// We must call the token system (instead of calling mint directly)
110116
console.log("Minting for myself");
111-
erc20.mint(myAddress, 1000);
117+
IWorldCall(worldAddress).call(tokenSystem, abi.encodeCall(ERC20.mint, (myAddress, 1000)));
112118
reportBalances(erc20, myAddress);
113119
114120
// Transfer tokens
@@ -138,12 +144,13 @@ contract ManageERC20 is Script {
138144
ResourceId namespaceResource = WorldResourceIdLib.encodeNamespace(bytes14("erc20Namespace"));
139145
ResourceId erc20RegistryResource = WorldResourceIdLib.encode(RESOURCE_TABLE, "erc20-module", "ERC20_REGISTRY");
140146
address tokenAddress = ERC20Registry.getTokenAddress(erc20RegistryResource, namespaceResource);
147+
ResourceId tokenSystem = SystemRegistry.get(tokenAddress);
141148
console.log("Token address", tokenAddress);
142149
```
143150

144-
This is the process to get the address of our token contract.
151+
This is the process to get the address of our token contract and the system id of the token.
145152
First, we get the [`resourceId` values](/world/resource-ids) for the `erc20-module__ERC20Registry` table and the namespace we are interested in (each namespace can only have one ERC-20 token).
146-
Then we use that table to get the token address.
153+
Then we use that table to get the token address. Finally, we obtain the token system id from the `SystemRegistry` table.
147154

148155
```solidity
149156
// Use the token
@@ -153,13 +160,15 @@ Then we use that table to get the token address.
153160
Cast the token address to an `ERC20` contract so we can call its methods.
154161

155162
```solidity
163+
// Mint some tokens
164+
// We must call the token system (instead of calling mint directly)
156165
console.log("Minting for myself");
157-
erc20.mint(myAddress, 1000);
166+
IWorldCall(worldAddress).call(tokenSystem, abi.encodeCall(ERC20.mint, (myAddress, 1000)));
158167
reportBalances(erc20, myAddress);
159168
```
160169

161170
Mint tokens for your address.
162-
Note that only the owner of the name space is authorized to mint tokens.
171+
Note that the mint function must be called through the world as a system function, as it is restricted to entities with access to the token's namespace.
163172

164173
```solidity
165174
console.log("Transfering to Alice");

docs/pages/world/reference/world-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ _This contract should only be used for contracts without their own storage, like
2020
Extract the `msg.sender` from the context appended to the calldata.
2121

2222
```solidity
23-
function _msgSender() public view returns (address sender);
23+
function _msgSender() public view virtual returns (address sender);
2424
```
2525

2626
**Returns**

packages/store-consumer/src/examples/SimpleVault.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ contract SimpleVault is WithWorld {
2525
constructor(IBaseWorld world, bytes14 namespace) WithWorld(world, namespace, false) {}
2626

2727
// Only accounts with namespace access (e.g. namespace systems) can transfer the ERC20 tokens held by this contract
28-
function transferTo(IERC20 token, address to, uint256 amount) external onlyNamespace {
28+
function transferTo(IERC20 token, address to, uint256 amount) external onlyWorld {
2929
require(token.transfer(to, amount), "Transfer failed");
3030
}
3131

packages/store-consumer/src/experimental/Context.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pragma solidity >=0.8.24;
44
// @dev Provides information about the current execution context
55
// We only use it in these contracts in case we want to extend it in the future
66
abstract contract Context {
7-
function _msgSender() internal view virtual returns (address) {
7+
function _msgSender() public view virtual returns (address) {
88
return msg.sender;
99
}
1010
}

packages/store-consumer/src/experimental/WithWorld.sol

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,32 @@ import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
66
import { ResourceAccess } from "@latticexyz/world/src/codegen/tables/ResourceAccess.sol";
77
import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol";
88
import { WorldResourceIdLib } from "@latticexyz/world/src/WorldResourceId.sol";
9+
import { WorldContextConsumer } from "@latticexyz/world/src/WorldContext.sol";
10+
import { System } from "@latticexyz/world/src/System.sol";
911

12+
import { Context } from "./Context.sol";
1013
import { WithStore } from "./WithStore.sol";
1114

12-
abstract contract WithWorld is WithStore {
15+
abstract contract WithWorld is WithStore, System {
1316
bytes14 public immutable namespace;
1417

1518
error WithWorld_RootNamespaceNotAllowed();
16-
error WithWorld_NamespaceAlreadyExists();
17-
error WithWorld_NamespaceDoesNotExists();
18-
error WithWorld_CallerHasNoNamespaceAccess();
19+
error WithWorld_NamespaceAlreadyExists(bytes14 namespace);
20+
error WithWorld_NamespaceDoesNotExists(bytes14 namespace);
21+
error WithWorld_CallerHasNoNamespaceAccess(bytes14 namespace, address caller);
22+
error WithWorld_CallerIsNotWorld(address caller);
23+
24+
modifier onlyWorld() {
25+
if (!_callerIsWorld()) {
26+
revert WithWorld_CallerIsNotWorld(msg.sender);
27+
}
28+
_;
29+
}
1930

2031
modifier onlyNamespace() {
21-
address sender = _msgSender();
22-
if (!ResourceAccess.get(getNamespaceId(), sender)) {
23-
revert WithWorld_CallerHasNoNamespaceAccess();
32+
// We use WorldContextConsumer directly as we already know the world is the caller
33+
if (!_callerIsWorld() || !ResourceAccess.get(getNamespaceId(), WorldContextConsumer._msgSender())) {
34+
revert WithWorld_CallerHasNoNamespaceAccess(namespace, _msgSender());
2435
}
2536
_;
2637
}
@@ -38,12 +49,12 @@ abstract contract WithWorld is WithStore {
3849

3950
if (registerNamespace) {
4051
if (namespaceExists) {
41-
revert WithWorld_NamespaceAlreadyExists();
52+
revert WithWorld_NamespaceAlreadyExists(_namespace);
4253
}
4354

4455
_world.registerNamespace(namespaceId);
4556
} else if (!namespaceExists) {
46-
revert WithWorld_NamespaceDoesNotExists();
57+
revert WithWorld_NamespaceDoesNotExists(_namespace);
4758
}
4859
}
4960

@@ -55,6 +66,14 @@ abstract contract WithWorld is WithStore {
5566
return IBaseWorld(getStore());
5667
}
5768

69+
function _msgSender() public view virtual override(Context, WorldContextConsumer) returns (address sender) {
70+
return _callerIsWorld() ? WorldContextConsumer._msgSender() : Context._msgSender();
71+
}
72+
73+
function _callerIsWorld() internal view returns (bool) {
74+
return msg.sender == address(getWorld());
75+
}
76+
5877
function _encodeResourceId(bytes2 typeId, bytes16 name) internal view virtual override returns (ResourceId) {
5978
return WorldResourceIdLib.encode(typeId, namespace, name);
6079
}

packages/store-consumer/test/StoreConsumer.t.sol

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { GasReporter } from "@latticexyz/gas-report/src/GasReporter.sol";
88

99
import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol";
1010
import { ResourceAccess } from "@latticexyz/world/src/codegen/tables/ResourceAccess.sol";
11-
import { WorldResourceIdLib } from "@latticexyz/world/src/WorldResourceId.sol";
11+
import { WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
12+
import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";
13+
import { IWorldErrors } from "@latticexyz/world/src/IWorldErrors.sol";
1214
import { createWorld } from "@latticexyz/world/test/createWorld.sol";
1315

1416
import { ResourceId, ResourceIdLib } from "@latticexyz/store/src/ResourceId.sol";
@@ -51,10 +53,16 @@ contract MockWithWorld is WithWorld, MockStoreConsumer {
5153
getWorld().transferOwnership(getNamespaceId(), to);
5254
}
5355

56+
function callableByAnyone() external view {}
57+
58+
function onlyCallableByWorld() external view onlyWorld {}
59+
5460
function onlyCallableByNamespace() external view onlyNamespace {}
5561
}
5662

5763
contract StoreConsumerTest is Test, GasReporter {
64+
using WorldResourceIdInstance for ResourceId;
65+
5866
function testWithStore() public {
5967
MockWithStore mock = new MockWithStore(address(0xBEEF));
6068
assertEq(mock.getStoreAddress(), address(0xBEEF));
@@ -77,23 +85,83 @@ contract StoreConsumerTest is Test, GasReporter {
7785
assertTrue(ResourceIds.getExists(namespaceId), "Namespace not registered");
7886
}
7987

88+
// Test internal MUD access control
89+
function testAccessControl() public {
90+
IBaseWorld world = createWorld();
91+
StoreSwitch.setStoreAddress(address(world));
92+
93+
bytes16 systemName = "mySystem";
94+
bytes14 namespace = "myNamespace";
95+
ResourceId namespaceId = WorldResourceIdLib.encodeNamespace(namespace);
96+
ResourceId systemId = WorldResourceIdLib.encode(RESOURCE_SYSTEM, namespace, systemName);
97+
MockWithWorld mock = new MockWithWorld(world, namespace, true);
98+
mock.transferNamespaceOwnership(address(this));
99+
100+
// Register the mock as a system with PRIVATE access
101+
world.registerSystem(systemId, mock, false);
102+
103+
address alice = address(0x1234);
104+
105+
vm.prank(alice);
106+
vm.expectRevert(abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, systemId.toString(), alice));
107+
world.call(systemId, abi.encodeCall(mock.callableByAnyone, ()));
108+
109+
// After granting access to namespace, it should work
110+
world.grantAccess(namespaceId, alice);
111+
vm.prank(alice);
112+
world.call(systemId, abi.encodeCall(mock.callableByAnyone, ()));
113+
}
114+
115+
function testOnlyWorld() public {
116+
IBaseWorld world = createWorld();
117+
StoreSwitch.setStoreAddress(address(world));
118+
119+
bytes16 systemName = "mySystem";
120+
bytes14 namespace = "myNamespace";
121+
ResourceId systemId = WorldResourceIdLib.encode(RESOURCE_SYSTEM, namespace, systemName);
122+
MockWithWorld mock = new MockWithWorld(world, namespace, true);
123+
mock.transferNamespaceOwnership(address(this));
124+
125+
// Register the mock as a system with PUBLIC access
126+
world.registerSystem(systemId, mock, true);
127+
128+
address alice = address(0x1234);
129+
130+
vm.prank(alice);
131+
vm.expectRevert(abi.encodeWithSelector(WithWorld.WithWorld_CallerIsNotWorld.selector, (alice)));
132+
mock.onlyCallableByWorld();
133+
134+
vm.prank(alice);
135+
world.call(systemId, abi.encodeCall(mock.onlyCallableByWorld, ()));
136+
}
137+
80138
function testOnlyNamespace() public {
81139
IBaseWorld world = createWorld();
82140
StoreSwitch.setStoreAddress(address(world));
83141

142+
bytes16 systemName = "mySystem";
84143
bytes14 namespace = "myNamespace";
85144
ResourceId namespaceId = WorldResourceIdLib.encodeNamespace(namespace);
145+
ResourceId systemId = WorldResourceIdLib.encode(RESOURCE_SYSTEM, namespace, systemName);
86146
MockWithWorld mock = new MockWithWorld(world, namespace, true);
87147
mock.transferNamespaceOwnership(address(this));
88148

149+
// Register the mock as a system with PUBLIC access
150+
world.registerSystem(systemId, mock, true);
151+
89152
address alice = address(0x1234);
90153

91154
vm.prank(alice);
92-
vm.expectRevert();
155+
vm.expectRevert(abi.encodeWithSelector(WithWorld.WithWorld_CallerHasNoNamespaceAccess.selector, namespace, alice));
93156
mock.onlyCallableByNamespace();
94157

158+
vm.prank(alice);
159+
vm.expectRevert(abi.encodeWithSelector(WithWorld.WithWorld_CallerHasNoNamespaceAccess.selector, namespace, alice));
160+
world.call(systemId, abi.encodeCall(mock.onlyCallableByNamespace, ()));
161+
162+
// After granting access to namespace, it should work
95163
world.grantAccess(namespaceId, alice);
96164
vm.prank(alice);
97-
mock.onlyCallableByNamespace();
165+
world.call(systemId, abi.encodeCall(mock.onlyCallableByNamespace, ()));
98166
}
99167
}

packages/store/ts/flattenStoreLogs.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ describe("flattenStoreLogs", async () => {
134134
"Store_SetRecord store__ResourceIds (0x746200000000000000000000000000005465727261696e000000000000000000)",
135135
"Store_SetRecord store__ResourceIds (0x737900000000000000000000000000004d6f766553797374656d000000000000)",
136136
"Store_SetRecord world__Systems (0x737900000000000000000000000000004d6f766553797374656d000000000000)",
137-
"Store_SetRecord world__SystemRegistry (0x00000000000000000000000008f2b45d8787be8a81869d9968f25323861352b0)",
138-
"Store_SetRecord world__ResourceAccess (0x6e73000000000000000000000000000000000000000000000000000000000000,0x00000000000000000000000008f2b45d8787be8a81869d9968f25323861352b0)",
137+
"Store_SetRecord world__SystemRegistry (0x0000000000000000000000006eb355773196079fe643ed78724140adb1f86c11)",
138+
"Store_SetRecord world__ResourceAccess (0x6e73000000000000000000000000000000000000000000000000000000000000,0x0000000000000000000000006eb355773196079fe643ed78724140adb1f86c11)",
139139
"Store_SetRecord world__FunctionSelector (0xb591186e00000000000000000000000000000000000000000000000000000000)",
140140
"Store_SetRecord world__FunctionSignatur (0xb591186e00000000000000000000000000000000000000000000000000000000)",
141141
"Store_SetRecord store__Tables (0x7462000000000000000000000000000043616c6c576974685369676e61747572)",

packages/store/ts/getStoreLogs.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ describe("getStoreLogs", async () => {
157157
"Store_SpliceStaticData store__ResourceIds (0x746200000000000000000000000000005465727261696e000000000000000000)",
158158
"Store_SpliceStaticData store__ResourceIds (0x737900000000000000000000000000004d6f766553797374656d000000000000)",
159159
"Store_SetRecord world__Systems (0x737900000000000000000000000000004d6f766553797374656d000000000000)",
160-
"Store_SpliceStaticData world__SystemRegistry (0x00000000000000000000000008f2b45d8787be8a81869d9968f25323861352b0)",
161-
"Store_SpliceStaticData world__ResourceAccess (0x6e73000000000000000000000000000000000000000000000000000000000000,0x00000000000000000000000008f2b45d8787be8a81869d9968f25323861352b0)",
160+
"Store_SpliceStaticData world__SystemRegistry (0x0000000000000000000000006eb355773196079fe643ed78724140adb1f86c11)",
161+
"Store_SpliceStaticData world__ResourceAccess (0x6e73000000000000000000000000000000000000000000000000000000000000,0x0000000000000000000000006eb355773196079fe643ed78724140adb1f86c11)",
162162
"Store_SetRecord world__FunctionSelector (0xb591186e00000000000000000000000000000000000000000000000000000000)",
163163
"Store_SetRecord world__FunctionSignatur (0xb591186e00000000000000000000000000000000000000000000000000000000)",
164164
"Store_SetRecord world__FunctionSignatur (0xb591186e00000000000000000000000000000000000000000000000000000000)",

0 commit comments

Comments
 (0)