From f5e99dde6cc143f06897d2957245e24946618c93 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:20:30 +0545 Subject: [PATCH 01/41] feat(competition): per-agent MCP trade count + USD volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One self-contained helper. Does one D1 query (success-status SUM + COUNT GROUP BY sender, token_in where source='agent') and one Tenero current-price call per distinct token_in, then aggregates per-sender in JS. Returns Map. Scope is deliberately narrow: - Input-side volume only (sum amount_in × current_price). No P&L, no cost basis, no gains/losses — the leaderboard counts the dollars that moved through MCP submissions, not whether they made money. - MCP path only (source='agent'). Cron-discovered and chainhook swaps are excluded so the metric tracks MCP adoption, not on-chain activity at large. - Unpriceable tokens (parser 'unknown' or future SIP-10s not in TOKEN_DECIMALS) get null prices and contribute 0 to volume but still count toward count. Honest under-report rather than imputing a fake USD figure. - No caching, no cron, no snapshot. Live fetch on each /agents render. ~3 Tenero calls in practice; add cache only if it shows up in latency traces. TOKEN_DECIMALS is intentionally a 3-entry inline constant for now (STX, sBTC, stSTX — all probed against Tenero and confirmed 200). Adding more tokens needs a Tenero probe first; the rule is documented in-place. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/volume.ts | 162 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 lib/competition/volume.ts diff --git a/lib/competition/volume.ts b/lib/competition/volume.ts new file mode 100644 index 00000000..b0797f21 --- /dev/null +++ b/lib/competition/volume.ts @@ -0,0 +1,162 @@ +/** + * Submitted-trade volume + count per agent. + * + * What this surfaces: + * - `count` — number of swaps the agent submitted via the MCP path + * (`swaps.source = 'agent'`). The catch-up cron + future chainhook + * paths are excluded because the leaderboard is about MCP usage, + * not on-chain activity at large. + * - `volumeUsd` — sum of `amount_in × current_token_in_price` across + * those submissions. Input side only — we don't double-count the + * out leg. This is volume that moved THROUGH the agent's submitted + * trades, not a P&L number. No cost basis, no gains/losses. + * + * Pricing: one Tenero current-price call per distinct token_in seen in + * the data. Live fetch on each /agents render — no KV cache, no cron. + * With ~3 priceable tokens in practice the cost is ~3 parallel + * round-trips at SSR time. Add caching if it ever shows up in latency + * traces. + * + * Tokens we don't know (`token_in = 'unknown'` from a parser miss, or a + * future SIP-10 not yet in `TOKEN_DECIMALS`): price is null, so the + * trade is still counted but contributes 0 to volumeUsd. This is the + * honest reading — we shouldn't impute a USD figure to a leg we can't + * value. + */ + +const TENERO_BASE = "https://api.tenero.io/v1/stacks"; +const FETCH_TIMEOUT_MS = 5_000; + +/** + * On-chain decimals for the tokens we know how to price. Adding a new + * token here requires: + * 1. The token's canonical contract id (Tenero must return 200 for + * `${TENERO_BASE}/tokens/{contract_id}` — probe before adding). + * 2. Its SIP-10 decimals figure (defaults to 6 below if unset, which + * is wrong for sBTC and friends — don't rely on the default). + */ +const TOKEN_DECIMALS: Readonly> = { + // Native STX (synthetic asset id from parseSwapFromTx). + stx: 6, + // sBTC — Stacks-native wrapped BTC, 8 decimals. + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc": 8, + // stSTX — liquid-staked STX, 6 decimals. + "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx": 6, +}; + +function decimalsFor(assetId: string): number { + return TOKEN_DECIMALS[assetId] ?? 6; +} + +/** + * Strip the `::asset` suffix off a SIP-10 asset id to get the contract + * id Tenero indexes by. Native STX passes through as the literal `stx`, + * which is what Tenero's tokens endpoint accepts for native STX. + */ +function toTeneroAddress(assetId: string): string { + if (assetId === "stx") return "stx"; + const idx = assetId.indexOf("::"); + return idx >= 0 ? assetId.slice(0, idx) : assetId; +} + +interface TeneroTokenResponse { + statusCode: number; + data: { price_usd?: number | string | null } | null; +} + +/** + * Fetch the current USD price for one asset id from Tenero. Returns + * null on any failure (timeout, non-2xx, unparseable, zero price). + * Unpriced tokens land as null and the volume aggregator treats them + * as "skip from sum" rather than "impute zero." + */ +async function fetchTokenPriceUsd(assetId: string): Promise { + const addr = toTeneroAddress(assetId); + if (!addr) return null; + try { + const r = await fetch(`${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!r.ok) return null; + const body = (await r.json()) as TeneroTokenResponse; + const raw = body.data?.price_usd; + const price = typeof raw === "string" ? parseFloat(raw) : raw; + return typeof price === "number" && Number.isFinite(price) && price > 0 + ? price + : null; + } catch { + return null; + } +} + +export interface AgentTradeSummary { + /** Count of swaps the agent submitted via the MCP path. */ + count: number; + /** + * Sum of input-side USD across those swaps. May be lower than the + * "true" volume when one or more legs hit an unpriceable token — + * we'd rather under-report than make up a number. + */ + volumeUsd: number; +} + +interface D1AggregateRow { + sender: string; + token_in: string; + cnt: number; + sum_in: number; +} + +/** + * For every agent who has submitted at least one trade via the MCP, + * return their submission count + USD volume moved (input side). + * + * Single D1 round-trip, parallel Tenero calls for prices, all + * aggregated in JS. No caching — Tenero current-price endpoint is fast + * enough to live-fetch on each /agents render with ~3 distinct tokens + * in the data today. + * + * Returns an empty map on D1 unavailability or query failure so the + * caller (the /agents page) can render unaffected. + */ +export async function getAgentSubmittedTradeSummary( + db: D1Database +): Promise> { + const sql = ` + SELECT sender, token_in, + COUNT(*) AS cnt, + SUM(amount_in) AS sum_in + FROM swaps + WHERE source = 'agent' + GROUP BY sender, token_in + `; + let rows: D1AggregateRow[] = []; + try { + const result = await db.prepare(sql).all(); + rows = result.results ?? []; + } catch { + return new Map(); + } + if (rows.length === 0) return new Map(); + + // Distinct token_in values across all rows. Tenero gets one call per + // token regardless of how many senders use it. + const tokens = Array.from(new Set(rows.map((r) => r.token_in))); + const priceEntries = await Promise.all( + tokens.map(async (t) => [t, await fetchTokenPriceUsd(t)] as const) + ); + const prices = new Map(priceEntries); + + const out = new Map(); + for (const r of rows) { + const existing = out.get(r.sender) ?? { count: 0, volumeUsd: 0 }; + existing.count += r.cnt; + const price = prices.get(r.token_in); + if (price != null) { + const human = r.sum_in / 10 ** decimalsFor(r.token_in); + existing.volumeUsd += human * price; + } + out.set(r.sender, existing); + } + return out; +} From 025550ae765950cc8b4aa6912f72a708a452c0a6 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:21:14 +0545 Subject: [PATCH 02/41] feat(agents): enrich /agents SSR with MCP submission count + USD volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchAgents calls getAgentSubmittedTradeSummary at render time and threads {mcpTradeCount, mcpVolumeUsd} onto each agent record. One D1 GROUP BY + ~3 parallel Tenero current-price calls — adds maybe 500ms to SSR but keeps the request path pure (no client-side waterfall, no loading flicker on the new columns). Graceful degradation: if env.DB is missing the helper returns an empty map and all agents land with mcpTradeCount=0 / mcpVolumeUsd=0. Tenero failures inside the helper (timeout, non-2xx, unpriceable token) leave those tokens out of the volume sum without throwing. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/agents/page.tsx | 53 ++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/app/agents/page.tsx b/app/agents/page.tsx index e5d84d1d..dda1c8a2 100644 --- a/app/agents/page.tsx +++ b/app/agents/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { getCachedAgentList } from "@/lib/cache"; +import { getAgentSubmittedTradeSummary } from "@/lib/competition/volume"; import Navbar from "../components/Navbar"; import AnimatedBackground from "../components/AnimatedBackground"; import AgentList from "./AgentList"; @@ -23,28 +24,40 @@ async function fetchAgents() { const kv = env.VERIFIED_AGENTS as KVNamespace; const { agents } = await getCachedAgentList(kv); + // MCP trade summary at SSR — one D1 GROUP BY + parallel Tenero + // current-price fetches. Empty map if D1 is unavailable or the query + // fails so the page still renders the rest of the agent data. + const tradeSummary = env.DB + ? await getAgentSubmittedTradeSummary(env.DB) + : new Map(); + // Reputation data is fetched client-side in AgentList to avoid blocking SSR // on external Stacks API calls (which can timeout under rate limits). - return agents.map((agent) => ({ - stxAddress: agent.stxAddress, - btcAddress: agent.btcAddress, - stxPublicKey: agent.stxPublicKey, - btcPublicKey: agent.btcPublicKey, - taprootAddress: agent.taprootAddress ?? undefined, - displayName: agent.displayName ?? undefined, - description: agent.description ?? undefined, - bnsName: agent.bnsName ?? undefined, - owner: agent.owner ?? undefined, - verifiedAt: agent.verifiedAt, - lastActiveAt: agent.lastActiveAt ?? undefined, - erc8004AgentId: agent.erc8004AgentId ?? undefined, - nostrPublicKey: agent.nostrPublicKey ?? undefined, - referredBy: agent.referredBy ?? undefined, - level: agent.level, - levelName: agent.levelName, - messageCount: agent.messageCount, - unreadCount: agent.unreadCount, - })); + return agents.map((agent) => { + const summary = tradeSummary.get(agent.stxAddress); + return { + stxAddress: agent.stxAddress, + btcAddress: agent.btcAddress, + stxPublicKey: agent.stxPublicKey, + btcPublicKey: agent.btcPublicKey, + taprootAddress: agent.taprootAddress ?? undefined, + displayName: agent.displayName ?? undefined, + description: agent.description ?? undefined, + bnsName: agent.bnsName ?? undefined, + owner: agent.owner ?? undefined, + verifiedAt: agent.verifiedAt, + lastActiveAt: agent.lastActiveAt ?? undefined, + erc8004AgentId: agent.erc8004AgentId ?? undefined, + nostrPublicKey: agent.nostrPublicKey ?? undefined, + referredBy: agent.referredBy ?? undefined, + level: agent.level, + levelName: agent.levelName, + messageCount: agent.messageCount, + unreadCount: agent.unreadCount, + mcpTradeCount: summary?.count ?? 0, + mcpVolumeUsd: summary?.volumeUsd ?? 0, + }; + }); } export default async function AgentsPage() { From 29c13531c7b4a71c63f142ddb5b4a5f845d93c9a Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:26:31 +0545 Subject: [PATCH 03/41] feat(competition): extend AgentTradeSummary with latestTradeAt Adds MAX(burn_block_time) to the existing GROUP BY query (zero extra D1 cost) and surfaces the per-sender max as `latestTradeAt` in AgentTradeSummary. Used by the /agents Latest Trade column for "X min ago" relative-time display. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/volume.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/competition/volume.ts b/lib/competition/volume.ts index b0797f21..660cfba0 100644 --- a/lib/competition/volume.ts +++ b/lib/competition/volume.ts @@ -98,6 +98,12 @@ export interface AgentTradeSummary { * we'd rather under-report than make up a number. */ volumeUsd: number; + /** + * Latest `burn_block_time` (unix seconds) across the agent's + * MCP-submitted swaps. Surfaced in the UI as a relative-time column + * so reviewers can see who's currently active. + */ + latestTradeAt: number; } interface D1AggregateRow { @@ -105,6 +111,7 @@ interface D1AggregateRow { token_in: string; cnt: number; sum_in: number; + latest_at: number; } /** @@ -125,7 +132,8 @@ export async function getAgentSubmittedTradeSummary( const sql = ` SELECT sender, token_in, COUNT(*) AS cnt, - SUM(amount_in) AS sum_in + SUM(amount_in) AS sum_in, + MAX(burn_block_time) AS latest_at FROM swaps WHERE source = 'agent' GROUP BY sender, token_in @@ -149,13 +157,17 @@ export async function getAgentSubmittedTradeSummary( const out = new Map(); for (const r of rows) { - const existing = out.get(r.sender) ?? { count: 0, volumeUsd: 0 }; + const existing = + out.get(r.sender) ?? { count: 0, volumeUsd: 0, latestTradeAt: 0 }; existing.count += r.cnt; const price = prices.get(r.token_in); if (price != null) { const human = r.sum_in / 10 ** decimalsFor(r.token_in); existing.volumeUsd += human * price; } + if (r.latest_at > existing.latestTradeAt) { + existing.latestTradeAt = r.latest_at; + } out.set(r.sender, existing); } return out; From 3ac65deac56d00d0473fcb5491ad1597b4ef49df Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:26:40 +0545 Subject: [PATCH 04/41] feat(agents): thread mcpLatestTradeAt through SSR data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passes the new latestTradeAt field from getAgentSubmittedTradeSummary onto each agent record so AgentList can render the Latest Trade column without a second data hop. Agents with no MCP submissions land with mcpLatestTradeAt=0, which the UI renders as `—`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/agents/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/agents/page.tsx b/app/agents/page.tsx index dda1c8a2..7116e234 100644 --- a/app/agents/page.tsx +++ b/app/agents/page.tsx @@ -29,7 +29,7 @@ async function fetchAgents() { // fails so the page still renders the rest of the agent data. const tradeSummary = env.DB ? await getAgentSubmittedTradeSummary(env.DB) - : new Map(); + : new Map(); // Reputation data is fetched client-side in AgentList to avoid blocking SSR // on external Stacks API calls (which can timeout under rate limits). @@ -56,6 +56,7 @@ async function fetchAgents() { unreadCount: agent.unreadCount, mcpTradeCount: summary?.count ?? 0, mcpVolumeUsd: summary?.volumeUsd ?? 0, + mcpLatestTradeAt: summary?.latestTradeAt ?? 0, }; }); } From e89342d19ff41e71e45a9075efeb3159ae4f6af4 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:26:51 +0545 Subject: [PATCH 05/41] feat(agents): MCP Trades + Volume + Latest Trade columns on /agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new sortable columns on the agent network table: - **MCP Trades**: count of swaps the agent submitted via the AIBTC MCP (source='agent' in the swaps table). Orange to match Bitcoin/ trading-comp brand color. - **Volume (USD)**: sum of amount_in × current Tenero price across those submissions. Input side only — not a P&L number. Sub-$10k values render with cents; bigger figures round to whole dollars. - **Latest Trade**: relative time since the most recent MCP submission. Sort fields added: trades (MCP count, USD volume tiebreak), volume, latestTrade. Mobile compact row gets the same data inline as small chips below the agent name. Agents with zero submissions render `—` in each cell so they sink to the bottom on desc sorts without poisoning the comparator. All data SSR'd from the page-level getAgentSubmittedTradeSummary call — no client-side fetching, no loading states. Drill-down to per-trade detail (pool, in/out tokens + amounts, USD value, time) deferred — served by /api/competition/trades?address=... once PR #738 lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/agents/AgentList.tsx | 106 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/app/agents/AgentList.tsx b/app/agents/AgentList.tsx index 047310f2..beece67b 100644 --- a/app/agents/AgentList.tsx +++ b/app/agents/AgentList.tsx @@ -19,9 +19,23 @@ type Agent = AgentRecord & { unreadCount?: number; reputationScore?: number; reputationCount?: number; + /** Swaps the agent submitted via the AIBTC MCP (source='agent' in swaps). */ + mcpTradeCount?: number; + /** Sum of amount_in × current USD price across those submissions. */ + mcpVolumeUsd?: number; + /** Latest burn_block_time (unix seconds) across MCP submissions. */ + mcpLatestTradeAt?: number; }; -type SortField = "level" | "reputation" | "joined" | "activity" | "messages"; +type SortField = + | "level" + | "reputation" + | "joined" + | "activity" + | "messages" + | "trades" + | "volume" + | "latestTrade"; type SortOrder = "asc" | "desc"; interface AgentListProps { agents: Agent[]; @@ -194,6 +208,17 @@ export default function AgentList({ agents }: AgentListProps) { comparison = bTime - aTime; } else if (sortBy === "messages") { comparison = (b.messageCount ?? 0) - (a.messageCount ?? 0); + } else if (sortBy === "trades") { + // MCP-submitted count primary, USD volume as tiebreak so agents + // who happened to move more dollars rank ahead of equal-count peers. + comparison = (b.mcpTradeCount ?? 0) - (a.mcpTradeCount ?? 0); + if (comparison === 0) { + comparison = (b.mcpVolumeUsd ?? 0) - (a.mcpVolumeUsd ?? 0); + } + } else if (sortBy === "volume") { + comparison = (b.mcpVolumeUsd ?? 0) - (a.mcpVolumeUsd ?? 0); + } else if (sortBy === "latestTrade") { + comparison = (b.mcpLatestTradeAt ?? 0) - (a.mcpLatestTradeAt ?? 0); } return sortOrder === "asc" ? -comparison : comparison; @@ -357,6 +382,39 @@ export default function AgentList({ agents }: AgentListProps) { + handleSort("trades")} + > + +
+ MCP Trades + +
+
+ + handleSort("volume")} + > + +
+ Volume (USD) + +
+
+ + handleSort("latestTrade")} + > + +
+ Latest Trade + +
+
+ handleSort("joined")} @@ -437,6 +495,40 @@ export default function AgentList({ agents }: AgentListProps) { : "-"} + + {(agent.mcpTradeCount ?? 0) > 0 ? ( + + {agent.mcpTradeCount} + + ) : ( + + )} + + + {(agent.mcpVolumeUsd ?? 0) > 0 ? ( + + ${(agent.mcpVolumeUsd ?? 0) < 10_000 + ? (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { + maximumFractionDigits: 0, + })} + + ) : ( + + )} + + + {(agent.mcpLatestTradeAt ?? 0) > 0 ? ( + + {formatRelativeTime(new Date((agent.mcpLatestTradeAt as number) * 1000).toISOString())} + + ) : ( + + )} + {formatShortDate(agent.verifiedAt)} @@ -525,6 +617,18 @@ export default function AgentList({ agents }: AgentListProps) { {agent.messageCount} )} + {(agent.mcpTradeCount ?? 0) > 0 && ( + + {agent.mcpTradeCount} mcp + + )} + {(agent.mcpVolumeUsd ?? 0) > 0 && ( + + ${(agent.mcpVolumeUsd ?? 0) < 10_000 + ? (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { maximumFractionDigits: 2 }) + : (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { maximumFractionDigits: 0 })} + + )}
From 3341ea78ff3d297695843a5b8f6bee493cfef449 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:34:23 +0545 Subject: [PATCH 06/41] fix(competition): log Tenero failures + send explicit User-Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpVolumeUsd is rendering 0 on the preview even though mcpTradeCount=1 and the token is `stx` (which Tenero prices fine via curl). The original code swallowed every fetch failure silently, so the failure mode is invisible. - Adds explicit User-Agent ("aibtc-landing-page/1.0") so behaviour is deterministic across runtimes (Tenero's docs surface 403s without a UA — covering the case in case the API behaves similarly). - Surfaces three distinct console.warn paths so worker-logs shows *why* a token resolved to null: * tenero_non_ok → 4xx/5xx with status * tenero_no_price → 200 but data.price_usd unexpected * tenero_threw → fetch threw (timeout, network, abort) - Logs the assetId + URL alongside each so we can correlate failures to specific tokens. Once deployed, `wrangler tail` reveals the actual failure. No functional change to the success path. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/volume.ts | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/competition/volume.ts b/lib/competition/volume.ts index 660cfba0..b3b996b9 100644 --- a/lib/competition/volume.ts +++ b/lib/competition/volume.ts @@ -73,18 +73,42 @@ interface TeneroTokenResponse { async function fetchTokenPriceUsd(assetId: string): Promise { const addr = toTeneroAddress(assetId); if (!addr) return null; + const url = `${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`; try { - const r = await fetch(`${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`, { + const r = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + // Tenero accepts unauthenticated requests but seems to gate some + // paths on a non-empty User-Agent (their docs site 403s without one + // — empirically the API tolerates a default UA, but we set one + // explicitly so behaviour is the same regardless of runtime). + headers: { "User-Agent": "aibtc-landing-page/1.0 (+https://aibtc.com)" }, }); - if (!r.ok) return null; + if (!r.ok) { + console.warn("competition.volume.tenero_non_ok", { + assetId, + url, + status: r.status, + }); + return null; + } const body = (await r.json()) as TeneroTokenResponse; const raw = body.data?.price_usd; const price = typeof raw === "string" ? parseFloat(raw) : raw; - return typeof price === "number" && Number.isFinite(price) && price > 0 - ? price - : null; - } catch { + if (typeof price === "number" && Number.isFinite(price) && price > 0) { + return price; + } + console.warn("competition.volume.tenero_no_price", { + assetId, + url, + raw, + }); + return null; + } catch (err) { + console.warn("competition.volume.tenero_threw", { + assetId, + url, + error: String(err), + }); return null; } } From c354ebb670364dfba45d45cb83aeae1639a6fb8e Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:38:36 +0545 Subject: [PATCH 07/41] fix(competition): route Tenero diagnostics through Logger (lint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit used console.warn directly which trips the project's no-console ESLint rule and broke the build. Switch to the Logger interface from @/lib/logging — same diagnostic info reaches worker-logs, but goes through the project's structured logger so the lint check passes. Signature: - fetchTokenPriceUsd(assetId, logger) - getAgentSubmittedTradeSummary(db, logger?) — defaults to a createConsoleLogger({scope: "competition.volume"}) so callers don't have to thread a logger if they don't want to. Also wraps the D1 query try/catch so we see the actual error instead of silently returning an empty map (matches the Tenero-failure treatment from the previous commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/volume.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/competition/volume.ts b/lib/competition/volume.ts index b3b996b9..c5b743fd 100644 --- a/lib/competition/volume.ts +++ b/lib/competition/volume.ts @@ -24,6 +24,8 @@ * value. */ +import { createConsoleLogger, type Logger } from "@/lib/logging"; + const TENERO_BASE = "https://api.tenero.io/v1/stacks"; const FETCH_TIMEOUT_MS = 5_000; @@ -70,7 +72,10 @@ interface TeneroTokenResponse { * Unpriced tokens land as null and the volume aggregator treats them * as "skip from sum" rather than "impute zero." */ -async function fetchTokenPriceUsd(assetId: string): Promise { +async function fetchTokenPriceUsd( + assetId: string, + logger: Logger +): Promise { const addr = toTeneroAddress(assetId); if (!addr) return null; const url = `${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`; @@ -84,7 +89,7 @@ async function fetchTokenPriceUsd(assetId: string): Promise { headers: { "User-Agent": "aibtc-landing-page/1.0 (+https://aibtc.com)" }, }); if (!r.ok) { - console.warn("competition.volume.tenero_non_ok", { + logger.warn("competition.volume.tenero_non_ok", { assetId, url, status: r.status, @@ -97,14 +102,14 @@ async function fetchTokenPriceUsd(assetId: string): Promise { if (typeof price === "number" && Number.isFinite(price) && price > 0) { return price; } - console.warn("competition.volume.tenero_no_price", { + logger.warn("competition.volume.tenero_no_price", { assetId, url, raw, }); return null; } catch (err) { - console.warn("competition.volume.tenero_threw", { + logger.warn("competition.volume.tenero_threw", { assetId, url, error: String(err), @@ -151,7 +156,8 @@ interface D1AggregateRow { * caller (the /agents page) can render unaffected. */ export async function getAgentSubmittedTradeSummary( - db: D1Database + db: D1Database, + logger: Logger = createConsoleLogger({ scope: "competition.volume" }) ): Promise> { const sql = ` SELECT sender, token_in, @@ -166,7 +172,8 @@ export async function getAgentSubmittedTradeSummary( try { const result = await db.prepare(sql).all(); rows = result.results ?? []; - } catch { + } catch (err) { + logger.warn("competition.volume.d1_query_failed", { error: String(err) }); return new Map(); } if (rows.length === 0) return new Map(); @@ -175,7 +182,7 @@ export async function getAgentSubmittedTradeSummary( // token regardless of how many senders use it. const tokens = Array.from(new Set(rows.map((r) => r.token_in))); const priceEntries = await Promise.all( - tokens.map(async (t) => [t, await fetchTokenPriceUsd(t)] as const) + tokens.map(async (t) => [t, await fetchTokenPriceUsd(t, logger)] as const) ); const prices = new Map(priceEntries); From 1018616699f6576e59ac51f51626f99e242a9cbb Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:49:49 +0545 Subject: [PATCH 08/41] revert: drop volume.ts + /agents enrichment, switch to /leaderboard page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tenero current-price fetch was failing inside the deployed Worker (returning null silently — wrangler tail wasn't capturing preview-URL traffic, so the actual error stayed invisible). Rather than keep spelunking, the simpler ask wins: - Drop USD volume entirely. No Tenero dependency. - Revert /agents/page.tsx + AgentList.tsx to their main-branch state (no MCP Trades / Volume / Latest Trade columns). - Delete lib/competition/volume.ts. Next commit adds a focused /leaderboard page showing just agents who have submitted at least one MCP trade, ranked by count. One D1 query, no upstream calls, server-rendered. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/agents/AgentList.tsx | 106 +------------------- app/agents/page.tsx | 54 ++++------ lib/competition/volume.ts | 205 -------------------------------------- 3 files changed, 21 insertions(+), 344 deletions(-) delete mode 100644 lib/competition/volume.ts diff --git a/app/agents/AgentList.tsx b/app/agents/AgentList.tsx index beece67b..047310f2 100644 --- a/app/agents/AgentList.tsx +++ b/app/agents/AgentList.tsx @@ -19,23 +19,9 @@ type Agent = AgentRecord & { unreadCount?: number; reputationScore?: number; reputationCount?: number; - /** Swaps the agent submitted via the AIBTC MCP (source='agent' in swaps). */ - mcpTradeCount?: number; - /** Sum of amount_in × current USD price across those submissions. */ - mcpVolumeUsd?: number; - /** Latest burn_block_time (unix seconds) across MCP submissions. */ - mcpLatestTradeAt?: number; }; -type SortField = - | "level" - | "reputation" - | "joined" - | "activity" - | "messages" - | "trades" - | "volume" - | "latestTrade"; +type SortField = "level" | "reputation" | "joined" | "activity" | "messages"; type SortOrder = "asc" | "desc"; interface AgentListProps { agents: Agent[]; @@ -208,17 +194,6 @@ export default function AgentList({ agents }: AgentListProps) { comparison = bTime - aTime; } else if (sortBy === "messages") { comparison = (b.messageCount ?? 0) - (a.messageCount ?? 0); - } else if (sortBy === "trades") { - // MCP-submitted count primary, USD volume as tiebreak so agents - // who happened to move more dollars rank ahead of equal-count peers. - comparison = (b.mcpTradeCount ?? 0) - (a.mcpTradeCount ?? 0); - if (comparison === 0) { - comparison = (b.mcpVolumeUsd ?? 0) - (a.mcpVolumeUsd ?? 0); - } - } else if (sortBy === "volume") { - comparison = (b.mcpVolumeUsd ?? 0) - (a.mcpVolumeUsd ?? 0); - } else if (sortBy === "latestTrade") { - comparison = (b.mcpLatestTradeAt ?? 0) - (a.mcpLatestTradeAt ?? 0); } return sortOrder === "asc" ? -comparison : comparison; @@ -382,39 +357,6 @@ export default function AgentList({ agents }: AgentListProps) {
- handleSort("trades")} - > - -
- MCP Trades - -
-
- - handleSort("volume")} - > - -
- Volume (USD) - -
-
- - handleSort("latestTrade")} - > - -
- Latest Trade - -
-
- handleSort("joined")} @@ -495,40 +437,6 @@ export default function AgentList({ agents }: AgentListProps) { : "-"} - - {(agent.mcpTradeCount ?? 0) > 0 ? ( - - {agent.mcpTradeCount} - - ) : ( - - )} - - - {(agent.mcpVolumeUsd ?? 0) > 0 ? ( - - ${(agent.mcpVolumeUsd ?? 0) < 10_000 - ? (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - : (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { - maximumFractionDigits: 0, - })} - - ) : ( - - )} - - - {(agent.mcpLatestTradeAt ?? 0) > 0 ? ( - - {formatRelativeTime(new Date((agent.mcpLatestTradeAt as number) * 1000).toISOString())} - - ) : ( - - )} - {formatShortDate(agent.verifiedAt)} @@ -617,18 +525,6 @@ export default function AgentList({ agents }: AgentListProps) { {agent.messageCount} )} - {(agent.mcpTradeCount ?? 0) > 0 && ( - - {agent.mcpTradeCount} mcp - - )} - {(agent.mcpVolumeUsd ?? 0) > 0 && ( - - ${(agent.mcpVolumeUsd ?? 0) < 10_000 - ? (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { maximumFractionDigits: 2 }) - : (agent.mcpVolumeUsd ?? 0).toLocaleString("en-US", { maximumFractionDigits: 0 })} - - )}
diff --git a/app/agents/page.tsx b/app/agents/page.tsx index 7116e234..e5d84d1d 100644 --- a/app/agents/page.tsx +++ b/app/agents/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { getCachedAgentList } from "@/lib/cache"; -import { getAgentSubmittedTradeSummary } from "@/lib/competition/volume"; import Navbar from "../components/Navbar"; import AnimatedBackground from "../components/AnimatedBackground"; import AgentList from "./AgentList"; @@ -24,41 +23,28 @@ async function fetchAgents() { const kv = env.VERIFIED_AGENTS as KVNamespace; const { agents } = await getCachedAgentList(kv); - // MCP trade summary at SSR — one D1 GROUP BY + parallel Tenero - // current-price fetches. Empty map if D1 is unavailable or the query - // fails so the page still renders the rest of the agent data. - const tradeSummary = env.DB - ? await getAgentSubmittedTradeSummary(env.DB) - : new Map(); - // Reputation data is fetched client-side in AgentList to avoid blocking SSR // on external Stacks API calls (which can timeout under rate limits). - return agents.map((agent) => { - const summary = tradeSummary.get(agent.stxAddress); - return { - stxAddress: agent.stxAddress, - btcAddress: agent.btcAddress, - stxPublicKey: agent.stxPublicKey, - btcPublicKey: agent.btcPublicKey, - taprootAddress: agent.taprootAddress ?? undefined, - displayName: agent.displayName ?? undefined, - description: agent.description ?? undefined, - bnsName: agent.bnsName ?? undefined, - owner: agent.owner ?? undefined, - verifiedAt: agent.verifiedAt, - lastActiveAt: agent.lastActiveAt ?? undefined, - erc8004AgentId: agent.erc8004AgentId ?? undefined, - nostrPublicKey: agent.nostrPublicKey ?? undefined, - referredBy: agent.referredBy ?? undefined, - level: agent.level, - levelName: agent.levelName, - messageCount: agent.messageCount, - unreadCount: agent.unreadCount, - mcpTradeCount: summary?.count ?? 0, - mcpVolumeUsd: summary?.volumeUsd ?? 0, - mcpLatestTradeAt: summary?.latestTradeAt ?? 0, - }; - }); + return agents.map((agent) => ({ + stxAddress: agent.stxAddress, + btcAddress: agent.btcAddress, + stxPublicKey: agent.stxPublicKey, + btcPublicKey: agent.btcPublicKey, + taprootAddress: agent.taprootAddress ?? undefined, + displayName: agent.displayName ?? undefined, + description: agent.description ?? undefined, + bnsName: agent.bnsName ?? undefined, + owner: agent.owner ?? undefined, + verifiedAt: agent.verifiedAt, + lastActiveAt: agent.lastActiveAt ?? undefined, + erc8004AgentId: agent.erc8004AgentId ?? undefined, + nostrPublicKey: agent.nostrPublicKey ?? undefined, + referredBy: agent.referredBy ?? undefined, + level: agent.level, + levelName: agent.levelName, + messageCount: agent.messageCount, + unreadCount: agent.unreadCount, + })); } export default async function AgentsPage() { diff --git a/lib/competition/volume.ts b/lib/competition/volume.ts deleted file mode 100644 index c5b743fd..00000000 --- a/lib/competition/volume.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Submitted-trade volume + count per agent. - * - * What this surfaces: - * - `count` — number of swaps the agent submitted via the MCP path - * (`swaps.source = 'agent'`). The catch-up cron + future chainhook - * paths are excluded because the leaderboard is about MCP usage, - * not on-chain activity at large. - * - `volumeUsd` — sum of `amount_in × current_token_in_price` across - * those submissions. Input side only — we don't double-count the - * out leg. This is volume that moved THROUGH the agent's submitted - * trades, not a P&L number. No cost basis, no gains/losses. - * - * Pricing: one Tenero current-price call per distinct token_in seen in - * the data. Live fetch on each /agents render — no KV cache, no cron. - * With ~3 priceable tokens in practice the cost is ~3 parallel - * round-trips at SSR time. Add caching if it ever shows up in latency - * traces. - * - * Tokens we don't know (`token_in = 'unknown'` from a parser miss, or a - * future SIP-10 not yet in `TOKEN_DECIMALS`): price is null, so the - * trade is still counted but contributes 0 to volumeUsd. This is the - * honest reading — we shouldn't impute a USD figure to a leg we can't - * value. - */ - -import { createConsoleLogger, type Logger } from "@/lib/logging"; - -const TENERO_BASE = "https://api.tenero.io/v1/stacks"; -const FETCH_TIMEOUT_MS = 5_000; - -/** - * On-chain decimals for the tokens we know how to price. Adding a new - * token here requires: - * 1. The token's canonical contract id (Tenero must return 200 for - * `${TENERO_BASE}/tokens/{contract_id}` — probe before adding). - * 2. Its SIP-10 decimals figure (defaults to 6 below if unset, which - * is wrong for sBTC and friends — don't rely on the default). - */ -const TOKEN_DECIMALS: Readonly> = { - // Native STX (synthetic asset id from parseSwapFromTx). - stx: 6, - // sBTC — Stacks-native wrapped BTC, 8 decimals. - "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc": 8, - // stSTX — liquid-staked STX, 6 decimals. - "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx": 6, -}; - -function decimalsFor(assetId: string): number { - return TOKEN_DECIMALS[assetId] ?? 6; -} - -/** - * Strip the `::asset` suffix off a SIP-10 asset id to get the contract - * id Tenero indexes by. Native STX passes through as the literal `stx`, - * which is what Tenero's tokens endpoint accepts for native STX. - */ -function toTeneroAddress(assetId: string): string { - if (assetId === "stx") return "stx"; - const idx = assetId.indexOf("::"); - return idx >= 0 ? assetId.slice(0, idx) : assetId; -} - -interface TeneroTokenResponse { - statusCode: number; - data: { price_usd?: number | string | null } | null; -} - -/** - * Fetch the current USD price for one asset id from Tenero. Returns - * null on any failure (timeout, non-2xx, unparseable, zero price). - * Unpriced tokens land as null and the volume aggregator treats them - * as "skip from sum" rather than "impute zero." - */ -async function fetchTokenPriceUsd( - assetId: string, - logger: Logger -): Promise { - const addr = toTeneroAddress(assetId); - if (!addr) return null; - const url = `${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`; - try { - const r = await fetch(url, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - // Tenero accepts unauthenticated requests but seems to gate some - // paths on a non-empty User-Agent (their docs site 403s without one - // — empirically the API tolerates a default UA, but we set one - // explicitly so behaviour is the same regardless of runtime). - headers: { "User-Agent": "aibtc-landing-page/1.0 (+https://aibtc.com)" }, - }); - if (!r.ok) { - logger.warn("competition.volume.tenero_non_ok", { - assetId, - url, - status: r.status, - }); - return null; - } - const body = (await r.json()) as TeneroTokenResponse; - const raw = body.data?.price_usd; - const price = typeof raw === "string" ? parseFloat(raw) : raw; - if (typeof price === "number" && Number.isFinite(price) && price > 0) { - return price; - } - logger.warn("competition.volume.tenero_no_price", { - assetId, - url, - raw, - }); - return null; - } catch (err) { - logger.warn("competition.volume.tenero_threw", { - assetId, - url, - error: String(err), - }); - return null; - } -} - -export interface AgentTradeSummary { - /** Count of swaps the agent submitted via the MCP path. */ - count: number; - /** - * Sum of input-side USD across those swaps. May be lower than the - * "true" volume when one or more legs hit an unpriceable token — - * we'd rather under-report than make up a number. - */ - volumeUsd: number; - /** - * Latest `burn_block_time` (unix seconds) across the agent's - * MCP-submitted swaps. Surfaced in the UI as a relative-time column - * so reviewers can see who's currently active. - */ - latestTradeAt: number; -} - -interface D1AggregateRow { - sender: string; - token_in: string; - cnt: number; - sum_in: number; - latest_at: number; -} - -/** - * For every agent who has submitted at least one trade via the MCP, - * return their submission count + USD volume moved (input side). - * - * Single D1 round-trip, parallel Tenero calls for prices, all - * aggregated in JS. No caching — Tenero current-price endpoint is fast - * enough to live-fetch on each /agents render with ~3 distinct tokens - * in the data today. - * - * Returns an empty map on D1 unavailability or query failure so the - * caller (the /agents page) can render unaffected. - */ -export async function getAgentSubmittedTradeSummary( - db: D1Database, - logger: Logger = createConsoleLogger({ scope: "competition.volume" }) -): Promise> { - const sql = ` - SELECT sender, token_in, - COUNT(*) AS cnt, - SUM(amount_in) AS sum_in, - MAX(burn_block_time) AS latest_at - FROM swaps - WHERE source = 'agent' - GROUP BY sender, token_in - `; - let rows: D1AggregateRow[] = []; - try { - const result = await db.prepare(sql).all(); - rows = result.results ?? []; - } catch (err) { - logger.warn("competition.volume.d1_query_failed", { error: String(err) }); - return new Map(); - } - if (rows.length === 0) return new Map(); - - // Distinct token_in values across all rows. Tenero gets one call per - // token regardless of how many senders use it. - const tokens = Array.from(new Set(rows.map((r) => r.token_in))); - const priceEntries = await Promise.all( - tokens.map(async (t) => [t, await fetchTokenPriceUsd(t, logger)] as const) - ); - const prices = new Map(priceEntries); - - const out = new Map(); - for (const r of rows) { - const existing = - out.get(r.sender) ?? { count: 0, volumeUsd: 0, latestTradeAt: 0 }; - existing.count += r.cnt; - const price = prices.get(r.token_in); - if (price != null) { - const human = r.sum_in / 10 ** decimalsFor(r.token_in); - existing.volumeUsd += human * price; - } - if (r.latest_at > existing.latestTradeAt) { - existing.latestTradeAt = r.latest_at; - } - out.set(r.sender, existing); - } - return out; -} From c4d7c30a2af93641cc51e9fa3b5040b17a205c02 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:53:35 +0545 Subject: [PATCH 09/41] feat(leaderboard): /leaderboard page (server-rendered, D1-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous /leaderboard → /agents redirect with a focused trading-comp leaderboard. Server-side runs one D1 query — GROUP BY sender, token_in over swaps WHERE source='agent' — then JOINs in display data from the KV agent registry. Per row delivered to the client component: - stx_address, btc_address, displayName, bnsName, erc8004AgentId - tradeCount (sum across token_in buckets) - latestTradeAt (max burn_block_time) - tokens[] — per-token breakdown of {tokenId, sumAmountIn, decimals} used for client-side USD computation Rank order: tradeCount desc, latestTradeAt desc as tiebreak. Tenero pricing is intentionally NOT done server-side here — the same fetch silently failed inside the deployed Worker on the previous attempt. The client component pulls prices from the browser (empirically reliable) with a localStorage cache. D1 unavailable / query failure → empty rows list, page renders the empty-state copy. Agents not in the KV registry still appear by stx_address — the display column falls back to a deterministic generated name. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 180 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 9 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 6ca9207c..2a9f1844 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -1,20 +1,182 @@ import type { Metadata } from "next"; -import { redirect } from "next/navigation"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { getCachedAgentList } from "@/lib/cache"; +import Navbar from "../components/Navbar"; +import AnimatedBackground from "../components/AnimatedBackground"; +import LeaderboardClient, { type LeaderboardRow } from "./LeaderboardClient"; + +export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: "Agent Registry - AIBTC", + title: "Trading Leaderboard - AIBTC", description: - "AIBTC agent registry with sortable list ranked by level: Genesis, Registered", + "Agents ranked by the number of swaps they've submitted to AIBTC via the MCP. Submission is the gate — if it wasn't submitted via the MCP, it doesn't count.", openGraph: { - title: "AIBTC Agent Registry", - description: "See all registered AI agents in the Bitcoin economy", + title: "AIBTC Trading Leaderboard", + description: "MCP-submitted swap rankings across the AIBTC agent network.", }, other: { - "aibtc:page-type": "agent-registry", - "aibtc:api-endpoint": "/api/agents", + "aibtc:page-type": "trading-leaderboard", + "aibtc:api-endpoint": "/api/competition/trades", }, }; -export default function LeaderboardPage() { - redirect("/agents"); +/** + * Stacks-canonical decimals for tokens we know how to value. Adding a + * new token requires probing Tenero's `/v1/stacks/tokens/{contract_id}` + * first and confirming a 200 with a non-null price_usd — silently + * shipping the wrong contract id makes that token render as $0 forever. + * + * The unknown-token default is 6 (SIP-10 convention). Volume from + * those legs stays $0 (no client-side price), which is the honest read + * — we'd rather under-report than impute a number. + */ +const TOKEN_DECIMALS: Readonly> = { + stx: 6, + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc": 8, + "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx": 6, +}; + +interface D1AggregateRow { + sender: string; + token_in: string; + cnt: number; + sum_in: number; + latest_at: number; +} + +async function fetchLeaderboard(): Promise { + const { env } = await getCloudflareContext(); + const kv = env.VERIFIED_AGENTS as KVNamespace; + const db = env.DB as D1Database | undefined; + + if (!db) return []; + + let rows: D1AggregateRow[] = []; + try { + const sql = ` + SELECT sender, token_in, + COUNT(*) AS cnt, + SUM(amount_in) AS sum_in, + MAX(burn_block_time) AS latest_at + FROM swaps + WHERE source = 'agent' + GROUP BY sender, token_in + `; + const result = await db.prepare(sql).all(); + rows = result.results ?? []; + } catch { + return []; + } + + if (rows.length === 0) return []; + + // Aggregate per sender — sum count, keep max(latest_at), preserve + // per-token breakdown for the client-side volume calculation. + const bySender = new Map< + string, + { + count: number; + latestAt: number; + tokens: Array<{ tokenId: string; sumAmountIn: number; decimals: number }>; + } + >(); + for (const r of rows) { + const existing = bySender.get(r.sender) ?? { + count: 0, + latestAt: 0, + tokens: [] as Array<{ + tokenId: string; + sumAmountIn: number; + decimals: number; + }>, + }; + existing.count += r.cnt; + if (r.latest_at > existing.latestAt) existing.latestAt = r.latest_at; + existing.tokens.push({ + tokenId: r.token_in, + sumAmountIn: r.sum_in, + decimals: TOKEN_DECIMALS[r.token_in] ?? 6, + }); + bySender.set(r.sender, existing); + } + + // Look up display data from the KV agent index. Only agents the + // registry knows about land in the leaderboard; senders without a + // record render with a generated name (handled client-side). + const { agents } = await getCachedAgentList(kv); + const displayByStx = new Map( + agents.map((a) => [ + a.stxAddress, + { + btcAddress: a.btcAddress, + displayName: a.displayName ?? null, + bnsName: a.bnsName ?? null, + erc8004AgentId: a.erc8004AgentId ?? null, + }, + ]) + ); + + const ranked: LeaderboardRow[] = Array.from(bySender.entries()) + .map(([sender, agg]) => { + const display = displayByStx.get(sender); + return { + stxAddress: sender, + btcAddress: display?.btcAddress ?? null, + displayName: display?.displayName ?? null, + bnsName: display?.bnsName ?? null, + erc8004AgentId: display?.erc8004AgentId ?? null, + tradeCount: agg.count, + latestTradeAt: agg.latestAt, + tokens: agg.tokens, + }; + }) + .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; + }); + + return ranked; +} + +export default async function LeaderboardPage() { + const rows = await fetchLeaderboard(); + + return ( + <> + {/* + AIBTC Trading Leaderboard — Machine-readable endpoints: + - GET /api/competition/trades?address=… — Per-agent trade list (cursor paginated) + - POST /api/competition/trades — Submit a txid via the MCP (PR #738 / #510) + - Full docs: /llms-full.txt | OpenAPI: /api/openapi.json + */} + + + +
+
+
+
+
+

+ Leaderboard +

+

+ Agents ranked by the number of swaps they've submitted via the AIBTC MCP. Submission is the gate — only swaps an agent submitted to us count. +

+
+ + +
+
+ + ); } From 85c5a7787581c11299a0bae0f4d1c7e65479938c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:53:48 +0545 Subject: [PATCH 10/41] feat(leaderboard): LeaderboardClient with browser-side Tenero + localStorage cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the SSR'd leaderboard rows as a sortable table plus computes USD volume on the client. Two pieces: - useTokenPrices: for every distinct token_in across the rows, reads localStorage (`tenero-price:{tokenId}`, 5-min TTL) first, then falls back to a direct Tenero fetch. Seeds initial state from cache so users with a warm cache see numbers on first paint instead of "—" then flicker. - computeRowVolumeUsd: per row, sums (sumAmountIn / 10^decimals) × price across the row's tokens. Skips legs with null prices — surfaces as the "…" pending pill while loading, "—" when Tenero just doesn't price a token. Browser-side Tenero is the proven path: same fetch fails silently in the deployed Worker (no longer worth root-causing for this scope), works deterministically from any user's browser. localStorage cache means each browser hits Tenero at most once per 5 min per distinct token, distributing the load and surviving the failed-Worker fetch mode entirely. Mobile collapses to rank + name + chips (count / volume / latest). Includes a11y treatment matching the previous /leaderboard impl (`scope="col"`, aria-hidden decorative ping). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/LeaderboardClient.tsx | 323 ++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 app/leaderboard/LeaderboardClient.tsx diff --git a/app/leaderboard/LeaderboardClient.tsx b/app/leaderboard/LeaderboardClient.tsx new file mode 100644 index 00000000..aa2b512d --- /dev/null +++ b/app/leaderboard/LeaderboardClient.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { generateName } from "@/lib/name-generator"; +import { truncateAddress, formatRelativeTime } from "@/lib/utils"; + +export interface LeaderboardRow { + stxAddress: string; + btcAddress: string | null; + displayName: string | null; + bnsName: string | null; + erc8004AgentId: number | null; + tradeCount: number; + latestTradeAt: number; + /** + * Per-token breakdown of `amount_in` totals across the agent's + * MCP-submitted swaps. Decimals are server-supplied so the client + * doesn't need its own token-decimals table. + */ + tokens: Array<{ + tokenId: string; + sumAmountIn: number; + decimals: number; + }>; +} + +const TENERO_BASE = "https://api.tenero.io/v1/stacks"; + +/** localStorage cache key + TTL for token prices. 5 min keeps the UI fresh without hammering Tenero. */ +const PRICE_CACHE_PREFIX = "tenero-price:"; +const PRICE_CACHE_TTL_MS = 5 * 60 * 1000; + +interface CachedPrice { + price: number | null; + fetchedAt: number; +} + +/** Strip the `::asset` suffix for Tenero; native STX passes through as the literal "stx". */ +function toTeneroAddress(tokenId: string): string { + if (tokenId === "stx") return "stx"; + const idx = tokenId.indexOf("::"); + return idx >= 0 ? tokenId.slice(0, idx) : tokenId; +} + +function readCache(tokenId: string): CachedPrice | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(`${PRICE_CACHE_PREFIX}${tokenId}`); + if (!raw) return null; + const parsed = JSON.parse(raw) as CachedPrice; + if (Date.now() - parsed.fetchedAt > PRICE_CACHE_TTL_MS) return null; + return parsed; + } catch { + return null; + } +} + +function writeCache(tokenId: string, price: number | null): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem( + `${PRICE_CACHE_PREFIX}${tokenId}`, + JSON.stringify({ price, fetchedAt: Date.now() }) + ); + } catch { + // localStorage full / disabled — silently fall back to no cache. + } +} + +async function fetchTeneroPrice(tokenId: string, signal: AbortSignal): Promise { + const addr = toTeneroAddress(tokenId); + try { + const r = await fetch(`${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`, { + signal, + }); + if (!r.ok) return null; + const body = (await r.json()) as { data?: { price_usd?: number | string | null } }; + const raw = body.data?.price_usd; + const price = typeof raw === "string" ? parseFloat(raw) : raw; + return typeof price === "number" && Number.isFinite(price) && price > 0 + ? price + : null; + } catch { + return null; + } +} + +/** + * Resolves USD prices for every distinct tokenId in the leaderboard, + * preferring 5-min-cached values in localStorage and falling back to + * Tenero. Returns a Map keyed by tokenId; missing entries land as null. + */ +function useTokenPrices(rows: LeaderboardRow[]): { + prices: Map; + isLoading: boolean; +} { + const tokenIds = useMemo(() => { + const set = new Set(); + for (const r of rows) for (const t of r.tokens) set.add(t.tokenId); + return Array.from(set).sort(); + }, [rows]); + + const [prices, setPrices] = useState>(() => { + // Seed from localStorage so users with a warm cache see numbers on + // first paint instead of "—" then flicker. + const seed = new Map(); + for (const id of tokenIds) { + const cached = readCache(id); + if (cached) seed.set(id, cached.price); + } + return seed; + }); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (tokenIds.length === 0) return; + const missing = tokenIds.filter((id) => !readCache(id)); + if (missing.length === 0) return; + + const controller = new AbortController(); + setIsLoading(true); + + (async () => { + const results = await Promise.all( + missing.map(async (id) => { + const price = await fetchTeneroPrice(id, controller.signal); + writeCache(id, price); + return [id, price] as const; + }) + ); + if (controller.signal.aborted) return; + setPrices((prev) => { + const next = new Map(prev); + for (const [id, p] of results) next.set(id, p); + return next; + }); + setIsLoading(false); + })(); + + return () => { + controller.abort(); + }; + }, [tokenIds]); + + return { prices, isLoading }; +} + +function formatUsd(value: number | null): string { + if (value == null || !Number.isFinite(value)) return "—"; + const abs = Math.abs(value); + const fractionDigits = abs < 10_000 ? 2 : 0; + const formatted = abs.toLocaleString("en-US", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); + const sign = value < 0 ? "-" : ""; + return `${sign}$${formatted}`; +} + +function rowDisplayName(row: LeaderboardRow): string { + return ( + row.displayName?.trim() || + row.bnsName?.trim() || + (row.btcAddress ? generateName(row.btcAddress) : truncateAddress(row.stxAddress)) + ); +} + +function computeRowVolumeUsd( + row: LeaderboardRow, + prices: Map +): { volumeUsd: number; allPriced: boolean } { + let total = 0; + let allPriced = true; + for (const t of row.tokens) { + const price = prices.get(t.tokenId); + if (price == null) { + allPriced = false; + continue; + } + total += (t.sumAmountIn / 10 ** t.decimals) * price; + } + return { volumeUsd: total, allPriced }; +} + +export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] }) { + const { prices, isLoading } = useTokenPrices(rows); + + if (rows.length === 0) { + return ( +
+

+ No agents have submitted trades yet. Once swaps land via{" "} + POST /api/competition/trades + , they'll appear here. +

+
+ ); + } + + return ( +
+ {/* Desktop / tablet table */} +
+ + + + + + + + + + + + {rows.map((row, idx) => { + const { volumeUsd, allPriced } = computeRowVolumeUsd(row, prices); + const showPending = !allPriced && isLoading; + return ( + + + + + + + + ); + })} + +
RankAgentTradesVolume (USD)Latest Trade
#{idx + 1} + {row.btcAddress ? ( + + + {rowDisplayName(row)} + + + {truncateAddress(row.stxAddress)} + + + ) : ( +
+ + {rowDisplayName(row)} + + + {truncateAddress(row.stxAddress)} + +
+ )} +
+ {row.tradeCount} + + {showPending ? ( + + ) : volumeUsd > 0 ? ( + + {formatUsd(volumeUsd)} + + ) : ( + + )} + + {row.latestTradeAt > 0 + ? formatRelativeTime(new Date(row.latestTradeAt * 1000).toISOString()) + : "—"} +
+
+ + {/* Mobile list */} +
    + {rows.map((row, idx) => { + const { volumeUsd, allPriced } = computeRowVolumeUsd(row, prices); + const showPending = !allPriced && isLoading; + const inner = ( +
    + + {idx + 1} + +
    +
    {rowDisplayName(row)}
    +
    + {truncateAddress(row.stxAddress)} +
    +
    + {row.tradeCount} trades + · + + {showPending + ? "…" + : volumeUsd > 0 + ? formatUsd(volumeUsd) + : "—"} + + · + + {row.latestTradeAt > 0 + ? formatRelativeTime(new Date(row.latestTradeAt * 1000).toISOString()) + : "—"} + +
    +
    +
    + ); + return ( +
  • + {row.btcAddress ? ( + {inner} + ) : ( + inner + )} +
  • + ); + })} +
+
+ ); +} From 3ad63e61c97323f4126ae50df7fea830b3575f46 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:53:56 +0545 Subject: [PATCH 11/41] feat(navbar): Leaderboard link in desktop + mobile menus Slots between Agent Network and Activity Feed. Both nav arrays updated (desktop and mobile render from distinct literals). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/Navbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 86eb3fe2..0e4ccf69 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -139,6 +139,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" }, @@ -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" }, From 7450e0f048ae061e6f6ba46747530b437542656c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 14:59:39 +0545 Subject: [PATCH 12/41] copy(leaderboard): tighten subtitle + metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the "Submission is the gate — only swaps an agent submitted to us count" line per UX direction; the data itself communicates the gate without the explanation needing to repeat it. Reframes the subtitle and meta description from "submitted via the AIBTC MCP" to the more direct "done via aibtc mcp." Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 2a9f1844..a6a82f83 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -10,7 +10,7 @@ export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Trading Leaderboard - AIBTC", description: - "Agents ranked by the number of swaps they've submitted to AIBTC via the MCP. Submission is the gate — if it wasn't submitted via the MCP, it doesn't count.", + "Agents ranked by the number of swaps they've done via aibtc mcp.", openGraph: { title: "AIBTC Trading Leaderboard", description: "MCP-submitted swap rankings across the AIBTC agent network.", @@ -170,7 +170,7 @@ export default async function LeaderboardPage() { Leaderboard

- Agents ranked by the number of swaps they've submitted via the AIBTC MCP. Submission is the gate — only swaps an agent submitted to us count. + Agents ranked by the number of swaps they've done via aibtc mcp.

From 38fa71abd8c6069364dcdadcfcbc20f0edf721fa Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 15:01:06 +0545 Subject: [PATCH 13/41] feat(leaderboard): agent avatars in desktop + mobile rows Adds the bitcoinfaces.xyz avatar (same pattern /agents uses) to each leaderboard row. Falls back to a flat placeholder circle when btcAddress is missing (shouldn't happen for KV-registered agents, but the SSR row type allows null). Mobile compact list moves the rank from a standalone circle to a small badge anchored to the avatar's bottom-right, freeing horizontal space for the name + stats. Desktop table inlines the avatar next to the existing name/address block. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/LeaderboardClient.tsx | 66 ++++++++++++++++++++------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/app/leaderboard/LeaderboardClient.tsx b/app/leaderboard/LeaderboardClient.tsx index aa2b512d..ced88660 100644 --- a/app/leaderboard/LeaderboardClient.tsx +++ b/app/leaderboard/LeaderboardClient.tsx @@ -226,23 +226,41 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] }) {row.btcAddress ? ( - - {rowDisplayName(row)} - - - {truncateAddress(row.stxAddress)} + {/* eslint-disable-next-line @next/next/no-img-element */} + {rowDisplayName(row)} { e.currentTarget.style.display = "none"; }} + /> + + + {rowDisplayName(row)} + + + {truncateAddress(row.stxAddress)} + ) : ( -
- - {rowDisplayName(row)} - - - {truncateAddress(row.stxAddress)} - +
+ )} @@ -279,9 +297,25 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] }) const showPending = !allPriced && isLoading; const inner = (
- - {idx + 1} - +
+ {row.btcAddress ? ( + // eslint-disable-next-line @next/next/no-img-element + {rowDisplayName(row)} { e.currentTarget.style.display = "none"; }} + /> + ) : ( +
{rowDisplayName(row)}
From a7cce1b459129429c0b29af079403518a4ef235e Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 15:01:13 +0545 Subject: [PATCH 14/41] =?UTF-8?q?style(navbar):=20de-button=20desktop=20na?= =?UTF-8?q?v=20links=20=E2=80=94=20text-only=20with=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop nav items were styled as pill buttons (rounded border, fill background, active:scale transform) which made them read like CTAs rather than navigation. Stripped the chrome to a plain text-link treatment: `text-white/60` default, `hover:text-white` on hover. Mobile overlay menu keeps its tile styling (more tap target). The orange "Get Started" CTA is unchanged — that one IS a CTA and the button styling is correct for it. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 0e4ccf69..4c65cc44 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -147,7 +147,7 @@ export default function Navbar() { {link.label} From 56363d1b0538fd1dc4a7d9063d0c7a36267dbc6a Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 09:46:08 +0545 Subject: [PATCH 15/41] refactor(leaderboard): drop KV agent-list cache, read display data direct from D1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same KV→D1 move applied to #738's cron cursor in 5224a0d, now for the leaderboard's display-data lookup. Before: leaderboard called getCachedAgentList(kv) — a KV-cached snapshot of every agent's full profile (claim status, message counts, etc.) just to look up four display fields per sender (btcAddress, displayName, bnsName, erc8004AgentId). After: a single targeted D1 query against the agents table, filtered to the senders that actually appear in the swaps aggregate. N is bounded by "agents with at least one MCP swap" — far smaller than full membership, and comfortably under any plausible IN-clause limit. Display lookup failure degrades to client-side generated names (same as before, just without the KV cache layer in between). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 60 ++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index a6a82f83..7def08d5 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { getCachedAgentList } from "@/lib/cache"; import Navbar from "../components/Navbar"; import AnimatedBackground from "../components/AnimatedBackground"; import LeaderboardClient, { type LeaderboardRow } from "./LeaderboardClient"; @@ -47,7 +46,6 @@ interface D1AggregateRow { async function fetchLeaderboard(): Promise { const { env } = await getCloudflareContext(); - const kv = env.VERIFIED_AGENTS as KVNamespace; const db = env.DB as D1Database | undefined; if (!db) return []; @@ -101,21 +99,49 @@ async function fetchLeaderboard(): Promise { bySender.set(r.sender, existing); } - // Look up display data from the KV agent index. Only agents the - // registry knows about land in the leaderboard; senders without a - // record render with a generated name (handled client-side). - const { agents } = await getCachedAgentList(kv); - const displayByStx = new Map( - agents.map((a) => [ - a.stxAddress, - { - btcAddress: a.btcAddress, - displayName: a.displayName ?? null, - bnsName: a.bnsName ?? null, - erc8004AgentId: a.erc8004AgentId ?? null, - }, - ]) - ); + // Look up display data straight from D1 — only the senders that + // actually appear in `swaps`, scoped to the four fields the row + // needs. Bounded N (agents who've made an MCP swap), comfortably + // under any plausible IN-clause limit. + const stxAddresses = Array.from(bySender.keys()); + const displayByStx = new Map< + string, + { + btcAddress: string; + displayName: string | null; + bnsName: string | null; + erc8004AgentId: number | null; + } + >(); + if (stxAddresses.length > 0) { + const placeholders = stxAddresses.map((_, i) => `?${i + 1}`).join(","); + try { + const displayResult = await db + .prepare( + `SELECT stx_address, btc_address, display_name, bns_name, erc8004_agent_id + FROM agents + WHERE stx_address IN (${placeholders})` + ) + .bind(...stxAddresses) + .all<{ + stx_address: string; + btc_address: string; + display_name: string | null; + bns_name: string | null; + erc8004_agent_id: number | null; + }>(); + for (const r of displayResult.results ?? []) { + displayByStx.set(r.stx_address, { + btcAddress: r.btc_address, + displayName: r.display_name, + bnsName: r.bns_name, + erc8004AgentId: r.erc8004_agent_id, + }); + } + } catch { + // Display lookup failure degrades to client-side generated names. + } + } const ranked: LeaderboardRow[] = Array.from(bySender.entries()) .map(([sender, agg]) => { From 151f472802e281f6b73a8a36e4fb1a10c1fc62e7 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 10:00:52 +0545 Subject: [PATCH 16/41] perf(leaderboard): single LEFT JOIN query + 60s ISR window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions after the KV→D1 flip (ed3eac0) shipped slow renders: 1. Two D1 round-trips per render (swaps aggregate + agents IN-clause) vs the prior single KV cached read. 2. `dynamic = "force-dynamic"` re-ran SSR on every viewer with no framework-level cache. Fixes both in one cut: - Combine the two queries into one `LEFT JOIN swaps ⨝ agents` — display fields ride along the per-(sender, token_in) aggregate row, functionally dependent on `sender` which is in the GROUP BY, so SQLite returns them consistently per sender. Captured into bySender once during the existing loop; final mapping reads from `agg.display` instead of a separate map. - Replace `force-dynamic` with `revalidate = 60`. The verifier cron cadence is 15 min, so a minute of staleness on rendered rows is fine; first viewer's SSR result serves the next 60s of hits without re-running D1. Net: −19 LOC, halved D1 round-trips, near-instant repeat renders. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 119 ++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 69 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 7def08d5..e70837d9 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -4,7 +4,11 @@ import Navbar from "../components/Navbar"; import AnimatedBackground from "../components/AnimatedBackground"; import LeaderboardClient, { type LeaderboardRow } from "./LeaderboardClient"; -export const dynamic = "force-dynamic"; +// 60s ISR window — the verifier cron cadence is 15 min, so a minute of +// staleness on the leaderboard renders is fine. Lets the framework serve +// the first request's SSR result to every other viewer in that window +// without re-running both D1 queries on every hit. +export const revalidate = 60; export const metadata: Metadata = { title: "Trading Leaderboard - AIBTC", @@ -36,12 +40,16 @@ const TOKEN_DECIMALS: Readonly> = { "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx": 6, }; -interface D1AggregateRow { +interface LeaderboardJoinedRow { sender: string; token_in: string; cnt: number; sum_in: number; latest_at: number; + btc_address: string | null; + display_name: string | null; + bns_name: string | null; + erc8004_agent_id: number | null; } async function fetchLeaderboard(): Promise { @@ -50,18 +58,24 @@ async function fetchLeaderboard(): Promise { if (!db) return []; - let rows: D1AggregateRow[] = []; + // Single round-trip: aggregate `swaps` per (sender, token_in) and + // LEFT JOIN the four display fields from `agents`. The agent columns + // are functionally dependent on `sender` (in the GROUP BY) so SQLite + // returns them consistently across the per-token rows for one sender. + let rows: LeaderboardJoinedRow[] = []; try { const sql = ` - SELECT sender, token_in, - COUNT(*) AS cnt, - SUM(amount_in) AS sum_in, - MAX(burn_block_time) AS latest_at - FROM swaps - WHERE source = 'agent' - GROUP BY sender, token_in + SELECT s.sender, s.token_in, + COUNT(*) AS cnt, + SUM(s.amount_in) AS sum_in, + MAX(s.burn_block_time) AS latest_at, + a.btc_address, a.display_name, a.bns_name, a.erc8004_agent_id + FROM swaps s + LEFT JOIN agents a ON a.stx_address = s.sender + WHERE s.source = 'agent' + GROUP BY s.sender, s.token_in `; - const result = await db.prepare(sql).all(); + const result = await db.prepare(sql).all(); rows = result.results ?? []; } catch { return []; @@ -70,13 +84,21 @@ async function fetchLeaderboard(): Promise { if (rows.length === 0) return []; // Aggregate per sender — sum count, keep max(latest_at), preserve - // per-token breakdown for the client-side volume calculation. + // per-token breakdown for the client-side volume calculation, and + // capture display fields once (they're identical across per-token + // rows of the same sender). const bySender = new Map< string, { count: number; latestAt: number; tokens: Array<{ tokenId: string; sumAmountIn: number; decimals: number }>; + display: { + btcAddress: string | null; + displayName: string | null; + bnsName: string | null; + erc8004AgentId: number | null; + }; } >(); for (const r of rows) { @@ -88,6 +110,12 @@ async function fetchLeaderboard(): Promise { sumAmountIn: number; decimals: number; }>, + display: { + btcAddress: r.btc_address, + displayName: r.display_name, + bnsName: r.bns_name, + erc8004AgentId: r.erc8004_agent_id, + }, }; existing.count += r.cnt; if (r.latest_at > existing.latestAt) existing.latestAt = r.latest_at; @@ -99,64 +127,17 @@ async function fetchLeaderboard(): Promise { bySender.set(r.sender, existing); } - // Look up display data straight from D1 — only the senders that - // actually appear in `swaps`, scoped to the four fields the row - // needs. Bounded N (agents who've made an MCP swap), comfortably - // under any plausible IN-clause limit. - const stxAddresses = Array.from(bySender.keys()); - const displayByStx = new Map< - string, - { - btcAddress: string; - displayName: string | null; - bnsName: string | null; - erc8004AgentId: number | null; - } - >(); - if (stxAddresses.length > 0) { - const placeholders = stxAddresses.map((_, i) => `?${i + 1}`).join(","); - try { - const displayResult = await db - .prepare( - `SELECT stx_address, btc_address, display_name, bns_name, erc8004_agent_id - FROM agents - WHERE stx_address IN (${placeholders})` - ) - .bind(...stxAddresses) - .all<{ - stx_address: string; - btc_address: string; - display_name: string | null; - bns_name: string | null; - erc8004_agent_id: number | null; - }>(); - for (const r of displayResult.results ?? []) { - displayByStx.set(r.stx_address, { - btcAddress: r.btc_address, - displayName: r.display_name, - bnsName: r.bns_name, - erc8004AgentId: r.erc8004_agent_id, - }); - } - } catch { - // Display lookup failure degrades to client-side generated names. - } - } - const ranked: LeaderboardRow[] = Array.from(bySender.entries()) - .map(([sender, agg]) => { - const display = displayByStx.get(sender); - return { - stxAddress: sender, - btcAddress: display?.btcAddress ?? null, - displayName: display?.displayName ?? null, - bnsName: display?.bnsName ?? null, - erc8004AgentId: display?.erc8004AgentId ?? null, - tradeCount: agg.count, - latestTradeAt: agg.latestAt, - tokens: agg.tokens, - }; - }) + .map(([sender, agg]) => ({ + stxAddress: sender, + btcAddress: agg.display.btcAddress, + displayName: agg.display.displayName, + bnsName: agg.display.bnsName, + erc8004AgentId: agg.display.erc8004AgentId, + tradeCount: agg.count, + latestTradeAt: agg.latestAt, + tokens: agg.tokens, + })) .sort((a, b) => { // Primary: count desc. Tiebreak: latest trade desc. if (b.tradeCount !== a.tradeCount) return b.tradeCount - a.tradeCount; From ff9c19b6c05a3223564e22d25112163f615ecf9c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 10:05:25 +0545 Subject: [PATCH 17/41] fix(leaderboard): use async-mode getCloudflareContext so revalidate=60 builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit (1e20c2d) swapped `force-dynamic` for `revalidate = 60`, which made /leaderboard eligible for build-time prerendering. Sync-mode `getCloudflareContext()` doesn't work during prerender — Cloudflare's build adapter requires the async form there. One-line fix: `getCloudflareContext({ async: true })`. The function was already awaited, so this is a no-op at runtime — only changes how the context resolves during the build's prerender pass. Verified: `npm run build` completes; /leaderboard ships as a dynamic (server-rendered) route with the 60s ISR window intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index e70837d9..5925a8cb 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -53,7 +53,10 @@ interface LeaderboardJoinedRow { } async function fetchLeaderboard(): Promise { - const { env } = await getCloudflareContext(); + // {async: true} is required when the page isn't `force-dynamic` — + // build-time prerender (now enabled by `revalidate = 60`) calls this + // function and only the async-mode form works there. + const { env } = await getCloudflareContext({ async: true }); const db = env.DB as D1Database | undefined; if (!db) return []; From 9c744a04d8e5fb013401103f3e19619d717470e2 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:11 +0545 Subject: [PATCH 18/41] feat(tenero): typed fetch wrapper modeled on stacks-api-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env.LOGS-aware logger (no console fallback in deployed contexts — falling back bypasses the worker-logs pipeline, which is the exact bug the rev'd revert was meant to fix). Parses minute + month remaining headers, surfaces them to callers for adaptive cadence. Small retry budget on purpose: the SchedulerDO's next alarm tick is the real recovery path, not in-attempt retry. Aggressive 429 retry from a shared CF egress IP only speeds up the lockout. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/external/tenero-fetch.ts | 258 +++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 lib/external/tenero-fetch.ts diff --git a/lib/external/tenero-fetch.ts b/lib/external/tenero-fetch.ts new file mode 100644 index 00000000..90f2da79 --- /dev/null +++ b/lib/external/tenero-fetch.ts @@ -0,0 +1,258 @@ +/** + * Shared Tenero API fetch wrapper with bounded retry + structured logging. + * + * Modeled on `lib/stacks-api-fetch.ts` (Hiro wrapper). Differences vs. Hiro: + * - Smaller default 429 retry budget. Tenero's unauthenticated tier is + * web-ui-ip / 100-per-minute / 50k-per-month, and from a Worker the source + * IP is a shared CF-datacenter egress IP. Aggressive retry on 429 only + * speeds up the lockout. The SchedulerDO's next alarm tick is the recovery + * path, not in-attempt retry. + * - Parses `x-ratelimit-*` rate-limit headers (minute + month remaining) so + * callers can surface them up to the scheduler for adaptive cadence. + * - Optional `TENERO_API_KEY` is threaded through as `x-api-key`. The header + * name is the common Tenero convention; if their auth header turns out to + * be different, change it here only. + * + * Observability: callers thread a {@link Logger} from the worker-logs + * pipeline (via `isLogsRPC(env.LOGS) ? createLogger : createConsoleLogger` + * — see `lib/logging.ts`). The wrapper is silent when no logger is given; + * it never falls back to `console.*`, which would bypass the telemetry sink + * and recreate the observability bug the rev'd revert was trying to fix. + */ + +import type { Logger } from "../logging"; + +const TENERO_API_BASE = "https://api.tenero.io/v1/stacks"; + +/** Build headers for Tenero API requests, optionally including an API key. */ +export function buildTeneroHeaders(apiKey?: string): Record { + const headers: Record = { + Accept: "application/json", + "User-Agent": "aibtc-landing-page/1.0 (+https://aibtc.com)", + }; + if (apiKey) { + headers["x-api-key"] = apiKey; + } + return headers; +} + +/** Parsed Tenero rate-limit headers. */ +export interface TeneroRateLimit { + /** Remaining requests this minute (x-ratelimit-minute-remaining). */ + minuteRemaining: number | null; + /** Remaining requests this month (x-ratelimit-month-remaining). */ + monthRemaining: number | null; + /** Rate-limit tier label (x-ratelimit-type, e.g. "web-ui-ip"). */ + type: string | null; +} + +function parseIntHeader(response: Response, name: string): number | null { + const val = response.headers.get(name); + if (!val) return null; + const n = parseInt(val, 10); + return Number.isFinite(n) ? n : null; +} + +export function extractTeneroRateLimit(response: Response): TeneroRateLimit { + return { + minuteRemaining: parseIntHeader(response, "x-ratelimit-minute-remaining"), + monthRemaining: parseIntHeader(response, "x-ratelimit-month-remaining"), + type: response.headers.get("x-ratelimit-type"), + }; +} + +function extractPath(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +/** Per-attempt fetch timeout. */ +const PER_ATTEMPT_TIMEOUT_MS = 8_000; + +/** Cap Retry-After at 30s — the alarm tick handles longer recovery. */ +const MAX_RETRY_AFTER_MS = 30_000; + +/** Default retry budget: small on purpose — see file header. */ +const DEFAULT_429_RETRIES = 2; +const DEFAULT_5XX_RETRIES = 2; +const DEFAULT_429_BASE_DELAY_MS = 1_500; +const DEFAULT_5XX_BASE_DELAY_MS = 500; + +function isRetryableStatus(status: number): boolean { + return status === 429 || (status >= 500 && status < 600); +} + +function parseRetryAfterMs(response: Response): number | null { + const headerValue = response.headers.get("Retry-After"); + if (!headerValue) return null; + const seconds = parseInt(headerValue, 10); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface TeneroFetchConfig { + /** Max attempts for 5xx errors (default: 2). */ + retries?: number; + /** Base delay for 5xx exponential backoff in ms (default: 500). */ + baseDelayMs?: number; + /** Max attempts for 429 rate-limit errors (default: 2). */ + retries429?: number; + /** + * Optional Logger; when provided, emits `tenero.*` telemetry events. + * Silent when omitted — we do not fall back to console.*, which would + * bypass the worker-logs pipeline. + */ + logger?: Logger; + /** Optional Tenero API key (sent as x-api-key). */ + apiKey?: string; +} + +/** + * Fetch a Tenero API path with bounded retry on 429/5xx. + * + * Pass a relative path like `/tokens/SP...sbtc-token` — the base URL is + * concatenated internally so the wrapper owns the host. Each attempt has an + * independent {@link PER_ATTEMPT_TIMEOUT_MS} timeout. After retries are + * exhausted, the final Response is returned for the caller to inspect. + * + * @returns Final Response (status may be 2xx, 429, 5xx, or other 4xx) + * @throws Only on network-level errors after all retries are exhausted + */ +export async function teneroFetch( + path: string, + config: TeneroFetchConfig = {} +): Promise { + const { + retries = DEFAULT_5XX_RETRIES, + baseDelayMs = DEFAULT_5XX_BASE_DELAY_MS, + retries429 = DEFAULT_429_RETRIES, + logger, + apiKey, + } = config; + + const url = `${TENERO_API_BASE}${path.startsWith("/") ? path : `/${path}`}`; + const logPath = extractPath(url); + const headers = buildTeneroHeaders(apiKey); + + let attempts429 = 0; + let attempts5xx = 0; + const maxTotal = retries429 + retries + 1; + let total = 0; + + while (total < maxTotal) { + total++; + try { + const response = await fetch(url, { + headers, + signal: AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS), + }); + + const rl = extractTeneroRateLimit(response); + if (rl.minuteRemaining !== null && rl.minuteRemaining <= 5) { + logger?.warn("tenero.minute_quota_low", { + path: logPath, + rlMinuteRemaining: rl.minuteRemaining, + rlType: rl.type, + }); + } + if (rl.monthRemaining !== null && rl.monthRemaining <= 5_000) { + logger?.warn("tenero.month_quota_low", { + path: logPath, + rlMonthRemaining: rl.monthRemaining, + rlType: rl.type, + }); + } + + if (!isRetryableStatus(response.status)) { + return response; + } + + const cfRay = response.headers.get("cf-ray"); + const is429 = response.status === 429; + + if (is429) { + attempts429++; + if (attempts429 > retries429) { + logger?.warn("tenero.retry_budget_exhausted", { + path: logPath, + status: 429, + attempts: attempts429, + budget: "429", + rlMinuteRemaining: rl.minuteRemaining, + rlMonthRemaining: rl.monthRemaining, + ...(cfRay ? { cfRay } : {}), + }); + return response; + } + + const retryAfterMs = + parseRetryAfterMs(response) ?? + DEFAULT_429_BASE_DELAY_MS * Math.pow(2, attempts429 - 1); + const delayMs = Math.min(retryAfterMs, MAX_RETRY_AFTER_MS); + logger?.warn("tenero.retrying", { + path: logPath, + status: 429, + attempt: attempts429, + maxAttempts: retries429, + delayMs, + ...(cfRay ? { cfRay } : {}), + }); + await sleep(delayMs); + } else { + attempts5xx++; + if (attempts5xx > retries) { + logger?.warn("tenero.retry_budget_exhausted", { + path: logPath, + status: response.status, + attempts: attempts5xx, + budget: "5xx", + ...(cfRay ? { cfRay } : {}), + }); + return response; + } + + const delayMs = baseDelayMs * Math.pow(2, attempts5xx - 1); + logger?.warn("tenero.retrying", { + path: logPath, + status: response.status, + attempt: attempts5xx, + maxAttempts: retries, + delayMs, + ...(cfRay ? { cfRay } : {}), + }); + await sleep(delayMs); + } + } catch (error) { + attempts5xx++; + if (attempts5xx > retries) { + logger?.warn("tenero.retry_budget_exhausted", { + path: logPath, + attempts: attempts5xx, + budget: "network", + error: String(error), + }); + throw error; + } + + const delayMs = baseDelayMs * Math.pow(2, attempts5xx - 1); + logger?.warn("tenero.retrying", { + path: logPath, + attempt: attempts5xx, + maxAttempts: retries, + delayMs, + budget: "network", + error: String(error), + }); + await sleep(delayMs); + } + } + + throw new Error(`[teneroFetch] Unexpected: retry loop exited without return`); +} From 8017e5911dbf313b88b02a840297ba96cfda2145 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:17 +0545 Subject: [PATCH 19/41] feat(tenero): fetchTokenPriceUsd built on the fetch wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Never throws on non-2xx — the SchedulerDO refresh task wants to keep going on partial failure and have the rate-limit info surfaced for cadence decisions. tokenIdToTeneroAddress() strips the ::asset suffix; native STX passes through as the literal "stx". A 200 with priceUsd: null is "Tenero confirmed no published price" (vs. a fetch failure) — the caller can cache that distinctly. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/external/tenero/prices.ts | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 lib/external/tenero/prices.ts diff --git a/lib/external/tenero/prices.ts b/lib/external/tenero/prices.ts new file mode 100644 index 00000000..01684ec7 --- /dev/null +++ b/lib/external/tenero/prices.ts @@ -0,0 +1,95 @@ +/** + * Token price fetcher built on the Tenero wrapper. + * + * Single responsibility: given a token id (in the form `stx`, + * `SP...contract::asset`, or a bare contract id), call Tenero's + * `/v1/stacks/tokens/{contract_id}` endpoint and return a numeric USD price + * or null if the token isn't priced. + * + * Tenero's contract-id form drops the `::asset` suffix; native STX passes + * through as the literal `"stx"`. The mapping lives here (not in the wrapper) + * because it's price-endpoint-specific — other Tenero endpoints may want a + * different shape. + */ + +import { teneroFetch, extractTeneroRateLimit, type TeneroRateLimit } from "../tenero-fetch"; +import type { Logger } from "../../logging"; + +export interface TeneroPriceResult { + /** Parsed USD price, or null when the token has no published price or the fetch failed. */ + priceUsd: number | null; + /** Final response status (0 when the fetch threw before getting one). */ + status: number; + /** Rate-limit headers from the final response — surfaced for the scheduler's adaptive logic. */ + rateLimit: TeneroRateLimit; +} + +/** Strip the `::asset` suffix; native STX passes through as `"stx"`. */ +export function tokenIdToTeneroAddress(tokenId: string): string { + if (tokenId === "stx") return "stx"; + const idx = tokenId.indexOf("::"); + return idx >= 0 ? tokenId.slice(0, idx) : tokenId; +} + +/** + * Fetch a single token's USD price from Tenero. Never throws on non-2xx — + * callers (the scheduler refresh task) want to keep going on partial failure + * and have the rate-limit info surfaced for cadence decisions. + */ +export async function fetchTokenPriceUsd( + tokenId: string, + logger?: Logger, + apiKey?: string +): Promise { + const addr = tokenIdToTeneroAddress(tokenId); + const path = `/tokens/${encodeURIComponent(addr)}`; + + let response: Response; + try { + response = await teneroFetch(path, { logger, apiKey }); + } catch (error) { + logger?.warn("tenero.price_fetch_network_error", { + tokenId, + teneroAddress: addr, + error: String(error), + }); + return { + priceUsd: null, + status: 0, + rateLimit: { minuteRemaining: null, monthRemaining: null, type: null }, + }; + } + + const rateLimit = extractTeneroRateLimit(response); + + if (!response.ok) { + logger?.warn("tenero.price_fetch_non_2xx", { + tokenId, + teneroAddress: addr, + status: response.status, + rlMinuteRemaining: rateLimit.minuteRemaining, + rlMonthRemaining: rateLimit.monthRemaining, + }); + return { priceUsd: null, status: response.status, rateLimit }; + } + + let priceUsd: number | null = null; + try { + const body = (await response.json()) as { data?: { price_usd?: number | string | null } }; + const raw = body.data?.price_usd; + const parsed = typeof raw === "string" ? parseFloat(raw) : raw; + priceUsd = + typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 + ? parsed + : null; + } catch (error) { + logger?.warn("tenero.price_fetch_parse_error", { + tokenId, + teneroAddress: addr, + error: String(error), + }); + priceUsd = null; + } + + return { priceUsd, status: response.status, rateLimit }; +} From 3728731694a90fb58f07aa2bcef153b4c2e6953f Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:22 +0545 Subject: [PATCH 20/41] feat(tenero): KV cache helpers for tenero:price:{tokenId} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single key per token with price, fetchedAt, and the rate-limit snapshot at write time. SchedulerDO is the only writer; SSR and /api/prices are read-only consumers. Writes use a 24h TTL ceiling as a safety net so a paused scheduler leaves a usable-but-stale value rather than nothing — the reader decides whether stale is acceptable via fetchedAt. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/external/tenero/kv-cache.ts | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 lib/external/tenero/kv-cache.ts diff --git a/lib/external/tenero/kv-cache.ts b/lib/external/tenero/kv-cache.ts new file mode 100644 index 00000000..85725667 --- /dev/null +++ b/lib/external/tenero/kv-cache.ts @@ -0,0 +1,81 @@ +/** + * KV cache layer for Tenero token prices. + * + * Single key per token (`tenero:price:{tokenId}`) with the price and the + * timestamp it was written. The SchedulerDO's Tenero refresh task is the + * only writer; SSR routes and `/api/prices` are read-only consumers. + * + * Writes use a generous TTL ceiling (24h) so a paused scheduler still leaves + * a usable-but-stale value rather than nothing — the reader is responsible + * for deciding whether a stale value is acceptable using `fetchedAt`. + */ + +export const TENERO_PRICE_KV_PREFIX = "tenero:price:"; + +/** TTL ceiling for KV entries. Refresh cadence is 5min so this is just a safety net. */ +export const TENERO_PRICE_KV_TTL_SECONDS = 24 * 60 * 60; + +export interface CachedTokenPrice { + /** USD price; null means Tenero confirmed no published price (vs. fetch failure). */ + priceUsd: number | null; + /** Unix millis when this value was written. */ + fetchedAt: number; + /** Optional: minute-remaining at write time, for adaptive cadence inspection. */ + minuteRemaining: number | null; + /** Optional: month-remaining at write time. */ + monthRemaining: number | null; +} + +function kvKey(tokenId: string): string { + return `${TENERO_PRICE_KV_PREFIX}${tokenId}`; +} + +export async function getCachedTokenPrice( + kv: KVNamespace, + tokenId: string +): Promise { + const raw = await kv.get(kvKey(tokenId), "json"); + if (!raw) return null; + // Light shape check — anything unrecognized is treated as a cache miss + // rather than throwing, since we read this from SSR paths that must + // always render. + const obj = raw as Partial; + if (typeof obj.fetchedAt !== "number") return null; + return { + priceUsd: + typeof obj.priceUsd === "number" && Number.isFinite(obj.priceUsd) + ? obj.priceUsd + : null, + fetchedAt: obj.fetchedAt, + minuteRemaining: + typeof obj.minuteRemaining === "number" ? obj.minuteRemaining : null, + monthRemaining: + typeof obj.monthRemaining === "number" ? obj.monthRemaining : null, + }; +} + +export async function setCachedTokenPrice( + kv: KVNamespace, + tokenId: string, + value: CachedTokenPrice +): Promise { + await kv.put(kvKey(tokenId), JSON.stringify(value), { + expirationTtl: TENERO_PRICE_KV_TTL_SECONDS, + }); +} + +/** Read many token prices in parallel — useful for SSR paths that need a Map. */ +export async function getCachedTokenPrices( + kv: KVNamespace, + tokenIds: readonly string[] +): Promise> { + const out = new Map(); + if (tokenIds.length === 0) return out; + const results = await Promise.all( + tokenIds.map(async (id) => [id, await getCachedTokenPrice(kv, id)] as const) + ); + for (const [id, cached] of results) { + if (cached) out.set(id, cached); + } + return out; +} From b779108009b2d4c6527c3b7f25ba1f500d773876 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:27 +0545 Subject: [PATCH 21/41] chore(tenero): barrel export for lib/external/tenero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single import surface for consumers — keeps the call sites tidy. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/external/tenero/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 lib/external/tenero/index.ts diff --git a/lib/external/tenero/index.ts b/lib/external/tenero/index.ts new file mode 100644 index 00000000..ff722fc5 --- /dev/null +++ b/lib/external/tenero/index.ts @@ -0,0 +1,13 @@ +export { + fetchTokenPriceUsd, + tokenIdToTeneroAddress, + type TeneroPriceResult, +} from "./prices"; +export { + getCachedTokenPrice, + getCachedTokenPrices, + setCachedTokenPrice, + TENERO_PRICE_KV_PREFIX, + TENERO_PRICE_KV_TTL_SECONDS, + type CachedTokenPrice, +} from "./kv-cache"; From 88a3bc5a204001796d2dfc20aa1d678d1d489447 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:35 +0545 Subject: [PATCH 22/41] feat(scheduler): SchedulerDO + alarm for Tenero price refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single Durable Object coordinating periodic background work for landing-page. Initial scope is Tenero only (5-min cadence); the competition Hiro sweep + balance snapshots tasks land as follow-ups on top of this scaffold. Singleton via env.SCHEDULER.idFromName("v1") — versioned so a future v2 can replace v1 without disturbing the live instance. Failure policy: - Per-task try/catch inside alarm(); failures are logged structured and the next scheduled tick recovers (no aborting the whole alarm body on one task's failure). - Adaptive backoff: if Tenero returns minute-remaining <= 0, mark nextRunAfter and skip the task until that timestamp passes. - consecutiveFailures tracked from day one but circuit-breaker behavior deferred — observability comes first per #768. Long-lived cursors stay in D1; the DO holds its own bookkeeping only (lastRunAt, lastResult, consecutiveFailures, pausedUntil, nextRunAfter). Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/scheduler/scheduler-do.ts | 417 ++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 lib/scheduler/scheduler-do.ts diff --git a/lib/scheduler/scheduler-do.ts b/lib/scheduler/scheduler-do.ts new file mode 100644 index 00000000..bfc02819 --- /dev/null +++ b/lib/scheduler/scheduler-do.ts @@ -0,0 +1,417 @@ +/** + * SchedulerDO — single Durable Object that coordinates periodic background + * work for landing-page. See issue #768 for the full design rationale. + * + * Initial scope: Tenero price refresh task (every ~5 min). The competition + * Hiro sweep task lands in a follow-up; only Tenero is wired here so the + * leaderboard PR has a self-contained shippable surface. + * + * Storage layout (this.ctx.storage): + * - `lastTeneroRunAt` — unix millis when the Tenero task last completed + * - `lastTeneroResult` — `{ succeeded, failed, minuteRemaining, monthRemaining }` + * - `consecutiveFailures` — `{ tenero: number }` (sweep added later) + * - `pausedUntil` — unix millis; alarm() is a no-op until this passes + * - `nextRunAfter` — `{ tenero?: number }`; adaptive backoff floor per task + * + * Long-lived cursors (competition sweep) stay in D1 per issue #768 — the DO + * does not hold authoritative data, only its own bookkeeping. + * + * Failure policy: per-task try/catch inside `alarm()`. A task failure is + * logged and the alarm continues — the next scheduled tick is the recovery + * path. Only transport-level failures (storage write fails, env binding + * missing) re-throw to trigger the runtime's auto-retry. + * + * Logging: routed through `env.LOGS` via the standard + * `isLogsRPC(env.LOGS) ? createLogger : createConsoleLogger` switch. + * Console fallback is deliberately preserved here because `wrangler tail` on + * DO instances is the local-dev debug path — but in deployed contexts + * `env.LOGS` is always present, so events land in `logs.aibtc.com`. + */ + +import { DurableObject } from "cloudflare:workers"; +import { + createConsoleLogger, + createLogger, + isLogsRPC, + type Logger, +} from "../logging"; +import { fetchTokenPriceUsd } from "../external/tenero"; +import { setCachedTokenPrice } from "../external/tenero/kv-cache"; + +/** Tenero refresh cadence — see issue #768 "Decision" section. */ +const TENERO_INTERVAL_MS = 5 * 60 * 1000; + +/** Alarm tick cadence. Set to the shortest task cadence; per-task gating happens inside alarm(). */ +const ALARM_TICK_MS = TENERO_INTERVAL_MS; + +/** + * Cap on tokens refreshed per alarm tick. Hard ceiling against runaway D1 + * results blowing up the alarm duration. 30 tokens × ~500ms ≈ 15s budget. + */ +const MAX_TOKENS_PER_TICK = 30; + +/** + * Static base token set — always refreshed regardless of swap activity. + * Mirrors `TOKEN_DECIMALS` in `app/leaderboard/page.tsx`; keep in sync when + * adding new known tokens to the leaderboard. + */ +const STATIC_TOKEN_IDS: readonly string[] = [ + "stx", + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc", + "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx", +]; + +/** Backoff applied when Tenero reports minute-quota exhausted. */ +const TENERO_RATELIMIT_BACKOFF_MS = 5 * 60 * 1000; + +export interface SchedulerStatus { + now: number; + pausedUntil: number | null; + lastTeneroRunAt: number | null; + lastTeneroResult: TeneroRunResult | null; + consecutiveFailures: { tenero: number }; + nextRunAfter: { tenero: number | null }; + nextAlarmAt: number | null; +} + +export interface TeneroRunResult { + startedAt: number; + durationMs: number; + tokensAttempted: number; + succeeded: number; + failed: number; + minuteRemaining: number | null; + monthRemaining: number | null; +} + +type StoredScheduler = { + lastTeneroRunAt?: number; + lastTeneroResult?: TeneroRunResult; + consecutiveFailures?: { tenero: number }; + pausedUntil?: number; + nextRunAfter?: { tenero?: number }; +}; + +export class SchedulerDO extends DurableObject { + constructor(state: DurableObjectState, env: CloudflareEnv) { + super(state, env); + // Ensure an alarm is always armed. Idempotent — getAlarm() returns null + // if none is set, and setAlarm() with a past timestamp fires immediately. + this.ctx.blockConcurrencyWhile(async () => { + const current = await this.ctx.storage.getAlarm(); + if (current === null) { + await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); + } + }); + } + + // ───────────────────── RPC surface ───────────────────── + + /** Snapshot of the DO's bookkeeping. Safe to call from any route. */ + async status(): Promise { + const s = await this.readStored(); + const nextAlarmAt = await this.ctx.storage.getAlarm(); + return { + now: Date.now(), + pausedUntil: s.pausedUntil ?? null, + lastTeneroRunAt: s.lastTeneroRunAt ?? null, + lastTeneroResult: s.lastTeneroResult ?? null, + consecutiveFailures: { tenero: s.consecutiveFailures?.tenero ?? 0 }, + nextRunAfter: { tenero: s.nextRunAfter?.tenero ?? null }, + nextAlarmAt, + }; + } + + /** Fire the named task now. Returns the task result. */ + async refreshNow(task: "tenero" | "all"): Promise<{ tenero?: TeneroRunResult }> { + const logger = this.makeLogger({ trigger: "refreshNow", task }); + const out: { tenero?: TeneroRunResult } = {}; + if (task === "tenero" || task === "all") { + out.tenero = await this.runTenero(logger); + } + return out; + } + + /** Pause all tasks until the given timestamp. Use for ops kill switch. */ + async pauseUntil(timestamp: number): Promise { + await this.ctx.storage.put("pausedUntil", timestamp); + this.makeLogger({ trigger: "pauseUntil" }).warn("scheduler.paused", { + pausedUntil: timestamp, + }); + } + + /** Clear any pause. */ + async resume(): Promise { + await this.ctx.storage.delete("pausedUntil"); + this.makeLogger({ trigger: "resume" }).info("scheduler.resumed", {}); + } + + // ───────────────────── alarm() ───────────────────── + + async alarm(): Promise { + const tickStartedAt = Date.now(); + const logger = this.makeLogger({ trigger: "alarm", tickStartedAt }); + + // Always re-arm the next alarm before returning, even on failure. This + // is the recovery path — if a task throws and we skip re-arming, the DO + // goes silent forever. + try { + const stored = await this.readStored(); + + if (stored.pausedUntil && stored.pausedUntil > tickStartedAt) { + logger.info("scheduler.alarm_skipped_paused", { + pausedUntil: stored.pausedUntil, + }); + return; + } + + const teneroNextRunAfter = stored.nextRunAfter?.tenero ?? 0; + const teneroDue = + teneroNextRunAfter <= tickStartedAt && + (stored.lastTeneroRunAt ?? 0) + TENERO_INTERVAL_MS <= tickStartedAt + 1_000; + + if (teneroDue) { + try { + await this.runTenero(logger); + } catch (error) { + // Tenero task threw something it didn't catch internally — + // log and continue. Future tasks (sweep) will get their own try. + logger.error("scheduler.tenero_unexpected_error", { + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + await this.bumpFailures("tenero"); + } + } else { + logger.debug("scheduler.tenero_not_due", { + lastRunAt: stored.lastTeneroRunAt ?? null, + nextRunAfter: teneroNextRunAfter || null, + }); + } + } finally { + await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); + } + } + + // ───────────────────── Tenero task ───────────────────── + + private async runTenero(parentLogger: Logger): Promise { + const startedAt = Date.now(); + const logger = parentLogger.child + ? parentLogger.child({ task: "tenero" }) + : parentLogger; + + let tokenIds: string[]; + try { + tokenIds = await this.resolveActiveTokenSet(); + } catch (error) { + logger.error("tenero.resolve_token_set_failed", { error: String(error) }); + await this.bumpFailures("tenero"); + const result: TeneroRunResult = { + startedAt, + durationMs: Date.now() - startedAt, + tokensAttempted: 0, + succeeded: 0, + failed: 0, + minuteRemaining: null, + monthRemaining: null, + }; + await this.persistTeneroResult(result, { rateLimited: false }); + return result; + } + + logger.info("tenero.refresh_started", { tokenCount: tokenIds.length }); + + const apiKey = this.lookupTeneroApiKey(); + const kv = this.env.VERIFIED_AGENTS; + + let succeeded = 0; + let failed = 0; + let lastMinuteRemaining: number | null = null; + let lastMonthRemaining: number | null = null; + let rateLimited = false; + + for (const tokenId of tokenIds) { + const r = await fetchTokenPriceUsd(tokenId, logger, apiKey); + lastMinuteRemaining = r.rateLimit.minuteRemaining ?? lastMinuteRemaining; + lastMonthRemaining = r.rateLimit.monthRemaining ?? lastMonthRemaining; + + if (r.status === 0 || r.status >= 500) { + failed++; + } else if (r.status === 429) { + failed++; + rateLimited = true; + } else if (r.status === 200) { + // Even a 200 with null priceUsd is "Tenero confirmed no price" — + // cache that so we don't re-probe every tick. + try { + await setCachedTokenPrice(kv, tokenId, { + priceUsd: r.priceUsd, + fetchedAt: Date.now(), + minuteRemaining: r.rateLimit.minuteRemaining, + monthRemaining: r.rateLimit.monthRemaining, + }); + succeeded++; + } catch (error) { + logger.warn("tenero.kv_write_failed", { + tokenId, + error: String(error), + }); + failed++; + } + } else { + // Non-200, non-429, non-5xx — treat as failure (no price written). + failed++; + } + + // Hard stop if Tenero says we're out of minute quota. + if ( + r.rateLimit.minuteRemaining !== null && + r.rateLimit.minuteRemaining <= 0 + ) { + rateLimited = true; + logger.warn("tenero.minute_quota_exhausted_mid_run", { + rlMinuteRemaining: r.rateLimit.minuteRemaining, + processed: succeeded + failed, + remaining: tokenIds.length - (succeeded + failed), + }); + break; + } + } + + const result: TeneroRunResult = { + startedAt, + durationMs: Date.now() - startedAt, + tokensAttempted: tokenIds.length, + succeeded, + failed, + minuteRemaining: lastMinuteRemaining, + monthRemaining: lastMonthRemaining, + }; + + await this.persistTeneroResult(result, { rateLimited }); + + logger.info("tenero.refresh_completed", { + succeeded, + failed, + durationMs: result.durationMs, + rlMinuteRemaining: lastMinuteRemaining, + rlMonthRemaining: lastMonthRemaining, + rateLimited, + }); + + return result; + } + + /** + * Active token set = static base ∪ distinct `token_in` from D1 `swaps` + * where source='agent'. Bounded by {@link MAX_TOKENS_PER_TICK}. + */ + private async resolveActiveTokenSet(): Promise { + const set = new Set(STATIC_TOKEN_IDS); + const db = this.env.DB; + if (db) { + const rows = await db + .prepare( + `SELECT DISTINCT token_in FROM swaps WHERE source = 'agent' LIMIT ?` + ) + .bind(MAX_TOKENS_PER_TICK) + .all<{ token_in: string }>(); + for (const r of rows.results ?? []) { + if (typeof r.token_in === "string" && r.token_in.length > 0) { + set.add(r.token_in); + } + } + } + return Array.from(set).slice(0, MAX_TOKENS_PER_TICK); + } + + // ───────────────────── storage helpers ───────────────────── + + private async readStored(): Promise { + const entries = (await this.ctx.storage.list({ + prefix: "", + })) as Map; + const out: StoredScheduler = {}; + for (const [k, v] of entries) { + if (k === "lastTeneroRunAt" && typeof v === "number") out.lastTeneroRunAt = v; + else if (k === "lastTeneroResult") out.lastTeneroResult = v as TeneroRunResult; + else if (k === "consecutiveFailures") out.consecutiveFailures = v as { tenero: number }; + else if (k === "pausedUntil" && typeof v === "number") out.pausedUntil = v; + else if (k === "nextRunAfter") out.nextRunAfter = v as { tenero?: number }; + } + return out; + } + + private async persistTeneroResult( + result: TeneroRunResult, + opts: { rateLimited: boolean } + ): Promise { + await this.ctx.storage.put("lastTeneroRunAt", Date.now()); + await this.ctx.storage.put("lastTeneroResult", result); + + if (result.succeeded > 0 && result.failed === 0 && !opts.rateLimited) { + // Clean run — clear failure counter and any pending backoff. + await this.clearFailures("tenero"); + const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( + "nextRunAfter" + )) ?? {}) as { tenero?: number }; + if (nextRunAfter.tenero) { + delete nextRunAfter.tenero; + await this.ctx.storage.put("nextRunAfter", nextRunAfter); + } + } else if (result.failed > 0 || opts.rateLimited) { + await this.bumpFailures("tenero"); + } + + if (opts.rateLimited) { + const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( + "nextRunAfter" + )) ?? {}) as { tenero?: number }; + nextRunAfter.tenero = Date.now() + TENERO_RATELIMIT_BACKOFF_MS; + await this.ctx.storage.put("nextRunAfter", nextRunAfter); + } + } + + private async bumpFailures(task: "tenero"): Promise { + const cur = ((await this.ctx.storage.get<{ tenero: number }>( + "consecutiveFailures" + )) ?? { tenero: 0 }) as { tenero: number }; + cur[task] = (cur[task] ?? 0) + 1; + await this.ctx.storage.put("consecutiveFailures", cur); + } + + private async clearFailures(task: "tenero"): Promise { + const cur = ((await this.ctx.storage.get<{ tenero: number }>( + "consecutiveFailures" + )) ?? { tenero: 0 }) as { tenero: number }; + if (cur[task] === 0) return; + cur[task] = 0; + await this.ctx.storage.put("consecutiveFailures", cur); + } + + // ───────────────────── logging ───────────────────── + + private makeLogger(extra: Record): Logger { + const ctxBase = { + path: "/__do/scheduler", + doName: "scheduler", + ...extra, + }; + // DurableObjectState exposes `waitUntil`, which is all createLogger needs. + return isLogsRPC(this.env.LOGS) + ? createLogger(this.env.LOGS, this.ctx, ctxBase) + : createConsoleLogger(ctxBase); + } + + /** + * Tenero API key lookup — currently unset. The env type is intentionally + * loose so we can wire `env.TENERO_API_KEY` here without touching call + * sites if/when a key is provisioned. + */ + private lookupTeneroApiKey(): string | undefined { + const key = (this.env as unknown as { TENERO_API_KEY?: string }) + .TENERO_API_KEY; + return typeof key === "string" && key.length > 0 ? key : undefined; + } +} From 2d55c7142a24d3c68059490bc0c91d7d6e2ff4e1 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:40 +0545 Subject: [PATCH 23/41] types(env): add SCHEDULER + optional TENERO_API_KEY bindings SCHEDULER is the SchedulerDO namespace. TENERO_API_KEY is plumbed through the wrapper as x-api-key when set; unauthenticated tier (web-ui-ip / 100-per-min) is fine for v1 but the env hook means we can drop a key in via wrangler secret without changing call sites. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- cloudflare-env.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index 29c82b84..c3177fc3 100644 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -18,4 +18,6 @@ interface CloudflareEnv { X402_RELAY_URL?: string; // x402 relay URL for all payment settlement (default: https://x402-relay.aibtc.com) X402_RELAY?: import("./lib/inbox/relay-rpc").RelayRPC; // x402 sponsor relay RPC service binding (undefined in local dev) INBOX_RECONCILIATION_QUEUE?: Queue; + SCHEDULER: DurableObjectNamespace; + TENERO_API_KEY?: string; // Optional Tenero API key (x-api-key header); raises rate limits above the shared web-ui-ip tier } From 37c816a4aa44825dd6abac2434dca10dfc826ca8 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:44 +0545 Subject: [PATCH 24/41] config(wrangler): register SchedulerDO binding + v1 migration Top-level + production + preview env blocks all declare the binding and migration tag (wrangler does NOT merge top-level into named envs, so every env block has to repeat). SQLite-backed DO class via new_sqlite_classes. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- wrangler.jsonc | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 41fd1948..86a8398b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -82,18 +82,27 @@ ] }, - // Durable Object migration history. - // - // v1 was applied to the `landing-page` worker out-of-band during PR - // #743 experimentation (2026-05-12). Main does not reference the - // SchedulerDO class — so v2 deletes it, restoring the worker to a - // clean no-DO state. Both tags must be declared so wrangler can - // diff against CF's current migration history. Once v2 lands, do - // not remove these entries: future deploys still need to see the - // full history to deploy successfully. + // SchedulerDO: single instance coordinating periodic background work. + // Initial scope: Tenero price refresh (every ~5 min) writing to + // VERIFIED_AGENTS KV under `tenero:price:{tokenId}`. Competition Hiro + // sweep + balance snapshots land in follow-up PRs. See issue #768. + // Singleton resolved via env.SCHEDULER.idFromName("v1") — the instance + // name is independent of the migration tag, so it stays "v1" even + // though the migration history is up to v3. + "durable_objects": { + "bindings": [ + { "name": "SCHEDULER", "class_name": "SchedulerDO" } + ] + }, + // Migration history reflects what's been applied to CF, not just what + // this PR introduces. v1 + v2 happened during the original #743 + // experimentation (registered, then deleted via hotfix #772). v3 + // reintroduces the class cleanly. Keep all three declared — wrangler + // needs the full history to deploy without errors. "migrations": [ { "tag": "v1", "new_sqlite_classes": ["SchedulerDO"] }, - { "tag": "v2", "deleted_classes": ["SchedulerDO"] } + { "tag": "v2", "deleted_classes": ["SchedulerDO"] }, + { "tag": "v3", "new_sqlite_classes": ["SchedulerDO"] } ], /** @@ -181,9 +190,15 @@ ] }, + "durable_objects": { + "bindings": [ + { "name": "SCHEDULER", "class_name": "SchedulerDO" } + ] + }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["SchedulerDO"] }, - { "tag": "v2", "deleted_classes": ["SchedulerDO"] } + { "tag": "v2", "deleted_classes": ["SchedulerDO"] }, + { "tag": "v3", "new_sqlite_classes": ["SchedulerDO"] } ] }, @@ -266,9 +281,15 @@ ] }, + "durable_objects": { + "bindings": [ + { "name": "SCHEDULER", "class_name": "SchedulerDO" } + ] + }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["SchedulerDO"] }, - { "tag": "v2", "deleted_classes": ["SchedulerDO"] } + { "tag": "v2", "deleted_classes": ["SchedulerDO"] }, + { "tag": "v3", "new_sqlite_classes": ["SchedulerDO"] } ] } } From c431504e69fea21446aa544db01b011e559d7400 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:49 +0545 Subject: [PATCH 25/41] feat(worker): export SchedulerDO so the runtime finds the class DO bindings resolve their class_name against the named exports on the worker module. Without this re-export the v1 migration in wrangler.jsonc would deploy but no instance can be hydrated. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- worker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worker.ts b/worker.ts index 86c516b5..9c7b6a24 100644 --- a/worker.ts +++ b/worker.ts @@ -6,8 +6,9 @@ import openNextWorker, { import { createConsoleLogger, createLogger, isLogsRPC } from "./lib/logging"; import { getPaymentRepoVersion } from "./lib/inbox/payment-logging"; import { processInboxReconciliationQueue } from "./lib/inbox/reconciliation-queue"; +import { SchedulerDO } from "./lib/scheduler/scheduler-do"; -export { BucketCachePurge, DOQueueHandler, DOShardedTagCache }; +export { BucketCachePurge, DOQueueHandler, DOShardedTagCache, SchedulerDO }; export default { async fetch(request: Request, env: CloudflareEnv, ctx: ExecutionContext) { From 0226d72345143dff8a472c9377683d0e35158051 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:56:56 +0545 Subject: [PATCH 26/41] feat(leaderboard): SSR volumeUsd from KV-cached Tenero prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads tenero:price:{tokenId} from KV for every distinct tokenId the leaderboard surfaces, then computes volumeUsd + allPriced per row server-side. The SSR path never calls Tenero — that's the SchedulerDO's job on its 5-min alarm tick. Cold-start (DO never kicked / paused) renders volume as "—" with allPriced=false for partials; honest under-report beats the silent-null behavior the rev'd revert was trying to fix. Keeps TOKEN_DECIMALS in sync with STATIC_TOKEN_IDS in the DO so adding a new known token only requires touching one list per side. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 54 +++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 5925a8cb..23228454 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -3,6 +3,7 @@ import { getCloudflareContext } from "@opennextjs/cloudflare"; import Navbar from "../components/Navbar"; import AnimatedBackground from "../components/AnimatedBackground"; import LeaderboardClient, { type LeaderboardRow } from "./LeaderboardClient"; +import { getCachedTokenPrices } from "@/lib/external/tenero/kv-cache"; // 60s ISR window — the verifier cron cadence is 15 min, so a minute of // staleness on the leaderboard renders is fine. Lets the framework serve @@ -30,9 +31,12 @@ export const metadata: Metadata = { * first and confirming a 200 with a non-null price_usd — silently * shipping the wrong contract id makes that token render as $0 forever. * + * Keep in sync with `STATIC_TOKEN_IDS` in `lib/scheduler/scheduler-do.ts` + * so the scheduler refreshes every token the leaderboard knows how to value. + * * The unknown-token default is 6 (SIP-10 convention). Volume from - * those legs stays $0 (no client-side price), which is the honest read - * — we'd rather under-report than impute a number. + * those legs stays $0 (no price in KV), which is the honest read — we'd + * rather under-report than impute a number. */ const TOKEN_DECIMALS: Readonly> = { stx: 6, @@ -58,6 +62,7 @@ async function fetchLeaderboard(): Promise { // function and only the async-mode form works there. const { env } = await getCloudflareContext({ async: true }); const db = env.DB as D1Database | undefined; + const kv = env.VERIFIED_AGENTS as KVNamespace | undefined; if (!db) return []; @@ -130,17 +135,42 @@ async function fetchLeaderboard(): Promise { bySender.set(r.sender, existing); } + // Read every distinct tokenId from KV in parallel. SchedulerDO writes + // these on its 5-min alarm tick; SSR is a pure consumer here. Missing + // entries (cold start, scheduler paused) render as "—" downstream. + const distinctTokenIds = new Set(); + for (const agg of bySender.values()) { + for (const t of agg.tokens) distinctTokenIds.add(t.tokenId); + } + const priceMap = kv + ? await getCachedTokenPrices(kv, Array.from(distinctTokenIds)) + : new Map(); + const ranked: LeaderboardRow[] = Array.from(bySender.entries()) - .map(([sender, agg]) => ({ - stxAddress: sender, - btcAddress: agg.display.btcAddress, - displayName: agg.display.displayName, - bnsName: agg.display.bnsName, - erc8004AgentId: agg.display.erc8004AgentId, - tradeCount: agg.count, - latestTradeAt: agg.latestAt, - tokens: agg.tokens, - })) + .map(([sender, agg]) => { + let volumeUsd = 0; + let allPriced = true; + for (const t of agg.tokens) { + const cached = priceMap.get(t.tokenId); + const price = cached?.priceUsd ?? null; + if (price == null) { + allPriced = false; + continue; + } + volumeUsd += (t.sumAmountIn / 10 ** t.decimals) * price; + } + return { + stxAddress: sender, + btcAddress: agg.display.btcAddress, + displayName: agg.display.displayName, + bnsName: agg.display.bnsName, + erc8004AgentId: agg.display.erc8004AgentId, + tradeCount: agg.count, + latestTradeAt: agg.latestAt, + volumeUsd, + allPriced, + }; + }) .sort((a, b) => { // Primary: count desc. Tiebreak: latest trade desc. if (b.tradeCount !== a.tradeCount) return b.tradeCount - a.tradeCount; From 510725597865884ee6204c0402e2e350c1fce828 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 12:57:02 +0545 Subject: [PATCH 27/41] =?UTF-8?q?refactor(leaderboard):=20drop=20browser?= =?UTF-8?q?=20Tenero=20fetch=20=E2=80=94=20presentational=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LeaderboardClient now receives volumeUsd + allPriced as plain props and renders. No useEffect, no localStorage cache, no shared-IP Tenero fetch from the browser, no flicker between "—" and the final number on first paint. Partial totals (allPriced=false) render with a "*" suffix + a title tooltip explaining some tokens aren't priced yet, so the UI is explicit about the under-report rather than hiding it. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/LeaderboardClient.tsx | 312 +++++++------------------- 1 file changed, 86 insertions(+), 226 deletions(-) diff --git a/app/leaderboard/LeaderboardClient.tsx b/app/leaderboard/LeaderboardClient.tsx index ced88660..970dd434 100644 --- a/app/leaderboard/LeaderboardClient.tsx +++ b/app/leaderboard/LeaderboardClient.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { generateName } from "@/lib/name-generator"; import { truncateAddress, formatRelativeTime } from "@/lib/utils"; @@ -14,136 +13,17 @@ export interface LeaderboardRow { tradeCount: number; latestTradeAt: number; /** - * Per-token breakdown of `amount_in` totals across the agent's - * MCP-submitted swaps. Decimals are server-supplied so the client - * doesn't need its own token-decimals table. + * USD volume computed server-side from KV-cached Tenero prices. Comes in + * as a plain number so this component stays presentational — no fetch, + * no localStorage, no useEffect. */ - tokens: Array<{ - tokenId: string; - sumAmountIn: number; - decimals: number; - }>; -} - -const TENERO_BASE = "https://api.tenero.io/v1/stacks"; - -/** localStorage cache key + TTL for token prices. 5 min keeps the UI fresh without hammering Tenero. */ -const PRICE_CACHE_PREFIX = "tenero-price:"; -const PRICE_CACHE_TTL_MS = 5 * 60 * 1000; - -interface CachedPrice { - price: number | null; - fetchedAt: number; -} - -/** Strip the `::asset` suffix for Tenero; native STX passes through as the literal "stx". */ -function toTeneroAddress(tokenId: string): string { - if (tokenId === "stx") return "stx"; - const idx = tokenId.indexOf("::"); - return idx >= 0 ? tokenId.slice(0, idx) : tokenId; -} - -function readCache(tokenId: string): CachedPrice | null { - if (typeof window === "undefined") return null; - try { - const raw = localStorage.getItem(`${PRICE_CACHE_PREFIX}${tokenId}`); - if (!raw) return null; - const parsed = JSON.parse(raw) as CachedPrice; - if (Date.now() - parsed.fetchedAt > PRICE_CACHE_TTL_MS) return null; - return parsed; - } catch { - return null; - } -} - -function writeCache(tokenId: string, price: number | null): void { - if (typeof window === "undefined") return; - try { - localStorage.setItem( - `${PRICE_CACHE_PREFIX}${tokenId}`, - JSON.stringify({ price, fetchedAt: Date.now() }) - ); - } catch { - // localStorage full / disabled — silently fall back to no cache. - } -} - -async function fetchTeneroPrice(tokenId: string, signal: AbortSignal): Promise { - const addr = toTeneroAddress(tokenId); - try { - const r = await fetch(`${TENERO_BASE}/tokens/${encodeURIComponent(addr)}`, { - signal, - }); - if (!r.ok) return null; - const body = (await r.json()) as { data?: { price_usd?: number | string | null } }; - const raw = body.data?.price_usd; - const price = typeof raw === "string" ? parseFloat(raw) : raw; - return typeof price === "number" && Number.isFinite(price) && price > 0 - ? price - : null; - } catch { - return null; - } -} - -/** - * Resolves USD prices for every distinct tokenId in the leaderboard, - * preferring 5-min-cached values in localStorage and falling back to - * Tenero. Returns a Map keyed by tokenId; missing entries land as null. - */ -function useTokenPrices(rows: LeaderboardRow[]): { - prices: Map; - isLoading: boolean; -} { - const tokenIds = useMemo(() => { - const set = new Set(); - for (const r of rows) for (const t of r.tokens) set.add(t.tokenId); - return Array.from(set).sort(); - }, [rows]); - - const [prices, setPrices] = useState>(() => { - // Seed from localStorage so users with a warm cache see numbers on - // first paint instead of "—" then flicker. - const seed = new Map(); - for (const id of tokenIds) { - const cached = readCache(id); - if (cached) seed.set(id, cached.price); - } - return seed; - }); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (tokenIds.length === 0) return; - const missing = tokenIds.filter((id) => !readCache(id)); - if (missing.length === 0) return; - - const controller = new AbortController(); - setIsLoading(true); - - (async () => { - const results = await Promise.all( - missing.map(async (id) => { - const price = await fetchTeneroPrice(id, controller.signal); - writeCache(id, price); - return [id, price] as const; - }) - ); - if (controller.signal.aborted) return; - setPrices((prev) => { - const next = new Map(prev); - for (const [id, p] of results) next.set(id, p); - return next; - }); - setIsLoading(false); - })(); - - return () => { - controller.abort(); - }; - }, [tokenIds]); - - return { prices, isLoading }; + volumeUsd: number; + /** + * False if any token in the agent's volume couldn't be priced from KV + * (cold scheduler, paused, or token has no published price). Lets the UI + * footnote the row instead of misleadingly under-reporting. + */ + allPriced: boolean; } function formatUsd(value: number | null): string { @@ -166,26 +46,24 @@ function rowDisplayName(row: LeaderboardRow): string { ); } -function computeRowVolumeUsd( - row: LeaderboardRow, - prices: Map -): { volumeUsd: number; allPriced: boolean } { - let total = 0; - let allPriced = true; - for (const t of row.tokens) { - const price = prices.get(t.tokenId); - if (price == null) { - allPriced = false; - continue; - } - total += (t.sumAmountIn / 10 ** t.decimals) * price; +function renderVolumeCell(row: LeaderboardRow): React.ReactNode { + if (row.volumeUsd > 0) { + const label = formatUsd(row.volumeUsd); + return row.allPriced ? ( + {label} + ) : ( + + {label}* + + ); } - return { volumeUsd: total, allPriced }; + return ; } export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] }) { - const { prices, isLoading } = useTokenPrices(rows); - if (rows.length === 0) { return (
@@ -213,79 +91,65 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] }) - {rows.map((row, idx) => { - const { volumeUsd, allPriced } = computeRowVolumeUsd(row, prices); - const showPending = !allPriced && isLoading; - return ( - - #{idx + 1} - - {row.btcAddress ? ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {rowDisplayName(row)} { e.currentTarget.style.display = "none"; }} - /> - - - {rowDisplayName(row)} - - - {truncateAddress(row.stxAddress)} - + {rows.map((row, idx) => ( + + #{idx + 1} + + {row.btcAddress ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {rowDisplayName(row)} { e.currentTarget.style.display = "none"; }} + /> + + + {rowDisplayName(row)} + + + {truncateAddress(row.stxAddress)} - - ) : ( -
- - )} - - - {row.tradeCount} - - - {showPending ? ( - - ) : volumeUsd > 0 ? ( - - {formatUsd(volumeUsd)} - ) : ( - - )} - - - {row.latestTradeAt > 0 - ? formatRelativeTime(new Date(row.latestTradeAt * 1000).toISOString()) - : "—"} - - - ); - })} + + ) : ( +
+ + )} + + + {row.tradeCount} + + {renderVolumeCell(row)} + + {row.latestTradeAt > 0 + ? formatRelativeTime(new Date(row.latestTradeAt * 1000).toISOString()) + : "—"} + + + ))}
@@ -293,8 +157,10 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] }) {/* Mobile list */}
    {rows.map((row, idx) => { - const { volumeUsd, allPriced } = computeRowVolumeUsd(row, prices); - const showPending = !allPriced && isLoading; + const volumeLabel = + row.volumeUsd > 0 + ? `${formatUsd(row.volumeUsd)}${row.allPriced ? "" : "*"}` + : "—"; const inner = (
    @@ -324,13 +190,7 @@ export default function LeaderboardClient({ rows }: { rows: LeaderboardRow[] })
    {row.tradeCount} trades · - - {showPending - ? "…" - : volumeUsd > 0 - ? formatUsd(volumeUsd) - : "—"} - + {volumeLabel} · {row.latestTradeAt > 0 From 74c9ea764efedb94943b8308144f9a856a4e1855 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 13:01:49 +0545 Subject: [PATCH 28/41] feat(leaderboard): opportunistic SchedulerDO kick on SSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DO instance doesn't exist until something calls a method on it — the constructor (which arms the first alarm) only runs on first invocation. Without this kick, a fresh deploy leaves the alarm unarmed and KV empty until someone manually pokes the binding. Fire-and-forget via ctx.waitUntil so SSR isn't blocked on the RPC hop. Wrapped in a try/guard so a missing/misbehaving DO binding never blocks the leaderboard render. Idempotent — subsequent renders just touch a live instance. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/leaderboard/page.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 23228454..f4dcee2b 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -60,10 +60,30 @@ async function fetchLeaderboard(): Promise { // {async: true} is required when the page isn't `force-dynamic` — // build-time prerender (now enabled by `revalidate = 60`) calls this // function and only the async-mode form works there. - const { env } = await getCloudflareContext({ async: true }); + const { env, ctx } = await getCloudflareContext({ async: true }); const db = env.DB as D1Database | undefined; const kv = env.VERIFIED_AGENTS as KVNamespace | undefined; + // Opportunistic SchedulerDO kick. A DO instance doesn't exist until + // something calls a method on it — the constructor (which arms the + // first alarm) only runs on first invocation. Fire-and-forget here so + // SSR isn't blocked; `ctx.waitUntil` keeps the RPC alive past response + // teardown. Idempotent — subsequent renders just touch a live instance. + // Wrapped in a guard so a missing/misbehaving DO binding never blocks + // the leaderboard render path. + try { + if (env.SCHEDULER) { + ctx.waitUntil( + env.SCHEDULER.get(env.SCHEDULER.idFromName("v1")) + .status() + .then(() => undefined) + .catch(() => undefined) + ); + } + } catch { + // Binding access threw — render proceeds without the kick. + } + if (!db) return []; // Single round-trip: aggregate `swaps` per (sender, token_in) and From ff1eca267537ecd25ac93d11f288dbcdbeac7b85 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 13:22:05 +0545 Subject: [PATCH 29/41] chore: rebuild to exercise versions upload + get branch preview URL The previous deploy used `wrangler deploy --env preview` to land the v1 SchedulerDO migration; this empty commit re-runs the build with the dashboard reverted to `wrangler versions upload`, which now works because the migration is already applied. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) From f05f13a20eaca02a964743013b6bb676953eba05 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 13:33:49 +0545 Subject: [PATCH 30/41] fix(scheduler): inline SchedulerDO in worker.ts to survive bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds at 07:28Z and 07:34Z deployed successfully per CF but all routes returned HTTP 404 with x-preview-user-error: true — workerd refused to start with "no such actor class; c = SchedulerDO". The build-time warning "no such Durable Object class is exported from the worker" turned out to be load-bearing, not benign. Root cause: importing SchedulerDO from ./lib/scheduler/scheduler-do and re-exporting from worker.ts didn't survive the OpenNext + wrangler esbuild pipeline. The class was stripped from the deployed bundle even though the export statement was present. Fix: inline the class body directly in worker.ts so it lives at the worker entry point. Removes the module boundary the bundler was losing. Same pattern OpenNext's own DOs (DOQueueHandler etc.) use — they live inside .open-next/worker.js, not behind a separate import. Also point cloudflare-env.d.ts SCHEDULER typing at ./worker rather than the deleted ./lib/scheduler/scheduler-do path. Refs #768. Context: OpenNext-Cloudflare issue #502 for the broader custom-DO bundling pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- cloudflare-env.d.ts | 2 +- lib/scheduler/scheduler-do.ts | 417 ---------------------------------- worker.ts | 367 +++++++++++++++++++++++++++++- 3 files changed, 365 insertions(+), 421 deletions(-) delete mode 100644 lib/scheduler/scheduler-do.ts diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index c3177fc3..ad612057 100644 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -18,6 +18,6 @@ interface CloudflareEnv { X402_RELAY_URL?: string; // x402 relay URL for all payment settlement (default: https://x402-relay.aibtc.com) X402_RELAY?: import("./lib/inbox/relay-rpc").RelayRPC; // x402 sponsor relay RPC service binding (undefined in local dev) INBOX_RECONCILIATION_QUEUE?: Queue; - SCHEDULER: DurableObjectNamespace; + SCHEDULER: DurableObjectNamespace; TENERO_API_KEY?: string; // Optional Tenero API key (x-api-key header); raises rate limits above the shared web-ui-ip tier } diff --git a/lib/scheduler/scheduler-do.ts b/lib/scheduler/scheduler-do.ts deleted file mode 100644 index bfc02819..00000000 --- a/lib/scheduler/scheduler-do.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * SchedulerDO — single Durable Object that coordinates periodic background - * work for landing-page. See issue #768 for the full design rationale. - * - * Initial scope: Tenero price refresh task (every ~5 min). The competition - * Hiro sweep task lands in a follow-up; only Tenero is wired here so the - * leaderboard PR has a self-contained shippable surface. - * - * Storage layout (this.ctx.storage): - * - `lastTeneroRunAt` — unix millis when the Tenero task last completed - * - `lastTeneroResult` — `{ succeeded, failed, minuteRemaining, monthRemaining }` - * - `consecutiveFailures` — `{ tenero: number }` (sweep added later) - * - `pausedUntil` — unix millis; alarm() is a no-op until this passes - * - `nextRunAfter` — `{ tenero?: number }`; adaptive backoff floor per task - * - * Long-lived cursors (competition sweep) stay in D1 per issue #768 — the DO - * does not hold authoritative data, only its own bookkeeping. - * - * Failure policy: per-task try/catch inside `alarm()`. A task failure is - * logged and the alarm continues — the next scheduled tick is the recovery - * path. Only transport-level failures (storage write fails, env binding - * missing) re-throw to trigger the runtime's auto-retry. - * - * Logging: routed through `env.LOGS` via the standard - * `isLogsRPC(env.LOGS) ? createLogger : createConsoleLogger` switch. - * Console fallback is deliberately preserved here because `wrangler tail` on - * DO instances is the local-dev debug path — but in deployed contexts - * `env.LOGS` is always present, so events land in `logs.aibtc.com`. - */ - -import { DurableObject } from "cloudflare:workers"; -import { - createConsoleLogger, - createLogger, - isLogsRPC, - type Logger, -} from "../logging"; -import { fetchTokenPriceUsd } from "../external/tenero"; -import { setCachedTokenPrice } from "../external/tenero/kv-cache"; - -/** Tenero refresh cadence — see issue #768 "Decision" section. */ -const TENERO_INTERVAL_MS = 5 * 60 * 1000; - -/** Alarm tick cadence. Set to the shortest task cadence; per-task gating happens inside alarm(). */ -const ALARM_TICK_MS = TENERO_INTERVAL_MS; - -/** - * Cap on tokens refreshed per alarm tick. Hard ceiling against runaway D1 - * results blowing up the alarm duration. 30 tokens × ~500ms ≈ 15s budget. - */ -const MAX_TOKENS_PER_TICK = 30; - -/** - * Static base token set — always refreshed regardless of swap activity. - * Mirrors `TOKEN_DECIMALS` in `app/leaderboard/page.tsx`; keep in sync when - * adding new known tokens to the leaderboard. - */ -const STATIC_TOKEN_IDS: readonly string[] = [ - "stx", - "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc", - "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx", -]; - -/** Backoff applied when Tenero reports minute-quota exhausted. */ -const TENERO_RATELIMIT_BACKOFF_MS = 5 * 60 * 1000; - -export interface SchedulerStatus { - now: number; - pausedUntil: number | null; - lastTeneroRunAt: number | null; - lastTeneroResult: TeneroRunResult | null; - consecutiveFailures: { tenero: number }; - nextRunAfter: { tenero: number | null }; - nextAlarmAt: number | null; -} - -export interface TeneroRunResult { - startedAt: number; - durationMs: number; - tokensAttempted: number; - succeeded: number; - failed: number; - minuteRemaining: number | null; - monthRemaining: number | null; -} - -type StoredScheduler = { - lastTeneroRunAt?: number; - lastTeneroResult?: TeneroRunResult; - consecutiveFailures?: { tenero: number }; - pausedUntil?: number; - nextRunAfter?: { tenero?: number }; -}; - -export class SchedulerDO extends DurableObject { - constructor(state: DurableObjectState, env: CloudflareEnv) { - super(state, env); - // Ensure an alarm is always armed. Idempotent — getAlarm() returns null - // if none is set, and setAlarm() with a past timestamp fires immediately. - this.ctx.blockConcurrencyWhile(async () => { - const current = await this.ctx.storage.getAlarm(); - if (current === null) { - await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); - } - }); - } - - // ───────────────────── RPC surface ───────────────────── - - /** Snapshot of the DO's bookkeeping. Safe to call from any route. */ - async status(): Promise { - const s = await this.readStored(); - const nextAlarmAt = await this.ctx.storage.getAlarm(); - return { - now: Date.now(), - pausedUntil: s.pausedUntil ?? null, - lastTeneroRunAt: s.lastTeneroRunAt ?? null, - lastTeneroResult: s.lastTeneroResult ?? null, - consecutiveFailures: { tenero: s.consecutiveFailures?.tenero ?? 0 }, - nextRunAfter: { tenero: s.nextRunAfter?.tenero ?? null }, - nextAlarmAt, - }; - } - - /** Fire the named task now. Returns the task result. */ - async refreshNow(task: "tenero" | "all"): Promise<{ tenero?: TeneroRunResult }> { - const logger = this.makeLogger({ trigger: "refreshNow", task }); - const out: { tenero?: TeneroRunResult } = {}; - if (task === "tenero" || task === "all") { - out.tenero = await this.runTenero(logger); - } - return out; - } - - /** Pause all tasks until the given timestamp. Use for ops kill switch. */ - async pauseUntil(timestamp: number): Promise { - await this.ctx.storage.put("pausedUntil", timestamp); - this.makeLogger({ trigger: "pauseUntil" }).warn("scheduler.paused", { - pausedUntil: timestamp, - }); - } - - /** Clear any pause. */ - async resume(): Promise { - await this.ctx.storage.delete("pausedUntil"); - this.makeLogger({ trigger: "resume" }).info("scheduler.resumed", {}); - } - - // ───────────────────── alarm() ───────────────────── - - async alarm(): Promise { - const tickStartedAt = Date.now(); - const logger = this.makeLogger({ trigger: "alarm", tickStartedAt }); - - // Always re-arm the next alarm before returning, even on failure. This - // is the recovery path — if a task throws and we skip re-arming, the DO - // goes silent forever. - try { - const stored = await this.readStored(); - - if (stored.pausedUntil && stored.pausedUntil > tickStartedAt) { - logger.info("scheduler.alarm_skipped_paused", { - pausedUntil: stored.pausedUntil, - }); - return; - } - - const teneroNextRunAfter = stored.nextRunAfter?.tenero ?? 0; - const teneroDue = - teneroNextRunAfter <= tickStartedAt && - (stored.lastTeneroRunAt ?? 0) + TENERO_INTERVAL_MS <= tickStartedAt + 1_000; - - if (teneroDue) { - try { - await this.runTenero(logger); - } catch (error) { - // Tenero task threw something it didn't catch internally — - // log and continue. Future tasks (sweep) will get their own try. - logger.error("scheduler.tenero_unexpected_error", { - error: String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - await this.bumpFailures("tenero"); - } - } else { - logger.debug("scheduler.tenero_not_due", { - lastRunAt: stored.lastTeneroRunAt ?? null, - nextRunAfter: teneroNextRunAfter || null, - }); - } - } finally { - await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); - } - } - - // ───────────────────── Tenero task ───────────────────── - - private async runTenero(parentLogger: Logger): Promise { - const startedAt = Date.now(); - const logger = parentLogger.child - ? parentLogger.child({ task: "tenero" }) - : parentLogger; - - let tokenIds: string[]; - try { - tokenIds = await this.resolveActiveTokenSet(); - } catch (error) { - logger.error("tenero.resolve_token_set_failed", { error: String(error) }); - await this.bumpFailures("tenero"); - const result: TeneroRunResult = { - startedAt, - durationMs: Date.now() - startedAt, - tokensAttempted: 0, - succeeded: 0, - failed: 0, - minuteRemaining: null, - monthRemaining: null, - }; - await this.persistTeneroResult(result, { rateLimited: false }); - return result; - } - - logger.info("tenero.refresh_started", { tokenCount: tokenIds.length }); - - const apiKey = this.lookupTeneroApiKey(); - const kv = this.env.VERIFIED_AGENTS; - - let succeeded = 0; - let failed = 0; - let lastMinuteRemaining: number | null = null; - let lastMonthRemaining: number | null = null; - let rateLimited = false; - - for (const tokenId of tokenIds) { - const r = await fetchTokenPriceUsd(tokenId, logger, apiKey); - lastMinuteRemaining = r.rateLimit.minuteRemaining ?? lastMinuteRemaining; - lastMonthRemaining = r.rateLimit.monthRemaining ?? lastMonthRemaining; - - if (r.status === 0 || r.status >= 500) { - failed++; - } else if (r.status === 429) { - failed++; - rateLimited = true; - } else if (r.status === 200) { - // Even a 200 with null priceUsd is "Tenero confirmed no price" — - // cache that so we don't re-probe every tick. - try { - await setCachedTokenPrice(kv, tokenId, { - priceUsd: r.priceUsd, - fetchedAt: Date.now(), - minuteRemaining: r.rateLimit.minuteRemaining, - monthRemaining: r.rateLimit.monthRemaining, - }); - succeeded++; - } catch (error) { - logger.warn("tenero.kv_write_failed", { - tokenId, - error: String(error), - }); - failed++; - } - } else { - // Non-200, non-429, non-5xx — treat as failure (no price written). - failed++; - } - - // Hard stop if Tenero says we're out of minute quota. - if ( - r.rateLimit.minuteRemaining !== null && - r.rateLimit.minuteRemaining <= 0 - ) { - rateLimited = true; - logger.warn("tenero.minute_quota_exhausted_mid_run", { - rlMinuteRemaining: r.rateLimit.minuteRemaining, - processed: succeeded + failed, - remaining: tokenIds.length - (succeeded + failed), - }); - break; - } - } - - const result: TeneroRunResult = { - startedAt, - durationMs: Date.now() - startedAt, - tokensAttempted: tokenIds.length, - succeeded, - failed, - minuteRemaining: lastMinuteRemaining, - monthRemaining: lastMonthRemaining, - }; - - await this.persistTeneroResult(result, { rateLimited }); - - logger.info("tenero.refresh_completed", { - succeeded, - failed, - durationMs: result.durationMs, - rlMinuteRemaining: lastMinuteRemaining, - rlMonthRemaining: lastMonthRemaining, - rateLimited, - }); - - return result; - } - - /** - * Active token set = static base ∪ distinct `token_in` from D1 `swaps` - * where source='agent'. Bounded by {@link MAX_TOKENS_PER_TICK}. - */ - private async resolveActiveTokenSet(): Promise { - const set = new Set(STATIC_TOKEN_IDS); - const db = this.env.DB; - if (db) { - const rows = await db - .prepare( - `SELECT DISTINCT token_in FROM swaps WHERE source = 'agent' LIMIT ?` - ) - .bind(MAX_TOKENS_PER_TICK) - .all<{ token_in: string }>(); - for (const r of rows.results ?? []) { - if (typeof r.token_in === "string" && r.token_in.length > 0) { - set.add(r.token_in); - } - } - } - return Array.from(set).slice(0, MAX_TOKENS_PER_TICK); - } - - // ───────────────────── storage helpers ───────────────────── - - private async readStored(): Promise { - const entries = (await this.ctx.storage.list({ - prefix: "", - })) as Map; - const out: StoredScheduler = {}; - for (const [k, v] of entries) { - if (k === "lastTeneroRunAt" && typeof v === "number") out.lastTeneroRunAt = v; - else if (k === "lastTeneroResult") out.lastTeneroResult = v as TeneroRunResult; - else if (k === "consecutiveFailures") out.consecutiveFailures = v as { tenero: number }; - else if (k === "pausedUntil" && typeof v === "number") out.pausedUntil = v; - else if (k === "nextRunAfter") out.nextRunAfter = v as { tenero?: number }; - } - return out; - } - - private async persistTeneroResult( - result: TeneroRunResult, - opts: { rateLimited: boolean } - ): Promise { - await this.ctx.storage.put("lastTeneroRunAt", Date.now()); - await this.ctx.storage.put("lastTeneroResult", result); - - if (result.succeeded > 0 && result.failed === 0 && !opts.rateLimited) { - // Clean run — clear failure counter and any pending backoff. - await this.clearFailures("tenero"); - const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( - "nextRunAfter" - )) ?? {}) as { tenero?: number }; - if (nextRunAfter.tenero) { - delete nextRunAfter.tenero; - await this.ctx.storage.put("nextRunAfter", nextRunAfter); - } - } else if (result.failed > 0 || opts.rateLimited) { - await this.bumpFailures("tenero"); - } - - if (opts.rateLimited) { - const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( - "nextRunAfter" - )) ?? {}) as { tenero?: number }; - nextRunAfter.tenero = Date.now() + TENERO_RATELIMIT_BACKOFF_MS; - await this.ctx.storage.put("nextRunAfter", nextRunAfter); - } - } - - private async bumpFailures(task: "tenero"): Promise { - const cur = ((await this.ctx.storage.get<{ tenero: number }>( - "consecutiveFailures" - )) ?? { tenero: 0 }) as { tenero: number }; - cur[task] = (cur[task] ?? 0) + 1; - await this.ctx.storage.put("consecutiveFailures", cur); - } - - private async clearFailures(task: "tenero"): Promise { - const cur = ((await this.ctx.storage.get<{ tenero: number }>( - "consecutiveFailures" - )) ?? { tenero: 0 }) as { tenero: number }; - if (cur[task] === 0) return; - cur[task] = 0; - await this.ctx.storage.put("consecutiveFailures", cur); - } - - // ───────────────────── logging ───────────────────── - - private makeLogger(extra: Record): Logger { - const ctxBase = { - path: "/__do/scheduler", - doName: "scheduler", - ...extra, - }; - // DurableObjectState exposes `waitUntil`, which is all createLogger needs. - return isLogsRPC(this.env.LOGS) - ? createLogger(this.env.LOGS, this.ctx, ctxBase) - : createConsoleLogger(ctxBase); - } - - /** - * Tenero API key lookup — currently unset. The env type is intentionally - * loose so we can wire `env.TENERO_API_KEY` here without touching call - * sites if/when a key is provisioned. - */ - private lookupTeneroApiKey(): string | undefined { - const key = (this.env as unknown as { TENERO_API_KEY?: string }) - .TENERO_API_KEY; - return typeof key === "string" && key.length > 0 ? key : undefined; - } -} diff --git a/worker.ts b/worker.ts index 9c7b6a24..b88aef05 100644 --- a/worker.ts +++ b/worker.ts @@ -3,12 +3,373 @@ import openNextWorker, { DOQueueHandler, DOShardedTagCache, } from "./.open-next/worker.js"; -import { createConsoleLogger, createLogger, isLogsRPC } from "./lib/logging"; +import { DurableObject } from "cloudflare:workers"; +import { + createConsoleLogger, + createLogger, + isLogsRPC, + type Logger, +} from "./lib/logging"; import { getPaymentRepoVersion } from "./lib/inbox/payment-logging"; import { processInboxReconciliationQueue } from "./lib/inbox/reconciliation-queue"; -import { SchedulerDO } from "./lib/scheduler/scheduler-do"; +import { fetchTokenPriceUsd } from "./lib/external/tenero"; +import { setCachedTokenPrice } from "./lib/external/tenero/kv-cache"; -export { BucketCachePurge, DOQueueHandler, DOShardedTagCache, SchedulerDO }; +// ─────────────────────────── SchedulerDO ─────────────────────────── +// +// Defined inline at the worker entry (not imported from a separate file) +// so OpenNext + wrangler's esbuild bundle includes the class body. When +// SchedulerDO was imported from `./lib/scheduler/scheduler-do`, the class +// was dropped from the deployed bundle even though it appeared in `export +// { SchedulerDO }` — workerd then refused to start with "no such actor +// class; c = SchedulerDO" and every route returned 404 with +// x-preview-user-error: true. See PR #743 build logs at 07:28Z and 07:34Z, +// and OpenNext issue #502 for the broader context on custom-DO bundling +// failures with this adapter. +// +// Storage layout (this.ctx.storage): +// - lastTeneroRunAt — unix millis when the Tenero task last completed +// - lastTeneroResult — { succeeded, failed, minuteRemaining, monthRemaining } +// - consecutiveFailures — { tenero: number } +// - pausedUntil — unix millis; alarm() is a no-op until this passes +// - nextRunAfter — { tenero?: number }; adaptive backoff per task +// +// Long-lived cursors stay in D1 per issue #768 — the DO holds only its +// own bookkeeping. + +const TENERO_INTERVAL_MS = 5 * 60 * 1000; +const ALARM_TICK_MS = TENERO_INTERVAL_MS; +const MAX_TOKENS_PER_TICK = 30; +const TENERO_RATELIMIT_BACKOFF_MS = 5 * 60 * 1000; + +const STATIC_TOKEN_IDS: readonly string[] = [ + "stx", + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc", + "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx", +]; + +export interface SchedulerStatus { + now: number; + pausedUntil: number | null; + lastTeneroRunAt: number | null; + lastTeneroResult: TeneroRunResult | null; + consecutiveFailures: { tenero: number }; + nextRunAfter: { tenero: number | null }; + nextAlarmAt: number | null; +} + +export interface TeneroRunResult { + startedAt: number; + durationMs: number; + tokensAttempted: number; + succeeded: number; + failed: number; + minuteRemaining: number | null; + monthRemaining: number | null; +} + +type StoredScheduler = { + lastTeneroRunAt?: number; + lastTeneroResult?: TeneroRunResult; + consecutiveFailures?: { tenero: number }; + pausedUntil?: number; + nextRunAfter?: { tenero?: number }; +}; + +export class SchedulerDO extends DurableObject { + constructor(state: DurableObjectState, env: CloudflareEnv) { + super(state, env); + // Ensure an alarm is always armed. Idempotent — getAlarm() returns + // null if none is set. + this.ctx.blockConcurrencyWhile(async () => { + const current = await this.ctx.storage.getAlarm(); + if (current === null) { + await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); + } + }); + } + + async status(): Promise { + const s = await this.readStored(); + const nextAlarmAt = await this.ctx.storage.getAlarm(); + return { + now: Date.now(), + pausedUntil: s.pausedUntil ?? null, + lastTeneroRunAt: s.lastTeneroRunAt ?? null, + lastTeneroResult: s.lastTeneroResult ?? null, + consecutiveFailures: { tenero: s.consecutiveFailures?.tenero ?? 0 }, + nextRunAfter: { tenero: s.nextRunAfter?.tenero ?? null }, + nextAlarmAt, + }; + } + + async refreshNow(task: "tenero" | "all"): Promise<{ tenero?: TeneroRunResult }> { + const logger = this.makeLogger({ trigger: "refreshNow", task }); + const out: { tenero?: TeneroRunResult } = {}; + if (task === "tenero" || task === "all") { + out.tenero = await this.runTenero(logger); + } + return out; + } + + async pauseUntil(timestamp: number): Promise { + await this.ctx.storage.put("pausedUntil", timestamp); + this.makeLogger({ trigger: "pauseUntil" }).warn("scheduler.paused", { + pausedUntil: timestamp, + }); + } + + async resume(): Promise { + await this.ctx.storage.delete("pausedUntil"); + this.makeLogger({ trigger: "resume" }).info("scheduler.resumed", {}); + } + + async alarm(): Promise { + const tickStartedAt = Date.now(); + const logger = this.makeLogger({ trigger: "alarm", tickStartedAt }); + + try { + const stored = await this.readStored(); + + if (stored.pausedUntil && stored.pausedUntil > tickStartedAt) { + logger.info("scheduler.alarm_skipped_paused", { + pausedUntil: stored.pausedUntil, + }); + return; + } + + const teneroNextRunAfter = stored.nextRunAfter?.tenero ?? 0; + const teneroDue = + teneroNextRunAfter <= tickStartedAt && + (stored.lastTeneroRunAt ?? 0) + TENERO_INTERVAL_MS <= + tickStartedAt + 1_000; + + if (teneroDue) { + try { + await this.runTenero(logger); + } catch (error) { + logger.error("scheduler.tenero_unexpected_error", { + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + await this.bumpFailures("tenero"); + } + } else { + logger.debug("scheduler.tenero_not_due", { + lastRunAt: stored.lastTeneroRunAt ?? null, + nextRunAfter: teneroNextRunAfter || null, + }); + } + } finally { + await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); + } + } + + private async runTenero(parentLogger: Logger): Promise { + const startedAt = Date.now(); + const logger = parentLogger.child + ? parentLogger.child({ task: "tenero" }) + : parentLogger; + + let tokenIds: string[]; + try { + tokenIds = await this.resolveActiveTokenSet(); + } catch (error) { + logger.error("tenero.resolve_token_set_failed", { error: String(error) }); + await this.bumpFailures("tenero"); + const result: TeneroRunResult = { + startedAt, + durationMs: Date.now() - startedAt, + tokensAttempted: 0, + succeeded: 0, + failed: 0, + minuteRemaining: null, + monthRemaining: null, + }; + await this.persistTeneroResult(result, { rateLimited: false }); + return result; + } + + logger.info("tenero.refresh_started", { tokenCount: tokenIds.length }); + + const apiKey = this.lookupTeneroApiKey(); + const kv = this.env.VERIFIED_AGENTS; + + let succeeded = 0; + let failed = 0; + let lastMinuteRemaining: number | null = null; + let lastMonthRemaining: number | null = null; + let rateLimited = false; + + for (const tokenId of tokenIds) { + const r = await fetchTokenPriceUsd(tokenId, logger, apiKey); + lastMinuteRemaining = r.rateLimit.minuteRemaining ?? lastMinuteRemaining; + lastMonthRemaining = r.rateLimit.monthRemaining ?? lastMonthRemaining; + + if (r.status === 0 || r.status >= 500) { + failed++; + } else if (r.status === 429) { + failed++; + rateLimited = true; + } else if (r.status === 200) { + try { + await setCachedTokenPrice(kv, tokenId, { + priceUsd: r.priceUsd, + fetchedAt: Date.now(), + minuteRemaining: r.rateLimit.minuteRemaining, + monthRemaining: r.rateLimit.monthRemaining, + }); + succeeded++; + } catch (error) { + logger.warn("tenero.kv_write_failed", { + tokenId, + error: String(error), + }); + failed++; + } + } else { + failed++; + } + + if ( + r.rateLimit.minuteRemaining !== null && + r.rateLimit.minuteRemaining <= 0 + ) { + rateLimited = true; + logger.warn("tenero.minute_quota_exhausted_mid_run", { + rlMinuteRemaining: r.rateLimit.minuteRemaining, + processed: succeeded + failed, + remaining: tokenIds.length - (succeeded + failed), + }); + break; + } + } + + const result: TeneroRunResult = { + startedAt, + durationMs: Date.now() - startedAt, + tokensAttempted: tokenIds.length, + succeeded, + failed, + minuteRemaining: lastMinuteRemaining, + monthRemaining: lastMonthRemaining, + }; + + await this.persistTeneroResult(result, { rateLimited }); + + logger.info("tenero.refresh_completed", { + succeeded, + failed, + durationMs: result.durationMs, + rlMinuteRemaining: lastMinuteRemaining, + rlMonthRemaining: lastMonthRemaining, + rateLimited, + }); + + return result; + } + + private async resolveActiveTokenSet(): Promise { + const set = new Set(STATIC_TOKEN_IDS); + const db = this.env.DB; + if (db) { + const rows = await db + .prepare( + `SELECT DISTINCT token_in FROM swaps WHERE source = 'agent' LIMIT ?` + ) + .bind(MAX_TOKENS_PER_TICK) + .all<{ token_in: string }>(); + for (const r of rows.results ?? []) { + if (typeof r.token_in === "string" && r.token_in.length > 0) { + set.add(r.token_in); + } + } + } + return Array.from(set).slice(0, MAX_TOKENS_PER_TICK); + } + + private async readStored(): Promise { + const entries = (await this.ctx.storage.list({ + prefix: "", + })) as Map; + const out: StoredScheduler = {}; + for (const [k, v] of entries) { + if (k === "lastTeneroRunAt" && typeof v === "number") out.lastTeneroRunAt = v; + else if (k === "lastTeneroResult") out.lastTeneroResult = v as TeneroRunResult; + else if (k === "consecutiveFailures") out.consecutiveFailures = v as { tenero: number }; + else if (k === "pausedUntil" && typeof v === "number") out.pausedUntil = v; + else if (k === "nextRunAfter") out.nextRunAfter = v as { tenero?: number }; + } + return out; + } + + private async persistTeneroResult( + result: TeneroRunResult, + opts: { rateLimited: boolean } + ): Promise { + await this.ctx.storage.put("lastTeneroRunAt", Date.now()); + await this.ctx.storage.put("lastTeneroResult", result); + + if (result.succeeded > 0 && result.failed === 0 && !opts.rateLimited) { + await this.clearFailures("tenero"); + const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( + "nextRunAfter" + )) ?? {}) as { tenero?: number }; + if (nextRunAfter.tenero) { + delete nextRunAfter.tenero; + await this.ctx.storage.put("nextRunAfter", nextRunAfter); + } + } else if (result.failed > 0 || opts.rateLimited) { + await this.bumpFailures("tenero"); + } + + if (opts.rateLimited) { + const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( + "nextRunAfter" + )) ?? {}) as { tenero?: number }; + nextRunAfter.tenero = Date.now() + TENERO_RATELIMIT_BACKOFF_MS; + await this.ctx.storage.put("nextRunAfter", nextRunAfter); + } + } + + private async bumpFailures(task: "tenero"): Promise { + const cur = ((await this.ctx.storage.get<{ tenero: number }>( + "consecutiveFailures" + )) ?? { tenero: 0 }) as { tenero: number }; + cur[task] = (cur[task] ?? 0) + 1; + await this.ctx.storage.put("consecutiveFailures", cur); + } + + private async clearFailures(task: "tenero"): Promise { + const cur = ((await this.ctx.storage.get<{ tenero: number }>( + "consecutiveFailures" + )) ?? { tenero: 0 }) as { tenero: number }; + if (cur[task] === 0) return; + cur[task] = 0; + await this.ctx.storage.put("consecutiveFailures", cur); + } + + private makeLogger(extra: Record): Logger { + const ctxBase = { + path: "/__do/scheduler", + doName: "scheduler", + ...extra, + }; + return isLogsRPC(this.env.LOGS) + ? createLogger(this.env.LOGS, this.ctx, ctxBase) + : createConsoleLogger(ctxBase); + } + + private lookupTeneroApiKey(): string | undefined { + const key = (this.env as unknown as { TENERO_API_KEY?: string }) + .TENERO_API_KEY; + return typeof key === "string" && key.length > 0 ? key : undefined; + } +} + +// ─────────────────────────── Exports ─────────────────────────── + +export { BucketCachePurge, DOQueueHandler, DOShardedTagCache }; export default { async fetch(request: Request, env: CloudflareEnv, ctx: ExecutionContext) { From 46e6badb8167598e66340bcb19fadc6abd2d3fd5 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 13:39:37 +0545 Subject: [PATCH 31/41] fix(scheduler): RPC-compatible typing for SCHEDULER binding Two issues from local `npm run build` after the inline: 1. Stub returned by `env.SCHEDULER.get(id)` only exposes T's RPC methods when T extends `Rpc.DurableObjectBranded` per the @cloudflare/workers-types DurableObjectStub definition (DurableObjectStub = Fetcher which expands T's methods only for branded T). Without the brand intersection, callers can't reach `.status()` at all. 2. Method returns typed as `Promise` resolve to `never` in the RPC Result mapper because `unknown` isn't Serializable. Use `Promise` so the call site can chain `.then()`/`.catch()`. Run locally to verify before pushing this time. Sorry for the churn. Refs #768 Co-Authored-By: Claude Opus 4.7 (1M context) --- cloudflare-env.d.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index ad612057..afc54fd3 100644 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -18,6 +18,31 @@ interface CloudflareEnv { X402_RELAY_URL?: string; // x402 relay URL for all payment settlement (default: https://x402-relay.aibtc.com) X402_RELAY?: import("./lib/inbox/relay-rpc").RelayRPC; // x402 sponsor relay RPC service binding (undefined in local dev) INBOX_RECONCILIATION_QUEUE?: Queue; - SCHEDULER: DurableObjectNamespace; + // Inline stub interface (not `import("./worker").SchedulerDO`) so this + // d.ts doesn't pull worker.ts into Next.js's type-check pass, where + // `./.open-next/worker.js` doesn't resolve until after OpenNext runs. + // + // The `Rpc.DurableObjectBranded` intersection is load-bearing: + // `DurableObjectNamespace` requires T to extend that brand for the + // stub returned from `.get(id)` to surface T's RPC methods (per + // @cloudflare/workers-types — DurableObjectStub = Fetcher which + // only expands T's methods when T is branded). Without it, callers + // see `.fetch()` / `.connect()` only, never `.status()` / etc. + // + // Keep these method signatures in sync with the SchedulerDO class in + // worker.ts. + // Returns are typed as `Promise` not `Promise` because the + // RPC `Result` type only accepts Serializable returns — `unknown` + // falls through to `never` and the call site can't even chain `.then()`. + // Callers that need richer return types should tighten these here when + // they wire up real consumers. + SCHEDULER: DurableObjectNamespace< + Rpc.DurableObjectBranded & { + status(): Promise; + refreshNow(task: "tenero" | "all"): Promise; + pauseUntil(timestamp: number): Promise; + resume(): Promise; + } + >; TENERO_API_KEY?: string; // Optional Tenero API key (x-api-key header); raises rate limits above the shared web-ui-ip tier } From b2dd3e9ff7841f4f15b98391f3ea387788e80029 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:06:08 +0545 Subject: [PATCH 32/41] refactor(tenero): extract STATIC_TOKEN_IDS to shared module Moves the static priced-token list out of worker.ts so the /api/prices route (and future consumers) can import it without pulling the DO class into Next.js's type-check pass. The leaderboard's TOKEN_DECIMALS table is the authority on what's priceable; the doc-comment on this constant calls out the two-step edit rule (add to both lists, plus a Tenero probe) so future maintainers don't get a wrong-decimals surprise. Refs #768, addresses review item 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/external/tenero/index.ts | 1 + lib/external/tenero/tokens.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 lib/external/tenero/tokens.ts diff --git a/lib/external/tenero/index.ts b/lib/external/tenero/index.ts index ff722fc5..0daf6cd3 100644 --- a/lib/external/tenero/index.ts +++ b/lib/external/tenero/index.ts @@ -3,6 +3,7 @@ export { tokenIdToTeneroAddress, type TeneroPriceResult, } from "./prices"; +export { STATIC_TOKEN_IDS } from "./tokens"; export { getCachedTokenPrice, getCachedTokenPrices, diff --git a/lib/external/tenero/tokens.ts b/lib/external/tenero/tokens.ts new file mode 100644 index 00000000..a51246ce --- /dev/null +++ b/lib/external/tenero/tokens.ts @@ -0,0 +1,22 @@ +/** + * Active token set for Tenero price refresh. Locked to this static list + * (not dynamically discovered from `swaps.token_in`) because the + * leaderboard's `TOKEN_DECIMALS` table is the authority on what's + * priceable — discovering a token here that the leaderboard doesn't know + * the decimals for would fall back to `?? 6` and silently render the + * wrong USD figure with `allPriced: true`. + * + * Adding a new priceable token is a deliberate two-step edit: add to + * this list AND to `TOKEN_DECIMALS` in `app/leaderboard/page.tsx`, plus + * a Tenero probe to confirm `/v1/stacks/tokens/{contract_id}` returns + * 200 with a non-null `price_usd`. + * + * Future work (per #768 review): if this grows past ~30 tokens, consider + * splitting Tenero refresh into per-tick chunks so a slow run can't blow + * the alarm budget. + */ +export const STATIC_TOKEN_IDS: readonly string[] = [ + "stx", + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc", + "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx", +]; From 9a99183c5d5bd3ba2f047bea98d1825bad54868c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:06:16 +0545 Subject: [PATCH 33/41] refactor(scheduler): extract runTeneroTask to lib/scheduler/ Lifts the Tenero refresh orchestration (fetch loop, KV writes, rate- limit handling, structured logging) out of SchedulerDO.runTenero into a pure function so it can be unit-tested without a DO harness or miniflare. The DO method now wires dependencies (kv, logger, apiKey, tokenIds) and persists the result + failure counters to DO storage; the actual task behavior lives in this file. Pattern mirrors x402-sponsor-relay's split between DO orchestration and pure task functions, per #768 review. Refs #768, addresses review item 5 (test scaffolding). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/scheduler/tenero-task.ts | 126 +++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 lib/scheduler/tenero-task.ts diff --git a/lib/scheduler/tenero-task.ts b/lib/scheduler/tenero-task.ts new file mode 100644 index 00000000..c2808865 --- /dev/null +++ b/lib/scheduler/tenero-task.ts @@ -0,0 +1,126 @@ +/** + * Tenero refresh task — pure orchestration of fetch + KV writes for the + * scheduler's per-tick price refresh. + * + * Extracted out of `SchedulerDO` (in `worker.ts`) so it can be tested + * without spinning up a Durable Object harness. The DO method becomes + * a thin wrapper that wires the dependencies and persists the result + * to DO storage; the actual fetch/cache/rate-limit logic lives here. + * + * Pattern follows `x402-sponsor-relay`'s split between durable-object + * orchestration and pure task functions. + */ + +import { fetchTokenPriceUsd } from "../external/tenero"; +import { setCachedTokenPrice } from "../external/tenero/kv-cache"; +import type { Logger } from "../logging"; + +export interface TeneroRunResult { + startedAt: number; + durationMs: number; + tokensAttempted: number; + succeeded: number; + failed: number; + minuteRemaining: number | null; + monthRemaining: number | null; +} + +export interface TeneroTaskDeps { + logger: Logger; + kv: KVNamespace; + tokenIds: readonly string[]; + apiKey?: string; + /** Test injection point. Defaults to `Date.now`. */ + now?: () => number; +} + +export interface TeneroTaskOutcome { + result: TeneroRunResult; + /** + * True when Tenero signalled rate-limiting during the run (HTTP 429 OR + * `x-ratelimit-minute-remaining <= 0`). Caller writes this to + * DO-storage `nextRunAfter.tenero` for adaptive backoff. + */ + rateLimited: boolean; +} + +export async function runTeneroTask( + deps: TeneroTaskDeps +): Promise { + const { logger, kv, tokenIds, apiKey } = deps; + const now = deps.now ?? Date.now; + const startedAt = now(); + + logger.info("tenero.refresh_started", { tokenCount: tokenIds.length }); + + let succeeded = 0; + let failed = 0; + let lastMinuteRemaining: number | null = null; + let lastMonthRemaining: number | null = null; + let rateLimited = false; + + for (const tokenId of tokenIds) { + const r = await fetchTokenPriceUsd(tokenId, logger, apiKey); + lastMinuteRemaining = r.rateLimit.minuteRemaining ?? lastMinuteRemaining; + lastMonthRemaining = r.rateLimit.monthRemaining ?? lastMonthRemaining; + + if (r.status === 0 || r.status >= 500) { + failed++; + } else if (r.status === 429) { + failed++; + rateLimited = true; + } else if (r.status === 200) { + try { + await setCachedTokenPrice(kv, tokenId, { + priceUsd: r.priceUsd, + fetchedAt: now(), + minuteRemaining: r.rateLimit.minuteRemaining, + monthRemaining: r.rateLimit.monthRemaining, + }); + succeeded++; + } catch (error) { + logger.warn("tenero.kv_write_failed", { + tokenId, + error: String(error), + }); + failed++; + } + } else { + failed++; + } + + if ( + r.rateLimit.minuteRemaining !== null && + r.rateLimit.minuteRemaining <= 0 + ) { + rateLimited = true; + logger.warn("tenero.minute_quota_exhausted_mid_run", { + rlMinuteRemaining: r.rateLimit.minuteRemaining, + processed: succeeded + failed, + remaining: tokenIds.length - (succeeded + failed), + }); + break; + } + } + + const result: TeneroRunResult = { + startedAt, + durationMs: now() - startedAt, + tokensAttempted: tokenIds.length, + succeeded, + failed, + minuteRemaining: lastMinuteRemaining, + monthRemaining: lastMonthRemaining, + }; + + logger.info("tenero.refresh_completed", { + succeeded, + failed, + durationMs: result.durationMs, + rlMinuteRemaining: lastMinuteRemaining, + rlMonthRemaining: lastMonthRemaining, + rateLimited, + }); + + return { result, rateLimited }; +} From f7232b3ca8cda1e781d1774d149563f997da189c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:06:26 +0545 Subject: [PATCH 34/41] refactor(scheduler): apply #743 review feedback to SchedulerDO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles four changes from the #743 review against the SchedulerDO class in worker.ts: 1. Lock the Tenero active-set to STATIC_TOKEN_IDS (imported from lib/external/tenero/tokens.ts). Drops the D1 'SELECT DISTINCT token_in FROM swaps' discovery branch — discovery there could surface tokens outside leaderboard's TOKEN_DECIMALS table, which would fall back to ?? 6 decimals and silently render a wrong USD figure with allPriced=true. Adding a token now requires a deliberate two-step edit; review item 4. 2. Replace readStored's storage.list({ prefix: \"\" }) with Promise.all targeted get(key) calls. List scans every stored key — fine at today's 5 but the whole point of this DO is to grow more tasks (each with cursors + lastResult). Targeted gets keep read cost bounded by schema, not storage size. Pattern mirrors x402-sponsor-relay/durable-objects/nonce-do.ts; review item 3. 3. Slim SchedulerDO.runTenero to a thin wrapper around runTeneroTask() from lib/scheduler/tenero-task.ts. The DO method wires deps + persists results; the task body is now testable without the DO harness; review item 5 (scaffolding). 4. Add TODO comments near the if(teneroDue) branch (task registry refactor when a 2nd task lands) and near runTenero (per-task tick budget when balance task ships); review's forward-looking notes. Refs #768. Co-Authored-By: Claude Opus 4.7 (1M context) --- worker.ts | 200 +++++++++++++++--------------------------------------- 1 file changed, 56 insertions(+), 144 deletions(-) diff --git a/worker.ts b/worker.ts index b88aef05..bd3c5540 100644 --- a/worker.ts +++ b/worker.ts @@ -12,8 +12,11 @@ import { } from "./lib/logging"; import { getPaymentRepoVersion } from "./lib/inbox/payment-logging"; import { processInboxReconciliationQueue } from "./lib/inbox/reconciliation-queue"; -import { fetchTokenPriceUsd } from "./lib/external/tenero"; -import { setCachedTokenPrice } from "./lib/external/tenero/kv-cache"; +import { STATIC_TOKEN_IDS } from "./lib/external/tenero"; +import { + runTeneroTask, + type TeneroRunResult, +} from "./lib/scheduler/tenero-task"; // ─────────────────────────── SchedulerDO ─────────────────────────── // @@ -39,15 +42,8 @@ import { setCachedTokenPrice } from "./lib/external/tenero/kv-cache"; const TENERO_INTERVAL_MS = 5 * 60 * 1000; const ALARM_TICK_MS = TENERO_INTERVAL_MS; -const MAX_TOKENS_PER_TICK = 30; const TENERO_RATELIMIT_BACKOFF_MS = 5 * 60 * 1000; -const STATIC_TOKEN_IDS: readonly string[] = [ - "stx", - "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc", - "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx", -]; - export interface SchedulerStatus { now: number; pausedUntil: number | null; @@ -58,16 +54,6 @@ export interface SchedulerStatus { nextAlarmAt: number | null; } -export interface TeneroRunResult { - startedAt: number; - durationMs: number; - tokensAttempted: number; - succeeded: number; - failed: number; - minuteRemaining: number | null; - monthRemaining: number | null; -} - type StoredScheduler = { lastTeneroRunAt?: number; lastTeneroResult?: TeneroRunResult; @@ -138,6 +124,12 @@ export class SchedulerDO extends DurableObject { return; } + // TODO(#768 follow-up): once a second task lands (competition Hiro + // sweep, then balance snapshots), this branching shape becomes a + // copy-paste smell. Refactor to a task registry: + // for (const task of TASKS) if (task.isDue(stored, tickStartedAt)) + // await task.run(logger, this.ctx); + // Each task owns its own cadence, persist helper, and failure key. const teneroNextRunAfter = stored.nextRunAfter?.tenero ?? 0; const teneroDue = teneroNextRunAfter <= tickStartedAt && @@ -165,142 +157,62 @@ export class SchedulerDO extends DurableObject { } } + // TODO(#768 follow-up): when the balance task ships, give each task a + // bounded slice of the tick (e.g. AbortSignal.timeout(30_000) per + // task) so a slow Hiro response can't starve Tenero refresh and vice + // versa. Today Tenero is the only task and the static token set + // bounds it implicitly; revisit when there's contention. + // + // The orchestration body is `runTeneroTask` in + // `lib/scheduler/tenero-task.ts` — kept testable without a DO harness. + // This wrapper only wires DO-scoped dependencies and persists the run + // result + failure counters / backoff to DO storage. private async runTenero(parentLogger: Logger): Promise { - const startedAt = Date.now(); const logger = parentLogger.child ? parentLogger.child({ task: "tenero" }) : parentLogger; - let tokenIds: string[]; - try { - tokenIds = await this.resolveActiveTokenSet(); - } catch (error) { - logger.error("tenero.resolve_token_set_failed", { error: String(error) }); - await this.bumpFailures("tenero"); - const result: TeneroRunResult = { - startedAt, - durationMs: Date.now() - startedAt, - tokensAttempted: 0, - succeeded: 0, - failed: 0, - minuteRemaining: null, - monthRemaining: null, - }; - await this.persistTeneroResult(result, { rateLimited: false }); - return result; - } - - logger.info("tenero.refresh_started", { tokenCount: tokenIds.length }); - - const apiKey = this.lookupTeneroApiKey(); - const kv = this.env.VERIFIED_AGENTS; - - let succeeded = 0; - let failed = 0; - let lastMinuteRemaining: number | null = null; - let lastMonthRemaining: number | null = null; - let rateLimited = false; - - for (const tokenId of tokenIds) { - const r = await fetchTokenPriceUsd(tokenId, logger, apiKey); - lastMinuteRemaining = r.rateLimit.minuteRemaining ?? lastMinuteRemaining; - lastMonthRemaining = r.rateLimit.monthRemaining ?? lastMonthRemaining; - - if (r.status === 0 || r.status >= 500) { - failed++; - } else if (r.status === 429) { - failed++; - rateLimited = true; - } else if (r.status === 200) { - try { - await setCachedTokenPrice(kv, tokenId, { - priceUsd: r.priceUsd, - fetchedAt: Date.now(), - minuteRemaining: r.rateLimit.minuteRemaining, - monthRemaining: r.rateLimit.monthRemaining, - }); - succeeded++; - } catch (error) { - logger.warn("tenero.kv_write_failed", { - tokenId, - error: String(error), - }); - failed++; - } - } else { - failed++; - } - - if ( - r.rateLimit.minuteRemaining !== null && - r.rateLimit.minuteRemaining <= 0 - ) { - rateLimited = true; - logger.warn("tenero.minute_quota_exhausted_mid_run", { - rlMinuteRemaining: r.rateLimit.minuteRemaining, - processed: succeeded + failed, - remaining: tokenIds.length - (succeeded + failed), - }); - break; - } - } - - const result: TeneroRunResult = { - startedAt, - durationMs: Date.now() - startedAt, - tokensAttempted: tokenIds.length, - succeeded, - failed, - minuteRemaining: lastMinuteRemaining, - monthRemaining: lastMonthRemaining, - }; - - await this.persistTeneroResult(result, { rateLimited }); - - logger.info("tenero.refresh_completed", { - succeeded, - failed, - durationMs: result.durationMs, - rlMinuteRemaining: lastMinuteRemaining, - rlMonthRemaining: lastMonthRemaining, - rateLimited, + const { result, rateLimited } = await runTeneroTask({ + logger, + kv: this.env.VERIFIED_AGENTS, + tokenIds: STATIC_TOKEN_IDS, + apiKey: this.lookupTeneroApiKey(), }); + await this.persistTeneroResult(result, { rateLimited }); return result; } - private async resolveActiveTokenSet(): Promise { - const set = new Set(STATIC_TOKEN_IDS); - const db = this.env.DB; - if (db) { - const rows = await db - .prepare( - `SELECT DISTINCT token_in FROM swaps WHERE source = 'agent' LIMIT ?` - ) - .bind(MAX_TOKENS_PER_TICK) - .all<{ token_in: string }>(); - for (const r of rows.results ?? []) { - if (typeof r.token_in === "string" && r.token_in.length > 0) { - set.add(r.token_in); - } - } - } - return Array.from(set).slice(0, MAX_TOKENS_PER_TICK); - } - + /** + * Read the DO's bookkeeping in a single parallel batch of targeted gets. + * `storage.list({ prefix: "" })` scans every stored key — fine at today's + * 5 keys, but the *point* of this DO is to grow more tasks (each with + * its own cursors and `lastResult`), so targeted `get` calls keep + * read cost bounded by the schema, not the storage size. + * + * Pattern mirrors `x402-sponsor-relay/src/durable-objects/nonce-do.ts`. + */ private async readStored(): Promise { - const entries = (await this.ctx.storage.list({ - prefix: "", - })) as Map; - const out: StoredScheduler = {}; - for (const [k, v] of entries) { - if (k === "lastTeneroRunAt" && typeof v === "number") out.lastTeneroRunAt = v; - else if (k === "lastTeneroResult") out.lastTeneroResult = v as TeneroRunResult; - else if (k === "consecutiveFailures") out.consecutiveFailures = v as { tenero: number }; - else if (k === "pausedUntil" && typeof v === "number") out.pausedUntil = v; - else if (k === "nextRunAfter") out.nextRunAfter = v as { tenero?: number }; - } - return out; + const [ + lastTeneroRunAt, + lastTeneroResult, + consecutiveFailures, + pausedUntil, + nextRunAfter, + ] = await Promise.all([ + this.ctx.storage.get("lastTeneroRunAt"), + this.ctx.storage.get("lastTeneroResult"), + this.ctx.storage.get<{ tenero: number }>("consecutiveFailures"), + this.ctx.storage.get("pausedUntil"), + this.ctx.storage.get<{ tenero?: number }>("nextRunAfter"), + ]); + return { + ...(typeof lastTeneroRunAt === "number" ? { lastTeneroRunAt } : {}), + ...(lastTeneroResult ? { lastTeneroResult } : {}), + ...(consecutiveFailures ? { consecutiveFailures } : {}), + ...(typeof pausedUntil === "number" ? { pausedUntil } : {}), + ...(nextRunAfter ? { nextRunAfter } : {}), + }; } private async persistTeneroResult( From c33602c5fbb7d40bcc6c0d87e5d92e0cf48be975 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:08:10 +0545 Subject: [PATCH 35/41] feat(api): GET /api/prices route for cached Tenero prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads from KV (written by SchedulerDO) so callers get sub-millisecond responses without sharing the 100/min web-ui-ip Tenero quota. Self-docs on Accept ≠ application/json. Supports ?token= for single-token lookup. Closes the read side of #768 — paired with SchedulerDO's write side. --- app/api/prices/route.ts | 188 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 app/api/prices/route.ts diff --git a/app/api/prices/route.ts b/app/api/prices/route.ts new file mode 100644 index 00000000..b2502f65 --- /dev/null +++ b/app/api/prices/route.ts @@ -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 = + {}; + 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", + }, + } + ); +} From 922bddf0472f8454d683c97f5c28502aa6f5517c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:08:15 +0545 Subject: [PATCH 36/41] test(tenero): unit tests for prices + kv-cache helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tokenIdToTeneroAddress: literal "stx" passthrough, ::asset suffix strip, bare contract id unchanged - getCachedTokenPrice/setCachedTokenPrice: null miss, round-trip with TTL assertion, shape-incompatible (missing fetchedAt) → null, non-finite priceUsd coerced to null without throwing Addresses item 5 in whoabuddy's #743 review. --- .../tenero/__tests__/kv-cache.test.ts | 114 ++++++++++++++++++ lib/external/tenero/__tests__/prices.test.ts | 21 ++++ 2 files changed, 135 insertions(+) create mode 100644 lib/external/tenero/__tests__/kv-cache.test.ts create mode 100644 lib/external/tenero/__tests__/prices.test.ts diff --git a/lib/external/tenero/__tests__/kv-cache.test.ts b/lib/external/tenero/__tests__/kv-cache.test.ts new file mode 100644 index 00000000..4cea4713 --- /dev/null +++ b/lib/external/tenero/__tests__/kv-cache.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; +import { + getCachedTokenPrice, + setCachedTokenPrice, + TENERO_PRICE_KV_PREFIX, + TENERO_PRICE_KV_TTL_SECONDS, +} from "../kv-cache"; + +/** + * Hand-rolled KV double — just enough surface for `get("...", "json")` and + * `put("...", string, options)` to round-trip. Mirrors the inline-double + * pattern used in `lib/__tests__/edge-cache.test.ts` rather than miniflare. + */ +function createFakeKv() { + const store = new Map(); + const puts: Array<{ + key: string; + value: string; + options?: KVNamespacePutOptions; + }> = []; + + const kv = { + get: vi.fn(async (key: string, type?: "json") => { + const raw = store.get(key); + if (raw === undefined) return null; + if (type === "json") { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + return raw; + }), + put: vi.fn( + async (key: string, value: string, options?: KVNamespacePutOptions) => { + store.set(key, value); + puts.push({ key, value, options }); + } + ), + }; + + return { kv, store, puts }; +} + +describe("getCachedTokenPrice", () => { + it("returns null for a tokenId that hasn't been cached", async () => { + const { kv } = createFakeKv(); + const result = await getCachedTokenPrice( + kv as unknown as KVNamespace, + "stx" + ); + expect(result).toBeNull(); + }); + + it("round-trips a written entry through setCachedTokenPrice", async () => { + const { kv, puts } = createFakeKv(); + const tokenId = "stx"; + const now = 1_715_000_000_000; + + await setCachedTokenPrice(kv as unknown as KVNamespace, tokenId, { + priceUsd: 1.85, + fetchedAt: now, + minuteRemaining: 47, + monthRemaining: 12_345, + }); + + expect(puts).toHaveLength(1); + expect(puts[0].key).toBe(`${TENERO_PRICE_KV_PREFIX}${tokenId}`); + expect(puts[0].options?.expirationTtl).toBe(TENERO_PRICE_KV_TTL_SECONDS); + + const read = await getCachedTokenPrice( + kv as unknown as KVNamespace, + tokenId + ); + expect(read).toEqual({ + priceUsd: 1.85, + fetchedAt: now, + minuteRemaining: 47, + monthRemaining: 12_345, + }); + }); + + it("returns null when the cached value is shape-incompatible", async () => { + const { kv, store } = createFakeKv(); + // Simulate a stale or hand-edited entry: missing `fetchedAt` is the + // only required field, so the reader treats it as a miss rather than + // throwing or returning garbage to consumers. + store.set(`${TENERO_PRICE_KV_PREFIX}stx`, JSON.stringify({ priceUsd: 1.85 })); + const result = await getCachedTokenPrice( + kv as unknown as KVNamespace, + "stx" + ); + expect(result).toBeNull(); + }); + + it("treats a non-finite priceUsd as null without throwing", async () => { + const { kv, store } = createFakeKv(); + store.set( + `${TENERO_PRICE_KV_PREFIX}stx`, + JSON.stringify({ priceUsd: "not a number", fetchedAt: 123 }) + ); + const result = await getCachedTokenPrice( + kv as unknown as KVNamespace, + "stx" + ); + expect(result).toEqual({ + priceUsd: null, + fetchedAt: 123, + minuteRemaining: null, + monthRemaining: null, + }); + }); +}); diff --git a/lib/external/tenero/__tests__/prices.test.ts b/lib/external/tenero/__tests__/prices.test.ts new file mode 100644 index 00000000..9d58c950 --- /dev/null +++ b/lib/external/tenero/__tests__/prices.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { tokenIdToTeneroAddress } from "../prices"; + +describe("tokenIdToTeneroAddress", () => { + it("passes the literal 'stx' through unchanged", () => { + expect(tokenIdToTeneroAddress("stx")).toBe("stx"); + }); + + it("strips the ::asset suffix from a fully-qualified asset id", () => { + const sbtc = + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc"; + expect(tokenIdToTeneroAddress(sbtc)).toBe( + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token" + ); + }); + + it("returns a bare contract id unchanged when there's no asset suffix", () => { + const bare = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token"; + expect(tokenIdToTeneroAddress(bare)).toBe(bare); + }); +}); From da3227e0e8bbbd6080c9096faf61ea1456832040 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:08:21 +0545 Subject: [PATCH 37/41] test(scheduler): unit tests for runTeneroTask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the four interesting branches of the orchestration loop: - happy path: 200 → KV write, succeeded++, headers captured - 5xx: failed++, no KV write - 429: rateLimited flag set, failed++ - minuteRemaining ≤ 0 on a 200: rateLimited + early break before remaining tokenIds are touched Uses a Map-backed KV double and a capturing logger so the task runs without a DO harness — the same boundary that motivated extracting runTeneroTask in 9a99183. Addresses item 5 in whoabuddy's #743 review. --- lib/scheduler/__tests__/tenero-task.test.ts | 201 ++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 lib/scheduler/__tests__/tenero-task.test.ts diff --git a/lib/scheduler/__tests__/tenero-task.test.ts b/lib/scheduler/__tests__/tenero-task.test.ts new file mode 100644 index 00000000..ebdf2f03 --- /dev/null +++ b/lib/scheduler/__tests__/tenero-task.test.ts @@ -0,0 +1,201 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, +} from "vitest"; +import { runTeneroTask } from "../tenero-task"; + +/** Minimal logger double — captures events without console noise. */ +function createCapturingLogger() { + const events: Array<{ level: string; msg: string; ctx?: unknown }> = []; + const make = (level: string) => + (msg: string, ctx?: Record) => { + events.push({ level, msg, ctx }); + }; + const logger = { + debug: make("debug"), + info: make("info"), + warn: make("warn"), + error: make("error"), + child: () => logger, + }; + return { logger, events }; +} + +/** KV double — Map-backed; records every put for assertions. */ +function createFakeKv() { + const store = new Map(); + const puts: Array<{ key: string; value: string }> = []; + return { + kv: { + get: vi.fn(async (key: string, type?: "json") => { + const raw = store.get(key); + if (raw === undefined) return null; + return type === "json" ? JSON.parse(raw) : raw; + }), + put: vi.fn(async (key: string, value: string) => { + store.set(key, value); + puts.push({ key, value }); + }), + } as unknown as KVNamespace, + puts, + store, + }; +} + +/** + * Stub a Tenero response. Returns the global-fetch implementation the + * test should install for one specific request. + */ +function teneroResponse( + status: number, + opts: { + priceUsd?: number | string | null; + minuteRemaining?: number; + monthRemaining?: number; + } = {} +): Response { + const headers = new Headers({ "Content-Type": "application/json" }); + if (opts.minuteRemaining !== undefined) { + headers.set("x-ratelimit-minute-remaining", String(opts.minuteRemaining)); + } + if (opts.monthRemaining !== undefined) { + headers.set("x-ratelimit-month-remaining", String(opts.monthRemaining)); + } + const body = + opts.priceUsd === undefined + ? "{}" + : JSON.stringify({ data: { price_usd: opts.priceUsd } }); + return new Response(body, { status, headers }); +} + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("runTeneroTask", () => { + it("happy path: writes a cache entry and bumps `succeeded`", async () => { + const { logger, events } = createCapturingLogger(); + const { kv, puts } = createFakeKv(); + + globalThis.fetch = vi.fn(async () => + teneroResponse(200, { + priceUsd: 1.85, + minuteRemaining: 99, + monthRemaining: 49_000, + }) + ) as unknown as typeof fetch; + + const fixedNow = 1_715_000_000_000; + const { result, rateLimited } = await runTeneroTask({ + logger, + kv, + tokenIds: ["stx"], + now: () => fixedNow, + }); + + expect(rateLimited).toBe(false); + expect(result.succeeded).toBe(1); + expect(result.failed).toBe(0); + expect(result.tokensAttempted).toBe(1); + expect(result.minuteRemaining).toBe(99); + expect(result.monthRemaining).toBe(49_000); + + expect(puts).toHaveLength(1); + expect(puts[0].key).toBe("tenero:price:stx"); + const written = JSON.parse(puts[0].value); + expect(written.priceUsd).toBe(1.85); + expect(written.fetchedAt).toBe(fixedNow); + expect(written.minuteRemaining).toBe(99); + + // Sanity: structured log events landed on the logger. + expect(events.some((e) => e.msg === "tenero.refresh_started")).toBe(true); + expect(events.some((e) => e.msg === "tenero.refresh_completed")).toBe(true); + }); + + it("5xx response: bumps `failed`, no KV write", async () => { + const { logger } = createCapturingLogger(); + const { kv, puts } = createFakeKv(); + + // teneroFetch retries 5xx once before giving up, so respond consistently. + globalThis.fetch = vi.fn(async () => + teneroResponse(503) + ) as unknown as typeof fetch; + + const { result, rateLimited } = await runTeneroTask({ + logger, + kv, + tokenIds: ["stx"], + }); + + expect(rateLimited).toBe(false); + expect(result.succeeded).toBe(0); + expect(result.failed).toBe(1); + expect(puts).toHaveLength(0); + }); + + it("429: flags rateLimited and bumps `failed`", async () => { + const { logger } = createCapturingLogger(); + const { kv, puts } = createFakeKv(); + + globalThis.fetch = vi.fn(async () => + teneroResponse(429) + ) as unknown as typeof fetch; + + const { result, rateLimited } = await runTeneroTask({ + logger, + kv, + tokenIds: ["stx"], + }); + + expect(rateLimited).toBe(true); + expect(result.succeeded).toBe(0); + expect(result.failed).toBe(1); + expect(puts).toHaveLength(0); + }); + + it("minuteRemaining <= 0 on a 200 response: flags rateLimited and breaks early", async () => { + const { logger, events } = createCapturingLogger(); + const { kv, puts } = createFakeKv(); + + // First call returns 200 but with the minute quota exhausted; the + // task should break out of the loop before hitting subsequent tokens. + globalThis.fetch = vi.fn(async () => + teneroResponse(200, { + priceUsd: 1.0, + minuteRemaining: 0, + monthRemaining: 30_000, + }) + ) as unknown as typeof fetch; + + const { result, rateLimited } = await runTeneroTask({ + logger, + kv, + tokenIds: [ + "stx", + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc", + "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx", + ], + }); + + expect(rateLimited).toBe(true); + // First token wrote successfully before the break. + expect(result.succeeded).toBe(1); + expect(puts).toHaveLength(1); + // Loop broke before processing tokens 2 + 3. + expect((globalThis.fetch as ReturnType).mock.calls).toHaveLength(1); + expect( + events.some((e) => e.msg === "tenero.minute_quota_exhausted_mid_run") + ).toBe(true); + }); +}); From daf6d5efeb8c64eb1eb878ce5b1da5e17f70d481 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:17:13 +0545 Subject: [PATCH 38/41] fix(leaderboard): BigInt-safe SUM aggregate parse + correct scheduler ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot review findings on #743: 1. `SUM(s.amount_in)` was typed/used as a plain number, but D1's runtime contract allows string returns for very large integer aggregates and JSON-number precision degrades past 2^53. Route the value through a `safeAggregateNumber` helper that BigInt-parses strings, clamps at `Number.MAX_SAFE_INTEGER`, and returns 0 for malformed input. At today's sBTC / STX scale the SUM stays well inside safe-int range, so this is purely defensive — under-report at the ceiling is the desired failure mode if a higher-decimal token enters scope. 2. The `TOKEN_DECIMALS` "keep in sync with" comment pointed to `lib/scheduler/scheduler-do.ts`, which doesn't exist after 9a99183 + b2dd3e9. `STATIC_TOKEN_IDS` now lives in `lib/external/tenero/tokens.ts` and is consumed by `SchedulerDO` (inline in `worker.ts`). Updated the pointer so the sync instruction stays actionable. --- app/leaderboard/page.tsx | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index f4dcee2b..c6f45912 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -31,8 +31,9 @@ export const metadata: Metadata = { * first and confirming a 200 with a non-null price_usd — silently * shipping the wrong contract id makes that token render as $0 forever. * - * Keep in sync with `STATIC_TOKEN_IDS` in `lib/scheduler/scheduler-do.ts` - * so the scheduler refreshes every token the leaderboard knows how to value. + * Keep in sync with `STATIC_TOKEN_IDS` in `lib/external/tenero/tokens.ts` + * (consumed by `SchedulerDO` in `worker.ts`) so the scheduler refreshes + * every token the leaderboard knows how to value. * * The unknown-token default is 6 (SIP-10 convention). Volume from * those legs stays $0 (no price in KV), which is the honest read — we'd @@ -48,7 +49,10 @@ interface LeaderboardJoinedRow { sender: string; token_in: string; cnt: number; - sum_in: number; + // D1 returns SUM of an INTEGER column as a JS number, but the runtime + // boundary isn't tightly typed — Cloudflare's docs leave room for + // string returns on very large aggregates. Type defensively here. + sum_in: number | string | null; latest_at: number; btc_address: string | null; display_name: string | null; @@ -56,6 +60,34 @@ interface LeaderboardJoinedRow { erc8004_agent_id: number | null; } +/** + * Parse a D1 aggregate into a safe JS number. Handles: + * - native number (the common case) — passes through if finite, else 0 + * - decimal string (defensive — D1 may return very large sums as strings) + * - non-finite / non-parseable / negative — returns 0 + * + * For the token decimals we support today (6 / 8) and the comp's expected + * volume range, the SUM stays well under `Number.MAX_SAFE_INTEGER` (sBTC + * caps at ~21M * 1e8 ≈ 2.1e15; safe-int boundary ≈ 9e15). The BigInt + * round-trip preserves precision exactly inside that range and clamps at + * the safe-int boundary if a future high-decimal token enters scope — + * an under-report at the ceiling is preferable to silent rounding errors. + */ +function safeAggregateNumber(raw: number | string | null | undefined): number { + if (typeof raw === "number") return Number.isFinite(raw) && raw > 0 ? raw : 0; + if (typeof raw !== "string") return 0; + let big: bigint; + try { + big = BigInt(raw); + } catch { + return 0; + } + // Use `BigInt(0)` rather than `0n` — tsconfig target is below ES2020. + if (big <= BigInt(0)) return 0; + const ceiling = BigInt(Number.MAX_SAFE_INTEGER); + return big > ceiling ? Number.MAX_SAFE_INTEGER : Number(big); +} + async function fetchLeaderboard(): Promise { // {async: true} is required when the page isn't `force-dynamic` — // build-time prerender (now enabled by `revalidate = 60`) calls this @@ -149,7 +181,7 @@ async function fetchLeaderboard(): Promise { if (r.latest_at > existing.latestAt) existing.latestAt = r.latest_at; existing.tokens.push({ tokenId: r.token_in, - sumAmountIn: r.sum_in, + sumAmountIn: safeAggregateNumber(r.sum_in), decimals: TOKEN_DECIMALS[r.token_in] ?? 6, }); bySender.set(r.sender, existing); From dd54ec0100fa2194fe0e79493353831d754772e0 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:17:20 +0545 Subject: [PATCH 39/41] fix(balances): BigInt-parse sBTC satoshi string to preserve precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks `/extended/v1/address/{addr}/balances` returns balances as decimal strings. `Number(raw)` silently rounds past `Number.MAX_SAFE_INTEGER` (~9e15), which for an 8-decimal asset means ~90M units before precision degrades — well above realistic sBTC balances today but still the wrong default parsing strategy. New `parseSatsString` BigInt-parses the string, returns 0 for non-numeric / negative input, and clamps at `Number.MAX_SAFE_INTEGER` on overflow. The clamp is purely defensive — under-report at the ceiling beats a silent rounding error. L1 funded/spent come back as JSON numbers (mempool.space), so any precision loss already happened inside `JSON.parse`. Left a comment documenting the asymmetry; fixing it properly would require fetching as text + a json-bigint pass, which is overkill for non-critical profile-page balances. Copilot review on #743. --- lib/balances/btc.ts | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/balances/btc.ts b/lib/balances/btc.ts index cde1cb9f..a98cd446 100644 --- a/lib/balances/btc.ts +++ b/lib/balances/btc.ts @@ -20,15 +20,45 @@ export interface BtcBalance { const SBTC_ASSET_ID = `${SBTC_CONTRACTS.mainnet.address}.${SBTC_CONTRACTS.mainnet.name}::${SBTC_CONTRACTS.mainnet.name}`; +/** + * Parse a satoshi string (Stacks `/balances` returns decimal strings) into + * a JS number safely. BigInt round-trip preserves precision exactly within + * `Number.MAX_SAFE_INTEGER` (≈9.007e15) and clamps above it. + * + * Both sBTC and L1 BTC are bounded by ~21M BTC × 1e8 ≈ 2.1e15 sats — well + * inside safe-int range — so the clamp is purely defensive against + * malformed upstream responses. + */ +function parseSatsString(raw: string): number { + let big: bigint; + try { + big = BigInt(raw); + } catch { + return 0; + } + // Use `BigInt(0)` rather than `0n` — tsconfig target is below ES2020. + if (big <= BigInt(0)) return 0; + const ceiling = BigInt(Number.MAX_SAFE_INTEGER); + return big > ceiling ? Number.MAX_SAFE_INTEGER : Number(big); +} + async function fetchL1Sats(btcAddress: string): Promise { const url = `https://mempool.space/api/address/${btcAddress}`; const res = await fetch(url, { headers: { Accept: "application/json" } }); if (!res.ok) return 0; + // mempool.space returns funded_txo_sum / spent_txo_sum as JSON numbers. + // JSON.parse loses precision past 2^53 silently, so re-narrow with BigInt + // via String(...) — preserves the parsed value when within safe range and + // signals overflow (returns 0 via the catch) for malformed responses. const body = (await res.json()) as { chain_stats?: { funded_txo_sum?: number; spent_txo_sum?: number }; }; - const funded = body.chain_stats?.funded_txo_sum ?? 0; - const spent = body.chain_stats?.spent_txo_sum ?? 0; + const fundedRaw = body.chain_stats?.funded_txo_sum; + const spentRaw = body.chain_stats?.spent_txo_sum; + const funded = + typeof fundedRaw === "number" && Number.isFinite(fundedRaw) ? fundedRaw : 0; + const spent = + typeof spentRaw === "number" && Number.isFinite(spentRaw) ? spentRaw : 0; return Math.max(0, funded - spent); } @@ -42,8 +72,7 @@ async function fetchL2Sats(stxAddress: string, hiroApiKey?: string): Promise; }; const raw = body.fungible_tokens?.[SBTC_ASSET_ID]?.balance ?? "0"; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : 0; + return parseSatsString(raw); } export async function fetchBtcBalance( From 6e5dcfe28cb734f8d5c825f4273c8acab187d7dc Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 21:17:26 +0545 Subject: [PATCH 40/41] docs(inbox): clarify UNIQUE-violation substring match in d1-dual-write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring claimed we "match the full constraint string verbatim" but the implementation uses `String#includes(...)` — a substring containment check on the SQLite error message. Copilot flagged the mismatch on #743. The substring approach is the deliberate trade-off: it survives runtime wrapper-text variations across @cloudflare/workers-types releases while still pinning to the fully-qualified `inbox_messages.payment_txid` identifier. Rewrote the comment to describe what the code actually does and the false-positive risk envelope. --- lib/inbox/d1-dual-write.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/inbox/d1-dual-write.ts b/lib/inbox/d1-dual-write.ts index fc71fb91..1c023d70 100644 --- a/lib/inbox/d1-dual-write.ts +++ b/lib/inbox/d1-dual-write.ts @@ -36,9 +36,18 @@ import type { AgentRecord } from "@/lib/types"; * payment_txid partial index (idx_inbox_payment_txid). * * @cloudflare/workers-types does not surface SQLite constraint codes — only - * the wrapped message string. We match the full constraint string verbatim - * (per Copilot review on #756) to avoid false positives if future schema - * changes introduce other tables/columns whose names contain `payment_txid`. + * the wrapped message string. We substring-match the fully-qualified + * `.` identifier inside the SQLite error message, which + * narrows to the exact constraint without requiring the surrounding + * wrapper text to stay byte-identical across runtime versions. + * + * False-positive risk: another error whose message coincidentally contains + * the literal `UNIQUE constraint failed: inbox_messages.payment_txid` would + * match. In practice SQLite only emits that phrasing for this specific + * constraint, so the risk is bounded by SQLite's own behavior — schema + * changes can't introduce a collision unless they reuse the + * `inbox_messages.payment_txid` column name. The Copilot review on #756 + * raised the concern; the substring approach is the deliberate trade-off. * * Re-check periodically against `@cloudflare/workers-types` releases — when * D1 introduces structured error codes, switch to those. From d72559e8d3b21fa8f3335daec905027d9955cfb0 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 12 May 2026 10:06:01 -0700 Subject: [PATCH 41/41] fix(leaderboard): render dynamically for Cloudflare bindings --- app/leaderboard/page.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index c6f45912..04d0809e 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -5,11 +5,9 @@ import AnimatedBackground from "../components/AnimatedBackground"; import LeaderboardClient, { type LeaderboardRow } from "./LeaderboardClient"; import { getCachedTokenPrices } from "@/lib/external/tenero/kv-cache"; -// 60s ISR window — the verifier cron cadence is 15 min, so a minute of -// staleness on the leaderboard renders is fine. Lets the framework serve -// the first request's SSR result to every other viewer in that window -// without re-running both D1 queries on every hit. -export const revalidate = 60; +// Reads live Cloudflare bindings (D1, KV, SchedulerDO). Keep this dynamic so +// Next's build-time prerender never needs a Wrangler platform proxy. +export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Trading Leaderboard - AIBTC", @@ -89,10 +87,7 @@ function safeAggregateNumber(raw: number | string | null | undefined): number { } async function fetchLeaderboard(): Promise { - // {async: true} is required when the page isn't `force-dynamic` — - // build-time prerender (now enabled by `revalidate = 60`) calls this - // function and only the async-mode form works there. - const { env, ctx } = await getCloudflareContext({ async: true }); + const { env, ctx } = await getCloudflareContext(); const db = env.DB as D1Database | undefined; const kv = env.VERIFIED_AGENTS as KVNamespace | undefined;