💭

The Graphハンズオン:Subgraphを作成してブロックチェーンデータを効率的に取得しよう

に公開

こんにちは、Komlock LabのWeb3エンジニアの阿部です!
本記事では、ハンズオンを通してThe Graphを理解していただくことをゴールとしています。

簡単なDAppsの開発(スマートコントラクト開発、フロント開発)はできる前提で説明を進めます。
もしDApps開発について興味がある方はこちらの記事をご参照ください。

1. The Graphとは?

The Graphは、ブロックチェーン上のデータを効率的にインデックス化し、検索・取得を容易にする分散型プロトコルです。
Ethereumをはじめとする複数のブロックチェーンで動作し、開発者が「サブグラフ」と呼ばれるデータインデックスを作成して、GraphQLを通じてデータ検索を実行できるようにします。
The Graph利用により、DApps開発者がブロックチェーンデータを迅速かつ簡単に利用できるようになります。

簡単に言うと、チェーンのトランザクションをウォッチして、イベントをリアルタイムで解析しデータをインデックスしてくれる、かつGraphQL形式でデータを取得可能にしてくれるツールです。

用例
DApps開発をしていると、特定のユーザーの取引履歴を取得したい場面があります。
その際にジェネシスブロックから遡ると、検索に時間がかかってしまいます。
そのような時に、役に立つのがThe Graphです。The Graphはデータを効率的に検索・取得ができるようになっています。
取引履歴をインデックスしておけば、ジェネシスブロックから遡るのに比べ、高速で取引履歴の取得が可能になります。

もっと詳しく知りたい方は、公式サイトをご確認ください。
https://thegraph.com/ja/

2. ハンズオン準備:必要なツールと環境構築

本ハンズオンのゴール

  • トークンの送金をThe Graphを利用してインデックス化できている
  • インデックス化したデータをThe Graphを利用して取得できている

必要なツール

  1. 以下のツールをインストールしてください
  • Node.js(推奨バージョン: 23以上)
    • この記事執筆時点で、v23.0.0を利用しています。
  • Docker(ローカル環境テスト用)
  • Hardhat
  1. 以下を参考にAlchemyでAPI Keyを取得してください。
    https://note.com/standenglish/n/n8b5f7712a151

スマートコントラクトの準備

以下の手順でERC20トークンコントラクトを作成・デプロイしてください。

詰まった時は以下のgithubレポジトリを参考にしてください。
https://github.com/takupeso/my-token

  1. Hardhatプロジェクトをセットアップ

    mkdir my-token && cd my-token
    npx hardhat init
    
     ✔ What do you want to do? · Create a JavaScript project
     ✔ Hardhat project root: · /Users/takumaabe/workspace/my-token
     ✔ Do you want to add a .gitignore? (Y/n) · y
     ✔ Do you want to install this sample project's dependencies with npm (hardhat @nomicfoundation/hardhat-toolbox)? (Y/n) · y
    

    不要なSolidityファイルを削除してください

    rm contracts/Lock.sol
    rm -rf ignition
    rm -rf test
    

    ワークスペースを分割してください

    mkdir packages
    mv contracts packages/contracts
    mv hardhat.config.js packages/contracts/hardhat.config.js
    

    package.jsonを以下のように修正してください

     {
       "name": "mytoken",
       "private": true,
       "workspaces": {
         "packages": [
           "packages/*"
         ]
       },
     }
    

    packages/contracts/package.jsonを以下のように修正してください

     {
       "name": "contract",
       "version": "1.0.0",
       "private": true,
       "devDependencies": {
         "@nomicfoundation/hardhat-chai-matchers": "1.0.6",
         "@nomicfoundation/hardhat-network-helpers": "1.0.8",
         "@nomicfoundation/hardhat-toolbox": "2.0.2",
         "@nomiclabs/hardhat-ethers": "2.2.2",
         "@nomiclabs/hardhat-etherscan": "3.1.7",
         "@openzeppelin/contracts": "^5.1.0",
         "@typechain/ethers-v5": "10.2.0",
         "@typechain/hardhat": "6.1.5",
         "chai": "4.3.7",
         "ethers": "^5.7.2",
         "hardhat": "^2.22.17"
         "hardhat-gas-reporter": "1.0.9",
         "solidity-coverage": "0.8.2",
         "typechain": "8.1.1",
         "dotenv": "16.1.3"
       }
     }
    
  2. ERC20トークンコントラクトを追加:
    以下packages/contracts/contracts/MyToken.sol を追加してください

     // SPDX-License-Identifier: MIT
     pragma solidity ^0.8.0;
     
     import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
     
     contract MyToken is ERC20 {
         constructor() ERC20("MyToken", "MTK") {
             _mint(msg.sender, 1000000 * 10 ** decimals());
         }
     }
    
  3. コントラクトをsepoliaネットワークにデプロイし、アドレスとABIをメモする

    1. packages/contracts/hardhat.config.jsにnetworksを追加してください

      require("@nomicfoundation/hardhat-toolbox");
      require("dotenv").config();
      
      const { PRIVATE_KEY, STAGING_ALCHEMY_KEY } = process.env;
      
      module.exports = {
        solidity: "0.8.20",
        networks: {
          sepolia: {
            url: STAGING_ALCHEMY_KEY || "",
            accounts: PRIVATE_KEY ? [PRIVATE_KEY] : ["0".repeat(64)],
          },
        },
      };
      
    2. packages/contracts/.envを追加してくだしあ

       PRIVATE_KEY=YOUR_PRIVATE_KEY
       STAGING_ALCHEMY_KEY=YOUR_STAGING_ALCHEMY_KEY
      
    3. packages/contracts/package.jsonに以下を追加してください

        "scripts": {
           "deploy": "npx hardhat run scripts/deploy.js --network sepolia"
         }
      
    4. contracts/scripts/deploy.jsを追加してください

       async function main() {
         const MyToken = await ethers.getContractFactory("MyToken");
         console.log("Deploying MyToken...");
         
         const myToken = await MyToken.deploy();
         await myToken.deployed();
       
         console.log("MyToken deployed to:", myToken.address);
       }
       
       const runMain = async () => {
         try {
           await main();
           process.exit(0);
         } catch (error) {
           console.error(error);
           process.exit(1);
         }
       };
       
       runMain();
      
      
    5. 以下のコマンドを実行してください

      cd packages/contracts
      yarn run deploy
      

      実行後に表示されるデプロイされたスマートコントラクトのアドレスをメモしてください。

3. Subgraphの作成

Subgraphプロジェクトの初期化

以下のコマンドで新しいSubgraphプロジェクトを作成してください

cd <my-tokenのrootディレクトリ>/packages
graph init my-token

? Network … Ethereum Sepolia Testnet · sepolia · https://sepolia.etherscan.io
✔ Network · Ethereum Sepolia Testnet · sepolia · https://sepolia.etherscan.io
✔ Source · Smart Contract · ethereum
✔ Subgraph slug · my-token
✔ Directory to create the subgraph in · my-token
✔ Contract address · YOUR_CONTRACT_ADDRESS
✖ Failed to fetch ABI: Error: NOTOK - Contract source code not verified
✔ Do you want to retry? (Y/n) · false
✔ Fetching start block from contract API...
✔ Fetching contract name from contract API...
✔ ABI file (path) · /xxxxx/my-token/packages/contracts/artifacts/contracts/MyToken.sol/MyToken.json

✔ Start block · 7385602
✔ Contract name · MyToken
✔ Index contract events as entities (Y/n) · true
  Generate subgraph
  Write subgraph to directory
✔ Create subgraph scaffold
✔ Initialize networks config
✔ Initialize subgraph repository
✔ Install dependencies with yarn
✔ Generate ABI and schema types with yarn codegen
✔ Add another contract? (y/N) · false

これにより、以下のディレクトリ構造が生成されます。

my-token/
├── subgraph.yaml
├── schema.graphql
├── src/
│   └── my-token.ts
└── package.json

Schema定義の修正

schema.graphql に以下のスキーマを記載します。
スキーマに記載されたエンティティは、DBで言うところのテーブルに相当します。

type Transfer @entity(immutable: true) {
  id: Bytes!
  from: Bytes! # address
  to: Bytes! # address
  value: BigInt! # uint256
  blockNumber: BigInt!
  blockTimestamp: BigInt!
  transactionHash: Bytes!
}

イベントハンドラー設定の確認

subgraph.yamlにイベントハンドラーの設定が記載されています。

specVersion: 1.0.0
indexerHints:
  prune: auto
schema:
  file: ./schema.graphql
dataSources:
  - kind: ethereum
    name: MyToken
    network: sepolia
    source:
      address: "xxxxxx"
      abi: MyToken
      startBlock: 7385602
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.7
      language: wasm/assemblyscript
      entities:
        - Approval
        - Transfer
      abis:
        - name: MyToken
          file: ./abis/MyToken.json
      eventHandlers:
        - event: Approval(indexed address,indexed address,uint256)
          handler: handleApproval
        - event: Transfer(indexed address,indexed address,uint256)
          handler: handleTransfer
      file: ./src/my-token.ts

こちらはgraph initにて、自動でコードが生成されています。

データマッピング:イベント処理ロジックの確認

src/my-token.ts に以下のコードが記載されています。

import {
  Approval as ApprovalEvent,
  Transfer as TransferEvent
} from "../generated/MyToken/MyToken"
import { Approval, Transfer } from "../generated/schema"

export function handleApproval(event: ApprovalEvent): void {
  let entity = new Approval(
    event.transaction.hash.concatI32(event.logIndex.toI32())
  )
  entity.owner = event.params.owner
  entity.spender = event.params.spender
  entity.value = event.params.value

  entity.blockNumber = event.block.number
  entity.blockTimestamp = event.block.timestamp
  entity.transactionHash = event.transaction.hash

  entity.save()
}

export function handleTransfer(event: TransferEvent): void {
  let entity = new Transfer(
    event.transaction.hash.concatI32(event.logIndex.toI32())
  )
  entity.from = event.params.from
  entity.to = event.params.to
  entity.value = event.params.value

  entity.blockNumber = event.block.number
  entity.blockTimestamp = event.block.timestamp
  entity.transactionHash = event.transaction.hash

  entity.save()
}

今回重要なのはhandleTransferです。
こちらのコードによって、送金履歴がThe Graphを通じて、イベントがインデックス化されます。

4. Subgraphのデプロイ

1. Subgraph Studioにログインし、サブグラフを作成してください

2. 作成したサブグラフ詳細画面に遷移してください

3. AUTH & DEPLOYにしたがって、サブグラフをデプロイしてください

6. フロントエンド実装

以下は、clientというディレクトリを作成し、その中でReactを使ったクライアントアプリケーションを構築する手順です。このアプリケーションでは、Apollo Clientを使用してGraphQLクエリを実行し、The Graphのサブグラフから取引履歴を取得して表示します。

1. ディレクトリの作成

まず、プロジェクトのルートディレクトリにclientという新しいディレクトリを作成します。

mkdir client
cd client

2. Reactアプリケーションの初期化

以下のコマンドでReactアプリケーションを作成してください

npx create-react-app .

3. Apollo ClientとGraphQLのインストール

Apollo ClientとGraphQLライブラリをインストールしてください

yarn add @apollo/client graphql

5. Apollo Clientの設定ファイル

GraphQLエンドポイントに接続するために、Apollo Clientを設定してください

コード例: src/apolloClient.js

import { ApolloClient, InMemoryCache } from "@apollo/client";

const client = new ApolloClient({
  uri: "https://api.studio.thegraph.com/query/USERID/mytoken/latest", // thegraphのサブグラフのエンドポイント
  cache: new InMemoryCache(),
});

export default client;

6. ApolloProviderでアプリ全体をラップ

Reactアプリ全体でApollo Clientを使用できるように、ApolloProviderでラップしてください

コード例: src/index.js

import { ApolloProvider } from "@apollo/client";
import React from 'react';
import ReactDOM from 'react-dom/client';
import client from "./apolloClient";
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>,
  </React.StrictMode>
);

7. 送金、データ取得Reactコンポーネントを作成

  • TransferHistory.js
import { gql, useQuery } from "@apollo/client";
import React from "react";

const TRANSFER_HISTORY_QUERY = gql`
  query GetTransferHistory($recipient: Bytes!) {
    transfers(where: { to: $recipient }) {
      id
      from
      to
      value
    }
  }
`;

const TransferHistory = ({ recipient }) => {
  const { loading, error, data } = useQuery(TRANSFER_HISTORY_QUERY, {
    variables: { recipient },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h2>トランスファー履歴</h2>
      <ul>
        {data.transfers.map((transfer) => (
          <li key={transfer.id}>
            From: {transfer.from} | To: {transfer.to} | Value:{" "}
            {parseFloat(transfer.value) / Math.pow(10, 18)} MTK
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TransferHistory;
  • TransferForm.js
import { ethers } from "ethers";
import React, { useState } from "react";
import MyTokenABI from "../abis/MyToken.json";

const TransferForm = ({ contractAddress }) => {
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");

  const handleTransfer = async () => {
    if (!window.ethereum) return alert("MetaMaskがインストールされていません");

    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const signer = provider.getSigner();
    const contract = new ethers.Contract(contractAddress, MyTokenABI, signer);

    try {
      const tx = await contract.transfer(recipient, ethers.utils.parseUnits(amount, 18));
      await tx.wait();
      alert("送金が成功しました");
    } catch (error) {
      console.error(error);
      alert("送金に失敗しました");
    }
  };

  return (
    <div>
      <h2>トークン送金</h2>
      <input
        type="text"
        placeholder="受取人アドレス"
        value={recipient}
        onChange={(e) => setRecipient(e.target.value)}
      />
      <input
        type="text"
        placeholder="送金額"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
      />
      <button onClick={handleTransfer}>送金</button>
    </div>
  );
};

export default TransferForm;
  • TokenBalance.js
import { ethers } from "ethers";
import React, { useEffect, useState } from "react";
import MyTokenABI from "../abis/MyToken.json"; // ABIファイル

const TokenBalance = ({ contractAddress }) => {
  const [balance, setBalance] = useState(0);
  const [account, setAccount] = useState("");

  useEffect(() => {
    const fetchBalance = async () => {
      if (!window.ethereum) return alert("MetaMaskがインストールされていません");

      const provider = new ethers.providers.Web3Provider(window.ethereum);
      await provider.send("eth_requestAccounts", []);
      const signer = provider.getSigner();
      const userAddress = await signer.getAddress();
      setAccount(userAddress);

      const contract = new ethers.Contract(contractAddress, MyTokenABI, signer);
      const balance = await contract.balanceOf(userAddress);
      setBalance(ethers.utils.formatUnits(balance, 18));
    };

    fetchBalance();
  }, [contractAddress]);

  return (
    <div>
      <h2>トークン残高</h2>
      <p>アカウント: {account}</p>
      <p>残高: {balance} MTK</p>
    </div>
  );
};

export default TokenBalance;

8. メインコンポーネントに組み込む

packages/client/src/App.js

import { ApolloProvider } from "@apollo/client";
import React, { useEffect, useState } from "react";
import client from "./apolloClient";
import TokenBalance from "./components/TokenBalance";
import TransferForm from "./components/TransferForm";
import TransferHistory from "./components/TransferHistory";

const CONTRACT_ADDRESS = "YOUR CONTRACT_ADDRESS";

function App() {
  const [account, setAccount] = useState("");

  // MetaMaskのアカウントを取得
  const connectWallet = async () => {
    try {
      const { ethereum } = window;
      if (!ethereum) {
        console.error("Get MetaMask!");
        return;
      }
      const accounts = await ethereum.request({
        method: "eth_requestAccounts",
      });
      console.log("Connected: ", accounts[0]);
      setAccount(accounts[0]);
    } catch (error) {
      console.log(error);
    }
  };

  useEffect(() => {
    connectWallet();
  }, []);

  return (
    <ApolloProvider client={client}>
      <div className="App">
        <h1>MyToken Dashboard</h1>
        <TokenBalance contractAddress={CONTRACT_ADDRESS} />
        <TransferForm contractAddress={CONTRACT_ADDRESS} />
        <TransferHistory recipient={account} />
      </div>
    </ApolloProvider>
  );
}

export default App;

9. packages/client/src/abis/MyToken.jsonを作成

mkdir packages/client/src/abis

cp packages/contracts/artifacts/contracts/MyToken.sol/MyToken.json packages/client/src/abis/MyToken.json

10. アプリケーションの実行

1. 開発サーバーの起動

以下のコマンドで開発サーバーを起動してください

yarn start

2. ブラウザで確認

ブラウザでhttp://localhost:3000にアクセスし、取引履歴が表示されることを確認してください。

「トークン送金」の

  • 受取人アドレス: 自ウォレットのアドレス
  • 送金額: 10000000以下の数
    を入力の後「送金」ボタンを押下してください。

数分後、取引履歴に入力した内容が表示されることを確認してください。


以上がTheGraphハンズオンになります!
お疲れさまでした!

こちらのハンズオンが皆さんのスキルアップの一助になれば幸いです!

もっとスキルアップをしたいという方は、是非是非Komlockが運営している、
Komlock Discordにご参加ください!

Web3の最新情報についてやり取りをしたり、Web3イベントに関しての共有を行なっています。

Komlock lab

Discussion