Additional features of Store
Singleton table
Store also supports “Singleton” tables, which are tables with a single record. Singleton tables don’t have keys.
They are useful to store top-level state.
Defining them in the config looks like this:
import { mudConfig } from "@latticexyz/store/register";
export default mudConfig({
tables: {
CounterSingleton: {
keySchema: {},
valueSchema: "uint256",
},
},
});
And working with these tables from generated libraries look like this:
// Setting the singleton
CounterSingleton.set(1);
// Getting data from the singleton
uint256 value = CounterSingleton.get();
Additional documentation on Table library generation can be found in the tablegen
section.
Ephemeral tables
Tables with the ephemeral
property do not write to on-chain storage.
Instead, they simply emit events when records are set.
They are useful for sending data to connected clients without the gas costs associated with a storage write.
Defining them in the config looks like this:
import { mudConfig } from "@latticexyz/store/register";
export default mudConfig({
tables: {
TradeExecuted: {
valueSchema: {
amount: "uint32",
receiver: "bytes32",
},
ephemeral: true,
},
},
});
This will slightly change the generated code for the table. The emitEphemeral
function is the main entrypoint:
import { TradeExecuted, TradeExecutedData } from "./codegen/Tables.sol";
// Emitting an ephemeral record
TradeExecuted.emitEphemeral("0x1234", TradeExecutedData({
amount: 10,
receiver: "0x5678",
}));
get
, set
, and delete
functions are not generated for ephemeral tables.
Storage hooks
It is possible to register hooks on tables, allowing additional logic to be executed when records or fields are updated. Use cases include: creating indices on another table (like the ownerOf
mapping of the ERC-721 spec as an example), emitting events on a different contract, or simply additional access-control and checks by reverting in the hook.
This is an example of a Mirror hook which mirrors a table into another one:
bytes32 constant indexerTableId = bytes32("indexer.table");
contract MirrorSubscriber is IStoreHook {
bytes32 _tableId;
constructor(bytes32 tableId, Schema keySchema, Schema valueSchema) {
IStore(msg.sender).registerSchema(indexerTableId, valueSchema, keySchema);
_tableId = tableId;
}
function onSetRecord(bytes32 tableId, bytes32[] memory key, bytes memory data) public {
if (_tableId != tableId) revert("invalid tableId");
StoreSwitch.setRecord(indexerTableId, key, data);
}
function onSetField(bytes32 tableId, bytes32[] memory key, uint8 schemaIndex, bytes memory data) public {
if (_tableId != tableId) revert("invalid tableId");
StoreSwitch.setField(indexerTableId, key, schemaIndex, data);
}
function onDeleteRecord(bytes32 tableId, bytes32[] memory key) public {
if (_tableId != tableId) revert("invalid tableId");
StoreSwitch.deleteRecord(indexerTableId, key);
}
}
Registering the hook can be done using the low-level Store API:
bytes32 tableId = bytes32("table");
Schema valueSchema = SchemaLib.encode(SchemaType.UINT256, SchemaType.UINT256);
Schema keySchema = SchemaLib.encode(SchemaType.UINT256);
MirrorSubscriber subscriber = new MirrorSubscriber(tableId, keySchema, valueSchema);
StoreCore.registerStoreHook(tableId, subscriber);
Accessing Store from a CALL
or DELEGATECALL
transparently
When spreading application logic over multiple contracts or wanting to enable upgrades, it becomes necessary to DELEGATECALL
the logic from a contract holding the state of the Store to an implementation contract. This is what happens with the proxy pattern (opens in a new tab) and the diamond pattern (opens in a new tab) (which is itself a form of proxy).
This works transparently, and it is possible for implementation contracts (or facets if using a diamond proxy) to use the Store low-level API when being called via DELEGATECALL
.
In contrast to proxies and diamonds, the MUD World framework mediates access to the Store at the table level for each implementation contract. This is unlike DELEGATECALL
-based solution where implementation contracts can write to any Table in the Store, or even corrupt the entire storage layout.
With World, different logic contracts — called “system” when using the World framework — can have access to different tables, and writes to unauthorized tables will revert. This whitelisting is handled by the World directly. This is done by CALL
ing the systems instead of DELEGATECALL
ing them. The system then sends read and write requests back to the World contract which holds the Store state and the access control logic.
With World, some systems can be DELEGATECALL
ed: they are called root systems. In order to make working with Store in both situations transparent to the developer and allow for root systems to become regular systems after deployment (and vice-versa), the code-generated table libraries use StoreSwitch
under the hood, which is a wrapper around StoreCore
that checks if a Store has been initialized in the storage of the contract or if a Store exists at the msg.sender
.
It executes reads and writes to the Store in its storage in case there exists one, or sends the read and write calls to the msg.sender
if not.
[2 diagram with the two types of systems: one root one non-root, and the same MyTable
code inside each system. Arrows that show writing to storage or sending it to the world. show storage of the World being borrowed with the root system using DELEGATECALL]