⛓️ Future is multichain! Check out the Tenderly 2024 networks integration recap. 🎉
Web3 Actions
How to Handle On-Chain Events

How to Handle On-Chain Events

This tutorial will teach you how to create a serverless backend for your smart contract using Tenderly Web3 Actions. Web3 Actions allow you to run custom code in response to on-chain or off-chain events that are initiated by your smart contract.

To illustrate how Web3 Actions work, we will build a simple Tic-Tac-Toe game and deploy it to a test network. The smart contract will be responsible for maintaining the game state while Web3 Actions will be used to monitor changes to the game.

Whenever a specific event gets fired from the smart contract, Tenderly will execute your custom code in the form of a NodeJS project. The results of the game and the game board will be printed to the console each time a player makes a move or when the game is over.

Check out this Github repo to find the source code for this project. Feel free to clone the repo and play around with it, or code along.

Understanding Web3 Actions

Smart contracts allow you to execute custom code when an event is triggered, a function is invoked, or perhaps periodically.

Tenderly helps you streamline this process with Web3 Actions. You can write your Web3 Actions in a single Javascript file or deploy them as a NodeJS project.

Web3 Actions also allow you to perform any action you normally would with NodeJS. This tutorial will show you how to use the Tenderly CLI to deploy your Web3 Actions and ensure they run when specific conditions are met.

Learn more about Web3 Actions and explore how you can use them in your projects with Basics of Web3 Actions.

Project Overview

The purpose of this project is to show you how to deploy a smart contract and write several Javascript functions that Tenderly will invoke when an event occurs. You will learn how to use the Tenderly CLI to deploy your Web3 Actions to Tenderly’s infrastructure.

Here’s a step-by-step overview of how we will go about doing this:

  1. Deploy the smart contract to a test network
  2. Use the Tenderly CLI to set up Javascript and config files needed to run the Web3 Actions
  3. Develop functions that respond to game events
  4. Deploy those functions to Tenderly’s infrastructure using the CLI

For this tutorial, you’ll need access to the Tenderly Dashboard. If you don’t have an account, sign up here for free (no cc required as well).

0: Deploy the smart contract

If you’re coding along, you can skip this step and use the contract we deployed and verified in Tenderly.

We will use a pre-written smart contract. Check out the source code of the smart contract on GitHub. This smart contract is designed to emit events whenever a change to the state of the game occurs.

Our simple Tic-Tac-Toe game can have four possible states:

  • Game start
  • Player joins the game
  • Player makes a move
  • Game over

Prerequisite

Create Two Accounts - deploy the smart contract to any network that is supported by Tenderly. The most convenient way to do this is with Remix and the Metamask wallet plugin. You need two accounts that have a positive Ether balance. These accounts will represent the two players. For the purpose of this tutorial we will use Sepolia. If you plan to follow along with this tutorial, you can use the Sepolia faucet to add Ether to the accounts.

Compile the contract - use the Remix IDE to compile the smart contract. Create a new contract file and add the code found here.

Tenderly Docs
Compiling the contract in the Remix IDE

Deploy to a test network - in your Metamask browser plugin, make sure Sepolia is selected as your preferred network. Next, go to the “Deploy And Run Transactions” section in Remix and select Injected Web3. Click “Deploy” once you’ve made sure that the account references the account in Metamask.

Tenderly Docs
Deploying the contract to a testnet
Tenderly Docs
Copying the contract address

To get more information on the deployed contract, check out the “Deployed Contracts” section. Click the copy icon to copy the full contract address (0x133...) and store it somewhere because we’ll need it for the steps that follow.

Now go to the Tenderly Dashboard to verify the contract. This will make it possible for us to interact with the contract later on in the tutorial.

1: Set Up Web3 Actions via the Tenderly CLI

To continue with this tutorial, you need to have the Tenderly CLI installed on your machine. Follow this guide to learn how to set it up and authenticate access.

With the CLI installed, create a new directory and cd into it. Initialize your Web3 Actions by running the tenderly actions init command:

$> cd tdly-actions
$> tenderly actions init

You’ll be prompted to select one of your existing Tenderly projects. Your Web3 Actions will be deployed to this project and should appear in your directory structure like so:

example
$> ls actions
  example.ts     # where we write Web3 actions code
  tsconfig.json
  package.json

Typescript is the default language but you can switch to plain Javascript. Before you execute the init command, set Javascript as your preferred language like so: tenderly actions init --language javascript.

The package.json holds npm dependencies which will be available when you deploy your Web3 Action. The tsconfig.json file holds Typescript configuration related only to .ts files within the actions directory.

1.1. Add Tic-Tac-Toe Contract’s ABI

Before we dive deeper into the code, copy the ABI generated by the compiler to the actions directory. In our example, this is the tdly-actions directory.

In Remix, go to files/artifacts/TicTacToe.json, copy/paste the file contents and paste them into your project’s TicTacToe.json file.

All files your Web3 Action code uses and references must be placed in the actions directory (e.g. TicTacToe.json).

1.2. Configure Typescript to Import a JSON File as a Module

By default, Typescript doesn’t allow you to import JSON files as modules. You need to configure Typescript to be able to import the TicTacToe.json file as a module and access it as an object.

Go to your tsconfig.json file and include the following two configurations under the compilerOptions entry:

example
{
    ...
    "compilerOptions": {
	...
+        "resolveJsonModule": true,
+        "esModuleInterop": true
    },
   ...
}

2: Write a Function to Handle New Game Event

Write a function that will execute when a new game is started. Let’s rename the Typescript file to something more descriptive:

example
mv example.ts ticTacToeActions.ts

To harness the power of Typescript, we’ll first need to define a type representing the game state. In essence, we’ll store a replica of the game state in the storage of your Web3 Action. Each field contains the ID of the player who played there. We’ll map the player’s address to their turn (first, second, etc) in the players object.

2.1. Add an Action

example.ts
// ticTacToeActions.ts
import { ActionFn, Context, Event, TransactionEvent } from '@tenderly/actions';
import { ethers } from 'ethers';
import TicTacToe from './TicTacToe.json';
 
export type Game = {
  players: { [address: string]: number };
  board: number[][];
};

At this point, we are ready to define the actual Web3 Action like so:

example.ts
export const createNewGame = (): Game => {
  return {
    players: {},
    board: [
      [0, 0, 0],
      [0, 0, 0],
      [0, 0, 0],
    ],
  };
};
 
// ticTacToeActions.ts continued
export const newGameAction: ActionFn = async (context: Context, event: Event) => {
  let txEvent = event as TransactionEvent;
 
  let iface = new ethers.utils.Interface(TicTacToe.abi);
 
  const result = iface.decodeEventLog('GameCreated', txEvent.logs[0].data, txEvent.logs[0].topics);
 
  const { gameId, playerNumber, player } = result;
 
  console.log('Game Created Event:', {
    gameId: gameId.toString(),
    playerNumber,
    player,
  });
 
  const game: Game = createNewGame();
  await context.storage.putJson(gameId.toString(), game);
};

This Web3 Action will handle the GameCreated event that is defined in the smart contract.

Looking at the smart contract, we can see that only one event is getting fired from the newGame function, so we’re interested in the first log entry. We can get the result with ethers.js by decoding txEvent.logs[0].data of the GameCreated event, based on the TicTacToe.abi.

Here we can access the ID associated with this particular game once it has been created: result.gameId. We want to track the data for this particular game: players and moves they made, using a brand new Game instance. We’re saving the object representing the new game in the Storage with this command: context.storage.putJson(gameId, game).

With an empty board, we are setting the foundation to persist future changes which are handled by other actions.

You may also write automated tests to verify the behaviour of the Web3 Action. Some tests are available in the repository, but this is out of the scope of this tutorial.

2.2. Specify New Game Action Invocation

Open up tenderly.yaml. In the specs section, define the specs for newGame (arbitrary name) to invoke the function newGameAction. You can do so like this newGameAction:newGameAction — first we define the name of the file containing the function and then the function name.

Next, specify the trigger that Tenderly is supposed to watch out for to invoke the action. This is a transaction trigger that will run when the block is mined. We’re doing this for network 3 when the event NewGame is emitted from the contract on the specified address.

Replace TTT_CONTRACT_ADDRESS with the actual address of your smart contract. If you want to deploy the contract to a network other than Sepolia, specify the network’s ID as the value of the network.

Replace YOUR_USERNAME and YOUR_PROJECT_SLUG with your Tenderly username and the slug of your project. You can copy those from the Dashboard URL:

https://dashboard.tenderly.co/{YOUR_USERNAME}/{YOUR_PROJECT_SLUG}/transactions

example.yaml
account_id: ''
actions:
  YOUR_USERNAME/YOUR_PROJECT_SLUG:
    runtime: v1
    sources: actions
    specs:
      newGame:
        description: Respond to newGame event
        function: ticTacToeActions:newGameAction
        trigger:
          type: transaction
          transaction:
            status:
              - mined
            filters:
              - network: 3
                eventEmitted:
                  contract:
                    address: TTT_CONTRACT_ADDRESS
                  name: GameCreated
project_slug: ''

2.3. Verify Your Tic-Tac-Toe Smart Contract on Tenderly

Before deploying the contract, you need to verify it in your Tenderly Dashboard.

If you wish, you can upload the contract through your browser. Once uploaded, select the TicTacToe contract, the network you deployed it to, and the contract address.

Click Add Contract and fill out the compiler options as shown in the picture below:

Tenderly Docs
Verifying and adding the contract to Tenderly
Tenderly Docs
Adding the contract compiler information

2.4. Deploy the Web3 Action to Tenderly

To deploy your Web3 Action, execute the deploy command using the Tenderly CLI:

example
tenderly actions deploy

The output should look like this:

Tenderly Docs
The Web3 Action execution output

To see details about the deployment of your Web3 Action, check the Tenderly Dashboard. If the deployment was successful, you should see something like this:

Tenderly Docs
Opening the Web3 Action deployment information

2.5. Try Joining a New Game 🎉

To verify that everything is working properly, head back to Remix and create a new game or even several games.

Open up the TicTacToe contract and hit newGame. Metamask should prompt you to confirm the execution of the Web3 Action.

Once the transaction is submitted to the chain, Remix should produce an output similar to this:

Tenderly Docs
Creating a new game in Remix

Next, head back to your Tenderly Dashboard and open the Transactions section to see your transaction. In the Actions section, you’ll notice that the “Latest Execution” column has changed.

To view the execution history, open your Web3 Action and click the Execution History tab:

Tenderly Docs
Opening the Execution history tab for your Web3 Action

The execution history will provide you with the following data:

Tenderly Docs
The execution history data

In the upper pane, you’ll find information about the payload coming from the chain. The lower pane contains details about the logged game number. In our case, this is 0xb, which means it’s the 11th game started for this contract.

2.6. Check the Storage of Your Web 3 Actions

Click the “Go To Storage” button to see the contents of the Storage. Each game that is initiated will get its own storage slot in this key-value map.

When you open the game we just created with the ID 11, you’ll see zeros across all the fields, meaning no players have played the game yet.

Tenderly Docs
Opening the Storage of your Web3 Action

3: Add a Web3 Action to Handle New Players Joining a Game

The player joining the game should be processed by registering the address of the player and their move (1 or 2).

Besides the boilerplate code that helps us retrieve the event data, the playerJoinAction.ts file also contains the code that allows us to:

  • Read the current game state from the storage using the game’s ID (storage.getJson).
  • Retrieve the player’s address and store their turn: game.players[player] = playerNumber
  • Save the updated game object to the storage of Web3 Action using storage.putJson.
example.ts
// playerJoinAction.ts
 
export const playerJoinedAction: ActionFn = async (context: Context, event: Event) => {
  let txEvent = event as TransactionEvent;
  let iface = new ethers.utils.Interface(TicTacToe.abi);
  const result = iface.decodeEventLog(
    'PlayerJoinedGame',
    txEvent.logs[0].data,
    txEvent.logs[0].topics,
  );
 
  const gameId = result.gameId.toString();
  const playerAddress = result.player.toLowerCase() as string;
  const playerNumber = result.playerNumber;
 
  console.log('Player joined event:', {
    gameId,
    playerAddress,
    playerNumber,
  });
 
  const game: Game = (await context.storage.getJson(gameId)) as Game;
  game.players[playerAddress] = playerNumber;
 
  await context.storage.putJson(result.gameId.toString(), game);
};

3.1. Specify PlayerJoinedGame Action Invocation

We also need to extend the specs from the tenderly.yaml file to include the specifications needed to invoke the Web3 Action.

example.yaml
playerJoined:
  description: Respond to player joining game
  function: ticTacToeActions:playerJoinedAction
  trigger:
    type: transaction
    transaction:
      status:
        - mined
      filters:
        - network: 3
          eventEmitted:
            contract:
              address: TTT_CONTRACT_ADDRESS
            name: PlayerJoinedGame

3.2. Deploy the action to Tenderly

Deploy your Web3 Action by running the deploy command. This command will also redeploy previously deployed Web3 Actions.

example.js
tenderly action deploy

3.3. Try Joining a New Game

Join a new game by going over to Remix and adding the game number from the logs in the newGame input field. To submit the transaction, click the joinGame button:

Tenderly Docs
Joining a new game in Remix

Once the transaction has been mined, the logs of the execution will display playerNumber: 1.

Tenderly Docs
The execution log

To play the game, switch to your other account in Metamask and click joinGame again. Upon the completion of the transaction, you’ll see a similar log output in the Execution History.

3.4. Check the Storage of Your Web 3 Actions

Tenderly Docs
Opening the execution of your Web3 Action

Open any of these executions and click the “Go To Storage” button.

The game our players have joined has the ID of 11, and this is the state before any move has been made. The Tic-Tac-Toe board contains only zeros and the map from an Ethereum address to the player’s turn is present.

Tenderly Docs
Opening the Storage key value

3.5. Add an Action to Handle When Player Makes a Move

Using Ethers, we get the game ID and load the game instance from storage. Next, update the field in row result.boardRow and column result.boardCol with the player’s input game.players[player].

The function processNewGameState, will log the board to the console, but you can also send a tweet when a move is made, trigger a new transaction on the chain, or use it in any other way.

example.ts
// ticTacToeActions.ts
//...
export const playerMadeMoveAction: ActionFn = async (context: Context, event: Event) => {
  let txEvent = event as TransactionEvent;
  let iface = new ethers.utils.Interface(TicTacToe.abi);
  const result = iface.decodeEventLog(
    'PlayerMadeMove',
    txEvent.logs[0].data,
    txEvent.logs[0].topics,
  );
 
  const gameId = result.gameId.toString();
  const game = (await context.storage.getJson(gameId)) as Game;
  const player = result.player.toLowerCase() as string;
  const { boardRow, boardCol } = result;
 
  console.log("Player's move event log:", {
    gameId,
    player,
    boardRow: boardRow.toString(),
    boardCol: boardCol.toString(),
  });
 
  console.log(
    `Move: gameId ${gameId}, game ${JSON.stringify(
      game,
    )}, boradRow ${boardRow}, boardCol ${boardCol}, player ${player}`,
  );
 
  game.board[boardRow][boardCol] = game.players[player];
  console.log('MV', JSON.stringify(game));
  await context.storage.putJson(gameId, game);
 
  processNewGameState(game);
};
 
const processNewGameState = (game: Game) => {
  let board = '\n';
  game.board.forEach(row => {
    row.forEach(field => {
      if (field == 1) {
        board += '❎ ';
        return;
      }
 
      if (field == 2) {
        board += '🅾️ ';
        return;
      }
 
      board += '💜 ';
    });
 
    board += '\n';
  });
 
  console.log(board);
};

The corresponding spec for this action is:

example.yaml
playerMadeMove:
  description: Respond to player making a move event
  function: ticTacToeActions:playerMadeMoveAction
  trigger:
    type: transaction
    transaction:
      status:
        - mined
      filters:
        - network: 3
          eventEmitted:
            contract:
              address: TTT_CONTRACT_ADDRESS
            name: PlayerMadeMove

3.6. Make a Move to Play the Game

To make a move, use Metamask to switch to the first player who joined the game. Next, enter the game number you received, as well as the row and column on the board (0, 1).

Tenderly Docs
Making a move in Remix

When the action gets executed, the output should look like this:

Tenderly Docs
The Web3 Action execution output

Step 4. Add a Web3 Action to Handle When the Game is Over

Below you’ll find the code for the GameOver event. The GameOver event is fired when a player makes a move that wins the game or when the board is full. This event is fired after the PlayerMadeMove event. In the txEvent.logs list, the GameOver event is the second element.

An easy way to get GameOver event is to access it via the txEvent.logs[1]. However, we’ll implement a more robust solution which doesn’t depend on the order and number of events fired.

First, you need to get the gameOverTopic using ethers via iface.getEventTopics. This will give you the corresponding hexadecimal value. Next, you need to find the log entry in the txEvent.logs, whose topics list contains the gameOverTopic. This is our GameOver event log that we can decode using Ethers.

example.js
export const gameOverAction: ActionFn = async (context: Context, event: Event) => {
	let txEvent = event as TransactionEvent;
	let iface = new ethers.utils.Interface(TicTacToe.abi);
 
	const gameOverTopic = iface.getEventTopic("GameOver");
	const gameOverLog = txEvent.logs.find(log => log.topics.find(topic => topic == gameOverTopic) !== undefined);
 
	if (gameOverLog == undefined) {
		// impossible
		throw Error("GameOver log not found in event's logs");
	}
 
	const result = iface.decodeEventLog("GameOver", gameOverLog.data, gameOverLog.topics);
 
	console.log(result);
 
	const gameId = result.gameId.toString();
	const winner = result.winner as number;
 
	const winnerMessage = getWinnerMessage(winner);
	if (winnerMessage !== false) {
		console.info(`🎉 Winner of the game ${gameId} is ${winnerMessage}`);
	} else {
		console.error("🤔 weird winner code");
	}
}

4.1. Specify GameOver Action Invocation

Finally, we need to add the specification to invoke the GameOver action by extending the specs in tenderly.yaml:

example.yaml
gameOver:
  description: Respond to game over
  function: ticTacToeActions:gameOverAction
  trigger:
    type: transaction
    transaction:
      status:
        - mined
      filters:
        - network: 3
          eventEmitted:
            contract:
              address: TTT_CONTRACT_ADDRESS
            name: GameOver

4.2. Play the Game

Keep playing until one player wins the game or the board is entirely filled out. At the end of the game, the Execution History should look like this:

Tenderly Docs
The execution history at the end of the game