Skip to main content

Raven House: a private NFT marketplace on Aztec

Category
Web3
Year
2024

A private NFT marketplace on Aztec Network, built solo. Ownership and balances live in encrypted notes, so the interface cannot simply read state: every view is reconstructed. Aztec ships no hosted event indexer either, so I wrote one in Bun + MongoDB + Fastify to serve those reads.

Raven House NFT Marketplace screenshot
On this page (6)

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. The frontend was the job: a Next.js 15 marketplace where ownership and balances live in encrypted notes, so no view can simply read state off the chain. I built that, the escrow-contract integration through the Aztec.js client, and the Supabase layer for off-chain metadata.

The indexer came with it. Aztec ships no hosted one, so serving those reads meant writing the event indexer, the MongoDB schema, and the Fastify API myself. It was not the part I set out to build, but the interface does not work without it.

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.

indexer/functions/lib/index.ts
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:

indexer/functions/shared/getPublicEvents.ts
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. Aztec has no mainnet yet, so this ships on its public testnet; the escrow contract is verifiable on AztecScan. The indexer became the foundation I reused for Red Sentinel, and I wrote up how it works in detail in a separate post.

More Projects