Indexer + frontend P3 polish: COUNT(*) per page, leaderboard tie-breaker, silent catch in refresh section, post-end podium hints
four small hardening / consistency findings on the indexer + frontend, surfaced from a code review pass against `390f9be`. none are blockers, all P3, lumped together since the fixes are tiny and tracking them as separate tickets would be heavier than the work. ## Finding 1 — `/v1/timecurve/buys` runs full-table COUNT(*) on every paginated request Severity: P3 perf `api.rs:381-390`: ``` let total: i64 = match sqlx::query_scalar::<_, i64>("SELECT COUNT(*)::bigint FROM idx_timecurve_buy") .fetch_one(&state.pool).await { ... }; ``` every paginated read does a sequential scan to populate `total`. on a busy launch with tens of thousands of buys, with the Simple page polling at refresh cadence and a couple dozen viewers paginating, this drives unnecessary pg cpu before anyone notices. fix shape: either (a) cache total for ~30s in `AppState`, (b) replace with `MAX(buy_index)::bigint` (already 1-based per `TimeCurve.sol:504`), or (c) drop `total` from non-first pages by accepting it only when `offset == 0`. option (b) is one-line. ## Finding 2 — referrer leaderboard pagination is non-deterministic on ties Severity: P3 consistency `api.rs:1896-1903`: ``` GROUP BY referrer ORDER BY SUM(referrer_amount) DESC NULLS LAST LIMIT $1 OFFSET $2 ``` no secondary tie-breaker. when multiple referrers tie on summed amount, postgres returns them in arbitrary order — paginating with LIMIT/OFFSET can drop a referrer or duplicate one across pages. the `rank` field computed off `i + 1` is wrong for ties. would surface under load even with modest traffic. fix shape: append `, referrer ASC` (or `, MIN(block_number) ASC` for entry-time ranking) as a deterministic secondary key. ## Finding 3 — `TimeCurveProtocolWarbowRefreshSection.loadFromIndexer` swallows exceptions silently Severity: P3 UX `TimeCurveProtocolWarbowRefreshSection.tsx:88-132`: ``` try { // 50-iter pagination loop, no catch } finally { setLoadingIdx(false); } ``` the null-from-fetch path is handled (sets `loadErr` with a friendly message), but anything inside the loop that throws (unexpected response shape, `checksumCandidates` exception, runtime promise rejection in `setState`) escapes upward. react logs to console, user sees the spinner stop with no message and an empty list — they will click again and again with no idea what is wrong. fix shape: wrap with `try { ... } catch (e) { setLoadErr(friendlyRevertFromUnknown(e)); setLoadedCandidates([]); setIdxExtras(null); }` before the finally. ## Finding 4 — refresh-candidates API merges podium hints post-endSale despite contract reverting Severity: P3 consistency `api.rs:1006-1015`: ``` if let Some(head) = guard.as_ref() { for w in &head.podium_contract[3].winners { if let Some(a) = normalize_refresh_candidate_addr(w) { if !podium_hints.contains(&a) { podium_hints.push(a); } } } } ``` unconditional. no check on `head.sale_ended`. post-end, `head.podium_contract[3]` is the WarBow finalized snapshot — the indexer still merges those into the candidate response as if the refresh flow were live. contract reverts on `refreshWarbowPodium` post-end (per the API note about "Post-end operators must call owner finalizeWarbowPodium"), so the chain side is safe, but the API shape encourages clients to keep calling refresh. the frontend correctly gates on `saleEnded` upstream — the API itself does not. fix shape: when `head.sale_ended`, either return 410 / drop hints from the response, or surface `sale_ended: true` in the body so the client does not have to cross-reference chain-timer separately. ## Disposition all four are independently fixable. happy to take any of them as a small MR if you want them off your queue — finding 2 is one line, finding 1 with option (b) is one line, finding 3 is one try/catch wrap, finding 4 needs an `if head.sale_ended` branch. flag and i can ship. cc @PlasticDigits
issue