メインコンテンツまでスキップ

lesson-3_ユーザーボードを作成しよう

📃 ユーザーボードを作成しよう

このレッスンでは、ユーザーが保有するトークンの情報を一覧表示するボードを作成していきます。

ユーザーボードは以下の機能を持ちます。

  • ユーザーのPrincipalを表示する
  • トークンの保有量が表示される
  • 各トークンに対しての操作(Faucet / Deposit / Withdraw)が行えるボタンを表示する

まずは、必要なファイルを作成します。

touch ./src/icp_basic_dex_frontend/src/components/UserBoard.jsx

続いて、DEX上で扱うトークンの情報をまとめておくためのファイルを作成します。

mkdir ./src/icp_basic_dex_frontend/src/utils && touch ./src/icp_basic_dex_frontend/src/utils/token.js

ここまでで、icp_basic_dex_frontend/srcディレクトリ下のフォルダ構成が以下のようになっているでしょう。

 src/
├── App.css
├── App.jsx
├── components/
│   ├── Header.jsx
+│   └── UserBoard.jsx
+├── utils/
+│   └── token.js
├── index.html
└── index.js

それでは、実装をしていきます。まずはトークンの情報をまとめておく配列をutils/token.jsファイルに実装します。

[tokens.js]

import {
canisterId as GoldDIP20canisterId,
createActor as GoldDIP20CreateActor,
GoldDIP20,
} from "../../../declarations/GoldDIP20";
import {
canisterId as SilverDIP20canisterId,
createActor as SilverDIP20CreateActor,
SilverDIP20,
} from "../../../declarations/SilverDIP20";

// DEX上で扱うトークンのデータを配列に格納
export const tokens = [
{
canisterName: "GoldDIP20",
canister: GoldDIP20,
tokenSymbol: "TGLD",
createActor: GoldDIP20CreateActor,
canisterId: GoldDIP20canisterId,
},
{
canisterName: "SilverDIP20",
canister: SilverDIP20,
tokenSymbol: "TSLV",
createActor: SilverDIP20CreateActor,
canisterId: SilverDIP20canisterId,
},
];

次に、ユーザーボードの機能と、トークン一覧を表示するための実装をします。components/UserBoard.jsxファイルに以下のコードを記述しましょう。

[UserBoard.jsx]

import {
canisterId as faucetCanisterId,
createActor as faucetCreateActor,
} from "../../../declarations/faucet";
import {
canisterId as DEXCanisterId,
createActor as DEXCreateActor,
icp_basic_dex_backend as DEX,
} from "../../../declarations/icp_basic_dex_backend";
import { tokens } from "../utils/token";
import { Principal } from "@dfinity/principal";

export const UserBoard = (props) => {
const { agent, userPrincipal, userTokens, setUserTokens } = props;

const TOKEN_AMOUNT = 500;

const options = {
agent,
};

// ユーザーボード上のトークンデータを更新する
const updateUserToken = async (updateIndex) => {
// ユーザーが保有するトークン量を取得
const balance = await tokens[updateIndex].canister.balanceOf(userPrincipal);
// ユーザーがDEXに預けたトークン量を取得
const dexBalance = await DEX.getBalance(
userPrincipal,
Principal.fromText(tokens[updateIndex].canisterId)
);

setUserTokens(
userTokens.map((userToken, index) =>
index === updateIndex
? {
symbol: userToken.symbol,
balance: balance.toString(),
dexBalance: dexBalance.toString(),
fee: userToken.fee,
}
: userToken
)
);
};

const handleDeposit = async (updateIndex) => {
try {
const DEXActor = DEXCreateActor(DEXCanisterId, options);
const tokenActor = tokens[updateIndex].createActor(
tokens[updateIndex].canisterId,
options
);

// ユーザーの代わりにDEXがトークンを転送することを承認する
const resultApprove = await tokenActor.approve(
Principal.fromText(DEXCanisterId),
TOKEN_AMOUNT
);
if (!resultApprove.Ok) {
alert(`Error: ${Object.keys(resultApprove.Err)[0]}`);
return;
}
// DEXにトークンを入金する
const resultDeposit = await DEXActor.deposit(
Principal.fromText(tokens[updateIndex].canisterId)
);
if (!resultDeposit.Ok) {
alert(`Error: ${Object.keys(resultDeposit.Err)[0]}`);
return;
}
console.log(`resultDeposit: ${resultDeposit.Ok}`);

updateUserToken(updateIndex);
} catch (error) {
console.log(`handleDeposit: ${error} `);
}
};

const handleWithdraw = async (updateIndex) => {
try {
const DEXActor = DEXCreateActor(DEXCanisterId, options);
// DEXからトークンを出金する
const resultWithdraw = await DEXActor.withdraw(
Principal.fromText(tokens[updateIndex].canisterId),
TOKEN_AMOUNT
);
if (!resultWithdraw.Ok) {
alert(`Error: ${Object.keys(resultWithdraw.Err)[0]}`);
return;
}
console.log(`resultWithdraw: ${resultWithdraw.Ok}`);

updateUserToken(updateIndex);
} catch (error) {
console.log(`handleWithdraw: ${error} `);
}
};

// Faucetからトークンを取得する
const handleFaucet = async (updateIndex) => {
try {
const faucetActor = faucetCreateActor(faucetCanisterId, options);
const resultFaucet = await faucetActor.getToken(
Principal.fromText(tokens[updateIndex].canisterId)
);
if (!resultFaucet.Ok) {
alert(`Error: ${Object.keys(resultFaucet.Err)[0]}`);
return;
}
console.log(`resultFaucet: ${resultFaucet.Ok}`);

updateUserToken(updateIndex);
} catch (error) {
console.log(`handleFaucet: ${error}`);
}
};

return (
<>
<div className="user-board">
<h2>User</h2>
<li>principal ID: {userPrincipal.toString()}</li>
<table>
<tbody>
<tr>
<th>Token</th>
<th>Balance</th>
<th>DEX Balance</th>
<th>Fee</th>
<th>Action</th>
</tr>
{/* トークンのデータを一覧表示する */}
{userTokens.map((token, index) => {
return (
<tr key={`${index} : ${token.symbol} `}>
<td data-th="Token">{token.symbol}</td>
<td data-th="Balance">{token.balance}</td>
<td data-th="DEX Balance">{token.dexBalance}</td>
<td data-th="Fee">{token.fee}</td>
<td data-th="Action">
<div>
{/* トークンに対して行う操作(Deposit / Withdraw / Faucet)のボタンを表示 */}
<button
className="btn-green"
onClick={() => handleDeposit(index)}
>
Deposit
</button>
<button
className="btn-red"
onClick={() => handleWithdraw(index)}
>
Withdraw
</button>
<button
className="btn-blue"
onClick={() => handleFaucet(index)}
>
Faucet
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
};

トークンを取得するためのFaucetボタンを押した際に実行される、handleFaucet関数を見てみましょう。ユーザーのPrincipalが必要な関数をコールする際には、ログイン認証後に作成したagentを用いて、キャニスターの関数をコールする必要があります。ここでは、faucetキャニスターのgetToken関数を実行する際にユーザー Principalが必要になります。入金を行うhandleDeposit関数や、出金を行うhandleWithdraw関数でも同様にagentを使用します。

続いて、ログインをした時にユーザーが保有するトークンの情報を取得できるようにしたいと思います。前回のレッスンで作成したHeader.jsxファイルを編集しましょう。

まずは、propsの部分を以下ように更新します。トークンの情報を更新するための関数や、ユーザーの情報を保存するための関数を追加で渡します。

[Header.jsx]

export const Header = (props) => {
const {
+ updateOrderList,
+ updateUserTokens,
+ setAgent,
setUserPrincipal,
} = props;

続いて、handleSuccess関数を以下の内容に書き換えます。propsで渡された関数をコールして、データの保存・更新を行います。

const handleSuccess = async (authClient) => {
// 認証したユーザーの`identity`を取得
const identity = await authClient.getIdentity();

// 認証したユーザーの`principal`を取得
const principal = identity.getPrincipal();
- setUserPrincipal(principal);

console.log(`User Principal: ${principal.toString()}`);

+ // 取得した`identity`を使用して、ICと対話する`agent`を作成する
+ const newAgent = new HttpAgent({ identity });
+ if (process.env.DFX_NETWORK === "local") {
+ newAgent.fetchRootKey();
+ }
+
+ // 認証したユーザーが保有するトークンのデータを取得
+ updateUserTokens(principal);
+ // オーダー一覧を取得
+ updateOrderList();
+
+ // ユーザーのデータを保存
+ setUserPrincipal(principal);
+ setAgent(newAgent);
};

ポイントはfetchRootKeyをコールする部分です。ログイン認証後に取得したユーザーの情報を用いて、ICと対話するagentを作成します。HttpAgentはキャニスターの機能をコールするための関数や、必要なデータがさまざま定義されているクラスになります。ローカル環境のagentはICの公開鍵を持っていないため、このままでは関数をコールすることができません。そのため、fetchRootKey()で鍵を取得する必要があります。

それでは最後に、App.jsxを更新します。新たに、以下の機能を追加します。

  • UserBoard.jsxをインポートしてユーザーボードを表示する
  • 画面のリロード時にユーザーの情報を再取得する
  • Header.jsxに渡すpropsを更新する

それでは、App.jsxファイルの中身を以下のコードで書き換えてください。

[App.jsx]

import React, { useEffect, useState } from "react";
import "./App.css";

import { HttpAgent } from "@dfinity/agent";
import { AuthClient } from "@dfinity/auth-client";
import { Principal } from "@dfinity/principal";

import { icp_basic_dex_backend as DEX } from "../../declarations/icp_basic_dex_backend";
import { Header } from "./components/Header";
import { UserBoard } from "./components/UserBoard";
import { tokens } from "./utils/token";

const App = () => {
const [agent, setAgent] = useState();
const [userPrincipal, setUserPrincipal] = useState();
const [userTokens, setUserTokens] = useState([]);
const [orderList, setOrderList] = useState([]);

const updateUserTokens = async (principal) => {
let getTokens = [];
// ユーザーの保有するトークンのデータを取得
for (let i = 0; i < tokens.length; ++i) {
// トークンのメタデータを取得
const metadata = await tokens[i].canister.getMetadata();
// ユーザーのトークン保有量を取得
const balance = await tokens[i].canister.balanceOf(principal);
// DEXに預けているトークン量を取得
const dexBalance = await DEX.getBalance(
principal,
Principal.fromText(tokens[i].canisterId)
);

// 取得したデータを格納
const userToken = {
symbol: metadata.symbol.toString(),
balance: balance.toString(),
dexBalance: dexBalance.toString(),
fee: metadata.fee.toString(),
};
getTokens.push(userToken);
}
setUserTokens(getTokens);
};

// オーダー一覧を更新する
const updateOrderList = async () => {
const orders = await DEX.getOrders();
const createdOrderList = orders.map((order) => {
const fromToken = tokens.find(
(e) => e.canisterId === order.from.toString()
);

return {
id: order.id,
from: order.from,
fromSymbol: fromToken.tokenSymbol,
fromAmount: order.fromAmount,
to: order.to,
toSymbol: tokens.find((e) => e.canisterId === order.to.toString())
.tokenSymbol,
toAmount: order.toAmount,
};
});
setOrderList(createdOrderList);
};

// ユーザーがログイン認証済みかを確認
const checkClientIdentity = async () => {
try {
const authClient = await AuthClient.create();
const resultAuthenticated = await authClient.isAuthenticated();
// 認証済みであればPrincipalを取得
if (resultAuthenticated) {
const identity = await authClient.getIdentity();
// ICと対話する`agent`を作成する
const newAgent = new HttpAgent({ identity });
// ローカル環境の`agent`はICの公開鍵を持っていないため、`fetchRootKey()`で鍵を取得する
if (process.env.DFX_NETWORK === "local") {
newAgent.fetchRootKey();
}

updateUserTokens(identity.getPrincipal());
updateOrderList();
setUserPrincipal(identity.getPrincipal());
setAgent(newAgent);
} else {
console.log(`isAuthenticated: ${resultAuthenticated}`);
}
} catch (error) {
console.log(`checkClientIdentity: ${error}`);
}
};

// ページがリロードされた時、以下の関数を実行
useEffect(() => {
checkClientIdentity();
}, []);

return (
<>
<Header
updateOrderList={updateOrderList}
updateUserTokens={updateUserTokens}
setAgent={setAgent}
setUserPrincipal={setUserPrincipal}
/>
{/* ログイン認証していない時 */}
{!userPrincipal && (
<div className="title">
<h1>Welcome!</h1>
<h2>Please push the login button.</h2>
</div>
)}
{/* ログイン認証済みの時 */}
{userPrincipal && (
<main className="app">
<UserBoard
agent={agent}
userPrincipal={userPrincipal}
userTokens={userTokens}
setUserTokens={setUserTokens}
/>
</main>
)}
</>
);
};

export default App;

ポイントはcheckClientIdentity関数です。useEffectでコールしている関数で、画面のリロードが行われた際に実行されます。この関数では、ログインしているユーザーの情報を再取得しています。ログインしているかどうかは、AuthClientが提供するisAuthenticated関数で確認ができます。

それでは、ブラウザで確認をしてみましょう。開発中は、フロントエンドの変更が動的に反映されるwebpackの使用がおすすめです。まずは、サーバーを立ち上げましょう。

npm start

起動が完了すると、最後にcompiled successfullyと出力されます。

webpack 5.74.0 compiled successfully in 1260 ms

それでは、npm startコマンドを実行したターミナルに表示されている<i> [webpack-dev-server] Project is running at:以降のURL (例えば http://localhost:8080/ )にアクセスしてみましょう。

ログインのセッションが切れている場合、再度ログインボタンを押してアンカーを入力しましょう。認証後、以下のようにユーザーボードが表示されていたら完成です!

🙋‍♂️ 質問する

ここまでの作業で何かわからないことがある場合は、Discordの#icpで質問をしてください。

ヘルプをするときのフローが円滑になるので、エラーレポートには下記の4点を記載してください ✨

1. 質問が関連しているセクション番号とレッスン番号
2. 何をしようとしていたか
3. エラー文をコピー&ペースト
4. エラー画面のスクリーンショット

次のレッスンに進んで、オーダーを作成・表示する機能を実装しましょう!