🎮

Next.jsでDApp入門|Metamaskと接続してトークン残高の表示&送金を実装してみた

に公開

以前Hardhatというスマートコントラクトの開発環境に触れ、簡単なスマートコントラクトを実装しました。

今回はその続きでフロントエンドアプリをNext.jsで作成し、スマートコントラクトと繋いで簡単なDApp(Decentralized Application)を作成したいと思います。

※スマートコントラクトはこちらの記事で作成したものを使います。

前提知識

  • next.jsの基本的な使い方
  • solidityの基礎知識

DAppとは?

DApp(分散型アプリケーション)は、ブロックチェーン技術を基盤とした非中央集権型のアプリケーションで、主な特徴はこちらです。

  • スマートコントラクトを使っている

    アプリのロジックがブロックチェーン上にデプロイされたコードで管理されてる

  • ブロックチェーンにデータが保存される

    状態や履歴がチェーン上に記録されて改ざんできない

  • ウォレット接続で動く(ユーザーが鍵を持っている)

    ユーザーがMetamaskなどで自分のアカウントを使って操作する

  • オープンで透明性がある

    コントラクトコードが誰でも見られる・検証できる

概要

スマートコントラクトをこちらを使います。

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.0;

import "hardhat/console.sol";

contract Token {
    // トークンの名前
    string public name = "My Hardhat Token";
    // トークンのシンボル
    string public symbol = "MHT";

    // トークンの固定供給量(uint256型の整数で保存)
    uint256 public totalSupply = 1000000;

    // イーサリアムアカウントを保存するためのアドレス型変数
    address public owner;

    // 各アカウントの残高を保持するマッピング
    mapping(address => uint256) balances;

		// イベントを定義 from(送信者アドレス) to(受信者アドレス) amount(送金額)
    event Transfer(address indexed _from, address indexed _to, uint256 _value);

    // デプロイ時に一度だけ実行
    constructor() {
        // totalSupplyをコントラクトのデプロイアカウントに割り当てます。
        console.log("Deploying Token with total supply: %s and owner: %s", totalSupply, msg.sender);
        balances[msg.sender] = totalSupply;
        owner = msg.sender;
    }

    // トークン送信用の関数
    function transfer(address to, uint256 amount) external {
        // 送信者が十分なトークンを持っているか確認します。
        // NOTE: unicodeを入れないとInvalid character in string literalエラーが出る
        require(balances[msg.sender] >= amount,unicode"トークンが不足しています");

        // トークンの送信
        balances[msg.sender] -= amount;
        balances[to] += amount;
        // イベントを発行 送金者、受取人、送金額がログに記録される
        emit Transfer(msg.sender, to, amount);
    }

    // 指定したアカウントのトークン残高を取得する関数
    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }
}

今回の流れとしては、

  1. フロントエンド環境の準備
  2. ウォレット(Metamask)の設定
  3. DAppを実際に動かしてみる

になります。

フロントエンド環境の準備

まず最初にフロントエンド環境を作成します。スマートコントラクトを作成したプロジェクトと同じプロジェクト内に配置します。
nodeのバージョンはv22.14.0です。

https://github.com/br-to/hardhat-tutorial/tree/main/front

npx create-next-app
✔ What is your project named? … front
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/br-to/hardhat-tutorial/front.

次にスマートコントラクトとフロントエンドを繋げるためのライブラリであるethers.jsをインストールします。
https://www.npmjs.com/package/ethers

npm i ethers

次にsrc/app/page.tsxを置き換えます。

https://github.com/br-to/hardhat-tutorial/blob/main/front/src/app/page.tsx

※ こちらはgithub copilot freeのeditモードで作りました。Token.solをスマートコントラクトとしたフロントエンドを実装してくださいと入力しただけでそれなりの実装が作れてすごいです。

'use client';
import { useState } from 'react';
import { ethers } from 'ethers';
import token from '@/abi/Token.sol/Token.json';

// Extend the Window interface to include the ethereum property
declare global {
  interface Window {
    ethereum?: any;
  }
}

// コントラクトのデプロイ後のアドレスを設定
const TOKEN_ADDRESS = '0x5FbDB2315678afecb367f032d93F642f64180aa3';

export default function Home() {
  // アカウントのアドレスを保持するstate
  const [account, setAccount] = useState('');
  // トークン残高を保持するstate
  const [balance, setBalance] = useState('0');
  // 送金先アドレスを保持するstate
  const [recipient, setRecipient] = useState('');
  // 送金額を保持するstate
  const [amount, setAmount] = useState('');
  // 送金処理中か
  const [loading, setLoading] = useState(false);

  // MetaMaskとの接続を行う関数
  const connectWallet = async () => {
    try {
      // MetaMaskからアカウントへのアクセスを要求
      const accounts = await window.ethereum.request({
        method: 'eth_requestAccounts',
      });
      // 接続されたアカウントを保存
      setAccount(accounts[0]);
      // 残高を取得
      await getBalance(accounts[0]);
    } catch (error) {
      console.error(error);
    }
  };

  // 指定アドレスのトークン残高を取得する関数
  const getBalance = async (address: string) => {
    try {
      // MetaMaskのプロバイダーを取得
      const provider = new ethers.BrowserProvider(window.ethereum);
      // コントラクトのインスタンスを作成
      const contract = new ethers.Contract(TOKEN_ADDRESS, token.abi, provider);
      // balanceOf関数を呼び出してトークン残高を取得
      const balance = await contract.balanceOf(address);
      setBalance(balance.toString());
    } catch (error) {
      console.error(error);
    }
  };

  // トークンを送金する関数
  const handleTransfer = async () => {
    try {
      setLoading(true);
      // MetaMaskのプロバイダーとSignerを取得
      const provider = new ethers.BrowserProvider(window.ethereum);
      const signer = await provider.getSigner();
      // 書き込み用のコントラクトインスタンスを作成
      const contract = new ethers.Contract(TOKEN_ADDRESS, token.abi, signer);

      // transfer関数を呼び出してトークンを送金
      const tx = await contract.transfer(recipient, amount);
      // トランザクションの完了を待機
      await tx.wait();

      // 送金後の残高を更新
      await getBalance(account);
      // フォームをリセット
      setRecipient('');
      setAmount('');
    } catch (error) {
      // エラーメッセージをユーザーに表示
      alert(`トランザクションが失敗しました: ${error}`);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen p-8">
      <main className="max-w-md mx-auto space-y-8">
        <h1 className="text-3xl font-bold text-center">トークンアプリ</h1>

        {/* ウォレット未接続の場合は接続ボタンを表示 */}
        {!account ? (
          <button
            onClick={connectWallet}
            className="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 cursor-pointer"
            type="button"
          >
            ウォレットを接続
          </button>
        ) : (
          // ウォレット接続済みの場合はトークン操作UIを表示
          <div className="space-y-6">
            {/* アカウント情報と残高の表示 */}
            <div className="bg-gray-100 p-4 rounded-lg">
              <p className="text-sm text-gray-600">接続中のアカウント</p>
              <p className="font-mono">{account}</p>
              <p className="mt-2 text-sm text-gray-600">残高</p>
              <p className="font-bold">{balance} MHT</p>
            </div>

            {/* 送金フォーム */}
            <div className="space-y-4">
              <input
                type="text"
                placeholder="送信先のアドレス"
                value={recipient}
                onChange={(e) => setRecipient(e.target.value)}
                className="w-full p-2 border rounded text-black"
              />
              <input
                type="number"
                placeholder="送信するトークン数"
                value={amount}
                onChange={(e) => setAmount(e.target.value)}
                className="w-full p-2 border rounded text-black"
              />
              <button
                onClick={handleTransfer}
                disabled={loading}
                className="w-full bg-green-500 text-white py-2 rounded-lg hover:bg-green-600 disabled:bg-gray-400 cursor-pointer"
                type="button"
              >
                {loading ? '送信中...' : 'トークンを送信'}
              </button>
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

ただこれだと、import token from '@/abi/Token.sol/Token.json'; のところでModule not found: Can't resolve '@/abi/Token.sol/Token.json'****というエラーが発生してしまいます。

artifactはこのままではフロントエンドに渡せないのでsrc/abiディレクトリにartifacts/contractsの内容をそのままコピーしてください。

https://github.com/br-to/hardhat-tutorial/tree/main/front/src/abi/Token.sol

そうすればローカル環境が立ち上がります!

※ABIとは?

このimportで渡しているものは、**Hardhat がコンパイルしたスマートコントラクトの成果物(artifact)**です。その中には **ABI(Application Binary Interface)**やバイトコードなどが入っています。

ABI はスマートコントラクトとの関数や引数の説明書のようなものです。ABIを渡すことによって、ethers.jsがこのコントラクトにはこの関数があるんだなと理解できるようになります。

ウォレット(Metamask)の設定

これで実装は完了しましたが、ここままでは「ウォレットを接続」ボタンを押下してもTypeError: Cannot read properties of undefined (reading 'request')というエラーが出るだけです。

実際にアプリケーションを動かすためにはウォレットというものが必要です。

ウォレットとは

ウォレットは、ざっくりいうと、暗号資産を入れておく財布みたいなものであり、DAppを利用するための認証キーです。従来のアプリケーションはIDやパスワードによって認証を行いますがDAppではウォレットによって認証を行います。

今回はブラウザ拡張機能や、モバイルアプリもあって導入が簡単なMetamaskをウォレットとして利用します。

Metamaskの導入

1. Metamaskのブラウザ拡張機能をインストール

https://metamask.io/download

こちらのURLから使用したいブラウザを選択し、ブラウザ拡張機能をインストールしてください。Chromeの場合 → Chromeウェブストアから追加です。

2. ウォレットの作成

拡張機能を追加すると、Metamaskの画面が開きます。

以下の手順でウォレットを作成しましょう。

  1. 「新規ウォレットを作成」を選択
  2. パスワードを設定
  3. シークレットリカバリーフレーズ(12個の単語)を保存

※ウォレットのシークレットリカバリーフレーズはとても重要なので忘れないようしっかり管理して誰にも見せないようにしましょう

これでMetamaskでウォレットが使えるようになります。

ただ、まだこれで終わりではありません。このアカウントだと暗号資産を全く持っていなのと、ネットワークも本番環境の設定になっているので、ローカル開発用テストネットワークとアカウントを作成します。

  1. 左上のEthereum Mainnetをクリックし、「カスタムネットワークを追加」ボタンを押下します。
  2. 以下を入力し、保存してネットワークを切り替えます。
ネットワーク名: Hardhat Local
新しいRPC URL: http://localhost:8545
チェーンID: 31337
通貨記号: GO
  1. npx hardhat node で表示されたAccount #0のPrivate Key(0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80)をコピーし、Metamaskで「Account1」から、「アカウントまたはハードウェアウォレットを追加」ボタンをクリックしアカウント追加します。
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: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690

アカウントが追加された後、少し待てば、約10000GOが追加されます。この10000GOはガス代の支払いに使われるテストトークンになります。

これでMetamaskの設定も完了です!

DAppを実際に動かしてみる

DAppを動かす準備が整ったので、フロントエンドと、hardhatを起動を止めていたら、再度起動します。両方起動していないとローカル環境で残高の取得や送金処理ができなくなるためです。

# フロントエンド環境
cd front
npm run dev

# hardhat
npx hardhat node
# デプロイも必要
npx hardhat ignition deploy ./ignition/modules/Token.ts --network localhost

フロントエンドのローカル環境を開いたらやることはこちら

  1. フロントエンドのローカルホストを起動し、「ウォレットを接続する」ボタンを押下
  2. 初回はMetamaskで接続するかどうかを求めてくるので、「接続」ボタンを押下
  3. 認証したウォレットのアドレスと残高が表示される

これでbalanceOf関数が呼ばれていることが確かめられました。

※混乱しやすいのですがやっていることはMetamaskのアドレスを使って認証し、スマートコントラクトに接続しているだけなのでここで返される残高は10000GOではなく、100000MHTになります。

 // 指定したアカウントのトークン残高を取得する関数
    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }

最後にトークン送信処理についても動作検証します。

  1. npx hardhat node で表示されていた、Account #1のアドレス(0x70997970C51812dc3A010C7d01b50e0d17dc79C8 )を送信先のアドレスに貼り、送信するトークン数には適当に数値を入力する
  2. Metamaskの画面に遷移したら、確認ボタンをクリック →アラートを確認したうえで続行しますをチェック → 確認ボタンをクリック(テストトークンの送金なのでアラートが出ますが気にしないでください)
  3. トークンアプリ画面に戻る

残高が減っていればtransfer関数が呼ばれていることが検証できています。

// トークン送信用の関数
    function transfer(address to, uint256 amount) external {
        // 送信者が十分なトークンを持っているか確認します。
        // NOTE: unicodeを入れないとInvalid character in string literalエラーが出る
        require(balances[msg.sender] >= amount,unicode"トークンが不足しています");

        // トークンの送信
        balances[msg.sender] -= amount;
        balances[to] += amount;
        // イベントを発行 送金者、受取人、送金額がログに記録される
        emit Transfer(msg.sender, to, amount);
    }

まとめ

Metamaskと接続し、残高を取得して、送金するだけの簡単なDAppですが作ってみました。
検証で軽く作ってみるくらいならAIに任せれるのですごい時代になったと思います。

今後はNFTなど、より汎用的なサービスを作ってみます。ここまでみていただきありがとうございました。🙇🏽‍♀️

Discussion