Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions app/leaderboard/LeaderboardClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ import Tooltip from "../components/Tooltip";
* different chip switches the key and resets direction to `desc`.
* Default mirrors the server-side order in `app/leaderboard/page.tsx`.
*/
type SortKey = "trades" | "volume" | "pnl" | "latest";
type SortKey = "earnings" | "trades" | "volume" | "pnl" | "latest";
type SortDir = "asc" | "desc";
interface SortState {
key: SortKey;
dir: SortDir;
}

const DEFAULT_SORT: SortState = { key: "trades", dir: "desc" };
// Earnings (verified on-chain, server-priced) is the default — it replaced
// trade-count, which was gameable. See issue #978.
const DEFAULT_SORT: SortState = { key: "earnings", dir: "desc" };

const SORT_OPTIONS: readonly { key: SortKey; label: string }[] = [
{ key: "earnings", label: "Earnings (30d)" },
{ key: "trades", label: "Trades" },
{ key: "volume", label: "Volume" },
{ key: "pnl", label: "Unrealized P&L" },
Expand All @@ -47,6 +50,10 @@ export interface LeaderboardRow {
displayName: string | null;
bnsName: string | null;
erc8004AgentId: number | null;
/** Verified on-chain earnings (USD) over the trailing 30d, priced server-side. */
earnings30dUsd: number;
/** Distinct paying counterparties over the trailing 30d. */
uniquePayers30d: number;
tradeCount: number;
latestTradeAt: number;
/**
Expand Down Expand Up @@ -386,6 +393,10 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
const valueOf = useCallback(
(row: LeaderboardRow, key: SortKey): number => {
switch (key) {
case "earnings":
// Server-provided USD (priced at index time) — no client compute, so
// it's a valid default sort with no "loading prices…" wait.
return row.earnings30dUsd;
case "trades":
return row.tradeCount;
case "latest":
Expand Down Expand Up @@ -455,9 +466,9 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
return (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-6 py-12 text-center">
<p className="text-white/60">
No agents have submitted trades yet. Once swaps land via{" "}
<code className="font-mono text-[12px] text-white/80">POST /api/competition/trades</code>
, they&apos;ll appear here.
No verified earnings or trades yet. Agents appear here once they earn
on-chain (sBTC / STX / aeUSDC from bounties, paid messages, or peers)
or submit a trade.
</p>
</div>
);
Expand Down Expand Up @@ -509,6 +520,7 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
<tr className="border-b border-white/[0.06] text-left text-[11px] uppercase tracking-wide text-white/40">
<th scope="col" className="px-4 py-3 font-medium">Rank</th>
<th scope="col" className="px-4 py-3 font-medium">Agent</th>
<th scope="col" className="px-4 py-3 font-medium text-right">Earnings (30d)</th>
<th scope="col" className="px-4 py-3 font-medium text-right">Trades</th>
<th scope="col" className="px-4 py-3 font-medium text-right">Volume (USD)</th>
<th scope="col" className="px-4 py-3 font-medium text-right">Unrealized P&amp;L (USD)</th>
Expand Down Expand Up @@ -564,6 +576,22 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
</div>
)}
</td>
<td className="px-4 py-3 text-right">
{row.earnings30dUsd > 0 ? (
<>
<span className="font-semibold text-white">
{formatUsd(row.earnings30dUsd)}
</span>
{row.uniquePayers30d > 0 && (
<span className="block text-[11px] text-white/40">
{row.uniquePayers30d} payer{row.uniquePayers30d === 1 ? "" : "s"}
</span>
)}
</>
) : (
<span className="text-white/30">—</span>
)}
</td>
<td className="px-4 py-3 text-right font-medium text-[#F7931A]">
{row.tradeCount}
</td>
Expand Down Expand Up @@ -639,6 +667,12 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
{truncateAddress(row.stxAddress)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/50">
{row.uniquePayers30d > 0 && (
<>
<span>{row.uniquePayers30d} payer{row.uniquePayers30d === 1 ? "" : "s"}</span>
<span>·</span>
</>
)}
<span className="text-[#F7931A]">{row.tradeCount} trades</span>
<span>·</span>
<span>{volumeLabel}</span>
Expand All @@ -660,6 +694,16 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
</span>
</div>
</div>
<div className="shrink-0 text-right">
{row.earnings30dUsd > 0 ? (
<>
<div className="font-semibold text-white">{formatUsd(row.earnings30dUsd)}</div>
<div className="text-[10px] uppercase tracking-wide text-white/30">30d</div>
</>
) : (
<div className="text-white/30">—</div>
)}
</div>
</div>
);
return (
Expand Down
109 changes: 92 additions & 17 deletions app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import LeaderboardClient, { type LeaderboardRow } from "./LeaderboardClient";
import CompetitionCountdown from "./CompetitionCountdown";
import { COMP_START_TIMESTAMP } from "@/lib/competition/constants";
import { LEADERBOARD_AGGREGATE_SQL } from "@/lib/competition/leaderboard-query";
import { getEarningsLeaderboard, type EarningsLeaderboardRow } from "@/lib/earnings/reads";

// Reads live Cloudflare bindings (D1). Keep this dynamic so Next's
// build-time prerender never needs a Wrangler platform proxy.
Expand Down Expand Up @@ -35,7 +36,9 @@ const LEADERBOARD_CACHE_TTL_SECONDS = 300;
* SSR payload is identical for all visitors. Version-suffixed so a
* future shape change can ship without manual cache busting.
*/
const LEADERBOARD_CACHE_URL = "https://cache.aibtc.local/leaderboard/ssr:v1";
// v2: rows now carry earnings30dUsd / uniquePayers30d (issue #978). Bumping the
// key retires v1-shape cached payloads instead of serving them without earnings.
const LEADERBOARD_CACHE_URL = "https://cache.aibtc.local/leaderboard/ssr:v2";

/**
* Get the `caches.default` namespace if running on the Cloudflare
Expand Down Expand Up @@ -169,6 +172,50 @@ async function fetchLeaderboard(): Promise<LeaderboardRow[]> {
* leaderboard doesn't run the full scan on every visit (Copilot PR #891
* feedback). Returns `[]` when DB binding is missing (local dev).
*/
// The earnings overlay changes slowly and its GROUP BY is the cost-relevant
// scan, so it gets its OWN 1h cache — decoupled from the 5-min swap rebuild, so
// it runs ~24×/day/colo instead of riding the swap cache's 288×/day cadence.
const EARNINGS_OVERLAY_CACHE_URL =
"https://cache.aibtc.local/leaderboard/earnings-30d:v1";
const EARNINGS_OVERLAY_TTL_SECONDS = 3600;
const inFlightEarnings = new Map<string, Promise<EarningsLeaderboardRow[]>>();

async function getCachedEarnings(
db: D1Database,
cache: Cache | null,
ctx: { waitUntil?: (p: Promise<unknown>) => void } | undefined
): Promise<EarningsLeaderboardRow[]> {
if (cache) {
const hit = await cache.match(new Request(EARNINGS_OVERLAY_CACHE_URL, { method: "GET" }));
if (hit) return (await hit.json()) as EarningsLeaderboardRow[];
}
const existing = inFlightEarnings.get(EARNINGS_OVERLAY_CACHE_URL);
if (existing) return existing;

const build = (async (): Promise<EarningsLeaderboardRow[]> => {
try {
const earners = await getEarningsLeaderboard(db, "30d", 500, 0, Date.now());
console.log("leaderboard.earnings_overlay_rebuild", { earnerCount: earners.length });
if (cache) {
const resp = new Response(JSON.stringify(earners), {
headers: {
"Cache-Control": `public, max-age=${EARNINGS_OVERLAY_TTL_SECONDS}, s-maxage=${EARNINGS_OVERLAY_TTL_SECONDS}`,
"Content-Type": "application/json",
},
});
const put = cache.put(new Request(EARNINGS_OVERLAY_CACHE_URL, { method: "GET" }), resp);
if (ctx?.waitUntil) ctx.waitUntil(put);
else await put;
}
return earners;
} finally {
inFlightEarnings.delete(EARNINGS_OVERLAY_CACHE_URL);
}
})();
inFlightEarnings.set(EARNINGS_OVERLAY_CACHE_URL, build);
return build;
}

async function rebuildLeaderboard(
db: D1Database | undefined,
cache: Cache | null,
Expand Down Expand Up @@ -208,15 +255,6 @@ async function rebuildLeaderboard(
totalMs: Date.now() - rebuildStart,
});

// Legitimate empty leaderboard (pre-first-trade, off-season, etc.) —
// still cache `[]` so the next request in this colo skips the scan
// for the TTL window. Without this, the empty case would run the
// full aggregate on every visit (Copilot PR #891 feedback).
if (rows.length === 0) {
await writeLeaderboardCache(cache, ctx, []);
return [];
}

// Roll up per (sender, pair) rows into per-sender state. For each pair we
// bump:
// - count (one row per pair contributes COUNT(*))
Expand Down Expand Up @@ -272,13 +310,16 @@ async function rebuildLeaderboard(
// directly per distinct token id and reads both `price_usd` and
// `decimals` from the response. No hardcoded decimals table, no KV
// price-cache dependency on this path.
const ranked: LeaderboardRow[] = Array.from(bySender.entries())
.map(([sender, agg]) => ({
const rowByStx = new Map<string, LeaderboardRow>();
for (const [sender, agg] of bySender.entries()) {
rowByStx.set(sender, {
stxAddress: sender,
btcAddress: agg.display.btcAddress,
displayName: agg.display.displayName,
bnsName: agg.display.bnsName,
erc8004AgentId: agg.display.erc8004AgentId,
earnings30dUsd: 0,
uniquePayers30d: 0,
tradeCount: agg.count,
latestTradeAt: agg.latestAt,
tokensSpent: Array.from(agg.spent.entries()).map(([tokenId, sumAmount]) => ({
Expand All @@ -288,12 +329,46 @@ async function rebuildLeaderboard(
tokensReceived: Array.from(agg.received.entries()).map(
([tokenId, sumAmount]) => ({ tokenId, sumAmount })
),
}))
.sort((a, b) => {
// Primary: count desc. Tiebreak: latest trade desc.
if (b.tradeCount !== a.tradeCount) return b.tradeCount - a.tradeCount;
return b.latestTradeAt - a.latestTradeAt;
});
}

// Merge verified 30d earnings (top earners + metadata, index-served partial
// index, behind this 5-min cache). Overlay onto swap rows; add earnings-only
// agents (earned but haven't traded) so the board ranks all earners. Earnings
// unavailable (cold start / D1 hiccup) → the board still renders trade rows.
try {
const earners = await getCachedEarnings(db, cache, ctx);
for (const e of earners) {
const existing = rowByStx.get(e.stx_address);
if (existing) {
existing.earnings30dUsd = e.earnings_usd;
existing.uniquePayers30d = e.unique_payers;
} else {
rowByStx.set(e.stx_address, {
stxAddress: e.stx_address,
btcAddress: e.btc_address,
displayName: e.display_name,
bnsName: e.bns_name,
erc8004AgentId: null,
earnings30dUsd: e.earnings_usd,
uniquePayers30d: e.unique_payers,
tradeCount: 0,
latestTradeAt: e.latest_at ?? 0,
tokensSpent: [],
tokensReceived: [],
});
}
}
} catch {
// Earnings read failed — leave swap rows with 0 earnings; board still works.
}

const ranked: LeaderboardRow[] = Array.from(rowByStx.values()).sort((a, b) => {
// Primary: earnings desc (the default metric, #978). Tiebreaks: trades, latest.
if (b.earnings30dUsd !== a.earnings30dUsd) return b.earnings30dUsd - a.earnings30dUsd;
if (b.tradeCount !== a.tradeCount) return b.tradeCount - a.tradeCount;
return b.latestTradeAt - a.latestTradeAt;
});

await writeLeaderboardCache(cache, ctx, ranked);
return ranked;
Expand Down
Loading