Guides
Adding Delegation

Adding delegation

On this page you learn how to add delegations (opens in a new tab) to enable addresses to act on behalf of other addresses. This is important, for example, because typically every game move is a transaction, but you wouldn't want the player to constantly have to confirm every move on the wallet extension. The common pattern is to create a burner wallet and have the user's main wallet authorize it to play on its behalf.

The sample application

The application is located in the MUD monorepository (opens in a new tab). It creates multiple burner addresses, and keeps track onchain when they last submitted a transaction.

Here are the steps to run the initial application:

  1. Clone the monorepo and go the example.

    git clone https://github.com/latticexyz/mud
    cd mud/examples/multiple-accounts
  2. Install the Node packages and start the application.

    pnpm install
    pnpm dev
  3. Browse to the application (opens in a new tab).

  4. Click a few buttons to see the application in action. Note that if you reload the page you get different burner addresses.

Deploy a delegation System

MUD comes with some delegation System contracts.

If none of these fulfills the needs of your application, you can write your own.

To deploy the delegation system:

  1. Open a terminal and change to packages/contracts.

    cd packages/contracts
  2. Add the World addresss to .env. You new .env might look something like this:

    .env
    # This .env file is for demonstration purposes only.
    #
    # This should usually be excluded via .gitignore and the env vars attached to
    # your deployment environment, but we're including this here for ease of local
    # development. Please do not commit changes to this file!
    #
    # Enable debug logs for MUD CLI
    DEBUG=mud:*
    #
    # Anvil default private key:
    PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
     
    WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
  3. Create this script in script/DeployDelegation.s.sol

    DeployDelegation.s.sol
    // SPDX-License-Identifier: MIT
    pragma solidity >=0.8.21;
     
    import { Script } from "forge-std/Script.sol";
    import { console } from "forge-std/console.sol";
     
    import { IWorld } from "../src/codegen/world/IWorld.sol";
     
    import { StandardDelegationsModule } from "@latticexyz/world-modules/src/modules/std-delegations/StandardDelegationsModule.sol";
     
    contract DeployDelegation is Script {
      function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        address worldAddress = vm.envAddress("WORLD_ADDRESS");
     
        vm.startBroadcast(deployerPrivateKey);
        IWorld world = IWorld(worldAddress);
     
        StandardDelegationsModule standardDelegationsModule = new StandardDelegationsModule();
        world.installRootModule(standardDelegationsModule, new bytes(0));
     
        vm.stopBroadcast();
      }
    }
    Explanation

    Other than boilerplate, this script does two things.

    StandardDelegationsModule standardDelegationsModule =
      new StandardDelegationsModule();

    Deploy a new StandardDelegationsModule contract. This contract, similar to a System contract, is stateless. This means that if there is already a StandardDelegationsModule contract deployed on the blockchain you can just use it.

    world.installRootModule(standardDelegationsModule, new bytes(0));

    Install the module. This module can only be installed by the owner of the root namespace.

  4. Execute the script

    forge script script/DeployDelegation.s.sol --broadcast --rpc-url http://127.0.0.1:8545

Create and use a delegation

Now that we have some delegations available, the next step is to see that we can actually delegate and run a delegation.

Using a forge script

Before moving over to the client, we will verify things work as expected using a forge script.

  1. Add the following definitions to .env:

    FieldMeaningSample value
    PRIVATE_KEY_2Another private key0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
    USER_ADDRESSThe address corresponding to PRIVATE_KEY0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    USER_ADDRESS_2The address corresponding to PRIVATE_KEY_20x70997970C51812dc3A010C7d01b50e0d17dc79C8

    Your new .env might look something like this:

    .env
    # This .env file is for demonstration purposes only.
    #
    # This should usually be excluded via .gitignore and the env vars attached to
    # your deployment environment, but we're including this here for ease of local
    # development. Please do not commit changes to this file!
    #
    # Enable debug logs for MUD CLI
    DEBUG=mud:*
    #
    # Anvil default private keys:
    PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    PRIVATE_KEY_2=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
     
    # Addresses corresponding to those keys:
    USER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    USER_ADDRESS_2=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
     
    WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
  2. Create script/TestDelegation.s.sol.

    // SPDX-License-Identifier: MIT
    pragma solidity >=0.8.21;
     
    import { Script } from "forge-std/Script.sol";
    import { console } from "forge-std/console.sol";
     
    import { SystemboundDelegationControl } from "@latticexyz/world-modules/src/modules/std-delegations/SystemboundDelegationControl.sol";
    import { SYSTEMBOUND_DELEGATION } from "@latticexyz/world-modules/src/modules/std-delegations/StandardDelegationsModule.sol";
    import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
    import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";
     
    import { IWorld } from "../src/codegen/world/IWorld.sol";
     
    contract TestDelegation is Script {
      using WorldResourceIdInstance for ResourceId;
     
      function run() external {
        // Load the configuration
        uint256 userPrivateKey = vm.envUint("PRIVATE_KEY");
        uint256 userPrivateKey2 = vm.envUint("PRIVATE_KEY_2");
        address userAddress = vm.envAddress("USER_ADDRESS");
        address userAddress2 = vm.envAddress("USER_ADDRESS_2");
        address worldAddress = vm.envAddress("WORLD_ADDRESS");
     
        IWorld world = IWorld(worldAddress);
        ResourceId systemId = WorldResourceIdLib.encode({
          typeId: RESOURCE_SYSTEM,
          namespace: "LastCall",
          name: "LastCallSystem"
        });
     
        // Run as the first address
        vm.startBroadcast(userPrivateKey);
     
        world.registerDelegation(
          userAddress2,
          SYSTEMBOUND_DELEGATION,
          abi.encodeCall(SystemboundDelegationControl.initDelegation, (userAddress2, systemId, 2))
        );
     
        vm.stopBroadcast();
     
        // Run as the second address
        vm.startBroadcast(userPrivateKey2);
     
        /* bytes memory returnData = */ world.callFrom(userAddress, systemId, abi.encodeWithSignature("newCall()"));
     
        vm.stopBroadcast();
      }
    }
    Explanation
    // SPDX-License-Identifier: MIT
    pragma solidity >=0.8.21;
     
    import { Script } from "forge-std/Script.sol";
    import { console } from "forge-std/console.sol";
     
    import { SystemboundDelegationControl } from "@latticexyz/world-modules/src/modules/std-delegations/SystemboundDelegationControl.sol";
    import { SYSTEMBOUND_DELEGATION } from "@latticexyz/world-modules/src/modules/std-delegations/StandardDelegationsModule.sol";

    Import the contract definitions for the delegation system we use, SystemboundDelegationControl (opens in a new tab), and the System identifier for that delegation type.

    import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
    import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";

    We need to create the resource identifier for the target System, the one where USER_ADDRESS allows USER_ADDRESS_2 to perform actions on its behalf.

    import { IWorld } from "../src/codegen/world/IWorld.sol";
     
    contract TestDelegation is Script {
      using WorldResourceIdInstance for ResourceId;
     
      function run() external {
     
        // Load the configuration
        uint256 userPrivateKey = vm.envUint("PRIVATE_KEY");
        uint256 userPrivateKey2 = vm.envUint("PRIVATE_KEY_2");
        address userAddress = vm.envAddress("USER_ADDRESS");
        address userAddress2 = vm.envAddress("USER_ADDRESS_2");
        address worldAddress = vm.envAddress("WORLD_ADDRESS");
     
        IWorld world = IWorld(worldAddress);
        ResourceId systemId =
            WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "LastCall", name: "LastCallSystem" });

    The system where newCall is located is LastCall:LastCallSystem.

     
        // Run as the first address
        vm.startBroadcast(userPrivateKey);
     
        world.registerDelegation(
          userAddress2,
          SYSTEMBOUND_DELEGATION,
          abi.encodeCall(SystemboundDelegationControl.initDelegation, (userAddress2, systemId, 2))
        );

    This is how you register a delegation. The exact parameters depend on the delegation System we are using, so we call registerDelegation with some parameters, and then provide the exact call to the System, in this case initDelegation(userAddress2, systemId, 2).

        vm.stopBroadcast();
     
        // Run as the second address
        vm.startBroadcast(userPrivateKey2);
     
        /* bytes memory returnData = */ world.callFrom(userAddress, systemId,
            abi.encodeWithSignature("newCall()")
        );

    To actually use a delegation you use world.callFrom with the address of the user on whose behalf you are performing the action, the resource identifier of the System on which you perform the action, and the calldata to send that system (which includes the signature of the function name and parameters, followed by parameter values).

    The output is returned as bytes memory Here it is commented out because newCall() (opens in a new tab) does not return any data so we don't need it.

     
        vm.stopBroadcast();
     
      }
    }
  3. Run the script

    forge script script/TestDelegation.s.sol --broadcast --rpc-url http://127.0.0.1:8545
  4. Return to the user interface (opens in a new tab). Look at the bottom call, that one is the latest. See that the caller (USER_ADDRESS, 0xf39F...2266) is different from the transaction sender (USER_ADDRESS_2, 0x7099...c79C8).

Using TypeScript

In most cases, you want to use a TypeScript client to handle both delegation and using delegates. These are system calls, so normally the best place for them would be packages/client/src/mud/createSystemCalls.ts. However, because this application uses multiple accounts, it doesn't use the normal system call mechanism and instead has everything in packages/client/src/App.tsx. We need to perform these actions:

  • Add the ABI for the functions whose calldata needs to be created and provided as a parameter: SystemboundDelegationControl.initDelegation and LastCallSystem.newCall. The ABIs are located under packages/contracts/out.
  • Provide the values of the constants we need, the System identifiers.
  • Create the functions for delegating access with registerDelegation and using delegated access with callFrom. This requires the viem encodeFunctionData function to build the calldata parameters.
  • Update the user interface.

Here is the modified packages/client/src/App.tsx:

App.tsx
import { encodeFunctionData } from "viem";
import { useMUD } from "./MUDContext";
import {
  resourceToHex,
  getBurnerPrivateKey,
  createBurnerAccount,
  transportObserver,
  getContract,
} from "@latticexyz/common";
import { createPublicClient, createWalletClient, fallback, webSocket, http, ClientConfig, Hex } from "viem";
import { getNetworkConfig } from "./mud/getNetworkConfig";
import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
 
// The ABI we need
const ABI = [
  {
    inputs: [
      {
        internalType: "address",
        name: "delegatee",
        type: "address",
      },
      {
        internalType: "ResourceId",
        name: "systemId",
        type: "bytes32",
      },
      {
        internalType: "uint256",
        name: "numCalls",
        type: "uint256",
      },
    ],
    name: "initDelegation",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [],
    name: "newCall",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
];
 
// Create the system name constants
const LASTCALL_SYSTEM_ID = resourceToHex({
  type: "system",
  namespace: "LastCall",
  name: "LastCallSystem",
});
 
const SYSTEMBOUND_DELEGATION = resourceToHex({
  type: "system",
  namespace: "",
  name: "systembound",
});
 
// A lot of code that would normally go in mud/getNetworkConfig.ts is part of the application here,
// because `getNetworkConfig.ts` assumes you use one wallet, and here we need several.
const networkConfig = await getNetworkConfig();
 
const clientOptions = {
  chain: networkConfig.chain,
  transport: transportObserver(fallback([webSocket(), http()])),
  pollingInterval: 1000,
} as const satisfies ClientConfig;
 
const publicClient = createPublicClient({
  ...clientOptions,
});
 
// Create a structure with two fields:
//   client - a wallet client that uses a random account
//   world - a world contract object that lets us issue newCall
const makeWorldContract = () => {
  const walletClient = createWalletClient({
    ...clientOptions,
    account: createBurnerAccount(getBurnerPrivateKey(Math.random().toString())),
  });
 
  return {
    world: getContract({
      address: networkConfig.worldAddress as Hex,
      abi: IWorldAbi,
      client: {
        public: publicClient,
        wallet: walletClient,
      }
    }),
    client: walletClient,
  };
};
 
// Create five world contracts
const worldContracts = [1, 2, 3, 4, 5].map((x) => makeWorldContract());
 
export const App = () => {
  const {
    network: { tables, useStore },
  } = useMUD();
 
  // Get all the calls from the records cache.
  const calls = useStore((state) => {
    const records = Object.values(state.getRecords(tables.LastCall));
    records.sort((a, b) => Number(a.value.callTime - b.value.callTime));
    return records;
  });
 
  // Convert timestamps to readable format
  const twoDigit = (str) => str.toString().padStart(2, "0");
  const timestamp2Str = (timestamp: number) => {
    const date = new Date(timestamp * 1000);
    return `${twoDigit(date.getHours())}:${twoDigit(date.getMinutes())}:${twoDigit(date.getSeconds())}`;
  };
 
  // Call newCall() on LastCall:LastCallSystem.
  const newCall = async (worldContract) => {
    const tx = await worldContract.write.LastCall__newCall();
  };
 
  // Delegate to an address
  const delegate = async (delegatorContract, delegateeAddress) => {
    const callData = encodeFunctionData({
      abi: ABI,
      functionName: "initDelegation",
      args: [delegateeAddress, LASTCALL_SYSTEM_ID, 2],
    });
 
    const tx = await delegatorContract.write.registerDelegation([delegateeAddress, SYSTEMBOUND_DELEGATION, callData]);
  };
 
  // Call as a different address
  const callFrom = async (delegateeContract, delegatorAddress) => {
    const callData = encodeFunctionData({
      abi: ABI,
      functionName: "newCall",
    });
 
    const tx = await delegateeContract.write.callFrom([delegatorAddress, LASTCALL_SYSTEM_ID, callData]);
  };
 
  return (
    <>
      <h2>Last calls</h2>
      <table>
        <tbody>
          <tr>
            <th>Caller</th>
            <th>tx.sender</th>
            <th>Time</th>
          </tr>
          {
            // Show all the calls
            calls.map((call) => (
              <tr key={call.id}>
                <td>{call.key.caller}</td>
                <td>{call.value.sender}</td>
                <td>{timestamp2Str(Number(call.value.callTime))}</td>
              </tr>
            ))
          }
        </tbody>
      </table>
      <h2>My clients</h2>
      {
        // For every world contract, have a button to call newCall as that address.
        worldContracts.map((worldContract) => (
          <>
            <h4>{worldContract.client.account.address}</h4>
            <button
              type="button"
              title="Call"
              onClick={async (event) => {
                event.preventDefault();
                await newCall(worldContract.world);
              }}
            >
              Call as yourself
            </button>
            <br />
            <button
              type="button"
              title="Delegate"
              key={`Delegate as ${worldContract.client.account.address}`}
              onClick={async (event) => {
                event.preventDefault();
                await delegate(worldContract.world, worldContracts[0].client.account.address);
              }}
            >
              Delegate permissions to {worldContracts[0].client.account.address}
            </button>
            <br />
            <button
              type="button"
              title="callFrom"
              key={`callFrom as ${worldContract.client.account.address}`}
              onClick={async (event) => {
                event.preventDefault();
                await callFrom(worldContracts[0].world, worldContract.client.account.address);
              }}
            >
              Call as {worldContracts[0].client.account.address}
            </button>
            <br />
          </>
        ))
      }
    </>
  );
};
Explanation
import { encodeFunctionData } from "viem";

To build call data we need viem's encodeFunctionData (opens in a new tab), which builds the call data without sending a transaction.

.
.
.
// The ABI we need
const ABI = [
  .
  .
  .
]

The ABI definitions (opens in a new tab) for the two functions we need to encode function data for: initDelegation and newCall.

// Create the system name constants
const LASTCALL_SYSTEM_ID = resourceToHex({
  type: "system",
  namespace: "LastCall",
  name: "LastCallSystem",
});
 
const SYSTEMBOUND_DELEGATION = resourceToHex({
  type: "system",
  namespace: "",
  name: "systembound",
});

Create the System resource IDs as hexadecimal values. The format of these identifiers is: sy + <namespace in 14 bytes> + <system name in 16 bytes>.

ConstantNamespaceSystem name
LASTCALL_SYSTEM_IDLastCallLastCallSystem
SYSTEMBOUND_DELEGATIONRoot (empty)systembound
.
.
.
export const App = () => {
  .
  .
  .
  // Delegate to an address
  const delegate = async (delegatorContract, delegateeAddress) => {
    const callData = encodeFunctionData({
      abi: ABI,
      functionName: "initDelegation",
      args: [delegateeAddress, LASTCALL_SYSTEM_ID, 2],
    });

This is equivalent to the Solidity line abi.encodeCall(SystemboundDelegationControl.initDelegation, (userAddress2, systemId, 2)). We create the call to SystemboundDelegationControl.initDelegation which will be executed later by the World to actually create the delegation.

    const tx = await delegatorContract.write.registerDelegation([delegateeAddress, SYSTEMBOUND_DELEGATION, callData]);
  };

Call registerDelegation to register the new delegation.

// Call as a different address
const callFrom = async (delegateeContract, delegatorAddress) => {
  const callData = encodeFunctionData({
    abi: ABI,
    functionName: "newCall",
  });
 
  const tx = await delegateeContract.write.callFrom([delegatorAddress, LASTCALL_SYSTEM_ID, callData]);
};

This function is similar to delegate above, except now we execute as the delegatee, and provide the delegator's address as a parameter.

  return (
    <>
      .
      .
      .
      <h2>My clients</h2>
      {
        // For every world contract, have a button to call newCall as that address.
        worldContracts.map((worldContract) => (
          <>
            <h4>{worldContract.client.account.address}</h4>
            .
            .
            .
            <button
              type="button"
              title="Delegate"
              key={`Delegate as ${worldContract.client.account.address}`}
              onClick={async (event) => {
                event.preventDefault();
                await delegate(worldContract.world, worldContracts[0].client.account.address);
              }}
            >
              Delegate permissions to {worldContracts[0].client.account.address}
            </button>
            <br />
            <button
              type="button"
              title="callFrom"
              key={`callFrom as ${worldContract.client.account.address}`}
              onClick={async (event) => {
                event.preventDefault();
                await callFrom(worldContracts[0].world, worldContract.client.account.address);
              }}
            >
              Call as {worldContracts[0].client.account.address}
            </button>
            .
            .
            .

Buttons to call delegate and callFrom. Note that each of these functions needs two parameters: the World contract to call as, and the address, either the delegatee we are giving power to, or the delegator we're going to pretend to be.

  1. Try to click a few Call as buttons, and see that they don't do anything because access has not been delegated (except for the top one, because an address always has delegation to itself).

  2. Try to click a few Delegate permission buttons and then the corresponding Call as buttons, to see that delegated permissions work. Notice that even though the caller is the delegator, the transaction's sender, tx.sender, is the top wallet, which originates the transaction.