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()
}
}
@entitymenjadikan type itu tabel.immutable: trueuntuk data yang gak pernah berubah (Transfer); Account berubah, jadifalse.startBlockmembuat indexer mulai dari block deploy, bukan block 0.eventHandlersmemetakan tanda tangan event ke fungsi. graph-node memanggilhandleTransfersekali per log, urut block lalulogIndex.- Alamat nol berarti mint (
from) atau burn (to): gak ada saldo yang dikurangi atau ditambah di sana. graph codegenmembuat classTransferEvent,AccountdanTransferdari ABI dan schema ("Types generated successfully");graph buildmengompilasi mapping ke WebAssembly ("Build completed: build/subgraph.yaml").
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 }
]
}
}
Bytesdikirim sebagai hex huruf kecil,BigIntsebagai string (angka 18 decimals terlalu besar untuk number JavaScript).- Filter boleh di field mana pun, termasuk yang gak
indexed:transfers(where: { value_gt: "50000000000000000000" }, orderBy: blockNumber)mengembalikan 4 Transfer (block 2, 3, 5 dan 9). Itu yang gak bisa dilakukaneth_getLogs.
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
- Hosted service The Graph dimatikan sejak 12 Juni 2024. Tutorial lama dengan URL
api.thegraph.com/subgraphs/name/...gak jalan lagi. - Subgraph Studio:
graph auth <DEPLOY_KEY>lalugraph deploy poin-raka. URL query development dibatasi 3.000 query per hari, cukup untuk mencoba di Sepolia. - The Graph Network: jaringan indexer terdesentralisasi tempat subgraph dipublikasikan; semua aktivitasnya, termasuk billing, ada di Arbitrum One. Free Plan 100.000 query per bulan, sesudahnya bayar dengan GRT atau kartu kredit.
- graph-node sendiri untuk chain lokal 31337: graph-node + Postgres + IPFS (biasanya lewat Docker), lalu
graph create --node http://localhost:8020/ poin-rakadangraph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 poin-raka.
📌 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
startBlocklupa diisi. Indexer mulai dari block 0 dan memeriksa jutaan block kosong sebelum sampai ke contract kamu.- Lupa alamat nol. Tanpa pengecekan
Address.zero(), mint mengurangi saldo alamat nol dan muncul Account dengan saldo minus. - Menyimpan
valuesebagaiInt.Intcuma 32-bit; nilaiuint256harusBigInt. Di JavaScript, bacabalancesebagai string laluBigInt(...)atauformatUnits. - Menganggap data indexer sudah final. Data sampai block terbaru bisa dibatalkan oleh reorg. Untuk keputusan penting, cek block-nya lewat
_metaatau tunggufinalized.
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.