How I built a custom Aztec event indexer in Bun and MongoDB
Aztec ships no hosted event indexer, so a marketplace has to build its own. Here is the full design: matching raw logs by event selector, cursor pagination, a bounded parallel fan-out, and idempotent, crash-safe writes.
If you build on Ethereum and you want "all the events from this contract," you reach for a subgraph or a hosted indexer and move on. On Aztec you do not have that option yet. Aztec is a privacy-first L2 where a lot of state lives in private notes, and the hosted indexing infrastructure that the EVM world takes for granted mostly does not exist.
When I built Raven House, a private NFT marketplace on Aztec, that gap became my problem to solve. The marketplace needs to show active listings, offers, sales, and ownership, and none of that can come from a subgraph that is not there. So I wrote the indexer. This is how it works, with the real code.
What an indexer actually has to do#
Strip away the buzzwords and an event indexer is a loop:
- Ask the node for events in a range of blocks.
- Decode them into typed records.
- Write them somewhere you can query fast.
- Remember how far you got, so next time you start there.
Every hard part hides inside those four steps. On Aztec, step 1 is harder than it sounds, step 3 has to be idempotent or you corrupt your own data, and step 4 has to survive a crash.
Reading events: you match the selector yourself#
The first surprise: reading a public event out of Aztec is not "give me all ListingSold
events." You pull raw logs and identify the event yourself, because raw log emission in
aztec.nr is not enshrined. The event selector lives in the log fields, and you compare it
against the selector of the event you are looking for:
const eventMetadataDef = NFTContract.events.ListingSold
const expectedLength = eventMetadataDef.fieldNames.length + 1 // +1 for the selector
const logFields = log.log.log.slice(0, expectedLength)
const selector = logFields[logFields.length - 1]
if (!EventSelector.fromField(selector).equals(eventMetadataDef.eventSelector)) {
return undefined // not the event we want
}
return decodeFromAbi([eventMetadataDef.abiType], log.log.log)Once you know the shape, the reliable way to pull a full range is to walk the node's event cursor until it is exhausted. This helper is generic over the event type, and it tags each decoded event with the L2 block number it came from, which I need later to attach timestamps:
export async function fetchContractEvents<T extends Record<string, unknown>>({
aztecNode, contractAddress, event, fromBlock, toBlock,
}: FetchArgs): Promise<IndexedEvent<T>[]> {
const indexedEvents: IndexedEvent<T>[] = []
let afterEvent: EventCursor | undefined = undefined
do {
const page = await getPublicEventsPage<T>(aztecNode, event, {
contractAddress,
fromBlock: BlockNumber(fromBlock),
toBlock: BlockNumber(toBlock),
afterEvent,
})
for (const { event: decoded, metadata } of page.events) {
indexedEvents.push({
...decoded,
blockNumber: Number(metadata.l2BlockNumber),
contractAddress: contractAddress.toString(),
})
}
afterEvent = page.nextCursor
} while (afterEvent !== undefined)
return indexedEvents
}Speed without abuse: a bounded parallel fan-out#
The naive loop, one contract and one event type at a time, could not keep up. A marketplace has many NFT collections, and each one needs its transfers and metadata updates, on top of the escrow's six listing and offer events. Serial fetching over that many contract-event pairs misses a 25-second polling cadence easily.
The fix is a bounded fan-out. Two dials, pulling in opposite directions:
- The block window is the throttle. I only ever process
BLOCK_RANGE = 14blocks per poll. That caps how much work a single tick can create, which keeps me under the node's rate limits. - The fetches inside the window are the speed. Within those 14 blocks, every contract-and-
event fetch fires concurrently with
Promise.all, including a nestedPromise.allacross all NFT collections. Oneawait, everything in flight at once.
const [
listingCreatedEvents,
listingSoldEvents,
listingCancelledEvents,
offerCreatedEvents,
offerAcceptedEvents,
offerCancelledEvents,
nftEventsPerCollection,
] = await Promise.all([
fetchEscrowEvent(NFTEscrowContract.events.ListingCreated),
fetchEscrowEvent(NFTEscrowContract.events.ListingSold),
fetchEscrowEvent(NFTEscrowContract.events.ListingCancelled),
fetchEscrowEvent(NFTEscrowContract.events.OfferCreated),
fetchEscrowEvent(NFTEscrowContract.events.OfferAccepted),
fetchEscrowEvent(NFTEscrowContract.events.OfferCancelled),
Promise.all(
nftCollectionAddresses.map(async (address) => {
const contractAddress = AztecAddress.fromStringUnsafe(address)
const [metadataUpdates, transfers] = await Promise.all([
fetchContractEvents({ aztecNode, contractAddress, event: NFTContract.events.MetadataUpdate, fromBlock, toBlock }),
fetchContractEvents({ aztecNode, contractAddress, event: NFTContract.events.NFTTransfer, fromBlock, toBlock }),
])
return { metadataUpdates, transfers }
}),
),
])Writing safely: idempotent upserts and a pointer you move last#
Here is the rule that keeps the data correct: advance the "last indexed block" pointer only after every write has committed. Persist first, then move the pointer.
// Every store.addEvents(...) is an upsert keyed on the event's natural identifiers.
await Promise.all(promises)
// Only now do we remember we processed this window. If anything above threw,
// the pointer stays put and the same blocks are retried on the next tick.
await listingCreatedStore.setLastIndexedBlockNumber(mode, toBlock)Because the pointer moves last, a crash mid-poll simply means the next tick reprocesses the same window. That is only safe if reprocessing is harmless, which is why every write is an upsert keyed on the event's natural identifiers (token id, contract, block). At-least-once delivery plus idempotent writes gives you exactly-once results without distributed transactions. This is the same discipline I later reused on Red Sentinel.
Keeping the loop alive#
The runtime details are boring and they matter:
- A cron tick, not a tight loop. The indexer runs on a
*/25 * * * * *schedule. - A single-instance guard. A
Setof running modes means a slow tick never overlaps with the next one and double-processes a window. - Connection pooling and graceful shutdown. One pooled MongoDB connection, closed cleanly
on
SIGINT, so a deploy does not sever an in-flight write.
const runningInstances = new Set<string>()
async function runAllHandlers(mode: string) {
if (runningInstances.has(mode)) return // previous tick still working; skip
runningInstances.add(mode)
try {
await handler(mode)
} finally {
runningInstances.delete(mode)
}
}What I would tell past me#
- The chain is the source of truth, but your app should almost never read from it directly. Put an indexer in front and let the UI stay fast.
- Design for at-least-once from line one. Idempotent writes are not a nice-to-have; they are the thing that lets you crash and recover without babysitting.
- A fixed block window is the single best knob you have. It bounds cost, memory, and blast radius all at once.
The indexer is the piece of Raven House I am proudest of, because it is infrastructure the ecosystem was missing and I did not wait for someone else to build it. If you are building on Aztec and stuck on the same wall, this is a well-worn path now.
The full marketplace is live at app.ravenhouse.xyz, and the case study has the architecture end to end.
Written by Yash Mittal. If this was useful, I'm on X and open to a conversation.