Appearance
L1-16: Subgraphs(サブグラフインデックス)
1. 課題
The Graph プロトコルを使用して、Ethereum スマートコントラクト向けの効率的なオンチェーンデータクエリレイヤーを構築します。データソース(subgraph.yaml)、データモデル(schema.graphql)、イベントハンドラ(mapping.ts)を定義し、オンチェーンイベントを高速にクエリ可能な GraphQL API に変換します。
コアチャレンジ:ERC-1155 GameItems コントラクトの TransferSingle、TransferBatch、TokenCreated イベントを Token、Holder、Transfer の3つの GraphQL エンティティとしてインデックスします。
2. なぜ重要か
オンチェーンデータは EVM ステートツリーに保存されており、イベントをブロックごとに走査するのは非常に非効率です。フロントエンドが UI を構築するためにブロックごとにイベントを直接スキャンすると、中規模の dApp でも使用不能になります。The Graph の Subgraph パターンは、プレインデックスによってこの問題を解決します。
- 事前計算:すべてのイベントは発生時点でリレーショナルエンティティに変換される
- ミリ秒単位のクエリ:GraphQL クエリがインデックス済みデータベースに直接ヒットする
- 標準化:すべての dApp が同じパターンを使用し、フロントエンド開発者はオンチェーンストレージの詳細を理解する必要がない
- 構成可能性:複数のサブグラフのデータを GraphQL フェデレーションで結合できる
これはすべての dApp フロントエンドの標準データレイヤーです。
3. 解決策
アーキテクチャ設計
subgraph.yaml → データソースを定義(コントラクトアドレス + ABI + 開始ブロック)
schema.graphql → クエリエンティティを定義(GraphQL 型)
mapping.ts → イベントハンドラ(オンチェーンイベントをエンティティに変換)
Step 1: subgraph.yaml
yaml
specVersion: 1.0.0
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum/contract
name: GameItems
network: sepolia
source:
address: "0xYourContractAddress"
abi: GameItems
startBlock: 5000000
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
entities:
- Token
- Transfer
- Holder
abis:
- name: GameItems
file: ./abis/GameItems.json
eventHandlers:
- event: TransferSingle(indexed address,indexed address,indexed address,uint256,uint256)
handler: handleTransferSingle
- event: TransferBatch(indexed address,indexed address,indexed address,uint256[],uint256[])
handler: handleTransferBatch
- event: TokenCreated(indexed uint256,string,uint256)
handler: handleTokenCreated
file: ./src/mapping.ts
Step 2: schema.graphql
graphql
type Token @entity {
id: ID!
uri: String!
totalSupply: BigInt!
holders: [Holder!]! @derivedFrom(field: "token")
transfers: [Transfer!]! @derivedFrom(field: "token")
}
type Holder @entity {
id: ID! # tokenId-address
address: Bytes!
token: Token!
balance: BigInt!
}
type Transfer @entity {
id: ID! # txHash-logIndex
from: Bytes!
to: Bytes!
token: Token!
amount: BigInt!
timestamp: BigInt!
blockNumber: BigInt!
transactionHash: Bytes!
}
Step 3: mapping.ts コアロジック
typescript
export function handleTransferSingle(event: TransferSingle): void {
let tokenId = event.params.id.toString();
let token = Token.load(tokenId);
if (token == null) return;
// 送信者を更新
if (event.params.from != Bytes.empty()) {
let fromHolderId = tokenId + '-' + event.params.from.toHexString();
let fromHolder = Holder.load(fromHolderId);
if (fromHolder != null) {
fromHolder.balance = fromHolder.balance.minus(event.params.amount);
fromHolder.save();
}
}
// 受信者を更新
let toHolderId = tokenId + '-' + event.params.to.toHexString();
let toHolder = Holder.load(toHolderId);
if (toHolder == null) {
toHolder = new Holder(toHolderId);
toHolder.address = event.params.to;
toHolder.token = tokenId;
toHolder.balance = BigInt.zero();
}
toHolder.balance = toHolder.balance.plus(event.params.amount);
toHolder.save();
// Transfer レコードを作成
let transferId = event.transaction.hash.toHexString() + '-' + event.logIndex.toString();
let transfer = new Transfer(transferId);
transfer.from = event.params.from;
transfer.to = event.params.to;
transfer.token = tokenId;
transfer.amount = event.params.amount;
transfer.timestamp = event.block.timestamp;
transfer.blockNumber = event.block.number;
transfer.transactionHash = event.transaction.hash;
transfer.save();
}
4. 遭遇した落とし穴
4.1 AssemblyScript と TypeScript の違い
The Graph のマッピングは AssemblyScript(TypeScript に似ているが WASM で実行される)を使用します。以下の機能はサポートされていません:switch 文(一部のケース)、for...of ループ、async/await、try/catch、正規表現。TypeScript の number は i32/f64 にマッピングされ(サイズ制限あり)、大きな数値には BigInt を使用する必要があります。
4.2 startBlock の選択
startBlock を遅く設定しすぎると、コントラクトの初期の履歴イベントを見逃します。早く設定しすぎると(例:ジェネシスブロックから)、インデクサーは数百万の空ブロックをスキャンし、時間とリソースを無駄にします。
4.3 エンティティ ID の一意性
各エンティティは一意の id を持つ必要があります。Transfer エンティティの場合、一般的な ID パターンは txHash-logIndex です。ただし、同じトランザクション内の同じ logIndex が(Batch イベントにより)2回処理される場合、ID の競合を防ぐ必要があります。
4.4 コントラクトアップグレードによる ABI 変更
コントラクトがプロキシパターンでアップグレードされ、新しいイベントが追加されたりイベントシグネチャが変更されたりした場合、サブグラフの ABI とイベントハンドラを更新する必要があります。そうしないと、新しいイベントが無視され、インデックスデータが不完全になります。
5. 落とし穴の原因
5.1
AssemblyScript は TypeScript の厳格なサブセット(WASM にコンパイル)であり、コードは Node.js やブラウザ環境ではなく The Graph の WASM ランタイムで実行されます。一般的な JavaScript 機能が欠けているのは、WASM が特定の JS ランタイム機能をサポートしていないためです。
5.2
The Graph ノードは startBlock からブロックを1つずつ処理し、各ブロックに対してコントラクトの eth_getLogs を呼び出します。startBlock が早すぎると、数千回の空の RPC 呼び出しが発生します。
6. 落とし穴の解決方法
- The Graph の AssemblyScript API ドキュメント(特に
BigInt、Bytes、Address型)を注意深く読む startBlockをコントラクトのデプロイブロック番号に設定する(Etherscan で照会可能)- エンティティ ID に複合キーを使用する:
tokenId + '-' + addressまたはtxHash + '-' + logIndex - コントラクトをアップグレードした後、subgraph.yaml に新しい dataSource を追加するか ABI を更新する
7. 技術的ポイント
| ポイント | 説明 |
|---|---|
| サブグラフの3要素 | subgraph.yaml + schema.graphql + mapping.ts |
| AssemblyScript | WASM にコンパイルされる TypeScript の厳格なサブセット |
| エンティティモデル | @entity アノテーション、@derivedFrom で逆方向リレーションを定義 |
| BigInt 型 | すべての Ethereum 数値は(number ではなく)BigInt を使用 |
| startBlock | 空ブロックのスキャンを避けるため、コントラクトのデプロイブロック番号に設定 |
| GraphQL クエリ | フロントエンドは graphql(client, query) でインデックスデータを取得 |