A TypeScript framework for reliable EVM indexing with PostgreSQL.
Voryn helps you build indexers that read blocks from EVM RPC, store normalized fetched data, commit chain progress in strict order, and run your application logic on transactions and events.
The library handles the boring but critical infrastructure: block queues, retries, cursors, singleton locks, reorg protection, retention, and metrics. You write business logic on top of committed data.
Voryn is a good fit for teams building:
- backends for DeFi, NFT, payments, wallets, and on-chain analytics;
- event-driven services that react to contract logs;
- pipelines that load blocks, transactions, and events into PostgreSQL;
- application-owned custom indexers;
- multi-chain services where each chain needs isolated progress tracking.
Voryn focuses on durable PostgreSQL-backed indexing pipelines with application-owned storage and processing.
- Ingestion pipeline:
HeadWorkerenqueues blocks,FetchWorkerdownloads data, andSequencerWorkercommits only the strictN -> N+1sequence. - Reorg handling:
parentHashchecks, common ancestor lookup, and rollback of replaced fetched data. - Horizontal fetch scaling: multiple fetch workers can safely share one PostgreSQL-backed queue.
- Durable reactions:
EventReactionWorkerandTransactionReactionWorkerread committed streams by block position and maintain their own cursors. - Operational tools:
RetentionWorker,PipelineMetrics,BlockJobRecovery,ConsoleLogger, and a PostgreSQL schema helper. - Replaceable pieces: you can bring your own
BlockSource, logger, repositories, transaction manager, or leader lock.
flowchart LR
RPC["EVM RPC"] --> Head["HeadWorker"]
Head --> Jobs["block_jobs"]
Jobs --> Fetch["FetchWorker x N"]
Fetch --> Data["blocks / transactions / events"]
Data --> Sequencer["SequencerWorker"]
Jobs --> Sequencer
Sequencer --> Cursor["chain_cursor"]
Data --> Reactions["Reaction workers"]
Cursor --> Reactions
Fetchcan be scaled horizontally. It writes normalizedblocks,transactions, andevents.Sequencervalidates order through block hashes, advanceschain_cursor, and marks the matchingblock_jobsrows as committed.Head,Sequencer,Retention, and Reaction workers run as singleton processes throughLeaderLock.
npm install @drillcoder/vorynThe package is published as ESM and uses ethers v6 and pg.
Voryn expects the base PostgreSQL schema from the SQL file included in the npm package.
The physical file path after installation is:
node_modules/@drillcoder/voryn/dist/sql/postgres-schema.sqlThe same file is also exposed as a package subpath:
@drillcoder/voryn/sql/postgres-schema.sqlYou can apply the SQL with your own migration flow:
psql "$DATABASE_URL" -f node_modules/@drillcoder/voryn/dist/sql/postgres-schema.sqlOr use the built-in helper:
import { Pool } from "pg";
import { ConsoleLogger, applySqlFileToPostgresDb } from "@drillcoder/voryn";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const logger = new ConsoleLogger({ minLevel: "info" });
await applySqlFileToPostgresDb({
pool,
sqlFilePath: "node_modules/@drillcoder/voryn/dist/sql/postgres-schema.sql",
logger,
});
await pool.end();Full example: examples/db-apply-sql.ts
The minimal ingestion pipeline consists of head, fetch, and sequencer. In production, they are usually started as separate processes or containers.
import { FetchWorker, HeadWorker, SequencerWorker } from "@drillcoder/voryn";
const dbUrl = "postgres://user:pass@localhost:5432/voryn";
const rpcUrl = "https://rpc.example.org";
const chainId = 1;
const logLevel = "info";
const headOptions = {
chainId,
delayBetweenTicksMs: 1_000,
confirmations: 12,
depthBlocks: 65_000,
logLevel,
dbUrl,
rpcUrl,
};
const fetchOptions = {
chainId,
delayBetweenTicksMs: 100,
fetchBatchSize: 10,
fetchConcurrency: 2,
fetchClaimTtlMs: 125_000,
retryMaxAttempts: 10,
retryBaseDelayMs: 1_000,
retryMaxDelayMs: 10_000,
logLevel,
dbUrl,
rpcUrl,
};
const sequencerOptions = {
chainId,
delayBetweenTicksMs: 100,
maxBlocksPerTick: 10,
logLevel,
dbUrl,
rpcUrl,
};
const head = await HeadWorker.create(headOptions);
const fetch = await FetchWorker.create(fetchOptions);
const sequencer = await SequencerWorker.create(sequencerOptions);
await Promise.all([
head.start(),
fetch.start(),
sequencer.start(),
]);Full worker examples:
Reaction workers read only committed data. Each workerName has its own persisted cursor, so handlers can be
restarted safely.
import type { CreateEventReactionWorkerOptions, EventReactionHandler, ReactionHandlerResult } from "@drillcoder/voryn";
import { EventReactionWorker } from "@drillcoder/voryn";
const dbUrl = "postgres://user:pass@localhost:5432/voryn";
const logLevel = "info";
const handler: EventReactionHandler = async (event): Promise<ReactionHandlerResult> => {
console.info("event_received", {
blockNumber: event.blockNumber,
transactionHash: event.transactionHash,
logIndex: event.index,
address: event.address,
});
return event.index === 10 ? "processed" : "skipped";
};
const options: CreateEventReactionWorkerOptions = {
chainId: 1,
workerName: "contract-events",
delayBetweenTicksMs: 500,
batchSize: 1000,
skipFlushInterval: 100,
logLevel,
dbUrl,
handler,
};
const worker = await EventReactionWorker.create(options);
await worker.start();Handlers may return "processed" or "skipped". Processed items advance the worker cursor immediately. Skipped
items are safe to advance too, but their cursor writes are batched by skipFlushInterval and flushed at
the end of the tick or before rethrowing a handler error.
A handler can be called more than once for the same item if it fails before returning a result, or if the worker stops before the cursor write for the item is persisted. Keep handler side effects idempotent.
Examples:
PipelineMetrics returns a pipeline snapshot: current RPC head, stage lag, data freshness, block job statuses, failed blocks, and reaction worker lag.
import { PipelineMetrics } from "@drillcoder/voryn";
const dbUrl = "postgres://user:pass@localhost:5432/voryn";
const metrics = await PipelineMetrics.create({
dbUrl,
chainIds: [1, 56],
rpcUrls: [
"https://mainnet-rpc.example.org",
"https://bsc-rpc.example.org",
],
});
const snapshot = await metrics.get();
const prometheusText = await metrics.getPrometheus();
await metrics.close();get()returns one aggregate snapshot with achainsarray.getPrometheus()returns one Prometheus text document for all configured chains. Serve it from your own/metricsendpoint.
Use BlockJobRecovery to manually put failed blocks back into processing.
Examples:
Voryn includes an adapter for ethers v6:
import { JsonRpcProvider } from "ethers";
import { EthersBlockSource } from "@drillcoder/voryn";
const provider = new JsonRpcProvider("https://rpc.example.org");
const source = await EthersBlockSource.create([provider]);Pass one provider per chain.
The adapter validates hashes, addresses, data fields, transaction indexes, and block number consistency. To use another data source, implement the BlockSource interface.
The stable public API is imported from the package root, @drillcoder/voryn. Internal paths are not part of the
stable API.
Main exports:
- workers:
HeadWorker,FetchWorker,SequencerWorker,RetentionWorker,EventReactionWorker,TransactionReactionWorker; - data and reactions:
PipelineBlock,PipelineTransaction,PipelineEvent,EventReactionHandler,TransactionReactionHandler; - infrastructure:
EthersBlockSource,ConsoleLogger,PostgresLeaderLock,PostgresTransactionManager; - PostgreSQL repositories and schema helpers;
- operational tools:
PipelineMetrics,BlockJobRecovery.
Commands are collected in dev/Makefile. Checks that require project dependencies should be run through the tools container:
make lint
make test
make buildFor the local development environment:
cp dev/.env.example dev/.env
make init
make ingestion-up