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
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
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
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;
};
The indexer performs the transformation:
Raw Log
↓
Decode
↓
Normalize
↓
Typed Domain Event
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 |
+----------------------+
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
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
For live indexing:
Last Indexed Block
↓
New Blocks
↓
Fetch Logs
↓
Process
↓
Advance Checkpoint
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
Then:
1,010,001 → 1,020,000
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,
});
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
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;
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;
};
A good decoder should preserve the original blockchain identifiers.
At minimum:
transaction hash
block number
block hash
log index
contract address
These values are useful later for debugging and reconciliation.
Normalize events
Different protocols often represent similar actions differently.
One contract may emit:
TradeExecuted
Another:
Swap
Another:
Buy
The application may want one normalized concept:
TRADE_EXECUTED
So I use a normalization layer:
Protocol Event
↓
Protocol Decoder
↓
Normalized Event
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;
};
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
I also keep block metadata for reconciliation:
blockHash
blockNumber
The important behavior is:
Process event
↓
Process same event again
↓
No duplicate application event
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
Then define a unique constraint over the event identity.
For example:
(chain_id, transaction_hash, log_index)
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
The processing flow should be:
Fetch block range
↓
Decode logs
↓
Normalize
↓
Persist
↓
Advance checkpoint
Do not reverse the last two steps.
This is dangerous:
Fetch
↓
Checkpoint
↓
Persist
If the process crashes between checkpointing and persistence, the indexer may permanently skip events.
Crash recovery
Suppose:
Blocks 10,000 - 10,100
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
This combination is powerful:
Checkpointing
+
Idempotent Processing
+
Persistent Events
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
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 |
+---------------------+
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
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
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
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
For example:
type TradeState = {
wallet: `0x${string}`;
token: `0x${string}`;
position: bigint;
tradeCount: number;
};
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
The application can then expose:
recent trades
positions
tokens
transaction history
PnL
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
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
That state can power:
launch monitor
wallet tracker
trading dashboard
alerts
copy trading
analytics
Example Pons launch pipeline
Conceptually:
TOKEN_LAUNCHED
↓
Create Token Record
↓
Monitor Trading Events
↓
Update Curve State
↓
Detect Graduation
↓
Update Venue
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;
}
Then the engine can register:
Generic EVM Adapter
Pons Adapter
Future Protocol Adapter
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
The important distinction is between:
raw indexed data
and:
derived application state
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
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
A WebSocket layer can later provide real-time updates.
For example:
New Trade
↓
Indexer
↓
WebSocket
↓
Trading Dashboard
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
For example:
Blockchain height: 2,000,100
Indexed height: 2,000,096
Indexing lag: 4 blocks
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
The right response is not always:
retry forever
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
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
A correct idempotent pipeline produces:
A → one record
B → one record
C → one record
not:
A → duplicate
B → duplicate
C → duplicate
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
The important tests include:
event decoding
event normalization
block-range scanning
checkpointing
deduplication
historical replay
restart recovery
RPC failure
database failure
Decoder tests
Given a known event fixture:
raw log
↓
decoder
↓
expected normalized event
The test should verify every important field:
trader
token
amount
transaction hash
block number
log index
These tests protect against ABI or mapping mistakes.
Checkpoint tests
A useful scenario:
Process blocks 100-110
Crash after block 105
Restart
Resume safely
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
Expected:
one event
one derived trade
one state update
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
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
This creates a feedback loop:
Observe
↓
Decide
↓
Execute
↓
Observe Again
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
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
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.
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(...)
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
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
And for an automated trading application:
Indexed Events
↓
Strategy
↓
Risk Engine
↓
Transaction Manager
↓
Solidity / EVM Executor
↓
Blockchain
↓
Event Indexer
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)