Skip to main content
Canopy emits a complete, self-contained set of on-chain events for every state transition — offer creation, partial fills, cancellations, series creation, claim issuance (via fills), and redemptions. Because all state changes are observable through these events, you can build a reliable, fully-reconstructible market data service by ingesting them from the deployment block forward without making repeated contract storage reads. This guide covers the architecture of a Canopy indexer, the key events you need to handle, sample ingestion code, reorg handling, and patterns for serving order book data to frontends and analytics dashboards.

Architecture Overview

A production-grade Canopy indexer consists of three loosely coupled layers:
  1. Ingestion layer — polls the RPC node using eth_getLogs (or subscribes via WebSocket) to fetch Canopy events in chronological block order and writes raw event records to a local store.
  2. State layer — applies each event as a delta to a structured offer and series table, maintaining derived fields like status, assetFilled, and assetRemaining so downstream consumers never need to re-derive state themselves.
  3. API layer — exposes a REST or GraphQL interface that queries the state layer and returns pre-computed order book snapshots, series metadata, and fill history to frontends and analytics tools.
For small markets or development environments, the ingestion and state layers can live entirely in memory. For production, use a persistent store keyed by offerId and seriesId.

Key Events to Index

Handle each of the following events to maintain complete and accurate state:
EventAction
OfferCreatedInsert offer record with escrowedAssetAmount; set assetFilled = 0, status = OPEN
OfferFilledIncrement assetFilled by assetUnits; set status = FILLED when assetFilled == escrowedAssetAmount
OfferCancelledSet status = CANCELLED; store assetDebited and assetReceived
SeriesCreatedInsert series record with claimToken address, asset, and unlockTime
RedeemedDecrement series.backing by amount; record assetReceived
All event parameters needed for full state reconstruction are included in the event payloads themselves. You do not need to call offers() or series() contract reads during initial sync — the events are self-contained, which significantly speeds up historical backfill.

Sample Indexer Loop

The following TypeScript snippet shows a block-range ingestion function that processes OfferCreated events and upserts them into a database. Extend the same pattern to cover the remaining event types listed above.
const DEPLOY_BLOCK = 1000000; // replace with actual deployment block

async function indexEvents(fromBlock: number, toBlock: number) {
  const created = await venue.queryFilter(
    venue.filters.OfferCreated(), fromBlock, toBlock
  );
  for (const e of created) {
    await db.upsertOffer({
      offerId: e.args.offerId,
      seller: e.args.seller,
      asset: e.args.asset,
      quote: e.args.quote,
      escrowedAssetAmount: e.args.escrowedAssetAmount,
      assetFilled: 0n,
      pricePerUnit: e.args.pricePerUnit,
      expiry: e.args.expiry,
      unlockTime: e.args.unlockTime,
      allowedBuyer: e.args.allowedBuyer,
      status: "OPEN",
    });
  }
  // repeat for OfferFilled, OfferCancelled, SeriesCreated, Redeemed
}
To run a continuous sync, call indexEvents in a loop — advancing fromBlock by your chosen chunk size on each iteration — until you reach the chain head, then switch to polling or subscribing for new blocks.
For public RPC endpoints that enforce response size limits, keep your block range window to 2,000–10,000 blocks per call. For a dedicated node or a node provider with higher limits, you can use larger windows to speed up historical backfill.

Handling Reorgs

Chain reorganizations can invalidate events you have already processed. To handle them gracefully:
  • Store the blockNumber and transactionHash alongside every event record when you ingest it.
  • On each new polling cycle, compare the latest stored block hash against the current canonical chain. If they diverge, you have detected a reorg.
  • Roll back all event records with blockNumber greater than the last stable (finalized) block, then re-index from that point forward.
For Robinhood Chain (Arbitrum Orbit stack), check the finality guarantees of the network to determine a safe confirmation depth below which reorgs are negligible.

Serving Market Depth

Once your state layer is populated, computing an order book for a trading pair is a straightforward query:
  1. Filter all offers where asset == targetAsset AND quote == targetQuote AND status == OPEN AND expiry > now.
  2. Compute assetRemaining = escrowedAssetAmount - assetFilled for each matching offer.
  3. Sort by pricePerUnit — ascending for asks, descending for bids — to produce depth ladders.
  4. Optionally aggregate offers at the same price level into a single depth entry for a traditional order book display.
Expose this computation as a cached endpoint (refreshed on each new block) so frontends can request the current book without re-running the aggregation themselves.
You can run a lightweight in-memory indexer for small markets or during development — no database required. For production deployments handling multiple trading pairs or high fill frequency, migrate to a persistent store such as PostgreSQL or SQLite keyed by offerId, and add indexes on (asset, quote, status) for fast pair lookups.