Indexer: live podium predictions from Postgres per category/epoch (GET /v1/arena/podiums)
Summary
Complete the #254 (closed) gap: while the arena is live, GET /v1/arena/podiums must return top-3 winner slots and scores derived from indexed Postgres (per category and current epoch), not from head TimeArena.podium(category) RPC alone. Consumers (Simple /arena, Arena views, play skills) use this route when VITE_INDEXER_URL is set.
Related: #254 (closed) (schema + HTTP baseline), #238 (closed) (epic), #247 (closed) (independent podium epochs), #252 (closed) (WarBow), frontend PODIUM_CONTRACT_CATEGORY_INDEX (#256 (closed)).
Current codebase
HTTP (GET /v1/arena/podiums)
- Handler:
indexer/src/api_arena.rs(arena_podiums). - Winners / values come from the in-memory chain-timer snapshot (
podium_contract[0..3]), which is populated by head RPCpodium(cat)inindexer/src/chain_timer.rs. - Indexed data used today: only
MAX(epoch)per category fromidx_arena_podium_epoch(rolled epochs). Response always setspodium_prediction: truewithout building leaders from the DB. - Row category labels follow onchain index order (
last_buy,time_booster,defended_streak,warbow), not the UX order the frontend expects when mapping rows by array index.
Frontend contract
frontend/src/pages/arena/usePodiumReads.ts— with indexer enabled, mapsresponse.rows[0..3]directly to UI slots.- UX order is defined in
frontend/src/pages/arena/podiumCopy.tsx: Last Buy · WarBow · Defended Streak · Time Booster viaPODIUM_CONTRACT_CATEGORY_INDEX = [0, 3, 2, 1](onchain cat indices). frontend/src/lib/indexerApi.tsdocumentspodium_predictionfor live sale windows but the indexer does not yet populate live leaders from events.
Indexed inputs (partial)
| Onchain category | Index | Leaderboard metric (live) | Indexed today |
|---|---|---|---|
| Last Buy | 0 | Last 3 distinct buyers in current lastBuyEpoch (buyCount) |
idx_arena_buy (no epoch column; no order index) |
| Time Booster | 1 | totalEffectiveTimerSecAdded[buyer] |
not projected |
| Defended Streak | 2 | bestDefendedStreak[buyer] |
not projected |
| WarBow | 3 | battlePoints[buyer] in current podiumEpoch[3] |
partial via idx_warbow_epoch_score (post-log eth_call snapshots) |
Settlement history: idx_arena_podium_epoch / view idx_arena_podium_snapshot — rolled epochs only, not live boards.
Product / onchain authority
- Scoring rules:
contracts/src/arena/TimeArena.sol(_updateTopThree,_applyBuyWarBowBp, defended-streak hooks). Spec:docs/product/arena-v2.md,docs/product/time-arena.md. - Guardrail: onchain state is authoritative; the indexer is a derived read model only (
docs/architecture/overview.md, yieldomega guardrails). Predictions must match chain at the same block tag, not invent alternate rules.
Stale design note
docs/indexer/design.md (TimeCurve podiums section) still describes live DB predictions during !sale_ended; Arena v2 implementation was never completed — this issue tracks that work.
Why this is needed
- #254 (closed) acceptance explicitly requires
GET /v1/arena/podiums: live predictions per category per epoch. #254 (closed) remains open on this slice. - WarBow UX: head
podium(3)is often empty mid-epoch while BP exists onchain; live sale UI must show indexed leaders (docs/indexer/design.md). - Agent / API consumers (
skills/play-active-time-arena) need a single HTTP source aligned with event history, not an extra RPC fan-out per page load. - Row ordering: indexer response order must align with frontend UX slots or expose explicit
category+ stable sort so Simple/Arena do not swap WarBow and Time Booster.
Constraints and guardrails
- AGPL repo; keep logic modular (
docs/licensing.md). - No new authoritative business rules offchain — replicate onchain leaderboard inputs at ingest or read with block-tagged eth_call snapshots (same pattern as
indexer/src/warbow_score.rs). Prefer event-sourced projections where events carry enough data; use bounded RPC only where logs omit scores. - One SQL transaction per block for ingest (#140 (closed)); reorg rollback must include new tables (
indexer/src/reorg.rs). - Epoch scope:
- Last Buy leaders scoped to head
lastBuyEpoch(from chain timer /LastBuyEpochStarted). - Categories 1–3 scoped to head
podiumEpoch[cat](RPC oridx_arena_podium_epoch+ 1 for live window).
- Last Buy leaders scoped to head
- Arena v2 only — no TimeCurve tables or
sale_endedgating (arena is live untilpaused). - After
rollPodiumEpoch: live board for that category clears onchain; API may return empty slots or fall back to settledidx_arena_podium_epochrow for the just-finished epoch — document chosen behavior in API + invariants. - Performance:
GET /v1/arena/podiumsshould stay O(1) or indexed top-3 queries, not full-table scans; target p95 acceptable on Anvil-rich dataset (see #255 (closed) wallet-stats note). - Schema version: bump
SCHEMA_VERSIONinindexer/src/api.rsand extendfrontend/src/lib/indexerApi.tstypes.
Relevant files
Recommended direction
- New projection table(s) (name TBD, e.g.
idx_arena_podium_liveor per-cat tables) storing, per (category, epoch, player): score, optional tie-break fields,block_number,updated_at— updated on ingest whenBuy, WarBow PvP logs, orLastBuyEpochStartedaffect leaders. - Ingest enrichment module (e.g.
indexer/src/arena_podium_live.rs):- On each qualifying log at block
B, eth_call atBthe same views TimeArena uses:buyCount,totalEffectiveTimerSecAdded,bestDefendedStreak,battlePoints(WarBow may reuse/extendwarbow_score.rs). - Alternatively, port
_updateTopThreemath in Rust only if every input is recoverable from indexed events without drift — prefer snapshot-at-block when in doubt.
- On each qualifying log at block
arena_podiumsread path:- Read head
last_buy_epoch+podium_epoch[cat]from chain timer (extend snapshot if missing). - For each UX-ordered row (
PODIUM_CONTRACT_CATEGORY_INDEX), query top-3 from Postgres for (cat, current_epoch). - Set
podium_prediction: truewhen rows are DB-derived; setfalsewhen intentionally mirroring RPC (e.g. post-roll settled-only mode, if implemented). - Return rows in UX order (or add
category_index+ sort; updateusePodiumReadsif needed).
- Read head
- Optional:
GET /v1/arena/podiums?category=warbowlater — out of scope unless trivial; single route is enough for v1. - Close #254 (closed) only after this issue’s verification criteria pass.
Acceptance criteria
- New migration(s) for live podium projection(s); included in
ARENA_INDEX_TABLES/rollback_after. - Ingest persists live leaderboard state for all four categories on relevant events (at minimum every
Buy+ WarBow BP mutations). -
GET /v1/arena/podiumsreturns top-3winners+valuesfrom Postgres for the current epoch per category while the arena is live. - Response includes correct
epoch(string decimal) per row matching onchainpodiumEpoch/lastBuyEpochsemantics. - Rows are ordered for frontend UX (Last Buy, WarBow, Defended Streak, Time Booster) or frontend remaps by
categorywithout slot swap. -
podium_prediction: trueonly when values are DB-derived; document any RPC fallback. - WarBow mid-epoch: non-zero leaders when
idx_warbow_epoch_score/ live table has data, even if headpodium(3)is empty. -
SCHEMA_VERSIONbump +indexerApi.tstypes updated. -
INV-INDEXER-PODIUM-PREDICT-LIVE(or successor) added/updated indocs/testing/invariants-and-business-logic.md; cross-linkdocs/indexer/design.md. -
integration_stage2.rscovers HTTP body shape + persist counts for at least two categories (e.g. Last Buy order + WarBow BP).
Test plan — functional paths
| # | Path | Expected |
|---|---|---|
| 1 | Empty DB, chain timer ready | GET /v1/arena/podiums → 200, four rows, zero/empty winners, epoch from head RPC |
| 2 | Single buyer, one DOUB buy | Last Buy row shows buyer in 1st; buyCount score matches onchain at buy block |
| 3 | Two buyers, same lastBuyEpoch |
Last Buy top-3 order = last-three distinct buyers (3rd → 2nd → 1st buy order) |
| 4 | Buys adding timer seconds | Time Booster top entrant matches totalEffectiveTimerSecAdded leader at head block |
| 5 | Streak buys under 15m | Defended Streak leader matches bestDefendedStreak at head block |
| 6 | WarBow buys + steal | WarBow top-3 matches battlePoints for current podiumEpoch[3] |
| 7 | LastBuyEpochStarted / hard reset |
Last Buy board resets for new epoch; prior-epoch buys not in live top-3 |
| 8 | PodiumEpochRolled for cat 1 |
Live Time Booster board clears or switches to new epoch per documented API rule |
| 9 | Reorg rollback | Live rows for reorged blocks removed; API consistent with surviving chain |
| 10 | chain_timer unavailable |
503 chain_timer_unavailable (unchanged) |
| 11 | Frontend with VITE_INDEXER_URL |
Simple podium cards show same addresses as cast call podium(cat) at same block (manual or scripted) |
Commands: YIELDOMEGA_PG_TEST_URL=… cargo test --test integration_stage2; local stack bash scripts/start-local-anvil-stack.sh + indexer; optional Playwright arena podium specs if present.
Test plan — attack, hack, and abuse vectors
| Vector | Risk | Mitigation / test |
|---|---|---|
| SQL injection via query params | Low (read-only route today) | Keep parameterized SQL; no raw address concatenation |
| Oversized / malicious addresses in stored rows | Display confusion | Store lowercase 0x + 40 hex; API validates format; ignore malformed rows in top-3 |
| Reorg double-spend of leaderboard | Stale winners shown | rollback_after deletes live rows; integration reorg test |
| Indexer drift vs chain | Wrong payout preview | Block-tagged snapshots at ingest; golden test: compare API vs cast call at read_block_number |
| RPC manipulation (indexer operator) | False leaders | Document trust model; compare against user RPC on dispute; do not accept client-supplied scores |
DoS via heavy GET /v1/arena/podiums |
DB CPU | Index (category, epoch, score DESC); limit work to top-3 per cat; rate limit at reverse proxy (ops) |
| WarBow BP generation bump | Stale BP from prior epoch | Tie snapshots to warbowBpGeneration / epoch; test epoch roll clears live WarBow table |
| Self-referral / sybil buys | Game design, not indexer | No special casing; ensure buyCount not inflated by indexer bugs (one row per log) |
| Flash-buy ordering | Last Buy slot gaming | Order by buy_index / block+log order from chain, not wall clock |
| Empty pool / zero address winners | UI crash | Match onchain address(0) handling; frontend already pads zeros |
Verification criteria (close checklist)
- All acceptance criteria checked.
-
cargo clippy --all-targets -- -D warningsandcargo testgreen inindexer/. -
integration_stage2passes with Postgres. - Manual: Anvil deploy → 3+ wallets buy →
curl $INDEXER/v1/arena/podiumsmatchescast callpodium for eachPODIUM_CONTRACT_CATEGORY_INDEXat same block. - QA comment on #254 (closed) linking this issue; close #254 (closed) when both pass.
- No new TimeCurve-only routes or tables introduced.