The GraphでNFTプロジェクトのサブグラフを作成してみた
前から気になっていたThe Graphを使用して公開されているNFTプロジェクトのサブグラフを作成し、ブラウザで表示してみたのでその備忘録です。
The Graphとは
Ethereumなどのチェーン上にデプロイされているスマートコントラクトからサブグラフと呼ばれるAPI(GraphQL)を構築・公開できる。Ethereumだけかと思っていたらbetaだけどAVALANCHEやCOSMOSなどのチェーンも対応してるよう。
サブグラフの作成
成果物はこちら
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を追加します。
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の拡張機能を入れることでアドレスバーに直接入力するだけで確認できる。
Shimashiのmetadataはこのような構造になっている
{
"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イベントのハンドラー関数を以下のように修正。
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件取得してみる。
作成したサブグラフはこちら。
Webの作成
せっかくなので作成したサブグラフからデータを取得してブラウザで表示してみる。
成果物はこちら
next.js
npx create-next-app --ts .
apollo client
作成したサブグラフにクエリを投げるのにはapollo clientを使用する。
とりあえずTransferスキーマから全項目取得するクエリを定義しておく。
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件ずつくらいデータを取得するような感じにしたいのでカスタムフックを下記のように定義しておく。
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で定義したフックを使ってデータを取得する。(詳細なコンポーネント定義は省略)
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を参考にするといいかも
一応、執筆時点での表示速度は以下のような感じだった
ipfs.io
2-3秒で表示
gateway.ipfs.io
30秒近くかかって画像の一部が表示、全部表示するまでには2-3分かかった
cloudflare-ipfs.com
2-3秒で表示
まとめ
- The Graphで任意のコントラクトのサブグラフを作成した。
- コントラクトのイベントスキーマにmetada情報を追加して、サブグラフを作成した。
- 作成したサブグラフにapollo clientとnext.jsを使用して取得したデータをブラウザで表示しました。
- ブラウザでipfs画像の表示をした。
慣れるとサブグラフの作成自体は割と簡単にできる。スキーマへのマッピングはAssembly Script
で記述する必要があるため、ライブラリの使用ができなかったりと制限は多そうだけどTha Graphパッケージで用意されているメソッドやパッケージはまだ多くあるため、もう少し複雑なこともできるかもしれない。
自作したコントラクトにフロントからアクセスすることがわかっていれば適切なevent設計をすることでフロントはGraphQLを用いたweb2の領域での開発ができるようになる。
既存のコントラクト情報の取得も当然できるためうまく使えばDapps開発の開発効率を上げることができるのかもしれない。
あとは、ipfsの画像表示がだいぶはまった。cloudflareのgatewayを使用することで表示できるようにはなったけど、表示できないものも多かった。gatewayのURLをimgタグに直接埋め込むのはよくないのかも知れない。GraphQLから画像をbase64エンコードしたデータを返すようにしたりできれば解決するのかも知れない。とりあえず今回はここまで
以上!
参考
Discussion