web3.path

PHASE 08 Backend · ~4 hours

Backend for Web3

"Why do I need a backend if everything's on-chain?" Because querying a chain for "all Transfers where to = X, grouped by day" through eth_getLogs is brutal. You need an indexer.

Goal — build a Node.js service that listens to events, writes them to Postgres, and serves a REST/GraphQL API. Also understand what The Graph does for you.

1. The classic Web3 architecture

UI ──REST/GraphQL──► Indexer API (Node/Express/Fastify) │ ▼ Postgres ◄─── Ingest worker │ ▼ RPC node (Alchemy/local) reads eth_getLogs + eth_getBlock

Chain is your event log (append-only, source of truth). Your SQL DB is the materialized view. Exactly like CQRS / Kafka-Connect-to-Postgres.

2. Why on-chain queries don't scale

3. A minimal indexer in ~80 lines

// npm i ethers pg
const { JsonRpcProvider, Contract, Interface } = require('ethers');
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const provider = new JsonRpcProvider(process.env.RPC_URL);
const iface = new Interface([
  "event Transfer(address indexed from, address indexed to, uint256 value)"
]);
const TOKEN = "0x...";

async function getCursor() {
  const r = await pool.query("SELECT last_block FROM cursor WHERE id='tok'");
  return r.rows[0]?.last_block ?? DEPLOY_BLOCK;
}
async function setCursor(b) {
  await pool.query(
    "INSERT INTO cursor(id,last_block) VALUES('tok',$1) ON CONFLICT (id) DO UPDATE SET last_block=$1", [b]);
}

async function tick() {
  const head = await provider.getBlockNumber();
  const safe = head - 5;                 // reorg buffer
  let from = await getCursor() + 1;
  while (from <= safe) {
    const to = Math.min(from + 1999, safe);
    const logs = await provider.getLogs({
      address: TOKEN, fromBlock: from, toBlock: to,
      topics: [iface.getEvent("Transfer").topicHash]
    });
    for (const l of logs) {
      const { args } = iface.parseLog(l);
      await pool.query(
        `INSERT INTO transfers(tx_hash, log_idx, block, "from", "to", value)
         VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
        [l.transactionHash, l.index, l.blockNumber,
         args.from, args.to, args.value.toString()]);
    }
    await setCursor(to);
    from = to + 1;
  }
}
setInterval(tick, 12_000);
Three rules every indexer needs — (1) persistent cursor, (2) reorg buffer (don't trust the tip), (3) unique key (tx_hash, log_idx) for idempotency.

4. Reorgs — the tricky part

Blocks <~12 confirmations can disappear. Your indexer must either (a) stay a few blocks behind the head, or (b) store block hashes and roll back if a hash changes.

-- Simple approach: track block_hash; on tick, refetch last N blocks and compare
SELECT block, hash FROM ingested_blocks ORDER BY block DESC LIMIT 12;

5. The Graph — indexing as a service

Instead of rolling your own, you can write a subgraph: a YAML + AssemblyScript handler that The Graph's nodes execute. You query with GraphQL. Great for public, read-heavy apps.

# schema.graphql
type Transfer @entity {
  id: ID!
  from: Bytes!
  to: Bytes!
  value: BigInt!
  block: BigInt!
}

# mapping.ts
export function handleTransfer(e: Transfer): void {
  let t = new Transfer(e.transaction.hash.toHex() + "-" + e.logIndex.toString())
  t.from  = e.params.from
  t.to    = e.params.to
  t.value = e.params.value
  t.block = e.block.number
  t.save()
}
Analogy — The Graph is "Elasticsearch for events" as a managed service. You give it a schema and handlers; it gives you a GraphQL endpoint.

6. When to use The Graph vs roll your own

Use The GraphRoll your own
Public data, read-heavyNeed auth, private data, custom joins
Ethereum / major L2sExotic chain or low-latency needs
OK with GraphQLNeed fine-grained SQL & analytics
Want minimal opsAlready have DB + infra

7. RPC providers & rate limits

Alchemy, Infura, Ankr, QuickNode. Free tiers are ~300 req/s, plenty for indexers if you batch. Use eth_getLogs with the widest block window the provider allows; use alchemy_getAssetTransfers for one-shot backfills.

8. Websocket subscriptions

import { WebSocketProvider } from 'ethers';
const p = new WebSocketProvider(process.env.WSS_URL);
p.on('block', (n) => console.log('new block', n));
contract.on('Transfer', (from, to, val) => push(...));

Use WS for hot listeners, HTTP polling as a fallback. WS disconnects all the time — wrap with auto-reconnect.

9. Transaction-submission backend (relayers)

Sometimes the backend is also a signer: it holds a hot key to submit txs on behalf of users (gasless UX, batching). Patterns:

10. Project

Deliverable — Dockerized Node service with: Postgres schema (blocks, transfers, cursor), an ingest worker, a REST route GET /holders?limit=50 computing top holders from the transfers table. Ship with health checks and a README.

Quiz

Q. Your indexer inserted a transfer at block 8,000,001 but the block later got reorged out. User's dashboard shows a balance that isn't real. Best fix?
Reorg-safety is a correctness invariant; no amount of speed or retries fixes it. Always trail the head.
← Phase 7Phase 9: Token Standards →