Skip to content

Architecture

flowchart LR
  subgraph chains[Archive RPC]
    eth[(Ethereum)]
    bsc[(BNB Chain)]
  end
  subgraph indexer[loop-indexer · hourly]
    plan[plan missing<br/>grid points] --> blocks[timestamp → block]
    blocks --> mc[Multicall3<br/>1 eth_call / hour / chain]
  end
  chains --> mc
  mc -->|idempotent upsert| pg[(Postgres · Supabase<br/>loop.* tables + views)]
  pg -->|append-only, cursor| dune[(Dune<br/>dune.ns.loop_*)]
  pg -->|SQL views| dash[Dashboard<br/>Next.js · Vercel]
  pg -->|export static| json[[static JSON]] --> dash
Piece Where Runs on
Indexer (Python) indexer/ GitHub Actions cron, or loop-indexer schedule on a VPS (vps.md)
Schema + views supabase/migrations/ Supabase, or the VPS Postgres container (any Postgres ≥ 14)
Dune export loop-indexer export dune same job, after indexing
Dune queries dune/queries/ Dune
Dashboard dashboard/ Vercel, or the Docker image behind Caddy on a VPS

Decisions

1. Snapshot contract state, not events

Most of what matters for a lending market is only available from view functions: accrued interest (expectedLiquidity), the interest-rate model output (baseInterestRate, supplyRate) and oracle prices (spotPrice). Reconstructing these from events would mean re-implementing the protocol's math. Reading the state at a block is exact by definition.

Anything that is naturally event-shaped (individual deposits, liquidations) can still be built on Dune from decoded logs and joined to these tables on ts.

2. One UTC grid for every chain

Every chain is sampled at the same instants (top of each hour, UTC). The block for an instant is the last block with timestamp <= instant, so a row means "the state as of 14:00 UTC". Ethereum (12 s blocks) and BNB Chain (3 s → 1.5 s → 0.75 s blocks over the period) therefore line up and can be joined without interpolation. The legacy collector sampled every N blocks, which drifted against wall-clock time and changed cadence whenever N was edited.

Timestamp → block uses the BlockResolver. It interpolates from the nearest known blocks, then fetches a 16-header window around the guess plus "fence" probes at ±64, ±512 and ±4,096 blocks, all in one JSON-RPC batch. If the window misses because block rates drift, the fences have still narrowed the bracket enough for an exact secant guess on the next round, instead of a long bisection. Every snapshot's block becomes an anchor for the next one. Measured on mainnet: about 1 round trip per hourly step, 2 per daily step, and 8–10 for a cold start.

3. Multicall3, one call per snapshot

All active series on a chain (83 on Ethereum, 31 on BNB Chain) are read in a single aggregate3 eth_call with allowFailure = true. A reverting call (a stale oracle, for example) fails only that series and is recorded in snapshots.failed_series. The indexer never writes a fabricated zero.

4. Long fact table + views

loop.observations holds one row per (series, hour): the exact uint256 as numeric(78,0) plus the scaled value. Wide, analysis-friendly shapes are SQL views (market_hourly, vault_hourly, daily rollups). The consequences:

  • adding a metric or a vault is a registry change, never a migration;
  • two markets cannot drift into different column layouts (the legacy CSVs had);
  • derived metrics (utilization, LTV, share prices) are defined once, in SQL.

5. Registry as code, verified on-chain

markets.yaml lists contracts, tokens and deployment blocks, and nothing else. The metrics are generated per entity type, so every pool gets the same 7 series and every vault the same 5. loop-indexer registry verify re-reads every fact from the chain: decimals, asset() and token() links, deployment blocks, and the pool's creditManagers() list. It runs weekly in CI, so a vault added to a pool fails loudly instead of going untracked (the legacy config missed 10 of 16 vaults).

6. Idempotent, self-healing runs

Each run computes every grid point since deployment that has no snapshot and fills it, so a failed hour, an RPC outage or a week of downtime is repaired by the next successful run. All writes are upserts. A re-sample that reads identical values leaves ingested_at unchanged, so nothing is re-exported. A run that could not fill every point exits non-zero, which fails the workflow and notifies you.

Adding a metric or a vault backfills itself. Each series records when it was registered (series.created_at). Snapshots taken before that are re-read at their stored block: one multicall each, with no timestamp-to-block resolution. Adding the two liquidation-parameter series back-filled 1,325 daily snapshots on both chains in under two minutes.

7. At-least-once delivery to Dune

The Dune exporter pages through observations in (ingested_at, series_id, ts) order and advances a cursor only after Dune acknowledges a batch. A crash can re-send at most one batch, and the Dune queries de-duplicate on (series_id, ts) by latest ingested_at. export dune --full clears and reloads the table.

8. A dashboard that works without a database

The dashboard reads Postgres views when DATABASE_URL is set. Otherwise it reads a static JSON snapshot written by loop-indexer export static. That keeps preview deployments, forks and the public demo working with no secrets, and both sources return identical shapes.

Failure modes

Failure Behaviour
RPC rate limit / 5xx / timeout exponential backoff with jitter, rotate to the next endpoint
Non-archive node ("missing trie node") treated as an endpoint failure → rotate
Call reverts that series is skipped for that hour; listed in failed_series
Whole hour unreadable hour stays missing, run exits 1, next run retries it
Dune 429 / 5xx retried; cursor not advanced until a batch is acknowledged
Dune short write run fails; cursor not advanced
Indexer down for days next run backfills every missing hour
New vault added on-chain weekly registry verify fails with the new address