From 2558020898d87dab75c5fdd056e2568e3c5a982a Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 8 Jun 2026 14:27:06 +0545 Subject: [PATCH 1/3] =?UTF-8?q?feat(earnings):=20Phase=203=20public=20read?= =?UTF-8?q?=20API=20=E2=80=94=20per-agent=20+=20platform=20+=20leaderboard?= =?UTF-8?q?=20(#978)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/agents/[address]/earnings/route.ts | 135 ++++++++++++ app/api/stats/earnings/route.ts | 105 ++++++++++ docs/earnings-ledger-architecture.md | 12 +- lib/earnings/__tests__/reads.test.ts | 113 ++++++++++ lib/earnings/reads.ts | 195 ++++++++++++++++++ lib/swr-keys.ts | 13 ++ migrations/022_earnings_leaderboard_index.sql | 14 ++ 7 files changed, 582 insertions(+), 5 deletions(-) create mode 100644 app/api/agents/[address]/earnings/route.ts create mode 100644 app/api/stats/earnings/route.ts create mode 100644 lib/earnings/__tests__/reads.test.ts create mode 100644 lib/earnings/reads.ts create mode 100644 migrations/022_earnings_leaderboard_index.sql diff --git a/app/api/agents/[address]/earnings/route.ts b/app/api/agents/[address]/earnings/route.ts new file mode 100644 index 00000000..f8859617 --- /dev/null +++ b/app/api/agents/[address]/earnings/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { withEdgeCache, buildEdgeCacheKey } from "@/lib/edge-cache"; +import { + classifyAddress, + lookupProfileByStxAddress, + lookupProfileByBtcAddress, + lookupProfileByAgentId, +} from "@/lib/cache/agent-profile"; +import { getAgentRollup, getAgentLineItems } from "@/lib/earnings/reads"; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; +const CACHE_TTL_SECONDS = 300; // 5 min — indexer cadence is 30 min, so plenty fresh. + +function selfDoc() { + return NextResponse.json( + { + endpoint: "/api/agents/{address}/earnings", + method: "GET", + description: + "Verified on-chain earnings for one agent: a 7d/30d/lifetime USD rollup plus recent line items. " + + "Earnings are indexed from confirmed inbound sBTC/STX/aeUSDC transfers, classified by counterparty, " + + "and priced in USD — self-dealing (self-funded / ring / alt-address) is excluded.", + pathParameters: { + address: "Agent STX address (SP…/SM…), BTC address, or numeric agent id.", + }, + queryParameters: { + limit: `Line items per page (1–${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`, + offset: "Line item offset (default 0).", + }, + responseFormat: { + address: "string (as supplied)", + stxAddress: "string (canonical)", + rollup: { + earnings_7d_usd: "number", + earnings_30d_usd: "number", + earnings_lifetime_usd: "number", + unique_payers_30d: "number", + top_source_class_30d: "string | null", + }, + lineItems: + "Array<{ txId, eventIndex, blockTime, sender, asset, amountRaw, amountUsd, sourceClass, sourceSubclass, explorerUrl }>", + pagination: { limit: "number", offset: "number", hasMore: "boolean" }, + }, + relatedEndpoints: { platform: "/api/stats/earnings" }, + }, + { headers: { "Cache-Control": "public, max-age=3600, s-maxage=86400" } } + ); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ address: string }> } +) { + const { address } = await params; + const url = new URL(request.url); + if (url.searchParams.get("docs") === "1") return selfDoc(); + + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Number(url.searchParams.get("limit")) || DEFAULT_LIMIT) + ); + const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0); + + const cacheKey = buildEdgeCacheKey( + "/api/agents", + address, + `/earnings?limit=${limit}&offset=${offset}` + ); + + // All work runs inside the loader so edge-cache hits skip the DB + context + // resolve entirely (mirrors app/api/agents/[address]/route.ts). + return withEdgeCache(cacheKey, CACHE_TTL_SECONDS, async () => { + const { env } = await getCloudflareContext(); + const db = env.DB as D1Database | undefined; + if (!db) { + return NextResponse.json( + { error: "Database unavailable." }, + { status: 503, headers: { "Cache-Control": "no-store" } } + ); + } + + // Resolve any address form → canonical agent stx_address. + const branch = classifyAddress(address); + let row = null; + if (branch === "stx") row = await lookupProfileByStxAddress(db, address); + else if (branch === "btc") row = await lookupProfileByBtcAddress(db, address); + else if (branch === "numeric") { + const id = parseInt(address, 10); + if (Number.isFinite(id)) row = await lookupProfileByAgentId(db, id); + } + + if (!row) { + return NextResponse.json( + { + error: + "Agent not found. Provide a registered STX address (SP…/SM…), BTC address, or numeric agent id.", + }, + { status: 404, headers: { "Cache-Control": "no-store" } } + ); + } + + const stxAddress = row.stx_address; + const now = Date.now(); + + const [rollup, items] = await Promise.all([ + getAgentRollup(db, stxAddress, now), + getAgentLineItems(db, stxAddress, limit + 1, offset), + ]); + + const hasMore = items.length > limit; + const lineItems = (hasMore ? items.slice(0, limit) : items).map((i) => ({ + txId: i.tx_id, + eventIndex: i.event_index, + blockTime: i.block_time, + sender: i.sender_stx, + asset: i.asset, + amountRaw: i.amount_raw, + amountUsd: i.amount_usd, + sourceClass: i.source_class, + sourceSubclass: i.source_subclass, + explorerUrl: `https://explorer.hiro.so/txid/${i.tx_id}?chain=mainnet`, + })); + + return NextResponse.json( + { address, stxAddress, rollup, lineItems, pagination: { limit, offset, hasMore } }, + { + headers: { + "Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}`, + }, + } + ); + }); +} diff --git a/app/api/stats/earnings/route.ts b/app/api/stats/earnings/route.ts new file mode 100644 index 00000000..b0f61cae --- /dev/null +++ b/app/api/stats/earnings/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { withEdgeCache, buildEdgeCacheKey } from "@/lib/edge-cache"; +import { + getPlatformEarnings, + getEarningsLeaderboard, + type EarningsWindow, +} from "@/lib/earnings/reads"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; +const CACHE_TTL_SECONDS = 3600; // 1h — platform aggregate + ranking change slowly. +const WINDOWS: ReadonlySet = new Set(["7d", "30d", "lifetime"]); + +function parseWindow(raw: string | null): EarningsWindow { + return raw && WINDOWS.has(raw as EarningsWindow) ? (raw as EarningsWindow) : "30d"; +} + +function selfDoc() { + return NextResponse.json( + { + endpoint: "/api/stats/earnings", + method: "GET", + description: + "Platform-wide verified earnings: total USD earned by all agents over 7d/30d/lifetime, " + + "a 30d breakdown by source class, and the agent leaderboard ranked by earnings in the chosen window.", + queryParameters: { + window: + "Leaderboard ranking window: 7d | 30d | lifetime (default 30d). Platform totals always include all three.", + limit: `Leaderboard rows per page (1–${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`, + offset: "Leaderboard offset (default 0).", + }, + responseFormat: { + platform: { + total_7d_usd: "number", + total_30d_usd: "number", + total_lifetime_usd: "number", + by_source_class_30d: "Array<{ source_class, total_usd }>", + }, + leaderboard: + "Array<{ rank, stxAddress, btcAddress, displayName, bnsName, earningsUsd, uniquePayers, latestAt }>", + window: "string", + pagination: { limit: "number", offset: "number", hasMore: "boolean" }, + }, + relatedEndpoints: { perAgent: "/api/agents/{address}/earnings" }, + }, + { headers: { "Cache-Control": "public, max-age=3600, s-maxage=86400" } } + ); +} + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + if (url.searchParams.get("docs") === "1") return selfDoc(); + + const window = parseWindow(url.searchParams.get("window")); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Number(url.searchParams.get("limit")) || DEFAULT_LIMIT) + ); + const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0); + + const cacheKey = buildEdgeCacheKey( + "/api/stats", + "earnings", + `?window=${window}&limit=${limit}&offset=${offset}` + ); + + return withEdgeCache(cacheKey, CACHE_TTL_SECONDS, async () => { + const { env } = await getCloudflareContext(); + const db = env.DB as D1Database | undefined; + if (!db) { + return NextResponse.json( + { error: "Database unavailable." }, + { status: 503, headers: { "Cache-Control": "no-store" } } + ); + } + + const now = Date.now(); + const [platform, rows] = await Promise.all([ + getPlatformEarnings(db, now), + getEarningsLeaderboard(db, window, limit + 1, offset, now), + ]); + + const hasMore = rows.length > limit; + const leaderboard = (hasMore ? rows.slice(0, limit) : rows).map((r, i) => ({ + rank: offset + i + 1, + stxAddress: r.stx_address, + btcAddress: r.btc_address, + displayName: r.display_name, + bnsName: r.bns_name, + earningsUsd: r.earnings_usd, + uniquePayers: r.unique_payers, + latestAt: r.latest_at, + })); + + return NextResponse.json( + { platform, leaderboard, window, pagination: { limit, offset, hasMore } }, + { + headers: { + "Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}`, + }, + } + ); + }); +} diff --git a/docs/earnings-ledger-architecture.md b/docs/earnings-ledger-architecture.md index 913331af..446211dd 100644 --- a/docs/earnings-ledger-architecture.md +++ b/docs/earnings-ledger-architecture.md @@ -372,11 +372,13 @@ would multiply Hiro calls by the cadence with no freshness benefit for a 30-day `agent_peer` earnings, wired into the indexer's `resolveRow`. Known limit: a ring whose two legs land in the *same* sweep tick isn't caught (the reverse leg isn't persisted yet); cross-tick rings are. -- **Reprice pass (Phase 3 scope).** Phase 1 stores `amount_usd = NULL`, - `price_source = 'none'` for transfers indexed during a Tenero gap. There is **no - reprice task yet** — add one in Phase 3 (a bounded sweep over `price_source = 'none'` - rows that re-reads the Tenero cache), so the gap doesn't get lost between phases. -- **Phase 3 — Public API.** `/api/agents/{addr}/earnings`, `/api/stats/earnings`, +- **Reprice pass (small follow-up, post-Phase 3).** Phase 1 stores `amount_usd = NULL`, + `price_source = 'none'` for transfers indexed during a Tenero gap. A bounded reprice + sweep (over a `price_source = 'none'` partial index) re-reads the Tenero cache and + fills them — deferred to keep the Phase 3 API PR focused; tracked here so it isn't + lost. +- **Phase 3 — Public API. DONE.** `/api/agents/{addr}/earnings` (per-agent rollup + + line items), `/api/stats/earnings` (platform totals + ranked earnings leaderboard), trading-board earnings ranking (all read-time + edge-cached). - **Phase 4 — UI.** leaderboard chip + new default, profile Earnings section, homepage hero stat, `$10–$100k` Club badges. diff --git a/lib/earnings/__tests__/reads.test.ts b/lib/earnings/__tests__/reads.test.ts new file mode 100644 index 00000000..0bef799f --- /dev/null +++ b/lib/earnings/__tests__/reads.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from "vitest"; +import { + windowStart, + getAgentRollup, + getPlatformEarnings, + getEarningsLeaderboard, +} from "../reads"; + +const NOW = 1_000_000_000_000; // fixed unix ms +const NOW_SEC = Math.floor(NOW / 1000); +const DAY = 86_400; + +describe("windowStart", () => { + it("computes 7d / 30d / lifetime bounds in unix seconds", () => { + expect(windowStart("7d", NOW)).toBe(NOW_SEC - 7 * DAY); + expect(windowStart("30d", NOW)).toBe(NOW_SEC - 30 * DAY); + expect(windowStart("lifetime", NOW)).toBe(0); + }); +}); + +/** D1 mock returning preset rows per query, capturing bind args. */ +function makeDb(handlers: { match: (sql: string) => boolean; first?: unknown; all?: unknown[] }[]) { + const calls: { sql: string; args: unknown[] }[] = []; + return { + calls, + db: { + prepare: (sql: string) => ({ + bind: (...args: unknown[]) => ({ + first: async () => { + calls.push({ sql, args }); + const h = handlers.find((x) => x.match(sql)); + return h?.first ?? null; + }, + all: async () => { + calls.push({ sql, args }); + const h = handlers.find((x) => x.match(sql)); + return { results: h?.all ?? [] }; + }, + }), + }), + } as unknown as D1Database, + }; +} + +describe("getAgentRollup", () => { + it("maps the totals + top source class query results", async () => { + const { db } = makeDb([ + { match: (s) => s.includes("COUNT(DISTINCT"), first: { e7: 10, e30: 42.5, elife: 100, payers30: 3 } }, + { match: (s) => s.includes("GROUP BY source_class"), first: { source_class: "inbox_message" } }, + ]); + const r = await getAgentRollup(db, "SP_AGENT", NOW); + expect(r).toEqual({ + earnings_7d_usd: 10, + earnings_30d_usd: 42.5, + earnings_lifetime_usd: 100, + unique_payers_30d: 3, + top_source_class_30d: "inbox_message", + }); + }); + + it("defaults to zeros / null when the agent has no earnings", async () => { + const { db } = makeDb([]); + const r = await getAgentRollup(db, "SP_NEW", NOW); + expect(r).toEqual({ + earnings_7d_usd: 0, + earnings_30d_usd: 0, + earnings_lifetime_usd: 0, + unique_payers_30d: 0, + top_source_class_30d: null, + }); + }); +}); + +describe("getPlatformEarnings", () => { + it("returns totals + the 30d source breakdown", async () => { + const { db } = makeDb([ + { match: (s) => s.includes("FROM agent_earnings WHERE is_earning = 1"), first: { e7: 5, e30: 20, elife: 50 } }, + { + match: (s) => s.includes("GROUP BY source_class"), + all: [ + { source_class: "inbox_message", total_usd: 12 }, + { source_class: "bounty", total_usd: 8 }, + ], + }, + ]); + const r = await getPlatformEarnings(db, NOW); + expect(r.total_7d_usd).toBe(5); + expect(r.total_30d_usd).toBe(20); + expect(r.total_lifetime_usd).toBe(50); + expect(r.by_source_class_30d).toHaveLength(2); + expect(r.by_source_class_30d[0]).toEqual({ source_class: "inbox_message", total_usd: 12 }); + }); +}); + +describe("getEarningsLeaderboard", () => { + it("binds the window start and returns ranked rows", async () => { + const { db, calls } = makeDb([ + { + match: (s) => s.includes("LEFT JOIN agents"), + all: [ + { stx_address: "SP_A", btc_address: "bc1a", display_name: "A", bns_name: null, earnings_usd: 99, unique_payers: 4, latest_at: 123 }, + ], + }, + ]); + const rows = await getEarningsLeaderboard(db, "30d", 20, 0, NOW); + expect(rows).toHaveLength(1); + expect(rows[0].stx_address).toBe("SP_A"); + // first bind arg is the window start (30d ago, unix seconds) + expect(calls[0].args[0]).toBe(NOW_SEC - 30 * DAY); + expect(calls[0].args[1]).toBe(20); // limit + expect(calls[0].args[2]).toBe(0); // offset + }); +}); diff --git a/lib/earnings/reads.ts b/lib/earnings/reads.ts new file mode 100644 index 00000000..5c16509e --- /dev/null +++ b/lib/earnings/reads.ts @@ -0,0 +1,195 @@ +/** + * Earnings read/aggregation helpers (issue #978, Phase 3). + * + * Read-time GROUP BY over the indexed ledger (only `is_earning = 1` rows), + * windowed by block_time. Served by the partial index + * `idx_agent_earnings_leaderboard (recipient_agent_stx, block_time) WHERE + * is_earning = 1` (migration 022) — the leaderboard GROUP BY needs no transient + * B-tree and per-agent rollups seek straight to the agent's earning rows. Routes + * also wrap these in a 1h edge cache (see docs §6/§13). `amount_usd` may be NULL + * for transfers indexed during a Tenero gap; SUM() ignores NULLs. + */ + +import type { SourceClass } from "./types"; + +const DAY = 24 * 60 * 60; + +export type EarningsWindow = "7d" | "30d" | "lifetime"; + +/** Window start as a unix-seconds bound (lifetime → 0). `now` is unix ms. */ +export function windowStart(window: EarningsWindow, now: number): number { + const nowSec = Math.floor(now / 1000); + if (window === "7d") return nowSec - 7 * DAY; + if (window === "30d") return nowSec - 30 * DAY; + return 0; +} + +export interface AgentEarningsRollup { + earnings_7d_usd: number; + earnings_30d_usd: number; + earnings_lifetime_usd: number; + unique_payers_30d: number; + top_source_class_30d: SourceClass | null; +} + +export async function getAgentRollup( + db: D1Database, + stxAddress: string, + now: number +): Promise { + const sevenAgo = windowStart("7d", now); + const thirtyAgo = windowStart("30d", now); + + // Two independent reads over the same recipient partition — overlap them. + const [totals, topSource] = await Promise.all([ + db + .prepare( + `SELECT + COALESCE(SUM(CASE WHEN block_time >= ?2 THEN amount_usd END), 0) AS e7, + COALESCE(SUM(CASE WHEN block_time >= ?3 THEN amount_usd END), 0) AS e30, + COALESCE(SUM(amount_usd), 0) AS elife, + COUNT(DISTINCT CASE WHEN block_time >= ?3 THEN sender_stx END) AS payers30 + FROM agent_earnings + WHERE recipient_agent_stx = ?1 AND is_earning = 1` + ) + .bind(stxAddress, sevenAgo, thirtyAgo) + .first<{ e7: number; e30: number; elife: number; payers30: number }>(), + db + .prepare( + // Only priced rows: a class whose 30d total is $0 (unpriced gap) isn't a + // meaningful "top earning source". + `SELECT source_class FROM agent_earnings + WHERE recipient_agent_stx = ?1 AND is_earning = 1 AND block_time >= ?2 + AND amount_usd IS NOT NULL + GROUP BY source_class ORDER BY SUM(amount_usd) DESC LIMIT 1` + ) + .bind(stxAddress, thirtyAgo) + .first<{ source_class: SourceClass }>(), + ]); + + return { + earnings_7d_usd: totals?.e7 ?? 0, + earnings_30d_usd: totals?.e30 ?? 0, + earnings_lifetime_usd: totals?.elife ?? 0, + unique_payers_30d: totals?.payers30 ?? 0, + top_source_class_30d: topSource?.source_class ?? null, + }; +} + +export interface EarningLineItem { + tx_id: string; + event_index: number; + block_time: number; + sender_stx: string; + asset: string; + amount_raw: number; + amount_usd: number | null; + source_class: SourceClass; + source_subclass: string | null; +} + +export async function getAgentLineItems( + db: D1Database, + stxAddress: string, + limit: number, + offset: number +): Promise { + const res = await db + .prepare( + `SELECT tx_id, event_index, block_time, sender_stx, asset, amount_raw, + amount_usd, source_class, source_subclass + FROM agent_earnings + WHERE recipient_agent_stx = ?1 AND is_earning = 1 + ORDER BY block_time DESC, event_index DESC + LIMIT ?2 OFFSET ?3` + ) + .bind(stxAddress, limit, offset) + .all(); + return res.results ?? []; +} + +export interface SourceBreakdownEntry { + source_class: SourceClass; + total_usd: number; +} + +export interface PlatformEarnings { + total_7d_usd: number; + total_30d_usd: number; + total_lifetime_usd: number; + by_source_class_30d: SourceBreakdownEntry[]; +} + +export async function getPlatformEarnings( + db: D1Database, + now: number +): Promise { + const sevenAgo = windowStart("7d", now); + const thirtyAgo = windowStart("30d", now); + + const totals = await 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 }>(); + + const bySource = await 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(); + + return { + total_7d_usd: totals?.e7 ?? 0, + total_30d_usd: totals?.e30 ?? 0, + total_lifetime_usd: totals?.elife ?? 0, + by_source_class_30d: bySource.results ?? [], + }; +} + +export interface EarningsLeaderboardRow { + stx_address: string; + btc_address: string | null; + display_name: string | null; + bns_name: string | null; + earnings_usd: number; + unique_payers: number; + latest_at: number | null; +} + +/** Agents ranked by earnings in the window. Fetches `limit + 1` to derive hasMore. */ +export async function getEarningsLeaderboard( + db: D1Database, + window: EarningsWindow, + limit: number, + offset: number, + now: number +): Promise { + const start = windowStart(window, now); + const res = await db + .prepare( + `SELECT e.recipient_agent_stx AS stx_address, a.btc_address, a.display_name, a.bns_name, + COALESCE(SUM(e.amount_usd), 0) AS earnings_usd, + COUNT(DISTINCT e.sender_stx) AS unique_payers, + MAX(e.block_time) AS latest_at + FROM agent_earnings e + LEFT JOIN agents a ON a.stx_address = e.recipient_agent_stx + WHERE e.is_earning = 1 AND e.block_time >= ?1 + GROUP BY e.recipient_agent_stx + HAVING earnings_usd > 0 + ORDER BY earnings_usd DESC, latest_at DESC + LIMIT ?2 OFFSET ?3` + ) + .bind(start, limit, offset) + .all(); + return res.results ?? []; +} diff --git a/lib/swr-keys.ts b/lib/swr-keys.ts index 41f2921b..a0dfaaa2 100644 --- a/lib/swr-keys.ts +++ b/lib/swr-keys.ts @@ -54,6 +54,19 @@ export const swrKeys = { vouch: (btcAddress: string) => `/api/vouch/${encodeURIComponent(btcAddress)}`, leaderboard: (limit: number) => `/api/leaderboard?limit=${limit}`, + earnings: (address: string, opts: { limit?: number; offset?: number } = {}) => + `/api/agents/${encodeURIComponent(address)}/earnings${buildQuery({ + limit: opts.limit, + offset: opts.offset, + })}`, + earningsStats: ( + opts: { window?: "7d" | "30d" | "lifetime"; limit?: number; offset?: number } = {} + ) => + `/api/stats/earnings${buildQuery({ + window: opts.window, + limit: opts.limit, + offset: opts.offset, + })}`, activity: () => "/api/activity", statusSummary: () => "/api/status/summary", } as const; diff --git a/migrations/022_earnings_leaderboard_index.sql b/migrations/022_earnings_leaderboard_index.sql new file mode 100644 index 00000000..18bf0dc1 --- /dev/null +++ b/migrations/022_earnings_leaderboard_index.sql @@ -0,0 +1,14 @@ +-- Migration 022: earnings read-path index (issue #978, Phase 3) +-- +-- Serves the public read API's aggregation: +-- - getEarningsLeaderboard: WHERE is_earning=1 AND block_time >= ? +-- GROUP BY recipient_agent_stx +-- - getAgentRollup: WHERE recipient_agent_stx = ? AND is_earning = 1 +-- +-- Partial on is_earning = 1 (the only rows the read path ever aggregates) and +-- leads recipient_agent_stx, so the leaderboard GROUP BY needs no transient +-- B-tree and per-agent rollups seek straight to the agent's earning rows. +-- Migration 020's non-partial (recipient_agent_stx, block_time) index stays for +-- the indexer's own reads. +CREATE INDEX IF NOT EXISTS idx_agent_earnings_leaderboard + ON agent_earnings (recipient_agent_stx, block_time) WHERE is_earning = 1; From d130835f33efd2e6b7111fd82559fd42aebe06a8 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 8 Jun 2026 14:39:21 +0545 Subject: [PATCH 2/3] review: parallelize getPlatformEarnings queries (arc0btc #983) 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. --- lib/earnings/reads.ts | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/lib/earnings/reads.ts b/lib/earnings/reads.ts index 5c16509e..e8f32f80 100644 --- a/lib/earnings/reads.ts +++ b/lib/earnings/reads.ts @@ -127,26 +127,28 @@ export async function getPlatformEarnings( const sevenAgo = windowStart("7d", now); const thirtyAgo = windowStart("30d", now); - const totals = await 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 }>(); - - const bySource = await 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(); + // Independent scans — overlap them (consistent with getAgentRollup). + const [totals, bySource] = 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(), + ]); return { total_7d_usd: totals?.e7 ?? 0, From 78945fae8eb8b97951504ba5d67cab7ccf38add2 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 8 Jun 2026 14:45:07 +0545 Subject: [PATCH 3/3] =?UTF-8?q?perf(earnings):=20bound=20stats=20leaderboa?= =?UTF-8?q?rd=20scan=20to=20=E2=89=A43=20cache=20keys=20(cost=20guard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- app/api/stats/earnings/route.ts | 37 +++++++++++++-------------------- lib/swr-keys.ts | 10 ++------- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/app/api/stats/earnings/route.ts b/app/api/stats/earnings/route.ts index b0f61cae..80e6368e 100644 --- a/app/api/stats/earnings/route.ts +++ b/app/api/stats/earnings/route.ts @@ -7,8 +7,11 @@ import { type EarningsWindow, } from "@/lib/earnings/reads"; -const DEFAULT_LIMIT = 20; -const MAX_LIMIT = 100; +// Fixed top-N ranking, like the trading leaderboard SSR. Crucially the cache key +// is keyed on WINDOW only (not limit/offset), so the GROUP BY scan runs at most +// 3×/hour/colo regardless of traffic — no pagination crawl can multiply D1 +// rows-read into a cost spike. Clients slice the top-N for display. +const LEADERBOARD_SIZE = 100; const CACHE_TTL_SECONDS = 3600; // 1h — platform aggregate + ranking change slowly. const WINDOWS: ReadonlySet = new Set(["7d", "30d", "lifetime"]); @@ -23,12 +26,10 @@ function selfDoc() { method: "GET", description: "Platform-wide verified earnings: total USD earned by all agents over 7d/30d/lifetime, " + - "a 30d breakdown by source class, and the agent leaderboard ranked by earnings in the chosen window.", + "a 30d breakdown by source class, and the top agents ranked by earnings in the chosen window.", queryParameters: { window: "Leaderboard ranking window: 7d | 30d | lifetime (default 30d). Platform totals always include all three.", - limit: `Leaderboard rows per page (1–${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`, - offset: "Leaderboard offset (default 0).", }, responseFormat: { platform: { @@ -37,10 +38,8 @@ function selfDoc() { total_lifetime_usd: "number", by_source_class_30d: "Array<{ source_class, total_usd }>", }, - leaderboard: - "Array<{ rank, stxAddress, btcAddress, displayName, bnsName, earningsUsd, uniquePayers, latestAt }>", + leaderboard: `Top ${LEADERBOARD_SIZE} Array<{ rank, stxAddress, btcAddress, displayName, bnsName, earningsUsd, uniquePayers, latestAt }>`, window: "string", - pagination: { limit: "number", offset: "number", hasMore: "boolean" }, }, relatedEndpoints: { perAgent: "/api/agents/{address}/earnings" }, }, @@ -53,17 +52,10 @@ export async function GET(request: NextRequest) { if (url.searchParams.get("docs") === "1") return selfDoc(); const window = parseWindow(url.searchParams.get("window")); - const limit = Math.min( - MAX_LIMIT, - Math.max(1, Number(url.searchParams.get("limit")) || DEFAULT_LIMIT) - ); - const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0); - const cacheKey = buildEdgeCacheKey( - "/api/stats", - "earnings", - `?window=${window}&limit=${limit}&offset=${offset}` - ); + // Cache key = window only → at most 3 distinct keys, so the leaderboard scan + // can never be multiplied by query-param cardinality. + const cacheKey = buildEdgeCacheKey("/api/stats", "earnings", `?window=${window}`); return withEdgeCache(cacheKey, CACHE_TTL_SECONDS, async () => { const { env } = await getCloudflareContext(); @@ -78,12 +70,11 @@ export async function GET(request: NextRequest) { const now = Date.now(); const [platform, rows] = await Promise.all([ getPlatformEarnings(db, now), - getEarningsLeaderboard(db, window, limit + 1, offset, now), + getEarningsLeaderboard(db, window, LEADERBOARD_SIZE, 0, now), ]); - const hasMore = rows.length > limit; - const leaderboard = (hasMore ? rows.slice(0, limit) : rows).map((r, i) => ({ - rank: offset + i + 1, + const leaderboard = rows.map((r, i) => ({ + rank: i + 1, stxAddress: r.stx_address, btcAddress: r.btc_address, displayName: r.display_name, @@ -94,7 +85,7 @@ export async function GET(request: NextRequest) { })); return NextResponse.json( - { platform, leaderboard, window, pagination: { limit, offset, hasMore } }, + { platform, leaderboard, window }, { headers: { "Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}`, diff --git a/lib/swr-keys.ts b/lib/swr-keys.ts index a0dfaaa2..aad668f7 100644 --- a/lib/swr-keys.ts +++ b/lib/swr-keys.ts @@ -59,14 +59,8 @@ export const swrKeys = { limit: opts.limit, offset: opts.offset, })}`, - earningsStats: ( - opts: { window?: "7d" | "30d" | "lifetime"; limit?: number; offset?: number } = {} - ) => - `/api/stats/earnings${buildQuery({ - window: opts.window, - limit: opts.limit, - offset: opts.offset, - })}`, + earningsStats: (opts: { window?: "7d" | "30d" | "lifetime" } = {}) => + `/api/stats/earnings${buildQuery({ window: opts.window })}`, activity: () => "/api/activity", statusSummary: () => "/api/status/summary", } as const;