👽

【Solidity / React】シンプルなdappsを作ってみる

2021/12/17に公開1

はじめに

今回は web2 を中心とした Web開発者向けに、

  • Solidity を利用したスマートコントラクト作成
  • React で↑と接続するためのフロントエンド作成

を通して、簡単に web3/dapps の開発がざっくりわかるようなハンズオンを作成してみました。

https://zenn.dev/linnefromice/scraps/c9973317e51064

利用する技術については以下になります。

  • スマートコントラクト
  • インターフェース(フロントエンド)
    • ベースフレームワーク ... React
    • web3クライアント ... ethers

https://hardhat.org/

https://docs.ethers.io/v5/

ハンズオン

作っていくものは簡易的なTODOアプリで、スマートコントラクトの実装を始め、多くをこちらを参考にしています

https://www.dappuniversity.com/articles/blockchain-app-tutorial

こちらのチュートリアルでは、truffle, bootstrap, web3.js をベースにしていますが、今回はこれらの利用技術を hardhat, React/TypeScript, ethers.js にしてリメイクしました。

以前は、truffle, web3.jsなどがよく使用されていましたが、この辺りは最近よく代替されるフレームワーク/ライブラリに置き換えてみました。

こちらが作っていく dapps のイメージです。
(今回なるべく必要な実装のみにフォーカスするために style を加味していません。見づらくてすみません...)

また今回のハンズオンのコードは github に置いてあります、必要であれば参考にしてください。

https://github.com/linnefromice/simple-todo-dapp-for-zenn

スマートコントラクト

セットアップ

最初から Typescript の恩恵を受けたいために、最初から用意されている TypeScript のサンプルプロジェクトを選択して、初期化します
Create an advanced sample project that uses TypeScript

simple-todo-dapp-for-zenn % npx hardhat
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.7.1 👷‍

✔ What do you want to do? · Create an advanced sample project that uses TypeScript
✔ Hardhat project root: · /Users/linnefromice/repository/github.com/linnefromice/simple-todo-dapp-for-zenn
✔ Do you want to add a .gitignore? (Y/n) · y
✔ Do you want to install this sample project's dependencies with npm (hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @nomiclabs/hardhat-etherscan dotenv eslint eslint-config-prettier eslint-config-standard eslint-plugin-import eslint-plugin-node eslint-plugin-prettier eslint-plugin-promise hardhat-gas-reporter prettier prettier-plugin-solidity solhint solidity-coverage @typechain/ethers-v5 @typechain/hardhat @typescript-eslint/eslint-plugin @typescript-eslint/parser @types/chai @types/node @types/mocha ts-node typechain typescript)? (Y/n) · y

コントラクト実装

以下のようなコントラクトを作成します

  • ざっくりとした仕様
    • 複数のタスクを管理可能
    • 1件のタスク作成をする機能
    • 指定したタスクの完了ステータスを変更する機能
      • 完了ステータスは、完了済/未完了の2種類
  • 補足
    • ユーザーごとのタスク管理はしない

これらを実現した実装は以下です。

contracts/TodoList.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

contract TodoList {
    event Created(uint id, string content);
    event UpdateIsCompleted(uint id, bool completed);

    struct Task {
        string content;
        bool isCompleted;
    }

    uint public taskCount = 0;
    mapping(uint => Task) public tasks;

    constructor() {
        createTask("Initial Data");
    }

    function createTask(string memory _content) public {
        taskCount++;
        tasks[taskCount] = Task(_content, false);
        emit Created(taskCount, _content);
    }

    function toggleIsCompleted(uint _id) public {
        Task memory _task = tasks[_id];
        _task.isCompleted = !_task.isCompleted;
        tasks[_id] = _task;
        emit UpdateIsCompleted(_id, _task.isCompleted);
    }
}

コードでやっていることを簡単に説明します

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
contract TodoList {
    ...
}
  • この中に Contract が保有する属性や機能を実装します
    • ここではTodoListという名前のコントラクトを宣言しています
    struct Task {
        string content;
        bool isCompleted;
    }
    uint public taskCount = 0;
    mapping(uint => Task) public tasks;
  • Contract のもつ属性を宣言しています
    • uint public taskCount ... 管理しているタスク数
    • mapping(uint => Task) public tasks ... 管理しているタスク
      • key = uint型の値, value = タスク の map を利用
  • 補足
    constructor() {
        createTask("Initial Data");
    }
  • コントラクトの初期化で一度だけ呼ばれる処理を記述しています
    • ここでは後述する function を利用していますが、1件だけタスクの作成を行なっています
    event Created(uint id, string content);
    event UpdatedIsCompleted(uint id, bool completed);

    ...

    function createTask(string memory _content) public {
        taskCount++;
        tasks[taskCount] = Task(_content, false);
        emit Created(taskCount, _content);
    }

    function toggleIsCompleted(uint _id) public {
        Task memory _task = tasks[_id];
        _task.isCompleted = !_task.isCompleted;
        tasks[_id] = _task;
        emit UpdatedIsCompleted(_id, _task.isCompleted);
    }

コントラクトのコンパイル

コントラクトの実装ができたら、コンパイルをしてみます。
デプロイ時に自動的にコンパイルもしてくれますが、明示的にコンパイルしてみましょう。

コンパイルをすることで下記を得られます。

  1. Solidity の実行環境になる EVM で動かすための bytecode
  2. Application Binary Interface (ABI)

今回は後続のフロントエンド開発のために、ABIが必要です。

ではコンパイルをしてみます。

# コンパイル
simple-todo-dapp-for-zenn % npx hardhat compile
Compiling 1 file with 0.8.4
Generating typings for: 1 artifacts in dir: typechain for target: ethers-v5
Successfully generated 5 typings!
Compilation finished successfully
# artifact 確認
simple-todo-dapp-for-zenn % ls artifacts/contracts/TodoList.sol
TodoList.dbg.json	TodoList.json

これにより、今回必要なABIであるartifacts/contracts/TodoList.sol/TodoList.jsonが生成できました。

コントラクトの動作確認

実装したコントラクトの動作確認もしてみましょう。
本来テストを書いておく方が良いですが、わかりやすいと思うので手で動作確認をしてみることとします。(コントラクトのテストコードについては後半で記述します。)

hardhat の console を利用することで、対話式にコマンド実行ができます
npx hardhat consoleを実行することで、Hardhat Runtime Environment (HRE) でコントラクトに対する操作を行います。

コマンドにてether.jsとそのAPIを利用するのですが、個別APIの解説は含めません。
ether.js自体はJavaScript/TypeScriptで ethereum を利用/操作するためのライブラリです。

linnefromice@arataharuyamanoMacBook-Pro simple-todo-dapp-for-zenn % npx hardhat console
No need to generate any newer typings.
Welcome to Node.js v17.2.0.
Type ".help" for more information.
# 1. コントラクト"TodoList"のデプロイ
> const TodoList = await ethers.getContractFactory("TodoList");
undefined
> const todoList = await TodoList.deploy();
undefined
# 2. コンストラクタで生成された1件のタスクを確認
# taskCount() -> 1件タスク生成されているので"1"
# todoList.tasks(1) -> content="Initial Data"のコンストラクタで生成されたタスクが取得できる
> await todoList.taskCount()
BigNumber { value: "1" }
> await todoList.tasks(1)
[
  BigNumber { value: "1" },
  'Initial Data',
  false,
  id: BigNumber { value: "1" },
  content: 'Initial Data',
  isCompleted: false
]
# 3. `function createTask(string memory _content)` を利用したタスクの生成
> await todoList.createTask("first task")
{
  hash: '0xfcb574156396348675be5b66c45259b7c6ffd89d2f73f7d92567141162f6d6ae',
  ...
}
# 4. 3で作成したタスクの確認
> await todoList.taskCount()
BigNumber { value: "2" }
> await todoList.tasks(2)
[
  BigNumber { value: "2" },
  'first task',
  false,
  id: BigNumber { value: "2" },
  content: 'first task',
  isCompleted: false
]
# 5. `function toggleIsCompleted(uint _id)` を利用したタスクの完了ステータスの更新
> await todoList.toggleIsCompleted(1)
{
  hash: '0x404b049e29b6cf1b75fa77a1d811f3f22eb8ff746610056a6fbfb7d7a31c1226',
  ...
}
# 6. 5で更新したタスクの確認
# -> id=1 は更新され、id=2 は更新されていない
> await todoList.tasks(1)
[
  BigNumber { value: "1" },
  'Initial Data',
  true,
  id: BigNumber { value: "1" },
  content: 'Initial Data',
  isCompleted: true
]
> await todoList.tasks(2)
[
  BigNumber { value: "2" },
  'first task',
  false,
  id: BigNumber { value: "2" },
  content: 'first task',
  isCompleted: false
]

一通り実装した function の動作確認をしてみました!

ローカルのネットワークにデプロイしてみる

ここまででコントラクト自体は完成しました!
このコントラクトをこれから作成するフロントエンドと接続するために、デプロイが必要です。

今回は簡単に接続までいくために、ローカルでネットワークを作成し、そのローカルネットワークにコントラクトをデプロイします。

  1. 指定したネットワークに対してデプロイするためのスクリプトを実装
scripts/deploy.ts
import { ethers } from "hardhat";

async function main() {
  // コントラクト`TodoList`をデプロイ
  const TodoList = await ethers.getContractFactory("TodoList");
  const todoList = await TodoList.deploy();
  // デプロイ完了まで待機
  await todoList.deployed();

  // デプロイされたアドレスを出力
  // -> フロントエンドで接続するための情報として必要
  console.log("todoList deployed to:", todoList.address);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
  1. ローカルのネットワークを作成

コマンドの実行によって起きていることをざっくり説明します。

  • ローカルネットワークへアクセスするためにhttp://127.0.0.1:8545/の JSON-RPC サーバーが起動します
  • このネットワーク内にデフォルトで存在する20件のアカウントの情報が出力されています

このコマンドで出力された、エンドポイントとアカウントをフロントエンドで利用します。

simple-todo-dapp-for-zenn % npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.

Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Account #1: 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 (10000 ETH)
Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

...

Account #19: 0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199 (10000 ETH)
Private Key: 0xdf57089febbacf7ba0bc227dafbffa9fc08a93fdc68e1e42411a14efcf23656e

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.
  1. ローカルのネットワークにデプロイスクリプトを実行
simple-todo-dapp-for-zenn % npx hardhat run scripts/deploy.ts --network localhost
No need to generate any newer typings.
todoList deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3

1.で実装した通り、ローカルのネットワークにデプロイされたコントラクトのアドレスが表示されています!
0x5FbDB2315678afecb367f032d93F642f64180aa3

合わせてこのタイミングで、ローカルのネットワークを起動した方のターミナルも確認してみてください。以下のようなログが出力されていると思います。

eth_sendTransaction
  Contract deployment: TodoList
  Contract address:    0x5fbdb2315678afecb367f032d93f642f64180aa3
  Transaction:         0x376862b3b1bc5fa66710ca238c5ed4f1189c05d46f9a09058b1fffa2ee3b7bb4
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  Value:               0 ETH
  Gas used:            631868 of 631868
  Block #1:            0x6aa4d590cd531182b74120ca30ea822e0f447c6bdd180be25f099ea9c94a2403

ここまででスマートコントラクト側の対応は完了です!
接続するフロントエンドを構築していきましょう。

フロントエンド

まず React/TypeScript の環境構築をしましょう。
今回は Parcel を利用しますが、create-react-appでもNext.jsでも何を利用していただいても構いません。

環境構築

このハンズオンではwebfront/フォルダでフロントエンド向けの実装を進めていきます。
もしご自分で React/TypeScript の環境を準備される方は、何らかページが出るようにしてもらえればこの章は飛ばしていただいて構いません!

  1. フロント用フォルダ作成 & 必要なライブラリ取得
mkdir webfront && cd webfront
yarn init -y
yarn add react react-dom typescript
yarn add --dev parcel @types/react @types/react-dom parcel-bundler
  1. TypeScript 利用のための設定

tsconfig.jsonを作成します。
npx tsc --initで初期生成し、手修正を加えます。
(面倒であれば後続の内容を copy & paste してもらってもいいと思います。)

npx tsc --init

tsconfig.jsonを手修正

tsconfig.json
{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Projects */
    // nothing.
    /* Language and Environment */
    "target": "es2020",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "lib": ["ES2020", "DOM"],                            /* Specify a set of bundled library declaration files that describe the target runtime environment. */
    "jsx": "react",                                      /* Specify what JSX code is generated. */
    /* Modules */
    "module": "esnext",                                  /* Specify what module code is generated. */
    "rootDir": "./",                                     /* Specify the root folder within your source files. */
    "moduleResolution": "node",                          /* Specify how TypeScript looks up a file from a given module specifier. */
    "baseUrl": "./src",                                  /* Specify the base directory to resolve non-relative module names. */
    /* JavaScript Support */
    // nothing.
    /* Emit */
    // nothing.
    /* Interop Constraints */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
    /* Type Checking */
    "strict": true,                                      /* Enable all strict type-checking options. */
    /* Completeness */
    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  }
}
  1. フロントを起動し、ブラウザで確認してみる

以下の流れで、フロント起動まで確認しましょう

  • view を追加
  • package.jsonに起動コマンドを追加
  • 起動し、ブラウザで確認
# view 用のファイルを配置
mkdir src && cd src
touch index.html index.tsx App.tsx
src/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>WebFront for TodoList Contract</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="index.tsx"></script>
  </body>
</html>
src/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";

const app = document.getElementById("app");
ReactDOM.render(<App />, app);
src/App.tsx
import React, { VFC } from "react";

export const App: VFC = () => <h1>Hello, TodoList Contract.</h1>

package.json
{
  ...,
+  "scripts": {
+    "dev": "parcel src/index.html"
+  }
}

cd ..
yarn dev

表示内容

yarn run v1.22.17
warning ../package.json: No license field
$ parcel src/index.html
Server running at http://localhost:1234

ここまできたら、任意のブラウザでhttp://localhost:1234を開きましょう。
以下のような表示になれば、ベースの構築はできています。

  1. 少し追加修正

改善と以降のための対応を少し入れておきます。

.gitignore
.cache
dist
package.json
{
  ...,
+  "browserslist": [
+    "since 2017-06"
+  ],
  "scripts": {
    "dev": "parcel src/index.html"
  }
}
tsconfig.json
{
  ...
+  "resolveJsonModule": true,
  ...
}

コントラクトへの接続

ようやくここからコントラクトを使うための対応を進めていけます

まずは、コントラクト自体と接続するために etherjs をインストールしましょう

yarn add etherjs

etherjs を利用してコントラクトと接続するためには、以下が必要です

  • コントラクトがデプロイされたアドレス
    • 実際にローカルネットワークにコントラクトをデプロイして確認します
  • コントラクトのABI
    • コントラクトをコンパイルして生成したアーティファクトを持ってきて利用します
  1. "コントラクトがデプロイされたアドレス"を取得

"ローカルのネットワークにデプロイしてみる"の時に実施したコマンドと同じものを実行し、アドレスを確認しましょう。

# Terminal 1
simple-todo-dapp-for-zenn % npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/
...
# Terminal 2
simple-todo-dapp-for-zenn % npx hardhat run scripts/deploy.ts --network localhost             
No need to generate any newer typings.
todoList deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
# -> 0x5FbDB2315678afecb367f032d93F642f64180aa3
  1. "コントラクトのABI"を取得

"コントラクトのコンパイル"で生成されたABIのファイルをwebfront/配下にコピーして参照可能にしましょう。

mkdir abi
cp -rp ../artifacts/contracts/TodoList.sol/TodoList.json src/abi
  1. アドレス、ABIを利用して接続のためのフロントを実装

ここでは、etherjs におけるブロックチェーンにデプロイされたコントラクトを表現するオブジェクトであるContractを宣言するところまで実装します。

src/App.tsx
import React, { VFC } from "react";
import { ethers } from "ethers";
import artifact from "./abi/TodoList.json";

const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3"

export const App: VFC = () => {
  const provider = new ethers.providers.JsonRpcProvider();
  const contract = new ethers.Contract(contractAddress, artifact.abi, provider);
  // const { METHOD_NAME } = contract.functions;

  return (<h1>Hello, TodoList Contract.</h1>)
}

1,2 で取得したアドレスとABIは以下のように利用できるようにします。

...
// ABI
import artifact from "./abi/TodoList.json";
// アドレス
const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3"
...

既に宣言したアドレスとABIを利用して、デプロイしたコントラクトに接続するための実装が以下です。
etherjs の中心となるオブジェクトも含めて簡単な解説をします。

  • Provider
    • ブロックチェーンデータにアクセスするためのオブジェクト
    • 先述した通りローカルネットワーク自体は hardhat によって以下にアクセスすることができます
      • ローカルネットワークへアクセスするためにhttp://127.0.0.1:8545/の JSON-RPC サーバーが起動します

    • etherjs の JsonRpcProvider を利用することでフロントからアクセス可能になります
      • ref: JsonRpcProvider
      • 特にURL, ネットワークを指定しないとデフォルトでhttp://127.0.0.1:8545にアクセスします
  • Contract
    • ブロックチェーンにデプロイされたコントラクトを表現するオブジェクト
    • 引数に以下を指定することで、コントラクトの抽象化を取得できます
      • アドレス
      • ABI
      • Provider
  const provider = new ethers.providers.JsonRpcProvider();
  const contract = new ethers.Contract(contractAddress, artifact.abi, provider);

これでコントラクトに接続する準備はできました。

最後におまけですが以下のようにして、コントラクトの function を呼び出すためのメソッドを取得します。
METHOD_NAMEは function name に置き換えます。
(ABI をみると、提供されている function の一覧が確認できます)

const { METHOD_NAME } = contract.functions;

コントラクトの利用 - 参照系

今回コントラクト実装に、function として createTask,toggleIsCompletedという更新系の処理を明示的に記述しました。
ではこのトピックで利用しようとしている参照系のメソッドは、どこから生まれたのかというと、コントラクトで宣言した状態変数から自動生成されたものです。

具体的には以下のコードです。

    uint public taskCount = 0;
    mapping(uint => Task) public tasks;

visibility type として public を設定しているので、自動的に getter 関数が提供されています。(ABIでもそこから生成された関数が確認できます。)
ref: https://docs.soliditylang.org/en/v0.8.9/contracts.html#visibility-and-getters

今回はこちらの自動生成された getter 関数を利用します。

タスク件数の取得

一番シンプルな function であるtaskCountを利用して、タスク件数の取得から着手してみます。
taskCountをコールし、画面描画するための実装の全量は以下です。

src/App.tsx
import React, { useEffect, useState, VFC } from "react";
import { ethers } from "ethers";
import artifact from "./abi/TodoList.json";

const useContent = (
  contract: ethers.Contract
) => {
  const { taskCount } = contract.functions;
  const [taskCountValue, setTaskCountValue] = useState<string>("");
  useEffect(() => {
    const getTaskCount = async () => {
      const _taskCount = await taskCount();
      setTaskCountValue(_taskCount);
    }
    getTaskCount()
  }, [])

  return {
    taskCount: taskCountValue
  }
}
const Content: VFC<{contract: ethers.Contract}> = ({contract}) => {
  const { taskCount } = useContent(contract);
  return (<p>{`taskCount ... ${taskCount}`}</p>);
}

const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3"

export const App: VFC = () => {
  const provider = new ethers.providers.JsonRpcProvider();
  const contract = new ethers.Contract(contractAddress, artifact.abi, provider);

  return (
    <div>
      <h1>Hello, TodoList Contract.</h1>
      <Content contract={contract} />
    </div>
  )
}

肝心のtaskCountをコールしてタスク件数を取得するコードは、hooks に集約しています。コメントを加えて、実装意図を説明します。

const useContent = (
  contract: ethers.Contract
) => {
  // function`taskCount`を呼び出すための関数を取得
  const { taskCount } = contract.functions;
  // `taskCount`を実行した結果を保存するための state
  const [taskCountValue, setTaskCountValue] = useState<string>("");
  // useEffect を利用してコンポーネントマウント時に`taskCount`をコールする
  useEffect(() => {
    const getTaskCount = async () => {
      const _taskCount = await taskCount(); // 実際にコントラクトをコールしている部分
      setTaskCountValue(_taskCount);
    }
    getTaskCount()
  }, [])

  return {
    taskCount: taskCountValue // state に保存した件数を return します
  }
}

hooks で取得した件数を、画面側で呼び出します。

const Content: VFC<{contract: ethers.Contract}> = ({contract}) => {
  const { taskCount } = useContent(contract);
  return (<p>{`taskCount ... ${taskCount}`}</p>);
}

ここまで実装ができたら、起動すると以下のような画面が確認できると思います。

コントラクトのローカルネットワークの方では以下のようにログ出力されていることが確認できます。

eth_call
  Contract call:       TodoList#taskCount
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  To:                  0x5fbdb2315678afecb367f032d93f642f64180aa3

タスク一覧の表示

次にtasksを利用して、タスク一覧を表示してみましょう。
taskCountで取得した件数を利用して、件数分 loop を回して全タスクを取得します。

  1. タスク自体を表現するための type を用意
src/App.tsx
type Task = {
  id: string,
  content: string,
  isCompleted: boo
}
  1. hooks 部分の更新

tasksを繰り返しコールして、全タスクを state に保持します。

src/App.tsx
 const useContent = (
   contract: ethers.Contract
 ) => {
-  const { taskCount } = contract.functions;
+  const { taskCount, tasks } = contract.functions;
   const [taskCountValue, setTaskCountValue] = useState<string>("");
+  const [tasksValue, setTasksValue] = useState<Task[]>([]);
   useEffect(() => {
-    const getTaskCount = async () => {
+    const getTasks = async () => {
       const _taskCount = await taskCount();
       setTaskCountValue(_taskCount);
+
+      const _tasks = []
+      for (let i = 1; i <= _taskCount; i++) {
+        const _task = await tasks(i);
+        _tasks.push({
+          ..._task,
+          id: i
+        })
+      }
+      setTasksValue(_tasks);
     }
-    getTaskCount();
+    getTasks();
   }, [])
 
   return {
-    taskCount: taskCountValue
+    taskCount: taskCountValue,
+    tasks: tasksValue
   }
 }

以下少し解説を加えていきます。

今回コールするためのtasks関数をここで取得しています。

const { taskCount, tasks } = contract.functions;

tasksをコールしてタスク全件取得する部分について

  • useEffectで実行する1つの非同期関数内で、タスク件数取得と全タスク取得を行なっています
    • 初期化した空配列を用意して、そこに1件ずつ取得したタスクを格納してきます
    • 格納する際に、最初に定義したtype Taskに格納できるように変換するようにしています
  useEffect(() => {
    const getTasks = async () => {
      const _taskCount = await taskCount();
      setTaskCountValue(_taskCount);

      const _tasks = []
      for (let i = 1; i <= _taskCount; i++) {
        const _task = await tasks(i);
        _tasks.push({
          ..._task,
          id: i
        })
      }
      setTasksValue(_tasks);
    }
    getTasks();
  }, [])

最後に、タスク全件を hooks から取得できるように return する対象に含めます。

  return {
    taskCount: taskCountValue,
    tasks: tasksValue
  }
  1. 描画部分の更新

<table>タグを利用して、取得できるタスク全件を表示します。
タスク全件であるtasksarrayなので、mapを利用し、1タスクごとの行を構築します。

src/App.tsx
-  const { taskCount } = useContent(contract);
-  return (<p>{`taskCount ... ${taskCount}`}</p>);
+  const { taskCount, tasks } = useContent(contract);
+  return (
+    <div>
+      <p>{`taskCount ... ${taskCount}`}</p>
+      <table>
+        <thead>
+          <tr>
+            <th>ID</th>
+            <th>Content</th>
+            <th>Status</th>
+          </tr>
+        </thead>
+        <tbody>
+          {tasks.map((t, index) => <tr key={`task.${index}`}>
+            <td>{t.id}</td>
+            <td>{t.content}</td>
+            <td>{t.isCompleted ? "Completed" : "Not Completed"}</td>
+          </tr>)}
+        </tbody>
+      </table>
+    </div>
+  );

ここまで実装ができたら、起動すると以下のような画面が確認できると思います。
現状コントラクト初期化時に生成した1件のタスクのみが表示されています。

コントラクトのローカルネットワークの方では以下のようにログ出力されていることが確認できます。

eth_call
  Contract call:       TodoList#tasks
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  To:                  0x5fbdb2315678afecb367f032d93f642f64180aa3

以上で参照系の対応は完了です。

コントラクトの利用 - 更新系

ここからタスクの作成/更新をするために、更新系の function を利用していく実装を進めていきます。
フロントからの利用方法は参照系とほぼ変わりませんが、今のままだと失敗してしまいます。
何が問題かというと、更新系の処理(もう少し具体的にいうと、ブロックチェーンネットワーク内にトランザクションを発生させる処理)を行うためには、常に利用料(ガス代)が必要です。
そのためには、コントラクト接続時に利用料を支払うイーサリアムアカウントの設定をする必要があります。
今回は、そのアカウントの設定から更新系の機能を利用するという流れで対応を進めます。

事前設定 - アカウントの設定

これまで etherjs において、Provider, Contract の概念について簡単に紹介し利用してきましたが、ここで利用する概念が一つ増えます。それが Signer です。

  • Signer
    • イーサリアムアカウントのオブジェクト
    • トランザクションに署名し、それを送信することで、イーサリアムネットワークの状態を変更する操作を可能にする
    • ref: https://docs.ethers.io/v5/api/signer/

この Signer を取得し Contract に接続することで、更新処理を実行可能にします。今回そのSignerに紐づくアカウントは、hardhat でのローカルネットワークを生成した時に自動で生成されるローカル用アカウントを利用します。(本来は Metamask などのサービスを利用して生成されたアカウントを利用したりしますが、今回はローカルでの開発のためこのような方針にしています。)

実装の全量は以下です。

src/App.tsx
 export const App: VFC = () => {
   const provider = new ethers.providers.JsonRpcProvider();
+  const signer = provider.getSigner();
   const contract = new ethers.Contract(contractAddress, artifact.abi, provider);
+  const contractWithSigner = contract.connect(signer);

   return (
     <div>
       <h1>Hello, TodoList Contract.</h1>
-      <Content contract={contract} />
+      <Content contract={contractWithSigner} />
     </div>
   )
 }

また簡単に解説と参考を加えると以下の通りです。

以上で、コントラクトへのアカウント設定は完了です。

ちなみに、先出しになりますが、もしアカウント設定せずにコントラクトの更新処理を利用しようとすると、フロント側で下記のようなエラーが確認できます。
(開発者ツールのコンソールを確認してみてください。)
エラー内容の通り、この時にはコントラクト側のログにトランザクション発生また関数呼び出しのログは出ていません。

Error: sending a transaction requires a signer (operation="sendTransaction", code=UNSUPPORTED_OPERATION, version=contracts/5.5.0)

タスクの作成

改めてタスク作成からやってみましょう。
参照系と同様に、今回呼び出したい function createTask を contract から呼び出し、実際に利用してみます。

まず、 hooks については

  • 作成したいタスクの content を設定する
  • 実際にコントラクトにタスク作成を行う

という2つの関数を作成します。以下のような修正になります。

src/App.tsx
const useContent = (
   ...
+  const [taskContent, setTaskContent] = useState<string>("");

   useEffect(() => {
     ...
   }, [])

+  const updateTaskContent = (e: React.ChangeEvent<HTMLInputElement>) => setTaskContent(e.target.value);
+  const requestCreateTask = async () => {
+    if (taskContent === "") return;
+    await createTask(taskContent);
+  };
+
   return {
     taskCount: taskCountValue,
     tasks: tasksValue,
+    updateTaskContent,
+    requestCreateTask
+  }
 }

次にこれらの関数を画面から呼び出す実装とUIを実装します。

src/App.tsx
 const Content: VFC<{contract: ethers.Contract}> = ({contract}) => {
-  const { taskCount, tasks } = useContent(contract);
+  const { taskCount, tasks, updateTaskContent, requestCreateTask } = useContent(contract);
+
+  const handleCreateTask = async () => {
+    await requestCreateTask();
+    window.location.reload();
+  }

   return (
     <div>
+      <p>
+        <input onChange={updateTaskContent} />
+        <button onClick={handleCreateTask}>Create Task</button>
+      </p>
       <p>{`taskCount ... ${taskCount}`}</p>
       <table>
         <thead>
          ...

一点だけ注意ポイントがあります。
ここでは実際にタスク作成をコントラクトにするための関数requestCreateTaskの実行に加え、window.location.reload()を行うための関数で改めてラップしています。
なぜなら更新した処理を反映させる手段を持っていないため、改めてコントラクトからタスクの全件を取得し直すために強制的にリロードさせるようにしています。

ここまで実装ができたら、起動すると以下のような画面表示と追加した更新処理の実行とその結果の確認ができると思います。

またコントラクトのローカルネットワークの方では以下のようにログ出力されていることが確認できます。

eth_estimateGas
eth_sendTransaction
  Contract call:       TodoList#createTask
  Transaction:         0x27b86bd7f2abbc5d06c1d045aa2f6b5486d7ba02e410d4a075595f095413f57b
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  To:                  0x5fbdb2315678afecb367f032d93f642f64180aa3
  Value:               0 ETH
  Gas used:            77592 of 77592
  Block #2:            0xefb3f6bcb15ac8b5f773a4bbf59bd076321c318db265d05830ad0032b1555a79
...
eth_estimateGas
eth_sendTransaction
  Contract call:       TodoList#createTask
  Transaction:         0x966c1e224e2df2ea279f70578e145f719012e40c9d3fc886b76f448c0fb35dce
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  To:                  0x5fbdb2315678afecb367f032d93f642f64180aa3
  Value:               0 ETH
  Gas used:            77604 of 77604
  Block #3:            0x11182689cfd6458a46243530855594ccb03ea55479e9eec5ebc0227c3bded740

タスクの更新

最後にタスク更新です。
更新対象のタスクを特定した上で、今回呼び出したい function toggleIsCompleted を利用することで、実際に完了ステータスの更新を行います。

まず、hooks に対して実装を加えましょう。
シンプルにtoggleIsCompletedを呼び出すための function を定義しています。
(念の為 state に保存されている task に含まれているかを確認しています。)

src/App.tsx
 const useContent = (
   contract: ethers.Contract
 ) => {
-  const { taskCount, tasks, createTask } = contract.functions;
+  const { taskCount, tasks, createTask, toggleIsCompleted } = contract.functions;
   ...
+  const requestToggleIsCompleted = async (id: string) => {
+    for (const _task of tasksValue) {
+      if (id === _task.id) {
+        await toggleIsCompleted(id);
+        return;
+      }
+    }
+  }
+
   return {
     ...
     updateTaskContent,
-    requestCreateTask
+    requestCreateTask,
+    requestToggleIsCompleted
   }
 }

次にこれらの関数を画面から呼び出す実装とUIを実装します。

  • 更新ボタンは各タスクの列に含めます
  • タスク作成の時と同様に、requestToggleIsCompleted実行後、画面を強制リロードさせます
src/App.tsx
const Content: VFC<{contract: ethers.Contract}> = ({contract}) => {
-  const { taskCount, tasks, updateTaskContent, requestCreateTask } = useContent(contract);
+  const { taskCount, tasks, updateTaskContent, requestCreateTask, requestToggleIsCompleted } = useContent(contract);
...
+  const handleToggleIsCompleted = async (id: string) => {
+    await requestToggleIsCompleted(id);
+    window.location.reload();
+  }
+
   return (
     ...
          {tasksValue.map((t, index) => <tr key={`task.${index}`}>
             <td>{t.id}</td>
             <td>{t.content}</td>
             <td>{t.isCompleted ? "Completed" : "Not Completed"}</td>
+            <td><button onClick={() => handleToggleIsCompleted(t.id)}>Change</button></td>
           </tr>)}
         </tbody>
       </table>
...

ここまで実装ができたら、起動すると以下のような画面表示と追加した更新処理の実行とその結果の確認ができると思います。

またコントラクトのローカルネットワークの方では以下のようにログ出力されていることが確認できます。

eth_estimateGas
eth_sendTransaction
  Contract call:       TodoList#toggleIsCompleted
  Transaction:         0x7c8e9b9a11ea017dfd963e08e3752d6ef702fb8f52561fe71e285f40741db9a2
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  To:                  0x5fbdb2315678afecb367f032d93f642f64180aa3
  Value:               0 ETH
  Gas used:            53760 of 53760
  Block #4:            0xecab1b99a2956684f1068d0c85f0708224b1172cb9a31591420618d7a38fd736
...
eth_estimateGas
eth_sendTransaction
  Contract call:       TodoList#toggleIsCompleted
  Transaction:         0x238860f21d59864a0ddd591c547e38d995f884804ef4aa2444814c0c6d9aa1b1
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  To:                  0x5fbdb2315678afecb367f032d93f642f64180aa3
  Value:               0 ETH
  Gas used:            53760 of 53760
  Block #8:            0x84b0e12f2e757b740ee63c33866e5542cc1d12b3278a548a74923e5e619adc5a

以上で更新系の対応は完了です。
ハンズオン自体もここで完了になります、ここまで実施していただいた皆様ありがとうございました!

おわりに

今回はテスト、イベントハンドリングや、さらに本格的にするのであればウォレット接続などの要素を落として、極力少ない要素で全体感がわかるようなハンズオンを意識してみましたがいかがでしょうか。

少しでも "web3" を知ってもらったり、また既に興味を持っている方々に対して何かしら貢献できていたら幸いです。

最後までお読みいただきありがとうございました!🙇

Discussion

yuki agatsumayuki agatsuma

大変面白い記事でした!!ありがとうございます!

ちなみに
yarn add etherjs
ではなく、
yarn add ethers

でしょうか・・?