【Solidity / React】シンプルなdappsを作ってみる
はじめに
今回は web2 を中心とした Web開発者向けに、
- Solidity を利用したスマートコントラクト作成
- React で↑と接続するためのフロントエンド作成
を通して、簡単に web3/dapps の開発がざっくりわかるようなハンズオンを作成してみました。
利用する技術については以下になります。
ハンズオン
作っていくものは簡易的なTODOアプリで、スマートコントラクトの実装を始め、多くをこちらを参考にしています
こちらのチュートリアルでは、truffle
, bootstrap
, web3.js
をベースにしていますが、今回はこれらの利用技術を hardhat
, React/TypeScript
, ethers.js
にしてリメイクしました。
以前は、truffle
, web3.js
などがよく使用されていましたが、この辺りは最近よく代替されるフレームワーク/ライブラリに置き換えてみました。
こちらが作っていく dapps のイメージです。
(今回なるべく必要な実装のみにフォーカスするために style を加味していません。見づらくてすみません...)
また今回のハンズオンのコードは github に置いてあります、必要であれば参考にしてください。
スマートコントラクト
セットアップ
最初から 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種類
- 補足
- ユーザーごとのタスク管理はしない
これらを実現した実装は以下です。
//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
- Solidity ではSPDXライセンス識別子の使用を推奨しています
- こちらを指定しないと warning が出力されます
- ref: Layout of a Solidity Source File#spdx-license-identifier — Solidity 0.8.10 documentation
pragma solidity ^0.8.0;
- Solidity に必要なコンパイラのバージョンを指定
- これは、
0.8.0 <= selected && selected < 0.9.0
となる - ref: Layout of a Solidity Source File#pragmas — Solidity 0.8.10 documentation
- これは、
contract TodoList {
...
}
- この中に Contract が保有する属性や機能を実装します
- ここでは
TodoList
という名前のコントラクトを宣言しています
- ここでは
struct Task {
string content;
bool isCompleted;
}
- タスクの概念を表す構造体を宣言しています
-
struct
を利用します - Typescript の type alias のようなものです
-
- 今回作成した構造体は以下の属性があります
-
string content
... コンテンツ本文 / string型 -
bool isCompleted
... 完了済かどうか / bool型 - Solidity にある型は以下を参照
-
uint public taskCount = 0;
mapping(uint => Task) public tasks;
- Contract のもつ属性を宣言しています
-
uint public taskCount
... 管理しているタスク数 -
mapping(uint => Task) public tasks
... 管理しているタスク- key =
uint
型の値, value = タスク の map を利用
- key =
-
- 補足
-
public
-
visibility
の一つで、public
を指定することで、外部からアクセス可能にしています - ref: Cheatsheet#function-visibility-specifiers — Solidity 0.8.10 documentation
-
-
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);
}
- 2種類の機能と、それらの機能から発行されるイベントを定義しています
-
function createTask(string memory _content)) public
- 指定したコンテンツ本文を入れて、未完了のタスクを作成する
-
Created
というイベントを発行する
-
function toggleIsCompleted(uint _id)) public
- 指定した id と一致する key から取得できたタスクのステータスを更新する
-
UpdatedIsCompleted
というイベントを発行する
-
- 参考
コントラクトのコンパイル
コントラクトの実装ができたら、コンパイルをしてみます。
デプロイ時に自動的にコンパイルもしてくれますが、明示的にコンパイルしてみましょう。
コンパイルをすることで下記を得られます。
- Solidity の実行環境になる EVM で動かすための bytecode
- Application Binary Interface (ABI)
- スマートコントラクトを利用するアプリケーションがコントラクトの機能を利用するためのJSONファイル
- ref: Contract ABI Specification — Solidity 0.8.11 documentation
今回は後続のフロントエンド開発のために、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 では、デフォルトで
artifacts/
に生成物を配置してくれます - ref: Compiling your contracts | Hardhat | Ethereum development environment for professionals by Nomic Labs
- hardhat では、デフォルトで
コントラクトの動作確認
実装したコントラクトの動作確認もしてみましょう。
本来テストを書いておく方が良いですが、わかりやすいと思うので手で動作確認をしてみることとします。(コントラクトのテストコードについては後半で記述します。)
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 の動作確認をしてみました!
ローカルのネットワークにデプロイしてみる
ここまででコントラクト自体は完成しました!
このコントラクトをこれから作成するフロントエンドと接続するために、デプロイが必要です。
今回は簡単に接続までいくために、ローカルでネットワークを作成し、そのローカルネットワークにコントラクトをデプロイします。
- 流れ
- 指定したネットワークに対してデプロイするためのスクリプトを実装
-
scripts/deploy.ts
で実装します
-
- ローカルのネットワークを作成
npx hardhat node
- ローカルのネットワークにデプロイスクリプトを実行
npx hardhat run scripts/deploy.ts --network localhost
- 指定したネットワークに対してデプロイするためのスクリプトを実装
- ref: Overview | Hardhat | Ethereum development environment for professionals by Nomic Labs
- 指定したネットワークに対してデプロイするためのスクリプトを実装
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;
});
- ローカルのネットワークを作成
コマンドの実行によって起きていることをざっくり説明します。
- ローカルネットワークへアクセスするために
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.
- ローカルのネットワークにデプロイスクリプトを実行
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 の環境を準備される方は、何らかページが出るようにしてもらえればこの章は飛ばしていただいて構いません!
- フロント用フォルダ作成 & 必要なライブラリ取得
mkdir webfront && cd webfront
yarn init -y
yarn add react react-dom typescript
yarn add --dev parcel @types/react @types/react-dom parcel-bundler
- TypeScript 利用のための設定
tsconfig.json
を作成します。
npx tsc --init
で初期生成し、手修正を加えます。
(面倒であれば後続の内容を copy & paste してもらってもいいと思います。)
npx tsc --init
↓ 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. */
}
}
- フロントを起動し、ブラウザで確認してみる
以下の流れで、フロント起動まで確認しましょう
- view を追加
-
package.json
に起動コマンドを追加 - 起動し、ブラウザで確認
# view 用のファイルを配置
mkdir src && cd src
touch index.html index.tsx App.tsx
<!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>
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
const app = document.getElementById("app");
ReactDOM.render(<App />, app);
import React, { VFC } from "react";
export const App: VFC = () => <h1>Hello, TodoList Contract.</h1>
↓
{
...,
+ "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
を開きましょう。
以下のような表示になれば、ベースの構築はできています。
- 少し追加修正
改善と以降のための対応を少し入れておきます。
- ビルド時に生成される
.cache
,dist
を git 管理対象外にする - parcel で async/await が使用できるように、利用可能ブラウザに制限をかける
- jsonファイルのモジュールを import 可能にするために、
tsconfig.json
でresolveJsonModule
を設定します
.cache
dist
{
...,
+ "browserslist": [
+ "since 2017-06"
+ ],
"scripts": {
"dev": "parcel src/index.html"
}
}
{
...
+ "resolveJsonModule": true,
...
}
コントラクトへの接続
ようやくここからコントラクトを使うための対応を進めていけます
まずは、コントラクト自体と接続するために etherjs をインストールしましょう
yarn add etherjs
etherjs を利用してコントラクトと接続するためには、以下が必要です
- コントラクトがデプロイされたアドレス
- 実際にローカルネットワークにコントラクトをデプロイして確認します
- コントラクトのABI
- コントラクトをコンパイルして生成したアーティファクトを持ってきて利用します
- "コントラクトがデプロイされたアドレス"を取得
"ローカルのネットワークにデプロイしてみる"の時に実施したコマンドと同じものを実行し、アドレスを確認しましょう。
# 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
- "コントラクトのABI"を取得
"コントラクトのコンパイル"で生成されたABIのファイルをwebfront/
配下にコピーして参照可能にしましょう。
mkdir abi
cp -rp ../artifacts/contracts/TodoList.sol/TodoList.json src/abi
- アドレス、ABIを利用して接続のためのフロントを実装
ここでは、etherjs におけるブロックチェーンにデプロイされたコントラクトを表現するオブジェクトであるContract
を宣言するところまで実装します。
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
をコールし、画面描画するための実装の全量は以下です。
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 を回して全タスクを取得します。
- タスク自体を表現するための type を用意
type Task = {
id: string,
content: string,
isCompleted: boo
}
- hooks 部分の更新
tasks
を繰り返しコールして、全タスクを state に保持します。
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
}
- 描画部分の更新
<table>
タグを利用して、取得できるタスク全件を表示します。
タスク全件であるtasks
がarray
なので、map
を利用し、1タスクごとの行を構築します。
- 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 などのサービスを利用して生成されたアカウントを利用したりしますが、今回はローカルでの開発のためこのような方針にしています。)
実装の全量は以下です。
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>
)
}
また簡単に解説と参考を加えると以下の通りです。
-
const signer = provider.getSigner()
- ローカルネットワークの1件目のダミーアカウントを取得しています
- ref: https://docs.ethers.io/v5/api/providers/jsonrpc-provider/#JsonRpcProvider-getSigner
-
const contractWithSigner = contract.connect(signer);
- contract とアカウントの接続をしています
- ref: https://docs.ethers.io/v5/api/contract/contract/#Contract-connect
- 最後に
Content
Componentに渡す contract をアカウント設定済みの contract に差し替えています
以上で、コントラクトへのアカウント設定は完了です。
ちなみに、先出しになりますが、もしアカウント設定せずにコントラクトの更新処理を利用しようとすると、フロント側で下記のようなエラーが確認できます。
(開発者ツールのコンソールを確認してみてください。)
エラー内容の通り、この時にはコントラクト側のログにトランザクション発生また関数呼び出しのログは出ていません。
Error: sending a transaction requires a signer (operation="sendTransaction", code=UNSUPPORTED_OPERATION, version=contracts/5.5.0)
タスクの作成
改めてタスク作成からやってみましょう。
参照系と同様に、今回呼び出したい function createTask
を contract から呼び出し、実際に利用してみます。
まず、 hooks については
- 作成したいタスクの content を設定する
- 実際にコントラクトにタスク作成を行う
という2つの関数を作成します。以下のような修正になります。
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を実装します。
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 に含まれているかを確認しています。)
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
実行後、画面を強制リロードさせます
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
大変面白い記事でした!!ありがとうございます!
ちなみに
yarn add etherjs
ではなく、
yarn add ethers
でしょうか・・?