The GraphでNFTプロジェクトのサブグラフを作成してみた

2022/09/25に公開約13,300字

前から気になっていたThe Graphを使用して公開されているNFTプロジェクトのサブグラフを作成し、ブラウザで表示してみたのでその備忘録です。

The Graphとは

https://thegraph.com/en/
Ethereumなどのチェーン上にデプロイされているスマートコントラクトからサブグラフと呼ばれるAPI(GraphQL)を構築・公開できる。Ethereumだけかと思っていたらbetaだけどAVALANCHEやCOSMOSなどのチェーンも対応してるよう。

サブグラフの作成

成果物はこちら

https://github.com/JY8752/The-graph-demo3

CLIインストール

npm install -g @graphprotocol/graph-cli

graph -v
> 0.33.1

init

サブグラフを作成したいコントラクトのアドレスを指定して初期化をする。
コントラクト初期化時にコントラクトのコードがサポートされていないものだったり、metadataの情報がipfsになかったりしてサブグラフ化できないものがけっこうあった。

最初はMETAKAWAIIのサブグラフを作成してみたかったのだが初期化時にエラーとなってしまったため断念。

  Generate subgraph
✖ Failed to create subgraph scaffold: Conversion from 'AssemblyScript' to 'Value' for source type 'Array<ethereum.Tuple>' is not supported

次に、DigiDaigakuのサブグラフを作成しようとしたのだがmetadataがipfs上にないのでこれも断念。(絵がかわいかったからサブグラフ作りたかった...)

次に試したのがDoodle。これは初期化まではうまくいったのだけど、デプロイした後にエラーが発生するトランザクションがあってサブグラフの構築が完了しなかったのでこれも断念。

ようやく作成まで行けたのがShimashiというNFT。ということで、The GraphのWebページからプロジェクトを作成しておく。Products > Hosted Service > My Dashboard > Add Subgraph を選択し、必須のSubgraph NameとSubtitleを入力しCreate subgraphでプロジェクトが作成される。初めて利用する場合はサインアップが必要でGitHubのアカウントと同期してサインアップできる。作成が完了したら以下のコマンドで初期化。

graph init --from-contract 0xe9814CcD783A12Ffc65E447c436b51A8a167fafd --contract-name Shimashi --index-events

//ethereumを選択
? Protocol … 
  arweave
❯ ethereum
  near
  cosmos

//hosted-serviceを選択
? Product for which to initialize … 
  subgraph-studio
❯ hosted-service

//<GitHubアカウント名/The Graphのプロジェクト名>
Subgraph name › jy8752/shimashi

Directory to create the subgraph in > shimashi
Ethereum network · mainnet
Contract address · 0xe9814CcD783A12Ffc65E447c436b51A8a167fafd
Contract Name · Shimashi

指定しているアドレスはコントラクトのアドレス、コントラクト名は初期化後のファイル名になる。
作成が完了したプロジェクトはこんな感じ。

schema

graphqlスキーマを修正します。コントラクトに設定されているeventに対応したスキーマが定義されている。今回はTransferのイベントスキーマにmetadaを追加します。

schema.graphql
type Transfer @entity {
  id: ID!
  from: Bytes! # address
  to: Bytes! # address
  tokenId: BigInt! # uint256
+  image: String!
+  personality: String
+  characteristics: String
+  catOrDogPerson: String
+  relationshipStatus: String
+  idealVacation: String
+  artisticVocation: String
+  workStyle: String
}

スキーマの修正が完了したらコードを生成し直す。

graph codegen

metadataの確認

metadata.jsonの確認をするにはEterscanからできる。
EtherscanでShimashiのコントラクトを開き、ContractのRead Contractを選択。

コントラクトのメソッド一覧が表示されるのでtokenURIに適当な数字を入力しQueryを押すとmetadataが配置されているipfsのハッシュ値が確認できる。

jsonの内容を確認する方法はいくつかあるがchromeの拡張機能を入れることでアドレスバーに直接入力するだけで確認できる。

https://chrome.google.com/webstore/detail/ipfs-companion/nibjojkomfdiaoajekhjakgkdhaomnch

Shimashiのmetadataはこのような構造になっている

metadata.json
{
  "description": "Shimashi NFT",
  "image": "ipfs://bafybeihg27p472hzuam5pmwufs6b3u2hqhsbmhugrlqxbdeqbc53sfy2am/1.png",
  "name": "Shimashi - ID 1",
  "attributes": [
    {
      "trait_type": "Personality",
      "value": "Extraverted Feeling"
    },
    {
      "trait_type": "Characteristics",
      "value": "Adaptive, relating well to the external"
    },
    {
      "trait_type": "Cat or Dog Person",
      "value": "Cat"
    },
    {
      "trait_type": "Relationship Status",
      "value": "Dating"
    },
    {
      "trait_type": "Ideal Vacation",
      "value": "Mountains"
    },
    {
      "trait_type": "Artistic Vocation",
      "value": "None"
    },
    {
      "trait_type": "Work Style",
      "value": "Web3 Hustler"
    }
  ],
  "edition": 1
}

上記のスキーマに追加したのはこのattributesの部分。

AssemblyScript

最後に追加したスキーマにmetadataから取得した値をマッピングする。マッピングはshimashi.ts内で普通にJavaScript(TypeScript)で記述していくが、AssemblyScriptというものらしくnpmライブラリなどを使うことができない。

例えば、DigiDaigakuのmetadata.jsonはhttpで公開されているのでaxiosパッケージをインストールしてファイルを取得しようとしたが、エラーとなって利用することができない。

ipfs上に公開されているファイルは@graphprotocol/graph-tsで用意されているipfsメソッドで取得ができるようになっている。

あと、おそらくアロー関数の利用などもエラーとなったので、いろいろ制約がありそう。

各イベントごとにハンドラー関数が用意されているのでTransferイベントのハンドラー関数を以下のように修正。

shimashi.ts
export function handleTransfer(event: TransferEvent): void {
  let entity = new Transfer(
    event.transaction.hash.toHex() + "-" + event.logIndex.toString()
  )
  entity.from = event.params.from
  entity.to = event.params.to
  entity.tokenId = event.params.tokenId

  const metadataHash = "bafybeifxtaxrlfoszvqhioobomo454imsyo3udputxyxikbrg5cesp7qte"
  const metadata = ipfs.cat(`${metadataHash}/${event.params.tokenId.toString()}.json`)
  if(metadata) {
    const metadataJson = json.fromBytes(metadata).toObject()

    const image = metadataJson.get("image")
    if(image) {
      entity.image = image.toString()
    }

    const attributes = metadataJson.get("attributes")
    if(attributes) {
      const attributesArray = attributes.toArray()
      for(let i = 0; i < attributesArray.length; i++) {
        const item = attributesArray[i].toObject()
        const traitType = item.get("trait_type")
        const value = item.get("value")
        if(traitType && value) {
          if(traitType.toString() == "Personality") {
            entity.personality = value.toString()
          } else if(traitType.toString() == "Characteristics") {
            entity.characteristics = value.toString()
          } else if(traitType.toString() == "Cat or Dog Person") {
            entity.catOrDogPerson = value.toString()
          } else if(traitType.toString() == "Relationship Status") {
            entity.relationshipStatus = value.toString()
          } else if(traitType.toString() == "Ideal Vacation") {
            entity.idealVacation = value.toString()
          } else if(traitType.toString() == "Artistic Vocation") {
            entity.artisticVocation = value.toString()
          } else if(traitType.toString() == "Work Style") {
            entity.workStyle = value.toString()
          } else {
            //nop
          }
        }
      }
    }
  }

  entity.save()
}

deploy

デプロイします。

graph auth --product hosted-service <access_token>
graph deploy --product hosted-service jy8752/shimashi

アクセストークンはThe Graphのプロジェクトから確認できる。

Syncingがほぼ100%くらいになっていれば完了してるはず。

transfersスキーマからtokenIDを5件取得してみる。

作成したサブグラフはこちら。

https://thegraph.com/hosted-service/subgraph/jy8752/shimashi

Webの作成

せっかくなので作成したサブグラフからデータを取得してブラウザで表示してみる。
成果物はこちら

https://github.com/JY8752/The-graph-web

next.js

npx create-next-app --ts .

apollo client

作成したサブグラフにクエリを投げるのにはapollo clientを使用する。

とりあえずTransferスキーマから全項目取得するクエリを定義しておく。

queries.ts
import { gql } from '@apollo/client'

export const GET_TRANSFERS = gql`
  query GetTransfers($first: Int!) {
    transfers(first: $first) {
      id
      from
      to
      tokenId
      image
      personality
      characteristics
      catOrDogPerson
      relationshipStatus
      idealVacation
      artisticVocation
      workStyle
    }
  }
`
yarn add -D @graphql-codegen/cli
yarn add -D @graphql-codegen/typescript

yarn graphql-codegen init
? What type of application are you building? Application built with React
? Where is your schema?: (path or url) https://api.thegraph.com/subgraphs/name/jy8752/shimashi
? Where are your operations and fragments?: queries/**/*.ts
? Pick plugins: TypeScript (required by other typescript plugins), TypeScript Operations (operations and fragments), TypeScript React Apollo (typed components and HOCs)
? Where to write the output: types/generated/graphql.tsx
? Do you want to generate an introspection file? No
? How to name the config file? codegen.yml
? What script in package.json should run the codegen? gen-types

yarn gen-types

custom hooks

ボタンを押すたびに10件ずつくらいデータを取得するような感じにしたいのでカスタムフックを下記のように定義しておく。

useGetTransfers.ts
import { useLazyQuery } from '@apollo/client'
import { useCallback, useState } from 'react'
import { GET_TRANSFERS } from '../queries/queries'
import {
  GetTransfersQuery,
  GetTransfersQueryVariables,
} from '../types/generated/graphql'

export const useGetTransfers = () => {
  const GET_COUNT = 10
  const [first, setFirst] = useState(GET_COUNT)

  const [loadTransfer, { loading, data, error, called }] = useLazyQuery<
    GetTransfersQuery,
    GetTransfersQueryVariables
  >(GET_TRANSFERS, {
    variables: { first: GET_COUNT },
    fetchPolicy: 'cache-and-network',
  })

  const handleNextPage = useCallback(async () => {
    const next = first + GET_COUNT
    setFirst(next)
    await loadTransfer({
      variables: { first },
      fetchPolicy: 'cache-and-network',
    })
  }, [first, loadTransfer])

  return {
    data,
    loading,
    error,
    called,
    handleNextPage,
  }
}

index.tsxで定義したフックを使ってデータを取得する。(詳細なコンポーネント定義は省略)

index.tsx
import { ApolloError } from '@apollo/client'
import type { NextPage } from 'next'
import Head from 'next/head'
import { Transfer } from '../components/Transfer'
import { useGetTransfers } from '../hooks/useGetTransfers'
import { useIpfs } from '../hooks/useIpfs'
import styles from '../styles/Home.module.css'
import { GetTransfersQuery } from '../types/generated/graphql'

const Home: NextPage = () => {
  const { data, loading, error, called, handleNextPage } = useGetTransfers()

  const createTransfers = (
    loading: boolean,
    error: ApolloError | undefined,
    data: GetTransfersQuery | undefined
  ) => {
    if (loading) return <p>Loading...</p>

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

    return (
      <>
        <div className={styles.cardList}>
          {data?.transfers.map((transfer, index) => {
            return (
              <Transfer
                key={transfer.id}
                index={index}
                id={transfer.id}
                from={transfer.from}
                to={transfer.to}
                tokenId={transfer.tokenId}
                image={transfer.image}
                personality={transfer.personality ?? ''}
                characteristics={transfer.characteristics ?? ''}
                catOrDogPerson={transfer.catOrDogPerson ?? ''}
                relationshipStatus={transfer.relationshipStatus ?? ''}
                idealVacation={transfer.idealVacation ?? ''}
                artisticVocation={transfer.artisticVocation ?? ''}
                workStyle={transfer.workStyle ?? ''}
              />
            )
          })}
        </div>
      </>
    )
  }

  const transfers = called ? createTransfers(loading, error, data) : null

  return (
    <div className={styles.container}>
      <Head>
        <title>Shimeshi NFT Subgraph</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <p>transfer list</p>
        {transfers}
        <button onClick={handleNextPage} className={styles.button}>
          {data && data.transfers.length > 0 ? 'More' : 'Get Transfers'}
        </button>
      </main>
    </div>
  )
}

export default Home

ブラウザで確認

確認してみるとこんな感じ

一応トランザクション情報、Attributes、画像パスなどマッピングさせた情報を取得、表示が確認できる。写ってないけどボタンクリックで10件ずつプラスして取得するようになっている。

ipfs画像の表示について

サブグラフから取得した画像データはipfs://bafybeihg27p472hzuam5pmwufs6b3u2hqhsbmhugrlqxbdeqbc53sfy2am/1.pngこのような形になっているため、imgタグにそのまま指定しても当然表示ができない。ので、httpリクエストでアクセスできるようなgatewayがいくつか存在するのでgateway経由でアクセスする。

検索でよく出てくるのがipfs.ioとかgateway.ipfs.ioなので試してみたが表示までだいぶ時間がかかり、imgタグの埋め込みだとタイムアウトで表示もできなかった。

いろいろ調べたところcludflareのドメインが早いということだったので、試したところめちゃくちゃ早かった。(こんなところでもさすがのcloudflare)

アクセスするネット環境や時間帯などによって表示までの速度は変わりそうですが、下記のgateway-checkerを参考にするといいかも

https://ipfs.github.io/public-gateway-checker/

一応、執筆時点での表示速度は以下のような感じだった

ipfs.io

2-3秒で表示

https://ipfs.io/ipfs/bafybeihg27p472hzuam5pmwufs6b3u2hqhsbmhugrlqxbdeqbc53sfy2am/2500.png

gateway.ipfs.io

30秒近くかかって画像の一部が表示、全部表示するまでには2-3分かかった

https://gateway.ipfs.io/ipfs/bafybeihg27p472hzuam5pmwufs6b3u2hqhsbmhugrlqxbdeqbc53sfy2am/2500.png

cloudflare-ipfs.com

2-3秒で表示

https://cloudflare-ipfs.com/ipfs/bafybeihg27p472hzuam5pmwufs6b3u2hqhsbmhugrlqxbdeqbc53sfy2am/2500.png

まとめ

  • The Graphで任意のコントラクトのサブグラフを作成した。
  • コントラクトのイベントスキーマにmetada情報を追加して、サブグラフを作成した。
  • 作成したサブグラフにapollo clientとnext.jsを使用して取得したデータをブラウザで表示しました。
  • ブラウザでipfs画像の表示をした。

慣れるとサブグラフの作成自体は割と簡単にできる。スキーマへのマッピングはAssembly Script
で記述する必要があるため、ライブラリの使用ができなかったりと制限は多そうだけどTha Graphパッケージで用意されているメソッドやパッケージはまだ多くあるため、もう少し複雑なこともできるかもしれない。

自作したコントラクトにフロントからアクセスすることがわかっていれば適切なevent設計をすることでフロントはGraphQLを用いたweb2の領域での開発ができるようになる。
既存のコントラクト情報の取得も当然できるためうまく使えばDapps開発の開発効率を上げることができるのかもしれない。

あとは、ipfsの画像表示がだいぶはまった。cloudflareのgatewayを使用することで表示できるようにはなったけど、表示できないものも多かった。gatewayのURLをimgタグに直接埋め込むのはよくないのかも知れない。GraphQLから画像をbase64エンコードしたデータを返すようにしたりできれば解決するのかも知れない。とりあえず今回はここまで

以上!

参考

https://qiita.com/chomado/items/705d0a6d9ce985f1a433

https://camiinthisthang.hashnode.dev/the-complete-guide-to-getting-started-with-the-graph

https://hanzochang.com/articles/8
GitHubで編集を提案

Discussion

ログインするとコメントできます