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