Raven House: a private NFT marketplace on Aztec
A privacy-preserving NFT marketplace on Aztec's ZK rollup.
- Role
- Solo full-stack
- Year
- 2024
- Category
- Web3
- Stack
- Next.js 15, Aztec.js, Bun
0x00d1f1…085d
The problem#
Aztec is a privacy-first Ethereum L2. Balances, ownership, and a lot of contract state live in private notes that only the owner can decrypt. That is exactly what you want for a marketplace where people would rather not broadcast their whole collection to the world, and exactly what makes building one hard: you cannot just query a subgraph for "all active listings," because a general-purpose hosted indexer for Aztec does not exist yet.
So Raven House needed two things that normally come for free on Ethereum: a way to reconstruct public marketplace state from on-chain events, and a fast API to serve it to the frontend. I built both, solo, along with the app itself.
My role#
Solo full-stack. I owned the Next.js 15 frontend, the escrow-contract integration through the Aztec.js client, the event indexer, the MongoDB schema and the Fastify API on top of it, and the Supabase layer for off-chain metadata. When I say I own the stack from the contract to the cursor, this is the project I mean.
Architecture#
Aztec testnet Indexer (Bun) App (Next.js 15)
┌────────────────┐ ┌───────────────────────┐ ┌────────────────────┐
│ Escrow │ │ cron: every 25s │ │ /marketplace │
│ ListingCreated│─────▶│ fetch 14 blocks │ │ listings, offers │
│ ListingSold │ │ Promise.all fan-out │─────▶│ owned NFTs │
│ OfferCreated │ │ decode + normalize │ │ │
│ NFT contracts │ │ upsert per event type│ │ Fastify API ◀────┤
│ NFTTransfer │ └──────────┬────────────┘ └────────────────────┘
│ MetadataUpdate│ │
└────────────────┘ ┌──────▼──────┐
│ MongoDB │ one collection per event type
└─────────────┘The indexer polls the node on a cron, decodes eight event types (ListingCreated,
ListingCancelled, ListingSold, NFTTransfer, OfferCreated, OfferAccepted,
OfferCancelled, MetadataUpdate), normalizes them, and upserts each into its own
MongoDB collection. The Fastify API reads those collections; the frontend never talks
to the chain for reads, so the marketplace stays fast even though the source of truth
is a ZK rollup.
The hardest decision#
The first working version indexed one contract, one event type, one block range at a time, sequentially. It was correct and unusably slow: every marketplace has many NFT collections, and every collection needs its transfers and metadata updates, on top of the escrow's six listing and offer events. Done in series, a single poll could not keep up with a 25-second cadence.
The decision was how much to parallelize without hammering the node into rate limits.
I settled on a bounded fan-out: process a fixed window of 14 blocks per poll, and
within that window fire every contract-and-event fetch concurrently with Promise.all,
including a nested Promise.all across all NFT collections. One await, everything in
flight, then a single pass to persist. The block window is the throttle; the fan-out is
the speed.
export const BLOCK_RANGE = 14
// Fetch every escrow event and every collection's NFT events concurrently.
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 }
}),
),
])The code that took the longest to get right#
Reading public logs out of Aztec is not "give me events of type X." You get raw logs and
have to match the event selector yourself, because raw log emission in aztec.nr is not
enshrined. The paginated reader below walks the node's event cursor until it is exhausted
and tags every decoded event with its L2 block number, which the persistence layer later
joins against block 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
}The other subtlety: the indexer advances its "last indexed block" pointer only after every persistence promise resolves. If a poll throws halfway through, the pointer stays put and the same block window is retried next tick. Reprocessing is safe because every write is an upsert keyed on the event's natural identifiers, so at-least-once delivery never produces duplicates.
Outcome#
Raven House runs live at app.ravenhouse.xyz with the marketplace reading entirely from the indexer, not the chain. The escrow contract is verifiable on AztecScan. The indexer became the foundation I reused for Red Sentinel, and the write-up of how it works is the most-read post on this site.