Skip to content
On this page

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, and Address types)
  • Set startBlock to the contract's deployment block number (queryable via Etherscan)
  • Use composite keys for entity IDs: tokenId + '-' + address or txHash + '-' + logIndex
  • After upgrading a contract, add a new dataSource in subgraph.yaml or update the ABI

7. Technical Highlights

PointDescription
Subgraph three pillarssubgraph.yaml + schema.graphql + mapping.ts
AssemblyScriptStrict subset of TypeScript compiled to WASM
Entity model@entity annotation, @derivedFrom for reverse relations
BigInt typeAll Ethereum numeric values use BigInt (not number)
startBlockSet to the contract deployment block to avoid scanning empty blocks
GraphQL queryFrontend retrieves indexed data via graphql(client, query)

Built with AiAda