Advanced features of Store

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.

Offchain tables

Tables with the offchainOnly 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",
      },
      offchainOnly: true,
    },
  },
});

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 keyTuple, bytes memory data) public {
    if (_tableId != tableId) revert("invalid tableId");
    StoreSwitch.setRecord(indexerTableId, keyTuple, data);
  }
 
  function onSetField(bytes32 tableId, bytes32[] memory keyTuple, uint8 schemaIndex, bytes memory data) public {
    if (_tableId != tableId) revert("invalid tableId");
    StoreSwitch.setField(indexerTableId, keyTuple, schemaIndex, data);
  }
 
  function onDeleteRecord(bytes32 tableId, bytes32[] memory keyTuple) public {
    if (_tableId != tableId) revert("invalid tableId");
    StoreSwitch.deleteRecord(indexerTableId, keyTuple);
  }
}

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 CALLing the systems instead of DELEGATECALLing 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 DELEGATECALLed: 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]