Appearance
L1-16: Subgraphs(子图索引)
1. 问题
使用 The Graph 协议为以太坊智能合约构建高效的链上数据查询层。通过定义数据源(subgraph.yaml)、数据模型(schema.graphql)和事件处理器(mapping.ts),将链上事件转化为可快速查询的 GraphQL API。
核心挑战:ERC-1155 GameItems 合约的 TransferSingle、TransferBatch 和 TokenCreated 事件需要被索引为 Token、Holder、Transfer 三个 GraphQL 实体。
2. 原因
链上数据存储在 EVM 状态树中,按事件遍历极其低效——前端如果直接逐区块扫描事件来构建用户界面,对中等规模的 dApp 就会变得不可用。The Graph 的 Subgraph 模式通过预索引解决了这个问题:
- 预计算:所有事件在发生时就转换为关系型实体
- 毫秒级查询:GraphQL 查询直接命中索引过的数据库
- 标准化:每个 dApp 使用相同的模式,前端开发者无需了解链上存储细节
- 可组合:多个 subgraph 的数据可以通过 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 的 mapping 使用 AssemblyScript(类似 TypeScript 但运行在 WASM 中),不支持:switch 语句(某些情况下)、for...of 循环、async/await、try/catch、正则表达式。TypeScript 的 number 被映射为 i32/f64(有大小限制),大数值必须用 BigInt。
4.2 startBlock 的选择
如果 startBlock 设置太晚,会错过合约早期的历史事件。如果设置太早(如从创世块开始),索引器需要扫描数百万个空块,浪费时间和资源。
4.3 实体 ID 的唯一性
每个 entity 必须有唯一的 id。对于 Transfer 实体,常用的 ID 模式是 txHash-logIndex。但如果同一交易中同一 logIndex 被处理两次(由于 Batch 事件),需要确保 ID 不会冲突。
4.4 合约升级导致 ABI 变化
如果合约通过代理模式升级,增加新的事件或修改事件签名,subgraph 需要更新 ABI 和事件处理器。否则新事件会被忽略,导致索引数据不完整。
5. 陷阱的原因
5.1
AssemblyScript 是 TypeScript 的严格子集(编译为 WASM),代码运行在 The Graph 的 WASM 运行时中,而非 Node.js 或浏览器环境。缺少常见的 JavaScript 功能是因为 WASM 不支持某些 JS 运行时特性。
5.2
The Graph 节点从 startBlock 开始逐一处理区块,对每个区块调用合约的 eth_getLogs。过早的 startBlock 意味着数千个空 RPC 调用。
6. 如何解决陷阱
- 仔细阅读 The Graph 的 AssemblyScript API 文档(特别是
BigInt、Bytes、Address类型) startBlock设为合约部署区块号(可通过 etherscan 查询)- 实体 ID 使用组合键:
tokenId + '-' + address或txHash + '-' + logIndex - 升级合约后,在 subgraph.yaml 中添加新的 dataSource 或更新 ABI
7. 技术要点
| 要点 | 说明 |
|---|---|
| Subgraph 三要素 | subgraph.yaml + schema.graphql + mapping.ts |
| AssemblyScript | WASM 编译的 TypeScript 严格子集 |
| 实体模型 | @entity 标记,@derivedFrom 定义反向关系 |
| BigInt 类型 | 所有以太坊数值使用 BigInt(非 number) |
| startBlock | 设为合约部署区块号,避免扫描空块 |
| GraphQL 查询 | 前端通过 graphql(client, query) 获取索引数据 |