DEV Community

Cover image for Building an EVM Event Indexer with TypeScript and viem
hamssog
hamssog

Posted on Originally published at hamssog.substack.com

Building an EVM Event Indexer with TypeScript and viem

A trading system needs to know more than whether a transaction was submitted.

It needs to know what happened on-chain.

A wallet traded.

A token was launched.

A position changed.

A protocol emitted an event.

A transaction was reverted.

A pool graduated.

For an automated trading application, these events eventually need to become structured application data.

That is the problem I am solving with this project:

Build a reusable EVM Event Indexer that turns blockchain logs into application-ready trading state.

The implementation uses:

  • TypeScript
  • viem
  • SQLite
  • Vitest
  • EVM-compatible networks

The indexer is designed to support applications such as:

  • Pons monitoring
  • wallet tracking
  • copy trading
  • arbitrage
  • stock-token systems
  • trading terminals
  • portfolio tracking
  • custom EVM applications

The key architecture is:

Blockchain
    ↓
Block Scanner
    ↓
Event Decoder
    ↓
Event Normalizer
    ↓
Deduplication
    ↓
Persistent Store
    ↓
State Processor
    ↓
API / Dashboard / Alerts
Enter fullscreen mode Exit fullscreen mode

Why build an event indexer?

Reading blockchain data directly from every application quickly becomes repetitive.

Without a central indexing layer:

Pons Monitor
 └── reads logs

Wallet Tracker
 └── reads logs

Copy Trader
 └── reads logs

Trading Terminal
 └── reads logs
Enter fullscreen mode Exit fullscreen mode

Each application ends up implementing its own:

  • block scanning
  • ABI decoding
  • event filtering
  • retry logic
  • deduplication
  • checkpointing

I prefer:

                EVM Blockchain
                       |
                       v
                 Event Indexer
                       |
        +--------------+--------------+
        |              |              |
        v              v              v
     Pons App      Wallet App     Trading App
Enter fullscreen mode Exit fullscreen mode

One indexing layer.

Multiple consumers.


Raw EVM logs are not application state

A blockchain gives us low-level data.

A trading application usually wants high-level objects.

For example, the chain may provide an event containing encoded values.

The application may actually need:

type TradeEvent = {
  protocol: string;
  trader: `0x${string}`;
  token: `0x${string}`;
  side: "buy" | "sell";
  amountIn: bigint;
  amountOut: bigint;
  transactionHash: `0x${string}`;
  blockNumber: bigint;
};
Enter fullscreen mode Exit fullscreen mode

The indexer performs the transformation:

Raw Log
   ↓
Decode
   ↓
Normalize
   ↓
Typed Domain Event
Enter fullscreen mode Exit fullscreen mode

Now downstream code does not need to understand low-level log encoding.


Overall architecture

The project is split into several responsibilities:

+----------------------+
| EVM Blockchain       |
+----------+-----------+
           |
           v
+----------------------+
| Block Scanner        |
+----------+-----------+
           |
           v
+----------------------+
| Log Fetcher          |
+----------+-----------+
           |
           v
+----------------------+
| Event Decoder        |
+----------+-----------+
           |
           v
+----------------------+
| Event Normalizer     |
+----------+-----------+
           |
           v
+----------------------+
| Deduplication        |
+----------+-----------+
           |
           v
+----------------------+
| SQLite               |
+----------+-----------+
           |
           v
+----------------------+
| State Processors     |
+----------+-----------+
           |
           v
+----------------------+
| API / Alerts / UI    |
+----------------------+
Enter fullscreen mode Exit fullscreen mode

The protocol-specific logic stays separate from the generic indexing engine.


Project structure

A simple repository structure:

evm-trading-event-indexer/
├── src/
│   ├── scanner/
│   │   ├── block-scanner.ts
│   │   └── log-fetcher.ts
│   │
│   ├── decoder/
│   │   ├── event-decoder.ts
│   │   └── abi-registry.ts
│   │
│   ├── normalizer/
│   │   └── event-normalizer.ts
│   │
│   ├── checkpoint/
│   │   └── checkpoint-store.ts
│   │
│   ├── persistence/
│   │   ├── database.ts
│   │   └── event-repository.ts
│   │
│   ├── deduplication/
│   │   └── event-deduplicator.ts
│   │
│   ├── reconciliation/
│   │   └── chain-reconciler.ts
│   │
│   ├── protocols/
│   │   ├── generic/
│   │   └── pons/
│   │
│   ├── state/
│   │   ├── trade-state.ts
│   │   ├── wallet-state.ts
│   │   └── position-state.ts
│   │
│   └── index.ts
│
├── test/
│   ├── unit/
│   ├── integration/
│   └── fixtures/
│
├── examples/
├── docs/
├── package.json
├── tsconfig.json
└── README.md
Enter fullscreen mode Exit fullscreen mode

The important rule is:

Keep protocol-specific event decoding separate from generic scanning and persistence.


Block scanning

The first job is discovering blocks that need to be processed.

For historical indexing:

Start Block
    ↓
Block Range 1
    ↓
Block Range 2
    ↓
Block Range 3
    ↓
Latest Block
Enter fullscreen mode Exit fullscreen mode

For live indexing:

Last Indexed Block
        ↓
New Blocks
        ↓
Fetch Logs
        ↓
Process
        ↓
Advance Checkpoint
Enter fullscreen mode Exit fullscreen mode

The same processing pipeline should ideally handle both cases.


Block ranges

I prefer processing configurable block chunks rather than requesting an enormous historical range in one operation.

For example:

fromBlock = 1,000,000
toBlock   = 1,010,000
Enter fullscreen mode Exit fullscreen mode

Then:

1,010,001 → 1,020,000
Enter fullscreen mode Exit fullscreen mode

and so on.

The chunk size should be configuration rather than hard-coded.

That makes the indexer easier to adapt to different RPC environments and workloads.


Fetching logs with viem

With viem, event log retrieval can be performed through the public client.

For a known contract and event:

const logs = await client.getLogs({
  address: contractAddress,
  event: tradeEvent,
  fromBlock,
  toBlock,
});
Enter fullscreen mode Exit fullscreen mode

The important part is filtering as early as possible.

Do not retrieve unrelated data and decode everything in application code when the RPC layer can filter it.

Useful filters include:

contract address
event signature
indexed parameters
block range
Enter fullscreen mode Exit fullscreen mode

Event definitions

The indexer needs ABI definitions for the events it understands.

For example:

const tradeEvent = {
  type: "event",
  name: "TradeExecuted",
  inputs: [
    {
      indexed: true,
      name: "trader",
      type: "address",
    },
    {
      indexed: true,
      name: "token",
      type: "address",
    },
    {
      indexed: false,
      name: "amountIn",
      type: "uint256",
    },
    {
      indexed: false,
      name: "amountOut",
      type: "uint256",
    },
  ],
} as const;
Enter fullscreen mode Exit fullscreen mode

The decoder can then convert matching logs into typed objects.


Decoding events

The decoder should have one job:

Convert an on-chain log into a domain event.

For example:

type DecodedTrade = {
  type: "TRADE_EXECUTED";
  trader: `0x${string}`;
  token: `0x${string}`;
  amountIn: bigint;
  amountOut: bigint;
  transactionHash: `0x${string}`;
  blockNumber: bigint;
  logIndex: number;
};
Enter fullscreen mode Exit fullscreen mode

A good decoder should preserve the original blockchain identifiers.

At minimum:

transaction hash
block number
block hash
log index
contract address
Enter fullscreen mode Exit fullscreen mode

These values are useful later for debugging and reconciliation.


Normalize events

Different protocols often represent similar actions differently.

One contract may emit:

TradeExecuted
Enter fullscreen mode Exit fullscreen mode

Another:

Swap
Enter fullscreen mode Exit fullscreen mode

Another:

Buy
Enter fullscreen mode Exit fullscreen mode

The application may want one normalized concept:

TRADE_EXECUTED
Enter fullscreen mode Exit fullscreen mode

So I use a normalization layer:

Protocol Event
      ↓
Protocol Decoder
      ↓
Normalized Event
Enter fullscreen mode Exit fullscreen mode

For example:

type NormalizedEvent =
  | {
      type: "TRADE_EXECUTED";
      protocol: string;
      trader: `0x${string}`;
      token: `0x${string}`;
      amountIn: bigint;
      amountOut: bigint;
      transactionHash: `0x${string}`;
      blockNumber: bigint;
    }
  | {
      type: "TOKEN_LAUNCHED";
      protocol: string;
      token: `0x${string}`;
      creator: `0x${string}`;
      transactionHash: `0x${string}`;
      blockNumber: bigint;
    };
Enter fullscreen mode Exit fullscreen mode

Now the rest of the application can consume a consistent interface.


Deduplication

An indexer should assume that an event may be seen more than once.

This can happen because of:

  • process restarts
  • retries
  • overlapping scans
  • checkpoint rollback
  • manual replay
  • reprocessing after an error

So the processing pipeline should be idempotent.

A log identity can be represented using:

chainId
transactionHash
logIndex
Enter fullscreen mode Exit fullscreen mode

I also keep block metadata for reconciliation:

blockHash
blockNumber
Enter fullscreen mode Exit fullscreen mode

The important behavior is:

Process event
     ↓
Process same event again
     ↓
No duplicate application event
Enter fullscreen mode Exit fullscreen mode

Unique event constraints

The database should help enforce deduplication.

A table can contain:

events
------
id
chain_id
block_number
block_hash
transaction_hash
log_index
contract_address
event_name
event_data
indexed_at
Enter fullscreen mode Exit fullscreen mode

Then define a unique constraint over the event identity.

For example:

(chain_id, transaction_hash, log_index)
Enter fullscreen mode Exit fullscreen mode

The application can then safely retry processing without creating duplicate event records.


Checkpointing

The indexer needs to know where it stopped.

A checkpoint record can be:

checkpoints
-----------
chain_id
stream
last_block
updated_at
Enter fullscreen mode Exit fullscreen mode

The processing flow should be:

Fetch block range
       ↓
Decode logs
       ↓
Normalize
       ↓
Persist
       ↓
Advance checkpoint
Enter fullscreen mode Exit fullscreen mode

Do not reverse the last two steps.

This is dangerous:

Fetch
  ↓
Checkpoint
  ↓
Persist
Enter fullscreen mode Exit fullscreen mode

If the process crashes between checkpointing and persistence, the indexer may permanently skip events.


Crash recovery

Suppose:

Blocks 10,000 - 10,100
Enter fullscreen mode Exit fullscreen mode

are being processed.

The application crashes after processing through block 10,060.

After restarting:

read checkpoint
      ↓
resume from safe position
      ↓
reprocess overlapping events if necessary
      ↓
deduplication prevents duplicates
Enter fullscreen mode Exit fullscreen mode

This combination is powerful:

Checkpointing
+
Idempotent Processing
+
Persistent Events
Enter fullscreen mode Exit fullscreen mode

It makes recovery much safer.


Real-time indexing

After historical backfill is complete, the indexer can continue watching new blocks.

A simple loop:

while running

    latestBlock = getBlockNumber()

    if latestBlock > checkpoint
        process range

    wait

end
Enter fullscreen mode Exit fullscreen mode

But the implementation should avoid a naive busy loop.

Use:

  • configurable polling
  • controlled sleep intervals
  • error backoff
  • cancellation support

The indexer should be able to shut down cleanly.


Historical backfill and live mode

I think of these as two operating modes:

+---------------------+
| Historical Backfill |
+----------+----------+
           |
           v
     Indexed State
           |
           v
+---------------------+
| Real-Time Indexing  |
+---------------------+
Enter fullscreen mode Exit fullscreen mode

The important part is that they use the same decoder, normalizer, repository, and state-processing logic.

That reduces the risk of having:

backfill behavior ≠ live behavior
Enter fullscreen mode Exit fullscreen mode

Reorganization awareness

One of the assumptions an indexer should avoid is:

A processed block can never change.

Chains can experience reorganizations.

That means the indexer should keep enough metadata to recognize block identity:

block number
block hash
parent hash
Enter fullscreen mode Exit fullscreen mode

When the canonical chain changes, affected indexed data may need to be rolled back and replayed.

The exact policy depends on the chain and application.

For a trading application, the important thing is to make this a deliberate part of the architecture.


Events versus contract reads

Events and current contract state serve different purposes.

Events tell us:

Something happened.

A contract read tells us:

This is the state now.

For example:

Events:
Trade A
Trade B
Trade C
Enter fullscreen mode Exit fullscreen mode

do not necessarily mean that simply summing those events gives the complete current state.

Some systems have:

  • cancellations
  • transfers
  • fees
  • refunds
  • partial executions
  • migrations
  • state resets

The indexer therefore needs to know when event history is enough and when state must be queried directly.


State processors

I do not want the indexer itself to become the complete trading engine.

Instead:

Indexed Event
      ↓
State Processor
      ↓
Position / Wallet / Protocol State
Enter fullscreen mode Exit fullscreen mode

For example:

type TradeState = {
  wallet: `0x${string}`;
  token: `0x${string}`;
  position: bigint;
  tradeCount: number;
};
Enter fullscreen mode Exit fullscreen mode

The indexer produces reliable input.

The state processor converts that input into a product-specific view.


Wallet tracking

A wallet tracker is a natural consumer.

The flow becomes:

Blockchain
   ↓
Event Indexer
   ↓
Trade Events
   ↓
Wallet Filter
   ↓
Wallet Activity
Enter fullscreen mode Exit fullscreen mode

The application can then expose:

recent trades
positions
tokens
transaction history
PnL
Enter fullscreen mode Exit fullscreen mode

The indexer itself remains generic.


Copy trading

The same indexed event can trigger another system.

For example:

Wallet Event
     ↓
Indexer
     ↓
Copy Strategy
     ↓
Risk Check
     ↓
Transaction Manager
     ↓
Blockchain
Enter fullscreen mode Exit fullscreen mode

This creates a closed trading loop.

The indexer observes.

The strategy decides.

The transaction manager executes.


Pons event indexing

Pons is a useful example because its application layer can benefit from indexed launch and trading activity.

The current Pons V2 documentation describes launch, bonding-curve trading, graduation, and subsequent pool trading, with protocol events that can be indexed to reconstruct activity. (docs.ponsfamily.com)

A Pons-focused application can therefore use:

Pons Contracts
      ↓
Pons Event Decoder
      ↓
Normalized Pons Events
      ↓
Pons State
Enter fullscreen mode Exit fullscreen mode

That state can power:

launch monitor
wallet tracker
trading dashboard
alerts
copy trading
analytics
Enter fullscreen mode Exit fullscreen mode

Example Pons launch pipeline

Conceptually:

TOKEN_LAUNCHED
       ↓
Create Token Record
       ↓
Monitor Trading Events
       ↓
Update Curve State
       ↓
Detect Graduation
       ↓
Update Venue
Enter fullscreen mode Exit fullscreen mode

The protocol adapter handles Pons-specific decoding.

The rest of the indexing infrastructure stays generic.


Building a generic protocol adapter

A protocol adapter can expose something like:

interface ProtocolAdapter {
  getSources(): IndexerSource[];

  decode(log: unknown): NormalizedEvent | null;

  normalize(event: unknown): NormalizedEvent | null;
}
Enter fullscreen mode Exit fullscreen mode

Then the engine can register:

Generic EVM Adapter
Pons Adapter
Future Protocol Adapter
Enter fullscreen mode Exit fullscreen mode

without changing the core scanner.


Persistence

For the reference implementation, SQLite is enough to demonstrate the architecture.

The database can store:

events
checkpoints
trades
tokens
wallet activity
Enter fullscreen mode Exit fullscreen mode

The important distinction is between:

raw indexed data
Enter fullscreen mode Exit fullscreen mode

and:

derived application state
Enter fullscreen mode Exit fullscreen mode

Keeping those layers separate makes rebuilding state easier.


Rebuilding state

Suppose the position-state logic changes.

If the raw events are still stored, the state can be rebuilt:

Raw Events
    ↓
Replay
    ↓
New State Processor
    ↓
New State
Enter fullscreen mode Exit fullscreen mode

This is a powerful property.

It prevents the system from depending entirely on whatever derived state happened to exist at one point in time.


API layer

The indexer can expose application-ready data through an API.

For example:

GET /events
GET /trades
GET /wallets/:address
GET /tokens/:address
GET /positions/:address
Enter fullscreen mode Exit fullscreen mode

A WebSocket layer can later provide real-time updates.

For example:

New Trade
   ↓
Indexer
   ↓
WebSocket
   ↓
Trading Dashboard
Enter fullscreen mode Exit fullscreen mode

The API should consume indexed state rather than performing raw blockchain scans on every request.


Monitoring

A production-style indexer needs its own monitoring.

Useful metrics include:

latest indexed block
indexing lag
events processed
processing errors
RPC failures
processing latency
reorg events
database latency
Enter fullscreen mode Exit fullscreen mode

For example:

Blockchain height: 2,000,100
Indexed height:    2,000,096

Indexing lag:      4 blocks
Enter fullscreen mode Exit fullscreen mode

This tells the operator whether the indexer is healthy.


Failure handling

The indexer should expect failures.

Examples:

RPC timeout
RPC rate limit
malformed event
decoder error
database error
process crash
network interruption
chain reorganization
Enter fullscreen mode Exit fullscreen mode

The right response is not always:

retry forever
Enter fullscreen mode Exit fullscreen mode

Some errors should be retried.

Some should be isolated.

Some should stop the affected stream and require investigation.


Error isolation

Suppose one malformed event causes a decoder error.

I do not want the entire indexer to lose all future blocks.

Instead:

Block
 ├── Event A → Processed
 ├── Event B → Processed
 ├── Event C → Decoder Error
 └── Event D → Processed
Enter fullscreen mode Exit fullscreen mode

Depending on the application's requirements, the problematic event can be recorded for investigation while the system continues processing safely.

The exact policy should be explicit.


Idempotent processing

A retry should be safe.

For example:

First attempt:
block 200 → events A, B, C

Crash after B

Retry:
block 200 → events A, B, C
Enter fullscreen mode Exit fullscreen mode

A correct idempotent pipeline produces:

A → one record
B → one record
C → one record
Enter fullscreen mode Exit fullscreen mode

not:

A → duplicate
B → duplicate
C → duplicate
Enter fullscreen mode Exit fullscreen mode

This is why event identity and database constraints are part of the architecture rather than an afterthought.


Testing strategy

The project uses two categories of tests.

Vitest
  ↓
Indexer / TypeScript logic

Integration tests
  ↓
RPC + local EVM + contracts
Enter fullscreen mode Exit fullscreen mode

The important tests include:

event decoding
event normalization
block-range scanning
checkpointing
deduplication
historical replay
restart recovery
RPC failure
database failure
Enter fullscreen mode Exit fullscreen mode

Decoder tests

Given a known event fixture:

raw log
  ↓
decoder
  ↓
expected normalized event
Enter fullscreen mode Exit fullscreen mode

The test should verify every important field:

trader
token
amount
transaction hash
block number
log index
Enter fullscreen mode Exit fullscreen mode

These tests protect against ABI or mapping mistakes.


Checkpoint tests

A useful scenario:

Process blocks 100-110
Crash after block 105
Restart
Resume safely
Enter fullscreen mode Exit fullscreen mode

The expected result is that the event stream is complete and duplicates are eliminated.


Reprocessing tests

Another important test is deliberate replay:

Process block 500
Process block 500 again
Process block 500 again
Enter fullscreen mode Exit fullscreen mode

Expected:

one event
one derived trade
one state update
Enter fullscreen mode Exit fullscreen mode

This demonstrates idempotency.


Integration test

A simple end-to-end local test:

Mock Contract
      ↓
Emit Event
      ↓
Local EVM
      ↓
Indexer
      ↓
Decode
      ↓
Normalize
      ↓
Persist
      ↓
Query Indexed Event
Enter fullscreen mode Exit fullscreen mode

This proves that the complete pipeline works rather than only testing isolated functions.


Connecting the indexer to a trading system

The indexer becomes especially useful when connected to the rest of the trading stack:

                   Blockchain
                       |
                       v
                 Event Indexer
                       |
                       v
                  Trading State
                       |
                       v
                    Strategy
                       |
                       v
                  Risk Engine
                       |
                       v
              Transaction Manager
                       |
                       v
                  Blockchain
Enter fullscreen mode Exit fullscreen mode

This creates a feedback loop:

Observe
   ↓
Decide
   ↓
Execute
   ↓
Observe Again
Enter fullscreen mode Exit fullscreen mode

That is the architecture behind many automated trading systems.


Connecting it to the transaction manager

The event indexer and transaction manager solve different problems.

The transaction manager answers:

What happened to the transaction I submitted?

The event indexer answers:

What happened on-chain?

Together:

Strategy
   ↓
Transaction Manager
   ↓
Blockchain
   ↓
Event Indexer
   ↓
Updated State
Enter fullscreen mode Exit fullscreen mode

This is much more robust than treating a transaction hash as the final result.


Where this can be used

The same indexing layer can support:

Pons Launch Monitor
Pons Wallet Tracker
Pons Copy Trading
Stock Token Monitoring
Stock Token Trading
Arbitrage Detection
Trading Terminals
Portfolio Analytics
Wallet Intelligence
Custom EVM Applications
Enter fullscreen mode Exit fullscreen mode

The indexer does not need to become a product-specific monolith.

It provides the data foundation underneath those products.


The engineering principles

The project is built around a few rules:

Blockchain is the source of truth.
Processing should be idempotent.
Checkpoints must be persistent.
Raw events should be retained.
Protocol logic should be isolated.
Uncertain state should remain explicit.
Recovery must be possible after restart.
Enter fullscreen mode Exit fullscreen mode

These principles are more important than the particular TypeScript classes used to implement them.


What I am trying to prove with this project

The important capability is not simply knowing how to call:

client.getLogs(...)
Enter fullscreen mode Exit fullscreen mode

The real engineering problem is building everything around it:

Block scanning
+
Event decoding
+
Normalization
+
Historical backfill
+
Real-time indexing
+
Checkpointing
+
Deduplication
+
Persistence
+
Reorg awareness
+
Failure recovery
+
State reconstruction
Enter fullscreen mode Exit fullscreen mode

That is what turns blockchain logs into usable infrastructure.


Final architecture

                         EVM Blockchain
                               |
                               v
                         Block Scanner
                               |
                               v
                          Log Fetcher
                               |
                               v
                         Event Decoder
                               |
                               v
                       Event Normalizer
                               |
                               v
                         Deduplication
                               |
                               v
                       Persistent Store
                               |
                               v
                        State Processor
                               |
             +-----------------+-----------------+
             |                 |                 |
             v                 v                 v
          Positions           PnL             Alerts
             |                 |                 |
             +-----------------+-----------------+
                               |
                               v
                         API / Dashboard
Enter fullscreen mode Exit fullscreen mode

And for an automated trading application:

Indexed Events
      ↓
Strategy
      ↓
Risk Engine
      ↓
Transaction Manager
      ↓
Solidity / EVM Executor
      ↓
Blockchain
      ↓
Event Indexer
Enter fullscreen mode Exit fullscreen mode

The loop closes.

The application observes the blockchain, makes a decision, executes the decision, observes the result, and updates its state.

That is the infrastructure I am building for reusable EVM trading systems, Pons applications, Stock Token systems, arbitrage, copy trading, trading terminals, and custom blockchain products.

Top comments (0)