World
ERC-20 tokens

ERC 20 (fungible tokens) module

⚠️

This module is unaudited and may change in the future.

The erc20-puppet (opens in a new tab) module lets you create ERC-20 (opens in a new tab) tokens as part of a MUD World. The advantage of doing this, rather than creating a separate ERC-20 contract (opens in a new tab) and merely controlling it from MUD, is that all the information is in MUD tables and is immediately available in the client.

Deployment

The easiest way to deploy this module is to edit mud.config.ts. This is a modified version of the vanilla template.

Note that before you use this file you need to run pnpm add viem (see explanation below).

mud.config.ts
import { defineWorld } from "@latticexyz/world";
import { encodeAbiParameters, stringToHex } from "viem";
 
const erc20ModuleArgs = encodeAbiParameters(
  [
    { type: "bytes14" },
    {
      type: "tuple",
      components: [{ type: "uint8" }, { type: "string" }, { type: "string" }],
    },
  ],
  [stringToHex("MyToken", { size: 14 }), [18, "Worthless Token", "WT"]],
);
 
export default defineWorld({
  namespace: "app",
  tables: {
    Counter: {
      schema: {
        value: "uint32",
      },
      key: [],
    },
  },
  modules: [
    {
      artifactPath: "@latticexyz/world-modules/out/PuppetModule.sol/PuppetModule.json",
      root: false,
      args: [],
    },
    {
      artifactPath: "@latticexyz/world-modules/out/ERC20Module.sol/ERC20Module.json",
      root: false,
      args: [
        {
          type: "bytes",
          value: erc20ModuleArgs,
        },
      ],
    },
  ],
});
Explanation
import { encodeAbiParameters, stringToHex } from "viem";

In simple cases it is enough to use the config parser to specify the module arguments. However, the ERC-20 module requires a struct as one of the arguments (opens in a new tab). We use encodeAbiParameters (opens in a new tab) to encode the struct data. The stringToHex (opens in a new tab) function is used to specify the namespace the token uses.

This is the reason we need to issue pnpm install viem in packages/contracts to be able to use the library here.

const erc20ModuleArgs = encodeAbiParameters(

You can see the arguments for the ERC-20 module here (opens in a new tab). There are two arguments:

However, the arguments for a module are ABI encoded (opens in a new tab) to a single value of type bytes. So we use encodeAbiParameters from the viem library to create this argument. The first parameter of this function is a list of argument types.

  [
    { type: "bytes14" },

The first parameter is simple, a 14 byte value for the namespace.

    {
      type: "tuple",
      components: [{ type: "uint8" }, { type: "string" }, { type: "string" }],
    },

The second value is more complicated, it's a struct, or as it is called in ABI, a tuple. The first field is the number of digits after the decimal point when displaying the token. The second field is the token's full name, and the third a short symbol for it.

  [
    stringToHex("MyToken", { size: 14 }),

The second encodeAbiParameters parameter is a list of the values, of the types declared in the first list.

The first parameter for the module is bytes14, the namespace of the ERC-20 token. We use stringToHex (opens in a new tab) to convert it from the text form that is easy for us to use, to the hexadecimal number that Viem expects for bytes14 parameter.

    [18, "Worthless Token", "WT"]],
  ],
);

The second parameter for the module is the ERC20MetadataData (opens in a new tab) structure.

  modules: [
    {
      artifactPath: "@latticexyz/world-modules/out/PuppetModule.sol/PuppetModule.json",
      root: false,
      args: [],
    },

A module declaration requires three parameters:

  • artifactPath, a link to the compiled JSON file for the module.
  • root, whether to install the module with root namespace permissions or not.
  • args the module arguments.

Here we install the puppet module (opens in a new tab). We need this module because a System is supposed to be stateless, and easily upgradeable to a contract in a different address. However, both the ERC-20 standard (opens in a new tab) and the ERC-721 standard (opens in a new tab) require the token contract to emit events. The solution is to put the System in one contract and have another contract, the puppet, which receives requests and emits events according to the ERC.

    {
      artifactPath: "@latticexyz/world-modules/out/ERC20Module.sol/ERC20Module.json",
      root: false,
      args: [
        {
          type: "bytes",

The data type for this parameter is bytes, because it is treated as opaque bytes by the World and only gets parsed by the module after it is transferred.

          value: erc20ModuleArgs,
        },
      ],
    },

The module arguments, stored in erc20ModuleArgs.

Usage

You can use the token the same way you use any other ERC20 contract. For example, run this script.

ManageERC20.s.sol
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
import { RESOURCE_TABLE } from "@latticexyz/store/src/storeResourceTypes.sol";
import { WorldResourceIdLib } from "@latticexyz/world/src/WorldResourceId.sol";
import { ERC20Registry } from "@latticexyz/world-modules/src/codegen/index.sol";
import { IERC20Mintable } from "@latticexyz/world-modules/src/modules/erc20-puppet/IERC20Mintable.sol";
 
import { IWorld } from "../src/codegen/world/IWorld.sol";
 
contract ManageERC20 is Script {
  function reportBalances(IERC20Mintable erc20, address myAddress) internal view {
    address goodGuy = address(0x600D);
    address badGuy = address(0x0BAD);
 
    console.log("     My balance:", erc20.balanceOf(myAddress));
    console.log("Goodguy balance:", erc20.balanceOf(goodGuy));
    console.log(" Badguy balance:", erc20.balanceOf(badGuy));
    console.log("--------------");
  }
 
  function run() external {
    address worldAddress = address(0x8D8b6b8414E1e3DcfD4168561b9be6bD3bF6eC4B);
 
    // Specify a store so that you can use tables directly in PostDeploy
    StoreSwitch.setStoreAddress(worldAddress);
 
    // Load the private key from the `PRIVATE_KEY` environment variable (in .env)
    uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
    address myAddress = vm.addr(deployerPrivateKey);
 
    // Start broadcasting transactions from the deployer account
    vm.startBroadcast(deployerPrivateKey);
 
    // Get the ERC-20 token address
    ResourceId namespaceResource = WorldResourceIdLib.encodeNamespace(bytes14("MyToken"));
    ResourceId erc20RegistryResource = WorldResourceIdLib.encode(RESOURCE_TABLE, "erc20-puppet", "ERC20Registry");
    address tokenAddress = ERC20Registry.getTokenAddress(erc20RegistryResource, namespaceResource);
    console.log("Token address", tokenAddress);
 
    address goodGuy = address(0x600D);
    address badGuy = address(0x0BAD);
 
    // Use the token
    IERC20Mintable erc20 = IERC20Mintable(tokenAddress);
 
    console.log("Initial state");
    reportBalances(erc20, myAddress);
 
    // Mint some tokens
    console.log("Minting for myself and Badguy");
    erc20.mint(myAddress, 1000);
    erc20.mint(badGuy, 500);
    reportBalances(erc20, myAddress);
 
    // Transfer tokens
    console.log("Transfering to Goodguy");
    erc20.transfer(goodGuy, 750);
    reportBalances(erc20, myAddress);
 
    // Burn tokens
    console.log("Burning badGuy's tokens");
    erc20.burn(badGuy, 500);
    reportBalances(erc20, myAddress);
 
    vm.stopBroadcast();
  }
}
Explanation
    console.log("     My balance:", erc20.balanceOf(myAddress));

The balanceOf function (opens in a new tab) is the way ERC-20 specifies to get an address's balance.

    // Get the ERC-20 token address
    ResourceId namespaceResource = WorldResourceIdLib.encodeNamespace(bytes14("MyToken"));
    ResourceId erc20RegistryResource = WorldResourceIdLib.encode(RESOURCE_TABLE, "erc20-puppet", "ERC20Registry");
    address tokenAddress = ERC20Registry.getTokenAddress(erc20RegistryResource, namespaceResource);
    console.log("Token address", tokenAddress);

This is the process to get the address of our token contract (the puppet). First, we get the resourceId values for the erc20-puppet__ERC20Registry table and the namespace we are interested in (each namespace can only have one ERC-20 token). Then we use that table to get the token address.

    // Use the token
   IERC20Mintable erc20 = IERC20Mintable(tokenAddress);

Create an IERC20Mintable (opens in a new tab) for the token.

    console.log("Minting for myself and Badguy");
    erc20.mint(myAddress, 1000);
    erc20.mint(badGuy, 500);
    reportBalances(erc20, myAddress);

Mint tokens for two addresses. Note that only the owner of the name space is authorized to mint tokens.

    console.log("Transfering to Goodguy");
    erc20.transfer(goodGuy, 750);
    reportBalances(erc20, myAddress);

Transfer a token. We can only transfer tokens we own, or that we have approval to transfer from the current owner.

    console.log("Burning badGuy's tokens");
    erc20.burn(badGuy, 500);
    reportBalances(erc20, myAddress);

Destroy some tokens.