The Graph & Indexing — Web3

The Graph & Indexing Blockchain menyimpan data, tapi querying data itu lambat dan terbatas. The Graph adalah protocol indexing yang memungkinkan kamu query…

Indexer adalah program yang membaca event blockchain satu kali, menyimpannya ke database dalam bentuk yang siap ditanya, lalu menjawab pertanyaan dari database itu. The Graph adalah indexer paling dikenal: kamu menulis subgraph (aturan data apa yang diambil dan bagaimana diolah), dan hasilnya ditanya lewat GraphQL. Bayangin buku kas Toko Kaos Raka. Kalau Raka ditanya "siapa pelanggan dengan POIN terbanyak?", tanpa indexer dia harus membuka buku dari halaman pertama dan menjumlah sendiri, setiap kali ada yang bertanya. Indexer itu asisten yang membaca tiap halaman baru sekali, langsung memperbarui buku rekap per pelanggan, jadi pertanyaan apa pun dijawab dari buku rekap.

🧪 Tebakan cepat: Subgraph PoinRaka sudah memproses block 10, dan saldo Dinda di sana 20 POIN. Lalu block 10 kena reorg: diganti block 10 lain yang isinya cuma approve(TukarPoin, 0) dari Dinda. Berapa saldo Dinda di subgraph sekarang: 20, 40, atau 0? Tebak dulu, jawabannya ada di bagian "Reorg dan finality: masalah indexer".

Kenapa butuh indexer

Kita pakai chain ERC-20 dari lesson ERC-20: PoinRaka di 0x5FbD...0aa3, 10 block, dijalankan ulang di node Hardhat 3 lokal (akun test publik, gak ada uang asli). Ada 8 event Transfer: 4 mint dari earn, Dinda ke Yoga 20, lalu TukarPoin mengambil 30, 70 dan 20 dari Dinda. Request eth_getLogs yang sungguhan, dan log pertama di jawabannya (beberapa field dipotong):

{"jsonrpc":"2.0","id":2,"method":"eth_getLogs","params":[{"address":"0x5FbDB2315678afecb367f032d93F642f64180aa3","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"],"fromBlock":"0x1","toBlock":"latest"}]}

{"removed":false,"logIndex":"0x0","transactionHash":"0x9cea7afbaddf3f644142e79ccca1b35e9f8af9de28a1f81030ea4f6fdf16d286","blockNumber":"0x2","address":"0x5fbdb2315678afecb367f032d93f642f64180aa3","data":"0x0000000000000000000000000000000000000000000000052663ccab1e1c0000","topics":["0xddf252ad...b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8"]}

Itu mint 95 POIN ke Dinda (topic1 = alamat nol, topic2 = Dinda). Untuk menampilkan "pemegang POIN terbanyak", halaman web tanpa indexer harus mengambil semua Transfer sejak block deploy, lalu menjumlah saldo tiap alamat di browser, setiap kali halaman dibuka. Di chain lokal ini 8 log; dengan batas penyedia 3 block per request, itu sudah 4 request. Di mainnet ada sekitar 7.200 block per hari, jadi setahun sekitar 2.628.000 block. Dengan batas 2.000 block per request (contoh), itu 1.314 request hanya untuk satu tabel. Dan eth_getLogs cuma bisa menyaring parameter indexed: "Transfer di atas 50 POIN" gak bisa disaring di node, karena value ada di data.

Subgraph: tiga file

Subgraph menjawab tiga pertanyaan: data apa yang disimpan (schema.graphql), dari contract dan event mana (subgraph.yaml), dan bagaimana event diubah jadi data (mapping, file AssemblyScript). Versi yang dipakai di sini: Graph CLI 0.98.1 dan @graphprotocol/graph-ts 0.38.2 (npm, Oktober 2026).

# schema.graphql
type Account @entity(immutable: false) {
  id: Bytes! # alamatnya
  balance: BigInt! # saldo dalam satuan terkecil (18 decimals)
  transferCount: Int!
}

type Transfer @entity(immutable: true) {
  id: Bytes! # hash transaksi + logIndex
  from: Bytes!
  to: Bytes!
  value: BigInt!
  blockNumber: BigInt!
  transactionHash: Bytes!
}
# subgraph.yaml
specVersion: 1.3.0
schema:
  file: ./schema.graphql
dataSources:
  - kind: ethereum
    name: PoinRaka
    network: localhost # graph-node lokal; di Subgraph Studio misalnya sepolia
    source:
      address: "0x5FbDB2315678afecb367f032d93F642f64180aa3"
      abi: PoinRaka
      startBlock: 1 # block tempat PoinRaka di-deploy
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.9
      language: wasm/assemblyscript
      entities: [Account, Transfer]
      abis:
        - name: PoinRaka
          file: ./abis/PoinRaka.json
      eventHandlers:
        - event: Transfer(indexed address,indexed address,uint256)
          handler: handleTransfer
      file: ./src/poin-raka.ts
// src/poin-raka.ts (AssemblyScript: mirip TypeScript, dikompilasi ke WebAssembly)
import { Address, BigInt } from "@graphprotocol/graph-ts"
import { Transfer as TransferEvent } from "../generated/PoinRaka/PoinRaka"
import { Account, Transfer } from "../generated/schema"

function loadAccount(address: Address): Account {
  let account = Account.load(address)
  if (account == null) {
    account = new Account(address)
    account.balance = BigInt.zero()
    account.transferCount = 0
  }
  return account
}

export function handleTransfer(event: TransferEvent): void {
  let transfer = new Transfer(event.transaction.hash.concatI32(event.logIndex.toI32()))
  transfer.from = event.params.from
  transfer.to = event.params.to
  transfer.value = event.params.value
  transfer.blockNumber = event.block.number
  transfer.transactionHash = event.transaction.hash
  transfer.save()

  if (!event.params.from.equals(Address.zero())) { // bukan mint
    let sender = loadAccount(event.params.from)
    sender.balance = sender.balance.minus(event.params.value)
    sender.transferCount = sender.transferCount + 1
    sender.save()
  }
  if (!event.params.to.equals(Address.zero())) { // bukan burn
    let receiver = loadAccount(event.params.to)
    receiver.balance = receiver.balance.plus(event.params.value)
    receiver.transferCount = receiver.transferCount + 1
    receiver.save()
  }
}

Handler dijalankan per event

Ini isi store setelah tiap block, persis seperti yang dihasilkan handler di atas untuk 8 log tadi (dicek dengan graph test, Matchstick 0.6.0: "All 1 tests passed!"):

Block Event Dinda Yoga TukarPoin
2 mint 95 ke Dinda 95
3 mint 75 ke Yoga 95 75
4 mint 40 ke Yoga 95 115
5 mint 65 ke Dinda 160 115
6 Dinda ke Yoga 20 140 135
7 cuma Approval, handler gak dipanggil 140 135
8 TukarPoin ambil 30 110 135 30
9 TukarPoin ambil 70 40 135 100
10 TukarPoin ambil 20 20 135 120

Jumlah ketiganya selalu 275, sama dengan totalSupply. Itu cara cepat mengecek handler-mu.

Query GraphQL

Satu request, jawaban sudah terhitung:

const query = `{ accounts(orderBy: balance, orderDirection: desc) { id balance transferCount } }`
const res = await fetch('http://localhost:8000/subgraphs/name/poin-raka', { // graph-node lokal
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query }),
})
console.log(await res.json())
{
  "data": {
    "accounts": [
      { "id": "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc", "balance": "135000000000000000000", "transferCount": 3 },
      { "id": "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65", "balance": "120000000000000000000", "transferCount": 3 },
      { "id": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", "balance": "20000000000000000000", "transferCount": 6 }
    ]
  }
}

Reorg dan finality: masalah indexer

Block terbaru bisa diganti (lesson membaca data blockchain). Di node Hardhat, evm_revert bisa meniru itu: block 10 lama (hash 0x49b2...75f4) dibuang, lalu block 10 baru (hash 0x7abd...be3f) ditambang dengan parent yang sama, block 9 0x809e...0131. Isinya cuma approve(TukarPoin, 0) dari Dinda, jadi gak ada Transfer.

Indexer yang baik menyimpan hash tiap block yang sudah diproses. Waktu parentHash block baru gak cocok, atau block yang sama datang dengan hash lain, ia membatalkan semua perubahan dari block yang dibuang, lalu memproses block penggantinya. graph-node melakukan ini otomatis. Hasilnya: Transfer 20 POIN di block 10 hilang, Dinda kembali 40 POIN dan TukarPoin 100. Itu jawaban tebakannya. Kalau kamu menulis indexer sendiri, pilihannya dua: tangani rollback seperti itu, atau cuma proses block sampai finalized (sekitar 13 menit di belakang di mainnet). Untuk tahu data subgraph sudah sampai block mana, tanya _meta { block { number hash } }.

Di mana subgraph dijalankan

📌 Alternatif selain The Graph

Semua menyelesaikan masalah yang sama (event masuk, tabel keluar), dengan cara berbeda. Versi npm per Oktober 2026:

Alat Bentuknya
Ponder 0.18.1 indexer TypeScript yang kamu jalankan sendiri, menyimpan ke Postgres, menyediakan GraphQL dan SQL
Envio (envio 3.14.0) HyperIndex: handler TypeScript, bisa self-hosted atau di layanan Envio
Goldsky (CLI 13.17.0) layanan berbayar: menjalankan subgraph yang sama, plus Mirror untuk mengalirkan data ke database kamu
Indexer Postgres sendiri script getLogs per potongan, simpan ke tabel, plus logika reorg di atas

⚠️ Jebakan yang sering ketemu

TL;DR: eth_getLogs cocok untuk sedikit data, tapi untuk tabel, ranking dan riwayat panjang kamu butuh indexer: baca event sekali, simpan, lalu jawab dari database. The Graph memakai subgraph: schema.graphql (entity), subgraph.yaml (contract, startBlock, event ke handler) dan mapping AssemblyScript (handleTransfer mengubah event jadi entity). Dari 8 Transfer PoinRaka, query accounts(orderBy: balance, orderDirection: desc) menjawab Yoga 135, TukarPoin 120, Dinda 20, dan GraphQL bisa menyaring field yang gak indexed. Reorg adalah masalah indexer: block yang dibuang harus dibatalkan (Dinda kembali 40), atau tunggu finalized. Hosted service mati sejak 12 Juni 2024; pakai Subgraph Studio dan The Graph Network (Arbitrum One), graph-node sendiri, atau alternatif seperti Ponder, Envio dan Goldsky.

Yang akan kamu pelajari