Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(store-consumer): extract store consumer contracts #3345

Merged
merged 10 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/nine-countries-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@latticexyz/store-consumer": patch
"@latticexyz/world-module-erc20": patch
---

Extracted StoreConsumer base contracts into an independent package.
Added a `registerNamespace` boolean to `WithWorld` to provide more control over namespace registration.
2 changes: 2 additions & 0 deletions packages/store-consumer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cache
out
1 change: 1 addition & 0 deletions packages/store-consumer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @latticexyz/store-consumer
11 changes: 11 additions & 0 deletions packages/store-consumer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Store Consumer Contracts

> :warning: **Important note: these contracts have not been audited yet, so any production use is discouraged for now.**

This set of contracts provides an easy way to make use of a `Store` for read and write operations, with the possibility of fully abstracting away the type of underlying store being used.

- `StoreConsumer`: all contracts that don't explicitly need to know which type of store is being used can inherit from `StoreConsumer`, which abstracts the way in which `ResourceId`s are encoded. This allows us to have composable contracts whose implementations don't depend on the type of the underlying Store.
- `WithStore(address) is StoreConsumer`: this contract initializes the store, using the contract's internal storage or the provided external `Store`. It encodes `ResourceId`s using `ResourceIdLib` from the `@latticexyz/store` package.
- `WithWorld(IBaseWorld, bytes14) is WithStore`: initializes the store and also registers the provided namespace in the provided World. It encodes `ResourceId`s using `WorldResourceIdLib` (using the namespace). It also provides an `onlyNamespace` modifier, which can be used to restrict access to certain functions, only allowing calls from addresses that have access to the namespace.

For examples of how these are used in practice you can check the [examples directory](./src/examples/) or our [ERC20 World Module](../world-module-erc20/).
15 changes: 15 additions & 0 deletions packages/store-consumer/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[profile.default]
solc = "0.8.24"
ffi = false
fuzz_runs = 256
optimizer = true
optimizer_runs = 3000
verbosity = 2
allow_paths = ["../../node_modules", "../"]
src = "src"
out = "out"
bytecode_hash = "none"
extra_output_files = [
"abi",
"evm.bytecode"
]
1 change: 1 addition & 0 deletions packages/store-consumer/gas-report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
53 changes: 53 additions & 0 deletions packages/store-consumer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@latticexyz/store-consumer",
"version": "2.2.14",
"description": "Store Consumer Contracts",
"repository": {
"type": "git",
"url": "https://github.com/latticexyz/mud.git",
"directory": "packages/store-consumer"
},
"license": "MIT",
"type": "module",
"exports": {
"./out/*": "./out/*"
},
"typesVersions": {
"*": {}
},
"files": [
"out",
"src"
],
"scripts": {
"build": "pnpm run build:abi && pnpm run build:abi-ts",
"build:abi": "forge build",
"build:abi-ts": "abi-ts",
"clean": "pnpm run clean:abi && pnpm run clean:js",
"clean:abi": "forge clean",
"clean:js": "shx rm -rf dist",
"clean:mud": "shx rm -rf src/**/codegen",
"dev": "tsup --watch",
"gas-report": "gas-report --save gas-report.json",
"lint": "solhint --config ./.solhint.json 'src/**/*.sol'",
"test": "forge test",
"test:ci": "pnpm run test"
},
"dependencies": {
"@latticexyz/schema-type": "workspace:*",
"@latticexyz/store": "workspace:*",
"@latticexyz/world": "workspace:*"
},
"devDependencies": {
"@latticexyz/abi-ts": "workspace:*",
"@latticexyz/gas-report": "workspace:*",
"@types/node": "^18.15.11",
"forge-std": "https://github.com/foundry-rs/forge-std.git#1eea5bae12ae557d589f9f0f0edae2faa47cb262",
"solhint": "^3.3.7",
"tsup": "^6.7.0",
"vitest": "0.34.6"
},
"publishConfig": {
"access": "public"
}
}
2 changes: 2 additions & 0 deletions packages/store-consumer/remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
forge-std/=node_modules/forge-std/src/
@latticexyz/=node_modules/@latticexyz/
33 changes: 33 additions & 0 deletions packages/store-consumer/src/examples/SimpleVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol";
import { NamespaceOwner } from "@latticexyz/world/src/codegen/tables/NamespaceOwner.sol";

import { WithWorld } from "../experimental/WithWorld.sol";

interface IERC20 {
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}

/**
* @title SimpleVault (NOT AUDITED)
* @dev Simple example of a Vault that allows accounts with namespace access to transfer its tokens out
* IMPORTANT: this contract expects an existing namespace
*/
contract SimpleVault is WithWorld {
error SimpleVault_TransferFailed();

constructor(IBaseWorld world, bytes14 namespace) WithWorld(world, namespace, false) {}

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

// ... other methods to deposit, etc
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { ResourceIds } from "@latticexyz/store/src/codegen/tables/ResourceIds.sol";
import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
import { ResourceAccess } from "@latticexyz/world/src/codegen/tables/ResourceAccess.sol";
import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol";
Expand All @@ -11,6 +12,9 @@ import { WithStore } from "./WithStore.sol";
abstract contract WithWorld is WithStore {
bytes14 public immutable namespace;

error WithWorld_RootNamespaceNotAllowed();
error WithWorld_NamespaceAlreadyExists();
error WithWorld_NamespaceDoesNotExists();
error WithWorld_CallerHasNoNamespaceAccess();

modifier onlyNamespace() {
Expand All @@ -21,19 +25,36 @@ abstract contract WithWorld is WithStore {
_;
}

constructor(IBaseWorld world, bytes14 _namespace) WithStore(address(world)) {
constructor(IBaseWorld _world, bytes14 _namespace, bool registerNamespace) WithStore(address(_world)) {
if (_namespace == bytes14(0)) {
revert WithWorld_RootNamespaceNotAllowed();
}

namespace = _namespace;

ResourceId namespaceId = getNamespaceId();

// This will revert if namespace already exists
world.registerNamespace(namespaceId);
bool namespaceExists = ResourceIds.getExists(namespaceId);

if (registerNamespace) {
if (namespaceExists) {
revert WithWorld_NamespaceAlreadyExists();
}

_world.registerNamespace(namespaceId);
} else if (!namespaceExists) {
revert WithWorld_NamespaceDoesNotExists();
}
}

function getNamespaceId() public view returns (ResourceId) {
return WorldResourceIdLib.encodeNamespace(namespace);
}

function getWorld() public view returns (IBaseWorld) {
return IBaseWorld(getStore());
}

function _encodeResourceId(bytes2 typeId, bytes16 name) internal view virtual override returns (ResourceId) {
return WorldResourceIdLib.encode(typeId, namespace, name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { Tables, ResourceIds } from "@latticexyz/store/src/codegen/index.sol";
import { StoreCore } from "@latticexyz/store/src/Store.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";

import { StoreConsumer } from "../src/StoreConsumer.sol";
import { WithStore } from "../src/WithStore.sol";
import { WithWorld } from "../src/WithWorld.sol";
import { StoreConsumer } from "../src/experimental/StoreConsumer.sol";
import { WithStore } from "../src/experimental/WithStore.sol";
import { WithWorld } from "../src/experimental/WithWorld.sol";

abstract contract MockStoreConsumer is StoreConsumer {
function getStoreAddress() public view virtual returns (address) {
Expand All @@ -37,14 +37,21 @@ contract MockWithStore is WithStore, MockStoreConsumer {
contract MockWithInternalStore is MockWithStore(address(this)) {}

contract MockWithWorld is WithWorld, MockStoreConsumer {
constructor(IBaseWorld world, bytes14 namespace) WithWorld(world, namespace) {
ResourceId namespaceId = getNamespaceId();
world.grantAccess(namespaceId, address(this));
constructor(
IBaseWorld world,
bytes14 namespace,
bool registerNamespace
) WithWorld(world, namespace, registerNamespace) {}

function grantNamespaceAccess(address to) external {
getWorld().grantAccess(getNamespaceId(), to);
}

// Transfer ownership to the creator so we can test `onlyNamespace`
world.transferOwnership(namespaceId, _msgSender());
function transferNamespaceOwnership(address to) external {
getWorld().transferOwnership(getNamespaceId(), to);
}
function onlyCallableByNamespace() public view onlyNamespace {}

function onlyCallableByNamespace() external view onlyNamespace {}
}

contract StoreConsumerTest is Test, GasReporter {
Expand All @@ -61,7 +68,7 @@ contract StoreConsumerTest is Test, GasReporter {
function testWithWorld() public {
IBaseWorld world = createWorld();
bytes14 namespace = "myNamespace";
MockWithWorld mock = new MockWithWorld(world, namespace);
MockWithWorld mock = new MockWithWorld(world, namespace, true);
assertEq(mock.getStoreAddress(), address(world));

StoreSwitch.setStoreAddress(address(world));
Expand All @@ -72,10 +79,12 @@ contract StoreConsumerTest is Test, GasReporter {

function testOnlyNamespace() public {
IBaseWorld world = createWorld();
StoreSwitch.setStoreAddress(address(world));

bytes14 namespace = "myNamespace";
ResourceId namespaceId = WorldResourceIdLib.encodeNamespace(namespace);
MockWithWorld mock = new MockWithWorld(world, namespace);
StoreSwitch.setStoreAddress(address(world));
MockWithWorld mock = new MockWithWorld(world, namespace, true);
mock.transferNamespaceOwnership(address(this));

address alice = address(0x1234);

Expand Down
7 changes: 7 additions & 0 deletions packages/store-consumer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["mud.config.ts", "ts"]
}
12 changes: 4 additions & 8 deletions packages/world-module-erc20/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@

## ERC20 contracts

In order to achieve a similar level of composability to [`OpenZeppelin` ERC20 contract extensions](https://docs.openzeppelin.com/contracts/5.x/api/token/erc20), we provide a way to abstract the underlying Store being used. This allows developers to easily create ERC20 tokens that can either use its own storage as the Store, or attach themselves to an existing World.
In order to achieve a similar level of composability to [`OpenZeppelin` ERC20 contract extensions](https://docs.openzeppelin.com/contracts/5.x/api/token/erc20), we use our experimental [`StoreConsumer` contract](../store-consumer) to abstract the underlying Store being used. This allows developers to easily create ERC20 tokens that can either use its own storage as the Store, or attach themselves to an existing World.

- `StoreConsumer`: all contracts inherit from `StoreConsumer`, which abstracts the way in which `ResourceId`s are encoded. This allows us to have composable contracts whose implementations don't depend on the type of Store being used.
- `WithStore(address) is StoreConsumer`: this contract initializes the store, using the contract's internal storage or the provided external `Store`. It encodes `ResourceId`s using `ResourceIdLib` from the `@latticexyz/store` package.
- `WithWorld(IBaseWorld, bytes14) is WithStore`: initializes the store and also registers the provided namespace in the provided World. It encodes `ResourceId`s using `WorldResourceIdLib` (using the namespace). It also provides an `onlyNamespace` modifier, which can be used to restrict access to certain functions, only allowing calls from addresses that have access to the namespace.
The `MUDERC20` contract is the base ERC20 implementation adapted from Openzeppelin's ERC20. Contains the ERC20 logic, reads/writes to the store through MUD's codegen libraries and initializes the tables it needs. As these libraries use `StoreSwitch` internally, this contract doesn't need to know about the store it's interacting with (it can be internal storage, an external `Store` or a `World`).

- `MUDERC20`: base ERC20 implementation adapted from Openzeppelin's ERC20. Contains the ERC20 logic, reads/writes to the store through MUD's codegen libraries and initializes the tables it needs. As these libraries use `StoreSwitch` internally, this contract doesn't need to know about the store it's interacting with (it can be internal storage, an external `Store` or a `World`).

- Extensions and other contracts: contracts like `Ownable`, `Pausable`, `ERC20Burnable`, etc are adapted from `OpenZeppelin` contracts to use MUD's codegen libraries to read and write from a `Store`. They inherit from `StoreConsumer`, so they can obtain the `ResourceId` for the tables they use using `_encodeResourceId()`.
Extensions and other contracts: contracts like `Ownable`, `Pausable`, `ERC20Burnable`, etc are adapted from `OpenZeppelin` contracts to use MUD's codegen libraries to read and write from a `Store`. They inherit from `StoreConsumer`, so they can obtain the `ResourceId` for the tables they use using `_encodeResourceId()`.

### Example 1: Using the contract's storage

Expand Down Expand Up @@ -53,7 +49,7 @@ contract ERC20WithWorld is WithWorld, MUDERC20, ERC20Pausable, ERC20Burnable {
bytes14 namespace,
string memory name,
string memory symbol
) WithWorld(world, namespace) MUDERC20(name, symbol) {
) WithWorld(world, namespace, true) MUDERC20(name, symbol) {
// transfer namespace ownership to the creator
world.transferOwnership(getNamespaceId(), _msgSender());
}
Expand Down
14 changes: 7 additions & 7 deletions packages/world-module-erc20/gas-report.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"file": "test/ERC20BaseTest.sol:ERC20WithWorldTest",
"test": "testTransfer",
"name": "world_transfer",
"gasUsed": 82184
"gasUsed": 82206
},
{
"file": "test/ERC20BaseTest.sol:ERC20WithWorldTest",
Expand Down Expand Up @@ -105,7 +105,7 @@
"file": "test/ERC20Burnable.t.sol:ERC20BurnableWithWorldTest",
"test": "testBurn",
"name": "world_burn",
"gasUsed": 70689
"gasUsed": 70734
},
{
"file": "test/ERC20Burnable.t.sol:ERC20BurnableWithWorldTest",
Expand All @@ -123,7 +123,7 @@
"file": "test/ERC20Burnable.t.sol:ERC20BurnableWithWorldTest",
"test": "testTransfer",
"name": "world_transfer",
"gasUsed": 82206
"gasUsed": 82162
},
{
"file": "test/ERC20Burnable.t.sol:ERC20BurnableWithWorldTest",
Expand All @@ -135,7 +135,7 @@
"file": "test/ERC20Module.t.sol:ERC20ModuleTest",
"test": "testInstall",
"name": "install erc20 module",
"gasUsed": 4487618
"gasUsed": 4499347
},
{
"file": "test/ERC20Pausable.t.sol:ERC20PausableWithInternalStoreTest",
Expand Down Expand Up @@ -195,19 +195,19 @@
"file": "test/ERC20Pausable.t.sol:ERC20PausableWithWorldTest",
"test": "testMint",
"name": "world_mint",
"gasUsed": 109674
"gasUsed": 109697
},
{
"file": "test/ERC20Pausable.t.sol:ERC20PausableWithWorldTest",
"test": "testPause",
"name": "world_pause",
"gasUsed": 66034
"gasUsed": 66012
},
{
"file": "test/ERC20Pausable.t.sol:ERC20PausableWithWorldTest",
"test": "testPause",
"name": "world_unpause",
"gasUsed": 44119
"gasUsed": 44097
},
{
"file": "test/ERC20Pausable.t.sol:ERC20PausableWithWorldTest",
Expand Down
1 change: 1 addition & 0 deletions packages/world-module-erc20/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"dependencies": {
"@latticexyz/schema-type": "workspace:*",
"@latticexyz/store": "workspace:*",
"@latticexyz/store-consumer": "workspace:*",
"@latticexyz/world": "workspace:*"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/world-module-erc20/remappings.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
ds-test/=node_modules/ds-test/src/
forge-std/=node_modules/forge-std/src/
@latticexyz/=node_modules/@latticexyz/
@latticexyz/=node_modules/@latticexyz/
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ pragma solidity >=0.8.24;

// Adapted example from OpenZeppelin's Contract Wizard: https://wizard.openzeppelin.com/

import { WithStore } from "../WithStore.sol";
import { Ownable } from "../Ownable.sol";
import { ERC20Pausable } from "../ERC20Pausable.sol";
import { ERC20Burnable } from "../ERC20Burnable.sol";
import { MUDERC20 } from "../MUDERC20.sol";
import { WithStore } from "@latticexyz/store-consumer/src/experimental/WithStore.sol";
import { Ownable } from "../experimental/Ownable.sol";
import { ERC20Pausable } from "../experimental/ERC20Pausable.sol";
import { ERC20Burnable } from "../experimental/ERC20Burnable.sol";
import { MUDERC20 } from "../experimental/MUDERC20.sol";

contract ERC20WithInternalStore is WithStore(address(this)), MUDERC20, ERC20Pausable, ERC20Burnable, Ownable {
constructor() MUDERC20("MyERC20", "MTK") Ownable(_msgSender()) {}
Expand Down
Loading