【Dapps開発入門】Hardhat.jsサンプルコードをデプロイしてUIをReactで作成する


はじめに

NFT,メタバース、Web3などのブロックチェーン、スマートコントラクト周りのワードをたくさん聞くようになってきました。スマートコントラクトはコードさえ書けば、バックエンドのサーバのようなものは管理せず簡単に使ってもらえますし、個人開発レベルでも多くの人に使ってもらえるチャンスが大きいというのもあり、Dapps開発に興味が湧いてきたエンジニア方は多いのではないでしょうか。自分もその一人であり、プライベートでDapps開発などについて最近学んでいます。EthereumをはじめとするEVMでのDapps開発のチュートリアルにはCryptoZombiesのような良質なものも既にありますが、近年開発で使用するライブラリ群がWaffle&web3.jsのものが多く、Hardhatを使うものはまだまだ少ないのかと思ったので、学んだことを少しづつまとめていけたらと思います。

Dapps開発をする際に必要になる最低限のことは以下になります。

  • コントラクトの作成
    • 状態の取得
    • 状態の更新
  • フロントエンドの作成
    • ウォレットとの接続
    • コントラクトの呼び出し
    • 情報の表示

コントラクト作成に使用するSolidityの書き方はそこまで複雑でもなく、かつ他の記事でも十分に解説されているので、ここでは深くは触れず、Hardhat.jsのサンプルコードをそのまま使用します。本記事ではReact/Typescriptでのフロントエンドの作成し、ローカルネットワークでの動作方法、そしてAstarNetworkを例としてメインネットへのデプロイ方法を中心に解説します。

前提知識と得られる知識

前提知識

  • MetaMaskを使ったことがある
  • Dappsの大まかな仕組み(スマートコントラクト、ウォレット)がわかる
  • React/Typescriptの基本的な書き方がわかる

当然と言えば当然ですが、Dappsを使ったことがない方は、開発する前にメタマスクを通じて何らかのDappsをいじってみることをおすすめします。

得られる知識

  • Dapps開発の大まかな流れ
  • ethers.jsを用いて、フロントエンドからのMetamask経由でコントラクトと通信する方法
  • ローカルでのデプロイ方法
    • ローカルネットへのメタマスク接続方法
    • ローカルネットでのガス代送金
  • EVM対応言語(AstarNetworkを例に)のメインネットへのデプロイ方法

成果物
※名前は適当です。DAOではありません

https://github.com/KtechB/greet-dapp-react

Hardhat.jsでGreetコントラクトを作成する

まず、hardhatを用いてサンプルコントラクトを作成し、ローカルで動かします。
Hardhatではsolidityのライブラリ含め、npmによるパッケージ管理を行います。

yarn init # プロジェクトを初期化
npx hardhat # hardhatプロジェクトを作成

いくつか選択肢が出てくるので以下のように選択します(バージョンによって変化する可能性があります)

What do you want to do? 
→Create an advanced sample project that uses TypeScript
Hardhat project root:
→デフォルト値
Do you want to add a .gitignore?
→yes
Do you want to install this sample project's dependencies with npm 
→yes

これにより以下のようなディレクトリ構成になると思います。

└── greet-contract
    ├── .env.example
    ├── .eslintignore
    ├── .eslintrc.js
    ├── .gitignore
    ├── .npmignore
    ├── .prettierignore
    ├── .prettierrc
    ├── .solhint.json 
    ├── .solhintignore
    ├── README.md
    ├── contracts
    │   └── Greeter.sol # コントラクト本体
    ├── hardhat.config.ts
    ├── package.json
    ├── scripts
    │   └── deploy.ts # デプロイ用スクリプト
    ├── test
    │   └── index.ts # テストコード
    ├── tsconfig.json
    └── yarn.lock

たくさんのファイルが作成されますが、hardhatでは基本的には以下の流れで作成します。

  • contracts/にsolidityでコントラクトを実装
  • test/に各コントラクトのテストコードをjestで書く
  • hardhat.config.tsにデプロイ先のノードなどを設定
  • scripts/にデプロイ用のスクリプトを作成し、npx hardhat run スクリプト名 でdeploy

contracts/greet.solがコントラクト本体で、以下のように生成されると思います。

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;


contract Greeter {
    string private greeting;

    constructor(string memory _greeting) {
        greeting = _greeting;
    }

    function greet() public view returns (string memory) {
        return greeting;
    }

    function setGreeting(string memory _greeting) public {
        greeting = _greeting;
    }
}

概要としては、「Greeterというコントラクトはgreetingという文字列の状態を保持しており、greet()によって文字列を取得、setGreeting(文字列)で文字列を設定できる」というものです。

フロントから叩けるようにするために、ローカルにブロックチェーンネットワークを立てて、そこにデプロイします。

npx hardhat node # run local node
npx hardhat run scripts/deploy.ts —-network localhost # run deploy.ts

Greeter deployed to:{アドレス} と表示され、{アドレス}をアドレスとするスマートコントラクトがローカルのネットワークにデプロイされました。

フロントエンドを作成する

ここからは本題である、作成されたgreetコントラクトを呼び出し、挨拶文の表示と更新を行うUIを作成します。

まず、Reactプロジェクトを作成します。

npx create-react-app greet-dapp-react --template typescript

まず、Greetコントラクトから取得したメッセージの表示と更新を行うためのガワを作成します。なお、本記事のサンプルUIでは、コードの読みやすさを考慮してApp.cssにノークラスcssであるsimple.cssを使用しています

https://simplecss.org/

src/components/GreetFrom.tsx

import { useState, FC } from "react";

interface GreetFormProps {
  message: string;
  updateMessage: (m: string) => void;
}

const GreetForm: FC<GreetFormProps> = ({ message, updateMessage }) => {
  const [formValue, setFormValue] = useState<string>("");
  return (
    <>
      <h3>{message}</h3>
      <input
        onChange={(e) => {
          setFormValue(e.target.value);
        }}
        value={formValue}
      ></input>
      <button onClick={() => updateMessage(formValue)}>update</button>
    </>
  );
};
export default GreetForm;

src/App.tsx

import "./App.css";
import GreetForm from "./components/GreetForm";
import { useGreet } from "./useGreet";

function App() {
  const { greet, setGreeting } = useGreet();
  return (
    <>
      <header>
        <h1>Greet DAO</h1>
      </header>
      <div>
        <GreetForm
          message={greet}
          updateMessage={(m) => {
            setGreeting(m);
          }}
        />
      </div>
    </>
  );
}

export default App;

Ethers.jsでのコントラクト呼び出し

ガワはできたので、useGreet()が、Greetコントラクトから取得したメッセージと、Greetコントラクトのメッセージをアップデートするための関数を返却するように作成します。
コントラクトと通信するために、ethers.jsというライブラリを使用します。

ethers.jsを使ってコントラクトにアクセスする流れは以下のようになります。

  1. ブロックチェーンノードと通信をするProviderオブジェクトを作成
  2. コントラクトのアドレス、ABI、ProviderオブジェクトからContractオブジェクトを作成
  3. 状態の更新を行うために署名者(ユーザ)をconnect
  4. コントラクトのメソッドを呼ぶ

ABI(Application Binary Interface)は、コントラクトをコンパイルした際に、作成されるjsonファイルで、コントラクトを呼び出す際に必要になるものです。サンプルコードだとartifacts/contracts/Greeter.sol/Greeter.jsonにあたります。
以下ではこれを呼び出す側のフロントエンドにもコピーして、読み込んでいます。

上記の流れを元に、useGreet.tsは以下のように書きます。

useGreet.ts

import { ethers, Contract } from "ethers";
import { useEffect, useState } from "react";
import greeterAbi from "./abi/Greeter.json";

export const useGreet = () => {
  const [greet, setGreet] = useState<string>("");
  // metamaskを介してネットワークノードとの通信をするオブジェクトを作成する
  const provider = new ethers.providers.Web3Provider((window as any).ethereum);
  const greetAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
  // アドレス、ABI, プロバイダを指定してコントラクトオブジェクトを作成
  // コントラクトの状態を変化させる(gas代が必要な)操作をするためには場合はSignerを与える必要がある
  const greetContract = new Contract(
    greetAddress,
    greeterAbi.abi,
    provider
  ).connect(provider.getSigner(0));

  const setGreeting = async (value: string) => {
    // setGreetingメソッドを呼び出し
    greetContract.setGreeting(value);
  };

  // greetを取得して、状態として保持する
  useEffect(() => {
    const fetchData = async () => {
      // greetメソッドを呼び出し
      const greetFetched = await greetContract.greet();
      setGreet(greetFetched);
    };

    fetchData().catch((e) => console.log(e));
  }, [greetContract]);
  return { greet, setGreeting };
};

メタマスクとの接続はどこで記述できているのかというと、Web3Providerの引数に与えている、window.etheriumです。メタマスクが拡張機能として入っているブラウザでは、window.etheriumにプロバイダが埋め込まれるため、ethers.js内ではこのオブジェクトを使ってノードとのやりとりを行います。

https://docs.metamask.io/guide/ethereum-provider.html#table-of-contents

メソッドの呼び出しは通常の開発におけるAPIコールと同様、非同期になるので、それぞれasyncメソッドを実装し、状態取得については、useEffect内でfetchを行い、ローカルの変数greetを更新する処理を行なっています。

この状態だとコントラクトの型情報がないため、メソッドの補完が効きません。 コントラクトのコンパイル時に作成されたtypechainの型情報をフロント側に持ってきてimportして使用することで型を指定できるようですが、ここでは一旦スルーします。

プロバイダのコンストラクタを毎度呼び出すのはどうなんだ、とかツッコミは多々あるかもしれないですが、ここのベストプラクティスを自分はわかっていないので、有識者コメントいただけるとありがたいです。複数コントラクトを扱うようなDappsでは、おそらくアプリのルートでcontextなどで一元管理する方が良いような気はします。

以上の工程で、フロントエンドが完成しました。ここから、ローカル環境でコントラクトとの接続を行います。

ローカル環境で動かす

Dappsを動かすには以下の3つの作業が必要です。

  • ローカルのブロックチェーンを動かすノードを建てる
  • コントラクトをデプロイする
  • フロントエンドを動かす

まず、ローカルのネットワークはhardhatで簡単に立てることができます。

npx hardhat node

すると以下のように、RPCサーバのURLと、ローカルネットワーク上に自動作成されたアカウントが表示されます。

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
・
・
・

次に、コントラクトをデプロイします。デプロイは、デプロイ用のスクリプト(scripts/deploy.ts)を用意して、npx hardhat runで実行します。この際、どのネットワークで動かすかを指定できるので、ローカル環境では以下のように指定します。

npx hardhat run scripts/deploy.ts --network localhost

デプロイが成功すると、デプロイされたコントラクトのアドレスが表示されると思います。これを先程のuseGreet.ts内に設定します。

最後に、フロントエンド側を立ち上げます。以下のコマンドで、localhost:3000にReactアプリが立ち上がります。

yarn start

Hello, Hardhat! と表示されていません。これは、メタマスクがまだローカルホストに立てたネットワークを認知していないためです。
そのため以下のように、localhost:8545をエンドポイントとするネットワークを追加します。通貨記号は自由ですが、他のネットワークと混ざらないように、ここでは推奨されているGOという名称にします。

これにより、ローカルのネットワークにmetamaskがつながり、コントラクトと通信を行い、greet()で取得できる文言「Hello, HardHat!」を取得できるようになります。

次に、状態の更新をしてみます。ローカルネットワークを立ち上げたてなのでウォレットにガス代がないため、
ローカルネット立ち上げ時に作成されたアカウントからガス代を送信します。scripts/sendGasFee.tsを以下のように作成します。

import { ethers } from "hardhat";

async function main() {
  const transactionSend = {
    // 送信先アドレス
    to: "0xA9...以下略",
    value: ethers.utils.parseEther("10.0"),
  };

  const [account] = await ethers.getSigners();
  await account.sendTransaction(transactionSend);
  console.log("success");
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

npx hardhat run scripts/sendGasFee.ts --network localhostを実行すると10トークンがローカルネットで初期生成されたアカウントから指定したメタマスクアドレスに送信されます。このガス代送信方法はもっとスマートなやり方もあるかもしれないので、有識者の方がいればコメントお願いします。

さて、以上の操作でメタマスクにガス代が入ったのでフォームに文字を入力してupdateを実行します。greetの取得はページ読み込み時に行われるため、自動では切り替わりません。ここはlogを購読をすることで改善できるので別の記事で描ければ描こうと思います。トランザクションが完了したことを確認して、ページをリロードすると、文字列が変わっていることが確認できると思います。

以上でローカル環境でのデプロイは完了です。次は本番環境にデプロイする手順に進みます。

本番環境にデプロイする

グローバルなブロックチェーンにもデプロイしてみます。Ethereumはガス代がバカにならないのと、いくらでも解説記事があるので、今回は日本発のAstarNetworkにデプロイしてみます。
Solidityで書かれたコントラクトはEVM(Ethereum Virtual Machine)に対応したネットワークには他にAvalancheやFantom, Polygonなどがあり、これらは同じコード、同じ手順でデプロイできます。
なお、本来であれば、ローカルネット→テストネット→メインネットの順で確認していくことが望ましいと思います。しかし、メインもテストネットもやり方は変わらないので、ここではいきなりメインネットにデプロイします。

まず、前提としてデプロイのためのガス代が必要なので、Binanceなどの取引所から送るなりしてAstrトークンの入ったMetamaskを用意します。デプロイで秘密鍵を扱うので、普段使っているウォレットではなく、新しく開発ようにメタマスクウォレットを作成することをおすすめします。なお、テストネットのような、ガス代が取引所で手に入らないようなネットワークでは、公式がガス代を配っているので(faucetで探すと出てくる)そちらのサイトを使用しましょう。

ウォレットが用意できたら、hardhat.config.tsのconfigオブジェクトにデプロイ先のネットワークのエンドポイントURLとガス代を払うアカウントの秘密鍵を追加します。秘密鍵は他人に絶対に公開してはいけないので、.envファイルで管理して、gitに絶対に含めないようにします。
エンドポイントはAstar公式のdocsに記載されています。

https://docs.astar.network/integration/network-details
const config: HardhatUserConfig = {
 solidity: "0.8.4",
 networks: {
   // デフォルトで書いている、イーサリアムネットワーク
   ropsten: {
     url: process.env.ROPSTEN_URL || "",
     accounts:
       process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
   },
   // Astar Networkノードの情報を追記
   astarMain: {
     // Astar Networkノードのエンドポイント
     url: "https://rpc.astar.network:8545",
     // ガス代を払うアカウントの秘密鍵(envファイルから読み込む)
     accounts:
       process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
   },
 },
(以下略)
};

.envファイルを作成し、開発用のウォレットの秘密鍵を以下(例)のように設定します。秘密鍵はメタマスクの「アカウント詳細を表示」から秘密鍵をエクスポートで取得できます。何度も言いますが、秘密鍵は絶対に外部に漏らさないようにしましょう。

PRIVATE_KEY=0xabc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1

さて、準備はできたので、ローカルと同様にデプロイスクリプトを実行します。ローカルへのデプロイとの違いは実行時にネットワークをlocalhostとしていたところを、hardhat.config.tsで設定したネットワーク名に変えるだけです。

npx hardhat run scripts/deploy.ts --network astarMain

デプロイが無事終了すると、デプロイしたコントラクトのアドレスが表示されるので、あとはこれをフロントエンドのuseGreet.tsで設定するだけです。
参考情報ですが、このようなシンプルなコントラクトであれば、デプロイにかかったガス代はたった0.0005ASTR(現在時点で0.00005$)でした。

フロントエンドの接続先のアドレスを変更し、メタマスクをAstarMainNetに設定すれば、ちゃんと表示されていることが確認できます。

終わりに

本記事では、hardhatのサンプルコントラクトをデプロイし、作成したフロントエンドでメタマスクに接続して実際に叩くということを行いました。Dapps開発の基本的なことは、この延長線上でできると思います。

もう少し踏み込んだDapps開発で必要な知識としては、以下のようなことが挙げられるかと思います。

  • トランザクション成功時のフィードバック(contractのlog emitをUIで取得する)
  • 独自トークンの作成と販売
  • NFTの作成、販売
  • 投票システム
  • EVM以外でのスマートコントラクト
  • etc.

今後勉強を進めていき、余裕があればこのGreetを拡張する形で、シリーズ化していきたいと思います。自分もまだ勉強中なので、指摘というあればコメントいただけると嬉しいです。