Appearance
L1-16: Subgraphs (Subgraph Indexing)
1. Problem
Use The Graph protocol to build an efficient on-chain data query layer for Ethereum smart contracts. Define a data source (subgraph.yaml), data model (schema.graphql), and event handlers (mapping.ts) to transform on-chain events into a fast, queryable GraphQL API.
Core challenge: the ERC-1155 GameItems contract's TransferSingle, TransferBatch, and TokenCreated events must be indexed into three GraphQL entities: Token, Holder, and Transfer.
2. Why
On-chain data is stored in the EVM state tree, and iterating through events block by block is extremely inefficient -- if a frontend directly scans events block-by-block to build its UI, it becomes unusable for even moderately sized dApps. The Graph's Subgraph pattern solves this through pre-indexing:
- Pre-computation: all events are transformed into relational entities as they occur
- Millisecond queries: GraphQL queries directly hit the indexed database
- Standardization: every dApp uses the same pattern; frontend developers don't need to understand on-chain storage details
- Composability: data from multiple subgraphs can be combined via GraphQL federation
This is the standard data layer for every dApp frontend.
3. Solution
Architecture Design
subgraph.yaml → defines data source (contract address + ABI + start block)
schema.graphql → defines query entities (GraphQL types)
mapping.ts → event handlers (transform on-chain events into entities)
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 Core Logic
typescript
export function handleTransferSingle(event: TransferSingle): void {
let tokenId = event.params.id.toString();
let token = Token.load(tokenId);
if (token == null) return;
// Update sender
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();
}
}
// Update recipient
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();
// Create Transfer record
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. Pitfalls Encountered
4.1 Differences Between AssemblyScript and TypeScript
The Graph's mapping uses AssemblyScript (similar to TypeScript but running in WASM), which does not support: switch statements (in certain cases), for...of loops, async/await, try/catch, and regular expressions. TypeScript's number is mapped to i32/f64 (with size limits); large numeric values must use BigInt.
4.2 Choosing startBlock
If startBlock is set too late, early historical events of the contract will be missed. If set too early (e.g., from the genesis block), the indexer must scan millions of empty blocks, wasting time and resources.
4.3 Entity ID Uniqueness
Every entity must have a unique id. For the Transfer entity, a common ID pattern is txHash-logIndex. However, if the same logIndex in the same transaction is processed twice (due to a Batch event), you must ensure IDs do not conflict.
4.4 Contract Upgrades Causing ABI Changes
If a contract is upgraded via a proxy pattern, adding new events or modifying event signatures, the subgraph needs its ABI and event handlers updated. Otherwise, new events will be ignored, leading to incomplete indexed data.
5. Why the Pitfalls Happen
5.1
AssemblyScript is a strict subset of TypeScript (compiled to WASM); the code runs in The Graph's WASM runtime, not in a Node.js or browser environment. The absence of common JavaScript features is because WASM does not support certain JS runtime capabilities.
5.2
The Graph node processes blocks one by one starting from startBlock, calling the contract's eth_getLogs for each block. A startBlock that is too early means thousands of empty RPC calls.
6. How to Resolve the Pitfalls
- Carefully read The Graph's AssemblyScript API documentation (especially the
BigInt,Bytes, andAddresstypes) - Set
startBlockto the contract's deployment block number (queryable via Etherscan) - Use composite keys for entity IDs:
tokenId + '-' + addressortxHash + '-' + logIndex - After upgrading a contract, add a new dataSource in subgraph.yaml or update the ABI
7. Technical Highlights
| Point | Description |
|---|---|
| Subgraph three pillars | subgraph.yaml + schema.graphql + mapping.ts |
| AssemblyScript | Strict subset of TypeScript compiled to WASM |
| Entity model | @entity annotation, @derivedFrom for reverse relations |
| BigInt type | All Ethereum numeric values use BigInt (not number) |
| startBlock | Set to the contract deployment block to avoid scanning empty blocks |
| GraphQL query | Frontend retrieves indexed data via graphql(client, query) |