feat(earnings): Phase 3 public read API — per-agent + platform + leaderboard (#978)#983
Conversation
…erboard (#978) Read-time aggregation over the earnings ledger, behind a 1h edge cache. - lib/earnings/reads.ts: getAgentRollup (7d/30d/lifetime + unique payers + top source), getAgentLineItems, getPlatformEarnings, getEarningsLeaderboard. - GET /api/agents/{address}/earnings: per-agent rollup + line items (Hiro links), resolves stx/btc/numeric, paginated, self-documenting. - GET /api/stats/earnings: platform totals + 30d source breakdown + ranked earnings leaderboard (?window=7d|30d|lifetime), self-documenting. - migration 022: partial index idx_agent_earnings_leaderboard (recipient_agent_stx, block_time) WHERE is_earning=1 — serves the leaderboard GROUP BY and per-agent rollups without a temp B-tree. - lib/swr-keys.ts: earnings() + earningsStats() builders for the Phase 4 UI. Addressed a /code-review pass before opening: dropped `force-dynamic` (defeats the edge cache), moved DB + context resolution inside the cache loader and dropped the per-request rate-limit (matches the canonical edge-cached agents route, so cache hits skip all work), added the leaderboard index, parallelized the rollup queries, and excluded $0-priced rows from top_source_class. Reprice pass for amount_usd=NULL transfers is a tracked follow-up.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
landing-page | 78945fa | Commit Preview URL Branch Preview URL |
Jun 08 2026, 09:02 AM |
arc0btc
left a comment
There was a problem hiding this comment.
Phase 3 of the earnings ledger — public read API layer. The pre-merge /code-review pass clearly caught real issues; the implementation is clean and consistent with existing patterns.
What looks good:
- Edge cache placement (all work inside the loader) correctly mirrors the canonical
agents/[address]route — cache hits skip DB and context resolution entirely - Both route handlers use
Promise.all()for independent DB calls;getAgentRollupparallelizes its two internal scans as well - Partial index (
recipient_agent_stx, block_time WHERE is_earning=1) is well-targeted: leaderboardGROUP BYand per-agent rollups both hit it without a transient B-tree - Bind ordering (
?1,?2,?3) verified correct across all queries — positions match the.bind()call arguments throughout limit + 1trick forhasMoreavoids aCOUNTquery — right call- Tests cover window bounds, empty-agent case, platform totals, and leaderboard bind verification (
calls[0].args[0]assertion) HAVING earnings_usd > 0correctly keeps the leaderboard meaningful during Tenero price gaps (all-NULL agents coalesce to 0 and are excluded)
[suggestion] Parallelize the two queries inside getPlatformEarnings (lib/earnings/reads.ts ~L155)
totals and bySource are independent scans — identical situation to the two queries in getAgentRollup, which already uses Promise.all(). Low-urgency since this route is 1h-cached, but worth closing the inconsistency:
const [totals, bySourceRes] = await Promise.all([
db
.prepare(
`SELECT
COALESCE(SUM(CASE WHEN block_time >= ?1 THEN amount_usd END), 0) AS e7,
COALESCE(SUM(CASE WHEN block_time >= ?2 THEN amount_usd END), 0) AS e30,
COALESCE(SUM(amount_usd), 0) AS elife
FROM agent_earnings WHERE is_earning = 1`
)
.bind(sevenAgo, thirtyAgo)
.first<{ e7: number; e30: number; elife: number }>(),
db
.prepare(
`SELECT source_class, COALESCE(SUM(amount_usd), 0) AS total_usd
FROM agent_earnings
WHERE is_earning = 1 AND block_time >= ?1
GROUP BY source_class ORDER BY total_usd DESC`
)
.bind(thirtyAgo)
.all<SourceBreakdownEntry>(),
]);
const bySource = bySourceRes;[question] Tie-handling in leaderboard ranks (app/api/stats/earnings/route.ts ~L91)
rank: offset + i + 1 gives sequential ranks — agents with equal earnings_usd get different ranks based on the latest_at DESC secondary sort. Is that intentional, or should Phase 4 UI expect dense-rank / shared-rank for ties? Not blocking — just want to confirm this aligns with the Phase 4 leaderboard design before it's wired to a visible UI.
Operational note: We run the landing-page in production and watch the Hiro API closely. The circuit-breaker pattern added in PR #958 is relevant context here — the earnings queries are D1-only (no Hiro calls), so they're isolated from that failure mode. The edge cache + CF DDoS protection as the established guard (no app-level rate-limit) is consistent with that prior decision.
Wrap the totals + by-source scans in Promise.all so they overlap, matching getAgentRollup. Consistency cleanup; the route is 1h-cached so it's low-urgency.
|
Thanks @arc0btc — applied + answered:
|
…uard) The leaderboard GROUP BY is the one earnings read that scans many rows. Keying its edge cache on window only (was window+limit+offset) means a pagination crawl can't multiply distinct cache keys → fresh full scans → a D1 rows-read spike. Now at most 3 keys (7d/30d/lifetime) → ≤3 scans/hour/colo regardless of traffic, matching the trading leaderboard SSR's single-fixed-key pattern. - /api/stats/earnings returns a fixed top-100 per window; drops limit/offset. - swrKeys.earningsStats keyed by window only. Keeps us flat at the $5/mo plan: no query-param cardinality can blow up D1 rows-read. (The parallelize change earlier is cost-neutral — same rows read, just overlapped round-trips.)
Phase 3 of the verified earnings ledger (#978): the public read API that the Phase 4 leaderboard/profile UI will consume. Read-time aggregation over the ledger, behind a 1h edge cache.
Endpoints
GET /api/agents/{address}/earningsGET /api/stats/earnings?window=7d|30d|lifetime).Both
?docs=1self-document.lib/earnings/reads.ts— the aggregation helpers (getAgentRollup,getAgentLineItems,getPlatformEarnings,getEarningsLeaderboard)migration 022— partial indexidx_agent_earnings_leaderboard (recipient_agent_stx, block_time) WHERE is_earning=1, serves the leaderboardGROUP BY+ per-agent rollups with no temp B-treelib/swr-keys.ts—earnings()+earningsStats()builders for Phase 4I ran
/code-reviewbefore opening this (it caught real antipatterns)The high-effort review surfaced three legit issues, all fixed in this PR:
force-dynamicdefeated the edge cache it sat next to → removed (matches the canonicalagents/[address]route).getCloudflareContextran on every request, including cache hits (a metered subrequest the cache was supposed to absorb) → moved all work inside the cache loader and dropped the rate-limit, matching the canonical edge-cached agents route (the cache + CF DDoS protection is the established guard).GROUP BYwasn't index-served → added the partial composite index (022); fixed the comment that overclaimed.Plus: parallelized the two rollup queries, and excluded $0-priced rows from
top_source_class. The review confirmed the rest (window unit handling ms→s, bind ordering, pagination/rank,404 + no-storenot cached, schema column match) is correct.Cost
All reads run behind a 1h (stats) / 5min (per-agent) edge cache, so the aggregation scans run ~hourly per colo, not per request — and now they're index-served. No new external calls.
Checks
tscclean ·lintclean · full suite 1510 passing (+5 new inreads.test.tscovering window bounds, rollup mapping incl. the empty-agent case, platform totals, and leaderboard window binding).Part of #978. Next: Phase 4 — the leaderboard flips to earnings (the visible win), profile Earnings tab, homepage hero stat, Club badges.