Branch RPG

BranchRPG is an example app to showcase smart account features for a web3 game


What you'll learn

BranchRPG is an example app to showcase smart account features for a web3 game. This game will showcase how to:

  1. Initialize a smart account
  2. Build a user operation
  3. Sponsor a user operation

โ‘  Open the template

Clone the example repository from Github. This contains the game with assets built in, but without any ERC-4337 parts.

git clone -b tutorial-start https://github.com/stackup-wallet/branch-rpg.git
cd branch-rpg

You'll also need to install the dependencies. Make sure you are using Node v14.

npm install

โ‘ก Set the environment

Copy the .env.example file to .env.

cp .env.example .env

Open it up, and fill out the NODE_RPC_URL and PAYMASTER_RPC_URL. You can get these URLs from your Stackup dashboard by clicking the big blue button. Select Polygon Mumbai.

NODE_RPC_URL="https://api.stackup.sh/v1/node/API_KEY"
PAYMASTER_RPC_URL="https://api.stackup.sh/v1/paymaster/API_KEY"

โ‘ข Run the game

npm run dev

Use the space bar to move through each of the messages, then WASD to move to the bulletin board. At the bulletin board, press ENTER.

Because you don't have any ERC-4337 stuff hooked up yet, the player's address will be 0x0000 and the game's score won't load. Don't worry though! We will add them now.


โ‘ฃ Create a User Operation

Open src/modules/web3/client/store.ts. This is a file that handles tasks the user performs.

Find the init function and the comment INITIALIZE USER OPERATION HERE. This is where you'll add the next few lines of code.

Connect to a client

First you will need a client to connect to the ERC-4337 bundler network. Immediately under the INITIALIZE USER OPERATION HERE create the client.

client = await Client.init(process.env.NODE_RPC_URL || "");

Specify the paymaster

You will also need a paymaster to sponsor the User Operation.

The userop.js library has some middleware that can handle the logic of requesting a signature from the paymaster.

const paymasterMiddleware = process.env.PAYMASTER_RPC_URL
  ? Presets.Middleware.verifyingPaymaster(process.env.PAYMASTER_RPC_URL, {
    type: "payg",
  })
  : undefined;

Create the builder

Next we will create a User Operation builder, userOpBuilder. This builder has a bunch of functions for filling out the various fields of the User Operation with sensible defaults.

The game will use a Kernel smart account. The preset in userop.js has the interfaces built into it, so just use it.

userOpBuilder = await Presets.Builder.Kernel.init(
  new ethers.Wallet(signer),
  process.env.NODE_RPC_URL || "",
  { paymasterMiddleware }
);

This sets an ethers.js Signer object to own the account, a URL to connect to the ERC-43337 bundler, and any other options you want to specify upfront. In this case, you will tell it to use the paymasterMiddleware.


โ‘ค Add the callData

The callData of a User Operation contains the instructions that will be executed on-chain. In this step, we'll create the callData.

Add the contract interface

All contract interactions will take place with an ERC-20 token contract.

At the top of the store.ts file, find the comment CONTRACT INTERFACE HERE. Immediately below that comment, create the interface to the contract, BranchRPGContract.

// 
// >>>>>>> CONTRACT INTERFACE HERE <<<<<<<
// 

const BranchRPGAddress = "0x20d8aE1faAFc55c8e2f1e86D02a62C79D9A43a73";
const BranchRPGContract = new ethers.Contract(
  BranchRPGAddress,
  abi,
  new ethers.providers.JsonRpcProvider(process.env.NODE_RPC_URL)
);

Add mint and burn calls

Now that you have the contract interface, you can start creating the calls. There are two actions that need to take place:

  • "water": mints an ERC-20 token
  • "garden": (burns an ERC-20 token)

We'll create an onTask function that will add the calls to the callData each time the player interacts with the bucket or garden.

Each call is an object containing the target contract to, the native token value to send value, and the contract data data:

const onTask = (store: IStore) => (data: ITaskData) => {
  switch (data.action) {
    case "water": {
      const call: ICall = {
        to: BranchRPGAddress,
        value: ethers.constants.Zero,
        data: BranchRPGContract.interface.encodeFunctionData("mint", [
          store.address,
          ethers.constants.WeiPerEther,
        ]),
      };
      store.calls = [...store.calls, call];
      break;
    }

    case "garden": {
      const call: ICall = {
        to: BranchRPGAddress,
        value: ethers.constants.Zero,
        data: BranchRPGContract.interface.encodeFunctionData("burn", [
          ethers.constants.WeiPerEther,
        ]),
      };
      store.calls = [...store.calls, call];
      break;
    }

    default:
      break;
  }
};

Display score

Additionally, we want the player to see how many times the garden has been watered. Just add this after onTask.

const onScore = (store: IStore) => async () => {
  store.score = ethers.utils.formatEther(await BranchRPGContract.score());
  store.waterBalance = ethers.utils.formatEther(await BranchRPGContract.balanceOf(store.address));
};

Handle mint and burn events

Go back to the init function, just after defining the userOpBuilder. Add calls to the onTask and onScore functions.

const score = await BranchRPGContract.score();

this.score = ethers.utils.formatEther(score);
this.address = userOpBuilder.getSender();
this.signer = signer;

const BranchRPCBurnFilter = BranchRPGContract.filters.Transfer(
  null,
  ethers.constants.AddressZero
);
BranchRPGContract.on(BranchRPCBurnFilter, onScore(this));

socket.on("task", onTask(this));

If you go back to the game and refresh, you'll see that you can see the score and your player has a non-zero address. Hooray!

You can now start collecting water and watering the garden, but when you go to the scarecrow nothing will happen. We need to send the User Operation to the client.


โ‘ฅ Execute User Operation

The execute function will be called when the player talks with the scarecrow. This will take the User Operation and send it to the client.

Go to the execute function and find the comment EXECUTE USER OPERATION HERE.

The client.sendUserOperation function will be used to execute the user operation created by the UserOpBuilder. When the User Operation is built it will be displayed in the console, along with the corresponding transaction.

// 
// >>>>>>> EXECUTE USER OPERATION HERE <<<<<<<
// 

console.log("Generating UserOperation...");
const res = await client.sendUserOperation(
  userOpBuilder.executeBatch(this.calls),
  {
    onBuild: (op) => console.log("Signed UserOperation:", op),
  }
);
console.log(`UserOpHash: ${res.userOpHash}`);

console.log("Waiting for transaction...");
const ev = await res.wait();
if (ev) {
  console.log(`Transaction hash: ${ev.transactionHash}`);
  console.log(
    `https://mumbai.polygonscan.com/tx/${ev.transactionHash}`
  );
}

โ‘ฆ Play

Now you're ready to play!

Get water from the bucket, water the garden, and then speak with the scarecrow. In your console you'll see the user operation.