Перейти до основного вмісту

Index Ethereum Smart Contract Events Without The Graph

· 5 хв. читання
Yaroslav Pavliuk
Dreamer at EasyLayer.io

If you've built anything with Ethereum smart contracts, you've probably reached for The Graph or a similar service to index your contract events. It's the obvious choice — set up a subgraph, query via GraphQL, done.

The problem is what happens next: recurring GRT costs, data you don't control, limited flexibility for custom state, and eventually the realization that every query you run is revenue for someone else.

There's a self-hosted alternative. This post shows how to build a contract event indexer using @easylayer/evm-crawler — running on your own infrastructure, zero recurring fees.

What We're Building

A self-hosted indexer that:

  • Tracks ERC-20 Transfer events for a specific contract (USDC in this example, but any contract works)
  • Maintains running balances per address
  • Exposes a query API to read current state or historical state at any block height
  • Handles chain reorganizations automatically

The Model

EVM Crawler uses an Aggregate Model — a TypeScript object that defines what state to maintain and what events to emit per block.

// model.ts
import type { DeclarativeModel } from '@easylayer/evm-crawler';
import { compileStateModelEVM } from '@easylayer/evm-crawler';

const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';

// The contract you want to track — replace with your contract address
const CONTRACT_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC

const model: DeclarativeModel<any> = {
modelId: 'usdc-balances',

state: {
balances: new Map<string, bigint>(), // address → token units
totalTransfers: 0,
},

sources: {
// Called for each log in the block
async log(ctx) {
if (ctx.log.address.toLowerCase() !== CONTRACT_ADDRESS.toLowerCase()) return;
if (ctx.log.topics[0] !== TRANSFER_TOPIC) return;

// Decode ERC-20 Transfer(address indexed from, address indexed to, uint256 value)
const from = '0x' + ctx.log.topics[1].slice(26);
const to = '0x' + ctx.log.topics[2].slice(26);
const value = BigInt(ctx.log.data);

return { from, to, value: value.toString() };
},

// Called once per block — emit an event if any matching logs were found
async block(ctx) {
const transfers = ctx.locals.log;
if (!transfers?.length) return;
ctx.applyEvent('Transfer', ctx.block.number, { transfers });
},
},

reducers: {
Transfer(state, event) {
for (const { from, to, value } of event.payload.transfers) {
const val = BigInt(value);
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';

// Debit sender (skip zero address — it's a mint)
if (from !== ZERO_ADDRESS) {
state.balances.set(from, (state.balances.get(from) ?? 0n) - val);
}

// Credit receiver (skip zero address — it's a burn)
if (to !== ZERO_ADDRESS) {
state.balances.set(to, (state.balances.get(to) ?? 0n) + val);
}

state.totalTransfers++;
}
},
},
};

export const UsdcBalances = compileStateModelEVM(model);

Bootstrap and Run

// main.ts
import { bootstrap } from '@easylayer/evm-crawler';
import { UsdcBalances } from './model';

bootstrap({ Models: [UsdcBalances] });
# .env
NETWORK_CHAIN_ID=1
PROVIDER_NETWORK_RPC_URLS=https://mainnet.infura.io/v3/YOUR_KEY

# Start from a recent block (USDC deployed at ~6082465)
START_BLOCK_HEIGHT=18000000

EVENTSTORE_DB_TYPE=sqlite
TRANSPORT_HTTP_PORT=3000

# Fetch receipts (needed for logs)
RECEIPTS_STRATEGY=auto

Run it:

npx ts-node main.ts

The crawler syncs from block 18,000,000 to the current tip, then switches to real-time mode. Logs from the USDC contract are extracted, Transfer events are emitted and stored, balances are maintained.

Query Balances

import { Client } from '@easylayer/transport-sdk';

const client = new Client({
transport: { type: 'http', query: { baseUrl: 'http://localhost:3000' } },
});

// Current state
const [model] = await client.query('GetModelsQuery', {
modelIds: ['usdc-balances'],
});

const { balances, totalTransfers } = model.state;
console.log(`Total transfers indexed: ${totalTransfers}`);

// Balance for a specific address
const address = '0xYourAddress';
const balance = BigInt(balances[address] ?? 0);
console.log(`Balance: ${balance} (raw token units)`);

Historical State at Any Block Height

Because EasyLayer uses Event Sourcing, you can query what the state was at any past block:

// What were the balances at block 18,500,000?
const [historical] = await client.query('GetModelsQuery', {
modelIds: ['usdc-balances'],
filter: { blockHeight: 18_500_000 },
});

The Graph doesn't support time-travel queries like this by default. With Event Sourcing it's free.

Real-Time Event Subscription

Subscribe to Transfer events as they happen:

client.subscribe('Transfer', (event) => {
console.log(`Block ${event.blockHeight}: ${event.payload.transfers.length} transfers`);
for (const { from, to, value } of event.payload.transfers) {
console.log(` ${from}${to}: ${value}`);
}
});

Tracking Multiple Contracts

Add more models to the same bootstrap call:

const DaiBalances = compileStateModelEVM({ modelId: 'dai-balances', /* ... */ });
const WethBalances = compileStateModelEVM({ modelId: 'weth-balances', /* ... */ });

bootstrap({ Models: [UsdcBalances, DaiBalances, WethBalances] });

Each model maintains independent state. Query them separately or together.

How This Compares to The Graph

Self-hosted (EasyLayer)The Graph
Recurring costServer only (~$20-40/month)GRT tokens per query
Data locationYour PostgreSQL/SQLiteTheir network
Custom stateFull controlGraphQL schema only
Historical queriesAny block heightLimited
Reorg handlingAutomaticAutomatic
Setup timeMinutesHours (subgraph deployment)
Supported chainsAny EVM chain you configureNetwork-supported chains

The practical difference: with The Graph, your data and costs are on their platform. With EasyLayer, you own both.

What You Need

  • VPS: 2-4 vCPU, 4-8 GB RAM (~$20-40/month)
  • Ethereum RPC: Infura free tier, Alchemy free tier, or your own node
  • Node.js ≥ 20

For a single contract event tracker, SQLite is sufficient. Switch to PostgreSQL when you're indexing multiple large contracts or need concurrent reads.

Full docs: EVM Crawler documentation · Questions: GitHub Discussions "

Join our developer Community

EasyLayer is 100% open source. Join our Forum to learn from others and get help whenever you need it!

Join our Forum
📫

Subscribe to our Newsletter

Once per month - receive useful blog posts and EasyLayer news.