⛓️

BlockchainのデータをGraphQLで取得する【SubQuery】

2022/10/18に公開

SubQueryについて雑に書いていきます。

event()

ブロックチェーンにあるデータをフロント側で管理することや情報を抽出することが難しい問題がありますが、TheGraphSubQueryというサービスがその問題を解決してくれます。

スマートコントラクトでeventを発火すると引数で指定したデータがトランザクションログに保存されます。TheGraphやSubQueryはそのデータをオフチェーンのサーバーに蓄積しGraphQLでqueryできるようにします。eventの発火はフロント側で監視することもできます。こういったサービスが出る前は自前でサーバー立ててやっていたようです。

対応チェーンはTheGraphはethereum、SubQueryはPolkadot,substrate,Avalanche,Cosmos等に対応しています。

eventはfunction内などに仕込んでemitを使うことで発火させます。
以下はadd()というeventを定義してaddList()内でemitを使い発火させています。
ちなみにindexedを使うとclient側でフィルターをかけられたりします。

Sample.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Emitter {
  struct Transactor {
    address sender;
    address reciever;
    uint256 amount;
  }

  Transactor[] list;

  // 最大3つまでindexed修飾子を追加できる
  event add(address indexed from, address indexed reciever, uint256 amount);

  function addList(address _reciver, uint256 _amount) public {
    list.push(Transactor(msg.sender, _reciver, _amount));
    emit add(msg.sender, _reciver, _amount);
  }
}

https://docs.soliditylang.org/en/v0.8.15/contracts.html#events

SubQuery

今回はSubQueryを例にして進めていきます。
https://doc.subquery.network/quickstart/quickstart.html

プロジェクトを構成する3つのファイル

SubQueryを構成する重要なファイルが3つあります。この構成はTheGraphとほぼ変わりません。

  • project.yaml
    • インデックスを作成するスマートコントラクトのaddress,abi,networkなどを記載する
    • どのeventをリッスンするか
    • 呼び出されるマッピング関数
  • mappings
    • 発火されたeventをentityに変換する関数の定義
    • project.yamlで記述された各関数を定義する必要がある
  • schema.graphql
    • GraphQLのスキーマ定義

プロジェクト作成

cliのインストールからです。

% npm install -g @subql/cli
% subql init <project name>
% cd <project name>
% yarn
% yarn codegen
% yarn build
% yarn start:docker

ここまでするとlocal:3000でgraphqlのplaygroundが立ち上がります。
既にコントラクトからeventが発火されていればplaygroundでqueryできると思います。

project.yaml

project.yamlではコントラクトのaddressやabi(application binary interface)などを記載していきます。またどのeventを監視 するか、そのeventをどのマッピング関数に紐付けるかを決めます。mappingについては後ほど。
変更が必要そうなdataSources配下をピックアップ。

  • abiのvalueはassetsのkeyである必要がある
  • addressはコントラクトのもの
  • assetsのfileにabiの置き場所を記載
  • mappingのhandlerは関数名
  • topicsはevent
  • 下のファイルでnullになってる箇所はeventのindex値を入れてフィルターできる
project.yaml
dataSources:
  - kind: substrate/FrontierEvm
    startBlock: 752073
    processor:
      file: "./node_modules/@subql/frontier-evm-processor/dist/bundle.js"
      options:
        # abi の value は assets の keyである必要がある
        abi: erc20
        # Contract address (or recipient if transfer) to filter, if `null` should be for contract creation
        address: "0x6bd193ee6d2104f14f94e2ca6efefae561a4334b"
    assets:
      erc20:
        file: ./erc20.abi.json
    mapping:
      file: ./dist/index.js
      handlers:
        - handler: handleFrontierEvmEvent
          kind: substrate/FrontierEvmEvent
          filter:
            topics:
              - "Transfer(address indexed from,address indexed to,uint256 value)"
              # topics[1]〜topics[3]はインデックス値
              - null
              - null
              - null
        - handler: handleFrontierEvmCall
          kind: substrate/FrontierEvmCall
          filter:
            ## The function can either be the function fragment or signature
            # function: '0x095ea7b3'
            # function: '0x7ff36ab500000000000000000000000000000000000000000000000000000000'
            # function: approve(address,uint256)
            function: "approve(address to,uint256 value)"

mapping

mappingはtypescriptのファイルです。
mapping関数にはいくつか種類があるので主要なものをピックアップします。

Block handlers

  • 新しいブロックがチェーンに追加されるたびに情報を取得できる
  • 定義済みのBlockHandlerが各Blockに対して1回呼び出される
  • パフォーマンス的にあまり使わないほうが良いらしい
import { SubstrateBlock } from "@subql/types";

export async function handleBlock(block: SubstrateBlock): Promise<void> {
  // Create a new BlockEntity with the block hash as it's ID
  const record = new BlockEntity(block.block.header.hash.toString());
  record.field1 = block.block.header.number.toNumber();
  await record.save();
}

Event handlers

  • 新しいブロックに特定のイベントが含まれている時に情報を取得できる
import { SubstrateEvent } from "@subql/types";

export async function handleEvent(event: SubstrateEvent): Promise<void> {
    const {event: {data: [account, balance]}} = event;
    const record = new EventEntity(event.extrinsic.block.block.header.hash.toString());
    record.field2 = account.toString();
    record.field3 = (balance as Balance).toBigInt();
    await record.save();
}

Call handlers

  • 特定のSubstrate extrinsicsに関する情報をキャッチアップする場合に仕様される
export async function handleCall(extrinsic: SubstrateExtrinsic): Promise<void> {
  const record = new CallEntity(extrinsic.block.block.header.hash.toString());
  record.field4 = extrinsic.block.timestamp;
  await record.save();
}

schema.graphql

プロジェクト作成時はこんな感じのファイルができてるかなと思います。

type Transaction @entity {
  id: ID! # Transaction hash
  value: BigInt!
  to: String!
  from: String!
  contractAddress: String!
}

type Approval @entity {
  id: ID! # Transaction hash
  value: BigInt!
  owner: String!
  spender: String!
  contractAddress: String!
}

mappingのファイル内にnew EventEntity(hoge)みたいな記述がありましたが、hogeの部分でidを指定してentityを作ろうとしてます。今回で言うとnew Transaction(hoge)みたいな感じです。save()で保存します。

集計関数

GraphQLでの集計は以下に基づいているらしいです。
playground見るとStringFilterとかIntFilterとかDateFilterとかいろいろ型が見れるのでそこみると良さそうです。
https://github.com/graphile/pg-aggregates
https://doc.subquery.network/run_publish/aggregate.html

idが0x0001056180590bf3c810a527a93c17b7bbd7f4c6c2ff74c076e9d998d3c6946eと同じ

query {
  logCardMinteds(filter: {id: {equalTo: "0x0001056180590bf3c810a527a93c17b7bbd7f4c6c2ff74c076e9d998d3c6946e"}} ) {
    nodes {
      nodeId
      id
      blockNumber
      buyer
      tokenId
      cardTypeId
    }
  }
}

Discussion