@moonwell-fi/lunar-indexer
v0.1.3
Published
TypeScript SDK for the Moonwell Lunar Indexer.
Downloads
485
Keywords
Readme
@moonwell-fi/lunar-data-sdk
Reorg-aware TypeScript SDK for building Lunar Indexers. Uses the latest viem client for chain access, zod for validation, and a pluggable storage adapter to persist raw events, projections, block history, and checkpoints. Ships as pure ESM with strict typing.
Installation
bun add @moonwell-fi/lunar-indexer viem zodCore Concepts
- Engine:
LunarIndexermanages live indexing, reorg detection/recovery, and replay. - Global trigger scanning:
GlobalIndexermanages permissive topic-driven trigger scans with checkpointing, optional selective raw-event archival, and no reorg handling. - Event definitions:
defineEventpairs a viem ABI with an optional zod schema for validated args. - Trigger definitions:
defineGlobalTriggerEventadds per-trigger decode behavior for noisy global scans. - Storage adapter:
LunarIndexerStorageAdapterlets you plug in any persistence (Postgres, KV, Durable Objects, etc.). - Reorg safety: Seam validation each loop + binary search rollback within a configurable window.
- Replay: Rebuild projections from the raw archive without hitting RPC.
Quick Start
import { http, createPublicClient, mainnet } from "viem";
import { z } from "zod";
import {
LunarIndexer,
defineEvent,
type LunarIndexerStorageAdapter,
type DecodedEvent,
} from "@moonwell-fi/lunar-indexer";
// 1) Define events with ABIs + zod schemas
const transferEvent = defineEvent({
name: "erc20-transfer",
abi: [
{
type: "event",
name: "Transfer",
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "value", type: "uint256", indexed: false },
],
},
],
eventName: "Transfer",
schema: z.object({
from: z.string(),
to: z.string(),
value: z.bigint(),
}),
});
// 2) Implement the storage adapter contract
const storage: LunarIndexerStorageAdapter = {
async getCheckpoint() {
return null; // TODO: load from DB
},
async saveIngestionBatch({ rawEvents, decodedEvents, blockHistory, newCheckpoint }) {
// TODO: persist atomically
},
async getBlockHash() {
return null; // TODO: return stored block hash
},
async deleteDataAfter() {
// TODO: rollback state at/after the divergence block
},
async saveProjectionBatch() {
/* TODO */
},
readRawEvents: async function* () {
/* TODO: yield stored RawEvent batches for replay */
},
};
// 3) Create a viem client
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL!),
});
// 4) Run the indexer
const indexer = new LunarIndexer({
indexerId: "my-indexer-v1",
client,
chainId: mainnet.id,
storage,
events: [transferEvent],
defaultStartBlock: 18_000_000n,
minConfirmations: 2,
reorgWindowSize: 128,
onBatchProcessed(info) {
console.log("batch", info);
},
});
await indexer.run();Replay projections
await indexer.replay({
projectionId: "balances-v2",
fromBlock: 15_000_000n,
toBlock: 16_000_000n,
batchSize: 500,
});Global Trigger Scanning
GlobalIndexer is for MAMO-style trigger discovery where you scan broad topic sets, tolerate unrelated topic collisions, and inspect receipts in application code after ingestion.
- It persists checkpoint progress so restarts resume from the last processed block.
- It does not do seam validation, block-history writes, or reorg rollback.
- It only persists raw trigger logs if the consumer explicitly marks emitted events during
onEventsIndexed. onEventsIndexedreceives controls forpersistEventIds(...)/persistEvents(...), so the consumer can keep only the accepted subset from a noisy batch.- It does not expose
replay(). - Mixed wildcard and address-filtered trigger configs are queried separately, so one wildcard trigger does not erase address filtering for the others.
import { http, createPublicClient, mainnet } from "viem";
import {
GlobalIndexer,
defineGlobalTriggerEvent,
type LunarIndexerStorageAdapter,
} from "@moonwell-fi/lunar-indexer";
const erc20Transfer = defineGlobalTriggerEvent({
name: "usdc-transfer",
abi: [
{
type: "event",
name: "Transfer",
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "value", type: "uint256", indexed: false },
],
},
],
eventName: "Transfer",
contractAddresses: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
decodeMode: "permissive",
});
const wildcardDeposit = defineGlobalTriggerEvent({
name: "mamo-deposit-trigger",
abi: [
{
type: "event",
name: "Deposited",
inputs: [
{ name: "owner", type: "address", indexed: true },
{ name: "amount", type: "uint256", indexed: false },
],
},
],
eventName: "Deposited",
decodeMode: "metadata-only",
});
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL!),
});
const storage: LunarIndexerStorageAdapter = {
async getCheckpoint() {
return null;
},
async getBlockHash() {
return null;
},
async deleteDataAfter() {},
async saveProjectionBatch() {},
async saveIngestionBatch() {},
readRawEvents: async function* () {},
async saveProgressBatch({ newCheckpoint }) {
// Persist checkpoint progress only.
},
};
const indexer = new GlobalIndexer({
indexerId: "mamo-global-triggers",
client,
chainId: mainnet.id,
storage,
events: [erc20Transfer, wildcardDeposit],
defaultStartBlock: 18_000_000n,
minConfirmations: 2,
includeBlockMetadata: false,
onEventsIndexed: async (events, controls) => {
const accepted = events.filter((event) => {
// After receipt inspection, keep only triggers your app actually owns.
return event.address.toLowerCase() ===
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
});
for (const event of events) {
// Fetch the tx receipt here and do strict strategy-specific filtering.
console.log(event.transactionHash, event.eventName, event.address);
}
controls.persistEvents(accepted);
},
});
await indexer.run();Storage Adapter Contract (summary)
getCheckpoint→ return last{ blockNumber, blockHash, chainId }ornull.saveIngestionBatch→ atomic write ofrawEvents,decodedEvents,blockHistory, andnewCheckpoint; idempotent by event IDs.saveProgressBatch→ optional checkpoint-only write path for scanners such asGlobalIndexerwhen no raw events from the batch were selected for persistence.getBlockHash→ used in reorg binary search.deleteDataAfter→ remove all raw/derived/block-history/checkpoint data at or after a block when a reorg is detected.readRawEvents→ ordered(blockNumber ASC, logIndex ASC)async iterable for replay.saveProjectionBatch→ idempotent derived writes keyed by(indexerId, projectionId, blockNumber, logIndex).
Configuration Notes
defaultStartBlock: starting block if no checkpoint.batchSize: number of blocks pergetLogsrequest (default 1000).minConfirmations: index up tohead - minConfirmations(default 0).liveProjectionId: projection key for live ingestion (defaultdefault).pollIntervalMs: sleep when caught up to tip (default 1000).includeBlockMetadata: enrich emittedGlobalIndexerevents with block metadata such astimestamp. Default:false. Keep this disabled for high-volume trigger scanners that only needtransactionHash,blockNumber,logIndex,address, and optional decoded args.blockConcurrency: parallelism for per-block hash/timestamp lookups (default 5). ForGlobalIndexer, this only matters whenincludeBlockMetadatais enabled.logLevel: logging verbosity for the built-in logger (silent,error,warn,info,debug). Default:info.
GlobalIndexer is optimized for a thin getLogs -> permissive match -> callback -> selective raw write -> checkpoint loop. Implement saveProgressBatch in storage if you want the cheapest checkpoint-only persistence path for batches where nothing was selected.
Import Alias
Local development uses @/ → src/ (configured in tsconfig.json and tsup.config.ts). The linter forbids relative imports inside src to keep the alias consistent.
Scripts
bun run build– bundle with tsup and emit d.tsbun run dev– watch & rebuildbun run lint/bun run format– Biome linting/formatting (fixes on write)bun run typecheck– strict type checkingbun run changeset– create a version entrybun run release– publish (workflow is manual-triggered)
Releasing
Changesets drives versioning and publishing. The GitHub Actions workflow is manual-only (workflow_dispatch); trigger from the Actions tab when you're ready, with NPM_TOKEN configured in repository secrets.
