The onchain components of the template

The onchain components of the template

You installed the template code, and now you can increment the counter with the best of them. Unfortunately, when you tried to figure out what is happening, it is as clear as mud. Have no fear, in this tutorial you learn how to understand the onchain components of the minimal template.

The onchain components are stored in packages/contracts. The onchain components can be divided into two types of functionality:

  • Data, the part of the system that stores the information.
  • Logic, the part of the system that can be called to read or modify the data.

Data

The data schema

mud.config.ts

The data schema is declared in packages/contracts/mud.config.ts. Read more details about the schema definition here.

The data schema provided in the example is extremely simple (one singleton). To see more of how it works, lets add a second table:

mud.config.ts
import { mudConfig } from "@latticexyz/world/register";
 
export default mudConfig({
  tables: {
    Counter: {
      keySchema: {},
      valueSchema: "uint32",
    },
    Users: {
      keySchema: {
        user: "address",
      },
      valueSchema: {
        score: "uint32",
        name: "string",
      },
    },
  },
});

This definition is roughly equivalent to this Solidity code:

contract Example {
  uint32 Counter;
 
  struct User {
    uint32 score;
    string name;
  }
 
  mapping(address => User) Users;
}

If you are running pnpm dev the data schema is updated automatically. Otherwise, the command to apply changes to the data schema is

pnpm mud tablegen

When you update the schema, you get three files (one for each table and one that imports the data from them):

  • packages/contracts/src/codegen/Tables.sol
  • packages/contracts/src/codegen/tables/Counter.sol
  • packages/contracts/src/codegen/tables/Users.sol

Click below for an explanation of these files.

The autogenerated files

Tables.sol

Tables.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
 
/* Autogenerated file. Do not edit manually. */
 
import { Counter, CounterTableId } from "./tables/Counter.sol";
import { Users, UsersData, UsersTableId } from "./tables/Users.sol";

This file just imports the definitions of all the tables defined in the schema.

Counter.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
 
/* Autogenerated file. Do not edit manually. */
 
// Import schema type
import { SchemaType } from "@latticexyz/schema-type/src/solidity/SchemaType.sol";
 
// Import store internals
import { IStore } from "@latticexyz/store/src/IStore.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
import { StoreCore } from "@latticexyz/store/src/StoreCore.sol";
import { Bytes } from "@latticexyz/store/src/Bytes.sol";
import { Memory } from "@latticexyz/store/src/Memory.sol";
import { SliceLib } from "@latticexyz/store/src/Slice.sol";
import { EncodeArray } from "@latticexyz/store/src/tightcoder/EncodeArray.sol";
import { Schema, SchemaLib } from "@latticexyz/store/src/Schema.sol";
import { PackedCounter, PackedCounterLib } from "@latticexyz/store/src/PackedCounter.sol";

These are various definitions required for a mud table. You don't typically need to worry about them.

bytes32 constant _tableId = bytes32(abi.encodePacked(bytes16(""), bytes16("Counter")));
bytes32 constant CounterTableId = _tableId;

One advantage of mud is that the metadata, the information that describes the data we are managing, is available onchain. For table IDs the first sixteen characters are the namespace of the table, followed by sixteen characters of the name of the table itself. The default namespace is empty.

library Counter {
  /** Get the table's value schema */
  function getValueSchema() internal pure returns (Schema) {
    SchemaType[] memory _valueSchema = new SchemaType[](1);
    _valueSchema[0] = SchemaType.UINT32;
 
    return SchemaLib.encode(_valueSchema);
  }
 
  function getKeySchema() internal pure returns (Schema) {
    SchemaType[] memory _keySchema = new SchemaType[](0);
 
    return SchemaLib.encode(_keySchema);
  }

These two functions return the schema, the different types of fields, for the value and the key. In this case, there is nothing in the key because the table only has a single row.

The list of field types is available here (opens in a new tab).

/** Get the table's metadata */
function getMetadata() internal pure returns (string memory, string[] memory) {
  string[] memory _fieldNames = new string[](1);
  _fieldNames[0] = "value";
  return ("Counter", _fieldNames);
}

This function returns the name of the table and the names of the fields in it. In this case there is only one, and as we didn't specify the name in mud.config.ts it is called by the default, value.

/** Register the table's schema */
function registerSchema() internal {
  StoreSwitch.registerSchema(_tableId, getSchema(), getKeySchema());
}
 
/** Register the table's schema (using the specified store) */
function registerSchema(IStore _store) internal {
  _store.registerSchema(_tableId, getSchema(), getKeySchema());
}

These two functions register the schema, either to the default Store (StoreSwitch) or to one given as a parameter.

/** Set the table's metadata */
function setMetadata() internal {
  (string memory _tableName, string[] memory _fieldNames) = getMetadata();
  StoreSwitch.setMetadata(_tableId, _tableName, _fieldNames);
}
 
/** Set the table's metadata (using the specified store) */
function setMetadata(IStore _store) internal {
  (string memory _tableName, string[] memory _fieldNames) = getMetadata();
  _store.setMetadata(_tableId, _tableName, _fieldNames);
}

These two functions register the metadata, the table and column names, either to the default Store (StoreSwitch) or to one given as a parameter.

/** Get value */
function get() internal view returns (uint32 value) {
  bytes32[] memory _keyTuple = new bytes32[](0);
 
  bytes memory _blob = StoreSwitch.getField(_tableId, _keyTuple, 0);
  return (uint32(Bytes.slice4(_blob, 0)));
}
 
/** Get value (using the specified store) */
function get(IStore _store) internal view returns (uint32 value) {
  bytes32[] memory _keyTuple = new bytes32[](0);
 
  bytes memory _blob = _store.getField(_tableId, _keyTuple, 0);
  return (uint32(Bytes.slice4(_blob, 0)));
}

These two functions read the value. In this case there is only one value and there are no keys, so they just get the first entry, the one with index zero.

/** Set value */
function set(uint32 value) internal {
  bytes32[] memory _keyTuple = new bytes32[](0);
 
  StoreSwitch.setField(_tableId, _keyTuple, 0, abi.encodePacked((value)));
}
 
/** Set value (using the specified store) */
function set(IStore _store, uint32 value) internal {
  bytes32[] memory _keyTuple = new bytes32[](0);
 
  _store.setField(_tableId, _keyTuple, 0, abi.encodePacked((value)));
}

These two functions overwrite the value.

/** Tightly pack full data using this table's schema */
function encode(uint32 value) internal view returns (bytes memory) {
  return abi.encodePacked(value);
}
 
/** Encode keys as a bytes32 array using this table's schema */
function encodeKeyTuple() internal pure returns (bytes32[] memory _keyTuple) {
  _keyTuple = new bytes32[](0);
}

Utility functions to encode a value.

 
  /* Delete all data for given keys */
  function deleteRecord() internal {
    bytes32[] memory _keyTuple = new bytes32[](0);
 
    StoreSwitch.deleteRecord(_tableId, _keyTuple);
  }
 
  /* Delete all data for given keys (using the specified store) */
  function deleteRecord(IStore _store) internal {
    bytes32[] memory _keyTuple = new bytes32[](0);
 
    _store.deleteRecord(_tableId, _keyTuple);
  }
}

These functions delete the value. Normally it would be the value associated with the a key provided as a paramter, but in this case there is no parameter.

Users.sol

This file is very similar to Counter.sol, so I will only explain the parts that are different.

struct UsersData {
  uint32 score;
  string name;
}

A struct to hold all the schema fields. By default this struct is created whenever there are multiple input fields.

function getKeySchema() internal pure returns (Schema) {
  SchemaType[] memory _schema = new SchemaType[](1);
  _schema[0] = SchemaType.ADDRESS;
 
  return SchemaLib.encode(_schema);
}

In contrast to Counter, this table does have a key, so it has a real key schema.

  /** Get score */
  function getScore(address user) internal view returns (uint32 score) {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    bytes memory _blob = StoreSwitch.getField(_tableId, _keyTuple, 0);
    return (uint32(Bytes.slice4(_blob, 0)));
  }
 
  /** Get score (using the specified store) */
  function getScore(IStore _store, address user) internal view returns (uint32 score) {
     ...
  }
 
  /** Set score */
  function setScore(address user, uint32 score) internal {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    StoreSwitch.setField(_tableId, _keyTuple, 0, abi.encodePacked((score)));
  }
 
  /** Set score (using the specified store) */
  function setScore(IStore _store, address user, uint32 score) internal {
     ...
  }
 
  /** Get name */
  function getName(address user) internal view returns (string memory name) {
     ...
  }
 
  /** Get name (using the specified store) */
  function getName(IStore _store, address user) internal view returns (string memory name) {
     ...
  }
 
  /** Set name */
  function setName(address user, string memory name) internal {
     ...
  }
 
  /** Set name (using the specified store) */
  function setName(IStore _store, address user, string memory name) internal {
     ...
  }

When there are multiple fields in the record, get and set functions are provided for each field.

  /** Get the length of name */
  function lengthName(address user) internal view returns (uint256) {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    uint256 _byteLength = StoreSwitch.getFieldLength(_tableId, _keyTuple, 1, getSchema());
    return _byteLength / 1;
  }
 
  /** Get the length of name (using the specified store) */
  function lengthName(IStore _store, address user) internal view returns (uint256) {
    ...
    uint256 _byteLength = _store.getFieldLength(_tableId, _keyTuple, 1, getSchema());
    return _byteLength / 1;
  }

A string is an array, so Name also gets the functions that arrays get - for example, the function to get the array length. The division by one is because strings are one byte per character.

  /** Get an item of name (unchecked, returns invalid data if index overflows) */
  function getItemName(address user, uint256 _index) internal view returns (string memory) {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    bytes memory _blob = StoreSwitch.getFieldSlice(_tableId, _keyTuple, 1, getSchema(), _index * 1, (_index + 1) * 1);
    return (string(_blob));
  }
 
  /** Get an item of name (using the specified store) (unchecked, returns invalid data if index overflows) */
  function getItemName(IStore _store, address user, uint256 _index) internal view returns (string memory) {
    ...
    bytes memory _blob = _store.getFieldSlice(_tableId, _keyTuple, 1, getSchema(), _index * 1, (_index + 1) * 1);
    return (string(_blob));
  }

Another array function, this one gets the n'th item (in this case, character).

  /** Push a slice to name */
  function pushName(address user, string memory _slice) internal {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    StoreSwitch.pushToField(_tableId, _keyTuple, 1, bytes((_slice)));
  }
 
  /** Push a slice to name (using the specified store) */
  function pushName(IStore _store, address user, string memory _slice) internal {
    ...
    _store.pushToField(_tableId, _keyTuple, 1, bytes((_slice)));
  }
 
  /** Pop a slice from name */
  function popName(address user) internal {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    StoreSwitch.popFromField(_tableId, _keyTuple, 1, 1);
  }
 
  /** Pop a slice from name (using the specified store) */
  function popName(IStore _store, address user) internal {
    ...
    _store.popFromField(_tableId, _keyTuple, 1, 1);
  }

Add and remove items from the end of the array.

  /** Update a slice of name at `_index` */
  function updateName(address user, uint256 _index, string memory _slice) internal {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    StoreSwitch.updateInField(_tableId, _keyTuple, 1, _index * 1, bytes((_slice)));
  }
 
  /** Update a slice of name (using the specified store) at `_index` */
  function updateName(IStore _store, address user, uint256 _index, string memory _slice)
 internal {
    ...
    _store.updateInField(_tableId, _keyTuple, 1, _index * 1, bytes((_slice)));
  }

Update a slice of the "array".

  /** Get the full data */
  function get(address user) internal view returns (UsersData memory _table) {
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    bytes memory _blob = StoreSwitch.getRecord(_tableId, _keyTuple, getSchema());
    return decode(_blob);
  }
 
  /** Get the full data (using the specified store) */
  function get(IStore _store, address user) internal view returns (UsersData memory _tab
le) {
    ....
    bytes memory _blob = _store.getRecord(_tableId, _keyTuple, getSchema());
    return decode(_blob);
  }
 
  /** Set the full data using individual values */
  function set(address user, uint32 score, string memory name) internal {
    bytes memory _data = encode(score, name);
 
    bytes32[] memory _keyTuple = new bytes32[](1);
    _keyTuple[0] = bytes32(uint256(uint160(user)));
 
    StoreSwitch.setRecord(_tableId, _keyTuple, _data);
  }
 
  /** Set the full data using individual values (using the specified store) */
  function set(IStore _store, address user, uint32 score, string memory name) internal {
    ...
    _store.setRecord(_tableId, _keyTuple, _data);
  }
 
  /** Set the full data using the data struct */
  function set(address user, UsersData memory _table) internal {
    set(user, _table.score, _table.name);
  }
 
  /** Set the full data using the data struct (using the specified store) */
  function set(IStore _store, address user, UsersData memory _table) internal {
    set(_store, user, _table.score, _table.name);
  }

Get and set the entire row of user data.

/** Decode the tightly packed blob using this table's schema */
function decode(bytes memory _blob) internal view returns (UsersData memory _table) {
  // 4 is the total byte length of static data
  PackedCounter _encodedLengths = PackedCounter.wrap(Bytes.slice32(_blob, 4));
 
  _table.score = (uint32(Bytes.slice4(_blob, 0)));
 
  // Store trims the blob if dynamic fields are all empty
  if (_blob.length > 4) {
    uint256 _start;
    // skip static data length + dynamic lengths word
    uint256 _end = 36;
 
    _start = _end;
    _end += _encodedLengths.atIndex(0);
    _table.name = (string(SliceLib.getSubslice(_blob, _start, _end).toBytes()));
  }
}

The first four bytes are the uint32 score, which is always present. The string name, however, might be empty, in which case nothing will be returned there. If there is a name, it is encoded in the standard Ethereum manner for strings, a 32 byte length followed by the data.

/** Tightly pack full data using this table's schema */
function encode(uint32 score, string memory name) internal view returns (bytes memory) {
  uint40[] memory _counters = new uint40[](1);
  _counters[0] = uint40(bytes(name).length);
  PackedCounter _encodedLengths = PackedCounterLib.pack(_counters);
 
  return abi.encodePacked(score, _encodedLengths.unwrap(), bytes((name)));
}
 
/** Encode keys as a bytes32 array using this table's schema */
function encodeKeyTuple(address user) internal pure returns (bytes32[] memory _keyTuple) {
  _keyTuple = new bytes32[](1);
  _keyTuple[0] = bytes32(uint256(uint160(user)));
}

These functions encode data so it can be treated as an array of Ethereum values (256 bits = 32 bytes).

Logic

The way mud works, onchain logic is implemented by one or more Systems. Those systems are typically called by a central World,

IncrementSystem.sol

This is the system that is provided by the demo (packages/contracts/src/systems/IncrementSystem.sol). As the name suggests, it includes a single function that increments Counter.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
 
import { System } from "@latticexyz/world/src/System.sol";
import { Counter } from "../codegen/Tables.sol";

The system needs to know how to be a System, as well as have access to the table (or tables) it needs.

 
contract IncrementSystem is System {
  function increment() public returns (uint32) {

There could be multiple functions in the same system, but in this case there is only one, increment.

    uint32 counter = Counter.get();

Read the value. Because Counter is a singleton, there are no keys to look up.

    uint32 newValue = counter + 1;
    Counter.set(newValue);

Update the value.

    return newValue;
  }
}

Return the new value.

System.sol and WorldContext.sol.

To see what else systems can do, let's look at @latticexyz/world/src/System.sol (opens in a new tab). At present, it is just an alias to @latticexyz/world/src/WorldContext.sol (opens in a new tab). That contract has two functions: _msgSender and _world.

// Extract the trusted msg.sender value appended to the calldata
function _msgSender() internal view returns (address sender) {
  assembly {
    // 96 = 256 - 20 * 8
    sender := shr(96, calldataload(sub(calldatasize(), 20)))
  }
  if (sender == address(0)) sender = msg.sender;
}

Systems are typically called from a World contract, so that would typically be msg.sender. To get the user identity and make access control decisions this World contract provides its own msg.sender is the last 20 bytes of the calldata.

function _world() internal view returns (address) {
  return StoreSwitch.inferStoreAddress();
}

This function gives us the identity of the World which called us. Under certain circumstances the System contract is called through CALL (opens in a new tab) (in which case the calling World is msg.sender), and in other cases DELEGATECALL (opens in a new tab) (in which case the calling World is address(this)).

Note that the identity of the world is not specified in the system. This allows the same System, the same application logic, to be shared between multiple different worlds.