本教程将教您如何 使用 Tenderly Web3 Actions 为您的智能合约创建无服务器后端。Web3 Actions 允许您运行自定义代码,以响应由您的智能合约发起的链上或链下事件。
为了说明 Web3 Actions 的工作原理,我们将构建一个简单的井字棋游戏并将其部署到测试网络。智能合约维护游戏状态,而 Web3 Actions 监控游戏的变化。
每当智能合约触发特定事件时,Tenderly 就会以 NodeJS 项目的形式执行您的自定义代码。每次玩家落子或游戏结束时,游戏结果和棋盘都会打印到控制台。
查看此 GitHub 仓库 以获取本项目的
源代码。您可以随意克隆仓库并试玩,或跟着编码。
理解 Web3 Actions
智能合约允许您在事件触发、函数被调用或按周期时执行自定义代码。
Tenderly 通过 Web3 Actions 帮助您简化这一过程。您可以将 Web3 Actions 写在一个 JavaScript 文件中,或作为 NodeJS 项目部署它们。
Web3 Actions 还允许您执行通常使用 NodeJS 可以完成的任何操作。本教程将向您展示如何使用 Tenderly CLI 部署您的 Web3 Actions,并确保它们在满足特定条件时运行。
项目概览
本项目的目的是向您展示如何部署智能合约以及编写多个 JavaScript 函数,Tenderly 会在事件发生时调用这些函数。您将学习如何使用 Tenderly CLI 将您的 Web3 Actions 部署到 Tenderly 的基础设施。
以下是执行此操作的分步概述:
- 将智能合约部署到测试网络
- 使用 Tenderly CLI 设置运行 Web3 Actions 所需的 JavaScript 和配置文件
- 开发响应游戏事件的函数
- 使用 CLI 将这些函数部署到 Tenderly 的基础设施
本教程需要访问 Tenderly Dashboard。如果您还没有账户,请
在此免费注册(也无需信用卡)。
0:部署智能合约
我们使用一个预先编写的智能合约。查看 GitHub 上智能合约的源代码。此智能合约设计为在游戏状态发生变化时发出事件。
我们简单的井字棋游戏可以具有四种可能的状态:
前提条件
创建两个账户 —— 将智能合约部署到任何 Tenderly 支持的网络。执行此操作最方便的方法是使用 Remix 和 Metamask 钱包插件。您需要两个具有正 Ether 余额的账户。这些账户将代表两位玩家。出于本教程的目的,我们使用 Sepolia。如果您计划按照本教程操作,可以使用 Sepolia faucet 向账户添加 Ether。
编译合约 - 使用 Remix IDE 编译智能合约。创建一个新的合约文件并添加 此处 找到的代码。
部署到测试网络 —— 在您的 Metamask 浏览器插件中,确保将 Sepolia 选为首选网络。接下来,前往 Remix 中的 “Deploy And Run Transactions” 部分,选择 Injected Web3。确认账户引用与 Metamask 中的账户相符后,点击 “Deploy”。
要获取有关已部署合约的更多信息,请查看 “Deployed Contracts” 部分。点击复制图标复制完整的合约地址(0x133...)并将其存储起来,因为我们在后续步骤中会用到它。
现在前往 Tenderly Dashboard 以验证合约。这将使我们能够在教程后面与合约进行交互。
1:通过 Tenderly CLI 设置 Web3 Actions
要继续本教程,您需要在您的计算机上安装 Tenderly CLI。按照 此指南 了解如何设置并验证访问。
安装 CLI 后,创建一个新目录并 cd 进入其中。通过运行 tenderly actions init 命令初始化您的 Web3 Actions:
$> cd tdly-actions
$> tenderly actions init
您将被提示选择您现有的 Tenderly 项目之一。您的 Web3 Actions 将部署到该项目,目录结构应如下所示:
$> ls actions
example.ts # where we write Web3 actions code
tsconfig.json
package.json
Typescript 是默认语言,但您也可以切换到普通的 Javascript。在执行 init 命令之前,将 Javascript 设置为您的首选语言,如下所示:tenderly actions init --language javascript。
package.json 保存 npm 依赖项,当您部署 Web3
Action 时可用。tsconfig.json 文件保存与
actions 目录中 .ts 文件相关的 Typescript
配置。
1.1. 添加井字棋合约的 ABI
在深入代码之前,请将编译器生成的 ABI 复制到 actions 目录。在我们的示例中,这是 tdly-actions 目录。
在 Remix 中,前往 files/artifacts/TicTacToe.json,复制/粘贴文件内容,并将其粘贴到您项目的 TicTacToe.json 文件中。
您的 Web3 Action 代码使用和引用的所有文件都必须放置在
actions 目录中(例如
TicTacToe.json)。
1.2. 配置 Typescript 以将 JSON 文件作为模块导入
默认情况下,Typescript 不允许您将 JSON 文件作为模块导入。您需要配置 Typescript,以便能够将 TicTacToe.json 文件作为模块导入并以对象形式访问。
前往您的 tsconfig.json 文件,在 compilerOptions 条目下包含以下两个配置:
{
...
"compilerOptions": {
...
+ "resolveJsonModule": true,
+ "esModuleInterop": true
},
...
}
2:编写处理新游戏事件的函数
编写一个在新游戏开始时执行的函数。让我们将 Typescript 文件重命名为更具描述性的名称:
mv example.ts ticTacToeActions.ts
为了利用 Typescript 的强大功能,我们首先需要定义一个表示游戏状态的类型。本质上,我们将在 Web3 Action 的存储中存储游戏状态的副本。每个字段包含在该位置落子的玩家的 ID。我们将玩家的地址映射到他们的回合(第一、第二等)在 players 对象中。
2.1. 添加 Action
// ticTacToeActions.ts
export type Game = {
players: { [address: string]: number };
board: number[][];
};
至此,我们准备好定义实际的 Web3 Action,如下所示:
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);
};
此 Web3 Action 将处理智能合约中定义的 GameCreated 事件。
查看智能合约,我们可以看到只有一个事件是从 newGame 函数触发的,因此我们只关心第一个日志条目。我们可以使用 ethers.js 通过基于 TicTacToe.abi 解码 txEvent.logs[0].data 来获取 result,即 GameCreated 事件。
在这里我们可以访问在此特定游戏创建时关联的 ID:result.gameId。我们想使用全新的 Game 实例跟踪该特定游戏的数据:玩家及其所落的子。我们使用此命令将表示新游戏的对象保存到 Storage 中:context.storage.putJson(gameId, game)。
有了一个空的棋盘,我们为持久化未来的变化奠定了基础,这些变化将由其他 actions 处理。
您还可以编写自动化测试来验证 Web3 Action 的行为。仓库中提供了一些测试,但这超出了本教程的范围。
2.2. 指定 New Game Action 的调用
打开 tenderly.yaml。在 specs 部分,定义 newGame(任意名称)的规范以调用函数 newGameAction。您可以像这样定义:newGameAction:newGameAction,首先定义包含函数的文件名,然后是函数名。
接下来,指定 Tenderly 需要监听以调用 action 的 trigger。这是一个 transaction 触发器,将在区块被打包时运行。我们为 network 3 上从指定地址合约发出 NewGame 事件时执行此操作。
将 TTT_CONTRACT_ADDRESS 替换为您的智能合约的实际地址。如果您想将合约部署到 Sepolia 以外的网络,请将该网络的 ID 指定为 network 的值。
将 YOUR_USERNAME 和
YOUR_PROJECT_SLUG 替换为您的 Tenderly 用户名和
项目 slug。您可以从 Dashboard URL 中复制它们:
https://dashboard.tenderly.co/{YOUR_USERNAME}/{YOUR_PROJECT_SLUG}/transactions
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. 在 Tenderly 上验证您的井字棋智能合约
在部署合约之前,您需要在 Tenderly Dashboard 中验证它。
如果需要,可以 通过浏览器上传合约。上传后,选择 TicTacToe 合约、您部署到的网络以及合约地址。
点击 Add Contract 并按下图所示填写编译器选项:
2.4. 将 Web3 Action 部署到 Tenderly
要部署您的 Web3 Action,请使用 Tenderly CLI 执行 deploy 命令:
输出应如下所示:
要查看 Web3 Action 部署的详细信息,请检查 Tenderly Dashboard。如果部署成功,您应该看到类似以下内容:
2.5. 尝试加入新游戏 🎉
要验证一切是否正常工作,请回到 Remix 并创建一个新游戏或多个游戏。
打开 TicTacToe 合约并点击 newGame。Metamask 应提示您确认 Web3 Action 的执行。
事务提交到链上后,Remix 应产生类似以下的输出:
接下来,回到您的 Tenderly Dashboard 并打开 Transactions 部分查看您的事务。在 Actions 部分,您会注意到 “Latest Execution” 列已发生变化。
要查看执行历史,打开您的 Web3 Action 并点击 Execution History 选项卡:
执行历史将提供以下数据:
在上方窗格中,您将找到来自链的负载信息。下方窗格包含所记录游戏编号的详细信息。在我们的例子中,这是 0xb,意味着这是该合约启动的第 11 个游戏。
2.6. 检查您 Web3 Actions 的 Storage
点击 “Go To Storage” 按钮以查看 Storage 的内容。每个启动的游戏都会在此键值映射中获得自己的存储槽。
当您打开我们刚刚创建的 ID 为 11 的游戏时,您会看到所有字段都是零,这意味着还没有玩家玩过该游戏。
3:添加一个 Web3 Action 处理新玩家加入游戏
玩家加入游戏时,应通过注册玩家的地址和其回合(1 或 2)来处理。
除了帮助我们检索事件数据的样板代码之外,playerJoinAction.ts 文件还包含允许我们执行以下操作的代码:
- 使用游戏 ID 从存储中读取当前游戏状态(
storage.getJson)。
- 检索玩家的地址并存储他们的回合:
game.players[player] = playerNumber
- 使用
storage.putJson 将更新的游戏对象保存到 Web3 Action 的存储中。
// 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. 指定 PlayerJoinedGame Action 的调用
我们还需要扩展 tenderly.yaml 文件中的 specs,以包括调用 Web3 Action 所需的规范。
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. 将 action 部署到 Tenderly
通过运行 deploy 命令部署您的 Web3 Action。此命令还将重新部署之前部署的 Web3 Actions。
3.3. 尝试加入新游戏
前往 Remix 加入新游戏,将日志中的游戏编号添加到 newGame 输入字段。要提交事务,点击 joinGame 按钮:
事务被打包后,执行日志将显示 playerNumber: 1。
要玩游戏,请在 Metamask 中切换到您的另一个账户,然后再次点击 joinGame。事务完成后,您将在 Execution History 中看到类似的日志输出。
3.4. 检查您 Web3 Actions 的 Storage
打开这些执行中的任意一个,然后点击 “Go To Storage” 按钮。
我们的玩家加入的游戏 ID 为 11,这是尚未落子前的状态。井字棋棋盘只包含零,并且存在从以太坊地址到玩家回合的映射。
3.5. 添加一个 Action 处理玩家落子
使用 Ethers,我们获取游戏 ID 并从存储中加载游戏实例。接下来,用玩家的输入 game.players[player] 更新第 result.boardRow 行和第 result.boardCol 列的字段。
processNewGameState 函数会将棋盘记录到控制台,但您也可以在落子时发送推文、在链上触发新的事务,或以任何其他方式使用它。
// 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);
};
此 action 的相应规范是:
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. 落子玩游戏
要落子,请使用 Metamask 切换到加入游戏的第一位玩家。接下来,输入您收到的游戏编号以及棋盘上的行和列(0, 1)。
当 action 执行时,输出应如下所示:
步骤 4. 添加一个 Web3 Action 处理游戏结束
以下是 GameOver 事件的代码。当玩家的落子赢得游戏或棋盘已满时,会触发 GameOver 事件。此事件在 PlayerMadeMove 事件之后触发。在 txEvent.logs 列表中,GameOver 事件是第二个元素。
获取 GameOver 事件的一种简单方法是通过 txEvent.logs[1] 访问它。不过,我们将实现一个更加健壮的解决方案,不依赖于事件触发的顺序和数量。
首先,您需要使用 ethers 通过 iface.getEventTopics 获取 gameOverTopic。这将为您提供相应的十六进制值。接下来,您需要在 txEvent.logs 中查找其 topics 列表包含 gameOverTopic 的日志条目。这就是我们的 GameOver 事件日志,我们可以使用 Ethers 解码它。
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. 指定 GameOver Action 的调用
最后,我们需要通过扩展 tenderly.yaml 中的 specs 来添加调用 GameOver action 的规范:
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. 玩游戏
继续玩,直到有一位玩家赢得游戏或棋盘完全被填满。游戏结束时,Execution History 应如下所示: