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).
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:
- A 14-byte identifier for the namespace.
- An
ERC20MetadataData
for the ERC-20 parameters, defined here (opens in a new tab).
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.
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.