Skip to content

feat(earnings): Phase 3 public read API — per-agent + platform + leaderboard (#978)#983

Merged
biwasxyz merged 3 commits into
mainfrom
feat/earnings-api-phase3
Jun 8, 2026
Merged

feat(earnings): Phase 3 public read API — per-agent + platform + leaderboard (#978)#983
biwasxyz merged 3 commits into
mainfrom
feat/earnings-api-phase3

Conversation

@biwasxyz

@biwasxyz biwasxyz commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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

Route Returns
GET /api/agents/{address}/earnings Per-agent rollup (7d/30d/lifetime USD, unique payers, top source) + paginated line items with Hiro explorer links. Resolves STX/BTC/numeric.
GET /api/stats/earnings Platform totals (7d/30d/lifetime) + 30d source breakdown + agent leaderboard ranked by earnings (?window=7d|30d|lifetime).

Both ?docs=1 self-document.

  • lib/earnings/reads.ts — the aggregation helpers (getAgentRollup, getAgentLineItems, getPlatformEarnings, getEarningsLeaderboard)
  • migration 022 — partial index idx_agent_earnings_leaderboard (recipient_agent_stx, block_time) WHERE is_earning=1, serves the leaderboard GROUP BY + per-agent rollups with no temp B-tree
  • lib/swr-keys.tsearnings() + earningsStats() builders for Phase 4

I ran /code-review before opening this (it caught real antipatterns)

The high-effort review surfaced three legit issues, all fixed in this PR:

  1. force-dynamic defeated the edge cache it sat next to → removed (matches the canonical agents/[address] route).
  2. Rate-limit + getCloudflareContext ran 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).
  3. Leaderboard GROUP BY wasn'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-store not 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

tsc clean · lint clean · full suite 1510 passing (+5 new in reads.test.ts covering 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.

…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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 8, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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 arc0btc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; getAgentRollup parallelizes its two internal scans as well
  • Partial index (recipient_agent_stx, block_time WHERE is_earning=1) is well-targeted: leaderboard GROUP BY and 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 + 1 trick for hasMore avoids a COUNT query — right call
  • Tests cover window bounds, empty-agent case, platform totals, and leaderboard bind verification (calls[0].args[0] assertion)
  • HAVING earnings_usd > 0 correctly 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.
@biwasxyz

biwasxyz commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @arc0btc — applied + answered:

  • Parallelize getPlatformEarnings — done; the totals + by-source scans now run under Promise.all, matching getAgentRollup. Consistency closed.

  • Tie-handling in ranks — intentional: rank is ordinal (1, 2, 3…), with a fully deterministic order earnings_usd DESC, latest_at DESC. So equal-earnings agents get distinct sequential ranks broken by most-recent activity (a reasonable "who's hot" tiebreak), and the ordering is stable across pages/requests. No dense-rank / shared-rank — Phase 4 can render ordinal ranks directly. If the Phase 4 design later wants shared ranks for visual ties, that's a presentation-layer concern (the API exposes earningsUsd, so the UI can detect/merge ties without an API change).

tsc + lint clean, reads tests green (5).

…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.)
@biwasxyz biwasxyz merged commit c38e527 into main Jun 8, 2026
8 checks passed
@biwasxyz biwasxyz deleted the feat/earnings-api-phase3 branch June 8, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants