Protocol
Indexer & data
How chain events become rows, how per-token stats are computed, and how fresh the index is.
The app never fabricates data: every number is read from Robinhood Chain or projected from its events by workers/indexer.ts.
Sync loop
- State.
sync_state.last_blockis the last fully processed block; the first run starts atPINARC_START_BLOCK. - Registry. Load every known token from D1: curve → token, token set, and for graduated tokens the Uniswap pair (with
token0resolved by multicall so swap direction can be decoded). - Walk. Fetch logs in chunks of 2,000 blocks from the factory, every curve and token, the locker, vault, bond and floor contracts, and every known pair. Each run has a time budget (25 s from the cron / an authenticated poke, 8 s for an unauthenticated poke) and stops cleanly at a chunk boundary.
- Project. Decode with the contract ABIs and apply the rules below inside one D1 batch per chunk.
- Refresh. Recompute derived stats for every token touched in the chunk (and structurally changed ones) with a multicall + SQL pass.
- Metadata. Fetch and sanitise the metadata document for new or updated
metadataURIs (own documents are read straight from D1).
Event → projection
| Event | Effect on D1 |
|---|---|
PinarcFactory.TokenCreated | Insert tokens row (status batch), snapshot the curve's parameters via multicall, record bond / dev buy / team allocation |
Launched | batch_ends_at |
BatchCommitted | Upsert batch_commits, batch_usdg |
BatchSettled | batch_settled = 1, status batch → live, clearing price, tokens out, refund; one trades row with venue batch |
BatchClaimed | Insert batch_claims |
Trade | Insert trades (venue curve), set price_usdg, last_trade_at; set creator_sold = 1 when the trader is the creator and isBuy = false |
Graduated | status graduated, pair, lp_lock_id (or burned), lp_usdg / lp_tokens, add floorUsdg to floor_reserve_usdg, liquidity_usdg = 2 × usdgLiquidity |
Token Transfer | Update holders balances for from and to |
Token MetadataUpdated | Refetch the document; update name/description/image/socials |
LPLocker.Locked / Extended / Withdrawn / LockTransferred | Upsert locks |
VestingVault.ScheduleCreated / Released | Upsert vesting |
CreatorBond.Posted / Released / Slashed | bond_usdg, bond_status |
FloorReserve.Deposited / Redeemed | floor_reserve_usdg |
Uniswap pair Swap (+ Sync) | Insert trades with venue dex (direction from token0), set price_usdg, liquidity_usdg = 2 × USDG reserve, creator-sold flag |
Per-token refresh
For each touched token, one multicall reads price, usdgRaised, tokensSold, progressBps, creatorFeesEarned, settled, batchUsdg, FloorReserve.reserveOf and, once graduated, the pair's getReserves / token0 (DEX price = USDG reserve ÷ token reserve). Then one SQL statement derives:
| Column | Definition |
|---|---|
price_usdg | DEX price after graduation, curve spot before |
mcap_usdg | price_usdg × total_supply |
liquidity_usdg | 2 × USDG in the pool after graduation; usdg_raised before |
curve_progress | progressBps / 100; forced to 100 once graduated |
holders | Count of holders rows with balance > 1e-6, excluding the curve, the pair, the vault, the locker and the dead address |
volume_24h, buys_24h, sells_24h | From trades with ts ≥ now − 86400 |
change_1h, change_24h | price_usdg / price of the last trade at or before (now − window) − 1 |
top10_pct | Sum of the 10 largest non-structural balances ÷ total_supply |
creator_pct | Creator's balance ÷ total_supply |
Freshness and triggering
- Cron: every minute.
- Page views: when a page loads and the index is more than 45 s behind the chain, the worker starts a lock-protected background sync (one at a time), so freshness does not depend on the cron alone.
POST /api/v1/sync: after every confirmed transaction the app pokes it; unauthenticated pokes are throttled to one per 5 s (8 s budget);Authorization: Bearer <SYNC_SECRET>allows a 25 s pass.GET /api/v1/healthreturnslast_blockandsynced_at; compare with the chain head to see lag.
Reorgs and idempotency
Robinhood Chain (Arbitrum stack) gives fast finality; the indexer processes only blocks up to the head returned by the RPC and writes each chunk as one D1 batch keyed by (tx_hash, log_index), so re-running a range is idempotent.
Run your own
pinarc-indexer is the open-source reference implementation of this pipeline (Node + SQLite, no native dependencies): pinarc-indexer sync --follow replays the chain from block 65,381,613 into a local database with an event audit log, and pinarc-indexer serve exposes the same read model. Its numbers should match this app's; if they ever don't, that is a bug worth reporting.
RPC endpoints
The indexer reads through an ordered fallback list (RPC_URL holds a comma-separated list) and moves to the next endpoint when one fails or rate-limits. In production the order is rpc.ordofi.network → rpc.mainnet.chain.robinhood.com → robinhood-rpc.publicnode.com: the official RPC answers eth_getLogs from Workers with 429 under load, and dRPC's free tier refuses log ranges, so neither can be the only source. The browser uses its own ranked list (RPC_URL_PUBLIC) and prefers the fastest endpoint that answers. GET /api/v1/rpc-probe (authenticated) reports the current health of each endpoint.