Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f5e99dd
feat(competition): per-agent MCP trade count + USD volume
biwasxyz May 11, 2026
025550a
feat(agents): enrich /agents SSR with MCP submission count + USD volume
biwasxyz May 11, 2026
29c1353
feat(competition): extend AgentTradeSummary with latestTradeAt
biwasxyz May 11, 2026
3ac65de
feat(agents): thread mcpLatestTradeAt through SSR data
biwasxyz May 11, 2026
e89342d
feat(agents): MCP Trades + Volume + Latest Trade columns on /agents
biwasxyz May 11, 2026
3341ea7
fix(competition): log Tenero failures + send explicit User-Agent
biwasxyz May 11, 2026
c354ebb
fix(competition): route Tenero diagnostics through Logger (lint)
biwasxyz May 11, 2026
1018616
revert: drop volume.ts + /agents enrichment, switch to /leaderboard page
biwasxyz May 11, 2026
c4d7c30
feat(leaderboard): /leaderboard page (server-rendered, D1-only)
biwasxyz May 11, 2026
85c5a77
feat(leaderboard): LeaderboardClient with browser-side Tenero + local…
biwasxyz May 11, 2026
3ad63e6
feat(navbar): Leaderboard link in desktop + mobile menus
biwasxyz May 11, 2026
7450e0f
copy(leaderboard): tighten subtitle + metadata
biwasxyz May 11, 2026
38fa71a
feat(leaderboard): agent avatars in desktop + mobile rows
biwasxyz May 11, 2026
a7cce1b
style(navbar): de-button desktop nav links — text-only with hover
biwasxyz May 11, 2026
56363d1
refactor(leaderboard): drop KV agent-list cache, read display data di…
biwasxyz May 12, 2026
151f472
perf(leaderboard): single LEFT JOIN query + 60s ISR window
biwasxyz May 12, 2026
ff9c19b
fix(leaderboard): use async-mode getCloudflareContext so revalidate=6…
biwasxyz May 12, 2026
9c744a0
feat(tenero): typed fetch wrapper modeled on stacks-api-fetch
biwasxyz May 12, 2026
8017e59
feat(tenero): fetchTokenPriceUsd built on the fetch wrapper
biwasxyz May 12, 2026
3728731
feat(tenero): KV cache helpers for tenero:price:{tokenId}
biwasxyz May 12, 2026
b779108
chore(tenero): barrel export for lib/external/tenero
biwasxyz May 12, 2026
88a3bc5
feat(scheduler): SchedulerDO + alarm for Tenero price refresh
biwasxyz May 12, 2026
2d55c71
types(env): add SCHEDULER + optional TENERO_API_KEY bindings
biwasxyz May 12, 2026
37c816a
config(wrangler): register SchedulerDO binding + v1 migration
biwasxyz May 12, 2026
c431504
feat(worker): export SchedulerDO so the runtime finds the class
biwasxyz May 12, 2026
0226d72
feat(leaderboard): SSR volumeUsd from KV-cached Tenero prices
biwasxyz May 12, 2026
5107255
refactor(leaderboard): drop browser Tenero fetch — presentational client
biwasxyz May 12, 2026
74c9ea7
feat(leaderboard): opportunistic SchedulerDO kick on SSR
biwasxyz May 12, 2026
ff1eca2
chore: rebuild to exercise versions upload + get branch preview URL
biwasxyz May 12, 2026
f05f13a
fix(scheduler): inline SchedulerDO in worker.ts to survive bundling
biwasxyz May 12, 2026
46e6bad
fix(scheduler): RPC-compatible typing for SCHEDULER binding
biwasxyz May 12, 2026
b2dd3e9
refactor(tenero): extract STATIC_TOKEN_IDS to shared module
biwasxyz May 12, 2026
9a99183
refactor(scheduler): extract runTeneroTask to lib/scheduler/
biwasxyz May 12, 2026
f7232b3
refactor(scheduler): apply #743 review feedback to SchedulerDO
biwasxyz May 12, 2026
c33602c
feat(api): GET /api/prices route for cached Tenero prices
biwasxyz May 12, 2026
922bddf
test(tenero): unit tests for prices + kv-cache helpers
biwasxyz May 12, 2026
da3227e
test(scheduler): unit tests for runTeneroTask
biwasxyz May 12, 2026
daf6d5e
fix(leaderboard): BigInt-safe SUM aggregate parse + correct scheduler…
biwasxyz May 12, 2026
dd54ec0
fix(balances): BigInt-parse sBTC satoshi string to preserve precision
biwasxyz May 12, 2026
6e5dcfe
docs(inbox): clarify UNIQUE-violation substring match in d1-dual-write
biwasxyz May 12, 2026
d72559e
fix(leaderboard): render dynamically for Cloudflare bindings
whoabuddy May 12, 2026
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
188 changes: 188 additions & 0 deletions app/api/prices/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* GET /api/prices — cached USD prices for supported Stacks tokens.
*
* Data comes from KV (`tenero:price:{tokenId}`), which the `SchedulerDO`
* refreshes every ~5 min by calling Tenero. This route is a pure read
* surface — it never calls Tenero itself, so its cost scales with KV
* reads, not upstream API quota.
*
* Shapes:
* - `GET /api/prices` with `Accept: application/json` → all cached prices
* - `GET /api/prices?token={tokenId}` with `Accept: application/json` → single token
* - `GET /api/prices` without `application/json` in `Accept` → self-doc
*
* Rate-limited via the existing `RATE_LIMIT_READ` binding (300 req / 60 s
* per IP). Fails open on binding errors in local dev, closed otherwise —
* matches the project convention (#666).
*
* Adding a new priceable token: edit `STATIC_TOKEN_IDS` in
* `lib/external/tenero/tokens.ts` AND `TOKEN_DECIMALS` in
* `app/leaderboard/page.tsx`. Run a Tenero probe first to confirm the
* contract id has a non-null `price_usd`.
*/

import { NextRequest, NextResponse } from "next/server";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import {
getCachedTokenPrice,
getCachedTokenPrices,
} from "@/lib/external/tenero/kv-cache";
import { STATIC_TOKEN_IDS } from "@/lib/external/tenero/tokens";
import { createConsoleLogger, createLogger, isLogsRPC } from "@/lib/logging";

const RATE_LIMIT_RETRY_AFTER = 60;

/**
* In production / preview, fail closed when the rate-limit binding errors
* (matches #666 convention via `DEPLOY_ENV !== undefined`).
*/
function shouldFailClosed(env: CloudflareEnv): boolean {
return env.DEPLOY_ENV !== undefined;
}

function acceptsJson(request: NextRequest): boolean {
const accept = request.headers.get("Accept") ?? "";
return accept.toLowerCase().includes("application/json");
}

function selfDoc(): NextResponse {
return NextResponse.json(
{
endpoint: "/api/prices",
description:
"USD prices for supported Stacks tokens. Cached by the SchedulerDO " +
"(~5 min refresh cadence) from Tenero. Read-only — no upstream calls.",
methods: {
"GET /api/prices": {
accept: "application/json",
description: "Return cached USD prices for all supported tokens.",
response: {
prices: {
"{tokenId}": {
priceUsd:
"number | null — USD price; null when Tenero confirmed no published price",
fetchedAt: "number — unix millis when the cache entry was written",
},
},
supportedTokens:
"string[] — full list of tokenIds the scheduler refreshes",
},
},
"GET /api/prices?token={tokenId}": {
accept: "application/json",
description: "Return a single token's cached price.",
response: {
tokenId: "string",
priceUsd: "number | null",
fetchedAt: "number | null — null when no cache entry exists yet",
},
},
},
supportedTokens: STATIC_TOKEN_IDS,
addingATokenRequires: [
"Adding to STATIC_TOKEN_IDS in lib/external/tenero/tokens.ts",
"Adding to TOKEN_DECIMALS in app/leaderboard/page.tsx",
"Probing https://api.tenero.io/v1/stacks/tokens/{contract_id} for a non-null price_usd",
],
rateLimit: "300 req / 60 s per IP (RATE_LIMIT_READ binding)",
},
{
headers: {
"Cache-Control": "public, max-age=300, s-maxage=3600",
},
}
);
}

export async function GET(request: NextRequest) {
const { env, ctx } = await getCloudflareContext({ async: true });
const rayId = request.headers.get("cf-ray") ?? crypto.randomUUID();
const logger = isLogsRPC(env.LOGS)
? createLogger(env.LOGS, ctx, { rayId, path: "/api/prices" })
: createConsoleLogger({ rayId, path: "/api/prices" });

if (!acceptsJson(request)) {
return selfDoc();
}

// Rate-limit by IP. RATE_LIMIT_READ is a 300/60s bucket; KV reads are
// cheap so this is the right size. Fails closed in deployed envs.
const ip =
request.headers.get("cf-connecting-ip") ||
request.headers.get("x-forwarded-for") ||
"unknown";
let limited = false;
try {
const result = await env.RATE_LIMIT_READ.limit({ key: `prices:${ip}` });
limited = !result.success;
} catch (err) {
const failClosed = shouldFailClosed(env);
logger.warn("prices.rate_limit_binding_error", {
error: String(err),
failClosed,
});
if (failClosed) limited = true;
}
if (limited) {
return NextResponse.json(
{
error: "Too many requests. Slow down.",
retryAfter: RATE_LIMIT_RETRY_AFTER,
},
{
status: 429,
headers: { "Retry-After": String(RATE_LIMIT_RETRY_AFTER) },
}
);
}

const kv = env.VERIFIED_AGENTS;
if (!kv) {
return NextResponse.json(
{ error: "Price cache unavailable in this environment." },
{ status: 503 }
);
}

const url = new URL(request.url);
const token = url.searchParams.get("token");

// Single-token lookup
if (token) {
const cached = await getCachedTokenPrice(kv, token);
return NextResponse.json(
{
tokenId: token,
priceUsd: cached?.priceUsd ?? null,
fetchedAt: cached?.fetchedAt ?? null,
},
{
headers: {
"Cache-Control": "public, max-age=30, s-maxage=60",
},
}
);
}

// Full set
const cached = await getCachedTokenPrices(kv, STATIC_TOKEN_IDS);
const prices: Record<string, { priceUsd: number | null; fetchedAt: number }> =
{};
for (const [tokenId, entry] of cached) {
prices[tokenId] = {
priceUsd: entry.priceUsd,
fetchedAt: entry.fetchedAt,
};
}
return NextResponse.json(
{
prices,
supportedTokens: STATIC_TOKEN_IDS,
},
{
headers: {
"Cache-Control": "public, max-age=30, s-maxage=60",
},
}
);
}
4 changes: 3 additions & 1 deletion app/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,15 @@ export default function Navbar() {

{[
{ href: "/agents", label: "Agent Network" },
{ href: "/leaderboard", label: "Leaderboard" },
{ href: "/activity", label: "Activity Feed" },
{ href: "/bounty", label: "Bounties" },
{ href: "/skills", label: "Skills" },
].map((link) => (
<Link
key={link.href}
href={link.href}
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-[rgba(30,30,30,0.8)] backdrop-blur-sm px-2.5 py-1.5 text-xs lg:px-4 lg:py-2 lg:text-sm font-medium text-white/80 transition-[background-color,border-color,color,transform] duration-200 hover:border-white/25 hover:bg-[rgba(45,45,45,0.85)] hover:text-white active:scale-[0.97]"
className="inline-flex items-center px-2 py-1 text-xs lg:px-3 lg:text-sm font-medium text-white/60 transition-colors duration-200 hover:text-white"
>
{link.label}
</Link>
Expand All @@ -173,6 +174,7 @@ export default function Navbar() {
>
{[
{ href: "/agents", label: "Agent Network" },
{ href: "/leaderboard", label: "Leaderboard" },
{ href: "/activity", label: "Activity Feed" },
{ href: "/bounty", label: "Bounties" },
{ href: "/skills", label: "Skills" },
Expand Down
Loading
Loading